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
24 changes: 22 additions & 2 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { WelcomeScreen } from './components/WelcomeScreen'
import { NewBlueprintWizard } from './components/NewBlueprintWizard'
import { SettingsDialog } from './components/SettingsDialog'
import { SimulationPanel } from './components/SimulationPanel'
import { getScenarios } from './data/template-scenarios'
import type { TemplateScenario } from './data/template-scenarios'
import { UnsavedChangesGuard } from './components/UnsavedChangesGuard'
import { useFileManager } from './hooks/useFileManager'
import { useProjectDirectory } from './hooks/useProjectDirectory'
Expand All @@ -25,6 +27,7 @@ export function App() {
const [showSimPanel, setShowSimPanel] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rulesDataMap, setRulesDataMap] = useState<RulesDataMap>({})
const [simulationRules, setSimulationRules] = useState<RulesDataMap>({})

const settingsHook = useSettings()
const { settings } = settingsHook
Expand All @@ -35,7 +38,17 @@ export function App() {
codeSearchUrl: settings.codeSearchUrl || undefined,
})

const simulation = useSimulation(doc, rulesDataMap)
// Merge project rules with simulation-scoped rules for the simulation hook
const effectiveRules: RulesDataMap = { ...rulesDataMap, ...simulationRules }
const simulation = useSimulation(doc, effectiveRules)
const scenarios = doc ? getScenarios(doc.name) : []

const handleSelectScenario = useCallback(
(scenario: TemplateScenario | null) => {
setSimulationRules(scenario?.rulesData ?? {})
},
[],
Comment on lines +46 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

handleSelectScenario writes scenario rules (or {}) directly into the shared rulesDataMap that the editor reads, and nothing restores the project's rules after the simulation panel closes, so selecting a template permanently hides the real project rules; can we keep scenario rules scoped to the simulation instead of mutating rulesDataMap?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/app/src/App.tsx around lines 43 to 47, the handleSelectScenario callback
writes scenario.rulesData into the shared rulesDataMap which permanently overwrites
project rules. Change this by introducing a new state const [simulationRules,
setSimulationRules] = useState<RulesDataMap>({}) and modify handleSelectScenario to call
setSimulationRules(scenario?.rulesData ?? {}) instead of setRulesDataMap. Then compute
an effectiveRules variable (e.g. const effectiveRules = simulation.isActive ?
simulationRules : rulesDataMap) and pass effectiveRules to FlowprintEditor as the
rulesDataMap prop. Finally, ensure simulation.stop() and the simulated panel close path
clear setSimulationRules({}) so project rules are preserved when simulation ends.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Commit 728ac41 addressed this comment by introducing simulationRules state and keeping scenario-specific rules isolated from the shared rulesDataMap. The new effectiveRules combine project rules with simulation rules only when needed and FlowprintEditor now receives those scoped rules, and all simulation stop/close paths clear simulationRules to restore project data.

)

// Review #5: gate on doc !== null only, not on rules presence
const canSimulate = doc !== null
Expand All @@ -44,6 +57,7 @@ export function App() {
if (simulation.isActive) {
simulation.stop()
setShowSimPanel(false)
setSimulationRules({})
} else {
setShowSimPanel(true)
}
Expand Down Expand Up @@ -135,6 +149,7 @@ export function App() {
setDoc(null)
fileManager.setDirty(false)
setRulesDataMap({})
setSimulationRules({})
setError(null)
}, [fileManager, simulation])

Expand Down Expand Up @@ -229,8 +244,10 @@ export function App() {
onChange={handleChange}
theme={settings.theme}
symbolSearch={symbolSearch ?? undefined}
rulesDataMap={rulesDataMap}
rulesDataMap={effectiveRules}
nodeHighlights={simulation.nodeHighlights}
edgeHighlights={simulation.edgeHighlights}
simulationAnimation={simulation.simulationAnimation}
showYamlPreview
showExportButton
style={{ width: '100%', height: '100%' }}
Expand All @@ -243,8 +260,11 @@ export function App() {
stop: () => {
simulation.stop()
setShowSimPanel(false)
setSimulationRules({})
},
}}
scenarios={scenarios}
onSelectScenario={handleSelectScenario}
/>
)}
</>
Expand Down
125 changes: 119 additions & 6 deletions packages/app/src/components/SimulationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useState, useCallback, useEffect } from 'react'
import type { UseSimulationReturn } from '../hooks/useSimulation'
import type { TemplateScenario } from '../data/template-scenarios'

export interface SimulationPanelProps {
simulation: UseSimulationReturn
scenarios?: TemplateScenario[]
onSelectScenario?: (scenario: TemplateScenario | null) => void
}

