Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-waves-dance.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions e2e/simulation.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@ruminaider/flowprint-editor": "workspace:*",
"@ruminaider/flowprint-engine": "workspace:*",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Finding 2 — CLAUDE.md dependency graph violation (code-reviewer)

CLAUDE.md states app depends on editor + schema. This line adds @ruminaider/flowprint-engine as a direct dependency, changing the documented dependency graph without updating CLAUDE.md.

Fix: Update CLAUDE.md to document app → editor + schema + engine, or restructure to avoid the direct dependency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5123bec. Updated CLAUDE.md to document app → editor + schema + engine.

"@ruminaider/flowprint-schema": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
45 changes: 44 additions & 1 deletion packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,28 @@ 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'

export function App() {
const [doc, setDoc] = useState<FlowprintDocument | null>(null)
const [wizardOpen, setWizardOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [showSimPanel, setShowSimPanel] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rulesDataMap, setRulesDataMap] = useState<RulesDataMap>({})

Expand All @@ -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)
Expand Down Expand Up @@ -112,11 +131,13 @@ export function App() {
return
}
}
simulation.stop()
setShowSimPanel(false)
setDoc(null)
fileManager.setDirty(false)
setRulesDataMap({})
setError(null)
}, [fileManager])
}, [fileManager, simulation])

return (
<div
Expand Down Expand Up @@ -198,6 +219,9 @@ export function App() {
onSettings={() => {
setSettingsOpen(true)
}}
onSimulate={handleSimulate}
isSimulating={simulation.isActive || showSimPanel}
canSimulate={canSimulate}
onClose={handleClose}
/>
<div style={{ flex: 1, minHeight: 0 }}>
Expand All @@ -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%' }}
/>
</div>
{showSimPanel && (
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Finding 6 — SimulationPanel has no React error boundary (error-hunter)

SimulationPanel is rendered outside the editor's <ErrorBoundary>. If it throws during rendering (e.g., JSON.stringify on a circular reference in the context viewer), the entire React tree crashes with no recovery path.

Fix: Wrap in its own error boundary:

<SimulationErrorBoundary onReset={() => { simulation.stop(); setShowSimPanel(false) }}>
  <SimulationPanel simulation={...} />
</SimulationErrorBoundary>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43dbfd0. Added SimulationErrorBoundary component that wraps SimulationPanel in App.tsx. On crash, it shows the error message with a "Stop Simulation" button that resets state.

<SimulationErrorBoundary
onReset={() => {
simulation.stop()
setShowSimPanel(false)
}}
>
<SimulationPanel
simulation={{
...simulation,
stop: () => {
simulation.stop()
setShowSimPanel(false)
},
}}
/>
</SimulationErrorBoundary>
)}
</>
)}

Expand Down
32 changes: 30 additions & 2 deletions packages/app/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export interface HeaderProps {
supportsOpenProject?: boolean
onSave: () => void
onSaveAs: () => void
onSimulate?: () => void
isSimulating?: boolean
canSimulate?: boolean
onSettings: () => void
onClose?: () => void
}
Expand Down Expand Up @@ -43,8 +46,8 @@ function HeaderButton({
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseEnter={() => { setHovered(true) }}
onMouseLeave={() => { setHovered(false) }}
style={{
...btnStyle,
background: hovered ? '#252434' : '#1C1B25',
Expand All @@ -65,6 +68,9 @@ export function Header({
supportsOpenProject,
onSave,
onSaveAs,
onSimulate,
isSimulating,
canSimulate,
onSettings,
onClose,
}: HeaderProps) {
Expand Down Expand Up @@ -109,6 +115,28 @@ export function Header({
)}
<HeaderButton onClick={onSave}>Save</HeaderButton>
<HeaderButton onClick={onSaveAs}>Save As</HeaderButton>
{canSimulate && onSimulate && (
<button
type="button"
onClick={onSimulate}
onMouseEnter={(e) => {
;(e.target as HTMLElement).style.background = isSimulating ? '#94e2a0' : '#252434'
}}
onMouseLeave={(e) => {
;(e.target as HTMLElement).style.background = isSimulating
? '#a6e3a1'
: '#1C1B25'
}}
style={{
...btnStyle,
background: isSimulating ? '#a6e3a1' : '#1C1B25',
color: isSimulating ? '#1e1e2e' : '#E8E7F4',
borderColor: isSimulating ? '#a6e3a1' : '#2E2D3D',
}}
>
{isSimulating ? 'Simulating' : 'Simulate'}
</button>
)}
<HeaderButton onClick={onSettings}>Settings</HeaderButton>
<HeaderButton onClick={onCycleTheme}>
Theme: {THEME_LABELS[themeMode]}
Expand Down
Loading