Files
windmill/frontend/src/lib/components/restartFromStepPath.test.ts
T
hugocasaandClaude Opus 4.8 3b0781761b fix(frontend): show nested restart button for subflows nested in containers (#10042)
* 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>
2026-07-10 15:23:09 +00:00

239 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 subflows 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()
})
})