Files
windmill/frontend/src/lib/components/flows/flowDiff.test.ts
T
centdix 3d5b79c154 feat(aichat): simplify flow mode edits (#6981)
* draft

* Phase 1: Remove deprecated granular flow AI tools

Simplify AI chat flow mode to use only YAML-based editing:
- Remove all commented-out granular tools (add_step, remove_step, set_code, etc.)
- Clean up FlowAIChatHelpers interface to only essential methods
- Update system prompts to focus on YAML-only workflow
- Remove unused imports and type definitions

This is part of a larger refactoring to simplify the flow editing
experience to a single YAML editing tool with automatic diff visualization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* use minified json

* use openflow in system prompt

* handle inputs

* cleaning

* cleaning

* diffmode in flowgraph

* remove acceptrejectmodule

* use new diff mode

* cleaning

* better props

* better logic

* cleaning

* accept reject logic

* use get set

* draft manager

* use diff manager

* draft

* Refactor flowDiffManager to be instance-based with auto-computation

- Remove singleton export, making it instantiable per FlowGraphV2
- Add afterFlow state tracking for auto-diff computation
- Add beforeInputSchema/afterInputSchema for schema change tracking
- Add $effect for reactive auto-computation when beforeFlow/afterFlow changes
- Add setAfterFlow() and setInputSchemas() methods
- Simplify accept/reject methods to just mark pending=false
- Add validation to throw error when accepting/rejecting without beforeFlow
- Update setSnapshot to accept undefined for clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor FlowGraphV2 to own diffManager instance

- Import and create diffManager instance per FlowGraphV2
- Remove onAcceptModule and onRejectModule props
- Add validation $effect to error if both diffBeforeFlow and moduleActions provided
- Add $effect to sync props (diffBeforeFlow or moduleActions) to diffManager
- Add $effect to watch current flow changes and update afterFlow
- Replace computedDiff with diffManager.moduleActions
- Use raw modules instead of merged flow (diffManager handles merging)
- Expose getDiffManager() and setBeforeFlow() methods
- Pass diffManager to graph context instead of callbacks
- Remove $inspect for removed props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update FlowModuleSchemaMap to use FlowGraphV2's diffManager

- Remove import of flowDiffManager singleton
- Update setBeforeFlow to call graph.setBeforeFlow()
- Update setModuleActions and getModuleActions to use graph.getDiffManager()
- Add getDiffManager() proxy method
- Simplify handleAcceptModule and handleRejectModule to use new API
- Handle editor state separately from diff operations
- Remove diffBeforeFlow, moduleActions, onAcceptModule, onRejectModule props passed to FlowGraphV2
- Remove onAcceptModule and onRejectModule from Props interface and destructured props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update FlowAIChat to use flowModuleSchemaMap's diffManager

- Remove import of flowDiffManager singleton
- Update revertToSnapshot to use flowModuleSchemaMap.getDiffManager()
- Add null check for diffManager before using

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Verify FlowGraphDiffViewer compatibility with refactored architecture

FlowGraphDiffViewer already uses the correct prop patterns:
- Before graph: moduleActions prop (display-only mode)
- After graph: diffBeforeFlow prop (full diff mode with auto-computation)

Each FlowGraphV2 instance creates its own diffManager, making the side-by-side
view work correctly with independent diff state per graph.

No code changes required.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update graph components to use diffManager instead of callbacks

- Update graphBuilder.svelte.ts to pass diffManager instead of onAcceptModule/onRejectModule
- Update InputNode and ModuleN type definitions with diffManager
- Update ModuleNode.svelte to pass diffManager to MapItem
- Update MapItem.svelte to pass diffManager to FlowModuleSchemaItem
- Update FlowModuleSchemaItem.svelte to use diffManager directly for accept/reject
- Replace callback-based accept/reject with direct diffManager calls
- Only show accept/reject buttons when beforeFlow exists and action is pending

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix removed modules not showing in diff viewer

Problem: After refactoring, removed modules were no longer appearing in the
diff viewer because we changed effectiveModules from using the merged flow
(which includes removed modules) to using raw modules.

Solution:
- Add mergedFlow state to flowDiffManager to store timeline's merged flow
- Add markRemovedAsShadowed parameter support for side-by-side view
- Store timeline.mergedFlow in auto-computation $effect
- Add getter for mergedFlow and setMarkRemovedAsShadowed method
- Clear mergedFlow in clearSnapshot()
- Update FlowGraphV2 to set markRemovedAsShadowed in diffManager
- Update effectiveModules/FailureModule/PreprocessorModule to use mergedFlow

The merged flow contains all modules including removed ones, enabling:
- Unified view: Removed modules appear in red with "removed" badge
- Side-by-side view: Removed modules show as shadowed in After graph

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Simplify accept/reject logic by removing actions instead of toggling pending state

Previously, accepting or rejecting a module action would set pending to false but keep the action in the moduleActions map. This caused a bug where the $effect would overwrite moduleActions with fresh actions having pending: true, making accept/reject buttons reappear on previously handled modules.

Now, when a user accepts or rejects a module action, we remove it entirely from the moduleActions map. This is simpler and fixes the button reappearing issue.

Changes:
- acceptModule: Remove action from moduleActions instead of setting pending: false
- rejectModule: Remove action from moduleActions instead of setting pending: false
- checkAndClearSnapshot: Check if moduleActions is empty instead of checking pending states
- Fix typo: getModuleFromFrom → getModuleFromFlow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* cleaning

* fix logic

* make diff drawer part of manager

* accept submodules

* fixes

* Phase 4: Add checkAndApplyChanges() helper to flowDiffManager

- Added new checkAndApplyChanges() function to apply mergedFlow to flowStore when all changes are decided
- This replaces the old checkAndClearSnapshot() behavior and ensures flowStore is updated atomically
- Handles both flow structure and input schema updates

* Phase 2: Simplify acceptModule() - only modify mergedFlow

- Remove flowStore mutations from acceptModule()
- For removed modules: just delete the shadowed (__prefix) version from mergedFlow
- For added/modified: no action needed (already correct in mergedFlow)
- Call checkAndApplyChanges() to apply changes when all decided

* Phase 3: Simplify rejectModule() - only modify mergedFlow

- Remove all flowStore mutations from rejectModule()
- For added modules: delete from mergedFlow
- For removed modules: replace shadowed (__) module with original from beforeFlow
- For modified modules: restore old version in mergedFlow
- For Input schema: revert afterInputSchema
- Call checkAndApplyChanges() to apply changes when all decided

* Phase 5: Verify acceptAll/rejectAll work with new architecture

- acceptAll() and rejectAll() already pass options correctly to acceptModule/rejectModule
- They will automatically benefit from checkAndApplyChanges()
- No changes needed for this phase

* Phase 6: Remove FlowGraphV2 reactive effect that updates afterFlow

- Removed the  (lines 252-266) that continuously updated afterFlow
- This effect created reactive loops when flowStore changed
- afterFlow should only be set once when AI generates changes via setFlowYaml()
- The initial sync effect (lines 226-250) is kept for prop-driven diff mode

* Phase 7: Update FlowAIChat setFlowYaml to use diffManager

- Changed setFlowYaml() to use diffManager.setAfterFlow() instead of modifying flowStore
- flowStore remains unchanged during AI review phase
- Changes are staged in mergedFlow for user review
- Only applied to flowStore when all changes are accepted/rejected
- Added error handling for missing diffManager

* Fix linter warnings

- Remove unused FlowTimeline type import
- Fix ChangeTracker initialization with proper type parameter
- Keep deleteModuleFromFlow and checkAndClearSnapshot for potential future use

* Update plan document with implementation status

- Mark all phases as complete
- Add commit references
- Update file checklist
- Add implementation summary at top of document

* Add comprehensive implementation summary document

- Detailed overview of architecture changes
- Before/after comparisons for each file
- Complete testing scenarios checklist
- Troubleshooting guide
- Migration notes and backwards compatibility info

* Show pending modules in editor panel

- Pass diffManager from FlowModuleSchemaMap to FlowEditorPanel
- Add effectiveModules derived value that uses mergedFlow when in diff mode
- Update module iteration to use effectiveModules instead of flowStore
- Allows users to view added/modified modules during AI review
- Fixes issue where clicking on pending modules showed nothing

* Add implementation summary for show pending modules feature

* fix

* shorter system prompt

* Fix Input schema diff mode issues

- Add Accept/Reject buttons to Input node (previously only showed Diff button)
- Pass diffManager to FlowInput component
- Add effectiveSchema derived value that uses afterInputSchema when in diff mode
- Add effectiveDisabled to prevent editing Input when reviewing AI changes
- Update FlowInputViewer to show pending schema changes
- Fixes issue where Input schema changes couldn't be accepted/rejected
- Fixes issue where pending Input schema wasn't visible in the panel

* Disable delete and move buttons when in pending mode

- Add effectiveDeletable derived value that checks diffManager.hasPendingChanges
- Replace all instances of deletable with effectiveDeletable in template
- Prevents delete/move operations when AI changes are being reviewed
- Delete and move buttons are hidden when there are pending changes
- Buttons reappear once all changes are accepted or rejected
- Prevents conflicting operations during review phase

* no move or delte when reviewing

* use context

* inline script reduction

* use json

* rollback to direct modif

* fix merge

* cleaning

* fix reject removed

* add set step code tool

* better prompt

* add back relevant tools

* add back accept reject

* use edit mode for pending

* fix input

* remove unneeded effect

* cleaner + bug fix

* fix failure and preprocessor

* fix show diff for failure module

* fix accept reject on failre module

* no auto add module to context

* cleaning

* add back effect

* cleaning

* fix multiple setflowjson

* track effectivemoduleactions for graph rendering

* nit prompt

* styling

* rm md files

* rm flake copy

* cleaning

* fix z index

* fix revert

* only change before after

* use add remove modify tools

* input + failure + preproc tools

* parsing issues

* nit

* use raw schema for tools

* resolve ref for gemini

* fix schema

* show test on graph

* much cleaner logic

* ignore empty assets

* Remove debug console.log statements from production code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove debug $inspect calls from FlowGraphV2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add error logging to setFlowJson before re-throwing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Standardize null/undefined handling to prefer null

- Use .nullable().optional() instead of .nullish() in Zod schemas
- Simplify addModuleToFlow signature to use string | null
- Coerce undefined to null when extracting parsed args
- Simplify null checks to only check !== null

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove debug console.log from AI tool functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Extract special module IDs to constants

Add SPECIAL_MODULE_IDS constant with INPUT, PREPROCESSOR, and FAILURE
to avoid magic strings throughout the flow AI chat code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add cleanup for diffDrawer reference on unmount

Prevents potential memory leaks by clearing the diffDrawer reference
when the FlowGraphV2 component is destroyed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Use structuredClone instead of JSON.parse(JSON.stringify())

structuredClone is more efficient and type-safe for deep cloning objects.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Cache module lookups in reconstructMergedFlow

Move getAllModulesMap and getAllModuleIds calls outside the loop to avoid
redundant recomputation. Track merged IDs incrementally as modules are added.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "Use structuredClone instead of JSON.parse(JSON.stringify())"

This reverts commit a62ba5b980.

* cleaning

* allow delete

* better openflow for ai agents + truncate system prompt

* handle ai agent tools

* fix set code for tool

* fix wrong cancel request called

* mark tool calls as canceled

* get lang instructions

* use streamiing args

* give db url to claude

* fix revert

* save and clear when leaving editor

* keep whitespace in user message

* uniformize colors

* fix diff button

* remove db from backend claude

* remove move module tool

* no failure and preprocessor

* fix error given to llm

* fix z index

* fix ts errors

* cleaning

* fix add module logic

* fix(copilot): add 'tools' to branchPath description for aiagent containers

The branchPath parameter description was missing 'tools' option for aiagent
containers and didn't mention branchall support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): correct AI agent tool IDs and summaries documentation

Tool summaries CAN contain spaces (they're human-readable descriptions).
Only tool IDs must avoid spaces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): remove reference to non-existent set_flow_json tool

The set_module_code tool description referenced set_flow_json which
doesn't exist as an exposed tool (it's an internal helper).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): clarify inspect_inline_script is read-only

