Files
stack/apps/web/src/spa/pages/project-detail.spec.tsx
T

319 lines
10 KiB
TypeScript

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<ReturnType<typeof createMemoryRouter>> {
const routes: RouteObject[] = [
{ path: '/projects', element: <p>Projects index target</p> },
{ path: '/projects/:id', element: <ProjectDetailPage /> },
];
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
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<void> {
await act(async () => {
await Promise.resolve();
});
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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');
});
});