feat(import): bookmarklet for store pages
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-30 15:59:05 +02:00
1 parent c6d76b4cb5
commit f01ad03f6e
8 files changed
+411 -22

No files matched your search

+58 -15
View File
@@ -1,6 +1,7 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Search} from 'lucide-react';
import {Copy, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
@@ -10,14 +11,22 @@ import useStartImport from '../../hooks/useStartImport.js';
import {readImportFile} from '../../utils/importFormats.js';
import {parseTextList} from '../../utils/imports.js';
import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
export default function ImportInput({onCreated}) {
const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG';
// React 19 blocks javascript: URLs in href. One function, so React sets it only when the link mounts.
const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(location.origin));
/**
* @param storeRows the games the bookmarklet read on a store page, or null
*/
export default function ImportInput({storeRows, onCreated}) {
const {t} = useTranslation();
const [source, setSource] = useState('TEXT');
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT');
const [text, setText] = useState('');
const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false);
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
const rows = source === 'TEXT' ? parseTextList(text) : (source === 'CSV' ? csv?.rows : storeRows) ?? [];
const tooMany = rows.length > MAX_IMPORT_ROWS;
const readFile = async (file) => setCsv(file ? readImportFile(await file.text()) : null);
@@ -35,12 +44,14 @@ export default function ImportInput({onCreated}) {
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
{['TEXT', 'CSV'].map((value) => (
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))}
</div>
{source === 'TEXT' ? (
{source === 'BOOKMARKLET' ? (
<StoreInput rows={storeRows}/>
) : source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
onChange={(e) => setText(e.target.value)}/>
) : (
@@ -52,15 +63,7 @@ export default function ImportInput({onCreated}) {
{csv?.rows && csv.format !== 'CSV' && (
<p className="text-sm">{t('IMPORT.FORMAT_READ', {format: t(`IMPORT.SOURCE_${csv.format}`)})}</p>
)}
{csv?.rows?.length > 0 && (
<ul className="text-sm text-muted-foreground">
{csv.rows.slice(0, 5).map((row, index) => (
<li key={index} className="truncate">
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
</li>
))}
</ul>
)}
<RowPreview rows={csv?.rows}/>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -74,3 +77,43 @@ export default function ImportInput({onCreated}) {
</Card>
);
}
const RowPreview = ({rows}) => rows?.length > 0 && (
<ul className="text-sm text-muted-foreground">
{rows.slice(0, 5).map((row, index) => (
<li key={index} className="truncate">{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}</li>
))}
</ul>
);
function StoreInput({rows}) {
const {t} = useTranslation();
const copyScript = async () => {
try {
await navigator.clipboard.writeText(consoleScript(location.origin));
toast.success(t('IMPORT.STORE_COPIED'));
} catch {
toast.error(t('IMPORT.STORE_COPY_FAILED'));
}
};
if (rows) {
return (
<div className="space-y-2">
<p className="text-sm">{t('IMPORT.STORE_READ', {count: rows.length})}</p>
<RowPreview rows={rows}/>
</div>
);
}
return (
<div className="space-y-3 text-sm">
<p>{t('IMPORT.STORE_HINT', {sites: STORE_SITES})}</p>
<div className="flex flex-wrap items-center gap-2">
<a ref={setBookmarklet} onClick={(e) => e.preventDefault()} className="rounded-md border border-dashed px-3 py-1.5 font-medium">{t('IMPORT.STORE_LINK')}</a>
<Button size="sm" variant="outline" onClick={copyScript}><Copy/>{t('IMPORT.STORE_COPY')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('IMPORT.STORE_NOTE')}</p>
</div>
);
}
+26 -3
View File
@@ -1,7 +1,8 @@
import {useCallback, useEffect, useState} from 'react';
import {Link, useSearchParams} from 'react-router-dom';
import {Link, useLocation, useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Loader2, Upload} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import ImportHistory from '../components/import/ImportHistory.jsx';
@@ -9,15 +10,20 @@ import ImportInput from '../components/import/ImportInput.jsx';
import ImportView from '../components/import/ImportView.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {listImports, RUNNING} from '../utils/imports.js';
import {decodeStoreList} from '../utils/storeList.js';
/**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; the history below can undo a saved import.
* the matches and saves them. ?id= opens one import; the history below can undo a saved import. #data= is the list
* the bookmarklet read on a store page.
*/
export default function ImportPage() {
const {t} = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [imports, setImports] = useState(null);
const [storeRows, setStoreRows] = useState(null);
const {hash, search} = useLocation();
const navigate = useNavigate();
const id = searchParams.get('id');
const loadImports = useCallback(async () => {
@@ -37,6 +43,21 @@ export default function ImportPage() {
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
const open = imports?.find((entry) => RUNNING.includes(entry.status));
// Out of the URL, so a reload or the back button does not bring the list again. Only after the first answer: on
// a 401 the login page must still get it.
useEffect(() => {
const data = new URLSearchParams(hash.slice(1)).get('data');
if (!data || imports === null) return;
const rows = decodeStoreList(data);
if (!rows?.length) toast.error(t('IMPORT.STORE_LIST_BROKEN'));
else {
setStoreRows(rows);
if (open) toast.info(t('IMPORT.STORE_LIST_WAITS'));
}
navigate({search}, {replace: true});
}, [hash, search, navigate, t, imports, open]);
const show = useCallback((next) => setSearchParams(next ? {id: String(next)} : {}, {replace: !id}),
[setSearchParams, id]);
// Into the URL, so the import stays shown when it leaves the worker (review, done).
@@ -62,7 +83,9 @@ export default function ImportPage() {
) : shownId ? (
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
) : (
<ImportInput onCreated={(created) => {
// A store list comes after the first answer: a new key opens its tab.
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
setStoreRows(null);
show(created);
loadImports();
}}/>
+77 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {initialChoice, stepOf} from '../utils/imports.js';
@@ -11,7 +11,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
@@ -29,6 +29,11 @@ const ready = {
};
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
const ShownUrl = () => {
const {pathname, search, hash} = useLocation();
return <output aria-label="url">{pathname + search + hash}</output>;
};
function renderPage(routes, entry = '/import') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
@@ -44,10 +49,13 @@ function renderPage(routes, entry = '/import') {
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
<ShownUrl/>
</MemoryRouter>,
);
}
const storeData = (games) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(games))));
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
const polls = (path) => fetch.mock.calls.filter(([url, init]) => !init?.method && url.endsWith(path)).length;
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
@@ -154,6 +162,59 @@ describe('ImportPage', () => {
});
});
it('takes the store list from the URL, drops it there and sends it as BOOKMARKLET', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'POST ': json(202, {id: 9}),
'GET /9': json(200, {id: 9, source: 'BOOKMARKLET', status: 'MATCHING', total: 2, matched: 0}),
}, `/import#data=${storeData([['Hades', 'PC'], ['Pokémon Scarlet', 'Switch']])}`);
expect(await screen.findByText('IMPORT.STORE_READ:2')).toBeTruthy();
expect(screen.getByText('Pokémon Scarlet · Switch')).toBeTruthy();
expect(screen.getByLabelText('url').textContent).toBe('/import');
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
await screen.findByText('IMPORT.MATCHING');
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({source: 'BOOKMARKLET', rows: [
{title: 'Hades', platform: 'PC', playtimeMinutes: null, status: null},
{title: 'Pokémon Scarlet', platform: 'Switch', playtimeMinutes: null, status: null},
]});
});
it('tells the user when the store list in the URL is broken', async () => {
renderPage({'GET ': json(200, {imports: []})}, '/import#data=broken');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN'));
expect(screen.getByLabelText('url').textContent).toBe('/import');
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('says that the store list waits while another import runs', async () => {
renderPage({
'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}),
'GET /5': json(200, {...ready, status: 'MATCHING', rows: null}),
}, `/import#data=${storeData([['Hades', 'PC']])}`);
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('IMPORT.STORE_LIST_WAITS'));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
});
it('treats a store list without a usable row as broken', async () => {
renderPage({'GET ': json(200, {imports: []})}, `/import#data=${btoa('[[1, "PC"]]')}`);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN'));
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('offers the bookmarklet as a link, not run on click', async () => {
renderPage({'GET ': json(200, {imports: []})});
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.SOURCE_BOOKMARKLET'}));
const link = screen.getByText('IMPORT.STORE_LINK');
expect(link.getAttribute('href')).toMatch(/^javascript:[^#]*$/);
expect(fireEvent.click(link)).toBe(false);
});
it('polls while matching and then shows the review', async () => {
let calls = 0;
renderPage({
@@ -360,4 +421,18 @@ describe('ImportPage session', () => {
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
expect(toast.error).not.toHaveBeenCalled();
});
it('keeps the store list in the URL for the login when the session has expired', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/imports': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const entry = `/import#data=${storeData([['Hades', 'PC']])}`;
const {router} = renderWithProviders(<ImportPage/>, {path: '/import', entries: [entry], guarded: true,
routes: {'/login': <p>login</p>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: entry});
});
});
+108
View File
@@ -0,0 +1,108 @@
import {importRow} from './imports.js';
import {MAX_IMPORT_ROWS} from './limits.js';
/**
* Runs on a store page, not in this app: as a bookmarklet or pasted in the browser console. It reads the user's
* games with the page's own login and opens our import page with them in the URL fragment, which the browser sends
* to no server. It must use nothing from this module: only its text is taken.
*
* Unverified: each reader follows public scripts of the site's own JSON (see the README), not a real account yet.
*
* @param origin the address of this app
*/
export function readStoreList(origin) {
const MAX_ROWS = 1000;
const json = async (url) => {
const response = await fetch(url, {credentials: 'include'});
if (!response.ok) throw new Error(`${new URL(url, location.href).host} answered ${response.status}`);
return response.json();
};
const readers = {
'library.playstation.com': async () => {
const rows = [];
const hash = '2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2';
for (let start = 0; rows.length < MAX_ROWS; start += 100) {
const variables = {isActive: true, platform: ['ps3', 'ps4', 'ps5'], start, size: 100, subscriptionService: 'NONE'};
const data = await json('https://web.np.playstation.com/api/graphql/v1/op?operationName=getPurchasedGameList'
+ `&variables=${encodeURIComponent(JSON.stringify(variables))}`
+ `&extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: hash}}))}`);
const games = data?.data?.purchasedTitlesRetrieve?.games ?? [];
games.forEach((game) => rows.push([game.name, {PS4: 'PS4', PS5: 'PS5'}[String(game.platform).toUpperCase()] ?? null]));
if (games.length < 100) break;
}
return rows;
},
'www.gog.com': async () => {
const rows = [];
for (let page = 1, pages = 1; page <= pages && rows.length < MAX_ROWS; page++) {
const data = await json(`/account/getFilteredProducts?mediaType=1&hiddenFlag=0&page=${page}`);
pages = data.totalPages ?? 1;
(data.products ?? []).forEach((product) => rows.push([product.title, 'PC']));
}
return rows;
},
'www.epicgames.com': async () => {
const rows = [];
let next = '';
do {
const data = await json(`/account/v2/payment/ajaxGetOrderHistory?locale=en-US&nextPageToken=${encodeURIComponent(next)}`);
(data.orders ?? []).forEach((order) => (order.items ?? []).forEach((item) => rows.push([item.description, 'PC'])));
next = data.nextPageToken ?? '';
} while (next && rows.length < MAX_ROWS);
return rows;
},
'ec.nintendo.com': async () => {
const rows = [];
for (let offset = 0; rows.length < MAX_ROWS; offset += 100) {
const data = await json(`/api/my/transactions?limit=100&offset=${offset}`);
const transactions = data.transactions ?? [];
transactions.forEach((transaction) => rows.push([transaction.title, 'Switch']));
if (transactions.length < 100 || offset + 100 >= (data.total ?? 0)) break;
}
return rows;
},
// No JSON without the Xbox tokens the page keeps to itself: the game links of the open page.
'www.xbox.com': async () => [...document.querySelectorAll('a[href*="/games/"]')]
.map((link) => [link.getAttribute('aria-label') || link.querySelector('img[alt]')?.alt || link.textContent, null]),
};
const read = readers[location.hostname];
if (!read) {
alert(`To Be Played reads the game list on: ${Object.keys(readers).join(', ')}.`);
return;
}
read().then((rows) => {
const seen = new Set();
const games = rows.map(([title, platform]) => [String(title ?? '').trim(), platform])
.filter(([title]) => title && !seen.has(title.toLowerCase()) && seen.add(title.toLowerCase()))
.slice(0, MAX_ROWS);
if (games.length === 0) {
alert('To Be Played found no games on this page. Are you logged in, and did the list load?');
return;
}
let binary = '';
new TextEncoder().encode(JSON.stringify(games)).forEach((byte) => { binary += String.fromCharCode(byte); });
const data = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
location.href = `${origin}/import#data=${data}`;
}).catch((err) => alert(`To Be Played could not read the list: ${err.message}`));
}
export const consoleScript = (origin) => `(${readStoreList})(${JSON.stringify(origin)});`;
// The whole code is encoded: a # in it would end the URL there.
export const bookmarkletHref = (origin) => `javascript:${encodeURIComponent(consoleScript(origin))}`;
/**
* The #data of readStoreList as import rows; null when it is no such list.
*/
export const decodeStoreList = (data) => {
try {
const binary = atob(data.replace(/-/g, '+').replace(/_/g, '/'));
const games = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
if (!Array.isArray(games)) return null;
return games.filter((game) => Array.isArray(game) && typeof game[0] === 'string' && game[0].trim())
.slice(0, MAX_IMPORT_ROWS)
.map(([title, platform]) => importRow({title, platform}));
} catch {
return null;
}
};
+110
View File
@@ -0,0 +1,110 @@
import {describe, expect, it, vi} from 'vitest';
import {bookmarkletHref, consoleScript, decodeStoreList} from './storeList.js';
const ORIGIN = 'https://tobeplayed.example';
// Runs the script text with only the globals it gets here, as a store page would: proves it needs nothing else.
const runOn = async (hostname, answers, {script = consoleScript(ORIGIN), document} = {}) => {
const location = {hostname, href: `https://${hostname}/`};
const alert = vi.fn();
const fetch = vi.fn(async (url) => {
const answer = answers.find(([part]) => url.includes(part));
if (!answer) return {ok: false, status: 404};
return {ok: true, json: async () => answer[1]};
});
new Function('fetch', 'location', 'alert', 'document', script)(fetch, location, alert, document);
await vi.waitFor(() => expect(alert.mock.calls.length + (location.href.startsWith(ORIGIN) ? 1 : 0)).toBe(1));
const data = location.href.startsWith(ORIGIN) ? location.href.split('#data=')[1] : null;
return {alert, fetch, rows: data && decodeStoreList(data)};
};
const game = (title, platform) => ({title, platform, playtimeMinutes: null, status: null});
describe('store list', () => {
it('reads GOG page by page', async () => {
const {rows, fetch} = await runOn('www.gog.com', [
['page=1', {totalPages: 2, products: [{title: 'Hades'}]}],
['page=2', {totalPages: 2, products: [{title: 'Celeste'}]}],
]);
expect(rows).toEqual([game('Hades', 'PC'), game('Celeste', 'PC')]);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('reads PlayStation until a page is not full, with the console per game', async () => {
const full = Array.from({length: 100}, (_, i) => ({name: `Game ${i}`, platform: 'ps5'}));
const {rows} = await runOn('library.playstation.com', [
['%22start%22%3A0%2C', {data: {purchasedTitlesRetrieve: {games: full}}}],
['%22start%22%3A100%2C', {data: {purchasedTitlesRetrieve: {games: [{name: 'Bloodborne', platform: 'PS4'}, {name: 'Old', platform: 'PS3'}]}}}],
]);
expect(rows).toHaveLength(102);
expect(rows.slice(100)).toEqual([game('Bloodborne', 'PS4'), game('Old', null)]);
});
it('reads Epic orders by the next page token', async () => {
const {rows} = await runOn('www.epicgames.com', [
['nextPageToken=abc', {orders: [{items: [{description: 'Celeste'}]}]}],
['nextPageToken=', {orders: [{items: [{description: 'Hades'}, {description: 'Control'}]}], nextPageToken: 'abc'}],
]);
expect(rows).toEqual([game('Hades', 'PC'), game('Control', 'PC'), game('Celeste', 'PC')]);
});
it('reads Nintendo purchases', async () => {
const {rows} = await runOn('ec.nintendo.com', [
['offset=0', {total: 1, transactions: [{title: 'Hollow Knight'}]}],
]);
expect(rows).toEqual([game('Hollow Knight', 'Switch')]);
});
it('reads the game links of the Xbox page', async () => {
const link = (label, alt, text) => ({
getAttribute: () => label, querySelector: () => (alt ? {alt} : null), textContent: text,
});
const document = {querySelectorAll: () => [link('Halo', null, ''), link(null, 'Forza', ''), link(null, null, ' Starfield ')]};
const {rows} = await runOn('www.xbox.com', [], {document});
expect(rows).toEqual([game('Halo', null), game('Forza', null), game('Starfield', null)]);
});
it('drops doubles and empty titles and keeps at most 1000 games', async () => {
const products = [{title: 'Hades'}, {title: ' hades '}, {title: ''}, {title: null},
...Array.from({length: 1200}, (_, i) => ({title: `Game ${i}`}))];
const {rows, alert} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products}]]);
expect(alert).not.toHaveBeenCalled();
expect(rows).toHaveLength(1000);
expect(rows[0]).toEqual(game('Hades', 'PC'));
});
it('keeps 1000 long titles under the URL size of Safari', async () => {
const products = Array.from({length: 1000}, (_, i) => ({title: `Ünïcode game title number ${i} with a subtitle`}));
const {rows} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products}]]);
const data = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(rows.map((row) => [row.title, row.platform])))));
expect(rows[5].title).toBe('Ünïcode game title number 5 with a subtitle');
expect(`${ORIGIN}/import#data=${data}`.length).toBeLessThan(80_000);
});
it('tells the user on an unknown site, an empty list and a failed call', async () => {
expect((await runOn('example.com', [])).alert.mock.calls[0][0]).toContain('www.gog.com');
expect((await runOn('www.gog.com', [['page=1', {products: []}]])).alert.mock.calls[0][0]).toContain('found no games');
expect((await runOn('ec.nintendo.com', [])).alert.mock.calls[0][0]).toContain('ec.nintendo.com answered 404');
});
it('runs the same code from the bookmarklet link, # included', async () => {
const href = bookmarkletHref(ORIGIN);
expect(href).toMatch(/^javascript:[^#]*$/);
const {rows} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products: [{title: 'Hades'}]}]],
{script: decodeURIComponent(href.slice('javascript:'.length))});
expect(rows).toEqual([game('Hades', 'PC')]);
});
});
describe('decodeStoreList', () => {
it('returns null for data that is no list', () => {
expect(decodeStoreList('%%%')).toBeNull();
expect(decodeStoreList(btoa('{"a":1}'))).toBeNull();
});
it('cuts titles and drops unknown values', () => {
const data = btoa(JSON.stringify([['x'.repeat(150), 'y'.repeat(40)], ['Hades', 7], [3, 'PC']]));
expect(decodeStoreList(data)).toEqual([game('x'.repeat(100), null), game('Hades', null)]);
});
});