The tool description incorrectly suggested it could modify code.
This tool only inspects - use set_module_code to modify.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): clarify afterId behavior for AI agent tools

Updated wording to clarify that afterId can be used but is optional
for AI agent tools since tool order doesn't affect execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(copilot): remove unused id param from get_instructions_for_code_generation

The id parameter was only used to check for preprocessor, which is no
longer needed. Simplified the tool to only require the language param.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(copilot): add result format to search_scripts tool description

Helps AI understand what data format to expect from the tool.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(copilot): add result format to resource_type tool description

Helps AI understand what data format to expect from the tool and
provides example resource type names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* nit

* Add support for adding branches to branchall/branchone via add_module

Previously, add_module could only add modules inside existing branches.
Now, using insideId with branchPath=null will add a NEW branch to a
branchall or branchone container.

API:
- add_module({ insideId: "my_branchall", branchPath: null, value: { summary: "New Branch", skip_failure: false, modules: [] } })
- add_module({ insideId: "my_branchone", branchPath: null, value: { summary: "Condition", expr: "...", modules: [] } })

Changes:
- Extended addModuleToFlow to handle branchPath=null case
- Updated validation to allow branchPath=null when adding branches
- Updated tool descriptions and system prompt documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* nit

* add remove branch tool

* check all ids for duplicates

* no dup

* nit

* cleaning

* fix dup ids

* split core.ts

* only mount diff drawer if useful

* remove wrong logic

* update exprs

* fix

