open-design/apps/web/tests/components/ProjectView.run-cleanup.test.tsx
Siri-Ray 208f09c60e
fix: settle completed runs and clean up shutdown children (#924)
* fix: clean up completed and shutting down runs

* fix: bound daemon CLI shutdown

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix: harden daemon shutdown cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix: harden daemon shutdown cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* test: align acp abort fake with typed child
2026-05-08 21:05:22 +08:00

176 lines
6.1 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={[]}
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');
});
});