From 64c48de1d1ba262c60b535c793b2f07bfa27fc65 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 10 Jul 2026 02:15:49 +0200 Subject: [PATCH] test: add shared frontend test infra (factories, mocks, render) Foundation for the frontend test consolidation (mirrors backend #267): - src/test/factories.ts: canonical makeNode/makeEdge/makeNodeData/makeDesign - src/test/mocks/: reusable mockSonner/mockReactFlow/makeUseCanvasStore builders consumed via async vi.mock dynamic-import to survive hoisting - src/test/render.tsx: renderWithProviders wrapping Tooltip + ReactFlow providers Migrate ScanConfigModal sonner mock as canary. No behavior change. ha-relevant: yes --- .../modals/__tests__/ScanConfigModal.test.tsx | 2 +- frontend/src/test/factories.ts | 73 +++++++++++++++++++ frontend/src/test/mocks/index.ts | 70 ++++++++++++++++++ frontend/src/test/render.tsx | 30 ++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 frontend/src/test/factories.ts create mode 100644 frontend/src/test/mocks/index.ts create mode 100644 frontend/src/test/render.tsx diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx index 8bdb4e8..67e8017 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({ trigger: vi.fn(), }, })) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) +vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner()) import { scanApi } from '@/api/client' import { toast } from 'sonner' diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts new file mode 100644 index 0000000..6f27289 --- /dev/null +++ b/frontend/src/test/factories.ts @@ -0,0 +1,73 @@ +/** + * Canonical test fixture builders. Prefer these over redefining `makeNode` / + * `makeEdge` inline per test file — one source of truth keeps fixtures in sync + * with the domain types. + * + * See CLAUDE.md → Testing Protocol. + */ +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData, Design } from '@/types' + +/** Build a `NodeData` payload with sane defaults; override any field. */ +export function makeNodeData(overrides: Partial = {}): NodeData { + return { + label: overrides.label ?? 'Test Node', + type: 'server', + status: 'unknown', + services: [], + ...overrides, + } +} + +/** + * Build a React Flow node. Accepts either an id string or a partial-node + * object as the first arg, so it covers both inline patterns that existed + * across the suite: + * makeNode('n1', { type: 'router' }) + * makeNode({ data: makeNodeData({ ip: '10.0.0.1' }) }) + */ +export function makeNode( + idOrOverrides: string | Partial> = 'n1', + dataOverrides: Partial = {}, +): Node { + const base: Partial> = + typeof idOrOverrides === 'string' ? { id: idOrOverrides } : idOrOverrides + const id = base.id ?? 'n1' + return { + id, + type: base.type ?? base.data?.type ?? dataOverrides.type ?? 'server', + position: base.position ?? { x: 0, y: 0 }, + ...base, + data: makeNodeData({ label: id, ...dataOverrides, ...base.data }), + } +} + +/** Build a React Flow edge between two node ids. */ +export function makeEdge( + id: string, + source: string, + target: string, + overrides: Partial> = {}, +): Edge { + return { + id, + source, + target, + type: 'ethernet', + ...overrides, + data: { type: 'ethernet', ...overrides.data }, + } +} + +/** Build a Design row. */ +export function makeDesign(overrides: Partial = {}): Design { + return { + id: 'd1', + name: 'Test Design', + design_type: 'network', + icon: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, + } +} diff --git a/frontend/src/test/mocks/index.ts b/frontend/src/test/mocks/index.ts new file mode 100644 index 0000000..8f675fb --- /dev/null +++ b/frontend/src/test/mocks/index.ts @@ -0,0 +1,70 @@ +/** + * Reusable mock builders. Consume them from an *async* `vi.mock` factory via + * dynamic import so the boilerplate lives in one place: + * + * vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner()) + * + * `vi.mock` factories are hoisted above the file's imports, so a plain + * top-level `import { mockSonner }` referenced inside the factory throws + * "cannot access before initialization". The dynamic `import()` sidesteps + * that — it resolves lazily when the mocked module is first loaded. + * + * See CLAUDE.md → Testing Protocol. + */ +import { vi } from 'vitest' + +/** `sonner` toast stub. Assert via `import { toast } from 'sonner'`. */ +export function mockSonner() { + return { + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, + } +} + +/** + * `@xyflow/react` stub covering the exports most node/edge component tests + * need. Pass `extra` to add or override exports for a specific test. + */ +export function mockReactFlow(extra: Record = {}) { + return { + Handle: () => null, + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, + NodeResizer: () => null, + NodeToolbar: () => null, + BaseEdge: () => null, + EdgeLabelRenderer: ({ children }: { children?: unknown }) => children ?? null, + useUpdateNodeInternals: () => vi.fn(), + useViewport: () => ({ x: 0, y: 0, zoom: 1 }), + useReactFlow: () => ({ + getNode: vi.fn(), + getNodes: vi.fn(() => []), + getEdges: vi.fn(() => []), + screenToFlowPosition: vi.fn((p: unknown) => p), + }), + getBezierPath: () => ['M0,0', 0, 0] as const, + getSmoothStepPath: () => ['M0,0', 0, 0] as const, + ...extra, + } +} + +/** + * Build a `useCanvasStore` replacement for component tests that only read a + * selected slice of store state. Returns a function usable as the hook: it + * invokes the passed selector against `state` (falling back to returning the + * whole state when called with no selector). + */ +export function makeUseCanvasStore>(state: S) { + return (selector?: (s: S) => R): R | S => (selector ? selector(state) : state) +} + +/** Common `serviceStatusKey` helper mirrored from the real store. */ +export function serviceStatusKey(nodeId: string, port?: number, protocol?: string) { + return `${nodeId}:${port ?? ''}/${protocol ?? ''}` +} diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx new file mode 100644 index 0000000..8d4ef29 --- /dev/null +++ b/frontend/src/test/render.tsx @@ -0,0 +1,30 @@ +/* eslint-disable react-refresh/only-export-components -- test helper module, not a component module */ +/** + * Custom render that wraps the UI in the same providers the app mounts + * (`TooltipProvider`, `ReactFlowProvider`), so component tests that touch + * React Flow context or tooltips don't each re-declare the wrapper. + * + * Re-exports everything from `@testing-library/react`, so a test can: + * import { renderWithProviders, screen } from '@/test/render' + * + * Use the plain `render` from `@testing-library/react` for pure presentational + * components that need no context. See CLAUDE.md → Testing Protocol. + */ +import type { ReactElement, ReactNode } from 'react' +import { render, type RenderOptions } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { TooltipProvider } from '@/components/ui/tooltip' + +function AllProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function renderWithProviders(ui: ReactElement, options?: Omit) { + return render(ui, { wrapper: AllProviders, ...options }) +} + +export * from '@testing-library/react'