* chore(flow): Add unit tests to flow diff manager (#7291)

* setup

* add basic tests for flowdiff

* add complex tests

* fix branch issue

* more complex tests

* add flow diff manager tests

* add utils

* better handling of moved case

* more tests for move case

* add buggy test case

* rm

* rework config

* cleaning

* fix config

* rm

* fix for reverting type change module

* all good

* rm

* add missing testmode

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-08 19:08:14 +01:00

1520 lines
64 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { buildFlowTimeline, hasInputSchemaChanged } from './flowDiff'
import type { FlowValue, RawScript, ForloopFlow, WhileloopFlow, BranchOne, BranchAll } from '$lib/gen'
import {
createRawScriptModule,
createIdentityModule,
createForloopModule,
createWhileloopModule,
createBranchOneModule,
createBranchAllModule,
createFlow,
createFlowWithSpecialModules,
expectModuleOrder
} from './flowDiff.testUtils'
describe('buildFlowTimeline', () => {
describe('basic detection', () => {
it('returns empty actions for identical flows', () => {
const moduleA = createRawScriptModule('a', 'console.log("hello")')
const beforeFlow = createFlow([moduleA])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(Object.keys(result.beforeActions)).toHaveLength(0)
expect(Object.keys(result.afterActions)).toHaveLength(0)
})
it('detects added module', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA])
const afterFlow = createFlow([moduleA, moduleB])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions).toEqual({})
expect(result.afterActions).toEqual({
b: { action: 'added', pending: false }
})
})
it('detects removed module', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions).toEqual({
b: { action: 'removed', pending: false }
})
// By default markRemovedAsShadowed is false, so removed modules show as 'removed' in afterActions
expect(result.afterActions).toEqual({
b: { action: 'removed', pending: false }
})
})
it('detects modified module (same type, different content)', () => {
const moduleBeforeA = createRawScriptModule('a', 'console.log("before")')
const moduleAfterA = createRawScriptModule('a', 'console.log("after")')
const beforeFlow = createFlow([moduleBeforeA])
const afterFlow = createFlow([moduleAfterA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions).toEqual({
a: { action: 'modified', pending: false }
})
expect(result.afterActions).toEqual({
a: { action: 'modified', pending: false }
})
})
it('treats type change as removed + added (not modified)', () => {
const moduleBeforeA = createRawScriptModule('a', 'console.log("script")')
const moduleAfterA = createIdentityModule('a')
const beforeFlow = createFlow([moduleBeforeA])
const afterFlow = createFlow([moduleAfterA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions).toEqual({
a: { action: 'removed', pending: false }
})
// The new module is 'added', and the old module is kept in mergedFlow
// with a prefixed ID 'old__a' and marked as 'removed'
expect(result.afterActions['a']).toEqual({ action: 'added', pending: false })
expect(result.afterActions['old__a']).toEqual({ action: 'removed', pending: false })
})
})
describe('options', () => {
it('markAsPending sets pending: true on all actions', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA])
const afterFlow = createFlow([moduleB])
const result = buildFlowTimeline(beforeFlow, afterFlow, {
markAsPending: true,
markRemovedAsShadowed: false
})
expect(result.beforeActions).toEqual({
a: { action: 'removed', pending: true }
})
expect(result.afterActions).toEqual({
a: { action: 'removed', pending: true },
b: { action: 'added', pending: true }
})
})
it('markRemovedAsShadowed: true shows removed as shadowed in afterActions', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow, {
markAsPending: false,
markRemovedAsShadowed: true
})
expect(result.beforeActions).toEqual({
b: { action: 'removed', pending: false }
})
expect(result.afterActions).toEqual({
b: { action: 'shadowed', pending: false }
})
})
it('markRemovedAsShadowed: false shows removed as removed in afterActions', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow, {
markAsPending: false,
markRemovedAsShadowed: false
})
expect(result.beforeActions).toEqual({
b: { action: 'removed', pending: false }
})
expect(result.afterActions).toEqual({
b: { action: 'removed', pending: false }
})
})
})
describe('mergedFlow', () => {
it('mergedFlow contains all modules from afterFlow', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA])
const afterFlow = createFlow([moduleA, moduleB])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'b'])
})
it('mergedFlow includes removed modules from beforeFlow', () => {
const moduleA = createRawScriptModule('a', 'console.log("a")')
const moduleB = createRawScriptModule('b', 'console.log("b")')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'b'])
})
})
describe('edge cases', () => {
it('handles empty flows', () => {
const beforeFlow = createFlow([])
const afterFlow = createFlow([])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(Object.keys(result.beforeActions)).toHaveLength(0)
expect(Object.keys(result.afterActions)).toHaveLength(0)
expect(result.mergedFlow.modules).toHaveLength(0)
})
it('handles flow with undefined modules', () => {
const beforeFlow = {}
const afterFlow = {}
const result = buildFlowTimeline(beforeFlow as FlowValue, afterFlow as FlowValue)
expect(Object.keys(result.beforeActions)).toHaveLength(0)
expect(Object.keys(result.afterActions)).toHaveLength(0)
})
it('handles multiple changes at once', () => {
const moduleA = createRawScriptModule('a', 'original')
const moduleB = createRawScriptModule('b', 'to be removed')
const moduleAModified = createRawScriptModule('a', 'modified')
const moduleC = createRawScriptModule('c', 'newly added')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleAModified, moduleC])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// Module 'a' was modified
expect(result.beforeActions['a']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['a']).toEqual({ action: 'modified', pending: false })
// Module 'b' was removed
expect(result.beforeActions['b']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['b']).toEqual({ action: 'removed', pending: false })
// Module 'c' was added
expect(result.afterActions['c']).toEqual({ action: 'added', pending: false })
})
})
describe('forloop operations', () => {
it('detects added module inside forloop', () => {
const innerA = createRawScriptModule('inner_a', 'step a')
const innerB = createRawScriptModule('inner_b', 'step b')
const beforeLoop = createForloopModule('loop1', [innerA])
const afterLoop = createForloopModule('loop1', [innerA, innerB])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['inner_b']).toEqual({ action: 'added', pending: false })
expect(result.beforeActions['inner_b']).toBeUndefined()
// Verify mergedFlow contains the loop with both modules in correct order
expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1'])
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
expect(mergedLoop?.value.type).toBe('forloopflow')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_a', 'inner_b'])
})
it('detects removed module from inside forloop', () => {
const innerA = createRawScriptModule('inner_a', 'step a')
const innerB = createRawScriptModule('inner_b', 'step b')
const beforeLoop = createForloopModule('loop1', [innerA, innerB])
const afterLoop = createForloopModule('loop1', [innerA])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['inner_b']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['inner_b']).toEqual({ action: 'removed', pending: false })
// Verify removed module is in mergedFlow inside the loop at correct position
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
expect(mergedLoop?.value.type).toBe('forloopflow')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_a', 'inner_b'])
})
it('detects modified module inside forloop', () => {
const innerBefore = createRawScriptModule('inner_a', 'original code')
const innerAfter = createRawScriptModule('inner_a', 'modified code')
const beforeLoop = createForloopModule('loop1', [innerBefore])
const afterLoop = createForloopModule('loop1', [innerAfter])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['inner_a']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['inner_a']).toEqual({ action: 'modified', pending: false })
// Verify mergedFlow contains the loop with the modified module
expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1'])
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_a'])
// Verify the modified content is present (from afterFlow)
const innerModule = loopModules.find((m) => m.id === 'inner_a')
expect((innerModule?.value as RawScript).content).toBe('modified code')
})
it('detects removal of entire forloop with nested children', () => {
const innerA = createRawScriptModule('inner_a', 'step a')
const innerB = createRawScriptModule('inner_b', 'step b')
const loop = createForloopModule('loop1', [innerA, innerB])
const moduleC = createRawScriptModule('c', 'step c')
const beforeFlow = createFlow([loop, moduleC])
const afterFlow = createFlow([moduleC])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The loop itself and all nested modules should be marked as removed
expect(result.beforeActions['loop1']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['inner_a']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['inner_b']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow contains the removed loop with all nested children at correct positions
expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1', 'c'])
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
expect(mergedLoop?.value.type).toBe('forloopflow')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_a', 'inner_b'])
})
it('handles nested forloop inside forloop with changes at inner level', () => {
const deepInnerA = createRawScriptModule('deep_a', 'deep step')
const deepInnerB = createRawScriptModule('deep_b', 'new deep step')
const innerLoopBefore = createForloopModule('inner_loop', [deepInnerA])
const innerLoopAfter = createForloopModule('inner_loop', [deepInnerA, deepInnerB])
const outerLoopBefore = createForloopModule('outer_loop', [innerLoopBefore])
const outerLoopAfter = createForloopModule('outer_loop', [innerLoopAfter])
const beforeFlow = createFlow([outerLoopBefore])
const afterFlow = createFlow([outerLoopAfter])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The new nested module is detected as added
expect(result.afterActions['deep_b']).toEqual({ action: 'added', pending: false })
// Note: Container modules are marked as 'modified' because their nested content changed
// (deepEqual comparison includes nested modules array)
expect(result.beforeActions['outer_loop']).toEqual({ action: 'modified', pending: false })
expect(result.beforeActions['inner_loop']).toEqual({ action: 'modified', pending: false })
// Verify mergedFlow preserves the outer_loop → inner_loop → [deep_a, deep_b] structure
expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_loop'])
const mergedOuterLoop = result.mergedFlow.modules?.find((m) => m.id === 'outer_loop')
expect(mergedOuterLoop?.value.type).toBe('forloopflow')
const outerLoopModules = (mergedOuterLoop?.value as ForloopFlow).modules
expectModuleOrder(outerLoopModules, ['inner_loop'])
const mergedInnerLoop = outerLoopModules.find((m) => m.id === 'inner_loop')
expect(mergedInnerLoop?.value.type).toBe('forloopflow')
const innerLoopModules = (mergedInnerLoop?.value as ForloopFlow).modules
expectModuleOrder(innerLoopModules, ['deep_a', 'deep_b'])
})
})
describe('whileloop operations', () => {
it('detects changes inside whileloop', () => {
const innerA = createRawScriptModule('while_inner_a', 'step a')
const innerB = createRawScriptModule('while_inner_b', 'step b')
const beforeLoop = createWhileloopModule('while1', [innerA])
const afterLoop = createWhileloopModule('while1', [innerA, innerB])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['while_inner_b']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow contains the whileloop with both modules in correct order
expectModuleOrder(result.mergedFlow.modules ?? [], ['while1'])
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'while1')
expect(mergedLoop?.value.type).toBe('whileloopflow')
const loopModules = (mergedLoop?.value as WhileloopFlow).modules
expectModuleOrder(loopModules, ['while_inner_a', 'while_inner_b'])
})
it('handles removal of module from whileloop and restores in mergedFlow', () => {
const innerA = createRawScriptModule('while_a', 'step a')
const innerB = createRawScriptModule('while_b', 'step b')
const beforeLoop = createWhileloopModule('while1', [innerA, innerB])
const afterLoop = createWhileloopModule('while1', [innerA])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['while_b']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow contains the removed module at correct position
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'while1')
const loopModules = (mergedLoop?.value as WhileloopFlow).modules
expectModuleOrder(loopModules, ['while_a', 'while_b'])
})
})
describe('branchone operations', () => {
it('detects added module in default branch', () => {
const defaultA = createRawScriptModule('default_a', 'default step a')
const defaultB = createRawScriptModule('default_b', 'default step b')
const beforeBranch = createBranchOneModule('branch1', [defaultA], [])
const afterBranch = createBranchOneModule('branch1', [defaultA, defaultB], [])
const beforeFlow = createFlow([beforeBranch])
const afterFlow = createFlow([afterBranch])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['default_b']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow.branch1.default contains both modules in correct order
expectModuleOrder(result.mergedFlow.modules ?? [], ['branch1'])
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1')
expect(mergedBranch?.value.type).toBe('branchone')
const defaultModules = (mergedBranch?.value as BranchOne).default
expectModuleOrder(defaultModules, ['default_a', 'default_b'])
})
it('detects removed module from conditional branch', () => {
const branchModuleA = createRawScriptModule('branch_a', 'branch step a')
const branchModuleB = createRawScriptModule('branch_b', 'branch step b')
const beforeBranch = createBranchOneModule('branch1', [], [
{ expr: 'true', modules: [branchModuleA, branchModuleB] }
])
const afterBranch = createBranchOneModule('branch1', [], [
{ expr: 'true', modules: [branchModuleA] }
])
const beforeFlow = createFlow([beforeBranch])
const afterFlow = createFlow([afterBranch])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['branch_b']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['branch_b']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow.branch1.branches[0].modules contains the removed branch_b at correct position
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1')
const branches = (mergedBranch?.value as BranchOne).branches
expect(branches).toHaveLength(1)
expectModuleOrder(branches[0].modules, ['branch_a', 'branch_b'])
})
it('detects removal of entire conditional branch', () => {
const defaultModule = createRawScriptModule('default_mod', 'default')
const branch1ModuleA = createRawScriptModule('b1_a', 'branch 1 a')
const branch2ModuleA = createRawScriptModule('b2_a', 'branch 2 a')
const branch2ModuleB = createRawScriptModule('b2_b', 'branch 2 b')
const beforeBranch = createBranchOneModule('branch1', [defaultModule], [
{ expr: 'x > 0', modules: [branch1ModuleA] },
{ expr: 'x < 0', modules: [branch2ModuleA, branch2ModuleB] } // entire branch removed
])
const afterBranch = createBranchOneModule('branch1', [defaultModule], [
{ expr: 'x > 0', modules: [branch1ModuleA] }
])
const beforeFlow = createFlow([beforeBranch])
const afterFlow = createFlow([afterBranch])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// All modules from removed branch should be marked as removed
expect(result.beforeActions['b2_a']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['b2_b']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['b2_a']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['b2_b']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow structure - should have 2 branches with removed modules restored
expectModuleOrder(result.mergedFlow.modules ?? [], ['branch1'])
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1')
const branchValue = mergedBranch?.value as BranchOne
// Default branch unchanged
expectModuleOrder(branchValue.default, ['default_mod'])
// First branch unchanged
expectModuleOrder(branchValue.branches[0].modules, ['b1_a'])
// Second branch should be restored with its modules
expect(branchValue.branches).toHaveLength(2)
expectModuleOrder(branchValue.branches[1].modules, ['b2_a', 'b2_b'])
})
it('detects changes across multiple branches simultaneously', () => {
const defaultModule = createRawScriptModule('default_mod', 'default')
const branch1ModuleA = createRawScriptModule('b1_a', 'branch 1 a')
const branch1ModuleB = createRawScriptModule('b1_b', 'branch 1 b - new')
const branch2ModuleA = createRawScriptModule('b2_a', 'branch 2 a - original')
const branch2ModuleAModified = createRawScriptModule('b2_a', 'branch 2 a - modified')
const beforeBranch = createBranchOneModule('branch1', [defaultModule], [
{ expr: 'x > 0', modules: [branch1ModuleA] },
{ expr: 'x < 0', modules: [branch2ModuleA] }
])
const afterBranch = createBranchOneModule('branch1', [defaultModule], [
{ expr: 'x > 0', modules: [branch1ModuleA, branch1ModuleB] },
{ expr: 'x < 0', modules: [branch2ModuleAModified] }
])
const beforeFlow = createFlow([beforeBranch])
const afterFlow = createFlow([afterBranch])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// branch1ModuleB was added
expect(result.afterActions['b1_b']).toEqual({ action: 'added', pending: false })
// branch2ModuleA was modified
expect(result.beforeActions['b2_a']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['b2_a']).toEqual({ action: 'modified', pending: false })
// Verify mergedFlow preserves structure with correct ordering
expectModuleOrder(result.mergedFlow.modules ?? [], ['branch1'])
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1')
const branchValue = mergedBranch?.value as BranchOne
// Check default branch
expectModuleOrder(branchValue.default, ['default_mod'])
// Check first conditional branch (should have b1_a and b1_b in order)
expectModuleOrder(branchValue.branches[0].modules, ['b1_a', 'b1_b'])
// Check second conditional branch (should have modified b2_a)
expectModuleOrder(branchValue.branches[1].modules, ['b2_a'])
const b2aModule = branchValue.branches[1].modules.find((m) => m.id === 'b2_a')
expect((b2aModule?.value as RawScript).content).toBe('branch 2 a - modified')
})
it('handles nested branch inside loop with changes', () => {
const branchInnerA = createRawScriptModule('nested_branch_a', 'nested a')
const branchInnerB = createRawScriptModule('nested_branch_b', 'nested b')
const beforeBranch = createBranchOneModule('inner_branch', [branchInnerA], [])
const afterBranch = createBranchOneModule('inner_branch', [branchInnerA, branchInnerB], [])
const beforeLoop = createForloopModule('outer_loop', [beforeBranch])
const afterLoop = createForloopModule('outer_loop', [afterBranch])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['nested_branch_b']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow.outer_loop.inner_branch.default contains added nested_branch_b at correct position
expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_loop'])
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'outer_loop')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_branch'])
const mergedBranch = loopModules.find((m) => m.id === 'inner_branch')
const branchDefault = (mergedBranch?.value as BranchOne).default
expectModuleOrder(branchDefault, ['nested_branch_a', 'nested_branch_b'])
})
})
describe('branchall operations', () => {
it('detects changes in parallel branches', () => {
const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a')
const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a')
const parallel2B = createRawScriptModule('p2_b', 'parallel 2 b - new')
const beforeBranchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A] },
{ modules: [parallel2A] }
])
const afterBranchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A] },
{ modules: [parallel2A, parallel2B] }
])
const beforeFlow = createFlow([beforeBranchAll])
const afterFlow = createFlow([afterBranchAll])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['p2_b']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow.branchall1.branches have correct module order
expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1'])
const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1')
expect(mergedBranchAll?.value.type).toBe('branchall')
const branches = (mergedBranchAll?.value as BranchAll).branches
expect(branches).toHaveLength(2)
// First branch unchanged
expectModuleOrder(branches[0].modules, ['p1_a'])
// Second branch has added module in correct order
expectModuleOrder(branches[1].modules, ['p2_a', 'p2_b'])
})
it('detects removal of entire branchall with all nested modules', () => {
const parallel1A = createRawScriptModule('p1_a', 'parallel 1')
const parallel2A = createRawScriptModule('p2_a', 'parallel 2')
const branchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A] },
{ modules: [parallel2A] }
])
const moduleC = createRawScriptModule('c', 'step c')
const beforeFlow = createFlow([branchAll, moduleC])
const afterFlow = createFlow([moduleC])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['branchall1']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['p1_a']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['p2_a']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow contains the removed branchall with correct structure
expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1', 'c'])
const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1')
expect(mergedBranchAll?.value.type).toBe('branchall')
const branches = (mergedBranchAll?.value as BranchAll).branches
expect(branches).toHaveLength(2)
expectModuleOrder(branches[0].modules, ['p1_a'])
expectModuleOrder(branches[1].modules, ['p2_a'])
})
it('handles removal from one parallel branch while adding to another', () => {
const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a')
const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b - to remove')
const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a')
const parallel2B = createRawScriptModule('p2_b', 'parallel 2 b - new')
const beforeBranchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A, parallel1B] },
{ modules: [parallel2A] }
])
const afterBranchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A] },
{ modules: [parallel2A, parallel2B] }
])
const beforeFlow = createFlow([beforeBranchAll])
const afterFlow = createFlow([afterBranchAll])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['p1_b']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['p2_b']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow.branchall1 has correct structure with all modules in order
expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1'])
const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1')
const branches = (mergedBranchAll?.value as BranchAll).branches
expect(branches).toHaveLength(2)
// First branch should have both p1_a and removed p1_b restored in order
expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b'])
// Second branch should have p2_a and added p2_b in order
expectModuleOrder(branches[1].modules, ['p2_a', 'p2_b'])
})
})
describe('deep nesting', () => {
it('handles loop inside branch inside loop with modifications at each level', () => {
// Structure: outerLoop -> branch -> innerLoop -> scripts
const deepScript1 = createRawScriptModule('deep1', 'deep script 1')
const deepScript2 = createRawScriptModule('deep2', 'deep script 2')
const deepScript3 = createRawScriptModule('deep3', 'deep script 3 - new')
const innerLoopBefore = createForloopModule('inner_loop', [deepScript1, deepScript2])
const innerLoopAfter = createForloopModule('inner_loop', [deepScript1, deepScript3])
const branchBefore = createBranchOneModule('mid_branch', [innerLoopBefore], [])
const branchAfter = createBranchOneModule('mid_branch', [innerLoopAfter], [])
const outerLoopBefore = createForloopModule('outer_loop', [branchBefore])
const outerLoopAfter = createForloopModule('outer_loop', [branchAfter])
const beforeFlow = createFlow([outerLoopBefore])
const afterFlow = createFlow([outerLoopAfter])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// deep2 was removed
expect(result.beforeActions['deep2']).toEqual({ action: 'removed', pending: false })
// deep3 was added
expect(result.afterActions['deep3']).toEqual({ action: 'added', pending: false })
// Note: Container modules are marked as 'modified' because their nested content changed
// (deepEqual comparison includes nested modules array)
expect(result.beforeActions['outer_loop']).toEqual({ action: 'modified', pending: false })
expect(result.beforeActions['mid_branch']).toEqual({ action: 'modified', pending: false })
expect(result.beforeActions['inner_loop']).toEqual({ action: 'modified', pending: false })
// Verify full structure with correct ordering at each level
expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_loop'])
const mergedOuterLoop = result.mergedFlow.modules?.find((m) => m.id === 'outer_loop')
expect(mergedOuterLoop?.value.type).toBe('forloopflow')
const outerLoopModules = (mergedOuterLoop?.value as ForloopFlow).modules
expectModuleOrder(outerLoopModules, ['mid_branch'])
const mergedMidBranch = outerLoopModules.find((m) => m.id === 'mid_branch')
expect(mergedMidBranch?.value.type).toBe('branchone')
const midBranchDefault = (mergedMidBranch?.value as BranchOne).default
expectModuleOrder(midBranchDefault, ['inner_loop'])
const mergedInnerLoop = midBranchDefault.find((m) => m.id === 'inner_loop')
expect(mergedInnerLoop?.value.type).toBe('forloopflow')
const innerLoopModules = (mergedInnerLoop?.value as ForloopFlow).modules
// deep1 is unchanged, deep2 was removed (but restored in mergedFlow), deep3 was added
expectModuleOrder(innerLoopModules, ['deep1', 'deep2', 'deep3'])
})
it('handles complex scenario with multiple nested structures and simultaneous changes', () => {
// Before: loop1 -> [scriptA, branch1 -> [scriptB, scriptC]]
// After: loop1 -> [scriptA_modified, branch1 -> [scriptB, scriptD]]
const scriptA = createRawScriptModule('a', 'script a original')
const scriptAModified = createRawScriptModule('a', 'script a modified')
const scriptB = createRawScriptModule('b', 'script b')
const scriptC = createRawScriptModule('c', 'script c - to remove')
const scriptD = createRawScriptModule('d', 'script d - new')
const branchBefore = createBranchOneModule('branch1', [scriptB, scriptC], [])
const branchAfter = createBranchOneModule('branch1', [scriptB, scriptD], [])
const loopBefore = createForloopModule('loop1', [scriptA, branchBefore])
const loopAfter = createForloopModule('loop1', [scriptAModified, branchAfter])
const beforeFlow = createFlow([loopBefore])
const afterFlow = createFlow([loopAfter])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// scriptA was modified
expect(result.beforeActions['a']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['a']).toEqual({ action: 'modified', pending: false })
// scriptC was removed
expect(result.beforeActions['c']).toEqual({ action: 'removed', pending: false })
// scriptD was added
expect(result.afterActions['d']).toEqual({ action: 'added', pending: false })
// scriptB unchanged
expect(result.beforeActions['b']).toBeUndefined()
expect(result.afterActions['b']).toBeUndefined()
// Verify mergedFlow structure with correct ordering
expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1'])
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['a', 'branch1'])
// Modified scriptA should be in mergedFlow with updated content
const mergedA = loopModules.find((m) => m.id === 'a')
expect((mergedA?.value as RawScript).content).toBe('script a modified')
// Branch1 should contain scriptB, removed c, and added d in correct order
const mergedBranch = loopModules.find((m) => m.id === 'branch1')
const branchDefault = (mergedBranch?.value as BranchOne).default
expectModuleOrder(branchDefault, ['b', 'c', 'd'])
})
it('preserves correct structure in mergedFlow for deeply nested removals', () => {
// Remove a script from inside a loop that is inside a branch
const deepScript = createRawScriptModule('deep', 'deep script to remove')
const keepScript = createRawScriptModule('keep', 'script to keep')
const innerLoopBefore = createForloopModule('inner_loop', [deepScript, keepScript])
const innerLoopAfter = createForloopModule('inner_loop', [keepScript])
const branchBefore = createBranchOneModule('branch', [innerLoopBefore], [])
const branchAfter = createBranchOneModule('branch', [innerLoopAfter], [])
const beforeFlow = createFlow([branchBefore])
const afterFlow = createFlow([branchAfter])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// Navigate into the merged flow structure and verify correct ordering
expectModuleOrder(result.mergedFlow.modules ?? [], ['branch'])
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch')
const branchDefault = (mergedBranch?.value as BranchOne).default
expectModuleOrder(branchDefault, ['inner_loop'])
const mergedInnerLoop = branchDefault.find((m) => m.id === 'inner_loop')
const innerLoopModules = (mergedInnerLoop?.value as ForloopFlow).modules
// Both modules should be present in the merged flow in correct order
expectModuleOrder(innerLoopModules, ['deep', 'keep'])
})
it('handles branchall inside branchone with nested changes', () => {
const scriptInParallel1 = createRawScriptModule('par1', 'parallel 1')
const scriptInParallel2 = createRawScriptModule('par2', 'parallel 2')
const scriptInParallel3 = createRawScriptModule('par3', 'parallel 3 - new')
const branchAllBefore = createBranchAllModule('parallel_section', [
{ modules: [scriptInParallel1] },
{ modules: [scriptInParallel2] }
])
const branchAllAfter = createBranchAllModule('parallel_section', [
{ modules: [scriptInParallel1] },
{ modules: [scriptInParallel2, scriptInParallel3] }
])
const branchOneBefore = createBranchOneModule('outer_branch', [branchAllBefore], [
{ expr: 'x > 0', modules: [] }
])
const branchOneAfter = createBranchOneModule('outer_branch', [branchAllAfter], [
{ expr: 'x > 0', modules: [] }
])
const beforeFlow = createFlow([branchOneBefore])
const afterFlow = createFlow([branchOneAfter])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['par3']).toEqual({ action: 'added', pending: false })
// Other modules unchanged
expect(result.beforeActions['par1']).toBeUndefined()
expect(result.beforeActions['par2']).toBeUndefined()
// Verify full nested structure is preserved in mergedFlow with correct ordering
expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_branch'])
const mergedBranchOne = result.mergedFlow.modules?.find((m) => m.id === 'outer_branch')
expect(mergedBranchOne?.value.type).toBe('branchone')
const branchOneDefault = (mergedBranchOne?.value as BranchOne).default
expectModuleOrder(branchOneDefault, ['parallel_section'])
const mergedBranchAll = branchOneDefault.find((m) => m.id === 'parallel_section')
expect(mergedBranchAll?.value.type).toBe('branchall')
const parallelBranches = (mergedBranchAll?.value as BranchAll).branches
expect(parallelBranches).toHaveLength(2)
// First parallel branch has par1
expectModuleOrder(parallelBranches[0].modules, ['par1'])
// Second parallel branch has par2 and added par3 in correct order
expectModuleOrder(parallelBranches[1].modules, ['par2', 'par3'])
})
})
describe('special modules', () => {
it('detects added failure_module', () => {
const moduleA = createRawScriptModule('a', 'main step')
const failureModule = createRawScriptModule('failure', 'handle failure')
const beforeFlow = createFlowWithSpecialModules({ modules: [moduleA] })
const afterFlow = createFlowWithSpecialModules({
modules: [moduleA],
failure_module: failureModule
})
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['failure']).toEqual({ action: 'added', pending: false })
expect(result.beforeActions['failure']).toBeUndefined()
// Verify mergedFlow contains the failure_module
expect(result.mergedFlow.failure_module?.id).toBe('failure')
})
it('detects removed failure_module', () => {
const moduleA = createRawScriptModule('a', 'main step')
const failureModule = createRawScriptModule('failure', 'handle failure')
const beforeFlow = createFlowWithSpecialModules({
modules: [moduleA],
failure_module: failureModule
})
const afterFlow = createFlowWithSpecialModules({ modules: [moduleA] })
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['failure']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['failure']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow contains the removed failure_module
expect(result.mergedFlow.failure_module?.id).toBe('failure')
})
it('detects modified failure_module', () => {
const moduleA = createRawScriptModule('a', 'main step')
const failureModuleBefore = createRawScriptModule('failure', 'handle failure v1')
const failureModuleAfter = createRawScriptModule('failure', 'handle failure v2')
const beforeFlow = createFlowWithSpecialModules({
modules: [moduleA],
failure_module: failureModuleBefore
})
const afterFlow = createFlowWithSpecialModules({
modules: [moduleA],
failure_module: failureModuleAfter
})
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['failure']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['failure']).toEqual({ action: 'modified', pending: false })
// Verify mergedFlow contains the modified failure_module with new content
expect(result.mergedFlow.failure_module?.id).toBe('failure')
expect((result.mergedFlow.failure_module?.value as RawScript).content).toBe(
'handle failure v2'
)
})
it('detects added preprocessor_module', () => {
const moduleA = createRawScriptModule('a', 'main step')
const preprocessorModule = createRawScriptModule('preprocessor', 'preprocess input')
const beforeFlow = createFlowWithSpecialModules({ modules: [moduleA] })
const afterFlow = createFlowWithSpecialModules({
modules: [moduleA],
preprocessor_module: preprocessorModule
})
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['preprocessor']).toEqual({ action: 'added', pending: false })
expect(result.beforeActions['preprocessor']).toBeUndefined()
// Verify mergedFlow contains the preprocessor_module
expect(result.mergedFlow.preprocessor_module?.id).toBe('preprocessor')
})
it('detects removed preprocessor_module', () => {
const moduleA = createRawScriptModule('a', 'main step')
const preprocessorModule = createRawScriptModule('preprocessor', 'preprocess input')
const beforeFlow = createFlowWithSpecialModules({
modules: [moduleA],
preprocessor_module: preprocessorModule
})
const afterFlow = createFlowWithSpecialModules({ modules: [moduleA] })
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['preprocessor']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['preprocessor']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow contains the removed preprocessor_module
expect(result.mergedFlow.preprocessor_module?.id).toBe('preprocessor')
})
it('detects changes to both failure and preprocessor modules simultaneously', () => {
const moduleA = createRawScriptModule('a', 'main step')
const failureModuleBefore = createRawScriptModule('failure', 'handle failure v1')
const failureModuleAfter = createRawScriptModule('failure', 'handle failure v2')
const preprocessorModule = createRawScriptModule('preprocessor', 'preprocess input')
const beforeFlow = createFlowWithSpecialModules({
modules: [moduleA],
failure_module: failureModuleBefore,
preprocessor_module: preprocessorModule
})
const afterFlow = createFlowWithSpecialModules({
modules: [moduleA],
failure_module: failureModuleAfter
// preprocessor_module removed
})
const result = buildFlowTimeline(beforeFlow, afterFlow)
// failure_module was modified
expect(result.beforeActions['failure']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['failure']).toEqual({ action: 'modified', pending: false })
// preprocessor_module was removed
expect(result.beforeActions['preprocessor']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['preprocessor']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow structure
expect(result.mergedFlow.failure_module?.id).toBe('failure')
expect(result.mergedFlow.preprocessor_module?.id).toBe('preprocessor')
})
})
describe('branchall entire branch removal', () => {
it('detects removal of entire parallel branch from branchall', () => {
const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a')
const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b')
const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a')
const beforeBranchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A, parallel1B] },
{ modules: [parallel2A] } // entire branch removed
])
const afterBranchAll = createBranchAllModule('branchall1', [
{ modules: [parallel1A, parallel1B] }
])
const beforeFlow = createFlow([beforeBranchAll])
const afterFlow = createFlow([afterBranchAll])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// Module from removed branch should be marked as removed
expect(result.beforeActions['p2_a']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['p2_a']).toEqual({ action: 'removed', pending: false })
// Modules from preserved branch should be unchanged
expect(result.beforeActions['p1_a']).toBeUndefined()
expect(result.beforeActions['p1_b']).toBeUndefined()
// Verify mergedFlow structure - should have 2 branches with removed modules restored
expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1'])
const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1')
const branches = (mergedBranchAll?.value as BranchAll).branches
// First branch unchanged
expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b'])
// Second branch should be restored with its module
expect(branches).toHaveLength(2)
expectModuleOrder(branches[1].modules, ['p2_a'])
})
it('detects removal of multiple parallel branches from branchall', () => {
const p1_a = createRawScriptModule('p1_a', 'parallel 1')
const p2_a = createRawScriptModule('p2_a', 'parallel 2')
const p3_a = createRawScriptModule('p3_a', 'parallel 3')
const beforeBranchAll = createBranchAllModule('branchall1', [
{ modules: [p1_a] },
{ modules: [p2_a] }, // removed
{ modules: [p3_a] } // removed
])
const afterBranchAll = createBranchAllModule('branchall1', [{ modules: [p1_a] }])
const beforeFlow = createFlow([beforeBranchAll])
const afterFlow = createFlow([afterBranchAll])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// Modules from removed branches should be marked as removed
expect(result.beforeActions['p2_a']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['p3_a']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow structure - should have 3 branches restored
const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1')
const branches = (mergedBranchAll?.value as BranchAll).branches
expect(branches).toHaveLength(3)
expectModuleOrder(branches[0].modules, ['p1_a'])
expectModuleOrder(branches[1].modules, ['p2_a'])
expectModuleOrder(branches[2].modules, ['p3_a'])
})
})
describe('hasInputSchemaChanged', () => {
it('returns false for identical schemas', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
}
}
const beforeFlow = { schema }
const afterFlow = { schema: JSON.parse(JSON.stringify(schema)) }
expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(false)
})
it('returns true for different schemas', () => {
const beforeFlow = {
schema: {
type: 'object',
properties: {
name: { type: 'string' }
}
}
}
const afterFlow = {
schema: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' }
}
}
}
expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(true)
})
it('returns false for undefined flows', () => {
expect(hasInputSchemaChanged(undefined, undefined)).toBe(false)
expect(hasInputSchemaChanged(undefined, { schema: {} })).toBe(false)
expect(hasInputSchemaChanged({ schema: {} }, undefined)).toBe(false)
})
it('returns true when schema is added', () => {
const beforeFlow = {}
const afterFlow = { schema: { type: 'object' } }
expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(true)
})
it('returns true when schema is removed', () => {
const beforeFlow = { schema: { type: 'object' } }
const afterFlow = {}
expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(true)
})
it('returns false for both empty schemas', () => {
const beforeFlow = { schema: {} }
const afterFlow = { schema: {} }
expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(false)
})
})
describe('multiple removed modules ordering', () => {
it('restores multiple removed modules in correct order', () => {
const moduleA = createRawScriptModule('a', 'first')
const moduleB = createRawScriptModule('b', 'second')
const moduleC = createRawScriptModule('c', 'third')
const moduleD = createRawScriptModule('d', 'fourth')
const moduleE = createRawScriptModule('e', 'fifth')
// Remove modules at beginning, middle, and end
const beforeFlow = createFlow([moduleA, moduleB, moduleC, moduleD, moduleE])
const afterFlow = createFlow([moduleB, moduleD]) // Remove a, c, e
const result = buildFlowTimeline(beforeFlow, afterFlow)
// All removed modules should be marked as removed
expect(result.beforeActions['a']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['c']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['e']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow restores all modules in the original order
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'b', 'c', 'd', 'e'])
})
it('restores multiple removed modules inside a loop in correct order', () => {
const innerA = createRawScriptModule('inner_a', 'first')
const innerB = createRawScriptModule('inner_b', 'second')
const innerC = createRawScriptModule('inner_c', 'third')
const innerD = createRawScriptModule('inner_d', 'fourth')
const beforeLoop = createForloopModule('loop1', [innerA, innerB, innerC, innerD])
const afterLoop = createForloopModule('loop1', [innerB]) // Remove a, c, d
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// All removed modules should be marked as removed
expect(result.beforeActions['inner_a']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['inner_c']).toEqual({ action: 'removed', pending: false })
expect(result.beforeActions['inner_d']).toEqual({ action: 'removed', pending: false })
// Verify mergedFlow restores all modules in the original order inside the loop
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_a', 'inner_b', 'inner_c', 'inner_d'])
})
})
describe('insert position tests', () => {
it('handles module added at beginning of list', () => {
const moduleA = createRawScriptModule('a', 'existing first')
const moduleB = createRawScriptModule('b', 'existing second')
const moduleNew = createRawScriptModule('new', 'new at beginning')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleNew, moduleA, moduleB])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['new']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow maintains correct order with new module at beginning
expectModuleOrder(result.mergedFlow.modules ?? [], ['new', 'a', 'b'])
})
it('handles module added in middle of list', () => {
const moduleA = createRawScriptModule('a', 'first')
const moduleB = createRawScriptModule('b', 'second')
const moduleC = createRawScriptModule('c', 'third')
const moduleNew = createRawScriptModule('new', 'new in middle')
const beforeFlow = createFlow([moduleA, moduleB, moduleC])
const afterFlow = createFlow([moduleA, moduleNew, moduleB, moduleC])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['new']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow maintains correct order with new module in middle
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'new', 'b', 'c'])
})
it('handles module added at beginning inside a loop', () => {
const innerA = createRawScriptModule('inner_a', 'existing first')
const innerB = createRawScriptModule('inner_b', 'existing second')
const innerNew = createRawScriptModule('inner_new', 'new at beginning')
const beforeLoop = createForloopModule('loop1', [innerA, innerB])
const afterLoop = createForloopModule('loop1', [innerNew, innerA, innerB])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['inner_new']).toEqual({ action: 'added', pending: false })
// Verify correct order inside the loop
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_new', 'inner_a', 'inner_b'])
})
it('handles multiple modules added at different positions', () => {
const moduleA = createRawScriptModule('a', 'original a')
const moduleB = createRawScriptModule('b', 'original b')
const moduleNew1 = createRawScriptModule('new1', 'new at start')
const moduleNew2 = createRawScriptModule('new2', 'new in middle')
const moduleNew3 = createRawScriptModule('new3', 'new at end')
const beforeFlow = createFlow([moduleA, moduleB])
const afterFlow = createFlow([moduleNew1, moduleA, moduleNew2, moduleB, moduleNew3])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['new1']).toEqual({ action: 'added', pending: false })
expect(result.afterActions['new2']).toEqual({ action: 'added', pending: false })
expect(result.afterActions['new3']).toEqual({ action: 'added', pending: false })
// Verify mergedFlow maintains correct order
expectModuleOrder(result.mergedFlow.modules ?? [], ['new1', 'a', 'new2', 'b', 'new3'])
})
})
describe('empty containers', () => {
it('handles adding empty forloop', () => {
const moduleA = createRawScriptModule('a', 'step a')
const emptyLoop = createForloopModule('empty_loop', [])
const beforeFlow = createFlow([moduleA])
const afterFlow = createFlow([moduleA, emptyLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['empty_loop']).toEqual({ action: 'added', pending: false })
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_loop'])
// Verify the empty loop has no modules
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'empty_loop')
expect((mergedLoop?.value as ForloopFlow).modules).toHaveLength(0)
})
it('handles removing empty forloop', () => {
const moduleA = createRawScriptModule('a', 'step a')
const emptyLoop = createForloopModule('empty_loop', [])
const beforeFlow = createFlow([moduleA, emptyLoop])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['empty_loop']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['empty_loop']).toEqual({ action: 'removed', pending: false })
// Verify the removed empty loop is in mergedFlow
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_loop'])
})
it('handles adding empty branchone', () => {
const moduleA = createRawScriptModule('a', 'step a')
const emptyBranch = createBranchOneModule('empty_branch', [], [])
const beforeFlow = createFlow([moduleA])
const afterFlow = createFlow([moduleA, emptyBranch])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.afterActions['empty_branch']).toEqual({ action: 'added', pending: false })
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_branch'])
// Verify the empty branch has no modules in default or branches
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'empty_branch')
expect((mergedBranch?.value as BranchOne).default).toHaveLength(0)
expect((mergedBranch?.value as BranchOne).branches).toHaveLength(0)
})
it('handles removing empty branchone with empty default and branches', () => {
const moduleA = createRawScriptModule('a', 'step a')
const emptyBranch = createBranchOneModule('empty_branch', [], [{ expr: 'true', modules: [] }])
const beforeFlow = createFlow([moduleA, emptyBranch])
const afterFlow = createFlow([moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
expect(result.beforeActions['empty_branch']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['empty_branch']).toEqual({ action: 'removed', pending: false })
// Verify the removed empty branch is in mergedFlow with its structure
expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_branch'])
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'empty_branch')
expect((mergedBranch?.value as BranchOne).default).toHaveLength(0)
expect((mergedBranch?.value as BranchOne).branches).toHaveLength(1)
})
})
describe('container type changes', () => {
it('treats forloop to whileloop change as removed + added', () => {
const innerModule = createRawScriptModule('inner', 'inner step')
const forLoop = createForloopModule('loop1', [innerModule])
const whileLoop = createWhileloopModule('loop1', [innerModule])
const beforeFlow = createFlow([forLoop])
const afterFlow = createFlow([whileLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The container itself should be treated as removed + added (type changed)
expect(result.beforeActions['loop1']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['loop1']).toEqual({ action: 'added', pending: false })
// Inner module's location type changes (forloop -> whileloop), so it's also treated as moved
expect(result.beforeActions['inner']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['inner']).toEqual({ action: 'added', pending: false })
// The old inner module should appear with prefix in the merged flow
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
expect(mergedLoop).toBeDefined()
const loopModules = (mergedLoop?.value as WhileloopFlow).modules
// The whileloop contains 'inner' (new) and the old one gets prefixed
expect(loopModules.some((m) => m.id === 'inner')).toBe(true)
})
it('treats branchone to branchall change as removed + added', () => {
const innerModule = createRawScriptModule('inner', 'inner step')
const branchOne = createBranchOneModule('branch1', [innerModule], [])
const branchAll = createBranchAllModule('branch1', [{ modules: [innerModule] }])
const beforeFlow = createFlow([branchOne])
const afterFlow = createFlow([branchAll])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The container itself should be treated as removed + added (type changed)
expect(result.beforeActions['branch1']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['branch1']).toEqual({ action: 'added', pending: false })
// Inner module's location type changes (branchone-default -> branchall-branch), so it's also treated as moved
expect(result.beforeActions['inner']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['inner']).toEqual({ action: 'added', pending: false })
// The new inner module should appear in the branchall
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1')
expect(mergedBranch).toBeDefined()
const branchModules = (mergedBranch?.value as BranchAll).branches[0].modules
expect(branchModules.some((m) => m.id === 'inner')).toBe(true)
})
})
describe('module movement', () => {
it('detects module moved from root to inside a loop', () => {
const moduleA = createRawScriptModule('a', 'step a')
const moduleB = createRawScriptModule('b', 'step b')
const emptyLoop = createForloopModule('loop1', [])
const loopWithB = createForloopModule('loop1', [moduleB])
// Before: a, b, loop1(empty)
// After: a, loop1(b)
const beforeFlow = createFlow([moduleA, moduleB, emptyLoop])
const afterFlow = createFlow([moduleA, loopWithB])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// Module b is in both flows but at different locations
// The implementation doesn't track "movement" - it sees b in both places
// The loop itself is modified because its modules changed
expect(result.beforeActions['loop1']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['loop1']).toEqual({ action: 'modified', pending: false })
// b is removed from root and added inside loop - implementation sees it as existing in both
// but since it's the same module ID, no action is recorded
// The mergedFlow should contain both the old position (at root) and the new position (in loop)
// This is handled by the duplicate ID logic - old__b prefix
const rootModuleIds = result.mergedFlow.modules?.map((m) => m.id) ?? []
expect(rootModuleIds).toContain('a')
expect(rootModuleIds).toContain('loop1')
// The old 'b' at root level gets prefixed with 'old__'
expect(rootModuleIds).toContain('old__b')
})
it('detects module moved from one branch to another', () => {
const moduleA = createRawScriptModule('a', 'step a')
// Before: branch with 'a' in first conditional branch
const beforeBranch = createBranchOneModule('branch1', [], [
{ expr: 'x > 0', modules: [moduleA] },
{ expr: 'x < 0', modules: [] }
])
// After: branch with 'a' in second conditional branch
const afterBranch = createBranchOneModule('branch1', [], [
{ expr: 'x > 0', modules: [] },
{ expr: 'x < 0', modules: [moduleA] }
])
const beforeFlow = createFlow([beforeBranch])
const afterFlow = createFlow([afterBranch])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The branch container is modified
expect(result.beforeActions['branch1']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['branch1']).toEqual({ action: 'modified', pending: false })
// Module 'a' moved from branchIndex 0 to branchIndex 1, so it's treated as removed + added
expect(result.beforeActions['a']).toEqual({ action: 'removed', pending: false })
expect(result.afterActions['a']).toEqual({ action: 'added', pending: false })
// The old 'a' should appear with prefix in the first branch of mergedFlow
const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1')
expect(mergedBranch).toBeDefined()
const firstBranchModules = (mergedBranch?.value as BranchOne).branches[0].modules
expect(firstBranchModules.some((m) => m.id === 'old__a')).toBe(true)
// The new 'a' should appear in the second branch
const secondBranchModules = (mergedBranch?.value as BranchOne).branches[1].modules
expect(secondBranchModules.some((m) => m.id === 'a')).toBe(true)
})
it('detects module moved from loop to root', () => {
const moduleA = createRawScriptModule('a', 'step a')
const moduleB = createRawScriptModule('b', 'step b')
const loopWithA = createForloopModule('loop1', [moduleA])
const emptyLoop = createForloopModule('loop1', [])
// Before: b, loop1(a)
// After: b, loop1(empty), a
const beforeFlow = createFlow([moduleB, loopWithA])
const afterFlow = createFlow([moduleB, emptyLoop, moduleA])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The loop is modified because its modules changed
expect(result.beforeActions['loop1']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['loop1']).toEqual({ action: 'modified', pending: false })
// Module 'a' exists in both flows, so no action recorded for it
// The mergedFlow should contain the old 'a' inside the loop (prefixed) and new 'a' at root
const rootModuleIds = result.mergedFlow.modules?.map((m) => m.id) ?? []
expect(rootModuleIds).toContain('b')
expect(rootModuleIds).toContain('loop1')
expect(rootModuleIds).toContain('a')
// The old 'a' inside the loop gets prefixed
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
const loopModuleIds = (mergedLoop?.value as ForloopFlow).modules.map((m) => m.id)
expect(loopModuleIds).toContain('old__a')
})
})
describe('module reordering', () => {
it('detects modules reordered at root level', () => {
const moduleA = createRawScriptModule('a', 'step a')
const moduleB = createRawScriptModule('b', 'step b')
const moduleC = createRawScriptModule('c', 'step c')
// Before: a, b, c
// After: c, a, b
const beforeFlow = createFlow([moduleA, moduleB, moduleC])
const afterFlow = createFlow([moduleC, moduleA, moduleB])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// No modules were added, removed, or modified - just reordered
// The implementation doesn't detect reordering as a change
expect(result.beforeActions['a']).toBeUndefined()
expect(result.beforeActions['b']).toBeUndefined()
expect(result.beforeActions['c']).toBeUndefined()
expect(result.afterActions['a']).toBeUndefined()
expect(result.afterActions['b']).toBeUndefined()
expect(result.afterActions['c']).toBeUndefined()
// mergedFlow should reflect the afterFlow order
expectModuleOrder(result.mergedFlow.modules ?? [], ['c', 'a', 'b'])
})
it('detects modules reordered inside a loop', () => {
const innerA = createRawScriptModule('inner_a', 'inner a')
const innerB = createRawScriptModule('inner_b', 'inner b')
const innerC = createRawScriptModule('inner_c', 'inner c')
// Before: loop with [a, b, c]
// After: loop with [c, b, a]
const beforeLoop = createForloopModule('loop1', [innerA, innerB, innerC])
const afterLoop = createForloopModule('loop1', [innerC, innerB, innerA])
const beforeFlow = createFlow([beforeLoop])
const afterFlow = createFlow([afterLoop])
const result = buildFlowTimeline(beforeFlow, afterFlow)
// The loop is modified because its internal structure changed
// (deepEqual compares the modules array which includes order)
expect(result.beforeActions['loop1']).toEqual({ action: 'modified', pending: false })
expect(result.afterActions['loop1']).toEqual({ action: 'modified', pending: false })
// Individual modules are not marked as changed
expect(result.beforeActions['inner_a']).toBeUndefined()
expect(result.beforeActions['inner_b']).toBeUndefined()
expect(result.beforeActions['inner_c']).toBeUndefined()
// mergedFlow should reflect the afterFlow order inside the loop
const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1')
const loopModules = (mergedLoop?.value as ForloopFlow).modules
expectModuleOrder(loopModules, ['inner_c', 'inner_b', 'inner_a'])
})
})
})