open-design/apps/web/tests/components/ProjectView.tabs-navigation.test.tsx
chaoxiaoche fce444bcab
Consolidate chat comments preview on main (#2906)
* feat(web): queue chat sends

* feat(web): render code comment directives

* feat(web): add preview comments and manual edits

* fix(web): polish shared chrome controls

* fix(web): align queued send loading state

* feat(web): open primary project artifacts

* fix(web): keep queued sends and tests aligned

* fix(web): restore docked comment tools layout

* fix(web): align preview comment toolbar

* fix(web): place local cli beside handoff

* fix(web): move agent menu beside handoff

* fix(web): make project instructions a direct header action

* fix(web): compact handoff and toolbar labels

* fix(web): clarify handoff menu and annotation label

* fix(web): restore compact cursor handoff trigger

* fix(web): align agent menu trigger with handoff

* fix(web): add draw toolbar close action

* fix(web): move inspect editing into edit mode

* fix(web): avoid reserving comment sidebar in annotation mode

* fix(web): float preview comments panel

* fix(web): keep edit canvas full width

* fix(web): polish preview annotation tools

* fix(web): highlight active preview comments

* fix(web): open comments panel after annotation save

* fix(web): polish comment handoff controls

* fix(web): remove palette preview tool

* fix(web): simplify draw annotation toolbar

* fix(web): restore queued tasks into composer

* fix(web): restore queued send strip styling

* fix(web): hide internal comment target ids

* fix(web): align manual edit panel header

* test(web): cover visual interaction contracts

* fix(web): address PR feedback regressions

* fix(web): preserve artifact chrome state

* fix(daemon): restore project raw file routes

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-26 10:31:19 +00:00

338 lines
9.9 KiB
TypeScript

// @vitest-environment jsdom
import { 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 { navigate } from '../../src/router';
import type {
AgentInfo,
AppConfig,
Conversation,
DesignSystemSummary,
Project,
SkillSummary,
} from '../../src/types';
import {
createConversation,
listConversations,
listMessages,
loadTabs,
saveTabs,
} from '../../src/state/projects';
import { fetchPreviewComments, fetchProjectFiles } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: vi.fn(),
}));
vi.mock('../../src/providers/daemon', () => ({
fetchChatRunStatus: vi.fn(),
listActiveChatRuns: vi.fn().mockResolvedValue([]),
listProjectRuns: vi.fn().mockResolvedValue([]),
reattachDaemonRun: vi.fn(),
streamViaDaemon: vi.fn(),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
deletePreviewComment: vi.fn(),
fetchDesignSystem: vi.fn(),
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
fetchPreviewComments: vi.fn(),
fetchProjectFiles: vi.fn().mockResolvedValue([]),
fetchSkill: vi.fn(),
getTemplate: vi.fn(),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
createConversation: vi.fn(),
listConversations: vi.fn(),
listMessages: vi.fn(),
loadTabs: vi.fn(),
patchConversation: vi.fn(),
patchProject: vi.fn(),
saveMessage: vi.fn(),
saveTabs: vi.fn(),
};
});
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: ({ tabsState, onTabsStateChange }: {
tabsState: { tabs: string[]; active: string | null };
onTabsStateChange: (state: { tabs: string[]; active: string | null }) => void;
}) => (
<div data-testid="file-workspace">
<output data-testid="workspace-active-tab">{tabsState.active ?? ''}</output>
<button
type="button"
data-testid="close-all-tabs"
onClick={() => onTabsStateChange({ tabs: [], active: null })}
>
close all tabs
</button>
</div>
),
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => <div data-testid="loader" />,
}));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: () => <div data-testid="chat-pane" />,
}));
const mockedListConversations = vi.mocked(listConversations);
const mockedCreateConversation = vi.mocked(createConversation);
const mockedListMessages = vi.mocked(listMessages);
const mockedLoadTabs = vi.mocked(loadTabs);
const mockedSaveTabs = vi.mocked(saveTabs);
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
const mockedFetchProjectFiles = vi.mocked(fetchProjectFiles);
const mockedNavigate = vi.mocked(navigate);
const config: AppConfig = {
mode: 'api',
apiKey: '',
baseUrl: '',
model: '',
agentId: null,
skillId: null,
designSystemId: null,
};
const project: Project = {
id: 'project-1',
name: 'Project 1',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
};
const conversation: Conversation = {
id: 'conv-1',
projectId: project.id,
title: null,
createdAt: 1,
updatedAt: 1,
};
function renderProjectView() {
return render(
<ProjectView
project={project}
routeFileName={null}
config={config}
agents={[] as AgentInfo[]}
skills={[] as SkillSummary[]}
designTemplates={[] as SkillSummary[]}
designSystems={[] as DesignSystemSummary[]}
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onRefreshAgents={vi.fn()}
onOpenSettings={vi.fn()}
onBack={vi.fn()}
onClearPendingPrompt={vi.fn()}
onTouchProject={vi.fn()}
onProjectChange={vi.fn()}
onProjectsRefresh={vi.fn()}
/>,
);
}
describe('ProjectView tab URL hydration', () => {
beforeEach(() => {
mockedListConversations.mockResolvedValue([conversation]);
mockedCreateConversation.mockResolvedValue(conversation);
mockedListMessages.mockResolvedValue([]);
mockedLoadTabs.mockResolvedValue({ tabs: ['index.html'], active: 'index.html' });
mockedFetchProjectFiles.mockResolvedValue([]);
mockedFetchPreviewComments.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('syncs a persisted active tab to the URL before the file list has hydrated', async () => {
renderProjectView();
await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith(
// The active conversation id is threaded into the URL alongside
// the active tab so a reload / share preserves the conversation
// segment of `/projects/:id/conversations/:cid/files/...`
// (PerishCode + Codex P1 on PR #1508).
{
kind: 'project',
projectId: project.id,
conversationId: 'conv-1',
fileName: 'index.html',
},
{ replace: true },
);
});
});
it('re-pushes /conversations/:cid when activeConversationId hydrates after the active tab has already synced (lefarcen P1 on PR #1508)', async () => {
// Race shape: `loadTabs` resolves and sets the active tab BEFORE
// `listConversations` resolves and sets `activeConversationId`.
// The first navigate fires with `conversationId: null` because
// the conversation hasn't loaded yet; the second navigate must
// fire with `conversationId: 'conv-1'` even though the active
// tab is identical. A ref guard that keys only on the file
// target skips the second call and the URL never gains the
// `/conversations/:cid` segment. The composite-key guard
// (`${activeConversationId}:${target}`) catches it.
let resolveConversations: (value: Conversation[]) => void = () => {};
const conversationsPromise = new Promise<Conversation[]>((resolve) => {
resolveConversations = resolve;
});
mockedListConversations.mockReturnValue(conversationsPromise);
mockedLoadTabs.mockResolvedValue({ tabs: ['index.html'], active: 'index.html' });
renderProjectView();
// First navigate: active tab synced, conversation still loading.
await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith(
{
kind: 'project',
projectId: project.id,
conversationId: null,
fileName: 'index.html',
},
{ replace: true },
);
});
// Now resolve the conversation list. The active tab is unchanged
// but `activeConversationId` flips from `null` to `'conv-1'`, so
// a second navigate must fire.
resolveConversations([conversation]);
await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith(
{
kind: 'project',
projectId: project.id,
conversationId: 'conv-1',
fileName: 'index.html',
},
{ replace: true },
);
});
});
it('does not reopen the primary file after the user closes the last tab', async () => {
mockedLoadTabs.mockResolvedValue({ tabs: [], active: null });
mockedFetchProjectFiles.mockResolvedValue([
{
name: 'index.html',
path: 'index.html',
type: 'file',
size: 1,
mtime: 1,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Index',
entry: 'index.html',
renderer: 'html',
primary: true,
exports: ['html'],
},
},
]);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('workspace-active-tab').textContent).toBe('index.html'));
expect(mockedSaveTabs).toHaveBeenCalledWith(project.id, { tabs: ['index.html'], active: 'index.html' });
fireEvent.click(screen.getByTestId('close-all-tabs'));
await waitFor(() => expect(screen.getByTestId('workspace-active-tab').textContent).toBe(''));
await waitFor(() => {
expect(mockedSaveTabs.mock.calls.at(-1)).toEqual([project.id, { tabs: [], active: null }]);
});
expect(mockedSaveTabs).toHaveBeenCalledTimes(2);
});
it('does not auto-open the primary file when saved tabs were explicitly empty', async () => {
mockedLoadTabs.mockResolvedValue({ tabs: [], active: null, hasSavedState: true });
mockedFetchProjectFiles.mockResolvedValue([
{
name: 'index.html',
path: 'index.html',
type: 'file',
size: 1,
mtime: 1,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Index',
entry: 'index.html',
renderer: 'html',
primary: true,
exports: ['html'],
},
},
]);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('workspace-active-tab').textContent).toBe(''));
expect(mockedSaveTabs).not.toHaveBeenCalled();
});
});