const fs = require('fs'); const path = require('path'); const vm = require('vm'); const ts = require('typescript'); const read = (...p) => fs.readFileSync(path.join(__dirname, '..', ...p), 'utf8'); const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; const js = src => ts.transpileModule(src, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; // Pull one `const name = ... };` block (or the function inside `useCallback(fn, [deps])`) out of a component const load = (src, name, sandbox) => { const hook = src.match(new RegExp(`const ${name} = useCallback\\((async \\([^)]*\\) => \\{[\\s\\S]*?\\n \\}), \\[[^\\]]*\\]\\);`)); const block = hook ? `const ${name} = ${hook[1]};` : (src.match(new RegExp(`const ${name} = [\\s\\S]*?\\n \\};`)) || [])[0]; assert(block, `${name} not found`); return vm.runInNewContext(`${js(block)}\n${name}`, sandbox); }; // POST /media/scan and /discovery/scan answer 409 while a scan runs const client = read('frontend', 'src', 'api', 'client.ts'); const conflictFn = client.match(/export function isConflict[\s\S]*?\n\}/); assert(conflictFn, 'client.ts must tell a 409 apart'); const isConflict = vm.runInNewContext(`${js(conflictFn[0].replace('export ', ''))}\nisConflict`, { axios: { isAxiosError: e => !!(e && e.isAxiosError) } }); const answer = (status, message) => Object.assign(new Error(`Request failed with status code ${status}`), { isAxiosError: true, response: { status, data: { message } } }); const running = answer(409, 'A scan is already running.'); const refused = answer(400, 'The folder does not exist.'); const noIgdb = answer(400, 'IGDB credentials are required for scanning.'); noIgdb.response.data.errorCode = 'IGDB_NOT_CONFIGURED'; assert(isConflict(running) && !isConflict(refused) && !isConflict(new Error('offline')), 'isConflict must match a 409 only'); const getErrorMessage = e => (e && e.response && e.response.data && e.response.data.message) || e.message; const stand = (calls, extra) => ({ isConflict, getErrorMessage, isAxiosError: e => !!(e && e.isAxiosError), t: k => k, console: { error: () => {} }, alert: m => { calls.alerts.push(m); }, setInterval: fn => { calls.polls.push(fn); return calls.polls.length; }, clearInterval: () => { calls.cleared++; }, setTimeout: () => 0, ...extra, }); const newCalls = () => ({ alerts: [], polls: [], cleared: 0, spinner: [], busy: true, reloads: 0, info: [], errors: [], posts: [], navigated: [] }); (async () => { // Library: a platform scan clicked during a running scan says so, and the spinner follows the running scan const library = read('frontend', 'src', 'pages', 'Library.tsx'); const platformScan = async failure => { const calls = newCalls(); const scan = load(library, 'handlePlatformScan', stand(calls, { platformScanning: false, setPlatformScanning: v => { calls.spinner.push(v); }, mediaApi: { startScan: async () => { if (failure) throw failure; }, getScanStatus: async () => ({ data: { isScanning: calls.busy } }), }, scanPollRef: { current: null }, loadPagedGamesRef: { current: () => { calls.reloads++; } }, loadPlatformCountsRef: { current: () => {} }, loadPlatforms: () => {}, })); await scan('snes'); return calls; }; const busy = await platformScan(running); // The server text is English; the UI names the running scan in its own language assert(busy.alerts.length === 1 && busy.alerts[0] === 'scanAlreadyRunning', `the running scan must be named with t('scanAlreadyRunning'), got ${JSON.stringify(busy.alerts)}`); assert(!busy.spinner.includes(false) && busy.polls.length === 1, 'the spinner must keep turning while the running scan goes on'); await busy.polls[0](); assert(!busy.spinner.includes(false) && busy.reloads === 0, 'the spinner must stay while the scan still runs'); busy.busy = false; await busy.polls[0](); assert(busy.spinner[busy.spinner.length - 1] === false && busy.reloads === 1, 'when the running scan ends the spinner stops and the library reloads'); const failed = await platformScan(refused); assert(failed.alerts[0] === 'The folder does not exist.' && failed.spinner[failed.spinner.length - 1] === false && failed.polls.length === 0, 'any other failure shows the server message and stops the spinner'); const igdb = await platformScan(noIgdb); assert(igdb.alerts[0] === 'igdbRequired' && igdb.spinner[igdb.spinner.length - 1] === false, 'missing IGDB credentials use the same translated message as the Media tab'); const started = await platformScan(null); assert(started.alerts.length === 0 && started.polls.length === 1, 'a started scan is followed without a message'); // Settings, Media tab: both Scan now and the discovery scan leave the spinner to the running scan const tab = read('frontend', 'src', 'components', 'settings', 'MediaTab.tsx'); const mediaCall = async (name, failure, arg) => { const calls = newCalls(); const fn = load(tab, name, stand(calls, { apiClient: { post: async (url, body) => { calls.posts.push({ url, body }); if (failure) throw failure; return { data: {} }; } }, setScanning: v => { calls.spinner.push(v); }, prefixSave: { current: Promise.resolve(true) }, navigate: to => { calls.navigated.push(to); }, folderPath: '/games', })); await fn(arg); return calls; }; for (const external of [false, true]) { const scanNow = await mediaCall('handleScanNow', running, external); assert(scanNow.alerts.length === 1 && scanNow.alerts[0] === 'scanAlreadyRunning', `Scan now must name the running scan, got ${JSON.stringify(scanNow.alerts)}`); assert(!scanNow.spinner.includes(false), 'Scan now must leave the spinner to the running scan'); } const discovery = await mediaCall('triggerDiscoveryScan', running); assert(discovery.alerts.length === 1 && discovery.alerts[0] === 'scanAlreadyRunning', 'the discovery scan must name the running scan'); assert(discovery.spinner.includes(true) && discovery.navigated.length === 0, 'the discovery scan must show the running scan instead of leaving the page'); const discoveryStarted = await mediaCall('triggerDiscoveryScan', null); assert(discoveryStarted.navigated[0] === '/discover' && discoveryStarted.alerts.length === 0, 'a started discovery scan still opens Discover'); // Discover page: the message stays (loadList clears errors) and the list refreshes until the running scan ends const discover = read('frontend', 'src', 'pages', 'Discover.tsx'); const discoverScan = async failure => { const calls = newCalls(); const fn = load(discover, 'triggerScan', stand(calls, { apiClient: { post: async () => { if (failure) throw failure; return { data: {} }; } }, mediaApi: { getScanStatus: async () => ({ data: { isScanning: calls.busy } }) }, setScanning: v => { calls.spinner.push(v); }, setInfo: v => { calls.info.push(v); }, setError: v => { calls.errors.push(v); }, loadList: async () => { calls.reloads++; }, follow: { current: undefined }, })); await fn(); return calls; }; const page = await discoverScan(running); assert(page.info[page.info.length - 1] === 'scanAlreadyRunning' && !page.errors.some(Boolean), 'Discover must name the running scan as info'); assert(!page.spinner.includes(false) && page.polls.length === 1, 'Discover must keep scanning while the other scan runs'); await page.polls[0](); assert(!page.spinner.includes(false), 'Discover must wait while the scan still runs'); page.busy = false; await page.polls[0](); assert(page.spinner[page.spinner.length - 1] === false && page.reloads === 1 && page.cleared === 1, 'Discover must stop and reload once the scan is done'); const pageFailed = await discoverScan(refused); assert(pageFailed.errors.includes('The folder does not exist.') && pageFailed.spinner[pageFailed.spinner.length - 1] === false, 'any other failure stays an error and stops the spinner'); // The list refresh during a scan waits longer the longer a load takes, and stops with the scan const refreshEffect = discover.match(/useEffect\(\(\) => (\{\n if \(!scanning\) return;[\s\S]*?\n \}), \[scanning, loadList\]\);/); assert(refreshEffect, 'Discover scan refresh not found'); const clock = { now: 0, load: 3000 }; const timers = []; let loads = 0; const effect = vm.runInNewContext(`${js(`const effect = () => ${refreshEffect[1]};`)}\neffect`, { scanning: true, Date: { now: () => clock.now }, loadList: async () => { loads++; clock.now += clock.load; }, setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clearTimeout: () => {}, }); const stopRefresh = effect(); assert(timers.length === 1 && timers[0].ms === 2000, 'the first refresh comes after 2 s'); await timers[0].fn(); assert(loads === 1 && timers.length === 2 && timers[1].ms === 30000, `a 3 s load must wait 30 s for the next one, got ${timers[1] && timers[1].ms}`); clock.load = 50; await timers[1].fn(); assert(timers[2].ms === 2000, 'a quick load keeps the 2 s refresh'); const inFlight = timers[2].fn(); stopRefresh(); await inFlight; assert(timers.length === 3, 'a load still running when the scan ends must not start another one'); console.log('scan-running: ok'); })().catch(e => { console.error(e.message); process.exit(1); });