326 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|