Modern UI port, render-based @ui tests, dev seed, layer contracts
- Port modern clean theme (DM Sans, orange accent, app-* CSS classes) and screen redesigns from festipod-mockups; replace sketchy Ubuntu theme. New shared components: BottomNav, EventCover, EventMeetingPoints, Toast, AvatarStack, Tag, RelevanceIcon. - Restructure from prototyping shell to real mobile web app: path-based routing (History API), Gallery/DemoMode/PhoneFrame removed, Storybook setup for screen/component browsing. - ConnectScreen ported from mockup (QR-based user connection); routed at /profile/connect, wired from FriendsListScreen. - Dev-only auto-seed of NG wallet when empty (gated on NODE_ENV !== 'production'); bootstrapWallet already self-checks for non-empty ngSet so safe even in race conditions. - Render-based @ui test infrastructure: happy-dom + LocalDataProvider + RouterProvider via src/shared/test-harness/renderHelper.tsx, exposed on the world as renderedDoc. world.hasText/hasField/hasElement prefer the rendered DOM and fall back to source for backward compatibility. - Migrate 25 brittle @ui assertions from regex-on-source to DOM queries; delete implementation-detail tests (showDuplicateWarning, importableEvents, importedFrom — anti-patterns per the new contract). Update feature files where the UI changed: "Mes amis" → "Mon réseau", "Mes événements à venir" → "À venir" on home, Thématique removed from create-event wizard, etc. - Path-based @e2e steps (pushState + popstate dispatch) replacing the legacy "#/demo/…" hash routing tied to the deleted Gallery. - Add .project/knowledge/test-layer-contracts.md defining the role of each test layer (@ui = display with seed data + DOM, @data = mutations through NG broker, @e2e = critical user journeys) with anti-patterns and migration consequences. Test status: 75 passed / 71 skipped (explicit "non implémenté") / 2 failed (pre-existing @wip on ngSet.delete() NG ORM limitation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Render helper for @ui tests.
|
||||
*
|
||||
* Spins up a happy-dom Window, renders a screen wrapped in the local data
|
||||
* provider (seedData) + router, and exposes the resulting DOM for assertions.
|
||||
*
|
||||
* Why happy-dom + local provider:
|
||||
* - happy-dom is in-process, fast, no broker/wallet needed
|
||||
* - LocalDataProvider gives us the same seed data used in disconnected mode,
|
||||
* so assertions can target real values ("Marie Dupont", "@mariedupont", …)
|
||||
* - We bypass NextGraphProvider entirely — those tests aren't about NG
|
||||
*/
|
||||
|
||||
import { Window } from 'happy-dom';
|
||||
import React from 'react';
|
||||
import { getScreen } from '../../screens/index';
|
||||
import { LocalDataProvider } from '../context/FestipodDataContext';
|
||||
import { RouterProvider } from '../../app/router';
|
||||
|
||||
let window: Window | null = null;
|
||||
let root: any | null = null;
|
||||
let createRoot: any = null;
|
||||
|
||||
/**
|
||||
* Install happy-dom globals so React/ReactDOM can run. Must be called before
|
||||
* ReactDOM is imported. Idempotent.
|
||||
*/
|
||||
export async function ensureDomGlobals(): Promise<void> {
|
||||
if (window) return;
|
||||
|
||||
window = new Window({ url: 'http://localhost/' });
|
||||
// Install minimal globals React/ReactDOM expect. Some (e.g. `navigator` on
|
||||
// Node 22+) are already defined as getter-only properties — we use
|
||||
// defineProperty to override them.
|
||||
const setGlobal = (name: string, value: any) => {
|
||||
try {
|
||||
(globalThis as any)[name] = value;
|
||||
} catch {
|
||||
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
|
||||
}
|
||||
};
|
||||
setGlobal('window', window);
|
||||
setGlobal('document', window.document);
|
||||
setGlobal('navigator', window.navigator);
|
||||
setGlobal('HTMLElement', (window as any).HTMLElement);
|
||||
setGlobal('HTMLInputElement', (window as any).HTMLInputElement);
|
||||
setGlobal('HTMLTextAreaElement', (window as any).HTMLTextAreaElement);
|
||||
setGlobal('HTMLButtonElement', (window as any).HTMLButtonElement);
|
||||
setGlobal('Element', (window as any).Element);
|
||||
setGlobal('Node', (window as any).Node);
|
||||
setGlobal('Event', (window as any).Event);
|
||||
setGlobal('MouseEvent', (window as any).MouseEvent);
|
||||
setGlobal('PopStateEvent', (window as any).PopStateEvent);
|
||||
setGlobal('requestAnimationFrame', (cb: any) => setTimeout(cb, 0));
|
||||
setGlobal('cancelAnimationFrame', (id: any) => clearTimeout(id));
|
||||
|
||||
// Import ReactDOM only after globals are installed
|
||||
const reactDom = await import('react-dom/client');
|
||||
createRoot = reactDom.createRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a screen at the given path. Returns the rendered document.
|
||||
*
|
||||
* If `path` is omitted, derives a default path from the screenId via the
|
||||
* screen registry. Any previous render is unmounted first.
|
||||
*/
|
||||
export async function renderScreen(screenId: string, path?: string): Promise<Document> {
|
||||
await ensureDomGlobals();
|
||||
if (!window) throw new Error('DOM globals not installed');
|
||||
|
||||
// Unmount any previous render to keep tests isolated
|
||||
if (root) {
|
||||
root.unmount();
|
||||
root = null;
|
||||
}
|
||||
|
||||
const screen = getScreen(screenId);
|
||||
if (!screen) throw new Error(`Unknown screen "${screenId}"`);
|
||||
|
||||
// Set pathname so RouterProvider picks the right route
|
||||
const targetPath = path ?? defaultPathFor(screen.path);
|
||||
(window.history as any).pushState({}, '', targetPath);
|
||||
|
||||
// Clear & mount
|
||||
const doc = window.document as unknown as Document;
|
||||
doc.body.innerHTML = '<div id="root"></div>';
|
||||
const container = doc.getElementById('root')!;
|
||||
|
||||
root = createRoot(container);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
root.render(
|
||||
<LocalDataProvider>
|
||||
<RouterProvider>
|
||||
<screen.component />
|
||||
</RouterProvider>
|
||||
</LocalDataProvider>,
|
||||
);
|
||||
// Wait one microtask for React to flush effects
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a registry path with `:id` placeholders to a concrete URL using the
|
||||
* first seed event/user when applicable. Tests can override via the explicit
|
||||
* `path` argument to renderScreen().
|
||||
*/
|
||||
function defaultPathFor(registryPath: string): string {
|
||||
// Substitute :id with a seed id matching the route's resource. /users/:id
|
||||
// needs a user id, /events/:id needs an event id. user-2 is the first
|
||||
// non-current user in seedData (Jean Durand).
|
||||
let path = registryPath;
|
||||
if (path.startsWith('/users/')) {
|
||||
path = path.replace(/:id/g, 'user-2');
|
||||
} else {
|
||||
path = path.replace(/:id/g, 'event-1');
|
||||
}
|
||||
return path.replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
export function unmountRender(): void {
|
||||
if (root) {
|
||||
root.unmount();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRenderedDocument(): Document | null {
|
||||
return (window?.document as unknown as Document) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user