fix: use in-process Harness API for local plugin connections

This commit is contained in:
xmanrui 2026-08-28 01:24:17 +08:00
parent f4ded46278
commit f0b6b38b45
40 changed files with 1413 additions and 603 deletions

View file

@ -20,7 +20,7 @@ function controller() {
test('Host exports the DingTalk plugin identity and required services', () => {
const plugin = createDingtalkHostPlugin({ controller: controller() });
assert.equal(name, 'dsh-dingtalk-host');
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
assert.deepEqual(inject, ['connection', 'credentials', 'apiProxy', 'typertGateway']);
assert.equal(plugin.name, name);
assert.deepEqual(plugin.inject, inject);
});

View file

@ -41,9 +41,10 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
async close() { seen.supervisorClosed = true; },
};
const credentials = {};
const apiProxy = {};
const production = await createProductionController({
credentials,
webServer: { port: 3080 },
apiProxy,
logger: () => console,
}, { dataDir: directory }, {
ConfigStore,
@ -56,7 +57,8 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
});
assert.equal(seen.controllerOptions.credentials, credentials);
assert.equal(seen.harnessOptions.baseUrl.href, 'http://127.0.0.1:3080/');
assert.equal(seen.harnessOptions.apiProxy, apiProxy);
assert.equal(Object.hasOwn(seen.harnessOptions, 'baseUrl'), false);
assert.equal(seen.harnessOptions.autostart, false);
assert.equal(Object.hasOwn(seen.harnessOptions, 'agentPreset'), false);
const runtime = await seen.controllerOptions.createRuntime({
@ -82,7 +84,7 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
const productionWithPreset = await createProductionController({
credentials,
webServer: { port: 3080 },
apiProxy,
logger: () => console,
}, { dataDir: directory, agentPreset: 'router-standard' }, {
ConfigStore,

View file

@ -44,7 +44,7 @@ test('Discord production has no per-bot result-file Gate', async (t) => {
};
const ctx = {
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};
const createRuntime = (botId) => controllerOptions.createRuntime({

View file

@ -1124,7 +1124,7 @@ test('DSH credential adapter stores refs off the browser plane and clears them',
assert.equal(await store.configured(), false);
});
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
test('production assembly uses ctx credentials and the active Host apiProxy without a webServer', async () => {
const constructed = {};
const httpInstance = { request: async () => ({}) };
const wsAgent = {
@ -1157,9 +1157,10 @@ test('production assembly needs only ctx credentials and the active DSH webServe
async close() { constructed.closed = true; }
}
const credentials = {};
const apiProxy = {};
const production = await createProductionController({
credentials,
webServer: { port: 43123, host: '127.0.0.1' },
apiProxy,
logger: console,
}, {
dshHome: '/tmp/dsh-feishu-host-test',
@ -1192,7 +1193,8 @@ test('production assembly needs only ctx credentials and the active DSH webServe
assert.equal(constructed.verifyOptions.httpInstance, httpInstance);
assert.equal(constructed.wsAgentCreated, 1);
assert.equal(constructed.wsProxyUrl, 'http://proxy.test:8080');
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
assert.equal(constructed.harness.apiProxy, apiProxy);
assert.equal(Object.hasOwn(constructed.harness, 'baseUrl'), false);
assert.equal(constructed.harness.autostart, false);
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
@ -1291,7 +1293,7 @@ test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting'
async set(ref, value) { secrets.set(ref, value); },
async unset(ref) { secrets.delete(ref); },
},
webServer: { port: 43124 },
apiProxy: {},
logger: console,
}, { dataDir, workspace: dataDir }, {
lark: { registerApp: async () => ({}) },

View file

@ -43,7 +43,7 @@ test('QQ production has no per-bot result-file Gate', async (t) => {
const production = await createProductionController({
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,

View file

@ -0,0 +1,296 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import {
HarnessClient,
HarnessHealthError,
HarnessInteractionError,
HarnessRpcError,
HarnessTransportError,
} from '../../../src/channels/shared/harness-client.mjs';
function localClient(apiProxy, options = {}) {
return new HarnessClient({
apiProxy,
workspace: '/tmp/dsh-im-test',
interactionReconnectDelayMs: 0,
fetchImpl: () => assert.fail('in-process calls must not use HTTP'),
createWebSocket: () => assert.fail('in-process events must not use WebSocket'),
...options,
});
}
async function eventually(predicate) {
for (let i = 0; i < 200; i += 1) {
if (predicate()) return;
await delay(5);
}
assert.fail('condition was not met');
}
// The real Host registers eagerly, then exposes a signal-controlled iterator.
// Reopening replays pending questions; since is deliberately not used.
function hostFixture() {
const streams = new Set();
const pending = new Map();
const history = [];
const prompts = [];
const responses = [];
let historyReads = 0;
let opened = 0;
const host = { streams, pending, history, prompts, responses, onPrompt: () => {} };
const success = (rpcId, value) => ({ rpcId, result: { ok: true, value } });
host.emit = (frame) => { for (const stream of streams) stream.push(frame); };
host.append = (event) => {
history.push({ event });
host.emit({ rpcId: `event-${event.seq}`, payload: { type: 'session/event', sessionId: 'session', event } });
};
host.question = () => {
const frame = {
rpcId: 'question-rpc',
payload: { type: 'question/requested', sessionId: 'session', questions: [] },
};
pending.set(frame.rpcId, frame);
host.emit(frame);
};
host.apiProxy = {
host: { describe: ({ rpcId }) => success(rpcId, {}) },
sessions: {
history: ({ rpcId }) => {
historyReads += 1;
return success(rpcId, { events: [...history] });
},
prompt: ({ rpcId, payload }) => {
assert.equal(streams.size > 0, true, 'subscribe before submitting a prompt');
prompts.push({ rpcId, payload });
host.onPrompt(rpcId, payload);
return success(rpcId, {});
},
},
events: {
mux(request, signal) {
assert.deepEqual(request.payload, {});
opened += 1;
const queue = [...pending.values()];
let wake;
let ended = false;
const stream = {
push(frame) { queue.push(frame); wake?.(); },
end() { ended = true; wake?.(); },
};
streams.add(stream);
return (async function* () {
const abort = () => stream.end();
signal.addEventListener('abort', abort, { once: true });
try {
while (!ended && !signal.aborted) {
if (queue.length) yield queue.shift();
else await new Promise((resolve) => { wake = resolve; });
}
} finally {
signal.removeEventListener('abort', abort);
streams.delete(stream);
}
})();
},
},
respond(envelope) {
responses.push(envelope);
pending.delete(envelope.rpcId);
return { accepted: true };
},
};
Object.defineProperties(host, {
historyReads: { get: () => historyReads },
opened: { get: () => opened },
});
return host;
}
test('in-process RPC preserves IDs, payloads, namespace receivers and errors', async () => {
const calls = [];
const apiProxy = {};
const methods = ['host.describe', 'workspace.list', 'workspace.create', 'session.list',
'session.create', 'session.history', 'session.prompt', 'session.cancel',
'session.models', 'session.selectModel', 'llm.models'];
for (const method of methods) {
const [domain, action] = method.split('.');
const namespace = domain === 'session' ? 'sessions' : domain;
const api = apiProxy[namespace] ??= {};
api[action] = function (request, signal) {
assert.equal(this, api);
assert.ok(signal instanceof AbortSignal);
calls.push({ method, request });
return { rpcId: request.rpcId, result: { ok: true, value: method } };
};
}
const client = localClient(apiProxy);
const payload = { sessionId: 'session', content: [{ type: 'text', text: 'hello' }] };
for (const method of methods) {
assert.equal(await client.rpc(method, payload, 1000, { rpcId: `fixed-${method}` }), method);
}
assert.deepEqual(calls, methods.map((method) => ({
method, request: { rpcId: `fixed-${method}`, payload },
})));
apiProxy.sessions.prompt = ({ rpcId }) => ({
rpcId, result: { ok: false, error: { code: 'queue-full', message: 'queue full' } },
});
await assert.rejects(client.rpc('session.prompt'), (error) => (
error instanceof HarnessRpcError && error.code === 'queue-full'
));
apiProxy.host.describe = ({ rpcId }) => ({ rpcId, result: { ok: false, error: { code: 'internal' } } });
await assert.rejects(client.health(), HarnessHealthError);
for (const response of [null, { rpcId: 'wrong' }, { rpcId: 'fixed', result: {} }]) {
apiProxy.host.describe = () => response;
await assert.rejects(client.rpc('host.describe', {}, 1000, { rpcId: 'fixed' }), (error) => (
error instanceof HarnessTransportError && error.code === 'harness-response-invalid'
));
}
await assert.rejects(client.rpc('session.missing'), { code: 'harness-api-not-found' });
});
test('in-process RPC bounds waits without retrying accepted calls or falling back to HTTP', async () => {
let calls = 0;
const client = localClient({
sessions: {
prompt: async ({ rpcId }) => {
calls += 1;
await delay(40); // Deliberately ignores the caller's AbortSignal.
return { rpcId, result: { ok: true, value: {} } };
},
},
});
await assert.rejects(client.rpc('session.prompt', {}, 2), { code: 'harness-timeout' });
assert.equal(calls, 1);
const controller = new AbortController();
const reason = new Error('caller cancelled');
const pending = client.rpc('session.prompt', {}, 1000, { signal: controller.signal });
await eventually(() => calls === 2);
controller.abort(reason);
await assert.rejects(pending, (error) => error === reason);
await assert.rejects(client.rpc('session.prompt', {}, 1000, { signal: controller.signal }), (error) => error === reason);
await delay(50);
assert.equal(calls, 2, 'late success must not trigger another prompt');
});
test('explicit baseUrl still selects the existing HTTP transport', async () => {
let requests = 0;
const client = localClient({ get host() { assert.fail('must not call local Host'); } }, {
baseUrl: 'http://127.0.0.1:1234',
fetchImpl: async (url, options) => {
requests += 1;
assert.equal(url.href, 'http://127.0.0.1:1234/api/host.describe');
return { ok: true, json: async () => ({
type: 'server-response', rpcId: JSON.parse(options.body).rpcId, result: { ok: true, value: {} },
}) };
},
});
assert.equal(await client.health(), true);
assert.equal(requests, 1);
});
test('in-process interaction responses preserve the full envelope and rejection receipts', async () => {
let received;
let receipt = { accepted: true };
const client = localClient({ respond: (envelope) => { received = envelope; return receipt; } });
const result = { ok: true, value: { sessionId: 'session', answer: { answers: [] } } };
assert.deepEqual(await client.respondInteraction('question', result), receipt);
assert.deepEqual(received, { type: 'client-response', rpcId: 'question', result });
for (const reason of ['not-pending', 'bad-response']) {
receipt = { accepted: false, reason };
await assert.rejects(client.respondInteraction('question', result), (error) => (
error instanceof HarnessInteractionError && error.code === `interaction-${reason}`
));
}
receipt = {};
await assert.rejects(client.respondInteraction('question', result), /invalid interaction response receipt/);
});
test('ask uses an initially empty in-process mux and correlates replies with its original prompt ID', async () => {
const host = hostFixture();
host.onPrompt = (rpcId) => {
host.append({ type: 'turn/start', seq: 0, data: { turn: 1 } });
host.append({ type: 'user/message', seq: 1, data: { turn: 1, source: { rpcId } } });
host.append({ type: 'assistant/message', seq: 2, data: {
turn: 1, step: 1, message: { content: [{ type: 'text', text: 'answer' }] },
} });
host.append({ type: 'turn/end', seq: 3, data: { turn: 1, reason: { kind: 'completed' } } });
};
const client = localClient(host.apiProxy, { rpcIdPrefix: 'local-test' });
assert.equal(await client.ask('session', 'hello', { onInteraction: () => {}, timeoutMs: 1000 }), 'answer');
assert.equal(host.prompts.length, 1);
assert.match(host.prompts[0].rpcId, /^local-test-/);
assert.equal(host.prompts[0].payload.mode, 'queue');
assert.equal(host.streams.size, 0, 'ask completion must dispose its mux subscription');
});
test('clients on one Host share interaction ownership across context wrappers and reconnect safely', async () => {
const host = hostFixture();
const scope = {};
const abort = new AbortController();
const received = [[], []];
const clients = [0, 1].map(() => localClient({ ...host.apiProxy }, { interactionScope: scope }));
host.onPrompt = (rpcId) => {
if (host.prompts.length !== 1) return; // Second queued prompt has not started a Turn.
host.append({ type: 'turn/start', seq: 0, data: { turn: 1 } });
host.append({ type: 'user/message', seq: 1, data: { turn: 1, source: { rpcId } } });
};
const asks = [];
try {
for (let index = 0; index < clients.length; index += 1) {
const ask = clients[index].ask('session', 'hello', {
signal: abort.signal,
onInteraction: (interaction) => { received[index].push(interaction); },
});
asks.push(ask.catch(() => {}));
await eventually(() => host.prompts.length === index + 1);
}
host.question();
await eventually(() => received[0].length === 1);
assert.equal(received[1].length, 0, 'queued client must not claim the active client question');
const historyReads = host.historyReads;
received[0][0].reconnect();
await eventually(() => received[0].length === 2);
assert.ok(host.historyReads > historyReads, 'reconnect must refresh ownership history');
assert.equal(received[0][1].rpcId, 'question-rpc', 'replay keeps the pending request ID');
assert.equal(received[1].length, 0);
await received[0][1].respond({ ok: true, value: { sessionId: 'session', answer: { answers: [] } } });
assert.equal(host.responses.length, 1);
assert.equal(host.responses[0].rpcId, 'question-rpc');
} finally {
abort.abort();
await Promise.all(asks);
}
assert.equal(host.streams.size, 0);
});
test('different Hosts never share interaction claims, even with identical session/request IDs', async () => {
const hosts = [hostFixture(), hostFixture()];
const controllers = hosts.map(() => new AbortController());
const received = [[], []];
const asks = hosts.map((host, index) => {
host.onPrompt = (rpcId) => {
host.append({ type: 'turn/start', seq: 0, data: { turn: 1 } });
host.append({ type: 'user/message', seq: 1, data: { turn: 1, source: { rpcId } } });
};
return localClient(host.apiProxy).ask('session', 'hello', {
signal: controllers[index].signal,
onInteraction: (interaction) => { received[index].push(interaction); },
}).catch(() => {});
});
try {
await eventually(() => hosts.every((host) => host.prompts.length === 1));
hosts.forEach((host) => host.question());
await eventually(() => received.every((items) => items.length === 1));
controllers[0].abort();
await asks[0];
assert.equal(hosts[0].streams.size, 0);
assert.equal(hosts[1].streams.size, 1, 'closing one Host client must not stop another');
} finally {
controllers.forEach((controller) => controller.abort());
await Promise.all(asks);
}
assert.ok(hosts.every((host) => host.streams.size === 0));
});

View file

@ -0,0 +1,338 @@
import assert from 'node:assert/strict';
import { getEventListeners } from 'node:events';
import test from 'node:test';
import { watchHarnessMux } from '../../../src/channels/shared/harness-mux.mjs';
const tick = () => new Promise((resolve) => setImmediate(resolve));
const frame = (id = 'frame-one') => ({
rpcId: id,
payload: { type: 'session/event', sessionId: 'session-one', event: { type: 'turn/end' } },
});
// Mirrors apiProxy's eager registration and lazy FrameQueue generator cleanup.
function hostMux({ initial = [], duringMux } = {}) {
const subscriptions = new Set();
const requests = [];
let started = 0;
let cleaned = 0;
const apiProxy = {
events: {
mux(request, signal) {
requests.push(request);
const subscription = { signal, buffer: [...initial], done: false, wake: undefined };
subscriptions.add(subscription);
duringMux?.();
return (async function* () {
started++;
const onAbort = () => {
subscription.done = true;
subscription.wake?.();
};
signal.addEventListener('abort', onAbort, { once: true });
try {
while (true) {
while (subscription.buffer.length) {
const value = subscription.buffer.shift();
if (value instanceof Error) throw value;
yield value;
}
if (subscription.done || signal.aborted) return;
await new Promise((resolve) => { subscription.wake = resolve; });
subscription.wake = undefined;
}
} finally {
signal.removeEventListener('abort', onAbort);
subscriptions.delete(subscription);
cleaned++;
}
})();
},
},
};
return {
apiProxy,
requests,
subscriptions,
get started() { return started; },
get cleaned() { return cleaned; },
send(value) {
for (const subscription of subscriptions) {
subscription.buffer.push(value);
subscription.wake?.();
}
},
end() {
for (const subscription of subscriptions) {
subscription.done = true;
subscription.wake?.();
}
},
};
}
class FakeSocket {
readyState = 0;
listeners = new Map();
closeCount = 0;
throwOnClose = false;
addEventListener(type, callback) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(callback);
}
removeEventListener(type, callback) {
this.listeners.get(type)?.delete(callback);
}
emit(type, data) {
if (type === 'open') this.readyState = 1;
if (type === 'close') this.readyState = 3;
for (const callback of [...(this.listeners.get(type) ?? [])]) callback({ data });
}
close() {
this.closeCount++;
if (this.throwOnClose) throw new Error('cannot close yet');
this.emit('close');
}
assertClean() {
for (const listeners of this.listeners.values()) assert.equal(listeners.size, 0);
}
}
test('in-process mux is ready with no frames and abort cleans an idle read', { timeout: 1000 }, async () => {
const host = hostMux();
const controller = new AbortController();
let opened = false;
const task = watchHarnessMux({
apiProxy: host.apiProxy,
rpcId: 'mux-one',
signal: controller.signal,
onOpen() {
assert.equal(host.started, 1);
assert.equal(host.subscriptions.size, 1);
opened = true;
},
onEnvelope() { assert.fail('empty mux emitted a frame'); },
});
assert.equal(opened, true);
assert.deepEqual(host.requests, [{ rpcId: 'mux-one', payload: {} }]);
controller.abort();
await task;
assert.equal(host.cleaned, 1);
assert.equal(host.subscriptions.size, 0);
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
});
test('in-process mux normalizes frames without changing request IDs', async () => {
const host = hostMux({ initial: [frame()] });
const controller = new AbortController();
const envelopes = [];
const task = watchHarnessMux({
apiProxy: host.apiProxy,
signal: controller.signal,
rpcId: 'subscription-id',
onEnvelope(envelope) { envelopes.push(envelope); },
});
await tick();
host.end();
await task;
assert.deepEqual(envelopes, [{
type: 'server-request', rpcId: 'frame-one', method: 'session/event', payload: frame().payload,
}]);
assert.equal(host.cleaned, 1);
});
for (const initial of [[], [frame(), frame('frame-two')]]) {
test(`closing in onOpen cleans ${initial.length ? 'queued' : 'empty'} in-process mux`, { timeout: 1000 }, async () => {
const host = hostMux({ initial });
const controller = new AbortController();
await watchHarnessMux({
apiProxy: host.apiProxy,
signal: controller.signal,
onOpen(close) { close(); },
onEnvelope() { assert.fail('forwarded after close'); },
});
assert.equal(host.started, 1);
assert.equal(host.cleaned, 1);
assert.equal(host.subscriptions.size, 0);
assert.equal(controller.signal.aborted, false);
});
}
test('pre-aborted watchers never create either transport', async () => {
const controller = new AbortController();
controller.abort();
const host = hostMux();
for (const baseUrl of [undefined, 'http://127.0.0.1:3080']) {
await watchHarnessMux({
apiProxy: host.apiProxy,
baseUrl,
signal: controller.signal,
createWebSocket() { assert.fail('opened an aborted transport'); },
onOpen() { assert.fail('reported aborted transport ready'); },
});
}
assert.equal(host.requests.length, 0);
});
test('abort during eager mux creation still starts the generator to release its listeners', { timeout: 1000 }, async () => {
const controller = new AbortController();
const host = hostMux({ initial: [frame()], duringMux: () => controller.abort() });
await watchHarnessMux({
apiProxy: host.apiProxy,
signal: controller.signal,
onOpen() { assert.fail('reported readiness after cancellation'); },
onEnvelope() { assert.fail('forwarded after cancellation'); },
});
assert.equal(host.started, 1);
assert.equal(host.cleaned, 1);
assert.equal(host.subscriptions.size, 0);
});
test('closing one in-process mux does not cancel another subscription on the same host', async () => {
const host = hostMux();
const controller = new AbortController();
let closeFirst;
const first = [];
const second = [];
const firstTask = watchHarnessMux({
apiProxy: host.apiProxy,
signal: controller.signal,
onOpen(close) { closeFirst = close; },
onEnvelope(envelope) { first.push(envelope); },
});
const secondTask = watchHarnessMux({
apiProxy: host.apiProxy,
signal: controller.signal,
onEnvelope(envelope) { second.push(envelope); },
});
closeFirst();
await firstTask;
assert.equal(host.subscriptions.size, 1);
host.send(frame());
await tick();
controller.abort();
await secondTask;
assert.equal(first.length, 0);
assert.equal(second.length, 1);
assert.equal(host.cleaned, 2);
});
test('in-process read failures reject and clean up their subscriptions', async () => {
const error = new Error('mux read failed');
const host = hostMux({ initial: [error] });
const controller = new AbortController();
await assert.rejects(watchHarnessMux({ apiProxy: host.apiProxy, signal: controller.signal }), error);
assert.equal(host.cleaned, 1);
assert.equal(host.subscriptions.size, 0);
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
});
for (const hook of ['onOpen', 'onEnvelope']) {
for (const asynchronous of [false, true]) {
test(`${hook} ${asynchronous ? 'async' : 'sync'} failure closes the in-process stream`, { timeout: 1000 }, async () => {
const error = new Error('callback failed');
const host = hostMux({ initial: [frame()] });
await assert.rejects(watchHarnessMux({
apiProxy: host.apiProxy,
signal: new AbortController().signal,
[hook]: asynchronous ? async () => { throw error; } : () => { throw error; },
}), error);
assert.equal(host.cleaned, 1);
assert.equal(host.subscriptions.size, 0);
});
}
}
test('explicit URL uses WebSocket even when apiProxy is available, isolating malformed messages', async () => {
const socket = new FakeSocket();
const host = hostMux();
const controller = new AbortController();
const received = [];
const malformed = [];
let close;
const task = watchHarnessMux({
apiProxy: host.apiProxy,
baseUrl: 'https://harness.example:8443/ignored',
signal: controller.signal,
createWebSocket(url) {
assert.equal(url, 'wss://harness.example:8443/api/events.mux');
return socket;
},
onOpen(value) { close = value; },
onEnvelope(value) { received.push(value); },
onMalformed(error) { malformed.push(error); },
});
assert.equal(close, undefined);
socket.emit('open');
socket.emit('message', '{');
socket.emit('message', new Uint8Array([1, 2]));
const envelope = { type: 'server-request', method: 'session/event', ...frame() };
socket.emit('message', JSON.stringify(envelope));
close();
socket.emit('message', JSON.stringify(envelope));
await task;
assert.deepEqual(received, [envelope]);
assert.equal(malformed.length, 2);
assert.equal(host.requests.length, 0);
assert.equal(socket.closeCount, 1);
socket.assertClean();
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
});
test('WebSocket abort settles even if close throws during connection', async () => {
const socket = new FakeSocket();
socket.throwOnClose = true;
const controller = new AbortController();
const task = watchHarnessMux({
baseUrl: 'http://127.0.0.1:3080',
signal: controller.signal,
createWebSocket: () => socket,
onOpen() { assert.fail('opened cancelled socket'); },
});
controller.abort();
await task;
socket.emit('open');
socket.assertClean();
assert.equal(socket.closeCount, 1);
});
for (const opened of [false, true]) {
for (const event of ['close', 'error']) {
test(`WebSocket ${event} ${opened ? 'after' : 'before'} opening has expected settlement and cleanup`, async () => {
const socket = new FakeSocket();
const controller = new AbortController();
const task = watchHarnessMux({
baseUrl: 'http://127.0.0.1:3080',
signal: controller.signal,
createWebSocket: () => socket,
});
if (opened) socket.emit('open');
socket.emit(event);
if (opened && event === 'close') await task;
else await assert.rejects(task, opened ? /WebSocket failed/ : /before opening/);
socket.assertClean();
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
});
}
}
test('WebSocket callback failures reject and close the connection', async () => {
const socket = new FakeSocket();
const error = new Error('callback failed');
const task = watchHarnessMux({
baseUrl: 'http://127.0.0.1:3080',
signal: new AbortController().signal,
createWebSocket: () => socket,
async onEnvelope() { throw error; },
});
socket.emit('open');
socket.emit('message', JSON.stringify(frame()));
await assert.rejects(task, error);
socket.assertClean();
assert.equal(socket.closeCount, 1);
});

View file

@ -37,7 +37,7 @@ test('token-channel production has no per-bot result-file Gate', async (t) => {
const production = await createTokenProductionController({
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,

View file

@ -44,7 +44,7 @@ test('Slack production has no per-bot result-file Gate', async (t) => {
};
const ctx = {
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};

View file

@ -56,7 +56,7 @@ test('Telegram production has no per-bot result-file Gate', async (t) => {
const production = await createProductionController({
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,

View file

@ -43,7 +43,7 @@ test('Enterprise WeChat production has no per-bot result-file Gate', async (t) =
const production = await createProductionController({
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,

View file

@ -45,7 +45,7 @@ test('Weixin production has no per-bot result-file Gate', async (t) => {
};
const ctx = {
credentials: {},
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};

View file

@ -44,7 +44,7 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
createConnectionSupervisor,
};
const ctx = {
webServer: { port: 3080 },
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};

View file

@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { harnessConnection } from '../plugin-src/host/harness-connection.mjs';
import { inject as hostInject } from '../plugin-src/host/index.mjs';
const IM_CHANNELS = [
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
'slack', 'telegram', 'discord', 'whatsapp',
];
test('Host connections share the current Cordis root without depending on a webServer', () => {
const root = {};
const apiProxy = {};
const first = harnessConnection({ root, apiProxy });
const second = harnessConnection({ root, apiProxy });
assert.deepEqual(first, { apiProxy, interactionScope: root });
assert.equal(first.interactionScope, second.interactionScope);
assert.notEqual(first.interactionScope, harnessConnection({ root: {}, apiProxy }).interactionScope);
const fixtureContext = { apiProxy };
assert.equal(harnessConnection(fixtureContext).interactionScope, fixtureContext);
});
test('an explicit Harness URL preserves HTTP transport and never reads the Host apiProxy', () => {
const ctx = { get apiProxy() { throw new Error('must not read local apiProxy'); } };
const connection = harnessConnection(ctx, { harnessBaseUrl: 'https://harness.example/base/' });
assert.equal(connection.baseUrl.href, 'https://harness.example/base/');
assert.deepEqual(Object.keys(connection), ['baseUrl']);
assert.throws(() => harnessConnection(ctx, { harnessBaseUrl: 'not a URL' }), TypeError);
});
test('a missing Host apiProxy fails clearly instead of silently falling back to localhost', () => {
assert.throws(
() => harnessConnection({ webServer: { port: 3080 } }),
/requires the Host apiProxy service/,
);
});
test('Host and all IM channel plugins wait for apiProxy rather than a webServer', async () => {
assert.ok(hostInject.includes('apiProxy'));
assert.equal(hostInject.includes('webServer'), false);
for (const channel of IM_CHANNELS) {
const { inject } = await import(`../plugin-src/host/channels/${channel}/index.mjs`);
assert.ok(inject.includes('apiProxy'), channel);
assert.equal(inject.includes('webServer'), false, channel);
}
});
async function assembledHarness(channel, ctx, config = {}) {
const { createProductionController } = await import(
`../plugin-src/host/channels/${channel}/production.mjs`
);
const constructed = {};
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class Harness {
constructor(options) { constructed.harness = options; }
stopManagedProcess() {}
}
class Controller {
constructor(options) { constructed.controller = options; }
async initialize() {}
async close() {}
}
class Runtime {
constructor(options) { constructed.runtime = options; }
}
const production = await createProductionController(ctx, {
workspace: '/test/workspace',
...config,
}, {
ConfigStore,
HarnessClient: Harness,
Controller,
Runtime,
FeishuRuntime: Runtime,
api: {},
deviceAuth: {},
qrAuth: {},
lark: {},
proxyEnv: {},
workspaces: {
async reconcile() {},
async ensure() {},
decorateStatus(value) { return value; },
},
createConnectionSupervisor: () => ({
ready: Promise.resolve(),
start() { return this; },
async close() {},
}),
});
try {
if (channel === 'office') {
constructed.controller.createRuntime({});
constructed.runtime.createHarness({ workspace: '/test/workspace' });
}
return constructed.harness;
} finally {
await production.close();
}
}
for (const channel of [...IM_CHANNELS, 'office']) {
test(`${channel} production uses its Host apiProxy with no webServer or listening port`, async () => {
const apiProxy = {};
const root = {};
const options = await assembledHarness(channel, { credentials: {}, apiProxy, root });
assert.equal(options.apiProxy, apiProxy);
assert.equal(options.interactionScope, root);
assert.equal(Object.hasOwn(options, 'baseUrl'), false);
assert.equal(options.workspace, '/test/workspace');
assert.equal(options.autostart, false);
});
test(`${channel} production preserves an explicitly configured Harness URL`, async () => {
const options = await assembledHarness(channel, {
credentials: {},
get apiProxy() { throw new Error('explicit URL must not use local apiProxy'); },
}, { harnessBaseUrl: 'http://127.0.0.1:43210/custom/' });
assert.equal(options.baseUrl.href, 'http://127.0.0.1:43210/custom/');
assert.equal(Object.hasOwn(options, 'apiProxy'), false);
assert.equal(Object.hasOwn(options, 'interactionScope'), false);
assert.equal(options.autostart, false);
});
}

View file

@ -38,7 +38,7 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
assert.deepEqual(inject, [
'connection',
'credentials',
'webServer',
'apiProxy',
'typertGateway',
]);
assert.deepEqual(calls, [