455 lines
28 KiB
TypeScript
455 lines
28 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { CodingWorkPreviewService, workPageResponds } from '../../electron/coding-runtime/work-preview';
|
|
import { inspectWorkPresence, type WorkPresence } from '../../electron/coding-runtime/work-presence';
|
|
import { OPEN_WORK_PROMPT } from '../../shared/coding-work-preview';
|
|
import { firstMessageTitle } from '../../electron/coding-runtime/conversation-title';
|
|
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;
|
|
function setup() {
|
|
const snapshot = { projectId: project.id, browserId: null, state: 'closed', url: '' } as AgentBrowserSnapshot;
|
|
const browser = { getSnapshot: vi.fn(async () => snapshot), open: vi.fn(async () => snapshot) };
|
|
const conversations = {
|
|
listConversations: vi.fn(async () => [conversation]),
|
|
getConversation: vi.fn(async () => conversation),
|
|
createConversation: vi.fn(async () => conversation),
|
|
getSnapshot: vi.fn(async () => ({ run: { status: 'idle', runId: 'work-run' } }) as ConversationSnapshot),
|
|
acceptPrompt: vi.fn(async () => ({ accepted: true as const, conversationId: 'c1', clientRequestId: 'work-preview-req', runId: 'work-run', mode: 'prompt' as const })),
|
|
};
|
|
const runtime = { getDiagnostics: vi.fn(() => ({ revision: { provider: 0, resources: 0 }, workers: [] })) };
|
|
const ensureActive = vi.fn(async () => {});
|
|
const responds = vi.fn(async () => false);
|
|
const inspectWork = vi.fn(async (_projectPath: string): Promise<WorkPresence> => 'possible');
|
|
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[] = [];
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })));
|
|
});
|
|
|
|
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-'));
|
|
directories.push(path);
|
|
const emptyProject = { ...project, path };
|
|
await mkdir(join(path, '.makelore'));
|
|
await mkdir(join(path, 'knowledge'));
|
|
await writeFile(join(path, '.makelore/project.json'), '{}');
|
|
await writeFile(join(path, '头像.png'), 'image');
|
|
s.inspectWork.mockImplementation(inspectWorkPresence);
|
|
s.conversations.listConversations.mockResolvedValue([]);
|
|
for (const requestId of ['req', 'req', 'new-click']) {
|
|
expect(await s.service.ensure(emptyProject, requestId)).toEqual({ status: 'empty' });
|
|
}
|
|
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
|
expect(s.conversations.getConversation).not.toHaveBeenCalled();
|
|
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
|
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
|
expect(s.browser.open).not.toHaveBeenCalled();
|
|
|
|
await mkdir(join(path, 'game'));
|
|
await writeFile(join(path, 'game/play.html'), '<h1>Game</h1>');
|
|
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);
|
|
});
|
|
|
|
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', true)).toMatchObject({ status: 'starting' });
|
|
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('waits for ongoing creation before showing an empty state', async () => {
|
|
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', true)).toEqual({ status: 'waiting' });
|
|
expect(s.inspectWork).not.toHaveBeenCalled();
|
|
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(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' }));
|
|
return 'empty';
|
|
});
|
|
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();
|
|
});
|
|
|
|
it('waits if creation starts during the local presence check', async () => {
|
|
const s = setup();
|
|
s.inspectWork.mockImplementation(async () => {
|
|
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', true)).toEqual({ status: 'waiting' });
|
|
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
|
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets a successful manual open supersede a pending startup without a late Agent request', async () => {
|
|
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', 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', true)).toMatchObject({ status: 'ready' });
|
|
finish({ run: { status: 'idle' } } as ConversationSnapshot);
|
|
expect(await pending).toMatchObject({ status: 'ready' });
|
|
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
|
});
|
|
it('restores a manually opened page after disposal without needing a model or sign-in', async () => {
|
|
const s = setup();
|
|
const healthy = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' };
|
|
s.service.remember(project, healthy);
|
|
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
|
|
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', true)).toMatchObject({ status: 'ready' });
|
|
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
|
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not restore a remembered address under another project or path', async () => {
|
|
const s = setup();
|
|
s.service.remember(project, { ...s.snapshot, browserId: 'manual', state: 'attached', url: 'http://localhost:3000' });
|
|
s.responds.mockResolvedValue(true);
|
|
await s.service.ensure({ ...project, path: '/different' }, 'req', 'c1');
|
|
expect(s.browser.open).not.toHaveBeenCalled();
|
|
expect(s.responds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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', 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', 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', 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', true)).toMatchObject({ status: 'ready', browser: s.snapshot });
|
|
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
|
expect(s.browser.open).not.toHaveBeenCalled();
|
|
expect(s.inspectWork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores a crashed preview while keeping a responding service', async () => {
|
|
const s = setup();
|
|
Object.assign(s.snapshot, { state: 'crashed', url: 'http://localhost:3000' });
|
|
s.responds.mockResolvedValue(true);
|
|
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();
|
|
});
|
|
|
|
it('restores the last healthy project page after background sleep disposed the native view', async () => {
|
|
const s = setup();
|
|
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', 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', 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();
|
|
});
|
|
|
|
it('does not reuse a remembered page for a different project path or an unavailable server', async () => {
|
|
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', true);
|
|
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
|
|
s.responds.mockResolvedValue(false);
|
|
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', 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', 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', 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', true)).toEqual({ status: 'waiting' });
|
|
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
|
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', 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', 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', 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', true);
|
|
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'idle', runId: 'old-run' } } as ConversationSnapshot);
|
|
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', 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(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();
|
|
});
|
|
|
|
it('does not turn the internal action into a conversation title', () => {
|
|
expect(firstMessageTitle({ kind: 'message', id: 'system-action', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'text', status: 'complete', text: OPEN_WORK_PROMPT }] })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('known work page health', () => {
|
|
it('only checks loopback HTTP pages and cancels the body', async () => {
|
|
const cancel = vi.fn();
|
|
const fetch = vi.fn(async () => ({ ok: true, body: { cancel } }));
|
|
vi.stubGlobal('fetch', fetch);
|
|
expect(await workPageResponds('http://127.0.0.1:3000/game')).toBe(true);
|
|
expect(cancel).toHaveBeenCalled();
|
|
expect(await workPageResponds('https://example.com')).toBe(false);
|
|
expect(await workPageResponds('file:///project/index.html')).toBe(false);
|
|
expect(await workPageResponds('http://me:secret@localhost')).toBe(false);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ redirect: 'manual', signal: expect.any(AbortSignal) }));
|
|
});
|
|
it('treats failed HTTP and connection errors as unavailable', async () => {
|
|
const fetch = vi.fn().mockResolvedValueOnce({ ok: false }).mockRejectedValueOnce(new Error('refused'));
|
|
vi.stubGlobal('fetch', fetch);
|
|
expect(await workPageResponds('http://localhost:3000')).toBe(false);
|
|
expect(await workPageResponds('http://localhost:3000')).toBe(false);
|
|
});
|
|
});
|