368 lines
23 KiB
JavaScript
368 lines
23 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {randomUUID} from 'node:crypto';
|
|
import {EventEmitter} from 'node:events';
|
|
import {createRequire, isBuiltin} from 'node:module';
|
|
import {fileURLToPath} from 'node:url';
|
|
import test from 'node:test';
|
|
import {build} from 'esbuild';
|
|
|
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
const compiled = await build({
|
|
stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './bot/forward/manager.ts';
|
|
export * from './utils/forward_protocol.ts';`, resolveDir: root, loader: 'ts'},
|
|
bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true,
|
|
plugins: [{name: 'isolated-node', setup(build) {
|
|
build.onResolve({filter: /(?:instance\/index\.ts|^socket\.io-client$|console_print\.ts|path_utils\.ts)$/}, args =>
|
|
({path: args.path.includes('instance/') ? 'registry' : args.path === 'socket.io-client' ? 'socket'
|
|
: args.path.endsWith('path_utils.ts') ? 'path' : 'console', namespace: 'stub'}));
|
|
build.onLoad({filter: /.*/, namespace: 'stub'}, ({path}) => ({loader: 'js', contents: {
|
|
registry: 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};',
|
|
socket: 'export const io = () => {throw Error("Unexpected real socket")};',
|
|
console: 'export const console_print = {success: x => x, log() {}};',
|
|
path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};',
|
|
}[path]}));
|
|
}}],
|
|
});
|
|
assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server')));
|
|
const require = createRequire(import.meta.url);
|
|
const module = {exports: {}};
|
|
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
|
|
assert.ok(isBuiltin(specifier), `Unexpected external import: ${specifier}`);
|
|
return require(specifier);
|
|
}, module, module.exports);
|
|
const {BotStream, NodeForwardManager, validate_rpc, validate_workflow_payload, error_ack} = module.exports;
|
|
const actions = ['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'];
|
|
const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code);
|
|
const request = (method, payload, scope = randomUUID(), deadline_at = Date.now() + 2000) => ({
|
|
request_id: randomUUID(), correlation_id: randomUUID(), method, payload, forward_session_id: scope, deadline_at,
|
|
});
|
|
const control = (action = 'run_once', context_generation = randomUUID()) => ({
|
|
command_id: randomUUID(), context_generation, action,
|
|
...(action === 'stop_run' ? {run_id: randomUUID()} : {workflow_id: 'gather'}),
|
|
});
|
|
|
|
function fixture(t) {
|
|
let now = 100;
|
|
const calls = [];
|
|
const command = {command_id: randomUUID(), action: 'run_once', workflow_id: 'gather', state: 'accepted'};
|
|
const runtime = {source_config_revision: 7, workflows: [{workflow_id: 'gather', generation: 3,
|
|
enabled: true, start_type: 'event', trigger_state: 'listening', accepted_count: 2, max_runs: null,
|
|
next_fire_at: null, active_run_ids: [randomUUID()], queued_count: 1, last_error: null}], commands: [command]};
|
|
const manager = {
|
|
control(input) { calls.push(['control', input]); return command; },
|
|
get_command(id) { calls.push(['command', id]); return id === command.command_id ? command : undefined; },
|
|
get_runtime_snapshot() { calls.push(['runtime']); return runtime; },
|
|
};
|
|
const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat() {}}, workflow_manager: manager});
|
|
let current = context;
|
|
const observers = new Set();
|
|
const source = {get: () => current, observe(_id, callback) { observers.add(callback); return () => observers.delete(callback); }};
|
|
const stream = new BotStream(1, {source, now: () => now});
|
|
t.after(() => stream.dispose());
|
|
return {stream, manager, runtime, command, calls, context,
|
|
generation: stream.snapshot().context_generation,
|
|
set(context) { current = context; for (const listener of observers) listener(context); },
|
|
time(value) { now = value; },
|
|
};
|
|
}
|
|
|
|
test('hot workflow protocol accepts exact action-specific payloads and safe generations', () => {
|
|
for (const action of actions) {
|
|
const payload = control(action);
|
|
const valid = request('workflow.control', payload);
|
|
assert.equal(validate_rpc(valid), valid);
|
|
assert.equal(validate_workflow_payload('workflow.control', payload), payload);
|
|
if (action !== 'stop_run') for (const expected_generation of [1, Number.MAX_SAFE_INTEGER]) {
|
|
assert.equal(validate_rpc(request('workflow.control', {...payload, expected_generation})).payload.expected_generation,
|
|
expected_generation);
|
|
}
|
|
for (const key of ['command_id', 'context_generation', 'action', action === 'stop_run' ? 'run_id' : 'workflow_id']) {
|
|
const missing = {...payload}; delete missing[key];
|
|
fails(() => validate_rpc(request('workflow.control', missing)));
|
|
}
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, extra: true})));
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, [action === 'stop_run' ? 'workflow_id' : 'run_id']: randomUUID()})));
|
|
if (action !== 'run_once') fails(() => validate_rpc(request('workflow.control', {...payload, input: {}})));
|
|
if (action === 'stop_run') fails(() => validate_rpc(request('workflow.control', {...payload, expected_generation: 1})));
|
|
}
|
|
const payload = control();
|
|
for (const expected_generation of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1', null, undefined])
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, expected_generation})));
|
|
for (const action of ['', 'start', 'delete', 'constructor', null, undefined, 1])
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, action})));
|
|
for (const workflow_id of ['', '../x', 'a.b', 'a/b', 'a b', 'x'.repeat(101), '__proto__', 'constructor', 'prototype', 1])
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, workflow_id})));
|
|
assert.equal(validate_rpc(request('workflow.control', {...payload, workflow_id: 'x'.repeat(100)})).method, 'workflow.control');
|
|
});
|
|
|
|
test('runtime and command reads reject extra fields and require both UUID identities', () => {
|
|
assert.equal(validate_rpc(request('workflow.runtime', {})).method, 'workflow.runtime');
|
|
for (const payload of [undefined, null, [], 1, {workflow_id: 'gather'}, {command_id: randomUUID()},
|
|
{[Symbol('extra')]: true}, Object.defineProperty({}, 'hidden', {value: true})])
|
|
fails(() => validate_rpc(request('workflow.runtime', payload)));
|
|
const payload = {command_id: randomUUID(), context_generation: randomUUID()};
|
|
assert.equal(validate_rpc(request('workflow.command', payload)).payload, payload);
|
|
for (const key of ['command_id', 'context_generation']) {
|
|
for (const value of ['', 'not-a-uuid', 'x'.repeat(36), null, undefined, 1]) {
|
|
fails(() => validate_rpc(request('workflow.command', {...payload, [key]: value})));
|
|
fails(() => validate_rpc(request('workflow.control', {...control(), [key]: value})));
|
|
}
|
|
const missing = {...payload}; delete missing[key];
|
|
fails(() => validate_rpc(request('workflow.command', missing)));
|
|
}
|
|
fails(() => validate_rpc(request('workflow.control', {...control('stop_run'), run_id: 'invalid'})));
|
|
for (const key of ['workflow_id', 'run_id', 'action', 'input', 'expected_generation', 'extra'])
|
|
fails(() => validate_rpc(request('workflow.command', {...payload, [key]: true})));
|
|
});
|
|
|
|
test('run_once input is strict plain JSON with an exact 64 KiB UTF-8 serialized boundary', () => {
|
|
const payload = control();
|
|
const input = {text: 'x'.repeat(65_536 - Buffer.byteLength(JSON.stringify({text: ''})))};
|
|
assert.equal(Buffer.byteLength(JSON.stringify(input)), 65_536);
|
|
assert.equal(validate_rpc(request('workflow.control', {...payload, input})).payload.input, input);
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, input: {text: input.text + 'x'}})));
|
|
const multibyte = {text: '\u00e9'.repeat(32_768)};
|
|
assert.ok(JSON.stringify(multibyte).length < 65_536);
|
|
assert.ok(Buffer.byteLength(JSON.stringify(multibyte)) > 65_536);
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, input: multibyte})));
|
|
const cycle = {}; cycle.self = cycle;
|
|
const sparse = Array(2);
|
|
let deep = {}; for (let index = 0; index < 66; index++) deep = {child: deep};
|
|
for (const input of [null, [], 'text', undefined, new Date(), Object.create({inherited: true}), cycle, deep,
|
|
{sparse}, {value: NaN}, {value: Infinity}, {value: 1n}, {value: undefined}, {value() {}},
|
|
JSON.parse('{"__proto__":1}'), {nested: {constructor: 1}}, {[Symbol('key')]: 1},
|
|
Object.defineProperty({}, 'hidden', {value: 1})])
|
|
fails(() => validate_rpc(request('workflow.control', {...payload, input})));
|
|
const plain = Object.assign(Object.create(null), {nested: {values: [false, 0, null]}});
|
|
assert.equal(validate_rpc(request('workflow.control', {...payload, input: plain})).payload.input, plain);
|
|
});
|
|
|
|
test('hot payload descriptors are rejected without invoking enumerable or hidden accessors', async t => {
|
|
for (const [method, base, keys] of [
|
|
['workflow.control', control(), ['command_id', 'context_generation', 'action', 'workflow_id', 'expected_generation', 'input', 'extra']],
|
|
['workflow.command', {command_id: randomUUID(), context_generation: randomUUID()}, ['command_id', 'context_generation', 'extra']],
|
|
['workflow.runtime', {}, ['extra']],
|
|
]) for (const key of keys) for (const enumerable of [true, false]) {
|
|
await t.test(`${method}.${key} enumerable=${enumerable}`, () => {
|
|
let reads = 0;
|
|
const payload = Object.defineProperty({...base}, key, {enumerable, get() { reads++; return base[key]; }});
|
|
fails(() => validate_rpc(request(method, payload)));
|
|
assert.equal(reads, 0);
|
|
});
|
|
}
|
|
for (const enumerable of [true, false]) {
|
|
let reads = 0;
|
|
const input = Object.defineProperty({}, 'secret', {enumerable, get() { reads++; return 1; }});
|
|
fails(() => validate_rpc(request('workflow.control', {...control(), input})));
|
|
assert.equal(reads, 0);
|
|
}
|
|
});
|
|
|
|
test('stream control is synchronous, strips context identity and clones input and accepted command', t => {
|
|
const app = fixture(t);
|
|
const payload = {...control('run_once', app.generation), expected_generation: 3, input: {nested: {value: 1}}};
|
|
app.command.command_id = payload.command_id;
|
|
app.manager.control = input => {
|
|
app.calls.push(input);
|
|
assert.equal(Object.hasOwn(input, 'context_generation'), false);
|
|
input.input.nested.value = 2;
|
|
return app.command;
|
|
};
|
|
const result = app.stream.workflow_control(payload, 200);
|
|
assert.equal(result instanceof Promise, false);
|
|
assert.deepEqual(result, app.command);
|
|
assert.notEqual(result, app.command);
|
|
assert.equal(payload.input.nested.value, 1);
|
|
assert.deepEqual(app.calls[0], {command_id: payload.command_id, action: 'run_once', workflow_id: 'gather',
|
|
expected_generation: 3, input: {nested: {value: 2}}});
|
|
result.state = 'completed';
|
|
assert.equal(app.command.state, 'accepted');
|
|
});
|
|
|
|
test('stream runtime and command reads work offline, deeply clone state and return null for unknown command', t => {
|
|
const app = fixture(t);
|
|
app.context.bot_status = 'offline'; app.context.bot_instance = null;
|
|
const state = app.stream.workflow_runtime({}, 200);
|
|
const payload = {command_id: app.command.command_id, context_generation: app.generation};
|
|
const accepted = app.stream.workflow_command(payload, 200);
|
|
assert.deepEqual(state, app.runtime);
|
|
assert.deepEqual(accepted, app.command);
|
|
state.workflows[0].active_run_ids.push(randomUUID()); state.commands[0].state = 'failed';
|
|
accepted.state = 'completed';
|
|
assert.equal(app.runtime.workflows[0].active_run_ids.length, 1);
|
|
assert.equal(app.command.state, 'accepted');
|
|
app.command.state = 'completed'; app.command.result = {status: 'started', run_id: randomUUID()};
|
|
const completed = app.stream.workflow_command(payload, 200);
|
|
assert.deepEqual(completed, app.command);
|
|
completed.result.run_id = 'mutated';
|
|
assert.notEqual(app.command.result.run_id, 'mutated');
|
|
app.command.state = 'failed'; delete app.command.result;
|
|
app.command.error = {code: 'GENERATION_MISMATCH', message: 'Workflow generation changed'};
|
|
const failed = app.stream.workflow_command(payload, 200);
|
|
assert.deepEqual(failed, app.command);
|
|
failed.error.message = 'mutated';
|
|
assert.equal(app.command.error.message, 'Workflow generation changed');
|
|
assert.equal(app.stream.workflow_command({...payload, command_id: randomUUID()}, 200), null);
|
|
});
|
|
|
|
test('stale context generations reject control and command lookup before any manager dispatch', t => {
|
|
const app = fixture(t);
|
|
const original = app.generation;
|
|
assert.match(original, /^[a-f0-9-]{36}$/i);
|
|
for (const generation of [randomUUID(), original]) {
|
|
if (generation === original) {
|
|
app.set(Object.assign(new EventEmitter(), app.context));
|
|
assert.notEqual(app.stream.snapshot().context_generation, original);
|
|
}
|
|
fails(() => app.stream.workflow_control(control('run_once', generation), 200), 'CANCELLED');
|
|
fails(() => app.stream.workflow_command({command_id: app.command.command_id, context_generation: generation}, 200), 'CANCELLED');
|
|
}
|
|
assert.deepEqual(app.calls, []);
|
|
});
|
|
|
|
test('hot stream APIs reject invalid payloads, missing managers and unavailable optional controls', async t => {
|
|
for (const name of ['runtime', 'control', 'command']) await t.test(name, t => {
|
|
const app = fixture(t);
|
|
const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation)
|
|
: {command_id: app.command.command_id, context_generation: app.generation};
|
|
const invoke = (input = payload) => app.stream[`workflow_${name}`](input, 200);
|
|
fails(() => invoke({...payload, extra: true}));
|
|
assert.deepEqual(app.calls, []);
|
|
app.context.workflow_manager = null;
|
|
fails(invoke, 'INTERNAL_ERROR');
|
|
app.context.workflow_manager = {};
|
|
fails(invoke, 'INTERNAL_ERROR');
|
|
app.set(undefined);
|
|
fails(invoke, 'NO_CONTEXT');
|
|
app.stream.dispose();
|
|
fails(invoke, 'CANCELLED');
|
|
});
|
|
});
|
|
|
|
test('hot stream APIs enforce deadlines, transport and identity before and after synchronous dispatch', async t => {
|
|
for (const name of ['runtime', 'control', 'command']) {
|
|
for (const change of ['deadline', 'context', 'instance', 'manager', 'disposed', 'transport']) {
|
|
await t.test(`${name}: ${change}`, t => {
|
|
const app = fixture(t);
|
|
const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation)
|
|
: {command_id: app.command.command_id, context_generation: app.generation};
|
|
let stale = false;
|
|
const transport = () => { if (stale) throw Object.assign(Error('SECRET transport'), {code: 'SESSION_STALE'}); };
|
|
const invoke = deadline => app.stream[`workflow_${name}`](payload, deadline, transport);
|
|
for (const deadline of [100, 99, NaN, Infinity, 1.5, undefined]) fails(() => invoke(deadline), 'TIMEOUT');
|
|
stale = true;
|
|
fails(() => invoke(200), 'SESSION_STALE');
|
|
assert.deepEqual(app.calls, []);
|
|
stale = false;
|
|
const key = {runtime: 'get_runtime_snapshot', control: 'control', command: 'get_command'}[name];
|
|
let dispatched = 0;
|
|
app.manager[key] = () => {
|
|
dispatched++;
|
|
if (change === 'deadline') app.time(200);
|
|
if (change === 'context') app.set(Object.assign(new EventEmitter(), app.context));
|
|
if (change === 'instance') app.context.bot_instance = {};
|
|
if (change === 'manager') app.context.workflow_manager = {};
|
|
if (change === 'disposed') app.stream.dispose();
|
|
if (change === 'transport') stale = true;
|
|
return name === 'runtime' ? app.runtime : app.command;
|
|
};
|
|
fails(() => invoke(200), change === 'deadline' ? 'TIMEOUT' : change === 'transport' ? 'SESSION_STALE' : 'CANCELLED');
|
|
assert.equal(dispatched, 1);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
async function relay(t, stream) {
|
|
const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1,
|
|
url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque',
|
|
expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000};
|
|
const socket = new EventEmitter();
|
|
socket.auth = {}; socket.io = {engine: {writeBuffer: []}}; socket.volatile = {emit() {}};
|
|
socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); };
|
|
socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); };
|
|
socket.timeout = () => ({emit(event, _payload, ack) {
|
|
const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command;
|
|
const data = event === 'forward_node_ready' ? {node_ready: true}
|
|
: {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false};
|
|
queueMicrotask(() => ack(null, {status: 'success', data}));
|
|
}});
|
|
const manager = new NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => stream});
|
|
t.after(() => manager.stop());
|
|
assert.equal((await manager.ensure(command)).status, 'success');
|
|
return {socket, manager, rpc: (method, payload, deadline = Date.now() + 2000) => new Promise(resolve =>
|
|
socket.emit('relay_request', request(method, payload, command.forward_session_id, deadline), resolve))};
|
|
}
|
|
|
|
test('real NodeForwardManager dispatches accepted controls, command polling and runtime in invocation order', async t => {
|
|
const app = fixture(t); app.time(Date.now());
|
|
const channel = await relay(t, app.stream);
|
|
const snapshot = await channel.rpc('bot.snapshot', {});
|
|
assert.equal(snapshot.status, 'success');
|
|
const generation = snapshot.data.context_generation;
|
|
assert.equal(generation, app.generation);
|
|
const payload = {...control('run_once', generation), expected_generation: 3, input: {value: 1}};
|
|
app.command.command_id = payload.command_id;
|
|
assert.deepEqual(await channel.rpc('workflow.control', payload), {status: 'success', data: app.command});
|
|
assert.deepEqual(await channel.rpc('workflow.command', {command_id: payload.command_id, context_generation: generation}),
|
|
{status: 'success', data: app.command});
|
|
app.command.state = 'completed'; app.command.result = {status: 'queued', request_id: randomUUID()};
|
|
app.context.bot_status = 'offline'; app.context.bot_instance = null;
|
|
const completed = await channel.rpc('workflow.command', {command_id: payload.command_id, context_generation: generation});
|
|
assert.deepEqual(completed, {status: 'success', data: app.command});
|
|
completed.data.result.request_id = 'mutated';
|
|
const runtime = await channel.rpc('workflow.runtime', {});
|
|
assert.deepEqual(runtime, {status: 'success', data: app.runtime});
|
|
runtime.data.workflows[0].active_run_ids.length = 0;
|
|
assert.equal(app.runtime.workflows[0].active_run_ids.length, 1);
|
|
assert.deepEqual(await channel.rpc('workflow.command', {command_id: randomUUID(), context_generation: generation}),
|
|
{status: 'success', data: null});
|
|
assert.deepEqual(app.calls.map(([name]) => name), ['control', 'command', 'command', 'runtime', 'command']);
|
|
assert.deepEqual(app.calls[0][1], {command_id: payload.command_id, action: 'run_once', workflow_id: 'gather',
|
|
expected_generation: 3, input: {value: 1}});
|
|
});
|
|
|
|
test('Node dispatch rejects malformed, stale-context and expired hot requests before manager invocation', async t => {
|
|
const app = fixture(t); app.time(Date.now());
|
|
const channel = await relay(t, app.stream);
|
|
for (const [method, payload, expected, deadline] of [
|
|
['workflow.runtime', {extra: true}, 'INVALID_REQUEST'],
|
|
['workflow.control', {...control('trigger_stop', app.generation), input: {}}, 'INVALID_REQUEST'],
|
|
['workflow.control', control(), 'CANCELLED'],
|
|
['workflow.command', {command_id: app.command.command_id, context_generation: randomUUID()}, 'CANCELLED'],
|
|
['workflow.runtime', {}, 'TIMEOUT', Date.now() - 1],
|
|
['workflow.control', control('reload', app.generation), 'TIMEOUT', Date.now() - 1],
|
|
['workflow.command', {command_id: app.command.command_id, context_generation: app.generation}, 'TIMEOUT', Date.now() - 1],
|
|
]) assert.deepEqual(await channel.rpc(method, payload, deadline), error_ack(expected));
|
|
assert.deepEqual(app.calls, []);
|
|
});
|
|
|
|
test('Node sanitizes workflow manager failures and retains only approved control error codes', async t => {
|
|
const app = fixture(t); app.time(Date.now());
|
|
const channel = await relay(t, app.stream);
|
|
for (const [method, key, payload, code] of [
|
|
['workflow.runtime', 'get_runtime_snapshot', {}, 'INTERNAL_ERROR'],
|
|
['workflow.command', 'get_command', {command_id: app.command.command_id, context_generation: app.generation}, 'INTERNAL_ERROR'],
|
|
['workflow.control', 'control', control('run_once', app.generation), 'INVALID_REQUEST'],
|
|
]) {
|
|
app.manager[key] = () => { throw Error('SECRET manager /private/path password'); };
|
|
assert.deepEqual(await channel.rpc(method, payload), error_ack(code));
|
|
}
|
|
app.manager.control = () => { throw Object.assign(Error('SECRET rate limit'), {code: 'RATE_LIMITED'}); };
|
|
assert.deepEqual(await channel.rpc('workflow.control', control('reload', app.generation)), error_ack('RATE_LIMITED'));
|
|
app.manager.control = () => { throw Object.assign(Error('SECRET custom error'), {code: 'PRIVATE_MANAGER_CODE'}); };
|
|
assert.deepEqual(await channel.rpc('workflow.control', control('stop', app.generation)), error_ack('INVALID_REQUEST'));
|
|
});
|
|
|
|
test('Node transport guards reject hot results when the relay disconnects during manager dispatch', async t => {
|
|
for (const name of ['runtime', 'control', 'command']) await t.test(name, async t => {
|
|
const app = fixture(t); app.time(Date.now());
|
|
const channel = await relay(t, app.stream);
|
|
const key = {runtime: 'get_runtime_snapshot', control: 'control', command: 'get_command'}[name];
|
|
const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation)
|
|
: {command_id: app.command.command_id, context_generation: app.generation};
|
|
app.manager[key] = () => { channel.socket.disconnect(); return name === 'runtime' ? app.runtime : app.command; };
|
|
assert.deepEqual(await channel.rpc(`workflow.${name}`, payload), error_ack('SESSION_STALE'));
|
|
});
|
|
});
|