// @vitest-environment node import { afterEach, describe, expect, it, vi } from 'vitest'; import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { createServer, get, type Server } from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { once } from 'node:events'; import { spawn } from 'node:child_process'; import { discoverWorkLaunch, startWorkLaunch, stopWorkLaunch, workLaunchMatches } from '../../electron/coding-runtime/work-launcher'; import type { WorkLaunchSpec } from '../../electron/coding-runtime/work-entry-contracts'; vi.mock('node:child_process', async (importOriginal) => { const original = await importOriginal(); return { ...original, spawn: vi.fn(original.spawn) }; }); const directories: string[] = []; const servers: Server[] = []; const html = 'Saved game

Play

'; async function fixture(): Promise { const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-launch-')); directories.push(directory); return directory; } async function staticFixture(root: string, directory = '.'): Promise { const target = path.resolve(root, directory); await mkdir(target, { recursive: true }); await writeFile(path.join(target, 'index.html'), html); await writeFile(path.join(target, 'main.js'), 'console.log("play")'); return { kind: 'static', directory, entry: 'index.html' }; } async function viteFixture(root: string, directory = '.', dev = 'vite --host 0.0.0.0 --port 5199'): Promise { const target = path.resolve(root, directory); await mkdir(path.join(target, 'node_modules', 'vite', 'bin'), { recursive: true }); await mkdir(path.join(target, 'node_modules', 'vite', 'dist', 'node'), { recursive: true }); await writeFile(path.join(target, 'index.html'), html); await writeFile(path.join(target, 'package.json'), JSON.stringify({ scripts: { dev } })); await writeFile(path.join(target, 'node_modules', 'vite', 'package.json'), JSON.stringify({ name: 'vite', type: 'module' })); await writeFile(path.join(target, 'node_modules', 'vite', 'bin', 'vite.js'), '// installed CLI entry'); await writeFile(path.join(target, 'node_modules', 'vite', 'dist', 'node', 'index.js'), ` import http from 'node:http'; export async function createServer(options) { const server = http.createServer((_req,res) => { res.setHeader('content-type','text/html'); res.end(${JSON.stringify(html.replace('', ''))}); }); return {config:{root:process.cwd(),base:options.base || '/'},async listen() { await new Promise(resolve => server.listen(options.server.port,options.server.host,resolve)); }}; } `); } async function listen(server: Server, port = 0): Promise { servers.push(server); await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('port unavailable'); return `http://127.0.0.1:${address.port}/`; } function request(url: string, requestPath?: string): Promise<{ status?: number; contentType?: string; body: string }> { return new Promise((resolve, reject) => { const address = new URL(url); get({ hostname: address.hostname, port: address.port, path: requestPath ?? address.pathname }, (response) => { let body = ''; response.on('data', (bytes: Buffer) => { body += bytes.toString('utf8'); }); response.on('end', () => resolve({ status: response.statusCode, contentType: response.headers['content-type'], body })); }).on('error', reject); }); } afterEach(async () => { for (const result of vi.mocked(spawn).mock.results) { if (result.type !== 'return') continue; const child = result.value; if (!child.pid || child.exitCode !== null || child.signalCode !== null) continue; const exited = once(child, 'exit').catch(() => undefined); if (process.platform !== 'win32') { try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill(); } } else child.kill(); await exited; } vi.mocked(spawn).mockClear(); for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve())); for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); }); describe('existing project work discovery', () => { it('finds a single nested Vite work, normalizing only recognized flags without needing installed dependencies', async () => { const root = await fixture(); await mkdir(path.join(root, 'game')); await writeFile(path.join(root, 'game', 'package.json'), JSON.stringify({ scripts: { dev: 'vite --host=0.0.0.0 --port=5179 --mode play --strictPort' } })); expect(await discoverWorkLaunch(root)).toEqual({ kind: 'vite', directory: 'game', args: ['--port', '5179', '--mode', 'play', '--strictPort'] }); }); it('does not discover generated nested copies underneath a recognized work', async () => { const root = await fixture(); await viteFixture(root, 'game'); await staticFixture(root, 'game/dist'); expect(await discoverWorkLaunch(root)).toMatchObject({ kind: 'vite', directory: 'game' }); }); it('requires help when separate existing works are ambiguous', async () => { const root = await fixture(); await staticFixture(root, 'first'); await staticFixture(root, 'second'); await expect(discoverWorkLaunch(root)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); }); it('ignores private directories and symlinked projects', async () => { const root = await fixture(); const outside = await fixture(); await staticFixture(root, '.makelore/cache'); await staticFixture(root, 'node_modules/template'); await staticFixture(outside); await symlink(outside, path.join(root, 'linked-game'), 'dir'); expect(await discoverWorkLaunch(root)).toBeNull(); }); it.each(['vite && touch owned', 'vite --open', 'npm run serve', 'vite --config ../../other.js'])('does not reinterpret unsupported script %s as a static website', async (dev) => { const root = await fixture(); await viteFixture(root, '.', dev); await expect(discoverWorkLaunch(root)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); expect(spawn).not.toHaveBeenCalled(); }); }); describe('model-free work launch', () => { it('reopens a stopped static work on the same saved local port with real HTTP readiness', async () => { const root = await fixture(); const spec = await staticFixture(root); const oldServer = createServer(); const previousUrl = await listen(oldServer); await new Promise((resolve) => oldServer.close(() => resolve())); const url = await startWorkLaunch({ id: 'game', path: root }, spec, previousUrl); expect(url).toBe(previousUrl); expect(await request(url)).toMatchObject({ status: 200, body: html }); expect(await request(url, '/main.js')).toMatchObject({ contentType: 'text/javascript; charset=utf-8', body: 'console.log("play")' }); expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); expect(await readFile(path.join(root, '.makelore', 'logs', 'static-preview.log'), 'utf8')).toContain('Makelore launch pid='); }); it('starts the installed Vite runtime directly without a shell and forces a loopback-only server', async () => { const root = await fixture(); await viteFixture(root); const spec = await discoverWorkLaunch(root); if (!spec) throw new Error('missing recipe'); const url = await startWorkLaunch({ id: 'game', path: root }, spec); expect(await request(url)).toMatchObject({ status: 200 }); expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); const canonicalRoot = await realpath(root); expect(spawn).toHaveBeenCalledWith(process.execPath, expect.any(Array), expect.objectContaining({ shell: false, detached: true, cwd: canonicalRoot })); const args = vi.mocked(spawn).mock.calls[0][1] as string[]; expect(args).toContain(path.join(canonicalRoot, 'node_modules', 'vite', 'bin', 'vite.js')); expect(args.slice(-5)).toEqual(['--host', '127.0.0.1', '--port', String(new URL(url).port), '--strictPort']); expect(args).not.toContain('npm'); }); it('serves a real installed Vite project using its configured base through the owned Node launcher', async () => { const root = await fixture(); await viteFixture(root, '.', 'vite'); await rm(path.join(root, 'node_modules', 'vite'), { recursive: true }); await symlink(await realpath(path.resolve('node_modules/vite')), path.join(root, 'node_modules', 'vite'), 'dir'); await writeFile(path.join(root, 'main.js'), 'document.body.dataset.playable = "yes"'); await writeFile(path.join(root, 'vite.config.js'), 'export default { base: "/game/" }'); const spec = await discoverWorkLaunch(root); const url = await startWorkLaunch({ id: 'game', path: root }, spec!); expect(new URL(url).pathname).toBe('/game/'); expect((await request(url)).body).toContain('/@vite/client'); expect((await request(url, '/game/main.js')).body).toContain('dataset.playable'); expect(await workLaunchMatches({ id: 'game', path: root }, spec!, url)).toBe(true); }); it('uses a free port and leaves an unrelated service at the saved port untouched', async () => { const root = await fixture(); const spec = await staticFixture(root); const unrelated = await listen(createServer((_req, response) => { response.setHeader('content-type', 'text/html'); response.end('Other project'); })); expect(await workLaunchMatches({ id: 'game', path: root }, spec, unrelated)).toBe(false); const url = await startWorkLaunch({ id: 'game', path: root }, spec, unrelated); expect(url).not.toBe(unrelated); expect((await request(unrelated)).body).toContain('Other project'); expect((await request(url)).body).toBe(html); }); it('reports missing dependencies without attempting an installation', async () => { const root = await fixture(); await viteFixture(root); const spec = await discoverWorkLaunch(root); await rm(path.join(root, 'node_modules'), { recursive: true }); await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_DEPENDENCIES_MISSING' }); expect(spawn).not.toHaveBeenCalled(); }); it('reports a failed local start rather than returning an address for an unready process', async () => { const root = await fixture(); await viteFixture(root); const spec = await discoverWorkLaunch(root); await writeFile(path.join(root, 'node_modules', 'vite', 'dist', 'node', 'index.js'), 'process.exit(12)'); await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_START_FAILED' }); }); it('rejects recipes after their package command or file location changes', async () => { const root = await fixture(); await viteFixture(root); const spec = await discoverWorkLaunch(root); await writeFile(path.join(root, 'package.json'), JSON.stringify({ scripts: { dev: 'vite && touch changed' } })); await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); await expect(startWorkLaunch({ id: 'game', path: root }, { kind: 'static', directory: '../outside', entry: 'index.html' })).rejects.toMatchObject({ code: 'WORK_PREVIEW_ENTRY_MISSING' }); expect(spawn).not.toHaveBeenCalled(); }); it('blocks traversal, private data and symlinked files in the static server', async () => { const root = await fixture(); const outside = await fixture(); const spec = await staticFixture(root); await writeFile(path.join(outside, 'secret.txt'), 'private'); await symlink(path.join(outside, 'secret.txt'), path.join(root, 'linked.txt')); const url = await startWorkLaunch({ id: 'game', path: root }, spec); expect((await request(url, '/%2e%2e/secret.txt')).status).toBe(403); expect((await request(url, '/linked.txt')).status).toBe(403); expect((await request(url, '/.makelore/logs/static-preview.log')).status).toBe(403); await rm(path.join(root, 'index.html')); await symlink(path.join(outside, 'secret.txt'), path.join(root, 'index.html')); await expect(startWorkLaunch({ id: 'game', path: root }, spec)).rejects.toMatchObject({ code: 'WORK_PREVIEW_ENTRY_MISSING' }); }); it('only stops its own newly started service and leaves unrelated local addresses alone', async () => { const root = await fixture(); const spec = await staticFixture(root); const project = { id: 'game', path: root }; const unrelated = await listen(createServer((_req, response) => response.end('Other work'))); await stopWorkLaunch(project, unrelated); expect((await request(unrelated)).body).toBe('Other work'); const url = await startWorkLaunch(project, spec); const child = vi.mocked(spawn).mock.results.at(-1)!.value; const exited = once(child, 'exit'); await stopWorkLaunch(project, url); await exited; await expect(request(url)).rejects.toBeDefined(); expect((await request(unrelated)).body).toBe('Other work'); }); }); describe('legacy Vite address identity', () => { it('rejects a different game sharing title and module URL, while accepting exact original TSX sourcesContent', async () => { const root = await fixture(); const expected = 'const App = () =>
My saved game
; export default App;'; const other = 'const App = () =>
A different game
; export default App;'; await writeFile(path.join(root, 'index.html'), html.replace('/main.js', '/main.tsx')); await writeFile(path.join(root, 'main.tsx'), expected); const spec: WorkLaunchSpec = { kind: 'vite', directory: '.', args: [] }; let servedSource = other; const url = await listen(createServer((req, response) => { if (req.url === '/main.tsx') { const map = Buffer.from(JSON.stringify({ sourcesContent: [servedSource] })).toString('base64'); response.setHeader('content-type', 'text/javascript'); response.end(`const App = transformedJSX();\n//# sourceMappingURL=data:application/json;base64,${map}`); } else { response.setHeader('content-type', 'text/html'); response.end(html.replace('/main.js', '/main.tsx').replace('', '')); } })); expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(false); servedSource = expected; expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); }); it('declines a shared bootstrap whose imported local game has not been verified', async () => { const root = await fixture(); const shared = 'import App from "./App.js"; document.body.append(App());'; await writeFile(path.join(root, 'index.html'), html); await writeFile(path.join(root, 'main.js'), shared); const url = await listen(createServer((req, response) => { response.setHeader('content-type', req.url === '/main.js' ? 'text/javascript' : 'text/html'); response.end(req.url === '/main.js' ? shared : html.replace('', '')); })); expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false); }); it('verifies original raw sources and rejects a different imported game behind an identical bootstrap', async () => { const root = await fixture(); const bootstrap = 'import App from "./App.js"; document.body.append(App());'; const expectedApp = 'export default () => "My saved game";'; let servedApp = 'export default () => "A different game";'; await writeFile(path.join(root, 'index.html'), html); await writeFile(path.join(root, 'main.js'), bootstrap); await writeFile(path.join(root, 'App.js'), expectedApp); const url = await listen(createServer((req, response) => { if (req.url?.endsWith('?raw')) { response.setHeader('content-type', 'text/javascript'); const original = req.url.startsWith('/main.js') ? bootstrap : servedApp; response.end(`export default ${JSON.stringify(original)};`); } else { response.setHeader('content-type', 'text/html'); response.end(html.replace('', '')); } })); const project = { id: 'game', path: root }; const spec: WorkLaunchSpec = { kind: 'vite', directory: '.', args: [] }; expect(await workLaunchMatches(project, spec, url)).toBe(false); servedApp = expectedApp; expect(await workLaunchMatches(project, spec, url)).toBe(true); }); it('declines unresolved local aliases instead of accepting an identical bootstrap for another game', async () => { const root = await fixture(); const source = 'import Game from "@/game"; document.body.append(Game());'; await writeFile(path.join(root, 'index.html'), html); await writeFile(path.join(root, 'main.js'), source); const url = await listen(createServer((req, response) => { if (req.url === '/main.js?raw') { response.setHeader('content-type', 'text/javascript'); response.end(`export default ${JSON.stringify(source)};`); } else { response.setHeader('content-type', 'text/html'); response.end(html.replace('', '')); } })); expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false); }); });