feat(web): add read-only project and task SPA pages
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
e00cc475a2
commit
a6085eea37
@@ -0,0 +1,131 @@
|
||||
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 { projectFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectsPage } from './projects';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <ProjectsPage /> },
|
||||
{ path: '/projects/:id', element: <p>Project detail target</p> },
|
||||
];
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('ProjectsPage', () => {
|
||||
it('shows a visible loading state while the project request is in flight', async () => {
|
||||
const deferred = createDeferred<typeof projectFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading projects...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders project cards from the API and navigates to a project detail route on click', async () => {
|
||||
apiMock.mockResolvedValueOnce(projectFixtures);
|
||||
|
||||
const router = await renderProjectsPage();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/projects');
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Mosaic Stack'),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects/project-1');
|
||||
expect(container.textContent).toContain('Project detail target');
|
||||
});
|
||||
|
||||
it('renders the empty state when the API returns no projects', async () => {
|
||||
apiMock.mockResolvedValueOnce([]);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('No projects yet');
|
||||
expect(container.textContent).toContain(
|
||||
'Projects will appear here when created via the gateway API',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a visible alert when the projects request fails', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Projects are unavailable');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user