Files
makelore/tests/unit/work-entry-store.test.ts
T

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']);
});
});