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); }; // ---- Map & Import: game picker available for ALL file types (incl. Main) ---- const status = read('frontend', 'src', 'pages', 'Status.tsx'); assert( !/mapPlatform && \(mapFileType === 'Patches' \|\| mapFileType === 'DLC'\) &&/.test(status), 'The game picker must no longer be gated to Patches/DLC only' ); assert( status.includes("size={Math.min(8, Math.max(2, matches.length + 1))}"), 'The picker should render as a visible list (select with size), not a collapsed dropdown' ); assert( status.includes('Search games by name...'), 'The picker must offer a name search input' ); assert( status.includes("Map to existing game (search by name)"), 'Main-game mapping must expose the map-to-existing-game label' ); assert( status.includes('gameId: mapGameId || 0,'), 'Import must still send the selected gameId, and 0 for "new game from download title"' ); // ---- Mapping goes to the one download: ids alone aren't unique across clients, names aren't unique at all ---- assert( status.includes('clientId: mapModal.downloadClientId,') && status.includes('downloadId: mapModal.downloadId,'), 'Map & Import must send the download\'s clientId and downloadId to map-platform' ); assert( status.includes('/queue/map-platform`, { downloadName: d.name, clientId: d.clientId, downloadId: d.id, platformFolder })'), 'The queue platform editor must send the download\'s clientId and downloadId to map-platform' ); assert( status.includes('') && !status.includes('key={u.downloadId}'), 'Unmapped rows must be keyed by client and download id' ); assert( (status.match(/editingPlatform === `\$\{d\.clientId\}-\$\{d\.id\}`|setEditingPlatform\(`\$\{d\.clientId\}-\$\{d\.id\}`\)/g) || []).length === 2, 'The queue platform editor must open for one client\'s download only' ); const mapController = read('src', 'RetroArr.Api.V3', 'DownloadClients', 'DownloadClientController.cs'); assert( /public int\? ClientId \{ get; set; \}\s*public string\? DownloadId \{ get; set; \}\s*\}/.test(mapController), 'MapPlatformRequest must take optional ClientId and DownloadId' ); assert( mapController.includes('_trackedDownloadService.Find(request.ClientId!.Value, request.DownloadId!)') && mapController.includes('tracked.PlatformFolder = request.PlatformFolder;'), 'map-platform must set the mapping on the tracked download its ids name' ); assert( /nameShared = byId && _trackedDownloadService\.GetTrackedDownloads\(\)\s*\.Any\(t => t != tracked && string\.Equals\(t\.Title, request\.DownloadName, StringComparison\.OrdinalIgnoreCase\)\);\s*if \(!nameShared\)\s*_platformTracker\.SetPlatformForDownload\(/.test(mapController), 'map-platform with ids must not store a name mapping another tracked download would pick up' ); // ---- Map & Import: the file type comes from the import's own rules, "Main" takes back a Patches/DLC subfolder ---- assert(!status.includes('detectFileType'), 'The map dialog must not guess the file type with keyword rules of its own'); assert( status.includes('apiClient.get(`${API_BASE}/queue/import-subfolder?${params}`)') && status.includes('}, [mapModal, mapGameId, mapPlatform, mapFileTouched]);'), 'The map dialog must ask the backend for the file type, again when the game or platform changes' ); assert(/onChange=\{e => \{ setMapFileType\([^}]*\); setMapFileTouched\(true\); \}\}/.test(status), 'Picking a file type by hand must stop the suggestion from overwriting it'); // Runs the suggestion effect once: what it fetches and what it sets const suggest = async ({ touched, answer }) => { const body = status.match(/useEffect\(\(\) => \{\n if \(!mapModal \|\| mapFileTouched\) return;[\s\S]*?\n \}, \[mapModal, mapGameId, mapPlatform, mapFileTouched\]\);/); assert(body, 'the file type suggestion effect not found'); const effect = body[0].replace(/^useEffect\(/, '(').replace(/, \[mapModal, mapGameId, mapPlatform, mapFileTouched\]\);$/, ')'); const js = ts.transpileModule(`const effect = ${effect};`, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; const seen = { fetched: 0, set: [] }; const run = vm.runInNewContext(`${js}\neffect`, { mapModal: { title: 'Patch Quest v1.0.3' }, mapGameId: 7, mapPlatform: 'windows', mapFileTouched: touched, API_BASE: '/api/v3/downloadclient', URLSearchParams, String, apiClient: { get: async () => { seen.fetched++; if (answer instanceof Error) throw answer; return { data: answer }; } }, setMapFileType: v => seen.set.push(v), }); run(); await new Promise(resolve => setTimeout(resolve, 0)); return seen; }; assert( mapController.includes('[HttpGet("queue/import-subfolder")]') && mapController.includes('PostDownloadProcessor.ImportSubfolderFor(title, game?.Title, picked) ?? "Main"'), 'queue/import-subfolder must answer with the import\'s classifier' ); const mapAndImportBody = async (mapFileType, mapGameId = 7, mapFileTouched = true) => { const block = status.match(/const handleMapAndImport = [\s\S]*?\n \};/); assert(block, 'handleMapAndImport not found'); const js = ts.transpileModule(block[0], { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; const calls = []; const run = vm.runInNewContext(`${js}\nhandleMapAndImport`, { mapModal: { title: 'Patch Quest v1.0.3', downloadClientId: 1, downloadId: 'abc' }, mapPlatform: 'windows', mapGameId, mapFileType, mapFileTouched, apiClient: { post: async (url, body) => { calls.push({ url, body }); return {}; } }, importDownload: async () => {}, setFileError: () => {}, showFileError: e => { throw e; }, setMapModal: () => {}, setMapPlatform: () => {}, setMapGameId: () => {}, setMapFileType: () => {}, setMapGameSearch: () => {}, fetchUnmapped: () => {}, fetchCounts: () => {}, }); await run(); return calls.find(c => c.url.endsWith('/queue/map-platform')).body; }; // ---- Hard-refresh fix: ChunkErrorBoundary wraps the lazy routes ---- const app = read('frontend', 'src', 'App.tsx'); assert( app.includes("import ChunkErrorBoundary from './components/ChunkErrorBoundary'"), 'App must import ChunkErrorBoundary' ); assert( /[\s\S]*[\s\S]*<\/ChunkErrorBoundary>/.test(app), 'ChunkErrorBoundary must wrap the Suspense/Routes block' ); const boundary = read('frontend', 'src', 'components', 'ChunkErrorBoundary.tsx'); assert( boundary.includes('getDerivedStateFromError'), 'ChunkErrorBoundary must implement getDerivedStateFromError' ); assert( boundary.includes('window.location.reload()'), 'ChunkErrorBoundary must reload to fetch a fresh index.html on chunk failure' ); assert( /ChunkLoadError|dynamically imported module|loading chunk/i.test(boundary), 'ChunkErrorBoundary must detect chunk-load errors' ); assert( boundary.includes('RELOAD_GUARD_KEY'), 'ChunkErrorBoundary must guard against reload loops' ); // ---- Backend: SPA shell served no-cache so index.html is never stale ---- const program = read('src', 'RetroArr.Host', 'Program.cs'); assert( program.includes('"Cache-Control"] = "no-cache, no-store, must-revalidate"'), 'The index.html fallback must be served with no-cache to avoid stale chunk references' ); // ---- GameDetails fast load: GetById must not block on a live IGDB fetch ---- const gameController = read('src', 'RetroArr.Api.V3', 'Games', 'GameController.cs'); assert( !gameController.includes('GetGameMetadataAsync(game.IgdbId.Value, lang)'), 'GetById must not re-fetch IGDB metadata synchronously (it blocked the detail page for seconds)' ); // ---- EmulatorJS CDN uses the 'latest' channel, not deprecated 'stable' ---- const emu = read('src', 'RetroArr.Api.V3', 'Emulator', 'EmulatorController.cs'); assert(!emu.includes('emulatorjs.org/stable'), 'EmulatorJS CDN must not use the deprecated stable channel'); assert(emu.includes('emulatorjs.org/latest/data'), 'EmulatorJS CDN base must point at latest/data'); const df = read('Dockerfile'); assert(!df.includes('emulatorjs.org/stable'), 'Dockerfile must not pull EmulatorJS from stable'); // ---- Cross-platform filename sanitizer used at every name-building site ---- const sanitizer = read('src', 'RetroArr.Core', 'IO', 'FileNameSanitizer.cs'); assert(sanitizer.includes('class FileNameSanitizer') && sanitizer.includes('?*') && sanitizer.includes('\\x00-\\x1F'), 'FileNameSanitizer must strip the Windows-reserved set plus control chars'); for (const f of [ ['src', 'RetroArr.Core', 'Configuration', 'MediaSettings.cs'], ['src', 'RetroArr.Core', 'Download', 'PostDownloadProcessor.cs'], ['src', 'RetroArr.Core', 'Rename', 'TemplateRenderer.cs'], ]) { assert(read(...f).includes('FileNameSanitizer.Sanitize'), `${f[f.length - 1]} must route names through FileNameSanitizer`); } // ---- Sanitizer uses a readable dash separator; rename can fix mangled folders ---- assert(sanitizer.includes('" - "'), 'FileNameSanitizer must replace illegal chars with a " - " separator'); const resort = read('src', 'RetroArr.Api.V3', 'Games', 'ResortController.cs'); assert(!/RenameGameFolder &&\s*\n\s*i\.ProposedAction != OperationType\.MoveGameFolder/.test(resort), 'per-game rename must no longer exclude RenameGameFolder (so it can fix mangled folder names)'); assert(resort.includes('OperationType.MoveGameFolder'), 'per-game rename still excludes platform relocations'); (async () => { for (const [type, sent] of [['Main', 'Main'], ['Patches', 'Patches'], ['DLC', 'DLC'], ['', null]]) { assert((await mapAndImportBody(type)).importSubfolder === sent, `a file type picked as ${JSON.stringify(type)} must be sent as ${sent}: a picked Main takes back a Patches/DLC subfolder`); } for (const type of ['Main', 'Patches', 'DLC', '']) { assert((await mapAndImportBody(type, 7, false)).importSubfolder === null, `a suggested ${JSON.stringify(type)} only shows what the import does: sent, it would override the grab's subfolder`); } const untouched = await suggest({ touched: false, answer: { importSubfolder: 'Patches' } }); assert(untouched.fetched === 1 && untouched.set.join() === 'Patches', 'an untouched file type must take the suggestion'); const touched = await suggest({ touched: true, answer: { importSubfolder: 'Main' } }); assert(touched.fetched === 0 && touched.set.length === 0, 'a file type picked by hand must not be overwritten by the suggestion'); const failed = await suggest({ touched: false, answer: new Error('offline') }); assert(failed.set.length === 0, 'a failed suggestion must leave the type to the import, not set one'); // Mapping or creating a game from a file shows why the server refused it and keeps the dialog open const fileAction = async (name, outcome) => { const blocks = ['showFileError', name].map(n => { const block = status.match(new RegExp(`const ${n} = [\\s\\S]*?\\n \\};`)); assert(block, `${n} not found`); return ts.transpileModule(block[0], { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; }); const seen = { error: null, closed: false }; const run = vm.runInNewContext(`${blocks.join('\n')}\n${name}`, { mapFileModal: { fullPath: '/library/gba/x.gba', platformFolder: 'gba' }, mapFileGameId: 7, createGameModal: { fullPath: '/library/gba/x.gba', platformFolder: 'gba' }, createGameTitle: 'X', apiClient: { post: async () => { if (outcome) throw outcome; return { data: {} }; } }, getErrorMessage: e => e.response.data.message, t: k => k, setFileError: v => { seen.error = v; }, setMapFileModal: () => { seen.closed = true; }, setMapFileGameId: () => {}, setCreateGameModal: () => { seen.closed = true; }, setCreateGameTitle: () => {}, fetchUnmappedFiles: () => {}, }); await run(); return seen; }; for (const name of ['handleMapFileToGame', 'handleCreateGameFromFile']) { const refused = await fileAction(name, { response: { status: 409, data: { message: 'Another game has this file' } } }); assert(refused.error === 'Another game has this file' && !refused.closed, `${name} must show the server's reason and keep the dialog open`); const done = await fileAction(name, null); assert(done.error === null && done.closed, `${name} must close the dialog when it worked`); } assert((await mapAndImportBody('Main', 7)).gameId === 7, 'the game picked in the dialog must be sent'); assert((await mapAndImportBody('Main', '')).gameId === 0, '"New game from download title" must be sent as game 0, nothing sent keeps a wrong game the download was linked to'); console.log('map-import-and-chunk-boundary: all contract checks passed'); })().catch(e => { console.error(e.message); process.exit(1); });