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
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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> = {}): 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<Node<NodeData>> = 'n1',
|
||||
dataOverrides: Partial<NodeData> = {},
|
||||
): Node<NodeData> {
|
||||
const base: Partial<Node<NodeData>> =
|
||||
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<EdgeData>> = {},
|
||||
): Edge<EdgeData> {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: 'ethernet',
|
||||
...overrides,
|
||||
data: { type: 'ethernet', ...overrides.data },
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a Design row. */
|
||||
export function makeDesign(overrides: Partial<Design> = {}): 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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<S extends Record<string, unknown>>(state: S) {
|
||||
return <R>(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 ?? ''}`
|
||||
}
|
||||
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<ReactFlowProvider>{children}</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderWithProviders(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
|
||||
return render(ui, { wrapper: AllProviders, ...options })
|
||||
}
|
||||
|
||||
export * from '@testing-library/react'
|
||||
Reference in New Issue
Block a user