open-design/apps/web/tests/components/ProjectView.run-isolation.test.tsx
Eli-tangerine 8193981511
Keep PR 2400 changes without folder pickers (#2462)
* feat(daemon): add project working directory management and editor hand-off functionality

- Introduced new flags for project commands to manage working directories, including `--working-dir` and `--dir`.
- Implemented API routes for listing available editors and opening projects in selected editors.
- Added a hand-off button in the ChatPane header to facilitate opening project folders in local applications.
- Enhanced the HomeHero component to include working directory and design system settings, improving user experience in project creation.
- Created HomeHeroSettingsChips component for inline management of working directory and design system selection.

* feat(chat): implement voice transcription proxy and enhance UI components

- Added a new API route for voice transcription using OpenAI's `/audio/transcriptions` endpoint, allowing users to send audio blobs directly for transcription.
- Integrated multer for handling audio file uploads in memory, ensuring efficient processing without disk storage.
- Updated the HomeHero component to include example prompt suggestions for plugins, enhancing user interaction.
- Introduced the EditorIcon component to visually represent different editors in the hand-off menu, improving the user experience.
- Refined the HandoffButton component to utilize the new EditorIcon, providing a more cohesive interface for selecting editors.
- Enhanced CSS styles for various components to improve layout and responsiveness, including adjustments to tab and button sizes for better usability.

* style(workspace-shell): enhance layout and overflow handling

- Updated CSS for .workspace-shell to ensure full viewport width and height, with proper overflow management.
- Adjusted grid layout to prevent content overflow and maintain responsiveness.
- Modified styles for .workspace-tabs-chrome to improve width handling and prevent overflow issues.

* refactor(chat): remove voice transcription proxy and related components

- Deleted the voice transcription proxy implementation, including the associated API route and multer configuration.
- Removed the MicButton component from the ChatComposer and HomeHero components to streamline the UI.
- Updated HomeHero to include example suggestions without the voice input functionality.
- Adjusted CSS styles for various components to maintain layout consistency after the removal of the MicButton.

* feat(daemon): implement minting of HMAC tokens for working directory management

- Added a new function `mintImportTokenFromCurrentSecret` to generate HMAC tokens bound to a specified base directory, enhancing security for working directory operations.
- Updated the `desktop-auth.ts` file to include the new token minting functionality, which returns structured errors when the desktop auth secret is cleared.
- Introduced new IPC message types for minting import tokens in the sidecar protocol, allowing seamless integration with the daemon's working directory management.
- Enhanced the `WorkingDirPill` component to utilize the new token minting flow for secure directory selection in desktop builds.
- Updated CSS styles for the HomeHero component to accommodate new example suggestion features and maintain layout consistency.

* fix(HomeView): import HOME_HERO_CHIPS constant for improved chip management

- Updated the HomeView component to import the HOME_HERO_CHIPS constant from the chips module, enhancing the management of hero chips within the component.

* feat(daemon): implement mintImportTokenViaSidecar for secure working directory management

- Introduced the `mintImportTokenViaSidecar` function to facilitate the minting of HMAC tokens for desktop-import operations via the daemon's sidecar IPC. This allows CLI commands to bypass authentication when the desktop-auth gate is active.
- Updated the CLI to utilize the new token minting function when setting the working directory, ensuring secure access to trust-gated API endpoints.
- Enhanced the sidecar server to handle minting requests and return structured error messages for improved user feedback.
- Added tests to validate the new token minting functionality and its integration with the working directory management process.
- Refactored related components to support the new token flow, improving overall security and user experience.

* feat(HomeHero): enhance UI components and styles for improved user experience

- Updated HomeHero component to replace active dot indicators with Plug icons for better visual representation of active plugins.
- Adjusted CSS styles for various elements, including padding and dimensions, to enhance layout consistency and responsiveness.
- Introduced new styles for active type icons and improved hover effects for buttons.
- Updated HomeHeroSettingsChips to change button titles and icons for clarity.
- Added tests to ensure proper rendering and functionality of updated components.

* feat(ProjectDesignSystemPicker): enhance design system selection with preview functionality

- Updated the ProjectDesignSystemPicker component to include a preview feature for design systems, allowing users to see a preview of the selected design system.
- Implemented hover functionality to update the preview based on the hovered design system.
- Added fullscreen preview capability for a more immersive experience.
- Enhanced CSS styles for the design system picker to improve layout and responsiveness.
- Introduced tests to validate the new preview functionality and ensure proper interaction within the component.

* feat: refactor project metadata handling and enhance design system picker

- Updated the default scenario plugin ID retrieval to use project metadata, improving the logic for determining the appropriate plugin based on project intent.
- Enhanced the ProjectDesignSystemPicker and related components to support localized design system summaries and categories, improving user experience.
- Introduced new translations for working directory and design system picker components, ensuring better accessibility and usability across different locales.
- Added a new 'live-artifact' project type to the HomeHero chips, expanding the functionality for users creating refreshable artifacts.
- Updated tests to validate the new project metadata handling and design system picker functionalities.

* feat: enhance localization and styling for design system components

- Added French translations for working directory and design system picker components, improving accessibility for French-speaking users.
- Updated CSS styles for the pet task item to ensure consistent padding and layout.
- Introduced a new test suite for HomeHeroSettingsChips to validate localization and design system selection functionality.
- Enhanced ProjectDesignSystemPicker tests to ensure proper localization and interaction with design system categories.

* fix: update .gitignore to include all claude-sessions directories and remove specific session files

- Modified .gitignore to ensure all claude-sessions directories are ignored by using a wildcard pattern.
- Deleted two specific claude-sessions markdown files to clean up unnecessary session data.

* fix: repair home automation ci regressions

* fix: stabilize artifact consistency e2e

* Remove folder picker changes from PR 2400

---------

Co-authored-by: pftom <1043269994@qq.com>
Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-05-20 22:07:30 +08:00

514 lines
18 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import type { AppConfig, ChatMessage, Conversation, Project } from '../../src/types';
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 streamViaDaemon = vi.fn();
const streamMessage = vi.fn();
const saveMessage = vi.fn();
const createConversation = vi.fn();
const patchConversation = vi.fn();
const patchProject = vi.fn();
const saveTabs = vi.fn();
const playSound = vi.fn();
const showCompletionNotification = vi.fn();
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: (...args: unknown[]) => streamMessage(...args),
}));
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: (...args: unknown[]) => streamViaDaemon(...args),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/utils/notifications', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/notifications')>()),
playSound: (...args: unknown[]) => playSound(...args),
showCompletionNotification: (...args: unknown[]) => showCompletionNotification(...args),
}));
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: 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: ({ children }: { children: ReactNode }) => <header>{children}</header>,
}));
vi.mock('../../src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: ({
streaming,
onSendBoardCommentAttachments,
}: {
streaming: boolean;
onSendBoardCommentAttachments: (attachments: unknown[]) => void;
}) => (
<>
<output data-testid="workspace-streaming-state">{streaming ? 'streaming' : 'idle'}</output>
<button
type="button"
data-testid="workspace-send-comment"
onClick={() => onSendBoardCommentAttachments([{ id: 'comment-1' }])}
>
workspace send
</button>
</>
),
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => null,
}));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: ({
activeConversationId,
conversations,
streaming,
sendDisabled,
onSelectConversation,
onSend,
onNewConversation,
error,
}: {
activeConversationId: string | null;
conversations: Conversation[];
streaming: boolean;
sendDisabled?: boolean;
error: string | null;
onSelectConversation: (id: string) => void;
onSend: (prompt: string, attachments: unknown[], commentAttachments: unknown[]) => void;
onNewConversation: () => void;
}) => (
<section>
<output data-testid="active-conversation">{activeConversationId}</output>
<output data-testid="streaming-state">{streaming ? 'streaming' : 'idle'}</output>
<output data-testid="chat-error">{error}</output>
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
data-testid={`conversation-select-${conversation.id}`}
onClick={() => onSelectConversation(conversation.id)}
>
{conversation.id}
</button>
))}
<button
type="button"
data-testid="send-message"
onClick={() => onSend('hello from b', [], [])}
disabled={sendDisabled}
>
send
</button>
<button type="button" data-testid="new-conversation" onClick={onNewConversation}>
new
</button>
</section>
),
}));
const config: AppConfig = {
mode: 'daemon',
apiKey: '',
baseUrl: '',
model: '',
agentId: 'agent-1',
agentModels: {},
skillId: null,
designSystemId: null,
notifications: {
soundEnabled: true,
successSoundId: 'success-sound',
failureSoundId: 'failure-sound',
desktopEnabled: false,
},
};
const project: Project = {
id: 'project-1',
name: 'Project',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
};
const conversations: Conversation[] = [
{ id: 'conv-a', projectId: project.id, title: 'A', createdAt: 1, updatedAt: 1 },
{ id: 'conv-b', projectId: project.id, title: 'B', createdAt: 1, updatedAt: 1 },
];
const createdConversation: Conversation = {
id: 'conv-c',
projectId: project.id,
title: null,
createdAt: 2,
updatedAt: 2,
};
const runningAssistant: ChatMessage = {
id: 'assistant-a',
role: 'assistant',
content: 'still running',
createdAt: 1,
runId: 'run-a',
runStatus: 'running',
};
const succeededAssistant: ChatMessage = {
...runningAssistant,
content: 'done',
runStatus: 'succeeded',
endedAt: 2,
};
describe('ProjectView conversation run isolation', () => {
let resolveConversationBMessages: ((messages: ChatMessage[]) => void) | null = null;
let conversationAMessages: ChatMessage[] = [runningAssistant];
beforeEach(() => {
resolveConversationBMessages = null;
conversationAMessages = [runningAssistant];
listConversations.mockResolvedValue(conversations);
listMessages.mockImplementation(async (_projectId: string, conversationId: string) => {
if (conversationId === 'conv-a') return conversationAMessages;
if (conversationId === 'conv-b') {
return new Promise<ChatMessage[]>((resolve) => {
resolveConversationBMessages = resolve;
});
}
return new Promise<ChatMessage[]>(() => {});
});
createConversation.mockResolvedValue(createdConversation);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], active: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
listProjectRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-a',
status: 'running',
createdAt: 1,
updatedAt: 1,
exitCode: null,
signal: null,
});
reattachDaemonRun.mockImplementation(async () => new Promise<void>(() => {}));
streamViaDaemon.mockImplementation(async () => {});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('allows sending in another conversation while the previous conversation has an active run', async () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-b'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
fireEvent.click(screen.getByTestId('send-message'));
expect(streamViaDaemon).not.toHaveBeenCalled();
if (!resolveConversationBMessages) throw new Error('Expected conv-b message load to be pending');
resolveConversationBMessages([]);
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(streamViaDaemon).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
conversationId: 'conv-b',
}),
);
});
it('does not create duplicate empty conversations while a fresh conversation is loading', async () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
fireEvent.click(screen.getByTestId('new-conversation'));
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-c'));
fireEvent.click(screen.getByTestId('new-conversation'));
expect(createConversation).toHaveBeenCalledTimes(1);
});
it('blocks duplicate new conversations while creation is in flight', async () => {
let resolveCreate!: (conversation: Conversation) => void;
createConversation.mockImplementationOnce(
() => new Promise<Conversation>((resolve) => {
resolveCreate = resolve;
}),
);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
fireEvent.click(screen.getByTestId('new-conversation'));
fireEvent.click(screen.getByTestId('new-conversation'));
expect(createConversation).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreate(createdConversation);
});
});
it('notifies when a detached active run is terminal after returning to its conversation', async () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-b'));
if (!resolveConversationBMessages) throw new Error('Expected conv-b message load to be pending');
resolveConversationBMessages([]);
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
conversationAMessages = [succeededAssistant];
fireEvent.click(screen.getByTestId('conversation-select-conv-a'));
await waitFor(() => expect(playSound).toHaveBeenCalledWith('success-sound'));
expect(showCompletionNotification).not.toHaveBeenCalled();
});
it('does not reload or reattach when selecting the active streaming conversation', async () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
listMessages.mockClear();
reattachDaemonRun.mockClear();
fireEvent.click(screen.getByTestId('conversation-select-conv-a'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByTestId('streaming-state').textContent).toBe('streaming');
expect(listMessages).not.toHaveBeenCalled();
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('keeps Stop hidden and Send disabled until active-run cancellation is attached', async () => {
fetchChatRunStatus.mockImplementation(async () => new Promise(() => {}));
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
fireEvent.click(screen.getByTestId('send-message'));
fireEvent.click(screen.getByTestId('workspace-send-comment'));
expect(streamViaDaemon).not.toHaveBeenCalled();
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('surfaces conversation message load errors and keeps sends disabled until messages load', async () => {
let conversationBLoadAttempts = 0;
listMessages.mockImplementation(async (_projectId: string, conversationId: string) => {
if (conversationId === 'conv-a') return [];
if (conversationId === 'conv-b') {
conversationBLoadAttempts += 1;
if (conversationBLoadAttempts === 1) throw new Error('messages unavailable');
return [];
}
return [];
});
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
await waitFor(() => expect(screen.getByTestId('chat-error').textContent).toBe('messages unavailable'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
expect(screen.getByTestId('workspace-streaming-state').textContent).toBe('streaming');
fireEvent.click(screen.getByTestId('send-message'));
expect(streamViaDaemon).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
await waitFor(() => expect(conversationBLoadAttempts).toBe(2));
await waitFor(() => expect(screen.getByTestId('chat-error').textContent).toBe(''));
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
});
it('does not rename an existing named project when sending the first message in an empty conversation', async () => {
const namedProject: Project = {
...project,
name: 'Imported Client Folder',
metadata: { kind: 'prototype', nameSource: 'user' },
};
const emptyConversation: Conversation = {
id: 'conv-empty',
projectId: namedProject.id,
title: null,
createdAt: 1,
updatedAt: 1,
};
listConversations.mockResolvedValue([emptyConversation]);
listMessages.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue(null);
renderProjectView(config, namedProject);
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-empty'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(patchProject).not.toHaveBeenCalledWith(
namedProject.id,
expect.objectContaining({ name: expect.any(String) }),
);
});
it('notifies when an API-mode chat completes without a daemon run status transition', async () => {
listMessages.mockResolvedValue([]);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
streamMessage.mockImplementation(
async (
_config: unknown,
_systemPrompt: unknown,
_history: unknown,
_signal: unknown,
handlers: { onDelta: (delta: string) => void; onDone: () => void },
) => {
handlers.onDelta('api response');
handlers.onDone();
},
);
renderProjectView({
...config,
mode: 'api',
apiKey: 'test-key',
model: 'api-model',
});
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamMessage).toHaveBeenCalledTimes(1));
await waitFor(() => expect(playSound).toHaveBeenCalledWith('success-sound'));
});
});
function renderProjectView(renderConfig = config, renderProject: Project = project) {
return render(
<ProjectView
project={renderProject}
routeFileName={null}
config={renderConfig}
agents={[{ id: 'agent-1', name: 'OpenCode', bin: 'opencode', available: true, models: [] }]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
}