const panelStyle: React.CSSProperties = {
Expand Down Expand Up @@ -103,23 +106,91 @@ function StatusBadge({ status }: { status: string }) {
}

function buildCumulativeContext(
steps: { stepOutput?: { nodeId: string; value: unknown } }[],
steps: {
stepOutput?: { nodeId: string; value: unknown }
branchOutputs?: Record<string, unknown>
}[],
upToIndex: number,
): Record<string, unknown> {
const ctx: Record<string, unknown> = {}
for (let i = 0; i <= upToIndex; i++) {
const out = steps[i]?.stepOutput
if (out) ctx[out.nodeId] = out.value
const step = steps[i]
if (step?.stepOutput) ctx[step.stepOutput.nodeId] = step.stepOutput.value
if (step?.branchOutputs) {
Comment on lines +108 to +119
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

buildCumulativeContext copies the loop/merge logic (iterate steps, apply stepOutput and branchOutputs entries) already implemented in useSimulation.buildTraceSnapshots (lines ~149-157); any future change to how cumulative contexts are computed would need updating in both places, so can we share a helper or rely on the precomputed snapshots instead of duplicating the merge?

Finding type: Code Dedup and Conventions | Severity: 🟢 Low


Want Baz to fix this for you? Activate Fixer

for (const [branchId, value] of Object.entries(step.branchOutputs)) {
ctx[branchId] = value
}
}
}
return ctx
}

export function SimulationPanel({ simulation }: SimulationPanelProps) {
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '6px 8px',
fontSize: 12,
fontFamily: 'var(--fp-font-sans, system-ui, sans-serif)',
background: '#1C1B25',
color: '#E8E7F4',
border: '1px solid #2E2D3D',
borderRadius: 6,
cursor: 'pointer',
}

export function SimulationPanel({ simulation, scenarios, onSelectScenario }: SimulationPanelProps) {
const [inputText, setInputText] = useState('{}')
const [fixturesText, setFixturesText] = useState('')
const [showFixtures, setShowFixtures] = useState(false)
const [showContext, setShowContext] = useState(false)
const [inputError, setInputError] = useState<string | null>(null)
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null)

const hasScenarios = scenarios && scenarios.length > 0
const selectedScenario = hasScenarios
? scenarios.find((s) => s.id === selectedScenarioId) ?? null
: null

const handleSelectScenario = useCallback(
(scenarioId: string) => {
if (scenarioId === '') {
setSelectedScenarioId(null)
onSelectScenario?.(null)
return
}
const scenario = scenarios?.find((s) => s.id === scenarioId)
if (!scenario) return
setSelectedScenarioId(scenarioId)
setInputText(JSON.stringify(scenario.input, null, 2))
setFixturesText(JSON.stringify(scenario.fixtures ?? {}, null, 2))
if (scenario.fixtures && Object.keys(scenario.fixtures).length > 0) {
setShowFixtures(true)
}
setInputError(null)
onSelectScenario?.(scenario)
},
[scenarios, onSelectScenario],
)

const handleInputChange = useCallback(
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.

[Important — Unnecessary re-renders] handleInputChange (and handleFixturesChange at line 185) include selectedScenarioId in their dependency arrays.

Since setSelectedScenarioId(null) is idempotent when already null, the if (selectedScenarioId) guard is unnecessary. The dependency causes new callback references on every scenario change, triggering child re-renders.

Suggestion: Remove the guard and dependency:

const handleInputChange = useCallback(
  (value: string) => {
    setInputText(value)
    setSelectedScenarioId(null)
  },
  [],
)

(value: string) => {
setInputText(value)
// Clear the scenario label but keep rules — user is customizing the input
if (selectedScenarioId) {
setSelectedScenarioId(null)
}
},
[selectedScenarioId],
)

const handleFixturesChange = useCallback(
Comment on lines +174 to +185
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

handleInputChange/handleFixturesChange always call onSelectScenario(null) after any manual edit, so App immediately resets rulesDataMap to {} (keep in mind handleSelectScenario in App only sets the scenario's rulesData), meaning editing a scenario input silently drops the rules such as rules/routing.rules.yaml/rules/provider-assignment.rules.yaml and the simulation no longer matches the template; can we keep the selected scenario's rules data (or ask before clearing) when the user tweaks the JSON?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/app/src/components/SimulationPanel.tsx around lines 174 to 191, the
handleInputChange and handleFixturesChange callbacks unconditionally clear the selected
scenario by calling setSelectedScenarioId(null) and onSelectScenario?.(null), which
causes the app to drop the scenario's rulesData. Change both handlers to prompt the user
before clearing the selected scenario: if selectedScenarioId is set, show a confirmation
(e.g. window.confirm) asking whether to abandon the selected scenario (and its rules)
before clearing; only call setSelectedScenarioId(null) and onSelectScenario?.(null) when
the user confirms. If the user cancels, keep the selected scenario (do not clear or call
onSelectScenario), but still update the inputText/fixturesText locally. This preserves
the scenario.rulesData unless the user explicitly agrees to discard it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Commit 728ac41 addressed this comment by preventing handleInputChange and handleFixturesChange from invoking onSelectScenario(null), so editing no longer clears the parent-selected scenario and its rules, and by merging scenario-specific rules into simulationRules so scenario data persists even while the local selectedScenarioId is null.

(value: string) => {
setFixturesText(value)
if (selectedScenarioId) {
setSelectedScenarioId(null)
}
},
[selectedScenarioId],
)

const {
isActive,
Expand All @@ -136,6 +207,8 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) {
reset,
setAutoPlay,
isAutoPlaying,
playbackSpeed,
setPlaybackSpeed,
} = simulation

const handleRun = useCallback(() => {
Expand Down Expand Up @@ -207,13 +280,38 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) {
</button>
</div>

{hasScenarios && (
<div style={inputAreaStyle}>
<label style={{ display: 'block', fontSize: 11, marginBottom: 4, color: '#8887A5' }}>
Example Scenario
</label>
<select
value={selectedScenarioId ?? ''}
onChange={(e) => { handleSelectScenario(e.target.value) }}
style={selectStyle}
>
<option value="">Custom input</option>
{scenarios.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
{selectedScenario && (
<div style={{ fontSize: 11, color: '#8887A5', marginTop: 4 }}>
{selectedScenario.description}
</div>
)}
</div>
)}

<div style={inputAreaStyle}>
<label style={{ display: 'block', fontSize: 11, marginBottom: 4, color: '#8887A5' }}>
Input JSON
</label>
<textarea
value={inputText}
onChange={(e) => { setInputText(e.target.value) }}
onChange={(e) => { handleInputChange(e.target.value) }}
style={textareaStyle}
placeholder='{"key": "value"}'
/>
Expand All @@ -236,7 +334,7 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) {
</label>
<textarea
value={fixturesText}
onChange={(e) => { setFixturesText(e.target.value) }}
onChange={(e) => { handleFixturesChange(e.target.value) }}
style={textareaStyle}
placeholder='{"wait_node_id": {"event": "data"}}'
/>
Expand Down Expand Up @@ -306,6 +404,21 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) {
>
{isAutoPlaying ? 'Pause' : 'Play'}
</button>

{/* Speed buttons */}
<span style={{ color: '#8887A5', fontSize: 10, marginLeft: 4 }}>Speed:</span>
{[0.5, 1, 2, 4].map((speed) => (
<button
key={speed}
type="button"
onClick={() => { setPlaybackSpeed(speed) }}
style={playbackSpeed === speed ? btnActiveStyle : btnStyle}
title={`${String(speed)}x speed`}
>
{speed}x
</button>
))}

<button type="button" onClick={stop} style={{ ...btnStyle, color: '#f38ba8' }}>
Stop
</button>
Expand Down
75 changes: 75 additions & 0 deletions packages/app/src/data/template-scenarios-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { simulateGraph } from '@ruminaider/flowprint-engine/browser'
import type { RulesDocument } from '@ruminaider/flowprint-engine/browser'
import type { RulesDataMap } from '@ruminaider/flowprint-editor'
import { getTemplates, loadTemplate } from './templates'
import { getScenarios } from './template-scenarios'

function convertRulesData(map: RulesDataMap): Record<string, RulesDocument> {
const result: Record<string, RulesDocument> = {}
for (const [key, entry] of Object.entries(map)) {
if (entry.data) {
result[key] = entry.data as unknown as RulesDocument
}
}
return result
}

describe('template scenarios e2e', () => {
const templates = getTemplates()

for (const tmpl of templates) {
const doc = loadTemplate(tmpl.id)
const docName = doc.name
const scenarios = getScenarios(docName)

if (scenarios.length === 0) continue

describe(docName, () => {
for (const scenario of scenarios) {
it(`${scenario.id}: ${scenario.name}`, async () => {
const trace = await simulateGraph(doc, {
input: scenario.input,
fixtures: scenario.fixtures,
rulesData: scenario.rulesData ? convertRulesData(scenario.rulesData) : {},
})

// 1. No engine crash
expect(
trace.status,
`Engine error: ${trace.error ?? 'unknown'}`,
).not.toBe('error')

// 2. Produced steps
expect(trace.steps.length).toBeGreaterThan(0)

// 3. Last step is terminal
const lastStep = trace.steps[trace.steps.length - 1]
expect(lastStep?.type, 'Last step should be terminal').toBe('terminal')

// 4. No switch fell through (no-match means missing fixture/case)
const noMatchSteps = trace.steps.filter((s) => s.status === 'no-match')
expect(
noMatchSteps,
`Switch nodes fell through: ${noMatchSteps.map((s) => s.node_id).join(', ')}`,
).toHaveLength(0)

// 5. No unexpected errors (error-caught is OK)
const errorSteps = trace.steps.filter(
(s) => s.status === 'error' && s.type !== 'error',
)
expect(
errorSteps,
`Unexpected errors: ${errorSteps.map((s) => `${s.node_id}: ${s.error}`).join(', ')}`,
).toHaveLength(0)

// 6. No infinite loops
expect(
trace.steps.length,
`Possible infinite loop: ${trace.steps.length} steps`,
).toBeLessThan(50)
})
}
})
}
})
Loading