mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 16:40:46 +08:00
fix(weixin): classify activation failures
This commit is contained in:
parent
832bd539a2
commit
77dea74326
7 changed files with 431 additions and 134 deletions
|
|
@ -621,6 +621,13 @@ var EN = Object.freeze({
|
|||
"\u8FD9\u662F\u5FAE\u4FE1\u9644\u52A0\u7684\u5B89\u5168\u786E\u8BA4\u6B65\u9AA4\u3002\u914D\u5BF9\u7801\u53EA\u7528\u4E8E\u672C\u6B21\u626B\u7801\u8F6E\u8BE2\uFF0C\u4E0D\u4F1A\u5199\u5165\u914D\u7F6E\u6216\u65E5\u5FD7\u3002": "This is an additional WeChat confirmation step. The pairing code is used only for this connection and is never stored.",
|
||||
"\u6B63\u5728\u4FDD\u5B58\u51ED\u636E\u5E76\u9A8C\u8BC1 Harness \u4E0E\u5FAE\u4FE1\u957F\u8F6E\u8BE2\u3002": "Saving credentials and verifying the WeChat connection.",
|
||||
"\u5FAE\u4FE1\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u542F\u52A8\u6D88\u606F\u8FDE\u63A5": "Confirmed in WeChat. Starting the message connection",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u8BFB\u53D6\u73B0\u6709\u767B\u5F55\u51ED\u636E\u3002\u8BF7\u68C0\u67E5 DSH \u51ED\u636E\u5B58\u50A8\u3002": "WeChat was authorized, but the existing login credential could not be read. Check the DSH credential store.",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u767B\u5F55\u51ED\u636E\u65E0\u6CD5\u5199\u5165 DSH \u51ED\u636E\u5B58\u50A8\u3002\u8BF7\u68C0\u67E5\u51ED\u636E\u5B58\u50A8\u662F\u5426\u53EF\u5199\u3002": "WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u8D26\u53F7\u914D\u7F6E\u65E0\u6CD5\u5199\u5165\u672C\u673A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u76EE\u5F55\u6743\u9650\u3002": "WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u521D\u59CB\u5316\u8D26\u53F7\u72B6\u6001\u6216\u5DE5\u4F5C\u533A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u548C\u5DE5\u4F5C\u533A\u76EE\u5F55\u3002": "WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u63D2\u4EF6\u65E0\u6CD5\u8FDE\u63A5\u672C\u673A Harness\u3002\u8BF7\u786E\u8BA4 dsh web \u5DF2\u6B63\u5E38\u542F\u52A8\u3002": "WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u521D\u59CB\u5316\u5931\u8D25\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u540E\u91CD\u8BD5\u3002": "WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.",
|
||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6FC0\u6D3B\u8FC7\u7A0B\u4E2D\u53D1\u751F\u672A\u77E5\u9519\u8BEF\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.",
|
||||
"\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\uFF0C\u53EF\u4EE5\u5F00\u59CB\u5411\u5DF2\u7ED1\u5B9A\u7684\u673A\u5668\u4EBA\u53D1\u6D88\u606F\u3002": "WeChat is connected and ready for messages.",
|
||||
"\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\u5DF2\u7ECF\u7ED1\u5B9A\u5E76\u4FDD\u6301\u5728\u7EBF\u3002": "This WeChat account is connected and online.",
|
||||
"\u5FAE\u4FE1\u8D26\u53F7\u53CA\u672C\u673A\u51ED\u636E\u5DF2\u79FB\u9664\u3002": "The WeChat account and local credentials were removed.",
|
||||
|
|
|
|||
236
lib/index.js
236
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -231,6 +231,13 @@ const EN = Object.freeze({
|
|||
'这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。': 'This is an additional WeChat confirmation step. The pairing code is used only for this connection and is never stored.',
|
||||
'正在保存凭据并验证 Harness 与微信长轮询。': 'Saving credentials and verifying the WeChat connection.',
|
||||
'微信已确认,正在启动消息连接': 'Confirmed in WeChat. Starting the message connection',
|
||||
'微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Check the DSH credential store.',
|
||||
'微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.',
|
||||
'微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.',
|
||||
'微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.',
|
||||
'微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。': 'WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.',
|
||||
'微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.',
|
||||
'微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.',
|
||||
'微信已绑定,可以开始向已绑定的机器人发消息。': 'WeChat is connected and ready for messages.',
|
||||
'这个微信账号已经绑定并保持在线。': 'This WeChat account is connected and online.',
|
||||
'微信账号及本机凭据已移除。': 'The WeChat account and local credentials were removed.',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ const ACTIVE_ATTEMPT_STATES = new Set([
|
|||
]);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
||||
const QR_TTL_MS = 5 * 60_000;
|
||||
const ACTIVATION_ERROR_MESSAGES = Object.freeze({
|
||||
'credential-read-failed': '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。',
|
||||
'credential-save-failed': '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
|
||||
'account-config-save-failed': '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
|
||||
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
||||
'harness-unreachable': '微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。',
|
||||
'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
|
||||
});
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -54,6 +62,26 @@ function safeAccountError(code, message) {
|
|||
return Object.freeze({ code, message });
|
||||
}
|
||||
|
||||
function activationStageError(code, cause) {
|
||||
const error = new Error(`Weixin activation failed during ${code}`, { cause });
|
||||
error.name = 'WeixinActivationStageError';
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function publicProvisioningError(error) {
|
||||
if (error instanceof WeixinApiError) return safeAccountError(error.code, error.message);
|
||||
const message = ACTIVATION_ERROR_MESSAGES[error?.code];
|
||||
return message
|
||||
? safeAccountError(error.code, message)
|
||||
: safeAccountError('activation-unknown-failed', '微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。');
|
||||
}
|
||||
|
||||
function preserveActivationError(error, fallbackCode) {
|
||||
if (error instanceof WeixinApiError || ACTIVATION_ERROR_MESSAGES[error?.code]) return error;
|
||||
return activationStageError(fallbackCode, error);
|
||||
}
|
||||
|
||||
export class WeixinController {
|
||||
#api;
|
||||
#credentials;
|
||||
|
|
@ -439,12 +467,7 @@ export class WeixinController {
|
|||
record.error = safeAccountError('cancelled', '扫码绑定已取消。');
|
||||
} else {
|
||||
record.state = 'failed';
|
||||
record.error = safeAccountError(
|
||||
error instanceof WeixinApiError ? error.code : 'activation-failed',
|
||||
error instanceof WeixinApiError
|
||||
? error.message
|
||||
: '微信已授权,但无法保存凭据或启动消息连接。',
|
||||
);
|
||||
record.error = publicProvisioningError(error);
|
||||
this.#logger.error?.('[dsh-weixin] provisioning failed:', error);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -469,13 +492,26 @@ export class WeixinController {
|
|||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined);
|
||||
let previousToken;
|
||||
try {
|
||||
previousToken = await this.#credentials.resolve(identity.tokenRef);
|
||||
} catch (error) {
|
||||
throw activationStageError('credential-read-failed', error);
|
||||
}
|
||||
|
||||
return this.#withBotTransition(identity.botId, async () => {
|
||||
await this.#credentials.set(identity.tokenRef, token);
|
||||
try {
|
||||
try {
|
||||
await this.#credentials.set(identity.tokenRef, token);
|
||||
} catch (error) {
|
||||
throw activationStageError('credential-save-failed', error);
|
||||
}
|
||||
this.#assertAttemptActive(record);
|
||||
await this.#configStore.save(config);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
throw activationStageError('account-config-save-failed', error);
|
||||
}
|
||||
this.#assertAttemptActive(record);
|
||||
await this.#startRuntime(config, token);
|
||||
this.#assertAttemptActive(record);
|
||||
|
|
@ -504,16 +540,24 @@ export class WeixinController {
|
|||
|
||||
async #startRuntime(config, token) {
|
||||
await this.#stopRuntime(config.botId);
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, token });
|
||||
let runtime;
|
||||
try {
|
||||
runtime = await this.#createRuntime({ botId: config.botId, config, token });
|
||||
} catch (error) {
|
||||
throw preserveActivationError(error, 'runtime-prepare-failed');
|
||||
}
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError('createRuntime returned an invalid Weixin runtime');
|
||||
throw activationStageError(
|
||||
'runtime-prepare-failed',
|
||||
new TypeError('createRuntime returned an invalid Weixin runtime'),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await runtime.start();
|
||||
this.#runtimes.set(config.botId, runtime);
|
||||
} catch (error) {
|
||||
await runtime.stop().catch(() => undefined);
|
||||
throw error;
|
||||
throw preserveActivationError(error, 'connection-start-failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ function retryableStartError(error) {
|
|||
&& (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500);
|
||||
}
|
||||
|
||||
function runtimeStartError(code, cause) {
|
||||
const error = new Error(`Weixin runtime failed during ${code}`, { cause });
|
||||
error.name = 'WeixinRuntimeStartError';
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
|
|
@ -117,7 +124,11 @@ export class WeixinRuntime {
|
|||
this.#status.weixinConnectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
try {
|
||||
await this.#harness.ensureRunning();
|
||||
try {
|
||||
await this.#harness.ensureRunning();
|
||||
} catch (error) {
|
||||
throw runtimeStartError('harness-unreachable', error);
|
||||
}
|
||||
this.#status.harnessReachable = true;
|
||||
await this.#notifyStart();
|
||||
this.#abortController = new AbortController();
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function configFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
function runtimeFactory({ failStart = false } = {}) {
|
||||
function runtimeFactory({ failStart = false, startError } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const createRuntime = async ({ config, token }) => {
|
||||
|
|
@ -64,6 +64,7 @@ function runtimeFactory({ failStart = false } = {}) {
|
|||
};
|
||||
},
|
||||
async start() {
|
||||
if (startError) throw startError;
|
||||
if (failStart) throw new Error('runtime start failed with host-only detail');
|
||||
ready = true;
|
||||
},
|
||||
|
|
@ -169,7 +170,7 @@ test('verification-code state pauses polling and resumes with the submitted digi
|
|||
await controller.close();
|
||||
});
|
||||
|
||||
test('activation failure rolls credentials and non-secret config back', async () => {
|
||||
test('runtime activation failure is classified and rolls credentials and config back', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
const runtimes = runtimeFactory({ failStart: true });
|
||||
|
|
@ -195,13 +196,216 @@ test('activation failure rolls credentials and non-secret config back', async ()
|
|||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(failed.error.code, 'activation-failed');
|
||||
assert.equal(failed.error.code, 'connection-start-failed');
|
||||
assert.match(failed.error.message, /消息连接初始化失败/);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
assert.doesNotMatch(JSON.stringify(failed), /must-be-rolled-back|host-only detail/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('credential write failure is classified and rolls back a post-commit error', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
credentials.provider.set = async (ref, value) => {
|
||||
credentials.values.set(ref, value);
|
||||
throw new Error('credential backend failed after commit with private detail');
|
||||
};
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'must-be-rolled-back',
|
||||
ilink_bot_id: 'credential-failure@im.bot',
|
||||
ilink_user_id: 'owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimeFactory().createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
const failed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(failed.error.code, 'credential-save-failed');
|
||||
assert.match(failed.error.message, /DSH 凭据存储/);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
assert.doesNotMatch(JSON.stringify(failed), /private detail|must-be-rolled-back/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('credential read failure stops activation before any durable write', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
credentials.provider.resolve = async () => {
|
||||
throw new Error('credential read host-only detail');
|
||||
};
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'must-never-be-written',
|
||||
ilink_bot_id: 'credential-read-failure@im.bot',
|
||||
ilink_user_id: 'owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimeFactory().createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
const failed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(failed.error.code, 'credential-read-failed');
|
||||
assert.equal(credentials.calls.length, 0);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-never-be-written/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('account config write and runtime preparation failures have distinct safe codes', async () => {
|
||||
for (const scenario of [
|
||||
{
|
||||
expectedCode: 'account-config-save-failed',
|
||||
prepare: ({ configs }) => {
|
||||
configs.store.save = async (account) => {
|
||||
configs.accounts.set(account.botId, structuredClone(account));
|
||||
throw new Error('config path host-only detail');
|
||||
};
|
||||
},
|
||||
createRuntime: runtimeFactory().createRuntime,
|
||||
},
|
||||
{
|
||||
expectedCode: 'runtime-prepare-failed',
|
||||
prepare: () => {},
|
||||
createRuntime: async () => { throw new Error('workspace path host-only detail'); },
|
||||
},
|
||||
]) {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
scenario.prepare({ credentials, configs });
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'must-be-rolled-back',
|
||||
ilink_bot_id: `${scenario.expectedCode}@im.bot`,
|
||||
ilink_user_id: 'owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: scenario.createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
const failed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(failed.error.code, scenario.expectedCode);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('known runtime activation codes cross the provisioning boundary unchanged', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
const runtimes = runtimeFactory({
|
||||
startError: Object.assign(new Error('loopback transport host-only detail'), {
|
||||
code: 'harness-unreachable',
|
||||
}),
|
||||
});
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'must-be-rolled-back',
|
||||
ilink_bot_id: 'harness-failure@im.bot',
|
||||
ilink_user_id: 'owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
const failed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(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();
|
||||
});
|
||||
|
||||
test('an unclassified activation error uses the explicit unknown fallback code', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
configs.store.getByAccountId = () => {
|
||||
throw new Error('unexpected host-only activation detail');
|
||||
};
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'must-never-cross-the-browser-boundary',
|
||||
ilink_bot_id: 'unknown-failure@im.bot',
|
||||
ilink_user_id: 'owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimeFactory().createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
const failed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(failed.error.code, 'activation-unknown-failed');
|
||||
assert.notEqual(failed.error.code, 'activation-failed');
|
||||
assert.match(failed.error.message, /未知错误/);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(failed),
|
||||
/unexpected host-only activation detail|must-never-cross-the-browser-boundary/,
|
||||
);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('cancelling an in-flight QR long poll is terminal and writes no credentials', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
|
|
|
|||
|
|
@ -308,6 +308,30 @@ 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 () => {
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => assert.fail('notifyStart must not run while Harness is unavailable'),
|
||||
notifyStop: async () => {},
|
||||
sendText: async () => {},
|
||||
getUpdates: async () => ({ ret: 0, msgs: [] }),
|
||||
},
|
||||
config: { botId: 'wx_harness', baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
|
||||
token: 'bot-token',
|
||||
harness: { ensureRunning: async () => { throw new Error('private loopback transport detail'); } },
|
||||
state: {},
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.start(), (error) => {
|
||||
assert.equal(error.code, 'harness-unreachable');
|
||||
assert.doesNotMatch(error.message, /private loopback transport detail/);
|
||||
assert.match(error.cause?.message ?? '', /private loopback transport detail/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
||||
});
|
||||
|
||||
test('runtime retries a transient notifyStart failure before reporting the account offline', async () => {
|
||||
let startCalls = 0;
|
||||
const runtime = new WeixinRuntime({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue