Restore existing project works independently of model sessions
This commit is contained in:
1 parent
4eb3efa3d1
commit
0f26b6d072
19 files changed
+1977
-68
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { expect, getStableWindow, test } from './fixtures/electron';
|
||||
import type { WorkPreviewResult } from '../../shared/coding-work-preview';
|
||||
|
||||
async function hostRequest<T>(page: Page, path: string, body?: unknown): Promise<T> {
|
||||
const response = await page.evaluate(async (request) => await window.electron.ipcRenderer.invoke('hostapi:fetch', request) as {
|
||||
ok: boolean; data?: { ok: boolean; json?: unknown };
|
||||
}, { path, method: body === undefined ? 'GET' : 'POST', body });
|
||||
expect(response, `${path}: ${JSON.stringify(response)}`).toMatchObject({ ok: true, data: { ok: true } });
|
||||
return response.data!.json as T;
|
||||
}
|
||||
|
||||
test('existing work survives a full app restart and restarts a stopped service without choosing a model', async ({ launchElectronApp, electronTestResources }) => {
|
||||
const projectPath = join(electronTestResources.rootDir, 'remembered-work');
|
||||
await mkdir(projectPath);
|
||||
const html = '<!doctype html><title>Remembered project work</title><h1>My existing game</h1>';
|
||||
await writeFile(join(projectPath, 'index.html'), html);
|
||||
const server = createServer((_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.end(html);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Missing test port');
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
let serverClosed = false;
|
||||
try {
|
||||
const first = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(first);
|
||||
const { project } = await hostRequest<{ project: { id: string } }>(page, '/api/coding/projects/open', { projectPath });
|
||||
const listPath = `/api/coding/projects/conversations?project_id=${project.id}`;
|
||||
const before = await hostRequest<{ conversations: unknown[] }>(page, listPath);
|
||||
await page.evaluate(() => window.electron.ipcRenderer.invoke('lifecycle:activity', { visible: true, module: 'programming' }));
|
||||
await hostRequest(page, '/api/agent-browser/open', { project_id: project.id, url, visible: false,
|
||||
bounds: { x: 20, y: 70, width: 420, height: 300 } });
|
||||
const record = JSON.parse(await readFile(join(projectPath, '.makelore/work-entry.json'), 'utf8'));
|
||||
expect(record).toMatchObject({ projectId: project.id, url, launch: { kind: 'static', directory: '.', entry: 'index.html' } });
|
||||
await first.close();
|
||||
|
||||
const restarted = await launchElectronApp({ skipSetup: true });
|
||||
page = await getStableWindow(restarted);
|
||||
await hostRequest(page, '/api/coding/projects/open', { projectPath });
|
||||
const restored = await hostRequest<WorkPreviewResult>(page, '/api/agent-browser/ensure-work', { project_id: project.id, request_id: 'app-restarted' });
|
||||
expect(restored).toMatchObject({ status: 'ready', browser: { url, state: 'attached' } });
|
||||
expect(await hostRequest(page, listPath)).toEqual(before);
|
||||
expect(await restarted.evaluate(({ webContents }, expectedUrl) => webContents.getAllWebContents().some((contents) => contents.getURL() === expectedUrl && contents.getTitle() === 'Remembered project work'), url)).toBe(true);
|
||||
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
serverClosed = true;
|
||||
const started = await hostRequest<WorkPreviewResult>(page, '/api/agent-browser/ensure-work', { project_id: project.id, request_id: 'service-stopped' });
|
||||
expect(started).toMatchObject({ status: 'ready', browser: { state: 'attached' } });
|
||||
expect(await (await fetch(started.browser!.url)).text()).toBe(html);
|
||||
expect(await hostRequest(page, listPath)).toEqual(before);
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
await page.getByRole('tab', { name: '作品', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '刷新网页', exact: true })).toBeEnabled();
|
||||
await expect(page.getByText('作品还没有打开', { exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath('restored-work-without-model.png') });
|
||||
} finally {
|
||||
if (!serverClosed) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
const log = await readFile(join(projectPath, '.makelore/logs/static-preview.log'), 'utf8').catch(() => '');
|
||||
for (const match of log.matchAll(/Makelore launch pid=(\d+) /g)) {
|
||||
const pid = Number(match[1]);
|
||||
try { process.kill(process.platform === 'win32' ? pid : -pid, 'SIGTERM'); } catch { /* Test child has already exited. */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { firstMessageTitle } from '../../electron/coding-runtime/conversation-ti
|
||||
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
|
||||
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
|
||||
import type { CodingConversationV2 } from '../../electron/coding-projects/conversation-store';
|
||||
import type { WorkEntry, WorkLaunchSpec } from '../../electron/coding-runtime/work-entry-contracts';
|
||||
|
||||
const project = { id: 'p1', path: '/project' };
|
||||
const conversation = { id: 'c1', archivedAt: null } as CodingConversationV2;
|
||||
@@ -26,8 +27,11 @@ function setup() {
|
||||
const ensureActive = vi.fn(async () => {});
|
||||
const responds = vi.fn(async () => false);
|
||||
const inspectWork = vi.fn(async (_projectPath: string): Promise<WorkPresence> => 'possible');
|
||||
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork });
|
||||
return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot };
|
||||
const entries = { load: vi.fn(async (): Promise<WorkEntry | null> => null), save: vi.fn(async () => {}) };
|
||||
const launcher = { discover: vi.fn(async (): Promise<WorkLaunchSpec | null> => null), start: vi.fn(async () => 'http://127.0.0.1:5173/'), matches: vi.fn(async () => true), stop: vi.fn(async () => {}) };
|
||||
const signature = vi.fn(async () => 'a'.repeat(64));
|
||||
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork, entries, launcher, signature });
|
||||
return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot, entries, launcher, signature };
|
||||
}
|
||||
|
||||
const directories: string[] = [];
|
||||
@@ -37,6 +41,173 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('opening a work preview', () => {
|
||||
it('restores a persisted project page in a new service before any model or conversation access', async () => {
|
||||
const s = setup();
|
||||
s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', documentHash: 'a'.repeat(64) });
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'restored', state: 'attached', url: 'http://localhost:3000' });
|
||||
s.conversations.getSnapshot.mockRejectedValue(Object.assign(new Error('no model'), { code: 'CODING_MIGRATION_MODEL_REQUIRED' }));
|
||||
expect(await s.service.ensure(project, 'after-restart')).toMatchObject({ status: 'ready' });
|
||||
expect(s.conversations.listConversations).not.toHaveBeenCalled();
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restarts saved existing work locally once across simultaneous clicks without a model', async () => {
|
||||
const s = setup();
|
||||
const launch: WorkLaunchSpec = { kind: 'vite', directory: 'game', args: [] };
|
||||
s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', launch });
|
||||
s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'new', state: 'attached', url: 'http://127.0.0.1:5173/' });
|
||||
const results = await Promise.all([s.service.ensure(project, 'one'), s.service.ensure(project, 'two')]);
|
||||
expect(results.every((result) => result.status === 'ready')).toBe(true);
|
||||
expect(s.launcher.start).toHaveBeenCalledOnce();
|
||||
expect(s.launcher.start).toHaveBeenCalledWith(project, launch, 'http://localhost:3000');
|
||||
expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ launch }));
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers explicit help for an unknown entry and preserves the model-required error when help is chosen', async () => {
|
||||
const s = setup();
|
||||
expect(await s.service.ensure(project, 'automatic', 'c1')).toMatchObject({ status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP' });
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
s.conversations.getSnapshot.mockRejectedValue(Object.assign(new Error('no model'), { code: 'CODING_MIGRATION_MODEL_REQUIRED' }));
|
||||
expect(await s.service.ensure(project, 'help', 'c1', true)).toMatchObject({ status: 'failed', errorCode: 'CODING_MIGRATION_MODEL_REQUIRED', message: expect.stringContaining('选择一个可用模型') });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upgrades a legacy project from a successful browser receipt without starting its Agent', async () => {
|
||||
const s = setup();
|
||||
s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' });
|
||||
const receipt = { ...s.snapshot, browserId: 'previous', state: 'attached', url: 'http://localhost:3000' };
|
||||
const storedSnapshot = vi.fn(async () => ({ conversation: { projectId: project.id }, nodes: [{ kind: 'tool', status: 'complete', toolName: 'agent_browser', details: { schema: 'agent-browser.v1', action: 'open' }, output: [{ kind: 'text', text: JSON.stringify(receipt) }] }] }) as ConversationSnapshot);
|
||||
const service = new CodingWorkPreviewService({ ...s, entries: s.entries, launcher: s.launcher, storedSnapshot });
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.browser.open.mockResolvedValue(receipt as AgentBrowserSnapshot);
|
||||
expect(await service.ensure(project, 'upgrade')).toMatchObject({ status: 'ready' });
|
||||
expect(storedSnapshot).toHaveBeenCalledWith('c1');
|
||||
expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: receipt.url }));
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a local startup failure without dispatching a model request', async () => {
|
||||
const s = setup();
|
||||
s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' });
|
||||
s.launcher.start.mockRejectedValue(Object.assign(new Error('missing entry'), { code: 'WORK_PREVIEW_ENTRY_MISSING' }));
|
||||
expect(await s.service.ensure(project, 'open')).toMatchObject({ status: 'failed', errorCode: 'WORK_PREVIEW_ENTRY_MISSING', message: expect.stringContaining('入口找不到') });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves a verified address even when its launch method cannot be inferred', async () => {
|
||||
const s = setup();
|
||||
s.launcher.discover.mockRejectedValue(Object.assign(new Error('two works'), { code: 'WORK_PREVIEW_NEEDS_HELP' }));
|
||||
await s.service.remember(project, { ...s.snapshot, browserId: 'open', state: 'attached', url: 'http://localhost:3000' });
|
||||
expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: 'http://localhost:3000' }));
|
||||
});
|
||||
|
||||
it('lets explicit assistant help inspect an ambiguous or failed local launch', async () => {
|
||||
const s = setup();
|
||||
s.launcher.discover.mockRejectedValue(Object.assign(new Error('two works'), { code: 'WORK_PREVIEW_NEEDS_HELP' }));
|
||||
expect(await s.service.ensure(project, 'automatic', 'c1')).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(await s.service.ensure(project, 'help', 'c1', true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps a newly started address for its own project when the student switches away', async () => {
|
||||
const s = setup();
|
||||
s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' });
|
||||
s.launcher.start.mockImplementation(async () => {
|
||||
s.ensureActive.mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' }));
|
||||
return 'http://127.0.0.1:5173/';
|
||||
});
|
||||
await expect(s.service.ensure(project, 'open')).rejects.toThrow('project changed');
|
||||
expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: 'http://127.0.0.1:5173/' }));
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
s.ensureActive.mockResolvedValue();
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'restored', state: 'attached', url: 'http://127.0.0.1:5173/' });
|
||||
expect(await s.service.ensure(project, 'return')).toMatchObject({ status: 'ready' });
|
||||
expect(s.launcher.start).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('preserves a manual open that supersedes a slow local startup', async () => {
|
||||
const s = setup();
|
||||
const launch: WorkLaunchSpec = { kind: 'static', directory: '.', entry: 'index.html' };
|
||||
s.launcher.discover.mockResolvedValue(launch);
|
||||
let finish!: (url: string) => void;
|
||||
s.launcher.start.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
|
||||
const pending = s.service.ensure(project, 'open');
|
||||
await vi.waitFor(() => expect(s.launcher.start).toHaveBeenCalled());
|
||||
const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' };
|
||||
await s.service.remember(project, manual, launch);
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.browser.open.mockResolvedValue(manual);
|
||||
finish('http://127.0.0.1:5173/');
|
||||
expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } });
|
||||
expect(s.launcher.stop).toHaveBeenCalledWith(project, 'http://127.0.0.1:5173/');
|
||||
expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: manual.url }));
|
||||
});
|
||||
|
||||
it('queues an explicit helper choice behind an already pending automatic check', async () => {
|
||||
const s = setup();
|
||||
let finish!: () => void;
|
||||
s.inspectWork.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve('possible'); }));
|
||||
const automatic = s.service.ensure(project, 'automatic', 'c1');
|
||||
await vi.waitFor(() => expect(s.inspectWork).toHaveBeenCalled());
|
||||
const help = s.service.ensure(project, 'help', 'c1', true);
|
||||
finish();
|
||||
expect(await automatic).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' });
|
||||
expect(await help).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not restore an unknown saved page whose content no longer matches', async () => {
|
||||
const s = setup();
|
||||
s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', documentHash: 'b'.repeat(64) });
|
||||
s.responds.mockResolvedValue(true);
|
||||
expect(await s.service.ensure(project, 'open')).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' });
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a newer manual page when a slow saved-address probe completes later', async () => {
|
||||
const s = setup();
|
||||
s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000/old', documentHash: 'a'.repeat(64) });
|
||||
let finish!: (value: boolean) => void;
|
||||
s.responds.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(true);
|
||||
const pending = s.service.ensure(project, 'restore');
|
||||
await vi.waitFor(() => expect(s.responds).toHaveBeenCalled());
|
||||
const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000/new' };
|
||||
await s.service.remember(project, manual);
|
||||
s.browser.open.mockResolvedValue(manual);
|
||||
finish(true);
|
||||
expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } });
|
||||
expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: manual.url }));
|
||||
expect(s.browser.open).not.toHaveBeenCalledWith(expect.objectContaining({ url: 'http://localhost:3000/old' }));
|
||||
});
|
||||
|
||||
it('does not stop the new server when a manual page uses another path on that same server', async () => {
|
||||
const s = setup();
|
||||
const launch: WorkLaunchSpec = { kind: 'static', directory: '.', entry: 'index.html' };
|
||||
s.launcher.discover.mockResolvedValue(launch);
|
||||
let finish!: (url: string) => void;
|
||||
s.launcher.start.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
|
||||
const pending = s.service.ensure(project, 'open');
|
||||
await vi.waitFor(() => expect(s.launcher.start).toHaveBeenCalled());
|
||||
const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://127.0.0.1:5173/play?level=2' };
|
||||
await s.service.remember(project, manual, launch);
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.browser.open.mockResolvedValue(manual);
|
||||
finish('http://127.0.0.1:5173/');
|
||||
expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } });
|
||||
expect(s.launcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps repeated empty-project clicks out of conversations and discovers work made later', async () => {
|
||||
const s = setup();
|
||||
const path = await mkdtemp(join(tmpdir(), 'makelore-empty-preview-'));
|
||||
@@ -59,7 +230,7 @@ describe('opening a work preview', () => {
|
||||
|
||||
await mkdir(join(path, 'game'));
|
||||
await writeFile(join(path, 'game/play.html'), '<h1>Game</h1>');
|
||||
expect(await s.service.ensure(emptyProject, 'new-click')).toMatchObject({ status: 'starting' });
|
||||
expect(await s.service.ensure(emptyProject, 'new-click', undefined, true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.createConversation).toHaveBeenCalledTimes(1);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -67,7 +238,7 @@ describe('opening a work preview', () => {
|
||||
it('preserves startup when a local check cannot establish absence', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockResolvedValue('unknown');
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -75,19 +246,19 @@ describe('opening a work preview', () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockResolvedValue('empty');
|
||||
s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' });
|
||||
expect(s.inspectWork).not.toHaveBeenCalled();
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'empty' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'empty' });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revalidates the active project after an asynchronous presence check', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockImplementation(async () => {
|
||||
s.ensureActive.mockRejectedValue(new Error('project changed'));
|
||||
s.ensureActive.mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' }));
|
||||
return 'empty';
|
||||
});
|
||||
await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed');
|
||||
await expect(s.service.ensure(project, 'req', 'c1', true)).rejects.toThrow('project changed');
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -98,7 +269,7 @@ describe('opening a work preview', () => {
|
||||
s.runtime.getDiagnostics.mockReturnValue({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
return 'empty';
|
||||
});
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' });
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -107,13 +278,13 @@ describe('opening a work preview', () => {
|
||||
const s = setup();
|
||||
let finish!: (value: ConversationSnapshot) => void;
|
||||
s.conversations.getSnapshot.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
|
||||
const pending = s.service.ensure(project, 'req', 'c1');
|
||||
const pending = s.service.ensure(project, 'req', 'c1', true);
|
||||
await vi.waitFor(() => expect(s.conversations.getSnapshot).toHaveBeenCalled());
|
||||
const healthy = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' };
|
||||
s.service.remember(project, healthy);
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.browser.open.mockResolvedValue(healthy);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' });
|
||||
finish({ run: { status: 'idle' } } as ConversationSnapshot);
|
||||
expect(await pending).toMatchObject({ status: 'ready' });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
@@ -126,7 +297,7 @@ describe('opening a work preview', () => {
|
||||
s.browser.open.mockResolvedValue({ ...healthy, generation: 2 });
|
||||
s.responds.mockResolvedValue(true);
|
||||
s.conversations.getSnapshot.mockRejectedValue(new Error('login required'));
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' });
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -142,19 +313,19 @@ describe('opening a work preview', () => {
|
||||
|
||||
it('returns the login recovery cause after an accepted action fails, without automatically resending', async () => {
|
||||
const s = setup();
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'error', runId: 'work-run', error: { code: 'CODING_PROVIDER_AUTH_REQUIRED' } } } as ConversationSnapshot);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') });
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') });
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
await s.service.ensure(project, 'after-login', 'c1');
|
||||
await s.service.ensure(project, 'after-login', 'c1', true);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('reuses a healthy existing page without invoking the Agent or reopening the browser', async () => {
|
||||
const s = setup();
|
||||
Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' });
|
||||
s.responds.mockResolvedValue(true);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: s.snapshot });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready', browser: s.snapshot });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
expect(s.inspectWork).not.toHaveBeenCalled();
|
||||
@@ -164,7 +335,7 @@ describe('opening a work preview', () => {
|
||||
const s = setup();
|
||||
Object.assign(s.snapshot, { state: 'crashed', url: 'http://localhost:3000' });
|
||||
s.responds.mockResolvedValue(true);
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: s.snapshot.url, projectPath: project.path }));
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -174,10 +345,10 @@ describe('opening a work preview', () => {
|
||||
const healthy = { ...s.snapshot, browserId: 'b1', state: 'attached' as const, url: 'http://localhost:3000' };
|
||||
s.browser.getSnapshot.mockResolvedValue(healthy);
|
||||
s.responds.mockResolvedValue(true);
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
|
||||
s.browser.open.mockResolvedValue({ ...healthy, browserId: 'restored', generation: 2 });
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } });
|
||||
expect(s.browser.open).toHaveBeenCalledWith({ projectId: 'p1', projectPath: '/project', url: healthy.url, visible: false });
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
@@ -187,72 +358,72 @@ describe('opening a work preview', () => {
|
||||
const s = setup();
|
||||
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, browserId: 'b1', state: 'attached', url: 'http://localhost:3000' });
|
||||
s.responds.mockResolvedValue(true);
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
|
||||
s.responds.mockResolvedValue(false);
|
||||
expect(await s.service.ensure(project, 'retry', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(await s.service.ensure(project, 'retry', 'c1', true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends the fixed action once across simultaneous clicks and later polls', async () => {
|
||||
const s = setup();
|
||||
await Promise.all([s.service.ensure(project, 'req', 'c1'), s.service.ensure(project, 'other', 'c1')]);
|
||||
await Promise.all([s.service.ensure(project, 'req', 'c1', true), s.service.ensure(project, 'other', 'c1', true)]);
|
||||
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'running', runId: 'work-run' } } as ConversationSnapshot);
|
||||
expect(await s.service.ensure(project, 'another', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(await s.service.ensure(project, 'another', 'c1', true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledWith({ conversationId: 'c1', clientRequestId: 'work-preview-req', mode: 'prompt', text: OPEN_WORK_PROMPT });
|
||||
Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' });
|
||||
s.responds.mockResolvedValue(true);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('waits for current project work and then starts without steering the active run', async () => {
|
||||
const s = setup();
|
||||
s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('settles failures without resubmission until an explicit retry', async () => {
|
||||
const s = setup();
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' });
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
await s.service.ensure(project, 'retry', 'c1');
|
||||
await s.service.ensure(project, 'retry', 'c1', true);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not automatically resend an uncertain prompt acceptance', async () => {
|
||||
const s = setup();
|
||||
s.conversations.acceptPrompt.mockRejectedValue(new Error('unknown receipt'));
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' });
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' });
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for the accepted run instead of mistaking the preceding idle snapshot for completion', async () => {
|
||||
const s = setup();
|
||||
await s.service.ensure(project, 'req', 'c1');
|
||||
await s.service.ensure(project, 'req', 'c1', true);
|
||||
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'idle', runId: 'old-run' } } as ConversationSnapshot);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates a conversation if needed without submitting a student draft', async () => {
|
||||
const s = setup();
|
||||
s.conversations.listConversations.mockResolvedValue([]);
|
||||
expect(await s.service.ensure(project, 'req')).toMatchObject({ status: 'starting', conversation });
|
||||
expect(await s.service.ensure(project, 'req', undefined, true)).toMatchObject({ status: 'starting', conversation });
|
||||
expect(s.conversations.createConversation).toHaveBeenCalledWith({ projectId: 'p1', title: '新对话' });
|
||||
});
|
||||
|
||||
it('cancels dispatch when the project changes during a browser check', async () => {
|
||||
const s = setup();
|
||||
s.ensureActive.mockResolvedValueOnce().mockRejectedValue(new Error('project changed'));
|
||||
await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed');
|
||||
s.ensureActive.mockResolvedValueOnce().mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' }));
|
||||
await expect(s.service.ensure(project, 'req', 'c1', true)).rejects.toThrow('project changed');
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useWorkPreview } from '@/pages/Chat/use-work-preview';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
const ensure = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/coding-work-preview', () => ({ ensureWorkPreview: ensure }));
|
||||
beforeEach(() => { vi.useFakeTimers(); ensure.mockReset(); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('work preview request lifecycle', () => {
|
||||
it('keeps the real model-required reason from the Host error instead of a generic retry', async () => {
|
||||
ensure.mockRejectedValue(new AppError('UNKNOWN', 'backend failure', undefined, { backendCode: 'CODING_MIGRATION_MODEL_REQUIRED' }));
|
||||
const { result } = renderHook(() => useWorkPreview({ projectId: 'p1', enabled: true, onResult: vi.fn() }));
|
||||
await act(async () => {});
|
||||
expect(result.current.result).toMatchObject({ status: 'failed', errorCode: 'CODING_MIGRATION_MODEL_REQUIRED', message: expect.stringContaining('选择一个可用模型') });
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000));
|
||||
expect(ensure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('permits assistant help only after an explicit choice and only for that action', async () => {
|
||||
ensure.mockResolvedValue({ status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP' });
|
||||
const { result, rerender } = renderHook(({ revision }) => useWorkPreview({ projectId: 'p1', conversationId: 'c1', enabled: true, revision, onResult: vi.fn() }), { initialProps: { revision: 0 } });
|
||||
await act(async () => {});
|
||||
expect(ensure.mock.calls[0]).toHaveLength(3);
|
||||
await act(async () => result.current.requestHelp());
|
||||
expect(ensure.mock.calls[1]).toEqual(['p1', expect.any(String), 'c1', true]);
|
||||
await act(async () => rerender({ revision: 1 }));
|
||||
expect(ensure.mock.calls[2]).toHaveLength(3);
|
||||
});
|
||||
it('stops polling an empty project and checks again when the work tab is reopened', async () => {
|
||||
ensure.mockResolvedValue({ status: 'empty' });
|
||||
const onResult = vi.fn();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user