import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures'; const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn(), })); vi.mock('@/lib/api', () => ({ api: apiMock, })); import { ProjectDetailPage } from './project-detail'; let root: Root | null = null; let container: HTMLDivElement; beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, }); }); afterAll(() => { Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); }); afterEach(async () => { await act(async () => { root?.unmount(); }); document.body.replaceChildren(); root = null; apiMock.mockReset(); sessionStorage.clear(); }); async function renderProjectDetailPage(): Promise> { const routes: RouteObject[] = [ { path: '/projects', element:

Projects index target

}, { path: '/projects/:id', element: }, ]; const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] }); container = document.createElement('div'); document.body.append(container); root = createRoot(container); await act(async () => { root?.render(); }); return router; } function clickButtonByText(text: string): void { const button = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(text), ); if (!button) { throw new Error(`Button containing "${text}" not found`); } button.dispatchEvent(new MouseEvent('click', { bubbles: true })); } async function flushAct(): Promise { await act(async () => { await Promise.resolve(); }); } interface Deferred { promise: Promise; resolve: (value: T) => void; } function createDeferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1'); function mockHealthyLoad(): void { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(projectOneTasks); } describe('ProjectDetailPage', () => { it('loads the project, tasks, missions, and optional PRD content for the active project', async () => { mockHealthyLoad(); await renderProjectDetailPage(); expect(apiMock.mock.calls.map((call) => call[0])).toEqual([ '/api/projects/project-1', '/api/missions', '/api/tasks?projectId=project-1', ]); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'current', ); expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Route /projects/:id'); expect(container.textContent).toContain('Tasks'); expect(container.textContent).toContain('Done'); expect(container.textContent).toContain('Blocked'); await act(async () => { clickButtonByText('Missions (1)'); }); expect(container.textContent).toContain('Ship web parity'); expect(container.textContent).not.toContain('Unrelated mission'); await act(async () => { clickButtonByText('PRD'); }); expect(container.textContent).toContain('Mosaic Stack PRD'); expect(container.textContent).toContain('Ship the SPA route parity pages.'); }); it('opens and closes the existing read-only task modal from the tasks tab', async () => { mockHealthyLoad(); await renderProjectDetailPage(); await act(async () => { clickButtonByText('Tasks (3)'); }); const row = [...container.querySelectorAll('tr')].find((candidate) => candidate.textContent?.includes('Route /projects'), ); expect(row).toBeTruthy(); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeTruthy(); expect(container.textContent).toContain('Read-only modal content should remain intact.'); const closeButton = container.querySelector('button[aria-label="Close task details"]'); expect(closeButton).toBeTruthy(); await act(async () => { closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeNull(); }); it('shows verified completion verdicts when the task collection is current', async () => { mockHealthyLoad(); await renderProjectDetailPage(); const doneCard = [...container.querySelectorAll('div')].find( (candidate) => candidate.textContent === 'Done1', ); expect(doneCard).toBeTruthy(); const inProgressCard = [...container.querySelectorAll('div')].find( (candidate) => candidate.textContent === 'In Progress1', ); expect(inProgressCard).toBeTruthy(); }); it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockRejectedValueOnce(new Error('Missions request failed')) .mockResolvedValueOnce(projectOneTasks); await renderProjectDetailPage(); // Secondary failure degrades the surface to partial; the project itself // still renders. expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'partial', ); expect(container.textContent).toContain('Mosaic Stack'); const partial = container.querySelector('[role="status"]'); expect(partial?.textContent).toContain('Missions'); expect(partial?.textContent).toContain('unavailable'); await act(async () => { clickButtonByText('Missions (?)'); }); const alert = container.querySelector('[role="alert"]'); expect(alert?.textContent).toContain('Missions request failed'); // Negative control: a failed fetch must not look like an empty list. expect(container.textContent).not.toContain('No missions for this project'); }); it('marks derived verdicts unknown when the tasks collection is unavailable', async () => { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(missionFixtures) .mockRejectedValueOnce(new Error('Tasks request failed')); await renderProjectDetailPage(); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'partial', ); // Completion verdicts become unknown ('?') — never green counts. for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) { const unknownCard = [...container.querySelectorAll('div')].find( (candidate) => candidate.textContent === `${label}?`, ); expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy(); } // Negative control: no green "Done 1" verdict anywhere. expect( [...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'), ).toBe(false); await act(async () => { clickButtonByText('Tasks (?)'); }); const alert = container.querySelector('[role="alert"]'); expect(alert?.textContent).toContain('Tasks request failed'); // Negative control: no healthy empty task list from a failed fetch. expect(container.textContent).not.toContain('No tasks found'); expect(container.querySelector('table')).toBeNull(); }); it('recovers a partial surface to current after revalidation', async () => { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(missionFixtures) .mockRejectedValueOnce(new Error('Tasks request failed')) .mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(projectOneTasks); await renderProjectDetailPage(); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'partial', ); await act(async () => { clickButtonByText('Revalidate'); }); await flushAct(); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'current', ); expect( [...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'), ).toBe(true); }); it("never shows one project's data on another project's route after navigation", async () => { mockHealthyLoad(); const router = await renderProjectDetailPage(); expect(container.textContent).toContain('Mosaic Stack'); const deferred = createDeferred<(typeof projectFixtures)[number]>(); apiMock .mockResolvedValueOnce(deferred.promise) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]); await act(async () => { await router.navigate('/projects/project-2'); }); // While project-2 loads, nothing from project-1 may render on its route. expect(container.textContent).toContain('Loading project...'); expect(container.textContent).not.toContain('Mosaic Stack'); expect(container.textContent).not.toContain('Route /projects/:id'); await act(async () => { deferred.resolve(projectFixtures[1]!); await deferred.promise; }); expect(container.textContent).toContain('Agent Runtime'); expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2'); }); it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => { apiMock .mockRejectedValueOnce(new Error('Project request failed')) .mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(projectOneTasks); const router = await renderProjectDetailPage(); const alert = container.querySelector('[role="alert"]'); expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Project request failed'); expect(alert?.textContent).toContain('not an empty result'); expect(container.textContent).not.toContain('Mosaic Stack'); await act(async () => { clickButtonByText('Back to projects'); }); expect(router.state.location.pathname).toBe('/projects'); }); });