175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
import { TaskExecution, TaskExecutionStep } from '@models/task-execution';
|
|
import {
|
|
buildAuthorizationGate,
|
|
buildVisibleExecutionLogs,
|
|
getExecutionInputValues,
|
|
getExecutionOutputValues,
|
|
isExecutionStartable
|
|
} from './execution-viewer.utils';
|
|
|
|
describe('execution viewer runtime values', () => {
|
|
const documentedStep: TaskExecutionStep = {
|
|
id: 'decision-1',
|
|
status: 'WAITING_FOR_INTERACTION',
|
|
simulated: false,
|
|
node: {
|
|
id: 'decision-1',
|
|
name: 'Decision',
|
|
typeName: 'HumanDecisionBlock',
|
|
inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
|
|
outputs: [
|
|
{ name: 'approve', type: 'TEXT', multiple: false },
|
|
{ name: 'reject', type: 'TEXT', multiple: false }
|
|
],
|
|
specificConfiguration: {}
|
|
}
|
|
};
|
|
|
|
it('uses node port descriptors when the execution step omits runtime wrappers', () => {
|
|
expect(getExecutionInputValues(documentedStep, {
|
|
'decision-1:input': 'Candidate evidence'
|
|
})).toEqual({ input: 'Candidate evidence' });
|
|
|
|
expect(getExecutionOutputValues(documentedStep, {
|
|
'decision-1:approve': 'Candidate evidence'
|
|
})).toEqual({ approve: 'Candidate evidence' });
|
|
});
|
|
|
|
it('surfaces inner subflow identifiers from bias experiment events', () => {
|
|
const [event] = buildVisibleExecutionLogs([{
|
|
id: 'event-1',
|
|
timestamp: 1,
|
|
type: 'BIAS_EXPERIMENT_APPLIED',
|
|
message: 'Applied behavioral probe',
|
|
details: {
|
|
innerExecutionId: 'inner-execution-1',
|
|
innerNodeId: 'inner-node-1',
|
|
innerStepId: 'inner-step-1',
|
|
iterationIndex: 2,
|
|
containerType: 'LoopContainer'
|
|
}
|
|
}]);
|
|
|
|
expect(event).toEqual(expect.objectContaining({
|
|
innerExecutionId: 'inner-execution-1',
|
|
innerNodeId: 'inner-node-1',
|
|
innerStepId: 'inner-step-1',
|
|
iterationIndex: '2',
|
|
containerType: 'LoopContainer'
|
|
}));
|
|
});
|
|
});
|
|
|
|
describe('authorization gate', () => {
|
|
const geminiRequirement = {
|
|
key: 'LLMProvider::Gemini::authorization',
|
|
provider: 'Gemini',
|
|
fieldName: 'authorization',
|
|
description: 'Select a saved credential for Gemini.',
|
|
requiredBySteps: ['step-1']
|
|
};
|
|
|
|
const headerRequirement = {
|
|
key: 'HTTPServerCall::step-2::authorization',
|
|
provider: 'HTTPServerCall',
|
|
fieldName: 'authorization',
|
|
description: 'Authorization header',
|
|
requiredBySteps: ['step-2']
|
|
};
|
|
|
|
const capabilities = [
|
|
{ name: 'InternalOllama', requiresCredential: false },
|
|
{ name: 'Gemini', requiresCredential: true }
|
|
];
|
|
|
|
const readyState = { capabilities, loading: false, failed: false };
|
|
const failedState = { capabilities: [], loading: false, failed: true };
|
|
|
|
const execution = (overrides: Partial<TaskExecution> = {}): TaskExecution => ({
|
|
id: 'execution-1',
|
|
name: 'Flow',
|
|
creationTime: 1,
|
|
requiredAuthorizations: [geminiRequirement],
|
|
providedAuthorizations: {},
|
|
missingAuthorizationKeys: ['LLMProvider::Gemini::authorization'],
|
|
context: {
|
|
inputs: {},
|
|
result: {},
|
|
errors: {},
|
|
warnings: {},
|
|
steps: {},
|
|
status: 'CREATED',
|
|
waitingSteps: []
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
it('blocks the start until the required credential is provided', () => {
|
|
const pending = execution();
|
|
expect(isExecutionStartable(pending, buildAuthorizationGate(pending, readyState))).toBe(false);
|
|
|
|
const provided = execution({
|
|
providedAuthorizations: { 'LLMProvider::Gemini::authorization': 'vault-secret-1' },
|
|
missingAuthorizationKeys: []
|
|
});
|
|
const gate = buildAuthorizationGate(provided, readyState);
|
|
|
|
expect(gate.satisfied).toBe(true);
|
|
expect(gate.vault).toEqual([]);
|
|
expect(gate.satisfiedVault.map((entry) => entry.provider)).toEqual(['Gemini']);
|
|
expect(isExecutionStartable(provided, gate)).toBe(true);
|
|
});
|
|
|
|
it('keeps a provider key on the vault path even when the catalog is unavailable', () => {
|
|
const gate = buildAuthorizationGate(execution(), failedState);
|
|
|
|
expect(gate.runtime).toEqual([]);
|
|
expect(gate.vault.map((entry) => entry.provider)).toEqual(['Gemini']);
|
|
expect(gate.vault[0].requiresCredential).toBeNull();
|
|
expect(gate.missingProviders).toEqual(['Gemini']);
|
|
});
|
|
|
|
it('routes authorizations that are not provider credentials to the literal-value panel', () => {
|
|
const withHeader = execution({
|
|
requiredAuthorizations: [geminiRequirement, headerRequirement],
|
|
missingAuthorizationKeys: [geminiRequirement.key, headerRequirement.key]
|
|
});
|
|
const gate = buildAuthorizationGate(withHeader, readyState);
|
|
|
|
expect(gate.runtime.map((requirement) => requirement.key)).toEqual([headerRequirement.key]);
|
|
expect(gate.vault.map((entry) => entry.requirement.key)).toEqual([geminiRequirement.key]);
|
|
expect(gate.satisfied).toBe(false);
|
|
});
|
|
|
|
it('reads requirements given as a map exactly like the array form', () => {
|
|
const asMap = execution({
|
|
requiredAuthorizations: { [geminiRequirement.key]: geminiRequirement }
|
|
});
|
|
|
|
expect(buildAuthorizationGate(asMap, readyState).vault.map((entry) => entry.requirement.key))
|
|
.toEqual([geminiRequirement.key]);
|
|
});
|
|
|
|
it('does not start an execution whose inputs are still unset', () => {
|
|
const missingInput = execution({
|
|
missingAuthorizationKeys: [],
|
|
context: {
|
|
...execution().context,
|
|
globalInputDescriptors: { topic: { name: 'topic', kind: 'TEXT', value: null } },
|
|
globalInputs: {}
|
|
}
|
|
});
|
|
|
|
expect(isExecutionStartable(missingInput, buildAuthorizationGate(missingInput, readyState))).toBe(false);
|
|
});
|
|
|
|
it('does not start an execution that has already left INIT', () => {
|
|
const running = execution({
|
|
missingAuthorizationKeys: [],
|
|
context: { ...execution().context, status: 'RUNNING' }
|
|
});
|
|
|
|
expect(isExecutionStartable(running, buildAuthorizationGate(running, readyState))).toBe(false);
|
|
});
|
|
});
|