Skip to content

Commit 7608503

Browse files
authored
feat: add performance optimization and comprehensive cookbook tests (#1451)
* feat: lazy load cookbook detail and graph pages Use React.lazy() with Suspense for CookbookDetailPage and CookbookGraphPage routes to enable code splitting. These pages pull in heavy dependencies (@xyflow/react, elkjs, dagre, CodeMirror) that are now loaded on demand rather than in the initial bundle. * test: add comprehensive tests for cookbook components and hooks Add tests for previously untested cookbook modules: - ComponentDetail: props table, usage context, dependencies, files - CompositionGraph: ReactFlow rendering, filters, legend, edge types - SagaFlowDiagram: nodes, edges, early exits, services legend - PreviewSourceTabs: tab switching, copy button, clipboard - useCookbook: virtual data loading, types - useFilterState: URL sync, partial updates, clear all Total cookbook test count: 83 -> 125 (42 new tests) --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 1befac2 commit 7608503

7 files changed

Lines changed: 693 additions & 4 deletions

File tree

frontend/src/App.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ReactNode, useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
1+
import { type ReactNode, lazy, Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
22
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'
33
import { QueryClientProvider } from '@tanstack/react-query'
44
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
@@ -30,7 +30,14 @@ import { DashboardPage } from '@/features/dashboard'
3030
import { ManifestsPage } from '@/features/manifests'
3131
import { McpConfigPage } from '@/features/mcp-config'
3232
import { TransactionsPage } from '@/features/transactions'
33-
import { CookbookPage, CookbookDetailPage, CookbookGraphPage } from '@/features/cookbook'
33+
import { CookbookPage } from '@/features/cookbook'
34+
35+
const CookbookDetailPage = lazy(() =>
36+
import('@/features/cookbook/pages/detail').then((m) => ({ default: m.CookbookDetailPage })),
37+
)
38+
const CookbookGraphPage = lazy(() =>
39+
import('@/features/cookbook/pages/graph').then((m) => ({ default: m.CookbookGraphPage })),
40+
)
3441
import { ThemePreviewPanel } from '@/components/dev/theme-preview-panel'
3542

3643
// Placeholder page components - replaced as each page task is implemented
@@ -258,8 +265,8 @@ function AppShellLayout() {
258265
<Route path="/manifests" element={<FeatureGuard feature="manifests">{guarded(<ManifestsPage />)}</FeatureGuard>} />
259266
<Route path="/mcp-config" element={<FeatureGuard feature="mcp-config">{guarded(<McpConfigPage />)}</FeatureGuard>} />
260267
<Route path="/cookbook" element={guarded(<CookbookPage />)} />
261-
<Route path="/cookbook/graph" element={guarded(<CookbookGraphPage />)} />
262-
<Route path="/cookbook/:name" element={guarded(<CookbookDetailPage />)} />
268+
<Route path="/cookbook/graph" element={guarded(<Suspense fallback={<div className="h-96 animate-pulse rounded bg-muted" />}><CookbookGraphPage /></Suspense>)} />
269+
<Route path="/cookbook/:name" element={guarded(<Suspense fallback={<div className="h-96 animate-pulse rounded bg-muted" />}><CookbookDetailPage /></Suspense>)} />
263270
<Route path="/audit-log" element={<FeatureGuard feature="audit">{guarded(<AuditLogPage />)}</FeatureGuard>} />
264271

265272
{/* Platform-only routes */}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import { MemoryRouter } from 'react-router-dom'
4+
import { ComponentDetail } from './component-detail'
5+
import type { CookbookItem, ComponentMeta } from '../hooks/use-cookbook'
6+
7+
vi.mock('@/api/transport', () => ({
8+
createTenantTransport: vi.fn(() => ({ __type: 'mock-transport' })),
9+
}))
10+
11+
vi.mock('@/api/clients', () => ({
12+
createServiceClients: vi.fn(() => ({})),
13+
}))
14+
15+
function renderDetail(item: CookbookItem) {
16+
return render(
17+
<MemoryRouter>
18+
<ComponentDetail item={item} />
19+
</MemoryRouter>,
20+
)
21+
}
22+
23+
const fullItem: CookbookItem = {
24+
name: 'balance-card',
25+
type: 'registry:ui',
26+
title: 'Balance Card',
27+
description: 'Displays account balance',
28+
registryDependencies: ['currency-formatter', 'icon-set'],
29+
categories: ['accounts'],
30+
files: [
31+
{ path: 'components/balance-card.tsx', type: 'registry:ui' },
32+
{ path: 'components/balance-card.css' },
33+
],
34+
meta: {
35+
feature_module: 'accounts',
36+
tenant_configurable: true,
37+
configurable_props: ['showCurrency', 'decimals'],
38+
used_by: ['dashboard', 'account-detail'],
39+
} satisfies ComponentMeta,
40+
}
41+
42+
const bareItem: CookbookItem = {
43+
name: 'simple-widget',
44+
type: 'registry:ui',
45+
title: 'Simple Widget',
46+
}
47+
48+
describe('ComponentDetail', () => {
49+
it('renders configurable props table', () => {
50+
renderDetail(fullItem)
51+
expect(screen.getByText('Configurable Props')).toBeInTheDocument()
52+
expect(screen.getByText('showCurrency')).toBeInTheDocument()
53+
expect(screen.getByText('decimals')).toBeInTheDocument()
54+
})
55+
56+
it('renders usage context with feature module', () => {
57+
renderDetail(fullItem)
58+
expect(screen.getByText('Usage Context')).toBeInTheDocument()
59+
expect(screen.getByText('accounts')).toBeInTheDocument()
60+
})
61+
62+
it('renders tenant configurable badge', () => {
63+
renderDetail(fullItem)
64+
expect(screen.getByText('Yes')).toBeInTheDocument()
65+
})
66+
67+
it('renders used_by badges', () => {
68+
renderDetail(fullItem)
69+
expect(screen.getByText('dashboard')).toBeInTheDocument()
70+
expect(screen.getByText('account-detail')).toBeInTheDocument()
71+
})
72+
73+
it('renders registry dependencies as links', () => {
74+
renderDetail(fullItem)
75+
expect(screen.getByText('Registry Dependencies')).toBeInTheDocument()
76+
expect(screen.getByText('currency-formatter')).toBeInTheDocument()
77+
expect(screen.getByText('icon-set')).toBeInTheDocument()
78+
79+
const link = screen.getByText('currency-formatter').closest('a')
80+
expect(link).toHaveAttribute('href', '/cookbook/currency-formatter')
81+
})
82+
83+
it('shows no dependencies message when none exist', () => {
84+
renderDetail(bareItem)
85+
expect(screen.getByText('No registry dependencies.')).toBeInTheDocument()
86+
})
87+
88+
it('renders source files list', () => {
89+
renderDetail(fullItem)
90+
expect(screen.getByText('Source Files')).toBeInTheDocument()
91+
expect(screen.getByText('components/balance-card.tsx')).toBeInTheDocument()
92+
expect(screen.getByText('components/balance-card.css')).toBeInTheDocument()
93+
})
94+
95+
it('renders live preview placeholder', () => {
96+
renderDetail(fullItem)
97+
expect(screen.getByText('Preview not available')).toBeInTheDocument()
98+
})
99+
100+
it('hides props table when no configurable_props', () => {
101+
renderDetail(bareItem)
102+
expect(screen.queryByText('Configurable Props')).not.toBeInTheDocument()
103+
})
104+
105+
it('hides usage context when no meta', () => {
106+
renderDetail(bareItem)
107+
expect(screen.queryByText('Usage Context')).not.toBeInTheDocument()
108+
})
109+
})
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { useState } from 'react'
3+
import { render, screen } from '@testing-library/react'
4+
import { MemoryRouter } from 'react-router-dom'
5+
import { CompositionGraph } from './composition-graph'
6+
import type { CookbookItem, PatternMeta } from '../hooks/use-cookbook'
7+
8+
vi.mock('@/api/transport', () => ({
9+
createTenantTransport: vi.fn(() => ({ __type: 'mock-transport' })),
10+
}))
11+
12+
vi.mock('@/api/clients', () => ({
13+
createServiceClients: vi.fn(() => ({})),
14+
}))
15+
16+
// Mock @xyflow/react
17+
vi.mock('@xyflow/react', () => {
18+
const Position = { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }
19+
const BackgroundVariant = { Dots: 'dots' }
20+
21+
function Handle() { return null }
22+
23+
function ReactFlow({ nodes, edges, children }: { nodes: unknown[]; edges: unknown[]; children?: React.ReactNode }) {
24+
return (
25+
<div data-testid="react-flow" data-node-count={nodes.length} data-edge-count={edges.length}>
26+
{(nodes as { id: string; data: { label?: string } }[]).map((n) => (
27+
<div key={n.id} data-testid={`node-${n.id}`}>
28+
{n.data?.label ?? n.id}
29+
</div>
30+
))}
31+
{children}
32+
</div>
33+
)
34+
}
35+
36+
function Controls() { return <div data-testid="controls" /> }
37+
function Background() { return null }
38+
function MiniMap() { return <div data-testid="minimap" /> }
39+
40+
return {
41+
ReactFlow,
42+
Controls,
43+
Background,
44+
MiniMap,
45+
Handle,
46+
Position,
47+
BackgroundVariant,
48+
useNodesState: (init: unknown[]) => {
49+
const [s, setS] = useState(init)
50+
return [s, setS, vi.fn()]
51+
},
52+
useEdgesState: (init: unknown[]) => {
53+
const [s, setS] = useState(init)
54+
return [s, setS, vi.fn()]
55+
},
56+
}
57+
})
58+
59+
// Mock ELK for layout
60+
vi.mock('elkjs/lib/elk.bundled.js', () => ({
61+
default: class MockELK {
62+
async layout(graph: { children: { id: string }[] }) {
63+
return {
64+
children: graph.children.map((child, i) => ({
65+
id: child.id,
66+
x: i * 100,
67+
y: i * 100,
68+
})),
69+
}
70+
}
71+
},
72+
}))
73+
74+
const patterns: CookbookItem[] = [
75+
{
76+
name: 'energy-trading',
77+
type: 'registry:pattern',
78+
title: 'Energy Trading',
79+
categories: ['energy'],
80+
registryDependencies: ['fiat-settlement'],
81+
meta: {
82+
complexity: 7,
83+
composes_with: ['carbon-offset'],
84+
extends: [],
85+
conflicts_with: [],
86+
} satisfies PatternMeta,
87+
},
88+
{
89+
name: 'fiat-settlement',
90+
type: 'registry:pattern',
91+
title: 'Fiat Settlement',
92+
categories: ['foundation'],
93+
meta: {
94+
complexity: 3,
95+
} satisfies PatternMeta,
96+
},
97+
{
98+
name: 'carbon-offset',
99+
type: 'registry:pattern',
100+
title: 'Carbon Offset',
101+
categories: ['carbon'],
102+
meta: {
103+
complexity: 5,
104+
composes_with: ['energy-trading'],
105+
} satisfies PatternMeta,
106+
},
107+
]
108+
109+
const mixedItems: CookbookItem[] = [
110+
...patterns,
111+
{
112+
name: 'balance-card',
113+
type: 'registry:ui',
114+
title: 'Balance Card',
115+
},
116+
]
117+
118+
function renderGraph(items: CookbookItem[]) {
119+
return render(
120+
<MemoryRouter>
121+
<CompositionGraph patterns={items} className="h-96" />
122+
</MemoryRouter>,
123+
)
124+
}
125+
126+
describe('CompositionGraph', () => {
127+
it('renders ReactFlow container', () => {
128+
renderGraph(patterns)
129+
expect(screen.getByTestId('react-flow')).toBeInTheDocument()
130+
})
131+
132+
it('renders controls and minimap', () => {
133+
renderGraph(patterns)
134+
expect(screen.getByTestId('controls')).toBeInTheDocument()
135+
expect(screen.getByTestId('minimap')).toBeInTheDocument()
136+
})
137+
138+
it('renders filter sidebar', () => {
139+
renderGraph(patterns)
140+
expect(screen.getByLabelText('Filter by category')).toBeInTheDocument()
141+
expect(screen.getByLabelText('Filter by industry')).toBeInTheDocument()
142+
})
143+
144+
it('renders edge legend', () => {
145+
renderGraph(patterns)
146+
expect(screen.getByText('Dependency')).toBeInTheDocument()
147+
expect(screen.getByText('Composes with')).toBeInTheDocument()
148+
expect(screen.getByText('Extends')).toBeInTheDocument()
149+
expect(screen.getByText('Conflicts')).toBeInTheDocument()
150+
})
151+
152+
it('filters out UI components (only patterns)', () => {
153+
renderGraph(mixedItems)
154+
expect(screen.getByText('3 patterns')).toBeInTheDocument()
155+
})
156+
157+
it('shows category filter options', () => {
158+
renderGraph(patterns)
159+
const select = screen.getByLabelText('Filter by category')
160+
expect(select).toBeInTheDocument()
161+
const options = select.querySelectorAll('option')
162+
expect(options.length).toBe(4) // All + 3 categories
163+
})
164+
165+
it('renders with empty patterns', () => {
166+
renderGraph([])
167+
expect(screen.getByTestId('react-flow')).toBeInTheDocument()
168+
expect(screen.getByText('0 patterns')).toBeInTheDocument()
169+
})
170+
})

0 commit comments

Comments
 (0)