humainflow-web/src/app/shared/execution-tree/execution-tree.spec.ts

313 lines
12 KiB
TypeScript

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskExecution } from '@models/task-execution';
import { BlockType } from '@models/flow';
import { ContainersService } from '@services/containers/containers';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ExecutionTreeComponent, executionTreeHasContent } from './execution-tree';
function containerStep(overrides: Partial<TaskExecution['context']['steps'][string]> = {}) {
return {
id: 'container-1',
status: 'SUCCESS',
simulated: false,
node: { id: 'container-1', name: 'Loop container', nodeFamily: 'container' as const, typeName: 'LoopContainer', inputs: [], outputs: [], specificConfiguration: {} },
activeInnerExecutionId: 'iteration-3',
containerIterationIndex: 3,
...overrides
};
}
function rootExecution(steps: TaskExecution['context']['steps'] = {}): TaskExecution {
return {
id: 'root-1',
name: 'Root run',
creationTime: 1,
context: {
inputs: {},
result: {},
errors: {},
warnings: {},
waitingSteps: [],
status: 'RUNNING',
steps
}
};
}
function iteration(id: string, index: number, subflowRole: string | null = null): TaskExecution {
return {
id,
name: `Iteration ${index}`,
creationTime: index,
executionKind: 'SUBFLOW',
parentExecutionId: 'root-1',
parentStepId: 'container-1',
parentIterationIndex: index,
subflowRole: subflowRole ?? undefined,
context: {
inputs: {},
result: {},
errors: {},
warnings: {},
waitingSteps: [],
status: 'SUCCESS',
steps: {}
}
};
}
describe('ExecutionTreeComponent', () => {
let fixture: ComponentFixture<ExecutionTreeComponent>;
let component: ExecutionTreeComponent;
const retrieveStepIterations = vi.fn();
beforeEach(async () => {
retrieveStepIterations.mockReset();
await TestBed.configureTestingModule({
imports: [ExecutionTreeComponent],
providers: [
{ provide: ContainersService, useValue: { peekContainerType: () => null as BlockType | null } },
{ provide: TaskExecutionsService, useValue: { retrieveStepIterations } }
]
}).compileComponents();
fixture = TestBed.createComponent(ExecutionTreeComponent);
component = fixture.componentInstance;
});
it('reports no container content for a flat execution with only block steps', () => {
const execution = rootExecution({
'block-1': { id: 'block-1', status: 'SUCCESS', simulated: false, node: { id: 'block-1', name: 'LLM', nodeFamily: 'block', typeName: 'LLMBlock', inputs: [], outputs: [], specificConfiguration: {} } }
});
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
expect(component.hasContainerContent()).toBe(false);
expect(component.containerSteps(execution)).toEqual([]);
});
it('lists container steps and flags WAITING_FOR_SUBFLOW ones', () => {
const execution = rootExecution({ 'container-1': containerStep({ status: 'WAITING_FOR_SUBFLOW' }) });
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
const steps = component.containerSteps(execution);
expect(steps).toEqual([{
stepId: 'container-1',
name: 'Loop container',
status: 'WAITING_FOR_SUBFLOW',
waitingForSubflow: true
}]);
expect(component.hasContainerContent()).toBe(true);
});
it('lazily loads iterations only once, in order, on expand', () => {
retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN'), iteration('it-2', 2, 'MAIN')]));
const execution = rootExecution({ 'container-1': containerStep() });
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
expect(component.isStepLoaded('root-1', 'container-1')).toBe(false);
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
expect(retrieveStepIterations).toHaveBeenCalledWith('root-1', 'container-1');
expect(retrieveStepIterations).toHaveBeenCalledTimes(1);
expect(component.isStepExpanded('root-1', 'container-1')).toBe(true);
expect(component.iterationsFor('root-1', 'container-1').map((it) => it.id)).toEqual(['it-1', 'it-2']);
component.toggleStep('root-1', 'container-1');
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
expect(retrieveStepIterations).toHaveBeenCalledTimes(1);
});
it('picks up new iterations and changed statuses on a poll tick', () => {
// The list endpoint is the only source both for which children exist and for what each is
// doing. Fetched once on expand, the tree showed a three-iteration run as "Iteration 1,
// RUNNING" for its whole life: a working run looked stuck, then looked like it had run once.
const running = { ...iteration('it-1', 1, 'MAIN') };
running.context = { ...running.context, status: 'RUNNING' };
retrieveStepIterations.mockReturnValue(of([running]));
const steps = { 'container-1': containerStep({ status: 'WAITING_FOR_SUBFLOW' }) };
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
expect(component.iterationsFor('root-1', 'container-1').map((one) => one.context.status))
.toEqual(['RUNNING']);
retrieveStepIterations.mockReturnValue(of([
iteration('it-1', 1, 'MAIN'),
iteration('it-2', 2, 'MAIN')
]));
// Polling hands the component a new object with the same id, which is the refresh signal.
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
expect(component.iterationsFor('root-1', 'container-1').map((one) => one.id))
.toEqual(['it-1', 'it-2']);
expect(component.iterationsFor('root-1', 'container-1').map((one) => one.context.status))
.toEqual(['SUCCESS', 'SUCCESS']);
});
it('refreshes once more on the tick that reports the run finished', () => {
// That tick carries the final status, and with it the last child's real state.
retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN')]));
const steps = { 'container-1': containerStep() };
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
const afterExpand = retrieveStepIterations.mock.calls.length;
const finished = rootExecution(steps);
finished.context.status = 'SUCCESS';
fixture.componentRef.setInput('rootExecution', finished);
fixture.detectChanges();
expect(retrieveStepIterations.mock.calls.length).toBe(afterExpand + 1);
// And then stops: nothing more will change, so further ticks cost nothing.
const settled = rootExecution(steps);
settled.context.status = 'SUCCESS';
fixture.componentRef.setInput('rootExecution', settled);
fixture.detectChanges();
expect(retrieveStepIterations.mock.calls.length).toBe(afterExpand + 1);
});
it('refreshes nothing while no step has been opened', () => {
retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN')]));
const steps = { 'container-1': containerStep() };
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
expect(retrieveStepIterations).not.toHaveBeenCalled();
});
it('keeps the iterations on screen when a refresh fails', () => {
retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN')]));
const steps = { 'container-1': containerStep() };
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
retrieveStepIterations.mockReturnValue(throwError(() => ({ status: 500 })));
fixture.componentRef.setInput('rootExecution', rootExecution(steps));
fixture.detectChanges();
// Replacing a list with an error would hide iterations that are still perfectly valid.
expect(component.iterationsFor('root-1', 'container-1').map((one) => one.id)).toEqual(['it-1']);
expect(component.stepError('root-1', 'container-1')).toBeNull();
});
it('filters out GUARD subflows from a LoopContainer iteration list', () => {
retrieveStepIterations.mockReturnValue(of([
iteration('main-1', 1, 'MAIN'),
iteration('guard-1', 1, 'GUARD'),
iteration('main-2', 2, 'MAIN'),
iteration('guard-2', 2, 'GUARD')
]));
const execution = rootExecution({ 'container-1': containerStep() });
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
expect(component.iterationsFor('root-1', 'container-1').map((it) => it.id)).toEqual(['main-1', 'main-2']);
});
it('surfaces a friendly message when the step is not a container', () => {
retrieveStepIterations.mockReturnValue(throwError(() => ({ status: 400 })));
const execution = rootExecution({ 'container-1': containerStep() });
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
expect(component.stepError('root-1', 'container-1')).toBe('This step is not an iterating container.');
expect(component.isStepLoading('root-1', 'container-1')).toBe(false);
});
it('emits the selected execution id on click', () => {
const execution = rootExecution({ 'container-1': containerStep() });
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
const selected = vi.fn();
component.executionSelected.subscribe(selected);
component.selectExecution(execution);
expect(selected).toHaveBeenCalledWith({ executionId: 'root-1' });
});
it('resets expansion and cached iterations when the root execution id changes', () => {
retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN')]));
const execution = rootExecution({ 'container-1': containerStep() });
fixture.componentRef.setInput('rootExecution', execution);
fixture.detectChanges();
component.toggleStep('root-1', 'container-1');
fixture.detectChanges();
expect(component.isStepLoaded('root-1', 'container-1')).toBe(true);
const otherExecution = rootExecution({ 'container-2': containerStep({ id: 'container-2' }) });
otherExecution.id = 'root-2';
fixture.componentRef.setInput('rootExecution', otherExecution);
fixture.detectChanges();
expect(component.isStepLoaded('root-1', 'container-1')).toBe(false);
expect(component.isExecutionExpanded('root-2')).toBe(true);
});
});
describe('executionTreeHasContent', () => {
const known = (typeName: string) => typeName === 'IteratorContainer';
function run(steps: Record<string, any>): any {
return { id: 'e1', name: 'run', creationTime: 0, context: { steps } };
}
it('reports nothing to show for a run without container steps', () => {
expect(executionTreeHasContent(run({
s1: { id: 's1', status: 'COMPLETED', simulated: false, node: { nodeFamily: 'block' } }
}), known)).toBe(false);
});
it('reports nothing for a missing run or a run with no steps', () => {
expect(executionTreeHasContent(null, known)).toBe(false);
expect(executionTreeHasContent(run({}), known)).toBe(false);
});
it('detects an explicit container node', () => {
expect(executionTreeHasContent(run({
s1: { id: 's1', status: 'RUNNING', simulated: false, node: { nodeFamily: 'container' } }
}), known)).toBe(true);
});
it('detects a step that already spawned a child execution', () => {
expect(executionTreeHasContent(run({
s1: { id: 's1', status: 'RUNNING', simulated: false, activeInnerExecutionId: 'child' }
}), known)).toBe(true);
});
it('detects a container by its registered type name', () => {
expect(executionTreeHasContent(run({
s1: { id: 's1', status: 'READY', simulated: false, node: { typeName: 'IteratorContainer' } }
}), known)).toBe(true);
expect(executionTreeHasContent(run({
s1: { id: 's1', status: 'READY', simulated: false, node: { typeName: 'LLMBlock' } }
}), known)).toBe(false);
});
});