test(web): add vitest + RTL harness with 16-route mount coverage
Some checks are pending
CI / verify (push) Waiting to run
Some checks are pending
CI / verify (push) Waiting to run
jsdom + single-instance styled-components/react dedupe (mirrors vite) so the ThemeProvider context reaches components; MemoryStorage shim for the theme's localStorage persistence. App.test.tsx proves every prototype route resolves to a real lazy-loaded view that mounts on the cocotte theme and renders content (not a Suspense fallback, no Placeholder), with the data layer stubbed to a loading state — queried by the semantic <main> region, not a class, since the shell is now pure styled-components. 17 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5a7e093485
commit
2512e7512c
5 changed files with 966 additions and 3 deletions
817
package-lock.json
generated
817
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "NODE_ENV=production vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cocotte/site-themes": "^0.1.1",
|
||||
|
|
@ -19,10 +20,14 @@
|
|||
"styled-components": "^6.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
web/src/App.test.tsx
Normal file
92
web/src/App.test.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { ThemeProvider } from '@cocotte/ui-theme';
|
||||
import { luxeDarkTheme } from '@cocotte/site-themes';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { App } from './App';
|
||||
|
||||
/**
|
||||
* Route-coverage proof for PLAN.md: every prototype route resolves to a real,
|
||||
* lazy-loaded view that MOUNTS on the cocotte theme without throwing — i.e. the
|
||||
* 16-view build-out actually exists and is wired, not a Placeholder.
|
||||
*
|
||||
* The data layer is stubbed to a pending/loading state so each view renders its
|
||||
* loading branch — no network, no per-view response-shape coupling. The assertion
|
||||
* is structural: the titlebar reflects the route (App routed correctly) and the
|
||||
* Suspense-lazy view resolved into real content in the `<main>` content region (no
|
||||
* thrown error, no lingering Suspense fallback). Queried by semantic element, not a
|
||||
* class, since the shell is now pure styled-components (no styles.css).
|
||||
*/
|
||||
|
||||
// usePoll → permanent loading (data:null) so usePoll-based views skip shape access.
|
||||
vi.mock('./usePoll', () => ({
|
||||
usePoll: () => ({ data: null, loading: true, error: null }),
|
||||
formatTime: (s: string) => s,
|
||||
}));
|
||||
|
||||
// Every api call → a pending promise, so views that fetch directly (e.g. Markets)
|
||||
// also stay in their loading branch and never read an unfulfilled shape.
|
||||
vi.mock('./api', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(actual)) {
|
||||
out[key] = typeof val === 'function' ? vi.fn(() => new Promise(() => {})) : val;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// The 16 prototype routes (route key → titlebar title), mirroring App's TITLES.
|
||||
const ROUTES: ReadonlyArray<readonly [string, string]> = [
|
||||
['dashboard', 'Dashboard'],
|
||||
['triage', 'Triage'],
|
||||
['prospects', 'Prospects'],
|
||||
['stream', 'Stream'],
|
||||
['queue', 'Outbox'],
|
||||
['backfill', 'Backfill'],
|
||||
['calendar', 'Bookings'],
|
||||
['campaigns', 'Campaigns'],
|
||||
['model', 'Model'],
|
||||
['voice', 'Voice alignment'],
|
||||
['markets', 'Markets'],
|
||||
['reports', 'Reports'],
|
||||
['services', 'Services'],
|
||||
['settings', 'Settings'],
|
||||
['control', 'Control'],
|
||||
['autopilot', 'Autopilot'],
|
||||
];
|
||||
|
||||
function renderRoute(view: string) {
|
||||
window.location.hash = `#/${view}`;
|
||||
return render(
|
||||
<ThemeProvider themeName="luxe" customTheme={luxeDarkTheme}>
|
||||
<App />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('prototype route coverage (16 views build + mount on the cocotte theme)', () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('covers all 16 prototype routes', () => {
|
||||
expect(ROUTES).toHaveLength(16);
|
||||
});
|
||||
|
||||
it.each(ROUTES)('#/%s mounts a real view (title "%s")', async (view, title) => {
|
||||
const { container, findByText, unmount } = renderRoute(view);
|
||||
|
||||
// App routed: titlebar reflects the route.
|
||||
await findByText(`Prospector • ${title}`);
|
||||
|
||||
// The lazy view resolved into real DOM (not the Suspense fallback, no throw).
|
||||
await waitFor(() => {
|
||||
const content = container.querySelector('main');
|
||||
expect(content).toBeTruthy();
|
||||
expect(content?.textContent ?? '').not.toContain('Loading view…');
|
||||
expect((content?.childElementCount ?? 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
32
web/src/test/setup.ts
Normal file
32
web/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// jsdom under vitest doesn't always expose a working Storage; @cocotte/ui-theme's
|
||||
// ThemeProvider reads localStorage for theme persistence. Provide a minimal mock.
|
||||
class MemoryStorage implements Storage {
|
||||
private store = new Map<string, string>();
|
||||
get length(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
getItem(key: string): string | null {
|
||||
return this.store.has(key) ? (this.store.get(key) as string) : null;
|
||||
}
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.store.keys())[index] ?? null;
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage?.getItem !== 'function') {
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: new MemoryStorage(), configurable: true });
|
||||
}
|
||||
if (typeof globalThis.sessionStorage?.getItem !== 'function') {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', { value: new MemoryStorage(), configurable: true });
|
||||
}
|
||||
19
web/vitest.config.ts
Normal file
19
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Test config for the PWA. jsdom + a single styled-components/React instance
|
||||
// (same dedupe as vite.config) so the ThemeProvider context reaches components.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { dedupe: ['styled-components', 'react', 'react-dom'] },
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
// The @cocotte/@lilith design-system packages ship ESM with extensionless
|
||||
// relative imports — Vite resolves them in the browser build, but vitest's
|
||||
// node resolver doesn't. Inline them so Vite transforms them for the test.
|
||||
server: { deps: { inline: [/@cocotte\//, /@lilith\//] } },
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue