257 lines
13 KiB
TypeScript
257 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { loadWorkEntry, saveWorkEntry } from '../../electron/coding-runtime/work-entry-store';
|
|
import type { WorkEntry, WorkEntryProject } from '../../electron/coding-runtime/work-entry-contracts';
|
|
|
|
const directories: string[] = [];
|
|
|
|
async function project(): Promise<WorkEntryProject> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-entry-'));
|
|
directories.push(directory);
|
|
await mkdir(path.join(directory, '.makelore'));
|
|
return { id: `p-${directories.length}`, path: await realpath(directory) };
|
|
}
|
|
|
|
function entry(target: WorkEntryProject, patch: Partial<WorkEntry> = {}): WorkEntry {
|
|
return {
|
|
version: 1, projectId: target.id, projectPath: target.path,
|
|
url: 'http://127.0.0.1:5179/play?level=2#game', ...patch,
|
|
};
|
|
}
|
|
|
|
function entryFile(target: WorkEntryProject): string {
|
|
return path.join(target.path, '.makelore/work-entry.json');
|
|
}
|
|
|
|
async function corrupt(target: WorkEntryProject, value: unknown): Promise<void> {
|
|
await writeFile(entryFile(target), JSON.stringify(value));
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.resetModules();
|
|
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe('project work entry persistence', () => {
|
|
it('reloads a project-bound work address from disk after a fresh module instance', async () => {
|
|
const target = await project();
|
|
const value = entry(target);
|
|
await saveWorkEntry(target, value);
|
|
vi.resetModules();
|
|
const fresh = await import('../../electron/coding-runtime/work-entry-store');
|
|
expect(await fresh.loadWorkEntry(target)).toEqual(value);
|
|
expect(JSON.parse(await readFile(entryFile(target), 'utf8'))).toEqual(value);
|
|
expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']);
|
|
});
|
|
|
|
it('preserves an optional document fingerprint through an atomic save and fresh reload', async () => {
|
|
const target = await project();
|
|
const value = { ...entry(target), documentHash: '0123456789abcdef'.repeat(4) };
|
|
await saveWorkEntry(target, value);
|
|
vi.resetModules();
|
|
const fresh = await import('../../electron/coding-runtime/work-entry-store');
|
|
expect(await fresh.loadWorkEntry(target)).toEqual(value);
|
|
expect(JSON.parse(await readFile(entryFile(target), 'utf8')).documentHash).toBe(value.documentHash);
|
|
});
|
|
|
|
it.each([null, 123, '', 'a'.repeat(63), 'a'.repeat(65), 'A'.repeat(64), 'g'.repeat(64)])('rejects an invalid document fingerprint: %j', async (documentHash) => {
|
|
const target = await project();
|
|
const value = { ...entry(target), documentHash };
|
|
await corrupt(target, value);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, value as unknown as WorkEntry)).rejects.toThrow();
|
|
});
|
|
|
|
it('binds the saved entry to the canonical project root', async () => {
|
|
const target = await project();
|
|
const alias = path.join(target.path, 'project-alias');
|
|
await symlink(target.path, alias, 'dir');
|
|
const viaAlias = { ...target, path: alias };
|
|
await saveWorkEntry(viaAlias, entry(viaAlias));
|
|
expect(await loadWorkEntry(target)).toEqual(entry(target));
|
|
expect(await loadWorkEntry(viaAlias)).toEqual(entry(target));
|
|
});
|
|
|
|
it('persists safe Vite options for a nested work directory', async () => {
|
|
const target = await project();
|
|
await mkdir(path.join(target.path, 'Alice 的游戏'));
|
|
const value = entry(target, { launch: {
|
|
kind: 'vite', directory: './Alice 的游戏',
|
|
args: ['--base', '/game/', '--mode', 'development', '--port', '5179', '--strictPort', '--force'],
|
|
} });
|
|
await saveWorkEntry(target, value);
|
|
expect(await loadWorkEntry(target)).toEqual({ ...value, launch: { ...value.launch, directory: 'Alice 的游戏' } });
|
|
});
|
|
|
|
it('accepts a root Vite work and a nested static entry without executing a recipe', async () => {
|
|
const target = await project();
|
|
const vite = entry(target, { launch: { kind: 'vite', directory: '.', args: ['--base', './'] } });
|
|
await saveWorkEntry(target, vite);
|
|
expect(await loadWorkEntry(target)).toEqual(vite);
|
|
await writeFile(path.join(target.path, 'index.html'), '<h1>Root</h1>');
|
|
const rootStatic = entry(target, { launch: { kind: 'static', directory: '.', entry: 'index.html' } });
|
|
await saveWorkEntry(target, rootStatic);
|
|
expect(await loadWorkEntry(target)).toEqual(rootStatic);
|
|
await mkdir(path.join(target.path, 'game/pages'), { recursive: true });
|
|
await writeFile(path.join(target.path, 'game/pages/index.html'), '<h1>Alice</h1>');
|
|
const staticEntry = entry(target, { launch: { kind: 'static', directory: 'game', entry: 'pages/index.html' } });
|
|
await saveWorkEntry(target, staticEntry);
|
|
expect(await loadWorkEntry(target)).toEqual(staticEntry);
|
|
});
|
|
|
|
it('rejects copied entries with a different project id or root', async () => {
|
|
const source = await project();
|
|
const destination = await project();
|
|
await saveWorkEntry(source, entry(source));
|
|
await copyFile(entryFile(source), entryFile(destination));
|
|
expect(await loadWorkEntry(destination)).toBeNull();
|
|
await corrupt(destination, entry(source, { projectId: destination.id }));
|
|
expect(await loadWorkEntry(destination)).toBeNull();
|
|
await expect(saveWorkEntry(destination, entry(source))).rejects.toThrow();
|
|
await expect(saveWorkEntry(destination, entry(source, { projectId: destination.id }))).rejects.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
'https://example.com/game', 'http://localhost.example.com/game', 'file:///project/index.html',
|
|
'http://user:password@localhost:5179/', 'http://192.168.1.2:5179/', 'http://localhost:5179/\n',
|
|
`http://localhost:5179/${'a'.repeat(2_048)}`,
|
|
])('rejects an unsafe work URL: %s', async (url) => {
|
|
const target = await project();
|
|
const value = entry(target, { url });
|
|
await corrupt(target, value);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, value)).rejects.toThrow();
|
|
});
|
|
|
|
it.each(['http://localhost:5179/', 'https://127.0.0.1:5179/', 'http://[::1]:5179/index.html'])('accepts loopback HTTP addresses: %s', async (url) => {
|
|
const target = await project();
|
|
await saveWorkEntry(target, entry(target, { url }));
|
|
expect((await loadWorkEntry(target))?.url).toBe(url);
|
|
});
|
|
|
|
it('ignores missing, malformed, unsupported and oversized records', async () => {
|
|
const target = await project();
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
for (const text of ['not JSON', '{}', JSON.stringify({ ...entry(target), version: 2 }), ' '.repeat(16 * 1024 + 1)]) {
|
|
await writeFile(entryFile(target), text);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
}
|
|
await writeFile(entryFile(target), Buffer.from([0xff, 0xfe]));
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await rm(entryFile(target));
|
|
await mkdir(entryFile(target));
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, entry(target))).rejects.toThrow();
|
|
});
|
|
|
|
it('leaves missing metadata absent on reads and rejects arbitrary launch commands', async () => {
|
|
const target = await project();
|
|
await corrupt(target, { ...entry(target), launch: { kind: 'shell', command: 'npm run dev' } });
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await corrupt(target, { ...entry(target), launch: { kind: 'vite', directory: '.', args: [], command: 'npm run dev' } });
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await rm(path.join(target.path, '.makelore'), { recursive: true });
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, entry(target, { url: 'https://example.com/' }))).rejects.toThrow();
|
|
expect(await readdir(target.path)).toEqual([]);
|
|
});
|
|
|
|
it('creates metadata safely when saving an imported folder and reloads the entry', async () => {
|
|
const target = await project();
|
|
await rm(path.join(target.path, '.makelore'), { recursive: true });
|
|
const value = entry(target);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
expect(await readdir(target.path)).toEqual([]);
|
|
await saveWorkEntry(target, value);
|
|
expect(await loadWorkEntry(target)).toEqual(value);
|
|
expect(await readdir(target.path)).toEqual(['.makelore']);
|
|
expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']);
|
|
});
|
|
|
|
it('rejects a colliding metadata file instead of replacing it with a directory', async () => {
|
|
const target = await project();
|
|
await rm(path.join(target.path, '.makelore'), { recursive: true });
|
|
await writeFile(path.join(target.path, '.makelore'), 'Existing user file');
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, entry(target))).rejects.toThrow();
|
|
expect(await readFile(path.join(target.path, '.makelore'), 'utf8')).toBe('Existing user file');
|
|
});
|
|
|
|
it('rejects metadata directory and entry symlinks without changing the outside file', async () => {
|
|
const target = await project();
|
|
const outside = await project();
|
|
await saveWorkEntry(outside, entry(outside));
|
|
const outsideBefore = await readFile(entryFile(outside), 'utf8');
|
|
await symlink(entryFile(outside), entryFile(target), 'file');
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, entry(target))).rejects.toThrow();
|
|
await rm(entryFile(target));
|
|
await rm(path.join(target.path, '.makelore'), { recursive: true });
|
|
await symlink(path.join(outside.path, '.makelore'), path.join(target.path, '.makelore'), 'dir');
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, entry(target))).rejects.toThrow();
|
|
expect(await readFile(entryFile(outside), 'utf8')).toBe(outsideBefore);
|
|
});
|
|
|
|
it.each(['../outside', '/absolute', 'C:/outside', 'game/../../outside', 'game\\outside'])('rejects recipe directory path escape: %s', async (directory) => {
|
|
const target = await project();
|
|
const value = entry(target, { launch: { kind: 'vite', directory, args: [] } });
|
|
await corrupt(target, value);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, value)).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects launch directory and static entry symlinks, including in-project links', async () => {
|
|
const target = await project();
|
|
await mkdir(path.join(target.path, 'game'));
|
|
await writeFile(path.join(target.path, 'game/index.html'), '<h1>Game</h1>');
|
|
await symlink(path.join(target.path, 'game'), path.join(target.path, 'linked-game'), 'dir');
|
|
const directoryLink = entry(target, { launch: { kind: 'vite', directory: 'linked-game', args: [] } });
|
|
await corrupt(target, directoryLink);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, directoryLink)).rejects.toThrow();
|
|
await symlink(path.join(target.path, 'game/index.html'), path.join(target.path, 'game/linked.html'), 'file');
|
|
const entryLink = entry(target, { launch: { kind: 'static', directory: 'game', entry: 'linked.html' } });
|
|
await corrupt(target, entryLink);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, entryLink)).rejects.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
['--config', 'outside.config.js'], ['--host', '0.0.0.0'], ['--open'], ['--port', '0'],
|
|
['--port', '65536'], ['--mode', 'dev; touch outside'], ['--base', 'https://example.com/'],
|
|
['--base', '//example.com/'], ['--base', '/../outside'], ['--port', '5179', '--port', '5199'],
|
|
['--mode'], ['game'],
|
|
])('rejects unsupported or unsafe persisted Vite args: %j', async (...args) => {
|
|
const target = await project();
|
|
const value = entry(target, { launch: { kind: 'vite', directory: '.', args } });
|
|
await corrupt(target, value);
|
|
expect(await loadWorkEntry(target)).toBeNull();
|
|
await expect(saveWorkEntry(target, value)).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects a static entry escaping its directory and preserves the last complete valid entry', async () => {
|
|
const target = await project();
|
|
await mkdir(path.join(target.path, 'game'));
|
|
await writeFile(path.join(target.path, 'index.html'), '<h1>Root</h1>');
|
|
const valid = entry(target);
|
|
await saveWorkEntry(target, valid);
|
|
const invalid = entry(target, { launch: { kind: 'static', directory: 'game', entry: '../index.html' } });
|
|
await expect(saveWorkEntry(target, invalid)).rejects.toThrow();
|
|
expect(await loadWorkEntry(target)).toEqual(valid);
|
|
expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']);
|
|
});
|
|
|
|
it('publishes complete records during concurrent writes and removes temporary files', async () => {
|
|
const target = await project();
|
|
await rm(path.join(target.path, '.makelore'), { recursive: true });
|
|
await Promise.all([5179, 5199, 5200].map((port) => saveWorkEntry(target, entry(target, { url: `http://localhost:${port}/` }))));
|
|
const loaded = await loadWorkEntry(target);
|
|
expect(loaded).toMatchObject({ projectId: target.id, projectPath: target.path });
|
|
expect(['http://localhost:5179/', 'http://localhost:5199/', 'http://localhost:5200/']).toContain(loaded?.url);
|
|
expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']);
|
|
});
|
|
});
|