mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
* fix(frontend): show nested restart button for subflows nested in containers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): keep nested-restart flat fallback anchored to the leaf Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
7.9 KiB
TypeScript
239 lines
7.9 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import type { FlowModule } from '$lib/gen'
|
||
import {
|
||
buildNestedRestartPath,
|
||
findStepPath,
|
||
parseExpandedSubflowId,
|
||
type ForloopGraphState,
|
||
type FlowStatusModuleLite
|
||
} from './restartFromStepPath'
|
||
|
||
// --- minimal FlowModule fixtures (findStepPath only reads type/modules/branches/parallel) ---
|
||
const script = (id: string): FlowModule =>
|
||
({ id, value: { type: 'rawscript', language: 'bun', content: '', input_transforms: {} } }) as any
|
||
const subflow = (id: string, path = 'f/x'): FlowModule =>
|
||
({ id, value: { type: 'flow', path } }) as any
|
||
const forloop = (id: string, modules: FlowModule[], parallel = false): FlowModule =>
|
||
({
|
||
id,
|
||
value: { type: 'forloopflow', modules, iterator: { type: 'static', value: [] }, parallel }
|
||
}) as any
|
||
const whileloop = (id: string, modules: FlowModule[]): FlowModule =>
|
||
({ id, value: { type: 'whileloopflow', modules, parallel: false } }) as any
|
||
const branchone = (id: string, def: FlowModule[], branches: FlowModule[][]): FlowModule =>
|
||
({
|
||
id,
|
||
value: { type: 'branchone', default: def, branches: branches.map((m) => ({ modules: m })) }
|
||
}) as any
|
||
const branchall = (id: string, branches: FlowModule[][], parallel = false): FlowModule =>
|
||
({
|
||
id,
|
||
value: { type: 'branchall', branches: branches.map((m) => ({ modules: m })), parallel }
|
||
}) as any
|
||
|
||
function build(opts: {
|
||
selectedJobStep: string
|
||
rawFlowModules: FlowModule[]
|
||
flowStatusModules?: FlowStatusModuleLite[]
|
||
graphModuleStates?: Record<string, ForloopGraphState>
|
||
expandedSubflows?: Record<string, { modules: FlowModule[] }>
|
||
}) {
|
||
return buildNestedRestartPath({
|
||
flowStatusModules: [],
|
||
graphModuleStates: {},
|
||
expandedSubflows: {},
|
||
...opts
|
||
})
|
||
}
|
||
|
||
describe('parseExpandedSubflowId', () => {
|
||
it('parses a multi-level subflow id', () => {
|
||
expect(parseExpandedSubflowId('subflow:a:b:leaf')).toEqual({
|
||
subflowSteps: ['a', 'b'],
|
||
leaf: 'leaf'
|
||
})
|
||
})
|
||
it('returns undefined for a bare id or a single segment', () => {
|
||
expect(parseExpandedSubflowId('leaf')).toBeUndefined()
|
||
expect(parseExpandedSubflowId('subflow:leaf')).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('buildNestedRestartPath', () => {
|
||
it('pure nested subflows: top is the outer subflow, no iterations', () => {
|
||
const r = build({
|
||
selectedJobStep: 'subflow:stop:smid:a',
|
||
rawFlowModules: [subflow('stop', 'f/mid')],
|
||
expandedSubflows: {
|
||
stop: { modules: [subflow('smid', 'f/leaf')] },
|
||
'subflow:stop:smid': { modules: [script('a')] }
|
||
}
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'stop',
|
||
topBranchOrIterationN: undefined,
|
||
path: [{ step_id: 'smid' }, { step_id: 'a' }],
|
||
iterationCounts: {}
|
||
})
|
||
})
|
||
|
||
it('subflow nested inside a ForLoop: recovers the ForLoop as the top step (the reported bug)', () => {
|
||
const r = build({
|
||
selectedJobStep: 'subflow:s:a',
|
||
rawFlowModules: [forloop('L', [subflow('s', 'f/leaf')])],
|
||
expandedSubflows: { s: { modules: [script('a')] } },
|
||
graphModuleStates: { L: { selectedForloopIndex: 1, flow_jobs: ['j0', 'j1'] } }
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'L',
|
||
topBranchOrIterationN: 1,
|
||
path: [{ step_id: 's' }, { step_id: 'a' }],
|
||
iterationCounts: { top: 2 }
|
||
})
|
||
})
|
||
|
||
it('ForLoop sitting between two subflow boundaries is recovered as an inner path step', () => {
|
||
const r = build({
|
||
selectedJobStep: 'subflow:sa:sb:a',
|
||
rawFlowModules: [subflow('sa', 'f/mid')],
|
||
expandedSubflows: {
|
||
sa: { modules: [forloop('L', [subflow('sb', 'f/leaf')])] },
|
||
'subflow:sa:sb': { modules: [script('a')] }
|
||
},
|
||
graphModuleStates: { 'subflow:sa:L': { selectedForloopIndex: 0, flow_jobs: ['j0', 'j1'] } }
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'sa',
|
||
topBranchOrIterationN: undefined,
|
||
path: [{ step_id: 'L', branch_or_iteration_n: 0 }, { step_id: 'sb' }, { step_id: 'a' }],
|
||
iterationCounts: { 'inner-0': 2 }
|
||
})
|
||
})
|
||
|
||
it('leaf directly inside a top-level ForLoop (no subflow)', () => {
|
||
const r = build({
|
||
selectedJobStep: 'x',
|
||
rawFlowModules: [forloop('L', [script('x')])],
|
||
graphModuleStates: { L: { selectedForloopIndex: 2, flow_jobs: ['a', 'b', 'c'] } }
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'L',
|
||
topBranchOrIterationN: 2,
|
||
path: [{ step_id: 'x' }],
|
||
iterationCounts: { top: 3 }
|
||
})
|
||
})
|
||
|
||
it('the leaf itself is a ForLoop: exposes its own iteration too', () => {
|
||
const r = build({
|
||
selectedJobStep: 'M',
|
||
rawFlowModules: [forloop('L', [forloop('M', [script('y')])])],
|
||
graphModuleStates: {
|
||
L: { selectedForloopIndex: 0, flow_jobs: ['a'] },
|
||
M: { selectedForloopIndex: 1, flow_jobs: ['p', 'q'] }
|
||
}
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'L',
|
||
topBranchOrIterationN: 0,
|
||
path: [{ step_id: 'M', branch_or_iteration_n: 1 }],
|
||
iterationCounts: { top: 1, 'inner-0': 2 }
|
||
})
|
||
})
|
||
|
||
it('BranchOne top step: no iteration sent (backend locks the branch from the run)', () => {
|
||
const r = build({
|
||
selectedJobStep: 'x',
|
||
rawFlowModules: [branchone('BO', [script('d')], [[script('x')]])],
|
||
flowStatusModules: [{ id: 'BO', branch_chosen: { type: 'branch', branch: 0 } }]
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'BO',
|
||
topBranchOrIterationN: undefined,
|
||
path: [{ step_id: 'x' }],
|
||
iterationCounts: {}
|
||
})
|
||
})
|
||
|
||
it('BranchOne default branch taken and leaf is in the default branch', () => {
|
||
const r = build({
|
||
selectedJobStep: 'd',
|
||
rawFlowModules: [branchone('BO', [script('d')], [[script('x')]])],
|
||
flowStatusModules: [{ id: 'BO', branch_chosen: { type: 'default' } }]
|
||
})
|
||
expect(r?.topStepId).toBe('BO')
|
||
expect(r?.path).toEqual([{ step_id: 'd' }])
|
||
})
|
||
|
||
it('rejects a leaf inside a BranchOne branch the original run did not take', () => {
|
||
const r = build({
|
||
selectedJobStep: 'x',
|
||
rawFlowModules: [branchone('BO', [script('d')], [[script('x')]])],
|
||
// Original run took the default, not branch 0 (which contains x).
|
||
flowStatusModules: [{ id: 'BO', branch_chosen: { type: 'default' } }]
|
||
})
|
||
expect(r).toBeNull()
|
||
})
|
||
|
||
it('rejects unsupported containers: parallel ForLoop, BranchAll, WhileLoop', () => {
|
||
expect(
|
||
build({ selectedJobStep: 'x', rawFlowModules: [forloop('L', [script('x')], true)] })
|
||
).toBeNull()
|
||
expect(
|
||
build({ selectedJobStep: 'x', rawFlowModules: [branchall('B', [[script('x')]])] })
|
||
).toBeNull()
|
||
expect(
|
||
build({ selectedJobStep: 'x', rawFlowModules: [whileloop('W', [script('x')])] })
|
||
).toBeNull()
|
||
})
|
||
|
||
it('returns null for a top-level step (handled by the top-level path)', () => {
|
||
expect(build({ selectedJobStep: 'top', rawFlowModules: [script('top')] })).toBeNull()
|
||
})
|
||
|
||
it('falls back to a flat path when a subflow’s modules are not loaded yet', () => {
|
||
const r = build({
|
||
selectedJobStep: 'subflow:stop:smid:a',
|
||
rawFlowModules: [subflow('stop', 'f/mid')],
|
||
expandedSubflows: {} // stop not expanded → modules unavailable at level 1
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'stop',
|
||
topBranchOrIterationN: undefined,
|
||
path: [{ step_id: 'smid' }, { step_id: 'a' }],
|
||
iterationCounts: {}
|
||
})
|
||
})
|
||
|
||
it('flat fallback still ends at the leaf when the deepest modules are loaded but stale', () => {
|
||
// smid IS expanded but its cached modules don't contain the leaf `a`
|
||
// (stale cache). The chain must still end at `a`, never at the `smid`
|
||
// subflow boundary.
|
||
const r = build({
|
||
selectedJobStep: 'subflow:stop:smid:a',
|
||
rawFlowModules: [subflow('stop', 'f/mid')],
|
||
expandedSubflows: {
|
||
stop: { modules: [subflow('smid', 'f/leaf')] },
|
||
'subflow:stop:smid': { modules: [script('z')] } // no `a`
|
||
}
|
||
})
|
||
expect(r).toEqual({
|
||
topStepId: 'stop',
|
||
topBranchOrIterationN: undefined,
|
||
path: [{ step_id: 'smid' }, { step_id: 'a' }],
|
||
iterationCounts: {}
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('findStepPath', () => {
|
||
it('locates a leaf inside a ForLoop and reports the ancestor chain', () => {
|
||
const p = findStepPath([forloop('L', [script('x')])], 'x')
|
||
expect(p?.target.id).toBe('x')
|
||
expect(p?.ancestors).toEqual([{ stepId: 'L', type: 'forloopflow', parallel: false }])
|
||
})
|
||
it('does not cross subflow boundaries', () => {
|
||
expect(findStepPath([subflow('s')], 'a')).toBeUndefined()
|
||
})
|
||
})
|