diff --git a/docs/05-native-filesystem.md b/docs/05-native-filesystem.md new file mode 100644 index 0000000..29ea95e --- /dev/null +++ b/docs/05-native-filesystem.md @@ -0,0 +1,47 @@ +# Native filesystem and pause safety + +The application stores cartridge bytes, battery saves and snapshots in its own IndexedDB database +through `storage.ts`. The emulator filesystem is the working copy used by the native core. + +## Why native filesystem operations must be synchronous + +The pinned mGBA 2.5.1 bundle uses Emscripten 6.0.5. Its `fd_sync` proxies from the emulator thread to +the browser main thread in promise mode and waits for an asynchronous IDBFS flush. A simultaneous +`pauseGame` or thread interrupt blocks the main thread until the emulator thread stops. The browser +cannot deliver the IndexedDB completion while that synchronous call is waiting, so both threads +wait indefinitely. Frame counters and save notifications alone do not prevent this cycle. + +`scripts/mgba-memory-filesystem.mjs` adapts the same-origin runtime during `setup-emulator`. It +checks the exact version and SHA-256 of the installed JavaScript before changing the reviewed +filesystem initializer and `fd_sync` proxy. SDK drift fails the build and requires a new review. +The WebAssembly binary, worker loading through `import.meta.url`, and runtime version remain intact. + +## Existing data and persistence + +Before loading a cartridge or starting emulation, `FSInit` awaits a read of both legacy SDK IDBFS +mounts, `/data` and `/autosave`. It collects their complete file bytes and directory structure, then +recreates them under MEMFS mounts. It neither deletes nor writes back the legacy IndexedDB databases. +Read failures, unsupported entries and copy failures reject initialization instead of starting with +missing data. This also preserves legacy native saves when the application's battery record is absent. + +During emulation, native `fd_sync` uses a synchronous proxy and returns a numeric result only for a +memory filesystem. An asynchronous backing store returns `ENOTSUP`; real filesystem errors propagate. +It never acknowledges an unfinished persistent-store flush. Application battery and snapshot writes +still await their existing IndexedDB transactions, and persistence failures remain visible. `FSSync` +continues to be awaited by the application, but the native working filesystem has no asynchronous +backing mount. + +Startup also pauses the core while replacing callbacks and configuring direction policy, volume and +speed. These structures are read by the native thread during frames. Configuration's internal +interrupt/continue preserves the native pause request; the application resumes only after setup +succeeds. A setup error leaves the core paused. Snapshot restore retains mask `61` and its existing +active-SRAM and battery-file semantics. + +## Verification + +Unit tests execute the emitted proxy, reject SDK drift and asynchronous backends, preserve syscall +errors, and verify legacy cache import without persistent writes. Emulator tests enforce paused setup +and cartridge switching. The full browser suite verifies GB/GBC/GBA pause/resume, snapshot restoration, +exact battery bytes, reload persistence, and access isolation without altered assertions or timeouts. +A real-browser legacy-cache regression brings the mandatory exact count to 31 and verifies that +working-copy writes leave the original SDK IndexedDB bytes intact. diff --git a/docs/README.md b/docs/README.md index 7a8e014..ab21556 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,3 +7,4 @@ 1. [6DQ adoption plan](01-6dq-adoption-plan.md) — requirements, baseline evidence, and accepted design. 2. [6DQ Tier S execution](02-6dq-tier-s-execution.md) — atomic implementation batches, reviews, final local acceptance, and hosted-validation limits. 3. [Workspace recovery](03-workspace-recovery.md) — verified recovery, backup inventory, and browser-test containment. +4. [Native filesystem and pause safety](05-native-filesystem.md) — deadlock prevention, legacy cache import and durable application storage. diff --git a/scripts/browser-shared.mjs b/scripts/browser-shared.mjs index 999fff0..308276a 100644 --- a/scripts/browser-shared.mjs +++ b/scripts/browser-shared.mjs @@ -14,7 +14,7 @@ export const ENV_BROWSER_PORT = 'POKEPOCKET_BROWSER_PORT'; export const ENV_BROWSER_PID = 'POKEPOCKET_BROWSER_PID'; export const ENV_BROWSER_WS = 'POKEPOCKET_BROWSER_WS'; -export const REQUIRED_TEST_COUNT = 30; // 28 core/layout/export journeys + 2 authorization tests +export const REQUIRED_TEST_COUNT = 31; // 28 core/layout/export journeys + 2 authorization + legacy SDK cache import export const OPTIONAL_TEST_COUNT = 16; // 13 series + 3 commercial export const FORBIDDEN_CLI_FLAGS = new Set([ diff --git a/scripts/mgba-memory-filesystem.mjs b/scripts/mgba-memory-filesystem.mjs new file mode 100644 index 0000000..c00f127 --- /dev/null +++ b/scripts/mgba-memory-filesystem.mjs @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto'; + +const SDK_SHA256 = '98e6f460fa2f95846bf79c3b1c42ecf3acca0ce11937ba0d4aa693438402a473'; + +export function initializeMemoryFilesystem(FS) { + return new Promise((resolve, reject) => { + const mounts = ['/data', '/autosave']; + for (const mount of mounts) { + FS.mkdir(mount); + FS.mount(FS.filesystems.IDBFS, {}, mount); + } + // Read legacy SDK caches before emulation; never delete or write back their databases. + FS.syncfs(true, (error) => { + if (error) { + reject(new Error(`Cannot read legacy emulator cache: ${error}`)); + return; + } + try { + const entries = []; + const collect = (directory) => { + for (const name of FS.readdir(directory)) { + if (name === '.' || name === '..') continue; + const file = `${directory}/${name}`; + const mode = FS.lstat(file).mode; + if (FS.isDir(mode)) { + entries.push({ file }); + collect(file); + } else if (FS.isFile(mode)) { + entries.push({ file, data: FS.readFile(file) }); + } else { + throw new Error(`Unsupported legacy emulator cache entry: ${file}`); + } + } + }; + for (const mount of mounts) collect(mount); + for (const mount of mounts) { + FS.unmount(mount); + FS.mount(FS.filesystems.MEMFS, {}, mount); + } + for (const { file, data } of entries) { + if (data) FS.writeFile(file, data); + else FS.mkdir(file); + } + for (const name of ['saves', 'states', 'games', 'cheats', 'screenshots', 'patches']) { + const directory = `/data/${name}`; + if (!FS.analyzePath(directory).exists) FS.mkdir(directory); + } + resolve(); + } catch (error) { + reject(error); + } + }); + }); +} + +export function syncMemoryFile(fd, FS, SYSCALLS) { + try { + const stream = SYSCALLS.getStreamFromFD(fd); + // A synchronous native caller must never acknowledge an asynchronous backing store. + if (stream.node.mount.type !== FS.filesystems.MEMFS) return 58; // WASI ENOTSUP + const result = stream.stream_ops?.fsync?.(stream); + if (result === undefined) return 0; + return typeof result === 'number' ? result : 29; // WASI EIO for a non-synchronous operation + } catch (error) { + if (error.name !== 'ErrnoError') throw error; + return error.errno; + } +} + +export function patchMgbaFilesystem(source, version) { + if (version !== '2.5.1' || createHash('sha256').update(source).digest('hex') !== SDK_SHA256) { + throw new Error( + 'Unreviewed mGBA runtime: review the filesystem adapter before updating the SDK', + ); + } + const initStart = source.indexOf('Module.FSInit='); + const initEnd = source.indexOf('Module.FSSync=', initStart); + const syncStart = source.indexOf('var _fd_sync='); + const syncEnd = source.indexOf('var doWritev=', syncStart); + // The digest binds these boundaries and proxy index to the reviewed SDK artifact. + return ( + source.slice(0, initStart) + + `Module.FSInit=()=>(${initializeMemoryFilesystem.toString()})(FS);` + + source.slice(initEnd, syncStart) + + `var _fd_sync=function(fd){if(ENVIRONMENT_IS_PTHREAD)return proxyToMainThread(80,0,1,fd);return (${syncMemoryFile.toString()})(fd,FS,SYSCALLS);};` + + source.slice(syncEnd) + ); +} diff --git a/scripts/setup-emulator.mjs b/scripts/setup-emulator.mjs index a6ffcd0..67bbccb 100644 --- a/scripts/setup-emulator.mjs +++ b/scripts/setup-emulator.mjs @@ -1,6 +1,7 @@ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; +import { patchMgbaFilesystem } from './mgba-memory-filesystem.mjs'; export const REQUIRED_DEPENDENCIES = [ 'react', @@ -17,13 +18,16 @@ export async function setupEmulator(options = {}) { const root = options.root ?? fileURLToPath(new URL('../', import.meta.url)); const source = options.source ?? path.join(root, 'node_modules/@thenick775/mgba-wasm'); const pkg = JSON.parse(await readFile(path.join(source, 'package.json'), 'utf8')); + const runtime = patchMgbaFilesystem( + await readFile(path.join(source, 'dist/mgba.js'), 'utf8'), + pkg.version, + ); const target = path.join(root, 'public/emulator', pkg.version); await mkdir(target, { recursive: true }); // Preserve import.meta.url: the threaded Emscripten runtime loads itself as a worker. - for (const file of ['mgba.js', 'mgba.wasm']) { - await copyFile(path.join(source, 'dist', file), path.join(target, file)); - } + await writeFile(path.join(target, 'mgba.js'), runtime); + await copyFile(path.join(source, 'dist/mgba.wasm'), path.join(target, 'mgba.wasm')); await writeFile( path.join(root, 'public/emulator/version.json'), JSON.stringify({ version: pkg.version }), diff --git a/src/lib/emulator.ts b/src/lib/emulator.ts index 8d5bc9c..581a899 100644 --- a/src/lib/emulator.ts +++ b/src/lib/emulator.ts @@ -227,6 +227,8 @@ export class PocketEmulator { } this.savedataDirty = false; + // The native thread reads both the callback list and configuration during frames. + core.pauseGame(); core.addCoreCallbacks({ videoFrameEndedCallback: () => { if (this.loadGeneration === currentGeneration) { @@ -246,10 +248,10 @@ export class PocketEmulator { }, 0); }, }); - core.setCoreSettings({ allowOpposingDirections: false }); core.setVolume(this.volume); core.setFastForwardMultiplier(this.state.speed); + core.resumeGame(); const previous = snapshots.find((snapshot) => snapshot.slot === 0); let resumedAutomatically = false; diff --git a/tests/e2e/native-filesystem.spec.ts b/tests/e2e/native-filesystem.spec.ts new file mode 100644 index 0000000..4e848bf --- /dev/null +++ b/tests/e2e/native-filesystem.spec.ts @@ -0,0 +1,92 @@ +import type { mGBAEmulator } from '@thenick775/mgba-wasm'; +import { CORE_VERSION } from '../../src/lib/emulator'; +import { test, expect } from '../fixtures/browser-harness'; + +test('imports legacy native save bytes into memory without rewriting the old IndexedDB caches', async ({ + page, +}) => { + await page.goto('/'); + const result = await page.evaluate(async (version) => { + const fixtures = [ + { + database: '/data', + directory: '/data/saves', + file: '/data/saves/legacy.sav', + bytes: [0, 128, 255], + }, + { + database: '/autosave', + directory: '/autosave/old', + file: '/autosave/old/game.ss0', + bytes: [9, 8, 7], + }, + ]; + const open = (name: string) => + new Promise((resolve, reject) => { + // The reviewed SDK's IDBFS database schema is version 21. + const request = indexedDB.open(name, 21); + request.onupgradeneeded = () => + request.result.createObjectStore('FILE_DATA').createIndex('timestamp', 'timestamp'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + for (const fixture of fixtures) { + const db = await open(fixture.database); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction('FILE_DATA', 'readwrite'); + const store = tx.objectStore('FILE_DATA'); + const timestamp = new Date('2026-01-01T00:00:00Z'); + store.put({ timestamp, mode: 0o40755 }, fixture.directory); + store.put( + { timestamp, mode: 0o100644, contents: new Uint8Array(fixture.bytes) }, + fixture.file, + ); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } + } + + const url = new URL(`/emulator/${version}/mgba.js`, window.location.origin).href; + const { default: createCore } = (await import(/* @vite-ignore */ url)) as { + default: (options: { canvas: HTMLCanvasElement }) => Promise; + }; + const core = await createCore({ canvas: document.createElement('canvas') }); + await core.FSInit(); + const imported = fixtures.map(({ file }) => [...core.FS.readFile(file)]); + for (const fixture of fixtures) core.FS.writeFile(fixture.file, new Uint8Array([42])); + await core.FSSync(); + + const oldDatabases = []; + for (const fixture of fixtures) { + const db = await open(fixture.database); + try { + oldDatabases.push( + await new Promise((resolve, reject) => { + const tx = db.transaction('FILE_DATA', 'readonly'); + const request = tx.objectStore('FILE_DATA').get(fixture.file); + tx.oncomplete = () => resolve([...request.result.contents]); + tx.onabort = () => reject(tx.error); + }), + ); + } finally { + db.close(); + } + } + return { + imported, + oldDatabases, + working: fixtures.map(({ file }) => [...core.FS.readFile(file)]), + }; + }, CORE_VERSION); + + expect(result.imported).toEqual([ + [0, 128, 255], + [9, 8, 7], + ]); + expect(result.working).toEqual([[42], [42]]); + expect(result.oldDatabases).toEqual(result.imported); +}); diff --git a/tests/unit/emulator.test.ts b/tests/unit/emulator.test.ts index 53624e9..2964c9e 100644 --- a/tests/unit/emulator.test.ts +++ b/tests/unit/emulator.test.ts @@ -129,6 +129,80 @@ describe('PocketEmulator with injected dependencies', () => { expect(core.loadGame).toHaveBeenCalled(); }); + it('configures callbacks and settings while paused, including cartridge switches', async () => { + const core = createFakeCore(); + let active = false; + const register = core.addCoreCallbacks; + core.loadGame = vi.fn(() => { + active = true; + return true; + }); + core.pauseGame = vi.fn(() => { + active = false; + }); + core.resumeGame = vi.fn(() => { + active = true; + }); + core.addCoreCallbacks = vi.fn((callbacks) => { + if (active) throw new Error('Core thread is reading the callback list'); + register(callbacks); + }); + core.setCoreSettings = vi.fn(() => { + if (active) throw new Error('Core thread is reading core settings'); + }); + core.setVolume = vi.fn(() => { + if (active) throw new Error('Core thread is reading volume settings'); + }); + core.setFastForwardMultiplier = vi.fn(() => { + if (active) throw new Error('Core thread is reading timing settings'); + }); + const emulator = new PocketEmulator({ + createCore: async () => core, + checkCrossOriginIsolated: () => true, + storage: createStorageDouble(), + clock: createClockDouble(), + }); + + for (const id of ['first-callback-owner', 'second-callback-owner']) { + await emulator.load(createCartridge(id), {} as HTMLCanvasElement); + expect(emulator.getSnapshot().status).toBe('running'); + expect(emulator.getSnapshot().cartridge?.id).toBe(id); + expect(active).toBe(true); + } + expect(core.addCoreCallbacks).toHaveBeenCalledTimes(2); + }); + + it('keeps the core paused if callback registration fails', async () => { + const core = createFakeCore(); + let active = false; + core.loadGame = vi.fn(() => { + active = true; + return true; + }); + core.pauseGame = vi.fn(() => { + active = false; + }); + core.resumeGame = vi.fn(() => { + active = true; + }); + core.addCoreCallbacks = vi.fn(() => { + throw new Error('Callback registration failed'); + }); + const emulator = new PocketEmulator({ + createCore: async () => core, + checkCrossOriginIsolated: () => true, + storage: createStorageDouble(), + clock: createClockDouble(), + }); + + await expect(emulator.load(createCartridge(), {} as HTMLCanvasElement)).rejects.toThrow( + 'Callback registration failed', + ); + expect(emulator.getSnapshot().status).toBe('error'); + expect(active).toBe(false); + expect(core.resumeGame).not.toHaveBeenCalled(); + }); + it('pauses and resumes cleanly', async () => { const core = createFakeCore(); const emulator = new PocketEmulator({ @@ -783,6 +857,7 @@ describe('PocketEmulator with injected dependencies', () => { }); await emulator.load(createCartridge('cart-deferred-persist'), {} as HTMLCanvasElement); + callOrder.length = 0; emulator.pause(); expect(emulator.getSnapshot().status).toBe('paused'); expect(corePaused).toBe(true); @@ -948,7 +1023,13 @@ describe('PocketEmulator with injected dependencies', () => { }); await emu1.load(cart, {} as HTMLCanvasElement, true); - expect(callOrder1).toEqual(['pauseGame', 'loadStateSlot:0:61:paused=true', 'resumeGame']); + expect(callOrder1).toEqual([ + 'pauseGame', + 'resumeGame', + 'pauseGame', + 'loadStateSlot:0:61:paused=true', + 'resumeGame', + ]); expect(core1.FS.writeFile).toHaveBeenCalledWith( '/states/cart-auto-lifecycle.ss0', new Uint8Array(autoData), diff --git a/tests/unit/mgba-memory-filesystem.test.mjs b/tests/unit/mgba-memory-filesystem.test.mjs new file mode 100644 index 0000000..bee79e8 --- /dev/null +++ b/tests/unit/mgba-memory-filesystem.test.mjs @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { + initializeMemoryFilesystem, + patchMgbaFilesystem, + syncMemoryFile, +} from '../../scripts/mgba-memory-filesystem.mjs'; + +const sdk = await readFile( + new URL('../../node_modules/@thenick775/mgba-wasm/dist/mgba.js', import.meta.url), + 'utf8', +); + +function legacyFilesystem(error) { + const nodes = new Map(); + const backing = new Map(); + const MEMFS = {}; + const IDBFS = {}; + const legacy = new Map([ + ['/data/saves', null], + ['/data/saves/game.sav', new Uint8Array([0, 128, 255])], + ['/data/states', null], + ['/data/states/game.ss0', new Uint8Array([9, 8, 7])], + ['/autosave/empty', null], + ['/data/empty.dat', new Uint8Array()], + ]); + const FS = { + filesystems: { MEMFS, IDBFS }, + mkdir: vi.fn((name) => { + if (nodes.has(name)) throw new Error('Directory already exists'); + nodes.set(name, null); + }), + mount: vi.fn((type, _options, name) => backing.set(name, type)), + unmount: vi.fn((name) => { + for (const key of nodes.keys()) if (key.startsWith(`${name}/`)) nodes.delete(key); + backing.delete(name); + }), + syncfs: vi.fn((populate, callback) => { + expect(populate).toBe(true); + for (const [name, data] of legacy) nodes.set(name, data); + queueMicrotask(() => callback(error)); + }), + readdir: (directory) => [ + '.', + '..', + ...[...nodes.keys()] + .filter((name) => name.startsWith(`${directory}/`)) + .map((name) => name.slice(directory.length + 1)) + .filter((name) => !name.includes('/')), + ], + lstat: (name) => ({ mode: nodes.get(name) === null ? 'directory' : 'file' }), + isDir: (mode) => mode === 'directory', + isFile: (mode) => mode === 'file', + readFile: (name) => nodes.get(name).slice(), + writeFile: vi.fn((name, data) => nodes.set(name, data.slice())), + analyzePath: (name) => ({ exists: nodes.has(name) }), + }; + return { FS, nodes, backing, legacy }; +} + +describe('native memory filesystem and durable application storage boundary', () => { + it('reads old caches before replacing mounts and preserves every byte and empty directory', async () => { + const { FS, nodes, backing, legacy } = legacyFilesystem(); + const initialized = initializeMemoryFilesystem(FS); + expect(FS.unmount).not.toHaveBeenCalled(); + await initialized; + for (const [name, bytes] of legacy) expect(nodes.get(name)).toEqual(bytes); + expect([...backing.values()]).toEqual([FS.filesystems.MEMFS, FS.filesystems.MEMFS]); + expect(FS.syncfs).toHaveBeenCalledTimes(1); + for (const [, options] of FS.mount.mock.calls) expect(options).toEqual({}); + for (const name of ['games', 'cheats', 'screenshots', 'patches']) { + expect(nodes.get(`/data/${name}`)).toBe(null); + } + }); + + it('fails before replacing mounts if legacy data cannot be read', async () => { + const { FS } = legacyFilesystem(new Error('IndexedDB read failed')); + await expect(initializeMemoryFilesystem(FS)).rejects.toThrow('IndexedDB read failed'); + expect(FS.unmount).not.toHaveBeenCalled(); + expect(FS.writeFile).not.toHaveBeenCalled(); + }); + + it('rejects unsupported legacy entries without discarding either backing database', async () => { + const { FS } = legacyFilesystem(); + FS.lstat = () => ({ mode: 'link' }); + await expect(initializeMemoryFilesystem(FS)).rejects.toThrow('Unsupported legacy'); + expect(FS.unmount).not.toHaveBeenCalled(); + }); + + it('propagates memory-copy errors rather than allowing a partially initialized core', async () => { + const { FS } = legacyFilesystem(); + FS.writeFile = () => { + throw new Error('Memory copy failed'); + }; + await expect(initializeMemoryFilesystem(FS)).rejects.toThrow('Memory copy failed'); + }); + + it('rejects SDK version or content drift before applying the runtime adapter', () => { + expect(() => patchMgbaFilesystem(sdk, '2.5.0')).toThrow('Unreviewed mGBA'); + expect(() => patchMgbaFilesystem(`${sdk}\n`, '2.5.1')).toThrow('Unreviewed mGBA'); + }); + + it('emits an actual synchronous pthread proxy and returns without a Promise turn', () => { + const patched = patchMgbaFilesystem(sdk, '2.5.1'); + const body = patched.slice(patched.indexOf('var _fd_sync='), patched.indexOf('var doWritev=')); + const invoke = new Function( + 'FS', + 'SYSCALLS', + 'ENVIRONMENT_IS_PTHREAD', + 'proxyToMainThread', + `${body}return _fd_sync(7);`, + ); + const FS = { filesystems: { MEMFS: {} } }; + const SYSCALLS = { + getStreamFromFD: () => ({ node: { mount: { type: FS.filesystems.MEMFS } } }), + }; + const proxy = vi.fn(() => invoke(FS, SYSCALLS, false, null)); + expect(invoke(FS, SYSCALLS, true, proxy)).toBe(0); + expect(proxy).toHaveBeenCalledWith(80, 0, 1, 7); + expect(patched).toContain('import.meta.url'); + }); + + it('rejects asynchronous backing stores and preserves real syscall errors', () => { + const MEMFS = {}; + const FS = { filesystems: { MEMFS } }; + const fsync = vi.fn(); + const stream = { node: { mount: { type: {} } }, stream_ops: { fsync } }; + const SYSCALLS = { getStreamFromFD: () => stream }; + expect(syncMemoryFile(7, FS, SYSCALLS)).toBe(58); + expect(fsync).not.toHaveBeenCalled(); + stream.node.mount.type = MEMFS; + fsync.mockReturnValue(29); + expect(syncMemoryFile(7, FS, SYSCALLS)).toBe(29); + fsync.mockReturnValue(Promise.resolve(0)); + expect(syncMemoryFile(7, FS, SYSCALLS)).toBe(29); + SYSCALLS.getStreamFromFD = () => { + throw Object.assign(new Error('Bad fd'), { name: 'ErrnoError', errno: 8 }); + }; + expect(syncMemoryFile(7, FS, SYSCALLS)).toBe(8); + SYSCALLS.getStreamFromFD = () => { + throw new Error('Unexpected failure'); + }; + expect(() => syncMemoryFile(7, FS, SYSCALLS)).toThrow('Unexpected failure'); + }); +}); diff --git a/tests/unit/setup-emulator.test.mjs b/tests/unit/setup-emulator.test.mjs index ba10fe6..1ab34ad 100644 --- a/tests/unit/setup-emulator.test.mjs +++ b/tests/unit/setup-emulator.test.mjs @@ -5,6 +5,10 @@ import path from 'node:path'; import { setupEmulator, REQUIRED_DEPENDENCIES } from '../../scripts/setup-emulator.mjs'; const roots = []; +const sdkRuntime = await readFile( + new URL('../../node_modules/@thenick775/mgba-wasm/dist/mgba.js', import.meta.url), + 'utf8', +); afterEach(async () => { await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true }))); }); @@ -21,7 +25,7 @@ describe('setup-emulator policy and asset preparation', () => { const source = path.join(root, 'node_modules/@thenick775/mgba-wasm'); await mkdir(path.join(source, 'dist'), { recursive: true }); await writeFile(path.join(source, 'package.json'), JSON.stringify({ version: '2.5.1' })); - await writeFile(path.join(source, 'dist/mgba.js'), '/* fake mgba.js */'); + await writeFile(path.join(source, 'dist/mgba.js'), sdkRuntime); await writeFile(path.join(source, 'dist/mgba.wasm'), '/* fake mgba.wasm */'); // Create mock node_modules dependencies with licenses @@ -44,7 +48,9 @@ describe('setup-emulator policy and asset preparation', () => { // Check emulator assets const jsContent = await readFile(path.join(root, 'public/emulator/2.5.1/mgba.js'), 'utf8'); - expect(jsContent).toBe('/* fake mgba.js */'); + expect(jsContent).toContain('proxyToMainThread(80,0,1,fd)'); + expect(jsContent).not.toContain('proxyToMainThread(80,0,2,fd)'); + expect(jsContent).toContain('initializeMemoryFilesystem'); const wasmContent = await readFile(path.join(root, 'public/emulator/2.5.1/mgba.wasm'), 'utf8'); expect(wasmContent).toBe('/* fake mgba.wasm */'); @@ -61,7 +67,7 @@ describe('setup-emulator policy and asset preparation', () => { const source = path.join(root, 'node_modules/@thenick775/mgba-wasm'); await mkdir(path.join(source, 'dist'), { recursive: true }); await writeFile(path.join(source, 'package.json'), JSON.stringify({ version: '2.5.1' })); - await writeFile(path.join(source, 'dist/mgba.js'), '/* fake mgba.js */'); + await writeFile(path.join(source, 'dist/mgba.js'), sdkRuntime); await writeFile(path.join(source, 'dist/mgba.wasm'), '/* fake mgba.wasm */'); for (const dep of REQUIRED_DEPENDENCIES) { const depDir = path.join(root, 'node_modules', dep);