174 lines
9.1 KiB
JavaScript
174 lines
9.1 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {randomUUID} from 'node:crypto';
|
|
import {EventEmitter} from 'node:events';
|
|
import {fileURLToPath} from 'node:url';
|
|
import test from 'node:test';
|
|
import {build} from 'esbuild';
|
|
|
|
const compiled = await build({
|
|
entryPoints: [fileURLToPath(new URL('../bot/info/bot/workflow_states.ts', import.meta.url))],
|
|
bundle: true, platform: 'node', format: 'cjs', write: false,
|
|
});
|
|
const module = {exports: {}};
|
|
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
|
|
assert.fail(`Unexpected workflow info runtime dependency: ${specifier}`);
|
|
}, module, module.exports);
|
|
const WorkflowStates = module.exports.init;
|
|
|
|
function fixture(t, legacy = false) {
|
|
const command = {command_id: randomUUID(), action: 'trigger_start', workflow_id: 'gather', state: 'accepted'};
|
|
const runtime = {source_config_revision: 5, workflows: [{workflow_id: 'gather', generation: 2, enabled: true,
|
|
start_type: 'event', trigger_state: 'listening', accepted_count: 0, max_runs: null,
|
|
next_fire_at: null, active_run_ids: [], queued_count: 0, last_error: null}], commands: [command]};
|
|
const run = {run_id: randomUUID(), workflow_id: 'gather', status: 'running', steps: 1, active_nodes: ['query'],
|
|
started_at: 1, ended_at: null, stop_reason: null, error: null,
|
|
...(!legacy ? {trigger: {secret: 'PRIVATE_RUN_TRIGGER'}, trigger_available: true} : {}),
|
|
branches: [{trigger: {secret: 'PRIVATE_TRIGGER'}, vars: {secret: 'PRIVATE_VARS'},
|
|
outputs: {secret: 'PRIVATE_OUTPUT'}, inputs: {secret: 'PRIVATE_INPUT'}, last_result: null, retry_counts: {}}]};
|
|
const runs = [run];
|
|
const listeners = new Set();
|
|
let unsubscribes = 0;
|
|
const source = {get_snapshots: () => runs,
|
|
...(!legacy ? {get_runtime_snapshot: () => runtime} : {}),
|
|
on_update(callback) {
|
|
listeners.add(callback);
|
|
return () => { unsubscribes++; listeners.delete(callback); };
|
|
},
|
|
};
|
|
const bot = new EventEmitter();
|
|
const info = new WorkflowStates(bot, {workflow_source: source});
|
|
t.after(() => info.dispose());
|
|
return {info, bot, source, runtime, command, run, runs, listeners,
|
|
unsubscribes: () => unsubscribes,
|
|
emit(event) { for (const listener of [...listeners]) listener(event); },
|
|
};
|
|
}
|
|
|
|
test('workflow_states reads runtime workflows and commands and excludes run branch state from streamed info', t => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
const app = fixture(t);
|
|
assert.equal(app.info.name, 'workflow_states');
|
|
assert.equal(app.bot.listenerCount('end'), 0);
|
|
assert.equal(app.listeners.size, 1);
|
|
const {branches, trigger, ...summary} = app.run;
|
|
const initial = app.info.get_info();
|
|
assert.deepEqual(initial, {available: true, ...app.runtime, runs: [summary]});
|
|
assert.equal(initial.runs[0].trigger_available, true);
|
|
assert.equal(Object.hasOwn(initial.runs[0], 'trigger'), false);
|
|
assert.equal(Object.hasOwn(initial.runs[0], 'branches'), false);
|
|
assert.equal(JSON.stringify(initial).includes('PRIVATE_'), false);
|
|
initial.workflows[0].active_run_ids.push('mutated');
|
|
initial.commands[0].state = 'failed'; initial.runs[0].active_nodes.push('mutated');
|
|
assert.deepEqual(app.runtime.workflows[0].active_run_ids, []);
|
|
assert.equal(app.command.state, 'accepted');
|
|
assert.deepEqual(app.run.active_nodes, ['query']);
|
|
const updates = [];
|
|
app.info.on_update(() => { throw Error('listener failure'); });
|
|
app.info.on_update(value => { value.commands[0].state = 'failed'; value.runs.length = 0; });
|
|
app.info.on_update(value => updates.push(value));
|
|
app.run.status = 'completed'; app.run.ended_at = 2; app.run.active_nodes = [];
|
|
app.command.state = 'completed'; app.command.result = {generation: 2};
|
|
app.emit({type: 'run_updated', snapshot: app.run});
|
|
assert.equal(updates.length, 0);
|
|
t.mock.timers.tick(250);
|
|
assert.equal(updates.length, 1);
|
|
assert.equal(updates[0].runs[0].status, 'completed');
|
|
assert.equal(updates[0].commands[0].state, 'completed');
|
|
assert.equal(Object.hasOwn(updates[0].runs[0], 'branches'), false);
|
|
assert.equal(Object.hasOwn(updates[0].runs[0], 'trigger'), false);
|
|
assert.equal(updates[0].runs[0].trigger_available, true);
|
|
assert.equal(JSON.stringify(updates[0]).includes('PRIVATE_'), false);
|
|
updates[0].commands[0].result.generation = 99;
|
|
assert.equal(app.info.get_info().commands[0].result.generation, 2);
|
|
});
|
|
|
|
test('runtime_updated publishes trigger and command changes with no runs and ignores node-only events', t => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
const app = fixture(t);
|
|
app.runs.length = 0;
|
|
const updates = [];
|
|
const unsubscribe = app.info.on_update(value => updates.push(value));
|
|
assert.deepEqual(updates, []);
|
|
app.runtime.source_config_revision = 6;
|
|
app.runtime.workflows[0].trigger_state = 'stopped';
|
|
app.command.state = 'completed'; app.command.result = {generation: 2};
|
|
app.emit({type: 'runtime_updated', snapshot: app.runtime});
|
|
t.mock.timers.tick(250);
|
|
assert.deepEqual(updates, [{available: true, ...app.runtime, runs: []}]);
|
|
app.emit({type: 'node_started', run_id: app.run.run_id, node_id: 'query', execution: 1});
|
|
app.emit({type: 'node_finished', run_id: app.run.run_id, node_id: 'query', execution: 1,
|
|
result: {status: 'success', output: true}});
|
|
assert.equal(updates.length, 1);
|
|
unsubscribe(); unsubscribe();
|
|
app.emit({type: 'runtime_updated', snapshot: app.runtime});
|
|
assert.equal(updates.length, 1);
|
|
});
|
|
|
|
test('workflow info disposes only its own source subscription once and preserves its last detached snapshot', t => {
|
|
const app = fixture(t);
|
|
const updates = [];
|
|
app.info.on_update(value => updates.push(value));
|
|
const late = [...app.listeners][0];
|
|
const before = app.info.get_info();
|
|
app.info.dispose(); app.info.dispose();
|
|
assert.equal(app.unsubscribes(), 1);
|
|
assert.equal(app.listeners.size, 0);
|
|
late({type: 'runtime_updated', snapshot: app.runtime});
|
|
app.emit({type: 'run_updated', snapshot: app.run});
|
|
assert.deepEqual(updates, []);
|
|
assert.deepEqual(app.info.get_info(), before);
|
|
const detached = app.info.get_info(); detached.workflows.length = 0;
|
|
assert.deepEqual(app.info.get_info(), before);
|
|
assert.throws(() => app.info.on_update(() => {}), /disposed/);
|
|
});
|
|
|
|
test('workflow info preserves optional legacy source fallback and distinguishes absent from empty sources', t => {
|
|
const missing = new WorkflowStates(new EventEmitter());
|
|
t.after(() => missing.dispose());
|
|
assert.deepEqual(missing.get_info(), {available: false, source_config_revision: 0, workflows: [], commands: [], runs: []});
|
|
const app = fixture(t, true);
|
|
const {branches, ...summary} = app.run;
|
|
assert.deepEqual(app.info.get_info(), {available: true, source_config_revision: 0, workflows: [], commands: [],
|
|
runs: [{...summary, trigger_available: false}]});
|
|
app.runs.length = 0;
|
|
app.emit({type: 'run_updated', snapshot: app.run});
|
|
assert.deepEqual(app.info.get_info(), {available: true, source_config_revision: 0, workflows: [], commands: [], runs: []});
|
|
});
|
|
|
|
test('live state includes only 20 completed commands while preserving accepted controls', t => {
|
|
const app = fixture(t);
|
|
const commands = Array.from({length: 100}, () => ({command_id: randomUUID(), action: 'run_once',
|
|
workflow_id: 'gather', state: 'completed'}));
|
|
const pending = [app.command, {...app.command, command_id: randomUUID()}];
|
|
app.runtime.commands = [pending[0], ...commands, pending[1]];
|
|
app.emit({type: 'runtime_updated', snapshot: app.runtime});
|
|
const state = app.info.get_info();
|
|
assert.equal(state.commands.length, 22);
|
|
assert.deepEqual(state.commands.filter(command => command.state === 'completed'), commands.slice(-20));
|
|
assert.deepEqual(state.commands.filter(command => command.state === 'accepted'), pending);
|
|
assert.equal(app.runtime.commands.length, 102);
|
|
});
|
|
|
|
test('workflow pushes coalesce a burst into its latest snapshot and skip unchanged duplicates', t => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
const app = fixture(t), updates = [];
|
|
app.info.on_update(value => updates.push(value));
|
|
let baseline_bytes = 0;
|
|
for (let index = 1; index <= 1000; index++) {
|
|
app.run.steps = index;
|
|
app.emit({type: 'run_updated', snapshot: app.run});
|
|
baseline_bytes += Buffer.byteLength(JSON.stringify(app.info.get_info()));
|
|
}
|
|
assert.equal(updates.length, 0);
|
|
assert.equal(app.info.get_info().runs[0].steps, 1000);
|
|
t.mock.timers.tick(250);
|
|
assert.equal(updates.length, 1);
|
|
assert.equal(updates[0].runs[0].steps, 1000);
|
|
const optimized_bytes = Buffer.byteLength(JSON.stringify(updates[0]));
|
|
assert.ok(optimized_bytes < baseline_bytes / 100);
|
|
for (let index = 0; index < 1000; index++) app.emit({type: 'runtime_updated', snapshot: app.runtime});
|
|
t.mock.timers.tick(250);
|
|
assert.equal(updates.length, 1);
|
|
t.diagnostic(`Synthetic 1000-update burst: ${baseline_bytes} JSON bytes -> ${optimized_bytes} JSON bytes; duplicate burst: 0 extra pushes`);
|
|
});
|