Ctrl K
ComunidadGuías…

tests/scan-running.test.cjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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); });