Files
makelore/tests/unit/work-launcher.test.ts
T

326 lines
18 KiB
TypeScript

// @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<typeof import('node:child_process')>();
return { ...original, spawn: vi.fn(original.spawn) };
});
const directories: string[] = [];
const servers: Server[] = [];
const html = '<!doctype html><title>Saved game</title><h1>Play</h1><script type="module" src="/main.js"></script>';
async function fixture(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-launch-'));
directories.push(directory);
return directory;
}
async function staticFixture(root: string, directory = '.'): Promise<WorkLaunchSpec> {
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<void> {
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('</title>', '</title><script src="/@vite/client"></script>'))});
});
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<string> {
servers.push(server);
await new Promise<void>((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<void>((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<void>((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('<title>Other project</title>'); }));
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 = () => <main>My saved game</main>; export default App;';
const other = 'const App = () => <main>A different game</main>; 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('</title>', '</title><script src="/@vite/client"></script>'));
}
}));
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('</title>', '</title><script src="/@vite/client"></script>'));
}));
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('</title>', '</title><script src="/@vite/client"></script>'));
}
}));
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('</title>', '</title><script src="/@vite/client"></script>'));
}
}));
expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false);
});
});