mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* e2e(ui): cover examples preview flows * e2e(ui): cover Codex local CLI fallback UX * test: expand desktop and connector regression coverage * e2e(ui): cover workspace restoration flows * e2e(ui): cover retry recovery workspace flow * test: cover artifact and connector recovery flows * e2e(ui): cover Continue in CLI stale provenance flow * e2e(ui): cover BYOK model fetch caching * test: expand Orbit and desktop connector coverage * e2e(ui): cover workspace quick switcher recovery flows * e2e(ui): cover connector pending authorization recovery * e2e(ui): cover workspace and conversation restoration routes * e2e(ui): cover conversation draft and attachment restoration * e2e(ui): cover conversation history selection recovery * e2e(ui): cover workspace surface conversation selection * test: cover artifact presentation and orbit link behavior * test: cover artifact external link restoration * e2e(ui): cover root-route deep-link restoration * e2e(specs): cover Orbit open-artifact desktop click * e2e(specs): cover desktop artifact open link * test: fix Orbit settings fixture type drift * test: split Playwright critical and extended suites * test: fix ProjectView design template fixtures * ci: split workspace test stages * guard: allow split Playwright suite scripts * test: shrink Playwright critical suite * test: restore omitted Playwright suites
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { ProjectView, resolveSucceededRunStatus } from '../../src/components/ProjectView';
|
|
|
|
const listConversations = vi.fn();
|
|
const listMessages = vi.fn();
|
|
const fetchPreviewComments = vi.fn();
|
|
const loadTabs = vi.fn();
|
|
const fetchProjectFiles = vi.fn();
|
|
const fetchLiveArtifacts = vi.fn();
|
|
const fetchSkill = vi.fn();
|
|
const fetchDesignSystem = vi.fn();
|
|
const getTemplate = vi.fn();
|
|
const fetchChatRunStatus = vi.fn();
|
|
const listActiveChatRuns = vi.fn();
|
|
const reattachDaemonRun = vi.fn();
|
|
const saveMessage = vi.fn();
|
|
const createConversation = vi.fn();
|
|
const patchConversation = vi.fn();
|
|
const patchProject = vi.fn();
|
|
const saveTabs = vi.fn();
|
|
|
|
vi.mock('../../src/i18n', () => ({
|
|
useT: () => ((value: string) => value),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/anthropic', () => ({
|
|
streamMessage: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/daemon', () => ({
|
|
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
|
|
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
|
|
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
|
|
streamViaDaemon: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/registry', () => ({
|
|
deletePreviewComment: vi.fn(),
|
|
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
|
|
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
|
|
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
|
|
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
|
|
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
|
|
patchPreviewCommentStatus: vi.fn(),
|
|
upsertPreviewComment: vi.fn(),
|
|
writeProjectTextFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/project-events', () => ({
|
|
useProjectFileEvents: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/router', () => ({
|
|
navigate: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/state/projects', () => ({
|
|
createConversation: (...args: unknown[]) => createConversation(...args),
|
|
deleteConversation: vi.fn(),
|
|
getTemplate: (...args: unknown[]) => getTemplate(...args),
|
|
listConversations: (...args: unknown[]) => listConversations(...args),
|
|
listMessages: (...args: unknown[]) => listMessages(...args),
|
|
loadTabs: (...args: unknown[]) => loadTabs(...args),
|
|
patchConversation: (...args: unknown[]) => patchConversation(...args),
|
|
patchProject: (...args: unknown[]) => patchProject(...args),
|
|
saveMessage: (...args: unknown[]) => saveMessage(...args),
|
|
saveTabs: (...args: unknown[]) => saveTabs(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/components/AppChromeHeader', () => ({
|
|
AppChromeHeader: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/AvatarMenu', () => ({
|
|
AvatarMenu: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/ChatPane', () => ({
|
|
ChatPane: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/FileWorkspace', () => ({
|
|
FileWorkspace: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/Loading', () => ({
|
|
CenteredLoader: () => null,
|
|
}));
|
|
|
|
describe('ProjectView daemon cleanup', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('does not abort daemon cancel reattach controllers during unmount cleanup', async () => {
|
|
let seenCancelSignal: { aborted: boolean } | null = null;
|
|
let seenSignal: { aborted: boolean } | null = null;
|
|
|
|
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
|
listMessages.mockResolvedValue([
|
|
{
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
content: 'working',
|
|
createdAt: Date.now(),
|
|
runId: 'run-1',
|
|
runStatus: 'running',
|
|
},
|
|
]);
|
|
fetchPreviewComments.mockResolvedValue([]);
|
|
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
|
fetchProjectFiles.mockResolvedValue([]);
|
|
fetchLiveArtifacts.mockResolvedValue([]);
|
|
fetchSkill.mockResolvedValue(null);
|
|
fetchDesignSystem.mockResolvedValue(null);
|
|
getTemplate.mockResolvedValue(null);
|
|
fetchChatRunStatus.mockResolvedValue({
|
|
id: 'run-1',
|
|
status: 'running',
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
exitCode: null,
|
|
signal: null,
|
|
});
|
|
listActiveChatRuns.mockResolvedValue([]);
|
|
reattachDaemonRun.mockImplementation(async (options: { signal: { aborted: boolean }; cancelSignal?: { aborted: boolean } }) => {
|
|
seenSignal = options.signal;
|
|
seenCancelSignal = options.cancelSignal ?? null;
|
|
return new Promise<void>(() => {});
|
|
});
|
|
|
|
const view = render(
|
|
<ProjectView
|
|
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
|
|
routeFileName={null}
|
|
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
|
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
|
skills={[]}
|
|
designTemplates={[]}
|
|
designSystems={[]}
|
|
daemonLive
|
|
onModeChange={() => {}}
|
|
onAgentChange={() => {}}
|
|
onAgentModelChange={() => {}}
|
|
onRefreshAgents={() => {}}
|
|
onOpenSettings={() => {}}
|
|
onBack={() => {}}
|
|
onClearPendingPrompt={() => {}}
|
|
onTouchProject={() => {}}
|
|
onProjectChange={() => {}}
|
|
onProjectsRefresh={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
|
|
expect(seenSignal).not.toBeNull();
|
|
expect(seenCancelSignal).not.toBeNull();
|
|
|
|
view.unmount();
|
|
|
|
if (!seenSignal || !seenCancelSignal) throw new Error('Expected reattach signals to be captured');
|
|
expect((seenSignal as any).aborted).toBe(true);
|
|
expect((seenCancelSignal as any).aborted).toBe(false);
|
|
});
|
|
|
|
it('marks successful daemon completion as succeeded even before runId reaches message state', () => {
|
|
expect(resolveSucceededRunStatus('running')).toBe('succeeded');
|
|
expect(resolveSucceededRunStatus('queued')).toBe('succeeded');
|
|
expect(resolveSucceededRunStatus(undefined)).toBe('succeeded');
|
|
expect(resolveSucceededRunStatus('failed')).toBe('failed');
|
|
expect(resolveSucceededRunStatus('canceled')).toBe('canceled');
|
|
});
|
|
|
|
it('marks a recoverable daemon run as failed when the run status can no longer be fetched', async () => {
|
|
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
|
listMessages.mockResolvedValue([
|
|
{
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
content: 'working',
|
|
createdAt: Date.now(),
|
|
runId: 'run-1',
|
|
runStatus: 'running',
|
|
},
|
|
]);
|
|
fetchPreviewComments.mockResolvedValue([]);
|
|
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
|
fetchProjectFiles.mockResolvedValue([]);
|
|
fetchLiveArtifacts.mockResolvedValue([]);
|
|
fetchSkill.mockResolvedValue(null);
|
|
fetchDesignSystem.mockResolvedValue(null);
|
|
getTemplate.mockResolvedValue(null);
|
|
fetchChatRunStatus.mockResolvedValue(null);
|
|
listActiveChatRuns.mockResolvedValue([]);
|
|
reattachDaemonRun.mockImplementation(async () => {
|
|
throw new Error('reattach should not be called when the run is already gone');
|
|
});
|
|
|
|
render(
|
|
<ProjectView
|
|
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
|
|
routeFileName={null}
|
|
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
|
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
|
skills={[]}
|
|
designTemplates={[]}
|
|
designSystems={[]}
|
|
daemonLive
|
|
onModeChange={() => {}}
|
|
onAgentChange={() => {}}
|
|
onAgentModelChange={() => {}}
|
|
onRefreshAgents={() => {}}
|
|
onOpenSettings={() => {}}
|
|
onBack={() => {}}
|
|
onClearPendingPrompt={() => {}}
|
|
onTouchProject={() => {}}
|
|
onProjectChange={() => {}}
|
|
onProjectsRefresh={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(saveMessage).toHaveBeenCalledWith(
|
|
'project-1',
|
|
'conv-1',
|
|
expect.objectContaining({
|
|
id: 'msg-1',
|
|
runStatus: 'failed',
|
|
}),
|
|
undefined,
|
|
),
|
|
);
|
|
expect(reattachDaemonRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('persists a failed run when reattach emits an error', async () => {
|
|
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
|
listMessages.mockResolvedValue([
|
|
{
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
content: 'working',
|
|
createdAt: Date.now(),
|
|
runId: 'run-1',
|
|
runStatus: 'running',
|
|
},
|
|
]);
|
|
fetchPreviewComments.mockResolvedValue([]);
|
|
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
|
fetchProjectFiles.mockResolvedValue([]);
|
|
fetchLiveArtifacts.mockResolvedValue([]);
|
|
fetchSkill.mockResolvedValue(null);
|
|
fetchDesignSystem.mockResolvedValue(null);
|
|
getTemplate.mockResolvedValue(null);
|
|
fetchChatRunStatus.mockResolvedValue({
|
|
id: 'run-1',
|
|
status: 'running',
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
exitCode: null,
|
|
signal: null,
|
|
});
|
|
listActiveChatRuns.mockResolvedValue([]);
|
|
reattachDaemonRun.mockImplementation(async ({ handlers }: { handlers: { onError: (err: Error) => void } }) => {
|
|
handlers.onError(new Error('Agent stalled without emitting any new output for 120s.'));
|
|
});
|
|
|
|
render(
|
|
<ProjectView
|
|
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
|
|
routeFileName={null}
|
|
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
|
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
|
skills={[]}
|
|
designTemplates={[]}
|
|
designSystems={[]}
|
|
daemonLive
|
|
onModeChange={() => {}}
|
|
onAgentChange={() => {}}
|
|
onAgentModelChange={() => {}}
|
|
onRefreshAgents={() => {}}
|
|
onOpenSettings={() => {}}
|
|
onBack={() => {}}
|
|
onClearPendingPrompt={() => {}}
|
|
onTouchProject={() => {}}
|
|
onProjectChange={() => {}}
|
|
onProjectsRefresh={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(saveMessage).toHaveBeenCalledWith(
|
|
'project-1',
|
|
'conv-1',
|
|
expect.objectContaining({
|
|
id: 'msg-1',
|
|
runStatus: 'failed',
|
|
}),
|
|
expect.objectContaining({ telemetryFinalized: true }),
|
|
),
|
|
);
|
|
});
|
|
});
|