From 52f529898187dfaae1b21f2288690fd59fa7c15b Mon Sep 17 00:00:00 2001 From: albertgwo Date: Fri, 6 Mar 2026 22:09:09 -0500 Subject: [PATCH 1/7] feat: add interactive graph walker visualization Add browser-safe simulation engine and visual step-through UI for flowprint blueprints. Users can run simulations directly in the editor, step through execution traces, and see node highlights updating in real-time. Engine changes: - Extract pure rules evaluation core (core.ts) from evaluator - Add browser-safe AST expression interpreter (no node:vm) - Create shared walkGraph skeleton used by both Node.js runner and browser simulator - Build browser simulator using shared skeleton - Add ./browser entry point (no Node.js imports) Editor changes: - Add SimulationContext with NodeHighlightMap for node state tracking - Add simulation CSS classes with Catppuccin Mocha palette colors and pulse animation for active nodes App changes: - Add useSimulation hook with pre-computed snapshots for O(1) step access, chained setTimeout auto-play, and error handling - Add SimulationPanel with input form, step controls, progress scrubber, rules/expression detail, and keyboard shortcuts - Wire Simulate button in Header and integrate into App.tsx Tests: 95 new engine tests (378 total), 5 editor tests (490 total), 14 app tests (83 total), 5 Playwright E2E tests --- e2e/simulation.spec.ts | 80 ++ packages/app/package.json | 1 + packages/app/src/App.tsx | 36 +- packages/app/src/components/Header.tsx | 32 +- .../app/src/components/SimulationPanel.tsx | 424 ++++++++++ packages/app/src/hooks/useSimulation.test.ts | 358 +++++++++ packages/app/src/hooks/useSimulation.ts | 216 +++++ .../editor/src/components/FlowprintEditor.tsx | 7 + .../src/contexts/SimulationContext.test.tsx | 48 ++ .../editor/src/contexts/SimulationContext.tsx | 13 + packages/editor/src/index.ts | 4 + packages/editor/src/nodes/NodeShell.tsx | 6 + packages/editor/src/styles/nodes.css | 31 + packages/engine/package.json | 8 +- .../engine/src/__tests__/conformance.test.ts | 222 +++++ .../engine/src/__tests__/interpreter.test.ts | 243 ++++++ .../engine/src/__tests__/rules-core.test.ts | 144 ++++ .../src/__tests__/rules/walker-rules.test.ts | 14 +- .../engine/src/__tests__/simulator.test.ts | 530 ++++++++++++ packages/engine/src/browser.ts | 31 + packages/engine/src/expressions/allowlist.ts | 3 + packages/engine/src/expressions/index.ts | 2 + .../engine/src/expressions/interpreter.ts | 274 +++++++ packages/engine/src/index.ts | 18 + packages/engine/src/rules/core.ts | 249 ++++++ .../engine/src/rules/evaluator-browser.ts | 4 + packages/engine/src/rules/evaluator.ts | 136 +--- packages/engine/src/rules/index.ts | 1 + packages/engine/src/rules/loader.ts | 43 + packages/engine/src/runner/types.ts | 17 +- packages/engine/src/runner/walker.ts | 760 +++++++----------- packages/engine/src/simulator/index.ts | 8 + packages/engine/src/simulator/simulator.ts | 256 ++++++ packages/engine/src/simulator/types.ts | 29 + packages/engine/src/walker/index.ts | 8 + packages/engine/src/walker/types.ts | 71 ++ packages/engine/src/walker/walk.ts | 108 +++ packages/engine/tsup.config.ts | 2 +- pnpm-lock.yaml | 3 + 39 files changed, 3844 insertions(+), 596 deletions(-) create mode 100644 e2e/simulation.spec.ts create mode 100644 packages/app/src/components/SimulationPanel.tsx create mode 100644 packages/app/src/hooks/useSimulation.test.ts create mode 100644 packages/app/src/hooks/useSimulation.ts create mode 100644 packages/editor/src/contexts/SimulationContext.test.tsx create mode 100644 packages/editor/src/contexts/SimulationContext.tsx create mode 100644 packages/engine/src/__tests__/conformance.test.ts create mode 100644 packages/engine/src/__tests__/interpreter.test.ts create mode 100644 packages/engine/src/__tests__/rules-core.test.ts create mode 100644 packages/engine/src/__tests__/simulator.test.ts create mode 100644 packages/engine/src/browser.ts create mode 100644 packages/engine/src/expressions/interpreter.ts create mode 100644 packages/engine/src/rules/core.ts create mode 100644 packages/engine/src/rules/loader.ts create mode 100644 packages/engine/src/simulator/index.ts create mode 100644 packages/engine/src/simulator/simulator.ts create mode 100644 packages/engine/src/simulator/types.ts create mode 100644 packages/engine/src/walker/index.ts create mode 100644 packages/engine/src/walker/types.ts create mode 100644 packages/engine/src/walker/walk.ts diff --git a/e2e/simulation.spec.ts b/e2e/simulation.spec.ts new file mode 100644 index 0000000..440f068 --- /dev/null +++ b/e2e/simulation.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test' + +test.describe('simulation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + + // Create a new blueprint so doc is loaded and Simulate button appears + await page.getByRole('button', { name: 'New Blueprint' }).click() + await page.getByLabel(/blueprint name/i).fill('sim-test') + await page.getByRole('button', { name: 'Create' }).click() + + // Wait for editor to appear + await expect(page.getByText('sim-test', { exact: true })).toBeVisible() + }) + + test('simulate button appears and opens panel', async ({ page }) => { + const simBtn = page.getByRole('button', { name: 'Simulate' }) + await expect(simBtn).toBeVisible() + + await simBtn.click() + + // Simulation panel should appear with input textarea + await expect(page.getByText('Input JSON')).toBeVisible() + await expect(page.getByRole('button', { name: 'Run' })).toBeVisible() + }) + + test('run simulation shows step counter and highlights', async ({ page }) => { + // Open simulation panel + await page.getByRole('button', { name: 'Simulate' }).click() + + // Run with default empty input + await page.getByRole('button', { name: 'Run' }).click() + + // Step counter should appear + await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible() + + // At least one node should have an active highlight + await expect(page.locator('.fp-node--sim-active')).toBeVisible() + }) + + test('stop simulation closes panel', async ({ page }) => { + await page.getByRole('button', { name: 'Simulate' }).click() + await page.getByRole('button', { name: 'Run' }).click() + + // Wait for simulation to load + await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible() + + // Stop the simulation — panel closes entirely + await page.getByRole('button', { name: 'Stop' }).click() + + // Panel should be gone + await expect(page.getByText(/Step \d+ of \d+/)).not.toBeVisible() + await expect(page.getByRole('button', { name: 'Simulate' })).toBeVisible() + }) + + test('close simulation hides panel', async ({ page }) => { + await page.getByRole('button', { name: 'Simulate' }).click() + await expect(page.getByText('Input JSON')).toBeVisible() + + // Close the panel + await page.getByRole('button', { name: 'Close' }).click() + + // Panel should be gone, button should say Simulate (not Simulating) + await expect(page.getByText('Input JSON')).not.toBeVisible() + await expect(page.getByRole('button', { name: 'Simulate' })).toBeVisible() + }) + + test('invalid JSON input shows error', async ({ page }) => { + await page.getByRole('button', { name: 'Simulate' }).click() + + // Enter invalid JSON + const textarea = page.locator('textarea').first() + await textarea.fill('not valid json') + + await page.getByRole('button', { name: 'Run' }).click() + + // Error message should appear + await expect(page.getByText('Invalid JSON input')).toBeVisible() + }) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 8ea6c23..5e9f030 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@ruminaider/flowprint-editor": "workspace:*", + "@ruminaider/flowprint-engine": "workspace:*", "@ruminaider/flowprint-schema": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index dc3dd43..1033764 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -7,11 +7,13 @@ import { Header } from './components/Header' import { WelcomeScreen } from './components/WelcomeScreen' import { NewBlueprintWizard } from './components/NewBlueprintWizard' import { SettingsDialog } from './components/SettingsDialog' +import { SimulationPanel } from './components/SimulationPanel' import { UnsavedChangesGuard } from './components/UnsavedChangesGuard' import { useFileManager } from './hooks/useFileManager' import { useProjectDirectory } from './hooks/useProjectDirectory' import { useRecentFiles } from './hooks/useRecentFiles' import { useSettings } from './hooks/useSettings' +import { useSimulation } from './hooks/useSimulation' import type { AppSettings } from './hooks/useSettings' import type { RecentFile } from './hooks/useRecentFiles' @@ -19,6 +21,7 @@ export function App() { const [doc, setDoc] = useState(null) const [wizardOpen, setWizardOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) + const [showSimPanel, setShowSimPanel] = useState(false) const [error, setError] = useState(null) const [rulesDataMap, setRulesDataMap] = useState({}) @@ -31,6 +34,20 @@ export function App() { codeSearchUrl: settings.codeSearchUrl || undefined, }) + const simulation = useSimulation(doc, rulesDataMap) + + // Review #5: gate on doc !== null only, not on rules presence + const canSimulate = doc !== null + + const handleSimulate = useCallback(() => { + if (simulation.isActive) { + simulation.stop() + setShowSimPanel(false) + } else { + setShowSimPanel(true) + } + }, [simulation]) + const handleDocLoaded = useCallback( (loaded: FlowprintDocument, fileName: string) => { setDoc(loaded) @@ -112,11 +129,13 @@ export function App() { return } } + simulation.stop() + setShowSimPanel(false) setDoc(null) fileManager.setDirty(false) setRulesDataMap({}) setError(null) - }, [fileManager]) + }, [fileManager, simulation]) return (
{ setSettingsOpen(true) }} + onSimulate={handleSimulate} + isSimulating={simulation.isActive || showSimPanel} + canSimulate={canSimulate} onClose={handleClose} />
@@ -207,11 +229,23 @@ export function App() { theme={settings.theme} symbolSearch={symbolSearch ?? undefined} rulesDataMap={rulesDataMap} + nodeHighlights={simulation.nodeHighlights} showYamlPreview showExportButton style={{ width: '100%', height: '100%' }} />
+ {showSimPanel && ( + { + simulation.stop() + setShowSimPanel(false) + }, + }} + /> + )} )} diff --git a/packages/app/src/components/Header.tsx b/packages/app/src/components/Header.tsx index 0552c00..8ec8df7 100644 --- a/packages/app/src/components/Header.tsx +++ b/packages/app/src/components/Header.tsx @@ -11,6 +11,9 @@ export interface HeaderProps { supportsOpenProject?: boolean onSave: () => void onSaveAs: () => void + onSimulate?: () => void + isSimulating?: boolean + canSimulate?: boolean onSettings: () => void onClose?: () => void } @@ -43,8 +46,8 @@ function HeaderButton({ + )} Settings Theme: {THEME_LABELS[themeMode]} diff --git a/packages/app/src/components/SimulationPanel.tsx b/packages/app/src/components/SimulationPanel.tsx new file mode 100644 index 0000000..53376b9 --- /dev/null +++ b/packages/app/src/components/SimulationPanel.tsx @@ -0,0 +1,424 @@ +import { useState, useCallback, useEffect } from 'react' +import type { UseSimulationReturn } from '../hooks/useSimulation' + +export interface SimulationPanelProps { + simulation: UseSimulationReturn +} + +const panelStyle: React.CSSProperties = { + borderTop: '1px solid #2E2D3D', + background: 'rgba(10, 10, 15, 0.95)', + color: '#E8E7F4', + fontFamily: 'var(--fp-font-sans, system-ui, sans-serif)', + fontSize: 13, + overflow: 'auto', + maxHeight: 320, +} + +const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 16px', + borderBottom: '1px solid #2E2D3D', +} + +const controlsStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '6px 16px', + borderBottom: '1px solid #2E2D3D', +} + +const btnStyle: React.CSSProperties = { + padding: '4px 10px', + fontSize: 11, + border: '1px solid #2E2D3D', + borderRadius: 6, + background: '#1C1B25', + color: '#E8E7F4', + cursor: 'pointer', +} + +const btnActiveStyle: React.CSSProperties = { + ...btnStyle, + background: '#a6e3a1', + color: '#1e1e2e', + borderColor: '#a6e3a1', +} + +const detailStyle: React.CSSProperties = { + padding: '8px 16px', + fontSize: 12, + lineHeight: 1.6, +} + +const badgeStyle: React.CSSProperties = { + display: 'inline-block', + padding: '1px 6px', + fontSize: 10, + fontWeight: 600, + borderRadius: 4, + textTransform: 'uppercase', +} + +const inputAreaStyle: React.CSSProperties = { + padding: '8px 16px', + borderBottom: '1px solid #2E2D3D', +} + +const textareaStyle: React.CSSProperties = { + width: '100%', + minHeight: 60, + padding: 8, + fontSize: 12, + fontFamily: 'monospace', + background: '#1C1B25', + color: '#E8E7F4', + border: '1px solid #2E2D3D', + borderRadius: 6, + resize: 'vertical', +} + +const MAX_INPUT_BYTES = 256 * 1024 // 256KB (Review #27) + +function StatusBadge({ status }: { status: string }) { + const colors: Record = { + completed: { bg: '#a6e3a1', fg: '#1e1e2e' }, + matched: { bg: '#89b4fa', fg: '#1e1e2e' }, + activated: { bg: '#b4befe', fg: '#1e1e2e' }, + error: { bg: '#f38ba8', fg: '#1e1e2e' }, + handled: { bg: '#fab387', fg: '#1e1e2e' }, + reached: { bg: '#cdd6f4', fg: '#1e1e2e' }, + default: { bg: '#585b70', fg: '#cdd6f4' }, + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- default is always defined + const c = colors[status] ?? colors.default! + return ( + + {status} + + ) +} + +function buildCumulativeContext( + steps: { stepOutput?: { nodeId: string; value: unknown } }[], + upToIndex: number, +): Record { + const ctx: Record = {} + for (let i = 0; i <= upToIndex; i++) { + const out = steps[i]?.stepOutput + if (out) ctx[out.nodeId] = out.value + } + return ctx +} + +export function SimulationPanel({ simulation }: SimulationPanelProps) { + const [inputText, setInputText] = useState('{}') + const [fixturesText, setFixturesText] = useState('') + const [showFixtures, setShowFixtures] = useState(false) + const [showContext, setShowContext] = useState(false) + const [inputError, setInputError] = useState(null) + + const { + isActive, + trace, + currentStep, + totalSteps, + currentNodeId, + currentStepData, + error, + start, + stop, + stepForward, + stepBack, + reset, + setAutoPlay, + isAutoPlaying, + } = simulation + + const handleRun = useCallback(() => { + // Review #27: byte-size guard + if (new Blob([inputText]).size > MAX_INPUT_BYTES) { + setInputError('Input exceeds 256KB limit') + return + } + + let input: unknown + try { + input = JSON.parse(inputText) + setInputError(null) + } catch { + setInputError('Invalid JSON input') + return + } + + let fixtures: Record | undefined + if (fixturesText.trim()) { + try { + fixtures = JSON.parse(fixturesText) as Record + } catch { + setInputError('Invalid JSON fixtures') + return + } + } + + start(input, fixtures) + }, [inputText, fixturesText, start]) + + // Review #37: keyboard shortcuts scoped to active simulation + useEffect(() => { + if (!isActive) return + + function handleKeyDown(e: KeyboardEvent) { + if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return + + switch (e.key) { + case 'ArrowRight': + e.preventDefault() + stepForward() + break + case 'ArrowLeft': + e.preventDefault() + stepBack() + break + case ' ': + e.preventDefault() + setAutoPlay(!isAutoPlaying) + break + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [isActive, stepForward, stepBack, setAutoPlay, isAutoPlaying]) + + // Input form (shown when simulation is not active) + if (!isActive) { + return ( +
+
+ Simulation + +
+ +
+ +