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 { 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 { 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 { 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'), '

Root

'); 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'), '

Alice

'); 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'), '

Game

'); 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'), '

Root

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