diff --git a/.changeset/bright-waves-dance.md b/.changeset/bright-waves-dance.md new file mode 100644 index 0000000..5b865b9 --- /dev/null +++ b/.changeset/bright-waves-dance.md @@ -0,0 +1,6 @@ +--- +'@ruminaider/flowprint-engine': minor +'@ruminaider/flowprint-editor': minor +--- + +Add browser-safe simulation engine with shared walkGraph skeleton, AST interpreter, and pure rules evaluation core. Add SimulationContext to editor for node highlighting during simulation. diff --git a/CLAUDE.md b/CLAUDE.md index f888556..7bed029 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ packages/ app/ flowprint-app (private) (Vite -> static site) ``` -Dependency graph: `schema` has zero dependents. `editor` and `cli` depend on `schema`. `app` depends on `editor` + `schema`. Editor and CLI are siblings -- neither depends on the other. +Dependency graph: `schema` has zero dependents. `editor` and `cli` depend on `schema`. `app` depends on `editor` + `schema` + `engine`. Editor and CLI are siblings -- neither depends on the other. ## Commands 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..06d1432 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -2,16 +2,20 @@ import { useState, useCallback } from 'react' import { FlowprintEditor, useTheme, useSymbolSearch } from '@ruminaider/flowprint-editor' import type { RulesDataMap } from '@ruminaider/flowprint-editor' import '@ruminaider/flowprint-editor/styles.css' +import './styles/app.css' import type { FlowprintDocument } from '@ruminaider/flowprint-schema' 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 { SimulationErrorBoundary } from './components/SimulationErrorBoundary' 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 +23,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 +36,20 @@ export function App() { codeSearchUrl: settings.codeSearchUrl || undefined, }) + const simulation = useSimulation(doc, rulesDataMap) + + // 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 +131,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 +231,30 @@ 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) + }} + > + { + 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/NewBlueprintWizard.tsx b/packages/app/src/components/NewBlueprintWizard.tsx index 5eb4615..d84e0fb 100644 --- a/packages/app/src/components/NewBlueprintWizard.tsx +++ b/packages/app/src/components/NewBlueprintWizard.tsx @@ -119,14 +119,16 @@ function WizardForm({ }, [name, description, schemaVersion, lanes, onCreate]) return ( - <> -

New Blueprint

+
+

New Blueprint

-
- -
+
+ { @@ -134,34 +136,36 @@ function WizardForm({ setNameError(null) }} placeholder="my-service-flow" - style={{ width: '100%' }} /> {nameError ? ( -
+
{nameError}
) : null}
-
- -
+
+