@xdelivered/emberflow 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/commands.test.ts +185 -0
- package/bin/commands.ts +306 -0
- package/bin/emberflow.mjs +38 -0
- package/bin/init.test.ts +165 -0
- package/bin/init.ts +297 -0
- package/bin/runtime.mjs +105 -0
- package/bin/runtime.test.ts +167 -0
- package/bin/skillTargets.test.ts +108 -0
- package/bin/skillTargets.ts +36 -0
- package/bin/skillTemplates.test.ts +37 -0
- package/bin/tsconfig.json +23 -0
- package/dist/bin/commands.d.ts +24 -0
- package/dist/bin/commands.js +252 -0
- package/dist/bin/init.d.ts +17 -0
- package/dist/bin/init.js +241 -0
- package/dist/bin/runtime.d.mts +39 -0
- package/dist/bin/runtime.mjs +96 -0
- package/dist/bin/skillTargets.d.ts +13 -0
- package/dist/bin/skillTargets.js +22 -0
- package/dist/server/agents/claudeAdapter.d.ts +8 -0
- package/dist/server/agents/claudeAdapter.js +190 -0
- package/dist/server/agents/claudeParse.d.ts +2 -0
- package/dist/server/agents/claudeParse.js +162 -0
- package/dist/server/agents/codexAdapter.d.ts +13 -0
- package/dist/server/agents/codexAdapter.js +174 -0
- package/dist/server/agents/codexParse.d.ts +2 -0
- package/dist/server/agents/codexParse.js +74 -0
- package/dist/server/agents/detect.d.ts +23 -0
- package/dist/server/agents/detect.js +35 -0
- package/dist/server/agents/gitScope.d.ts +24 -0
- package/dist/server/agents/gitScope.js +95 -0
- package/dist/server/agents/modelRejectionHint.d.ts +8 -0
- package/dist/server/agents/modelRejectionHint.js +12 -0
- package/dist/server/agents/prompt.d.ts +79 -0
- package/dist/server/agents/prompt.js +364 -0
- package/dist/server/agents/runManager.d.ts +90 -0
- package/dist/server/agents/runManager.js +259 -0
- package/dist/server/agents/types.d.ts +15 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/apiStore.d.ts +60 -0
- package/dist/server/apiStore.js +249 -0
- package/dist/server/auth/enforce.d.ts +1 -0
- package/dist/server/auth/enforce.js +6 -0
- package/dist/server/auth/types.d.ts +1 -0
- package/dist/server/auth/types.js +1 -0
- package/dist/server/auth/verifiers.d.ts +1 -0
- package/dist/server/auth/verifiers.js +4 -0
- package/dist/server/authAttach.d.ts +19 -0
- package/dist/server/authAttach.js +79 -0
- package/dist/server/cli.d.ts +7 -0
- package/dist/server/cli.js +804 -0
- package/dist/server/client.d.ts +130 -0
- package/dist/server/client.js +247 -0
- package/dist/server/environments.d.ts +103 -0
- package/dist/server/environments.js +486 -0
- package/dist/server/flowStore.d.ts +37 -0
- package/dist/server/flowStore.js +117 -0
- package/dist/server/httpOperations.d.ts +32 -0
- package/dist/server/httpOperations.js +62 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1022 -0
- package/dist/server/infrastructure.d.ts +51 -0
- package/dist/server/infrastructure.js +116 -0
- package/dist/server/login.d.ts +21 -0
- package/dist/server/login.js +44 -0
- package/dist/server/mcp.d.ts +1 -0
- package/dist/server/mcp.js +182 -0
- package/dist/server/migrateFlows.d.ts +8 -0
- package/dist/server/migrateFlows.js +25 -0
- package/dist/server/mockHandler.d.ts +15 -0
- package/dist/server/mockHandler.js +35 -0
- package/dist/server/nodesPayload.d.ts +14 -0
- package/dist/server/nodesPayload.js +14 -0
- package/dist/server/normalizeFlow.d.ts +21 -0
- package/dist/server/normalizeFlow.js +48 -0
- package/dist/server/openBrowser.d.ts +8 -0
- package/dist/server/openBrowser.js +16 -0
- package/dist/server/operationResult.d.ts +12 -0
- package/dist/server/operationResult.js +26 -0
- package/dist/server/pathGuard.d.ts +1 -0
- package/dist/server/pathGuard.js +16 -0
- package/dist/server/pathSafety.d.ts +20 -0
- package/dist/server/pathSafety.js +35 -0
- package/dist/server/projectConfig.d.ts +47 -0
- package/dist/server/projectConfig.js +61 -0
- package/dist/server/projectMode.d.ts +26 -0
- package/dist/server/projectMode.js +44 -0
- package/dist/server/redact.d.ts +6 -0
- package/dist/server/redact.js +37 -0
- package/dist/server/runRegistry.d.ts +84 -0
- package/dist/server/runRegistry.js +200 -0
- package/dist/server/runtime/RuntimeAdapter.d.ts +22 -0
- package/dist/server/runtime/RuntimeAdapter.js +1 -0
- package/dist/server/runtime/expressAdapter.d.ts +19 -0
- package/dist/server/runtime/expressAdapter.js +56 -0
- package/dist/server/scenarioTest.d.ts +14 -0
- package/dist/server/scenarioTest.js +66 -0
- package/dist/server/subflowRunner.d.ts +32 -0
- package/dist/server/subflowRunner.js +72 -0
- package/dist/server/testRunner.d.ts +70 -0
- package/dist/server/testRunner.js +151 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +2 -0
- package/dist/src/engine/authVerify.d.ts +33 -0
- package/dist/src/engine/authVerify.js +63 -0
- package/dist/src/engine/diagnostics.d.ts +62 -0
- package/dist/src/engine/diagnostics.js +148 -0
- package/dist/src/engine/executor.d.ts +151 -0
- package/dist/src/engine/executor.js +684 -0
- package/dist/src/engine/httpError.d.ts +7 -0
- package/dist/src/engine/httpError.js +14 -0
- package/dist/src/engine/index.d.ts +11 -0
- package/dist/src/engine/index.js +11 -0
- package/dist/src/engine/output.d.ts +11 -0
- package/dist/src/engine/output.js +20 -0
- package/dist/src/engine/publish.d.ts +30 -0
- package/dist/src/engine/publish.js +95 -0
- package/dist/src/engine/registry.d.ts +34 -0
- package/dist/src/engine/registry.js +67 -0
- package/dist/src/engine/scenarios.d.ts +12 -0
- package/dist/src/engine/scenarios.js +19 -0
- package/dist/src/engine/schemaCheck.d.ts +4 -0
- package/dist/src/engine/schemaCheck.js +34 -0
- package/dist/src/engine/trace.d.ts +12 -0
- package/dist/src/engine/trace.js +12 -0
- package/dist/src/engine/types.d.ts +308 -0
- package/dist/src/engine/types.js +1 -0
- package/dist/src/engine/validation.d.ts +26 -0
- package/dist/src/engine/validation.js +318 -0
- package/dist/src/flows/anomaly-flows.d.ts +23 -0
- package/dist/src/flows/anomaly-flows.js +151 -0
- package/dist/src/flows/city-sweep-flow.d.ts +10 -0
- package/dist/src/flows/city-sweep-flow.js +127 -0
- package/dist/src/flows/login-flow.d.ts +13 -0
- package/dist/src/flows/login-flow.js +145 -0
- package/dist/src/flows/pradar-flows.d.ts +5 -0
- package/dist/src/flows/pradar-flows.js +375 -0
- package/dist/src/flows/weather-flow.d.ts +12 -0
- package/dist/src/flows/weather-flow.js +150 -0
- package/dist/src/lib/pathParams.d.ts +3 -0
- package/dist/src/lib/pathParams.js +6 -0
- package/dist/src/nodes/anomaly.d.ts +2 -0
- package/dist/src/nodes/anomaly.js +556 -0
- package/dist/src/nodes/flow-control.d.ts +7 -0
- package/dist/src/nodes/flow-control.js +270 -0
- package/dist/src/nodes/index.d.ts +3 -0
- package/dist/src/nodes/index.js +20 -0
- package/dist/src/nodes/login.d.ts +7 -0
- package/dist/src/nodes/login.js +158 -0
- package/dist/src/nodes/loops.d.ts +11 -0
- package/dist/src/nodes/loops.js +60 -0
- package/dist/src/nodes/pradar/index.d.ts +7 -0
- package/dist/src/nodes/pradar/index.js +9 -0
- package/dist/src/nodes/pradar/logic.d.ts +189 -0
- package/dist/src/nodes/pradar/logic.js +370 -0
- package/dist/src/nodes/pradar/nodes.d.ts +42 -0
- package/dist/src/nodes/pradar/nodes.js +801 -0
- package/dist/src/nodes/requireAuth.d.ts +20 -0
- package/dist/src/nodes/requireAuth.js +48 -0
- package/dist/src/nodes/response.d.ts +8 -0
- package/dist/src/nodes/response.js +33 -0
- package/dist/src/nodes/weather.d.ts +33 -0
- package/dist/src/nodes/weather.js +260 -0
- package/package.json +92 -0
- package/server/agentRoute.test.ts +157 -0
- package/server/agents/__fixtures__/fake-claude-writer.mjs +23 -0
- package/server/agents/__fixtures__/fake-claude.mjs +55 -0
- package/server/agents/__fixtures__/fake-codex-writer.mjs +27 -0
- package/server/agents/__fixtures__/fake-codex.mjs +52 -0
- package/server/agents/__fixtures__/fake-version-empty.mjs +3 -0
- package/server/agents/__fixtures__/fake-version-fail.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-garbage.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-good.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-hang.mjs +3 -0
- package/server/agents/claudeAdapter.test.ts +127 -0
- package/server/agents/claudeAdapter.ts +212 -0
- package/server/agents/claudeParse.ts +193 -0
- package/server/agents/codexAdapter.test.ts +125 -0
- package/server/agents/codexAdapter.ts +198 -0
- package/server/agents/codexParse.test.ts +58 -0
- package/server/agents/codexParse.ts +126 -0
- package/server/agents/detect.test.ts +65 -0
- package/server/agents/detect.ts +43 -0
- package/server/agents/gitScope.test.ts +129 -0
- package/server/agents/gitScope.ts +110 -0
- package/server/agents/modelRejectionHint.test.ts +30 -0
- package/server/agents/modelRejectionHint.ts +14 -0
- package/server/agents/prompt.test.ts +613 -0
- package/server/agents/prompt.ts +527 -0
- package/server/agents/runManager.test.ts +251 -0
- package/server/agents/runManager.ts +286 -0
- package/server/agents/types.ts +17 -0
- package/server/apiMount.test.ts +591 -0
- package/server/apiStore.test.ts +324 -0
- package/server/apiStore.ts +241 -0
- package/server/auth/enforce.test.ts +61 -0
- package/server/auth/enforce.ts +6 -0
- package/server/auth/types.ts +5 -0
- package/server/auth/verifiers.test.ts +37 -0
- package/server/auth/verifiers.ts +9 -0
- package/server/authAttach.test.ts +175 -0
- package/server/authAttach.ts +99 -0
- package/server/cli.ts +847 -0
- package/server/cliAuth.test.ts +229 -0
- package/server/cliCreate.test.ts +131 -0
- package/server/cliDoctor.test.ts +326 -0
- package/server/cliTest.test.ts +735 -0
- package/server/client.ts +314 -0
- package/server/demoProject.test.ts +29 -0
- package/server/diagnosticsRoute.test.ts +169 -0
- package/server/environments.test.ts +620 -0
- package/server/environments.ts +603 -0
- package/server/environmentsRoute.test.ts +385 -0
- package/server/errorWorkflow.test.ts +205 -0
- package/server/flowStore.test.ts +84 -0
- package/server/flowStore.ts +125 -0
- package/server/httpOperations.test.ts +377 -0
- package/server/httpOperations.ts +96 -0
- package/server/index.ts +1111 -0
- package/server/infrastructure.test.ts +154 -0
- package/server/infrastructure.ts +161 -0
- package/server/infrastructureRoute.test.ts +105 -0
- package/server/login.test.ts +153 -0
- package/server/login.ts +67 -0
- package/server/mcp.ts +278 -0
- package/server/migrateFlows.test.ts +55 -0
- package/server/migrateFlows.ts +23 -0
- package/server/mockHandler.test.ts +138 -0
- package/server/mockHandler.ts +52 -0
- package/server/mockRun.test.ts +367 -0
- package/server/mockServe.test.ts +467 -0
- package/server/nodesPayload.test.ts +29 -0
- package/server/nodesPayload.ts +26 -0
- package/server/normalizeFlow.test.ts +80 -0
- package/server/normalizeFlow.ts +59 -0
- package/server/openBrowser.test.ts +20 -0
- package/server/openBrowser.ts +22 -0
- package/server/operationResult.test.ts +67 -0
- package/server/operationResult.ts +27 -0
- package/server/pathGuard.test.ts +19 -0
- package/server/pathGuard.ts +12 -0
- package/server/pathSafety.test.ts +38 -0
- package/server/pathSafety.ts +33 -0
- package/server/projectConfig.test.ts +67 -0
- package/server/projectConfig.ts +101 -0
- package/server/projectMode.test.ts +106 -0
- package/server/projectMode.ts +59 -0
- package/server/redact.test.ts +88 -0
- package/server/redact.ts +35 -0
- package/server/redactEgress.test.ts +281 -0
- package/server/runRegistry.ts +270 -0
- package/server/runsAuth.test.ts +119 -0
- package/server/runtime/RuntimeAdapter.ts +24 -0
- package/server/runtime/expressAdapter.test.ts +38 -0
- package/server/runtime/expressAdapter.ts +70 -0
- package/server/scenarioTest.test.ts +148 -0
- package/server/scenarioTest.ts +88 -0
- package/server/setupStatus.test.ts +127 -0
- package/server/subflowRunner.ts +104 -0
- package/server/testRunner.ts +216 -0
- package/server/tsconfig.json +20 -0
- package/server/workflowTestRoute.test.ts +297 -0
- package/src/App.tsx +233 -0
- package/src/components/AgentConsole.tsx +461 -0
- package/src/components/BranchRulesEditor.tsx +156 -0
- package/src/components/CreateModal.tsx +275 -0
- package/src/components/Dock.tsx +487 -0
- package/src/components/EnvironmentDialog.tsx +569 -0
- package/src/components/EnvironmentPicker.tsx +357 -0
- package/src/components/EnvironmentsDialog.tsx +144 -0
- package/src/components/ExecutionPager.tsx +67 -0
- package/src/components/InfraPanel.test.tsx +103 -0
- package/src/components/InfraPanel.tsx +171 -0
- package/src/components/Inspector.tsx +1182 -0
- package/src/components/Json.tsx +50 -0
- package/src/components/JsonModal.tsx +52 -0
- package/src/components/LogMessage.tsx +61 -0
- package/src/components/NodeRunModal.tsx +213 -0
- package/src/components/RunLogPanel.tsx +397 -0
- package/src/components/RunbookView.tsx +1085 -0
- package/src/components/Scenarios.tsx +476 -0
- package/src/components/SettingsDialog.tsx +229 -0
- package/src/components/Sidebar.tsx +436 -0
- package/src/components/StatusBar.tsx +302 -0
- package/src/components/Toolbar.tsx +663 -0
- package/src/components/WelcomeDialog.test.tsx +107 -0
- package/src/components/WelcomeDialog.tsx +412 -0
- package/src/components/agentMarkdown.test.tsx +91 -0
- package/src/components/agentMarkdown.tsx +158 -0
- package/src/components/ui/badge.tsx +31 -0
- package/src/components/ui/button.tsx +46 -0
- package/src/components/ui/combobox.tsx +114 -0
- package/src/components/ui/command.tsx +82 -0
- package/src/components/ui/dialog.tsx +73 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/popover.tsx +32 -0
- package/src/components/ui/resizable.tsx +44 -0
- package/src/components/ui/tabs.tsx +36 -0
- package/src/config.ts +4 -0
- package/src/engine/authVerify.ts +81 -0
- package/src/engine/diagnostics.test.ts +437 -0
- package/src/engine/diagnostics.ts +211 -0
- package/src/engine/executor.test.ts +1339 -0
- package/src/engine/executor.ts +840 -0
- package/src/engine/httpError.test.ts +12 -0
- package/src/engine/httpError.ts +16 -0
- package/src/engine/index.ts +11 -0
- package/src/engine/output.ts +21 -0
- package/src/engine/publish.test.ts +176 -0
- package/src/engine/publish.ts +127 -0
- package/src/engine/registry.test.ts +64 -0
- package/src/engine/registry.ts +80 -0
- package/src/engine/scenarios.test.ts +55 -0
- package/src/engine/scenarios.ts +29 -0
- package/src/engine/schemaCheck.test.ts +21 -0
- package/src/engine/schemaCheck.ts +35 -0
- package/src/engine/trace.test.ts +19 -0
- package/src/engine/trace.ts +23 -0
- package/src/engine/types.test.ts +22 -0
- package/src/engine/types.ts +316 -0
- package/src/engine/validation.test.ts +314 -0
- package/src/engine/validation.ts +354 -0
- package/src/flows/anomaly-flows.test.ts +81 -0
- package/src/flows/anomaly-flows.ts +202 -0
- package/src/flows/city-sweep-flow.ts +131 -0
- package/src/flows/flows.test.ts +30 -0
- package/src/flows/login-flow.ts +150 -0
- package/src/flows/pradar-flows.test.ts +204 -0
- package/src/flows/pradar-flows.ts +419 -0
- package/src/flows/weather-flow.ts +154 -0
- package/src/globals.css +147 -0
- package/src/lib/pathParams.test.ts +24 -0
- package/src/lib/pathParams.ts +6 -0
- package/src/lib/registerLens.test.ts +79 -0
- package/src/lib/registerLens.ts +59 -0
- package/src/lib/runbookModel.test.ts +61 -0
- package/src/lib/runbookModel.ts +290 -0
- package/src/lib/runbookProjection.test.ts +417 -0
- package/src/lib/runbookProjection.ts +254 -0
- package/src/lib/useTailAnchor.ts +53 -0
- package/src/lib/utils.ts +7 -0
- package/src/main.tsx +13 -0
- package/src/nodes/anomaly.test.ts +98 -0
- package/src/nodes/anomaly.ts +637 -0
- package/src/nodes/effects-scan.test.ts +44 -0
- package/src/nodes/flow-control.test.ts +280 -0
- package/src/nodes/flow-control.ts +305 -0
- package/src/nodes/index.ts +22 -0
- package/src/nodes/login.test.ts +81 -0
- package/src/nodes/login.ts +186 -0
- package/src/nodes/loops.ts +69 -0
- package/src/nodes/pradar/index.ts +11 -0
- package/src/nodes/pradar/logic.test.ts +288 -0
- package/src/nodes/pradar/logic.ts +582 -0
- package/src/nodes/pradar/nodes.test.ts +146 -0
- package/src/nodes/pradar/nodes.ts +955 -0
- package/src/nodes/requireAuth.test.ts +75 -0
- package/src/nodes/requireAuth.ts +55 -0
- package/src/nodes/response.test.ts +33 -0
- package/src/nodes/response.ts +39 -0
- package/src/nodes/traceDetail.test.ts +39 -0
- package/src/nodes/weather.test.ts +65 -0
- package/src/nodes/weather.ts +306 -0
- package/src/store/agentClient.test.ts +103 -0
- package/src/store/agentClient.ts +131 -0
- package/src/store/apiTree.test.ts +17 -0
- package/src/store/apiTree.ts +73 -0
- package/src/store/builderStore.test.ts +1234 -0
- package/src/store/builderStore.ts +1879 -0
- package/src/store/infraClient.ts +63 -0
- package/src/store/nodeMeta.test.ts +24 -0
- package/src/store/nodeMeta.ts +21 -0
- package/src/store/persistence.test.ts +15 -0
- package/src/store/persistence.ts +54 -0
- package/src/store/serverRunner.ts +381 -0
- package/src/store/setupClient.ts +57 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/studio-dist/assets/index-DNJwW-hM.css +1 -0
- package/studio-dist/assets/index-O26dKRjW.js +65 -0
- package/studio-dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/studio-dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/studio-dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/studio-dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/studio-dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/studio-dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/studio-dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/studio-dist/favicon.svg +1 -0
- package/studio-dist/icons.svg +24 -0
- package/studio-dist/index.html +14 -0
- package/templates/skills/emberflow-basics/SKILL.md +316 -0
- package/templates/skills/emberflow-model-process/SKILL.md +209 -0
- package/templates/skills/emberflow-new-workflow/SKILL.md +211 -0
- package/templates/skills/emberflow-review-workflow/SKILL.md +148 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { pinsOf, useBuilderStore } from './builderStore';
|
|
3
|
+
import { createLoginFlow } from '../flows/login-flow';
|
|
4
|
+
import { createDefaultRegistry } from '../nodes';
|
|
5
|
+
import * as serverRunner from './serverRunner';
|
|
6
|
+
import * as agentClient from './agentClient';
|
|
7
|
+
import type { WorkflowDefinition, WorkflowNode } from '../engine';
|
|
8
|
+
import type { ScenarioTestReport, ServerRunHandlers } from './serverRunner';
|
|
9
|
+
|
|
10
|
+
vi.mock('./serverRunner', async () => {
|
|
11
|
+
const actual = await vi.importActual<typeof import('./serverRunner')>('./serverRunner');
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
fetchWorkflows: vi.fn(),
|
|
15
|
+
putWorkflow: vi.fn(),
|
|
16
|
+
createOperationOnServer: vi.fn(),
|
|
17
|
+
listEnvironments: vi.fn(),
|
|
18
|
+
loginEnvironment: vi.fn(),
|
|
19
|
+
setEnvironmentSecret: vi.fn(),
|
|
20
|
+
deleteEnvironmentSecret: vi.fn(),
|
|
21
|
+
setEnvironmentAuth: vi.fn(),
|
|
22
|
+
runnerHealthy: vi.fn(),
|
|
23
|
+
testWorkflow: vi.fn(),
|
|
24
|
+
startServerRun: vi.fn(),
|
|
25
|
+
subscribeServerRun: vi.fn(),
|
|
26
|
+
setServingMode: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock('./agentClient', async () => {
|
|
31
|
+
const actual = await vi.importActual<typeof import('./agentClient')>('./agentClient');
|
|
32
|
+
return {
|
|
33
|
+
...actual,
|
|
34
|
+
startAgent: vi.fn(async () => 'run-1'),
|
|
35
|
+
streamAgent: vi.fn(() => () => {}),
|
|
36
|
+
fetchAgentDiff: vi.fn(async () => ({ diff: '', files: [] })),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('builderStore graph editing', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('connect with a targetHandle creates an input mapping to the whole output', () => {
|
|
46
|
+
const s = useBuilderStore.getState();
|
|
47
|
+
s.connect('validate', 'issueToken', 'userId');
|
|
48
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'issueToken')!;
|
|
49
|
+
expect(node.inputMap?.userId).toEqual({ sourceNodeId: 'validate', sourceField: '$' });
|
|
50
|
+
const edge = useBuilderStore.getState().flow.edges.find(
|
|
51
|
+
(e) => e.source === 'validate' && e.target === 'issueToken',
|
|
52
|
+
);
|
|
53
|
+
expect(edge?.targetHandle).toBe('userId');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('removing a field edge clears the mapping it created', () => {
|
|
57
|
+
const s = useBuilderStore.getState();
|
|
58
|
+
s.connect('validate', 'issueToken', 'userId');
|
|
59
|
+
const edge = useBuilderStore.getState().flow.edges.find(
|
|
60
|
+
(e) => e.source === 'validate' && e.target === 'issueToken' && e.targetHandle === 'userId',
|
|
61
|
+
)!;
|
|
62
|
+
useBuilderStore.getState().removeEdge(edge.id);
|
|
63
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'issueToken')!;
|
|
64
|
+
expect(node.inputMap?.userId).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('setInputMapping sets and clears mappings', () => {
|
|
68
|
+
useBuilderStore.getState().setInputMapping('issueToken', 'userId', {
|
|
69
|
+
sourceNodeId: 'fetch', sourceField: 'id',
|
|
70
|
+
});
|
|
71
|
+
let node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'issueToken')!;
|
|
72
|
+
expect(node.inputMap?.userId).toEqual({ sourceNodeId: 'fetch', sourceField: 'id' });
|
|
73
|
+
|
|
74
|
+
useBuilderStore.getState().setInputMapping('issueToken', 'userId', null);
|
|
75
|
+
node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'issueToken')!;
|
|
76
|
+
expect(node.inputMap?.userId).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('updateNodeConfig writes config values', () => {
|
|
80
|
+
useBuilderStore.getState().updateNodeConfig('validate', 'username', 'grace');
|
|
81
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'validate')!;
|
|
82
|
+
expect(node.config.username).toBe('grace');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renameNode updates the label', () => {
|
|
86
|
+
useBuilderStore.getState().renameNode('validate', 'Check Login');
|
|
87
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'validate')!;
|
|
88
|
+
expect(node.label).toBe('Check Login');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('seedParamDefault seeds an empty default on the Input node and saves', () => {
|
|
92
|
+
const saveSpy = vi.spyOn(useBuilderStore.getState(), 'saveFlow');
|
|
93
|
+
useBuilderStore.getState().seedParamDefault('id');
|
|
94
|
+
const input = useBuilderStore.getState().flow.nodes.find((n) => n.type === 'Input')!;
|
|
95
|
+
expect((input.config.defaults as { params: Record<string, unknown> }).params.id).toBe('');
|
|
96
|
+
expect(saveSpy).toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('seedParamDefault preserves an existing value for the param', () => {
|
|
100
|
+
const flow = useBuilderStore.getState().flow;
|
|
101
|
+
useBuilderStore.setState({
|
|
102
|
+
flow: {
|
|
103
|
+
...flow,
|
|
104
|
+
nodes: flow.nodes.map((n) =>
|
|
105
|
+
n.type === 'Input'
|
|
106
|
+
? { ...n, config: { ...n.config, defaults: { params: { id: 'real-id' } } } }
|
|
107
|
+
: n,
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
useBuilderStore.getState().seedParamDefault('id');
|
|
112
|
+
const input = useBuilderStore.getState().flow.nodes.find((n) => n.type === 'Input')!;
|
|
113
|
+
expect((input.config.defaults as { params: Record<string, unknown> }).params.id).toBe('real-id');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('seedParamDefault is a no-op when the flow has no Input node', () => {
|
|
117
|
+
const flow = useBuilderStore.getState().flow;
|
|
118
|
+
const flowWithoutInput = { ...flow, nodes: flow.nodes.filter((n) => n.type !== 'Input') };
|
|
119
|
+
useBuilderStore.setState({ flow: flowWithoutInput });
|
|
120
|
+
expect(() => useBuilderStore.getState().seedParamDefault('id')).not.toThrow();
|
|
121
|
+
const stillNoInput = useBuilderStore.getState().flow.nodes.find((n) => n.type === 'Input');
|
|
122
|
+
expect(stillNoInput).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('builderStore workflows', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('createWorkflow adds an empty flow and switches to it', () => {
|
|
132
|
+
const before = useBuilderStore.getState().flow.id;
|
|
133
|
+
useBuilderStore.getState().createWorkflow();
|
|
134
|
+
const state = useBuilderStore.getState();
|
|
135
|
+
expect(state.flow.id).not.toBe(before);
|
|
136
|
+
expect(state.flow.nodes).toEqual([]);
|
|
137
|
+
expect(state.workflows.some((w) => w.id === state.flow.id)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('switchWorkflow loads the target and clears run state', () => {
|
|
141
|
+
const loginId = useBuilderStore.getState().flow.id;
|
|
142
|
+
useBuilderStore.getState().createWorkflow();
|
|
143
|
+
const newId = useBuilderStore.getState().flow.id;
|
|
144
|
+
useBuilderStore.getState().switchWorkflow(loginId);
|
|
145
|
+
expect(useBuilderStore.getState().flow.id).toBe(loginId);
|
|
146
|
+
expect(useBuilderStore.getState().run).toBeNull();
|
|
147
|
+
useBuilderStore.getState().switchWorkflow(newId);
|
|
148
|
+
expect(useBuilderStore.getState().flow.id).toBe(newId);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('workflows list reflects renames', () => {
|
|
152
|
+
useBuilderStore.getState().renameFlow('Signup Flow');
|
|
153
|
+
const state = useBuilderStore.getState();
|
|
154
|
+
expect(state.workflows.find((w) => w.id === state.flow.id)?.name).toBe('Signup Flow');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('moveWorkflowToFolder sets and clears folder on active and shelved flows', () => {
|
|
158
|
+
const activeId = useBuilderStore.getState().flow.id;
|
|
159
|
+
useBuilderStore.getState().createWorkflow();
|
|
160
|
+
const newId = useBuilderStore.getState().flow.id;
|
|
161
|
+
|
|
162
|
+
useBuilderStore.getState().moveWorkflowToFolder(activeId, 'Auth');
|
|
163
|
+
useBuilderStore.getState().moveWorkflowToFolder(newId, 'Auth');
|
|
164
|
+
let workflows = useBuilderStore.getState().workflows;
|
|
165
|
+
expect(workflows.find((w) => w.id === activeId)?.folder).toBe('Auth');
|
|
166
|
+
expect(workflows.find((w) => w.id === newId)?.folder).toBe('Auth');
|
|
167
|
+
|
|
168
|
+
useBuilderStore.getState().moveWorkflowToFolder(newId, null);
|
|
169
|
+
workflows = useBuilderStore.getState().workflows;
|
|
170
|
+
expect(workflows.find((w) => w.id === newId)?.folder).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('builderStore pinning', () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('pinned output skips execution and feeds downstream mapping', async () => {
|
|
180
|
+
useBuilderStore
|
|
181
|
+
.getState()
|
|
182
|
+
.pinNodeOutput('validate', { userId: 'user-pinned', username: 'pinned' });
|
|
183
|
+
await useBuilderStore.getState().runToEnd();
|
|
184
|
+
const run = useBuilderStore.getState().run!;
|
|
185
|
+
expect(run.nodeStates.validate.pinned).toBe(true);
|
|
186
|
+
expect((run.nodeStates.fetch.input as { userId: string }).userId).toBe('user-pinned');
|
|
187
|
+
}, 10_000);
|
|
188
|
+
|
|
189
|
+
it('unpinNode clears the pin and empties metadata', () => {
|
|
190
|
+
useBuilderStore.getState().pinNodeOutput('validate', { userId: 'x' });
|
|
191
|
+
expect(pinsOf(useBuilderStore.getState().flow)).toHaveProperty('validate');
|
|
192
|
+
useBuilderStore.getState().unpinNode('validate');
|
|
193
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'validate')!;
|
|
194
|
+
expect(node.metadata).toBeUndefined();
|
|
195
|
+
expect(pinsOf(useBuilderStore.getState().flow)).toEqual({});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('pins survive export/import round-trips', () => {
|
|
199
|
+
useBuilderStore.getState().pinNodeOutput('validate', { userId: 'kept' });
|
|
200
|
+
const json = useBuilderStore.getState().exportFlow();
|
|
201
|
+
useBuilderStore.getState().importFlow(json);
|
|
202
|
+
expect(pinsOf(useBuilderStore.getState().flow)).toEqual({
|
|
203
|
+
validate: { userId: 'kept' },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('builderStore isolated node run', () => {
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('executes one node against supplied input, capturing logs and a sample', async () => {
|
|
214
|
+
const before = useBuilderStore.getState().trace.samplesFor('checkPlan').length;
|
|
215
|
+
const result = await useBuilderStore
|
|
216
|
+
.getState()
|
|
217
|
+
.runNodeIsolated('checkPlan', { user: { plan: 'pro' } });
|
|
218
|
+
expect(result.error).toBeUndefined();
|
|
219
|
+
expect((result.output as { plan: string }).plan).toBe('pro');
|
|
220
|
+
expect(result.logs.some((l) => l.message.includes('pro'))).toBe(true);
|
|
221
|
+
const samples = useBuilderStore.getState().trace.samplesFor('checkPlan');
|
|
222
|
+
expect(samples.length).toBe(before + 1);
|
|
223
|
+
expect(samples[0].input).toEqual({ user: { plan: 'pro' } });
|
|
224
|
+
expect(useBuilderStore.getState().run).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('reports node failure as error with a failed sample', async () => {
|
|
228
|
+
const result = await useBuilderStore
|
|
229
|
+
.getState()
|
|
230
|
+
.runNodeIsolated('validate', { username: 'ada', password: 'lovelace' });
|
|
231
|
+
expect(result.error).toBeUndefined();
|
|
232
|
+
|
|
233
|
+
const failed = await useBuilderStore
|
|
234
|
+
.getState()
|
|
235
|
+
.runNodeIsolated('validate', { username: 'ada', password: 'x' });
|
|
236
|
+
expect(failed.error).toContain('Password too short');
|
|
237
|
+
expect(useBuilderStore.getState().trace.samplesFor('validate')[0].status).toBe('failed');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('builderStore runner sync', () => {
|
|
242
|
+
const serverFlow = (id: string, name: string): WorkflowDefinition => ({
|
|
243
|
+
id,
|
|
244
|
+
name,
|
|
245
|
+
version: 1,
|
|
246
|
+
nodes: [],
|
|
247
|
+
edges: [],
|
|
248
|
+
createdAt: '2026-07-02T00:00:00Z',
|
|
249
|
+
updatedAt: '2026-07-02T00:00:00Z',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/** Wraps flows into the { flows, operations } shape fetchWorkflows now returns. */
|
|
253
|
+
const payload = (
|
|
254
|
+
flows: WorkflowDefinition[],
|
|
255
|
+
operations: serverRunner.OperationMeta[] = [],
|
|
256
|
+
): serverRunner.WorkflowsPayload => ({ flows, operations });
|
|
257
|
+
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
vi.mocked(serverRunner.fetchWorkflows).mockReset();
|
|
260
|
+
vi.mocked(serverRunner.putWorkflow).mockReset().mockResolvedValue(true);
|
|
261
|
+
vi.mocked(serverRunner.createOperationOnServer).mockReset().mockResolvedValue({ ok: true });
|
|
262
|
+
useBuilderStore.setState({
|
|
263
|
+
flow: createLoginFlow(),
|
|
264
|
+
shelf: [],
|
|
265
|
+
workspaceSource: 'local',
|
|
266
|
+
opMeta: new Map(),
|
|
267
|
+
run: null,
|
|
268
|
+
logs: [],
|
|
269
|
+
activeRun: null,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('syncFromRunner adopts the server flows and flips workspaceSource', async () => {
|
|
274
|
+
const flows = [serverFlow('srv-a', 'Server A'), serverFlow('srv-b', 'Server B')];
|
|
275
|
+
vi.mocked(serverRunner.fetchWorkflows).mockResolvedValue(payload(flows));
|
|
276
|
+
|
|
277
|
+
await useBuilderStore.getState().syncFromRunner();
|
|
278
|
+
|
|
279
|
+
const state = useBuilderStore.getState();
|
|
280
|
+
expect(state.workspaceSource).toBe('server');
|
|
281
|
+
expect(state.flow.id).toBe('srv-a');
|
|
282
|
+
expect([state.flow, ...state.shelf].map((f) => f.id).sort()).toEqual(['srv-a', 'srv-b']);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('syncFromRunner leaves the workspace alone when the runner returns null', async () => {
|
|
286
|
+
const before = useBuilderStore.getState().flow.id;
|
|
287
|
+
vi.mocked(serverRunner.fetchWorkflows).mockResolvedValue(null);
|
|
288
|
+
|
|
289
|
+
await useBuilderStore.getState().syncFromRunner();
|
|
290
|
+
|
|
291
|
+
const state = useBuilderStore.getState();
|
|
292
|
+
expect(state.workspaceSource).toBe('local');
|
|
293
|
+
expect(state.flow.id).toBe(before);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('saveFlow PUTs every flow to the runner once adopted', async () => {
|
|
297
|
+
const flows = [serverFlow('srv-a', 'Server A'), serverFlow('srv-b', 'Server B')];
|
|
298
|
+
vi.mocked(serverRunner.fetchWorkflows).mockResolvedValue(payload(flows));
|
|
299
|
+
await useBuilderStore.getState().syncFromRunner();
|
|
300
|
+
|
|
301
|
+
useBuilderStore.getState().saveFlow();
|
|
302
|
+
|
|
303
|
+
expect(vi.mocked(serverRunner.putWorkflow)).toHaveBeenCalledTimes(2);
|
|
304
|
+
const putIds = vi.mocked(serverRunner.putWorkflow).mock.calls.map((c) => c[0].id).sort();
|
|
305
|
+
expect(putIds).toEqual(['srv-a', 'srv-b']);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('saveFlow does not PUT while the workspace is local', () => {
|
|
309
|
+
useBuilderStore.getState().saveFlow();
|
|
310
|
+
expect(vi.mocked(serverRunner.putWorkflow)).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('summaries() carries path/http from the operations array', async () => {
|
|
314
|
+
const flows = [serverFlow('claims/claims/create', 'Create'), serverFlow('billing/charge', 'Charge')];
|
|
315
|
+
vi.mocked(serverRunner.fetchWorkflows).mockResolvedValue(
|
|
316
|
+
payload(flows, [
|
|
317
|
+
{ id: 'claims/claims/create', name: 'Create', path: 'claims/claims/create', http: { method: 'POST', path: '/claims' } },
|
|
318
|
+
{ id: 'billing/charge', name: 'Charge', path: 'billing/charge' },
|
|
319
|
+
]),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await useBuilderStore.getState().syncFromRunner();
|
|
323
|
+
|
|
324
|
+
const summaries = useBuilderStore.getState().workflows;
|
|
325
|
+
const create = summaries.find((s) => s.id === 'claims/claims/create')!;
|
|
326
|
+
expect(create.path).toBe('claims/claims/create');
|
|
327
|
+
expect(create.http).toEqual({ method: 'POST', path: '/claims' });
|
|
328
|
+
const charge = summaries.find((s) => s.id === 'billing/charge')!;
|
|
329
|
+
expect(charge.path).toBe('billing/charge');
|
|
330
|
+
expect(charge.http).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('createOperation posts a new flow at the api/folder/slug path with matching http, then refreshes and selects it', async () => {
|
|
334
|
+
vi.mocked(serverRunner.fetchWorkflows).mockResolvedValue(
|
|
335
|
+
payload([serverFlow('claims/claims/create-claim', 'Create Claim')], [
|
|
336
|
+
{
|
|
337
|
+
id: 'claims/claims/create-claim',
|
|
338
|
+
name: 'Create Claim',
|
|
339
|
+
path: 'claims/claims/create-claim',
|
|
340
|
+
http: { method: 'POST', path: '/claims' },
|
|
341
|
+
},
|
|
342
|
+
]),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const result = await useBuilderStore.getState().createOperation({
|
|
346
|
+
api: 'claims',
|
|
347
|
+
folder: 'claims',
|
|
348
|
+
name: 'Create Claim',
|
|
349
|
+
method: 'POST',
|
|
350
|
+
httpPath: '/claims',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(result).toEqual({ ok: true });
|
|
354
|
+
expect(vi.mocked(serverRunner.createOperationOnServer)).toHaveBeenCalledTimes(1);
|
|
355
|
+
const [savedFlow, savedPath] = vi.mocked(serverRunner.createOperationOnServer).mock.calls[0];
|
|
356
|
+
expect(savedPath).toBe('claims/claims/create-claim');
|
|
357
|
+
expect(savedFlow.id).toBe('claims/claims/create-claim');
|
|
358
|
+
expect(savedFlow.http).toEqual({ method: 'POST', path: '/claims' });
|
|
359
|
+
expect(savedFlow.nodes.some((n) => n.type === 'Response')).toBe(true);
|
|
360
|
+
|
|
361
|
+
expect(vi.mocked(serverRunner.fetchWorkflows)).toHaveBeenCalled();
|
|
362
|
+
const state = useBuilderStore.getState();
|
|
363
|
+
expect(state.flow.id).toBe('claims/claims/create-claim');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('createOperation surfaces the collision error and does not refresh/switch when the server refuses an existing path (409)', async () => {
|
|
367
|
+
vi.mocked(serverRunner.createOperationOnServer).mockResolvedValue({
|
|
368
|
+
ok: false,
|
|
369
|
+
error: 'operation already exists at billing/charge',
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const result = await useBuilderStore.getState().createOperation({ api: 'billing', name: 'Charge' });
|
|
373
|
+
|
|
374
|
+
expect(result).toEqual({ ok: false, error: 'operation already exists at billing/charge' });
|
|
375
|
+
expect(vi.mocked(serverRunner.fetchWorkflows)).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('builderStore run history', () => {
|
|
380
|
+
const fakeRun = (id: string, flowId: string) => ({
|
|
381
|
+
id,
|
|
382
|
+
workflowId: flowId,
|
|
383
|
+
status: 'succeeded' as const,
|
|
384
|
+
startedAt: '2026-07-02T10:00:00Z',
|
|
385
|
+
completedAt: '2026-07-02T10:00:02Z',
|
|
386
|
+
nodeStates: {},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
beforeEach(() => {
|
|
390
|
+
useBuilderStore.setState({
|
|
391
|
+
flow: createLoginFlow(),
|
|
392
|
+
run: null,
|
|
393
|
+
logs: [],
|
|
394
|
+
activeRun: null,
|
|
395
|
+
runHistory: [],
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('recordRun prepends to history', () => {
|
|
400
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
401
|
+
useBuilderStore.getState().recordRun(fakeRun('r1', flowId));
|
|
402
|
+
useBuilderStore.getState().recordRun(fakeRun('r2', flowId));
|
|
403
|
+
expect(useBuilderStore.getState().runHistory.map((r) => r.id)).toEqual(['r2', 'r1']);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('viewRun sets the run snapshot and restores its logs', () => {
|
|
407
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
408
|
+
useBuilderStore.setState({
|
|
409
|
+
logs: [{ timestamp: 't', level: 'info', runId: 'r1', message: 'hello' }],
|
|
410
|
+
});
|
|
411
|
+
useBuilderStore.getState().recordRun(fakeRun('r1', flowId));
|
|
412
|
+
useBuilderStore.setState({ logs: [] });
|
|
413
|
+
useBuilderStore.getState().viewRun('r1');
|
|
414
|
+
expect(useBuilderStore.getState().run?.id).toBe('r1');
|
|
415
|
+
expect(useBuilderStore.getState().logs.map((l) => l.message)).toEqual(['hello']);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('history for another flow is not shown by historyForActiveFlow', () => {
|
|
419
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
420
|
+
useBuilderStore.getState().recordRun(fakeRun('r1', flowId));
|
|
421
|
+
useBuilderStore.getState().recordRun(fakeRun('r2', 'other-flow'));
|
|
422
|
+
const visible = useBuilderStore
|
|
423
|
+
.getState()
|
|
424
|
+
.runHistory.filter((r) => r.workflowId === flowId);
|
|
425
|
+
expect(visible.map((r) => r.id)).toEqual(['r1']);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('builderStore scenarios', () => {
|
|
430
|
+
beforeEach(() => {
|
|
431
|
+
// Start from a scenario-free flow: the seeded examples would offset counts.
|
|
432
|
+
const flow = createLoginFlow();
|
|
433
|
+
delete flow.scenarios;
|
|
434
|
+
useBuilderStore.setState({
|
|
435
|
+
flow,
|
|
436
|
+
run: null,
|
|
437
|
+
logs: [],
|
|
438
|
+
activeRun: null,
|
|
439
|
+
runHistory: [],
|
|
440
|
+
activeScenarioId: null,
|
|
441
|
+
executionMode: 'browser',
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('scenario CRUD mutates flow.scenarios', () => {
|
|
446
|
+
useBuilderStore
|
|
447
|
+
.getState()
|
|
448
|
+
.addScenario('new user', { username: 'newton', password: 'apple123' }, 'welcome branch');
|
|
449
|
+
const scenarios = useBuilderStore.getState().flow.scenarios!;
|
|
450
|
+
expect(scenarios).toHaveLength(1);
|
|
451
|
+
expect(scenarios[0].description).toBe('welcome branch');
|
|
452
|
+
|
|
453
|
+
const id = scenarios[0].id;
|
|
454
|
+
useBuilderStore.getState().updateScenario(id, { name: 'brand new user' });
|
|
455
|
+
expect(useBuilderStore.getState().flow.scenarios![0].name).toBe('brand new user');
|
|
456
|
+
|
|
457
|
+
useBuilderStore.getState().removeScenario(id);
|
|
458
|
+
expect(useBuilderStore.getState().flow.scenarios).toBeUndefined();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('runScenario feeds the payload through the Input node and tags history', async () => {
|
|
462
|
+
useBuilderStore
|
|
463
|
+
.getState()
|
|
464
|
+
.addScenario('new user', { username: 'newton', password: 'apple123' });
|
|
465
|
+
const id = useBuilderStore.getState().flow.scenarios![0].id;
|
|
466
|
+
|
|
467
|
+
await useBuilderStore.getState().runScenario(id);
|
|
468
|
+
|
|
469
|
+
const run = useBuilderStore.getState().run!;
|
|
470
|
+
expect(run.status).toBe('succeeded');
|
|
471
|
+
expect((run.nodeStates.input.output as { username: string }).username).toBe('newton');
|
|
472
|
+
// 'newton' is a new user: the welcome branch fires and checkPlan is skipped.
|
|
473
|
+
expect(run.nodeStates.welcome.status).toBe('succeeded');
|
|
474
|
+
expect(run.nodeStates.checkPlan.status).toBe('skipped');
|
|
475
|
+
expect(useBuilderStore.getState().runHistory[0].scenarioName).toBe('new user');
|
|
476
|
+
}, 15_000);
|
|
477
|
+
|
|
478
|
+
it('a plain run after a scenario run is not tagged', async () => {
|
|
479
|
+
useBuilderStore
|
|
480
|
+
.getState()
|
|
481
|
+
.addScenario('new user', { username: 'newton', password: 'apple123' });
|
|
482
|
+
const id = useBuilderStore.getState().flow.scenarios![0].id;
|
|
483
|
+
await useBuilderStore.getState().runScenario(id);
|
|
484
|
+
|
|
485
|
+
await useBuilderStore.getState().runToEnd();
|
|
486
|
+
expect(useBuilderStore.getState().runHistory[0].scenarioName).toBeUndefined();
|
|
487
|
+
}, 20_000);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('builderStore environments + safe mode', () => {
|
|
491
|
+
const env = (name: string, isProtected: boolean) => ({
|
|
492
|
+
name,
|
|
493
|
+
protected: isProtected,
|
|
494
|
+
varKeys: [],
|
|
495
|
+
secretKeys: [],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
beforeEach(() => {
|
|
499
|
+
useBuilderStore.setState({
|
|
500
|
+
environments: [env('local', false), env('prod', true)],
|
|
501
|
+
environmentsDefault: 'local',
|
|
502
|
+
selectedEnvironment: 'local',
|
|
503
|
+
safeMode: true,
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('selectEnvironment forces safe mode on protected environments', () => {
|
|
508
|
+
useBuilderStore.getState().setSafeMode(true);
|
|
509
|
+
useBuilderStore.getState().selectEnvironment('prod');
|
|
510
|
+
expect(useBuilderStore.getState().selectedEnvironment).toBe('prod');
|
|
511
|
+
expect(useBuilderStore.getState().safeMode).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('selectEnvironment leaves safe mode alone on unprotected environments', () => {
|
|
515
|
+
useBuilderStore.setState({ safeMode: false, selectedEnvironment: 'prod' });
|
|
516
|
+
useBuilderStore.getState().selectEnvironment('local');
|
|
517
|
+
expect(useBuilderStore.getState().safeMode).toBe(false);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('setSafeMode refuses to disable on a protected env without a matching confirm', () => {
|
|
521
|
+
useBuilderStore.setState({ selectedEnvironment: 'prod', safeMode: true });
|
|
522
|
+
expect(useBuilderStore.getState().setSafeMode(false)).toBe(false);
|
|
523
|
+
expect(useBuilderStore.getState().setSafeMode(false, 'nope')).toBe(false);
|
|
524
|
+
expect(useBuilderStore.getState().safeMode).toBe(true);
|
|
525
|
+
|
|
526
|
+
expect(useBuilderStore.getState().setSafeMode(false, 'prod')).toBe(true);
|
|
527
|
+
expect(useBuilderStore.getState().safeMode).toBe(false);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('setSafeMode disables freely on an unprotected env', () => {
|
|
531
|
+
useBuilderStore.setState({ selectedEnvironment: 'local', safeMode: true });
|
|
532
|
+
expect(useBuilderStore.getState().setSafeMode(false)).toBe(true);
|
|
533
|
+
expect(useBuilderStore.getState().safeMode).toBe(false);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('switching flows reverts an unsafe-on-protected session to safe', () => {
|
|
537
|
+
useBuilderStore.setState({ selectedEnvironment: 'prod', safeMode: false });
|
|
538
|
+
const loginId = useBuilderStore.getState().flow.id;
|
|
539
|
+
useBuilderStore.getState().createWorkflow();
|
|
540
|
+
useBuilderStore.getState().switchWorkflow(loginId);
|
|
541
|
+
expect(useBuilderStore.getState().safeMode).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('fetchEnvironments falls back to the default when the selection is unknown', async () => {
|
|
545
|
+
vi.mocked(serverRunner.listEnvironments).mockResolvedValue({
|
|
546
|
+
defaultEnvironment: 'local',
|
|
547
|
+
environments: [env('local', false), env('prod', true)],
|
|
548
|
+
});
|
|
549
|
+
useBuilderStore.setState({ selectedEnvironment: 'gone' });
|
|
550
|
+
await useBuilderStore.getState().fetchEnvironments();
|
|
551
|
+
expect(useBuilderStore.getState().selectedEnvironment).toBe('local');
|
|
552
|
+
expect(useBuilderStore.getState().environments).toHaveLength(2);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('fetchEnvironments clears the list when the runner is offline', async () => {
|
|
556
|
+
vi.mocked(serverRunner.listEnvironments).mockResolvedValue(null);
|
|
557
|
+
await useBuilderStore.getState().fetchEnvironments();
|
|
558
|
+
expect(useBuilderStore.getState().environments).toEqual([]);
|
|
559
|
+
expect(useBuilderStore.getState().environmentsDefault).toBe('');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('loginEnvironment calls the client then re-fetches so authenticated flips true', async () => {
|
|
563
|
+
useBuilderStore.setState({
|
|
564
|
+
environments: [
|
|
565
|
+
{ ...env('dev', false), auth: { configured: true, authenticated: false, secretRef: 'dev-token' } },
|
|
566
|
+
],
|
|
567
|
+
environmentsDefault: 'dev',
|
|
568
|
+
selectedEnvironment: 'dev',
|
|
569
|
+
});
|
|
570
|
+
vi.mocked(serverRunner.loginEnvironment).mockResolvedValue(undefined);
|
|
571
|
+
vi.mocked(serverRunner.listEnvironments).mockResolvedValue({
|
|
572
|
+
defaultEnvironment: 'dev',
|
|
573
|
+
environments: [
|
|
574
|
+
{ ...env('dev', false), auth: { configured: true, authenticated: true, secretRef: 'dev-token' } },
|
|
575
|
+
],
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
await useBuilderStore.getState().loginEnvironment('dev');
|
|
579
|
+
|
|
580
|
+
expect(vi.mocked(serverRunner.loginEnvironment)).toHaveBeenCalledWith('dev');
|
|
581
|
+
expect(vi.mocked(serverRunner.listEnvironments)).toHaveBeenCalled();
|
|
582
|
+
expect(useBuilderStore.getState().environments.find((e) => e.name === 'dev')?.auth?.authenticated).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('setEnvironmentSecret calls the client then re-fetches environments', async () => {
|
|
586
|
+
useBuilderStore.setState({
|
|
587
|
+
environments: [env('dev', false)],
|
|
588
|
+
environmentsDefault: 'dev',
|
|
589
|
+
selectedEnvironment: 'dev',
|
|
590
|
+
});
|
|
591
|
+
vi.mocked(serverRunner.setEnvironmentSecret).mockResolvedValue(undefined);
|
|
592
|
+
vi.mocked(serverRunner.listEnvironments).mockResolvedValue({
|
|
593
|
+
defaultEnvironment: 'dev',
|
|
594
|
+
environments: [{ ...env('dev', false), secretKeys: ['k'] }],
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await useBuilderStore.getState().setEnvironmentSecret('dev', 'k', 'v');
|
|
598
|
+
|
|
599
|
+
expect(vi.mocked(serverRunner.setEnvironmentSecret)).toHaveBeenCalledWith('dev', 'k', 'v');
|
|
600
|
+
expect(vi.mocked(serverRunner.listEnvironments)).toHaveBeenCalled();
|
|
601
|
+
expect(useBuilderStore.getState().environments.find((e) => e.name === 'dev')?.secretKeys).toEqual(['k']);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('deleteEnvironmentSecret calls the client then re-fetches environments', async () => {
|
|
605
|
+
useBuilderStore.setState({
|
|
606
|
+
environments: [{ ...env('dev', false), secretKeys: ['k'] }],
|
|
607
|
+
environmentsDefault: 'dev',
|
|
608
|
+
selectedEnvironment: 'dev',
|
|
609
|
+
});
|
|
610
|
+
vi.mocked(serverRunner.deleteEnvironmentSecret).mockResolvedValue(undefined);
|
|
611
|
+
vi.mocked(serverRunner.listEnvironments).mockResolvedValue({
|
|
612
|
+
defaultEnvironment: 'dev',
|
|
613
|
+
environments: [env('dev', false)],
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await useBuilderStore.getState().deleteEnvironmentSecret('dev', 'k');
|
|
617
|
+
|
|
618
|
+
expect(vi.mocked(serverRunner.deleteEnvironmentSecret)).toHaveBeenCalledWith('dev', 'k');
|
|
619
|
+
expect(vi.mocked(serverRunner.listEnvironments)).toHaveBeenCalled();
|
|
620
|
+
expect(useBuilderStore.getState().environments.find((e) => e.name === 'dev')?.secretKeys).toEqual([]);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('setEnvironmentAuth calls the client then re-fetches so configured flips true', async () => {
|
|
624
|
+
useBuilderStore.setState({
|
|
625
|
+
environments: [env('dev', false)],
|
|
626
|
+
environmentsDefault: 'dev',
|
|
627
|
+
selectedEnvironment: 'dev',
|
|
628
|
+
});
|
|
629
|
+
const auth = { attach: { as: 'header' as const, name: 'Authorization', secretRef: 'dev-token' } };
|
|
630
|
+
vi.mocked(serverRunner.setEnvironmentAuth).mockResolvedValue(undefined);
|
|
631
|
+
vi.mocked(serverRunner.listEnvironments).mockResolvedValue({
|
|
632
|
+
defaultEnvironment: 'dev',
|
|
633
|
+
environments: [
|
|
634
|
+
{ ...env('dev', false), auth: { configured: true, authenticated: false, secretRef: 'dev-token', config: auth } },
|
|
635
|
+
],
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await useBuilderStore.getState().setEnvironmentAuth('dev', auth);
|
|
639
|
+
|
|
640
|
+
expect(vi.mocked(serverRunner.setEnvironmentAuth)).toHaveBeenCalledWith('dev', auth);
|
|
641
|
+
expect(vi.mocked(serverRunner.listEnvironments)).toHaveBeenCalled();
|
|
642
|
+
expect(useBuilderStore.getState().environments.find((e) => e.name === 'dev')?.auth?.configured).toBe(true);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe('builderStore agent picker', () => {
|
|
647
|
+
beforeEach(() => {
|
|
648
|
+
vi.mocked(agentClient.startAgent).mockClear().mockResolvedValue('run-1');
|
|
649
|
+
useBuilderStore.setState({ agentChoice: {}, agentRun: null });
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('runAgent threads the persisted agentChoice to startAgent when called with no opts', async () => {
|
|
653
|
+
useBuilderStore.getState().setAgentChoice({ agent: 'codex', model: 'gpt-x' });
|
|
654
|
+
await useBuilderStore.getState().runAgent({ action: 'edit-flow', flowId: 'f1', instruction: 'go' });
|
|
655
|
+
expect(agentClient.startAgent).toHaveBeenCalledWith(
|
|
656
|
+
{ action: 'edit-flow', flowId: 'f1', instruction: 'go' },
|
|
657
|
+
{ agent: 'codex', model: 'gpt-x' },
|
|
658
|
+
);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('runAgent prefers explicit opts over the persisted choice', async () => {
|
|
662
|
+
useBuilderStore.getState().setAgentChoice({ agent: 'codex', model: 'gpt-x' });
|
|
663
|
+
await useBuilderStore
|
|
664
|
+
.getState()
|
|
665
|
+
.runAgent({ action: 'edit-flow', flowId: 'f1', instruction: 'go' }, { model: 'other' });
|
|
666
|
+
expect(agentClient.startAgent).toHaveBeenCalledWith(expect.anything(), { model: 'other' });
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
describe('builderStore node sizing', () => {
|
|
671
|
+
beforeEach(() => {
|
|
672
|
+
useBuilderStore.setState({ flow: createLoginFlow(), run: null, logs: [], activeRun: null });
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('resizeNode writes metadata.size and preserves other metadata', () => {
|
|
676
|
+
useBuilderStore.getState().pinNodeOutput('result', { ok: true });
|
|
677
|
+
useBuilderStore.getState().resizeNode('result', { width: 320, height: 240 });
|
|
678
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'result')!;
|
|
679
|
+
expect(node.metadata?.size).toEqual({ width: 320, height: 240 });
|
|
680
|
+
expect(node.metadata?.pinnedOutput).toEqual({ ok: true });
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('size survives export/import round-trips', () => {
|
|
684
|
+
useBuilderStore.getState().resizeNode('result', { width: 320, height: 240 });
|
|
685
|
+
const json = useBuilderStore.getState().exportFlow();
|
|
686
|
+
useBuilderStore.getState().importFlow(json);
|
|
687
|
+
const node = useBuilderStore.getState().flow.nodes.find((n) => n.id === 'result')!;
|
|
688
|
+
expect(node.metadata?.size).toEqual({ width: 320, height: 240 });
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe('builderStore panel visibility', () => {
|
|
693
|
+
beforeEach(() => {
|
|
694
|
+
useBuilderStore.setState({ sidebarOpen: true, dockOpen: true, inspectorOpen: true });
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('toggleDock flips dockOpen', () => {
|
|
698
|
+
expect(useBuilderStore.getState().dockOpen).toBe(true);
|
|
699
|
+
useBuilderStore.getState().toggleDock();
|
|
700
|
+
expect(useBuilderStore.getState().dockOpen).toBe(false);
|
|
701
|
+
useBuilderStore.getState().toggleDock();
|
|
702
|
+
expect(useBuilderStore.getState().dockOpen).toBe(true);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('toggleInspector flips inspectorOpen', () => {
|
|
706
|
+
useBuilderStore.getState().toggleInspector();
|
|
707
|
+
expect(useBuilderStore.getState().inspectorOpen).toBe(false);
|
|
708
|
+
useBuilderStore.getState().toggleInspector();
|
|
709
|
+
expect(useBuilderStore.getState().inspectorOpen).toBe(true);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('toggleSidebar flips sidebarOpen', () => {
|
|
713
|
+
useBuilderStore.getState().toggleSidebar();
|
|
714
|
+
expect(useBuilderStore.getState().sidebarOpen).toBe(false);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('toggles persist their open state to localStorage', () => {
|
|
718
|
+
const store: Record<string, string> = {};
|
|
719
|
+
vi.stubGlobal('localStorage', {
|
|
720
|
+
getItem: (k: string) => store[k] ?? null,
|
|
721
|
+
setItem: (k: string, v: string) => {
|
|
722
|
+
store[k] = v;
|
|
723
|
+
},
|
|
724
|
+
removeItem: (k: string) => {
|
|
725
|
+
delete store[k];
|
|
726
|
+
},
|
|
727
|
+
clear: () => {
|
|
728
|
+
for (const k of Object.keys(store)) delete store[k];
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
try {
|
|
732
|
+
useBuilderStore.getState().toggleSidebar();
|
|
733
|
+
useBuilderStore.getState().toggleDock();
|
|
734
|
+
useBuilderStore.getState().toggleInspector();
|
|
735
|
+
expect(store['emberflow.panel.sidebar']).toBe('0');
|
|
736
|
+
expect(store['emberflow.panel.dock']).toBe('0');
|
|
737
|
+
expect(store['emberflow.panel.inspector']).toBe('0');
|
|
738
|
+
useBuilderStore.getState().toggleDock();
|
|
739
|
+
expect(store['emberflow.panel.dock']).toBe('1');
|
|
740
|
+
} finally {
|
|
741
|
+
vi.unstubAllGlobals();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
describe('builderStore view register', () => {
|
|
747
|
+
beforeEach(() => {
|
|
748
|
+
useBuilderStore.setState({ viewRegister: 'simple' });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('defaults to simple', () => {
|
|
752
|
+
expect(useBuilderStore.getState().viewRegister).toBe('simple');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('setViewRegister switches registers', () => {
|
|
756
|
+
useBuilderStore.getState().setViewRegister('technical');
|
|
757
|
+
expect(useBuilderStore.getState().viewRegister).toBe('technical');
|
|
758
|
+
useBuilderStore.getState().setViewRegister('simple');
|
|
759
|
+
expect(useBuilderStore.getState().viewRegister).toBe('simple');
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('persists the selection to localStorage', () => {
|
|
763
|
+
const store: Record<string, string> = {};
|
|
764
|
+
vi.stubGlobal('localStorage', {
|
|
765
|
+
getItem: (k: string) => store[k] ?? null,
|
|
766
|
+
setItem: (k: string, v: string) => {
|
|
767
|
+
store[k] = v;
|
|
768
|
+
},
|
|
769
|
+
removeItem: (k: string) => {
|
|
770
|
+
delete store[k];
|
|
771
|
+
},
|
|
772
|
+
clear: () => {
|
|
773
|
+
for (const k of Object.keys(store)) delete store[k];
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
try {
|
|
777
|
+
useBuilderStore.getState().setViewRegister('technical');
|
|
778
|
+
expect(store['emberflow.view.register']).toBe('technical');
|
|
779
|
+
useBuilderStore.getState().setViewRegister('simple');
|
|
780
|
+
expect(store['emberflow.view.register']).toBe('simple');
|
|
781
|
+
} finally {
|
|
782
|
+
vi.unstubAllGlobals();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('migrates a legacy stored "business" value to simple on load', async () => {
|
|
787
|
+
vi.stubGlobal('localStorage', {
|
|
788
|
+
getItem: (k: string) => (k === 'emberflow.view.register' ? 'business' : null),
|
|
789
|
+
setItem: () => {},
|
|
790
|
+
removeItem: () => {},
|
|
791
|
+
clear: () => {},
|
|
792
|
+
});
|
|
793
|
+
try {
|
|
794
|
+
vi.resetModules();
|
|
795
|
+
const fresh = await import('./builderStore');
|
|
796
|
+
expect(fresh.useBuilderStore.getState().viewRegister).toBe('simple');
|
|
797
|
+
} finally {
|
|
798
|
+
vi.unstubAllGlobals();
|
|
799
|
+
vi.resetModules();
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
describe('builderStore console position', () => {
|
|
805
|
+
beforeEach(() => {
|
|
806
|
+
useBuilderStore.setState({ consolePosition: 'right' });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('defaults to right', () => {
|
|
810
|
+
expect(useBuilderStore.getState().consolePosition).toBe('right');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('setConsolePosition switches sides', () => {
|
|
814
|
+
useBuilderStore.getState().setConsolePosition('bottom');
|
|
815
|
+
expect(useBuilderStore.getState().consolePosition).toBe('bottom');
|
|
816
|
+
useBuilderStore.getState().setConsolePosition('right');
|
|
817
|
+
expect(useBuilderStore.getState().consolePosition).toBe('right');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('persists the selection to localStorage', () => {
|
|
821
|
+
const store: Record<string, string> = {};
|
|
822
|
+
vi.stubGlobal('localStorage', {
|
|
823
|
+
getItem: (k: string) => store[k] ?? null,
|
|
824
|
+
setItem: (k: string, v: string) => {
|
|
825
|
+
store[k] = v;
|
|
826
|
+
},
|
|
827
|
+
removeItem: (k: string) => {
|
|
828
|
+
delete store[k];
|
|
829
|
+
},
|
|
830
|
+
clear: () => {
|
|
831
|
+
for (const k of Object.keys(store)) delete store[k];
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
try {
|
|
835
|
+
useBuilderStore.getState().setConsolePosition('bottom');
|
|
836
|
+
expect(store['emberflow.console.position']).toBe('bottom');
|
|
837
|
+
useBuilderStore.getState().setConsolePosition('right');
|
|
838
|
+
expect(store['emberflow.console.position']).toBe('right');
|
|
839
|
+
} finally {
|
|
840
|
+
vi.unstubAllGlobals();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('loads a persisted "bottom" value on init', async () => {
|
|
845
|
+
vi.stubGlobal('localStorage', {
|
|
846
|
+
getItem: (k: string) => (k === 'emberflow.console.position' ? 'bottom' : null),
|
|
847
|
+
setItem: () => {},
|
|
848
|
+
removeItem: () => {},
|
|
849
|
+
clear: () => {},
|
|
850
|
+
});
|
|
851
|
+
try {
|
|
852
|
+
vi.resetModules();
|
|
853
|
+
const fresh = await import('./builderStore');
|
|
854
|
+
expect(fresh.useBuilderStore.getState().consolePosition).toBe('bottom');
|
|
855
|
+
} finally {
|
|
856
|
+
vi.unstubAllGlobals();
|
|
857
|
+
vi.resetModules();
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
describe('builderStore run console dismissal', () => {
|
|
863
|
+
const liveRun = (id: string) => ({
|
|
864
|
+
id,
|
|
865
|
+
workflowId: 'wf',
|
|
866
|
+
status: 'running' as const,
|
|
867
|
+
startedAt: '2026-07-02T10:00:00Z',
|
|
868
|
+
nodeStates: {},
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
beforeEach(() => {
|
|
872
|
+
useBuilderStore.setState({ run: null, runConsoleDismissedId: null, runConsoleOpenedIds: new Set() });
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it('dismissRunConsole records the current run id, reopenRunConsole clears it', () => {
|
|
876
|
+
useBuilderStore.setState({ run: liveRun('run-1') });
|
|
877
|
+
useBuilderStore.getState().dismissRunConsole();
|
|
878
|
+
expect(useBuilderStore.getState().runConsoleDismissedId).toBe('run-1');
|
|
879
|
+
useBuilderStore.getState().reopenRunConsole();
|
|
880
|
+
expect(useBuilderStore.getState().runConsoleDismissedId).toBeNull();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('dismissRunConsole with no run stores null', () => {
|
|
884
|
+
useBuilderStore.getState().dismissRunConsole();
|
|
885
|
+
expect(useBuilderStore.getState().runConsoleDismissedId).toBeNull();
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('a new run is not pre-dismissed (dismissal is per run id)', () => {
|
|
889
|
+
useBuilderStore.setState({ run: liveRun('run-1') });
|
|
890
|
+
useBuilderStore.getState().dismissRunConsole();
|
|
891
|
+
expect(useBuilderStore.getState().runConsoleDismissedId).toBe('run-1');
|
|
892
|
+
// A fresh run has a different id, so it is not considered dismissed.
|
|
893
|
+
useBuilderStore.setState({ run: liveRun('run-2') });
|
|
894
|
+
expect(useBuilderStore.getState().run!.id).not.toBe(useBuilderStore.getState().runConsoleDismissedId);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('starts with no opened-console run ids', () => {
|
|
898
|
+
expect(useBuilderStore.getState().runConsoleOpenedIds.size).toBe(0);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('reopenRunConsole records the current run id in runConsoleOpenedIds', () => {
|
|
902
|
+
useBuilderStore.setState({ run: liveRun('run-1') });
|
|
903
|
+
useBuilderStore.getState().reopenRunConsole();
|
|
904
|
+
expect(useBuilderStore.getState().runConsoleOpenedIds.has('run-1')).toBe(true);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('reopenRunConsole with no run leaves runConsoleOpenedIds untouched', () => {
|
|
908
|
+
useBuilderStore.getState().reopenRunConsole();
|
|
909
|
+
expect(useBuilderStore.getState().runConsoleOpenedIds.size).toBe(0);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it('opening one run does not mark a different run as opened', () => {
|
|
913
|
+
useBuilderStore.setState({ run: liveRun('run-1') });
|
|
914
|
+
useBuilderStore.getState().reopenRunConsole();
|
|
915
|
+
useBuilderStore.setState({ run: liveRun('run-2') });
|
|
916
|
+
expect(useBuilderStore.getState().runConsoleOpenedIds.has('run-2')).toBe(false);
|
|
917
|
+
expect(useBuilderStore.getState().runConsoleOpenedIds.has('run-1')).toBe(true);
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// NOTE: useRunConsole's actual open/closed gating (register === 'technical' ||
|
|
922
|
+
// runConsoleOpenedIds.has(run.id)) lives in the RunLogPanel hook, which reads
|
|
923
|
+
// multiple store slices via useBuilderStore selectors — that composition, and
|
|
924
|
+
// the toolbar's visual reflection of it, is only verifiable in a rendered
|
|
925
|
+
// browser/component-test environment, not from the store alone.
|
|
926
|
+
|
|
927
|
+
describe('builderStore subflow execution', () => {
|
|
928
|
+
const node = (id: string, type: string, extra: Partial<WorkflowNode> = {}): WorkflowNode => ({
|
|
929
|
+
id, type, label: id, position: { x: 0, y: 0 }, config: {}, ...extra,
|
|
930
|
+
});
|
|
931
|
+
const mkFlow = (
|
|
932
|
+
id: string, name: string, nodes: WorkflowNode[], edges: WorkflowDefinition['edges'],
|
|
933
|
+
): WorkflowDefinition => ({
|
|
934
|
+
id, name, version: 1, nodes, edges,
|
|
935
|
+
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
beforeEach(() => {
|
|
939
|
+
// Delay-free registry so end-to-end runs are fast; browser mode so runs
|
|
940
|
+
// execute in-tab through the store's subflow runner.
|
|
941
|
+
useBuilderStore.setState({
|
|
942
|
+
registry: createDefaultRegistry(0),
|
|
943
|
+
runnerOnline: false,
|
|
944
|
+
executionMode: 'browser',
|
|
945
|
+
run: null,
|
|
946
|
+
logs: [],
|
|
947
|
+
activeRun: null,
|
|
948
|
+
activeServerRunId: null,
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it('resolves a shelf workflow and forwards its logs with prefixing', async () => {
|
|
953
|
+
const child = mkFlow('child', 'Child', [node('cres', 'Result', { label: 'Result' })], []);
|
|
954
|
+
const parent = mkFlow(
|
|
955
|
+
'parent', 'Parent',
|
|
956
|
+
[
|
|
957
|
+
node('sub', 'Subflow', { config: { workflowId: 'child' } }),
|
|
958
|
+
node('res', 'Result', { inputMap: { data: { sourceNodeId: 'sub', sourceField: '$' } } }),
|
|
959
|
+
],
|
|
960
|
+
[{ id: 'e', source: 'sub', target: 'res', targetHandle: 'data' }],
|
|
961
|
+
);
|
|
962
|
+
useBuilderStore.setState({ flow: parent, shelf: [child] });
|
|
963
|
+
|
|
964
|
+
await useBuilderStore.getState().runToEnd();
|
|
965
|
+
const s = useBuilderStore.getState();
|
|
966
|
+
expect(s.run?.status).toBe('succeeded');
|
|
967
|
+
expect(s.run?.nodeStates.sub.status).toBe('succeeded');
|
|
968
|
+
// The child's Result-collected output flows into the parent's Result.
|
|
969
|
+
expect(s.run?.nodeStates.res.output).toEqual({ data: {} });
|
|
970
|
+
|
|
971
|
+
const forwarded = s.logs.find((l) => l.nodeId === 'sub/cres');
|
|
972
|
+
expect(forwarded, JSON.stringify(s.logs)).toBeDefined();
|
|
973
|
+
expect(forwarded?.nodeLabel).toBe('Child › Result');
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('rejects an unknown workflow id', async () => {
|
|
977
|
+
const parent = mkFlow('parent', 'Parent', [node('sub', 'Subflow', { config: { workflowId: 'nope' } })], []);
|
|
978
|
+
useBuilderStore.setState({ flow: parent, shelf: [] });
|
|
979
|
+
await useBuilderStore.getState().runToEnd();
|
|
980
|
+
const s = useBuilderStore.getState();
|
|
981
|
+
expect(s.run?.status).toBe('failed');
|
|
982
|
+
expect(s.run?.nodeStates.sub.error).toBe('Unknown workflow: nope');
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it('catches an A→B→A cycle at runtime', async () => {
|
|
986
|
+
const a = mkFlow('a', 'A', [node('subA', 'Subflow', { config: { workflowId: 'b' } })], []);
|
|
987
|
+
const b = mkFlow('b', 'B', [node('subB', 'Subflow', { config: { workflowId: 'a' } })], []);
|
|
988
|
+
useBuilderStore.setState({ flow: a, shelf: [b] });
|
|
989
|
+
await useBuilderStore.getState().runToEnd();
|
|
990
|
+
const s = useBuilderStore.getState();
|
|
991
|
+
expect(s.run?.status).toBe('failed');
|
|
992
|
+
// The cycle is reported from deep in the child; its error log is forwarded up.
|
|
993
|
+
expect(s.logs.some((l) => l.message === 'subflow cycle: a → b → a')).toBe(true);
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
describe('builderStore.syncNodeMeta', () => {
|
|
998
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
999
|
+
|
|
1000
|
+
it('merges fetched nodes and yields a fresh registry reference', async () => {
|
|
1001
|
+
vi.stubGlobal(
|
|
1002
|
+
'fetch',
|
|
1003
|
+
vi.fn(async () => ({
|
|
1004
|
+
ok: true,
|
|
1005
|
+
json: async () => ({ nodes: [{ type: 'Zzz', label: 'Zzz' }] }),
|
|
1006
|
+
})),
|
|
1007
|
+
);
|
|
1008
|
+
const before = useBuilderStore.getState().registry;
|
|
1009
|
+
await useBuilderStore.getState().syncNodeMeta();
|
|
1010
|
+
const after = useBuilderStore.getState().registry;
|
|
1011
|
+
expect(after).not.toBe(before);
|
|
1012
|
+
expect(after.has('Zzz')).toBe(true);
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
describe('builderStore.testWorkflow', () => {
|
|
1017
|
+
beforeEach(() => {
|
|
1018
|
+
vi.mocked(serverRunner.testWorkflow).mockReset();
|
|
1019
|
+
useBuilderStore.setState({ scenarioTestReports: {}, scenarioTestPending: null });
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('stores the report and toggles pending around the call', async () => {
|
|
1023
|
+
const report: ScenarioTestReport = {
|
|
1024
|
+
results: [{ opId: 'login', scenario: 'happy path', status: 'passed' }],
|
|
1025
|
+
passed: 1,
|
|
1026
|
+
failed: 0,
|
|
1027
|
+
skipped: 0,
|
|
1028
|
+
};
|
|
1029
|
+
let pendingDuringCall: string | null = null;
|
|
1030
|
+
vi.mocked(serverRunner.testWorkflow).mockImplementation(async () => {
|
|
1031
|
+
pendingDuringCall = useBuilderStore.getState().scenarioTestPending;
|
|
1032
|
+
return report;
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
expect(useBuilderStore.getState().scenarioTestPending).toBeNull();
|
|
1036
|
+
await useBuilderStore.getState().testWorkflow('login');
|
|
1037
|
+
|
|
1038
|
+
expect(pendingDuringCall).toBe('login');
|
|
1039
|
+
expect(useBuilderStore.getState().scenarioTestPending).toBeNull();
|
|
1040
|
+
expect(useBuilderStore.getState().scenarioTestReports.login).toEqual(report);
|
|
1041
|
+
expect(vi.mocked(serverRunner.testWorkflow)).toHaveBeenCalledWith('login', undefined);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it('threads the environment through to the client', async () => {
|
|
1045
|
+
vi.mocked(serverRunner.testWorkflow).mockResolvedValue({ results: [], passed: 0, failed: 0, skipped: 0 });
|
|
1046
|
+
await useBuilderStore.getState().testWorkflow('login', 'staging');
|
|
1047
|
+
expect(vi.mocked(serverRunner.testWorkflow)).toHaveBeenCalledWith('login', 'staging');
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('clears pending even when the client call rejects', async () => {
|
|
1051
|
+
vi.mocked(serverRunner.testWorkflow).mockRejectedValue(new Error('boom'));
|
|
1052
|
+
await expect(useBuilderStore.getState().testWorkflow('login')).rejects.toThrow('boom');
|
|
1053
|
+
expect(useBuilderStore.getState().scenarioTestPending).toBeNull();
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
describe('builderStore.checkRunner runnerMock', () => {
|
|
1058
|
+
beforeEach(() => {
|
|
1059
|
+
vi.mocked(serverRunner.runnerHealthy).mockReset();
|
|
1060
|
+
vi.mocked(serverRunner.listEnvironments).mockReset().mockResolvedValue(null);
|
|
1061
|
+
useBuilderStore.setState({ runnerOnline: null, runnerMock: false, workspaceSource: 'server' });
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it('sets runnerMock true when /healthz reports mock:true', async () => {
|
|
1065
|
+
vi.mocked(serverRunner.runnerHealthy).mockResolvedValue({ online: true, mock: true });
|
|
1066
|
+
await useBuilderStore.getState().checkRunner();
|
|
1067
|
+
expect(useBuilderStore.getState().runnerOnline).toBe(true);
|
|
1068
|
+
expect(useBuilderStore.getState().runnerMock).toBe(true);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('sets runnerMock false when /healthz reports mock:false', async () => {
|
|
1072
|
+
vi.mocked(serverRunner.runnerHealthy).mockResolvedValue({ online: true, mock: false });
|
|
1073
|
+
await useBuilderStore.getState().checkRunner();
|
|
1074
|
+
expect(useBuilderStore.getState().runnerMock).toBe(false);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('sets runnerMock false when the runner is offline', async () => {
|
|
1078
|
+
vi.mocked(serverRunner.runnerHealthy).mockResolvedValue({ online: false, mock: false });
|
|
1079
|
+
await useBuilderStore.getState().checkRunner();
|
|
1080
|
+
expect(useBuilderStore.getState().runnerOnline).toBe(false);
|
|
1081
|
+
expect(useBuilderStore.getState().runnerMock).toBe(false);
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
describe('builderStore mock runs (studio wiring)', () => {
|
|
1086
|
+
beforeEach(() => {
|
|
1087
|
+
vi.mocked(serverRunner.startServerRun).mockReset();
|
|
1088
|
+
vi.mocked(serverRunner.subscribeServerRun).mockReset();
|
|
1089
|
+
const flow = createLoginFlow();
|
|
1090
|
+
flow.scenarios = [
|
|
1091
|
+
{ id: 's1', name: 'happy', input: { username: 'g', password: 'x' }, mocks: { validate: { ok: true } } },
|
|
1092
|
+
];
|
|
1093
|
+
useBuilderStore.setState({
|
|
1094
|
+
flow,
|
|
1095
|
+
shelf: [],
|
|
1096
|
+
run: null,
|
|
1097
|
+
logs: [],
|
|
1098
|
+
activeRun: null,
|
|
1099
|
+
activeServerRunId: null,
|
|
1100
|
+
executionMode: 'server',
|
|
1101
|
+
runnerOnline: true,
|
|
1102
|
+
runnerMock: true,
|
|
1103
|
+
runHistory: [],
|
|
1104
|
+
activeScenarioId: null,
|
|
1105
|
+
activeRunMock: false,
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('runScenario sends scenarioName so the runner can merge that scenario\'s mocks', async () => {
|
|
1110
|
+
vi.mocked(serverRunner.startServerRun).mockResolvedValue('run-m1');
|
|
1111
|
+
vi.mocked(serverRunner.subscribeServerRun).mockImplementation(() => () => {});
|
|
1112
|
+
|
|
1113
|
+
await useBuilderStore.getState().runScenario('s1');
|
|
1114
|
+
|
|
1115
|
+
const call = vi.mocked(serverRunner.startServerRun).mock.calls[0];
|
|
1116
|
+
expect(call[4]).toMatchObject({ scenarioName: 'happy' });
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it('tags the history entry mock:true when the run started under Mock', async () => {
|
|
1120
|
+
let capturedHandlers: ServerRunHandlers | undefined;
|
|
1121
|
+
vi.mocked(serverRunner.startServerRun).mockResolvedValue('run-m2');
|
|
1122
|
+
vi.mocked(serverRunner.subscribeServerRun).mockImplementation((_runId, handlers) => {
|
|
1123
|
+
capturedHandlers = handlers;
|
|
1124
|
+
return () => {};
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
await useBuilderStore.getState().runScenario('s1');
|
|
1128
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
1129
|
+
capturedHandlers!.onFinished({
|
|
1130
|
+
id: 'run-m2',
|
|
1131
|
+
workflowId: flowId,
|
|
1132
|
+
status: 'succeeded',
|
|
1133
|
+
startedAt: '2026-07-10T10:00:00Z',
|
|
1134
|
+
completedAt: '2026-07-10T10:00:01Z',
|
|
1135
|
+
nodeStates: {},
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
const entry = useBuilderStore.getState().runHistory.find((r) => r.id === 'run-m2');
|
|
1139
|
+
expect(entry?.mock).toBe(true);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('does not tag mock on a run started while serving real', async () => {
|
|
1143
|
+
useBuilderStore.setState({ runnerMock: false });
|
|
1144
|
+
let capturedHandlers: ServerRunHandlers | undefined;
|
|
1145
|
+
vi.mocked(serverRunner.startServerRun).mockResolvedValue('run-r1');
|
|
1146
|
+
vi.mocked(serverRunner.subscribeServerRun).mockImplementation((_runId, handlers) => {
|
|
1147
|
+
capturedHandlers = handlers;
|
|
1148
|
+
return () => {};
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
await useBuilderStore.getState().runScenario('s1');
|
|
1152
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
1153
|
+
capturedHandlers!.onFinished({
|
|
1154
|
+
id: 'run-r1',
|
|
1155
|
+
workflowId: flowId,
|
|
1156
|
+
status: 'succeeded',
|
|
1157
|
+
startedAt: '2026-07-10T10:00:00Z',
|
|
1158
|
+
completedAt: '2026-07-10T10:00:01Z',
|
|
1159
|
+
nodeStates: {},
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
expect(useBuilderStore.getState().runHistory.find((r) => r.id === 'run-r1')?.mock).toBeUndefined();
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
describe('builderStore server-run finished event → errorHandler tag', () => {
|
|
1167
|
+
beforeEach(() => {
|
|
1168
|
+
vi.mocked(serverRunner.startServerRun).mockReset();
|
|
1169
|
+
vi.mocked(serverRunner.subscribeServerRun).mockReset();
|
|
1170
|
+
useBuilderStore.setState({
|
|
1171
|
+
flow: createLoginFlow(),
|
|
1172
|
+
shelf: [],
|
|
1173
|
+
run: null,
|
|
1174
|
+
logs: [],
|
|
1175
|
+
activeRun: null,
|
|
1176
|
+
activeServerRunId: null,
|
|
1177
|
+
executionMode: 'server',
|
|
1178
|
+
runnerOnline: true,
|
|
1179
|
+
runHistory: [],
|
|
1180
|
+
activeScenarioId: null,
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('tags the runHistory entry when the finished SSE event carries errorHandler', async () => {
|
|
1185
|
+
let capturedHandlers: ServerRunHandlers | undefined;
|
|
1186
|
+
vi.mocked(serverRunner.startServerRun).mockResolvedValue('run-1');
|
|
1187
|
+
vi.mocked(serverRunner.subscribeServerRun).mockImplementation((_runId, handlers) => {
|
|
1188
|
+
capturedHandlers = handlers;
|
|
1189
|
+
return () => {};
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
await useBuilderStore.getState().runToEnd();
|
|
1193
|
+
expect(capturedHandlers).toBeDefined();
|
|
1194
|
+
|
|
1195
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
1196
|
+
capturedHandlers!.onFinished(
|
|
1197
|
+
{
|
|
1198
|
+
id: 'run-1',
|
|
1199
|
+
workflowId: flowId,
|
|
1200
|
+
status: 'failed',
|
|
1201
|
+
startedAt: '2026-07-09T10:00:00Z',
|
|
1202
|
+
completedAt: '2026-07-09T10:00:01Z',
|
|
1203
|
+
nodeStates: {},
|
|
1204
|
+
},
|
|
1205
|
+
{ firedBy: 'notifyOnFailure' },
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1208
|
+
const entry = useBuilderStore.getState().runHistory.find((r) => r.id === 'run-1');
|
|
1209
|
+
expect(entry?.errorHandler).toEqual({ firedBy: 'notifyOnFailure' });
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it('leaves errorHandler unset for an ordinary finished run', async () => {
|
|
1213
|
+
let capturedHandlers: ServerRunHandlers | undefined;
|
|
1214
|
+
vi.mocked(serverRunner.startServerRun).mockResolvedValue('run-2');
|
|
1215
|
+
vi.mocked(serverRunner.subscribeServerRun).mockImplementation((_runId, handlers) => {
|
|
1216
|
+
capturedHandlers = handlers;
|
|
1217
|
+
return () => {};
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
await useBuilderStore.getState().runToEnd();
|
|
1221
|
+
const flowId = useBuilderStore.getState().flow.id;
|
|
1222
|
+
capturedHandlers!.onFinished({
|
|
1223
|
+
id: 'run-2',
|
|
1224
|
+
workflowId: flowId,
|
|
1225
|
+
status: 'succeeded',
|
|
1226
|
+
startedAt: '2026-07-09T10:00:00Z',
|
|
1227
|
+
completedAt: '2026-07-09T10:00:01Z',
|
|
1228
|
+
nodeStates: {},
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
const entry = useBuilderStore.getState().runHistory.find((r) => r.id === 'run-2');
|
|
1232
|
+
expect(entry?.errorHandler).toBeUndefined();
|
|
1233
|
+
});
|
|
1234
|
+
});
|