fix(weixin): classify Harness health failures

This commit is contained in:
xmanrui 2026-08-22 04:21:00 +08:00
parent 8eed60bbc9
commit eb21b14f60
9 changed files with 387 additions and 171 deletions

View file

@ -6,6 +6,10 @@ import { QqHarnessClient } from '../../../src/channels/qq/harness-client.mjs';
import { SlackHarnessClient } from '../../../src/channels/slack/harness-client.mjs';
import { TelegramHarnessClient } from '../../../src/channels/telegram/harness-client.mjs';
import { WecomHarnessClient } from '../../../src/channels/wecom/harness-client.mjs';
import {
HarnessHealthError,
HarnessTransportError,
} from '../../../src/channels/shared/harness-client.mjs';
import { HarnessClient, HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
import { WhatsappHarnessClient } from '../../../src/channels/whatsapp/harness-client.mjs';
@ -45,6 +49,91 @@ test('all legacy channel clients now use the shared Harness RPC transport', asyn
}
});
test('shared Harness health checks expose precise safe availability codes', async () => {
const clientWithFetch = (fetchImpl) => new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
fetchImpl,
});
const privateConnectionError = new Error('ECONNREFUSED at private loopback port');
await assert.rejects(
clientWithFetch(async () => { throw privateConnectionError; }).health(),
(error) => {
assert.ok(error instanceof HarnessTransportError);
assert.equal(error.code, 'harness-connect-failed');
assert.equal(error.cause, privateConnectionError);
assert.doesNotMatch(error.message, /private loopback port/);
return true;
},
);
const timeoutClient = clientWithFetch((_url, { signal }) => new Promise((_resolve, reject) => {
const rejectTimeout = () => reject(signal.reason);
if (signal.aborted) rejectTimeout();
else signal.addEventListener('abort', rejectTimeout, { once: true });
}));
await assert.rejects(timeoutClient.rpc('host.describe', {}, 1), (error) => {
assert.ok(error instanceof HarnessTransportError);
assert.equal(error.code, 'harness-timeout');
return true;
});
for (const [status, expectedCode] of [
[401, 'harness-access-denied'],
[403, 'harness-access-denied'],
[404, 'harness-api-not-found'],
[500, 'harness-http-failed'],
]) {
await assert.rejects(
clientWithFetch(async () => ({ ok: false, status })).health(),
(error) => {
assert.ok(error instanceof HarnessTransportError);
assert.equal(error.code, expectedCode);
assert.equal(error.status, status);
return true;
},
);
}
await assert.rejects(
clientWithFetch(async () => ({
ok: true,
json: async () => { throw new SyntaxError('private malformed response body'); },
})).health(),
(error) => {
assert.ok(error instanceof HarnessTransportError);
assert.equal(error.code, 'harness-response-invalid');
assert.doesNotMatch(error.message, /private malformed response body/);
return true;
},
);
await assert.rejects(
clientWithFetch(async (_url, options) => {
const { rpcId } = JSON.parse(options.body);
return {
ok: true,
json: async () => ({
type: 'server-response',
rpcId,
result: {
ok: false,
error: { code: 'private-host-code', message: 'private Host RPC detail' },
},
}),
};
}).health(),
(error) => {
assert.ok(error instanceof HarnessHealthError);
assert.equal(error.code, 'harness-rpc-rejected');
assert.match(error.cause?.message ?? '', /private Host RPC detail/);
assert.doesNotMatch(error.message, /private-host-code|private Host RPC detail/);
return true;
},
);
});
test('HarnessClient lets the Host resolve an omitted agent preset and forwards an explicit override', async () => {
const createPayload = async (options = {}) => {
const client = new HarnessClient({

View file

@ -351,7 +351,7 @@ test('known runtime activation codes cross the provisioning boundary unchanged',
const configs = configFixture();
const runtimes = runtimeFactory({
startError: Object.assign(new Error('loopback transport host-only detail'), {
code: 'harness-unreachable',
code: 'harness-api-not-found',
}),
});
const controller = new WeixinController({
@ -377,8 +377,9 @@ test('known runtime activation codes cross the provisioning boundary unchanged',
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'harness-unreachable');
assert.match(failed.error.message, /无法连接本机 Harness/);
assert.equal(failed.error.code, 'harness-api-not-found');
assert.notEqual(failed.error.code, 'harness-unreachable');
assert.match(failed.error.message, /找不到 Harness 健康检查接口/);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
await controller.close();
});

View file

@ -308,7 +308,7 @@ test('runtime refuses to report ready when notifyStart rejects the stored token'
assert.equal(runtime.status.weixinConnectionState, 'failed');
});
test('runtime identifies a local Harness health failure without exposing its detail', async () => {
test('runtime uses an explicit unknown code for an unclassified Harness health failure', async () => {
const runtime = new WeixinRuntime({
api: {
notifyStart: async () => assert.fail('notifyStart must not run while Harness is unavailable'),
@ -323,7 +323,8 @@ test('runtime identifies a local Harness health failure without exposing its det
});
await assert.rejects(runtime.start(), (error) => {
assert.equal(error.code, 'harness-unreachable');
assert.equal(error.code, 'harness-check-unknown-failed');
assert.notEqual(error.code, 'harness-unreachable');
assert.doesNotMatch(error.message, /private loopback transport detail/);
assert.match(error.cause?.message ?? '', /private loopback transport detail/);
return true;
@ -332,6 +333,37 @@ test('runtime identifies a local Harness health failure without exposing its det
assert.equal(runtime.status.weixinConnectionState, 'failed');
});
test('runtime preserves classified Harness health codes without exposing their causes', async () => {
for (const code of [
'harness-connect-failed',
'harness-timeout',
'harness-access-denied',
'harness-api-not-found',
'harness-http-failed',
'harness-response-invalid',
'harness-rpc-rejected',
]) {
const healthError = Object.assign(new Error(`private detail for ${code}`), { code });
const runtime = new WeixinRuntime({
api: {
notifyStart: async () => assert.fail('notifyStart must not run after a failed health check'),
notifyStop: async () => {},
},
config: { botId: `wx_${code}`, baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
token: 'bot-token',
harness: { ensureRunning: async () => { throw healthError; } },
state: {},
});
await assert.rejects(runtime.start(), (error) => {
assert.equal(error.code, code);
assert.equal(error.cause, healthError);
assert.doesNotMatch(error.message, /private detail/);
return true;
});
}
});
test('runtime retries a transient notifyStart failure before reporting the account offline', async () => {
let startCalls = 0;
const runtime = new WeixinRuntime({