Public Access
feat(import): bookmarklet for store pages
This commit is contained in:
1 parent
c6d76b4cb5
commit
f01ad03f6e
8 files changed
+411
-22
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}/>
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user