mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(web): localize quick brief prompt Generated-By: looper 0.8.1 (runner=worker, agent=codex) * fix(web): pass locale from design system chat Generated-By: looper 0.8.1 (runner=fixer, agent=codex) * fix(web): preserve task-type routing options Generated-By: looper 0.8.1 (runner=fixer, agent=codex) * fix(web): preserve task-type routing options Generated-By: looper 0.8.1 (runner=fixer, agent=codex)
218 lines
8 KiB
TypeScript
218 lines
8 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ProjectView } 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 listProjectRuns = vi.fn();
|
|
const reattachDaemonRun = vi.fn();
|
|
const deleteConversation = vi.fn();
|
|
const createConversation = vi.fn();
|
|
const patchConversation = vi.fn();
|
|
const patchProject = vi.fn();
|
|
const saveMessage = vi.fn();
|
|
const saveTabs = vi.fn();
|
|
|
|
// Capture the props ChatPane receives so the test can drive
|
|
// `onDeleteConversation` directly — ChatPane itself is mocked to a
|
|
// no-op renderer (the real component pulls in markdown + chat
|
|
// streaming machinery that isn't relevant to the projects-refresh
|
|
// regression we want to pin).
|
|
const chatPaneProps: { onDeleteConversation?: (id: string) => Promise<void> | void } = {};
|
|
|
|
vi.mock('../../src/i18n', () => ({
|
|
useI18n: () => ({
|
|
locale: 'en',
|
|
setLocale: () => undefined,
|
|
t: (value: string) => value,
|
|
}),
|
|
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),
|
|
listProjectRuns: (...args: unknown[]) => listProjectRuns(...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/router', () => ({
|
|
navigate: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/state/projects', () => ({
|
|
createConversation: (...args: unknown[]) => createConversation(...args),
|
|
deleteConversation: (...args: unknown[]) => deleteConversation(...args),
|
|
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: (props: { onDeleteConversation?: (id: string) => Promise<void> | void }) => {
|
|
chatPaneProps.onDeleteConversation = props.onDeleteConversation;
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/components/FileWorkspace', () => ({
|
|
FileWorkspace: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/Loading', () => ({
|
|
CenteredLoader: () => null,
|
|
}));
|
|
|
|
function renderProjectView(onProjectsRefresh: () => void) {
|
|
return 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={onProjectsRefresh}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
describe('ProjectView conversation delete', () => {
|
|
beforeEach(() => {
|
|
listProjectRuns.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
chatPaneProps.onDeleteConversation = undefined;
|
|
});
|
|
|
|
// Issue #1202: the home `Needs input` badge is rendered from the
|
|
// cached `/api/projects` payload (App.tsx owns the `projects` state).
|
|
// Deleting a conversation that owned an unanswered question-form
|
|
// flips the daemon-side flag, but without calling onProjectsRefresh
|
|
// here the home view keeps the stale flag until the next manual
|
|
// reload. All the other state-changing branches in ProjectView
|
|
// already call onProjectsRefresh (run end, live artifact events,
|
|
// etc.) — this pins that the delete-conversation branch joins them.
|
|
it('triggers onProjectsRefresh after deleting a conversation', async () => {
|
|
listConversations.mockResolvedValue([
|
|
{ id: 'conv-1', title: 'Conversation 1' },
|
|
{ id: 'conv-2', title: 'Conversation 2' },
|
|
]);
|
|
listMessages.mockResolvedValue([]);
|
|
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.mockResolvedValue(undefined);
|
|
deleteConversation.mockResolvedValue(true);
|
|
|
|
const onProjectsRefresh = vi.fn();
|
|
|
|
renderProjectView(onProjectsRefresh);
|
|
|
|
// ChatPane mount is async (ProjectView loads conversations in an
|
|
// effect, then renders chat). Wait for the mocked ChatPane to
|
|
// surface its `onDeleteConversation` prop.
|
|
await waitFor(() => expect(chatPaneProps.onDeleteConversation).toBeDefined());
|
|
|
|
await act(async () => {
|
|
await chatPaneProps.onDeleteConversation!('conv-1');
|
|
});
|
|
|
|
expect(deleteConversation).toHaveBeenCalledWith('project-1', 'conv-1');
|
|
expect(onProjectsRefresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Defensive complement: if the daemon delete fails, we must not
|
|
// pretend it succeeded — onProjectsRefresh would feed the home view
|
|
// a "deleted" state that isn't actually true on disk, putting the
|
|
// cache MORE out of sync than the bug we're fixing.
|
|
it('does not trigger onProjectsRefresh when the delete request fails', async () => {
|
|
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation 1' }]);
|
|
listMessages.mockResolvedValue([]);
|
|
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.mockResolvedValue(undefined);
|
|
deleteConversation.mockResolvedValue(false);
|
|
|
|
const onProjectsRefresh = vi.fn();
|
|
|
|
renderProjectView(onProjectsRefresh);
|
|
|
|
await waitFor(() => expect(chatPaneProps.onDeleteConversation).toBeDefined());
|
|
|
|
await act(async () => {
|
|
await chatPaneProps.onDeleteConversation!('conv-1');
|
|
});
|
|
|
|
expect(deleteConversation).toHaveBeenCalledWith('project-1', 'conv-1');
|
|
expect(onProjectsRefresh).not.toHaveBeenCalled();
|
|
});
|
|
});
|