mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 07:53:20 +08:00
fix(feishu): stabilize post-merge card workflows
This commit is contained in:
parent
9d1dc71794
commit
bd9c58cf9e
19 changed files with 3372 additions and 456 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -93,7 +93,11 @@ test('custom steer card wraps input and submit in a form container', () => {
|
|||
const input = form.elements.find((element) => element.tag === 'input');
|
||||
assert.equal(input?.name, 'steer_text');
|
||||
const submit = form.elements.find((element) => element.tag === 'button');
|
||||
assert.equal(submit?.name, 'steer_submit');
|
||||
assert.equal(submit?.form_action_type, 'submit');
|
||||
assert.equal(submit?.action_type, undefined, 'Card 2.0 must not rely on the legacy action_type field');
|
||||
const controlNames = [form.name, ...form.elements.map((element) => element.name).filter(Boolean)];
|
||||
assert.equal(new Set(controlNames).size, controlNames.length, 'form control names must be card-global unique');
|
||||
assert.deepEqual(submit?.behaviors, [{
|
||||
type: 'callback',
|
||||
value: { action: 'steer', source: 'form' },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,25 @@ class FakeWSClient {
|
|||
this.options.onReady();
|
||||
}
|
||||
|
||||
fail(error = new Error('synthetic WebSocket failure')) {
|
||||
this.state = 'failed';
|
||||
this.options.onError(error);
|
||||
}
|
||||
|
||||
beginReconnecting() {
|
||||
this.state = 'reconnecting';
|
||||
this.options.onReconnecting();
|
||||
}
|
||||
|
||||
becomeReconnected() {
|
||||
this.state = 'connected';
|
||||
this.options.onReconnected();
|
||||
}
|
||||
|
||||
becomeIdle() {
|
||||
this.state = 'idle';
|
||||
}
|
||||
|
||||
getConnectionStatus() {
|
||||
return { state: this.state };
|
||||
}
|
||||
|
|
@ -81,6 +100,16 @@ function fakeLark() {
|
|||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||
let harnessChecks = 0;
|
||||
let harnessSignal;
|
||||
|
|
@ -98,6 +127,7 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
},
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
connectTimeoutMs: 1_234,
|
||||
});
|
||||
|
||||
assert.equal(runtime.status.ready, false);
|
||||
|
|
@ -110,6 +140,7 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
assert.equal(settled, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
||||
assert.equal(FakeWSClient.instances[0].options.agent, wsAgent);
|
||||
assert.equal(FakeWSClient.instances[0].options.handshakeTimeoutMs, 1_234);
|
||||
assert.equal('agent' in FakeClient.instances[0].options, false);
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
const status = await starting;
|
||||
|
|
@ -122,6 +153,19 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
url: 'https://open.feishu.cn/test',
|
||||
})).timeout, 15_000);
|
||||
|
||||
const firstDispatcher = FakeWSClient.instances[0].dispatcher;
|
||||
FakeWSClient.instances[0].beginReconnecting();
|
||||
const reconnecting = await runtime.start();
|
||||
assert.equal(reconnecting.ready, false);
|
||||
assert.equal(reconnecting.feishuLongConnectionState, 'reconnecting');
|
||||
assert.equal(harnessChecks, 1);
|
||||
assert.equal(FakeWSClient.instances.length, 1);
|
||||
assert.equal(FakeClient.instances.length, 1);
|
||||
assert.equal(FakeWSClient.instances[0].dispatcher, firstDispatcher);
|
||||
FakeWSClient.instances[0].becomeReconnected();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'connected');
|
||||
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(FakeClient.sent, [{
|
||||
params: { receive_id_type: 'open_id' },
|
||||
|
|
@ -137,6 +181,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
assert.equal(harnessSignal.aborted, true);
|
||||
|
||||
const stoppedStatus = runtime.status;
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
FakeWSClient.instances[0].fail(new Error('late failure after stop'));
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
FakeWSClient.instances[0].beginReconnecting();
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
FakeWSClient.instances[0].becomeReconnected();
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
});
|
||||
|
||||
test('FeishuRuntime uses a remembered private target for wildcard-only manual bots', async () => {
|
||||
|
|
@ -173,6 +227,50 @@ test('FeishuRuntime uses a remembered private target for wildcard-only manual bo
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime stop waits for a pending Harness check and prevents startup resurrection', async () => {
|
||||
const harnessReady = deferred();
|
||||
let harnessSignal;
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_delayed_harness',
|
||||
appSecret: 'secret',
|
||||
ownerOpenId: 'ou_owner',
|
||||
harness: {
|
||||
async ensureRunning({ signal }) {
|
||||
harnessSignal = signal;
|
||||
await harnessReady.promise;
|
||||
},
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
});
|
||||
|
||||
const starting = runtime.start();
|
||||
const startRejected = assert.rejects(starting, (error) => error?.name === 'AbortError');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(harnessSignal.aborted, false);
|
||||
|
||||
let stopSettled = false;
|
||||
const stopping = runtime.stop().then((status) => {
|
||||
stopSettled = true;
|
||||
return status;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(harnessSignal.aborted, true);
|
||||
assert.equal(stopSettled, false);
|
||||
assert.equal(FakeClient.instances.length, 0);
|
||||
assert.equal(FakeWSClient.instances.length, 0);
|
||||
|
||||
harnessReady.resolve();
|
||||
await startRejected;
|
||||
const stopped = await stopping;
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
assert.equal(FakeClient.instances.length, 0);
|
||||
assert.equal(FakeWSClient.instances.length, 0);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(runtime.status, stopped);
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
|
|
@ -188,6 +286,17 @@ test('FeishuRuntime fails closed when the initial WebSocket handshake times out'
|
|||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
assert.equal(FakeWSClient.instances[0].options.handshakeTimeoutMs, 10);
|
||||
|
||||
const failedStatus = runtime.status;
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
FakeWSClient.instances[0].fail(new Error('late failure after timeout'));
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
FakeWSClient.instances[0].beginReconnecting();
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
FakeWSClient.instances[0].becomeReconnected();
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when Harness is unavailable', async () => {
|
||||
|
|
@ -226,6 +335,41 @@ async function startRuntimeForProbe(options = {}) {
|
|||
return runtime;
|
||||
}
|
||||
|
||||
test('FeishuRuntime drains failed and idle WS resources before creating a replacement', async () => {
|
||||
const runtime = await startRuntimeForProbe({
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
const firstWsClient = FakeWSClient.instances[0];
|
||||
firstWsClient.fail(new Error('terminal connection failure'));
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
|
||||
const restartingFromFailure = runtime.start();
|
||||
for (let attempt = 0; attempt < 20 && FakeWSClient.instances.length < 2; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(firstWsClient.state, 'closed');
|
||||
assert.equal(FakeWSClient.instances.length, 2);
|
||||
assert.equal(FakeClient.instances.length, 2);
|
||||
const secondWsClient = FakeWSClient.instances[1];
|
||||
secondWsClient.becomeReady();
|
||||
assert.equal((await restartingFromFailure).ready, true);
|
||||
|
||||
// The SDK snapshot is authoritative even if a transition callback was
|
||||
// missed and Runtime status still says connected.
|
||||
secondWsClient.becomeIdle();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
const restartingFromIdle = runtime.start();
|
||||
for (let attempt = 0; attempt < 20 && FakeWSClient.instances.length < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(secondWsClient.state, 'closed');
|
||||
assert.equal(FakeWSClient.instances.length, 3);
|
||||
assert.equal(FakeClient.instances.length, 3);
|
||||
FakeWSClient.instances[2].becomeReady();
|
||||
assert.equal((await restartingFromIdle).ready, true);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
function probeAction({ messageId = 'message-1', nonce, operatorOpenId = 'ou_owner' } = {}) {
|
||||
return {
|
||||
operator: { open_id: operatorOpenId },
|
||||
|
|
@ -234,6 +378,192 @@ function probeAction({ messageId = 'message-1', nonce, operatorOpenId = 'ou_owne
|
|||
};
|
||||
}
|
||||
|
||||
test('FeishuRuntime dispatcher ACKs immediately while card work is still pending', async () => {
|
||||
const seen = new Set();
|
||||
const runtime = await startRuntimeForProbe({
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
harness: {
|
||||
async ensureRunning() {},
|
||||
async listWorkspaces() { return []; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
sessionFor: () => null,
|
||||
includesArchivedSessions: () => false,
|
||||
},
|
||||
});
|
||||
const handlers = FakeWSClient.instances[0].dispatcher.handlers;
|
||||
|
||||
assert.equal(handlers['im.message.reaction.created_v1']({}), undefined);
|
||||
assert.equal(handlers['im.message.reaction.deleted_v1']({}), undefined);
|
||||
assert.equal(handlers['im.message.receive_v1']({
|
||||
sender: {
|
||||
sender_type: 'user',
|
||||
sender_id: { open_id: 'ou_owner' },
|
||||
},
|
||||
message: {
|
||||
message_id: 'incoming-menu',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '/m' }),
|
||||
},
|
||||
}), undefined);
|
||||
|
||||
for (let attempt = 0; attempt < 20 && FakeClient.sent.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(FakeClient.sent[0]?.data?.msg_type, 'interactive');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let patchCalls = 0;
|
||||
let resolvePatch;
|
||||
const pendingPatch = new Promise((resolve) => { resolvePatch = resolve; });
|
||||
FakeClient.instances[0].im.v1.message.patch = async () => {
|
||||
patchCalls += 1;
|
||||
return pendingPatch;
|
||||
};
|
||||
|
||||
const result = handlers['card.action.trigger']({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'help' } },
|
||||
context: {
|
||||
open_message_id: 'message-1',
|
||||
open_chat_id: 'oc_chat',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, undefined);
|
||||
assert.equal(runtime.status.cardActionsReceived, 1);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(patchCalls, 1);
|
||||
|
||||
resolvePatch({ code: 0, data: { message_id: 'message-1' } });
|
||||
await pendingPatch;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime start waits for an idle-draining stop and preserves the new resources', async () => {
|
||||
const seen = new Set();
|
||||
const runtime = await startRuntimeForProbe({
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
harness: {
|
||||
async ensureRunning() {},
|
||||
async listWorkspaces() { return []; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
sessionFor: () => null,
|
||||
includesArchivedSessions: () => false,
|
||||
},
|
||||
});
|
||||
const firstWsClient = FakeWSClient.instances[0];
|
||||
const handlers = firstWsClient.dispatcher.handlers;
|
||||
handlers['im.message.receive_v1']({
|
||||
sender: {
|
||||
sender_type: 'user',
|
||||
sender_id: { open_id: 'ou_owner' },
|
||||
},
|
||||
message: {
|
||||
message_id: 'restart-menu',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '/m' }),
|
||||
},
|
||||
});
|
||||
for (let attempt = 0; attempt < 20 && FakeClient.sent.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(FakeClient.sent[0]?.data?.msg_type, 'interactive');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const patchEntered = deferred();
|
||||
const patchReleased = deferred();
|
||||
FakeClient.instances[0].im.v1.message.patch = async () => {
|
||||
patchEntered.resolve();
|
||||
return patchReleased.promise;
|
||||
};
|
||||
handlers['card.action.trigger']({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'help' } },
|
||||
context: {
|
||||
open_message_id: 'message-1',
|
||||
open_chat_id: 'oc_chat',
|
||||
},
|
||||
});
|
||||
await patchEntered.promise;
|
||||
|
||||
let stopSettled = false;
|
||||
const stopping = runtime.stop().then((status) => {
|
||||
stopSettled = true;
|
||||
return status;
|
||||
});
|
||||
let restartSettled = false;
|
||||
const restarting = runtime.start().then((status) => {
|
||||
restartSettled = true;
|
||||
return status;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(stopSettled, false);
|
||||
assert.equal(restartSettled, false);
|
||||
assert.equal(FakeWSClient.instances.length, 1);
|
||||
assert.equal(FakeClient.instances.length, 1);
|
||||
assert.equal(firstWsClient.state, 'closed');
|
||||
|
||||
patchReleased.resolve({ code: 0, data: { message_id: 'message-1' } });
|
||||
const stopped = await stopping;
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
for (let attempt = 0; attempt < 20 && FakeWSClient.instances.length < 2; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(FakeWSClient.instances.length, 2);
|
||||
assert.equal(FakeClient.instances.length, 2);
|
||||
|
||||
const secondWsClient = FakeWSClient.instances[1];
|
||||
secondWsClient.becomeReady();
|
||||
const restarted = await restarting;
|
||||
assert.equal(restarted.ready, true);
|
||||
assert.equal(restarted.feishuLongConnectionState, 'connected');
|
||||
assert.equal(secondWsClient.state, 'connected');
|
||||
|
||||
const sentBeforeLateDispatch = FakeClient.sent.length;
|
||||
const cardActionsBeforeLateDispatch = runtime.status.cardActionsReceived;
|
||||
handlers['im.message.receive_v1']({
|
||||
sender: {
|
||||
sender_type: 'user',
|
||||
sender_id: { open_id: 'ou_owner' },
|
||||
},
|
||||
message: {
|
||||
message_id: 'late-old-dispatcher-message',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '/m' }),
|
||||
},
|
||||
});
|
||||
handlers['card.action.trigger']({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'help' } },
|
||||
context: {
|
||||
open_message_id: 'message-1',
|
||||
open_chat_id: 'oc_chat',
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(seen.has('late-old-dispatcher-message'), false);
|
||||
assert.equal(runtime.status.cardActionsReceived, cardActionsBeforeLateDispatch);
|
||||
assert.equal(FakeClient.sent.length, sentBeforeLateDispatch);
|
||||
|
||||
assert.deepEqual(await runtime.sendConnectionTest('重启后连接测试'), { sent: true });
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime resolves a card-action probe only for the exact message, nonce and operator', async () => {
|
||||
const runtime = await startRuntimeForProbe();
|
||||
let settled = false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
|
||||
import { LoggerLevel, WSClient } from '@larksuiteoapi/node-sdk';
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const silentLogger = Object.fromEntries(
|
||||
['error', 'warn', 'info', 'debug', 'trace'].map((level) => [level, () => {}]),
|
||||
);
|
||||
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function completeWebSocketHandshake(socket) {
|
||||
let request = '';
|
||||
const handleData = (chunk) => {
|
||||
request += chunk.toString('utf8');
|
||||
if (!request.includes('\r\n\r\n')) {
|
||||
return;
|
||||
}
|
||||
socket.removeListener('data', handleData);
|
||||
const key = request.match(/^Sec-WebSocket-Key:\s*(.+)$/imu)?.[1]?.trim();
|
||||
if (!key) {
|
||||
throw new Error('WebSocket handshake did not contain Sec-WebSocket-Key');
|
||||
}
|
||||
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
|
||||
socket.write([
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
};
|
||||
socket.on('data', handleData);
|
||||
}
|
||||
|
||||
async function runScenario(mode) {
|
||||
let acceptedConnections = 0;
|
||||
const sockets = new Set();
|
||||
const server = createServer((socket) => {
|
||||
acceptedConnections += 1;
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
socket.on('error', () => {});
|
||||
if (mode === 'connected-repeat') {
|
||||
completeWebSocketHandshake(socket);
|
||||
}
|
||||
else {
|
||||
// Consume the HTTP upgrade bytes without responding. This keeps the
|
||||
// WebSocket handshake blackholed while allowing FIN/RST to surface as a
|
||||
// server-side close when the client aborts the pending socket.
|
||||
socket.resume();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const { port } = server.address();
|
||||
let endpointPulls = 0;
|
||||
let callbackCount = 0;
|
||||
const client = new WSClient({
|
||||
appId: 'cli_0123456789abcdef',
|
||||
appSecret: 'test-secret',
|
||||
autoReconnect: !['failed-restart', 'timeout'].includes(mode),
|
||||
handshakeTimeoutMs: ['connected-repeat', 'duplicate-start', 'restart'].includes(mode)
|
||||
? 500
|
||||
: 50,
|
||||
httpInstance: {
|
||||
async request() {
|
||||
endpointPulls += 1;
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
URL: `ws://127.0.0.1:${port}/blackhole`,
|
||||
ClientConfig: {
|
||||
PingInterval: 120,
|
||||
ReconnectCount: 5,
|
||||
ReconnectInterval: 0.01,
|
||||
ReconnectNonce: 0,
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
};
|
||||
},
|
||||
},
|
||||
logger: silentLogger,
|
||||
loggerLevel: LoggerLevel.error,
|
||||
onError: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
onReady: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
onReconnected: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
onReconnecting: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
if (mode === 'duplicate-start') {
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
}
|
||||
await waitFor(() => acceptedConnections >= 1, 'first WebSocket handshake');
|
||||
|
||||
if (mode === 'duplicate-start') {
|
||||
await delay(50);
|
||||
const stateBeforeClose = client.getConnectionStatus().state;
|
||||
const activeSocketsBeforeClose = sockets.size;
|
||||
if (stateBeforeClose !== 'connecting' || endpointPulls !== 1 ||
|
||||
acceptedConnections !== 1 || activeSocketsBeforeClose !== 1 || callbackCount !== 0) {
|
||||
throw new Error(JSON.stringify({
|
||||
stateBeforeClose,
|
||||
endpointPulls,
|
||||
acceptedConnections,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
}));
|
||||
}
|
||||
|
||||
client.close({ force: true });
|
||||
await waitFor(() => sockets.size === 0, 'duplicate-start socket shutdown');
|
||||
await delay(100);
|
||||
const activeSocketsAfterClose = sockets.size;
|
||||
if (endpointPulls !== 1 || acceptedConnections !== 1 || callbackCount !== 0) {
|
||||
throw new Error('duplicate start produced a callback or reconnect after close');
|
||||
}
|
||||
return {
|
||||
acceptedConnections,
|
||||
activeSocketsAfterClose,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
endpointPulls,
|
||||
stateBeforeClose,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'connected-repeat') {
|
||||
await waitFor(
|
||||
() => client.getConnectionStatus().state === 'connected',
|
||||
'connected WebSocket state',
|
||||
);
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
await delay(100);
|
||||
|
||||
const stateBeforeClose = client.getConnectionStatus().state;
|
||||
const activeSocketsBeforeClose = sockets.size;
|
||||
if (stateBeforeClose !== 'connected' || endpointPulls !== 1 ||
|
||||
acceptedConnections !== 1 || activeSocketsBeforeClose !== 1 || callbackCount !== 1) {
|
||||
throw new Error(JSON.stringify({
|
||||
stateBeforeClose,
|
||||
endpointPulls,
|
||||
acceptedConnections,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
}));
|
||||
}
|
||||
|
||||
client.close({ force: true });
|
||||
await waitFor(() => sockets.size === 0, 'connected socket shutdown');
|
||||
await delay(100);
|
||||
const activeSocketsAfterClose = sockets.size;
|
||||
if (endpointPulls !== 1 || acceptedConnections !== 1 || callbackCount !== 1) {
|
||||
throw new Error('connected repeat start produced a duplicate callback or reconnect');
|
||||
}
|
||||
return {
|
||||
acceptedConnections,
|
||||
activeSocketsAfterClose,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
endpointPulls,
|
||||
stateBeforeClose,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'failed-restart') {
|
||||
await waitFor(() => client.getConnectionStatus().state === 'failed', 'terminal failure state');
|
||||
const callbackCountBeforeRestart = callbackCount;
|
||||
if (endpointPulls !== 1 || callbackCountBeforeRestart !== 1) {
|
||||
throw new Error('initial terminal failure did not settle exactly once');
|
||||
}
|
||||
await waitFor(() => sockets.size === 0, 'failed socket shutdown');
|
||||
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
await waitFor(() => acceptedConnections >= 2, 'post-failure WebSocket handshake');
|
||||
const stateAfterRestart = client.getConnectionStatus().state;
|
||||
if (endpointPulls !== 2 || stateAfterRestart !== 'connecting') {
|
||||
throw new Error(`failed client did not restart: pulls=${endpointPulls}, state=${stateAfterRestart}`);
|
||||
}
|
||||
client.close({ force: true });
|
||||
await waitFor(() => sockets.size === 0, 'post-failure restarted socket shutdown');
|
||||
await delay(100);
|
||||
if (endpointPulls !== 2 || callbackCount !== callbackCountBeforeRestart) {
|
||||
throw new Error('post-failure restart produced a stale callback or reconnect');
|
||||
}
|
||||
return { callbackCountBeforeRestart, endpointPulls, stateAfterRestart };
|
||||
}
|
||||
|
||||
if (mode === 'restart') {
|
||||
client.close({ force: true });
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
await waitFor(() => acceptedConnections >= 2, 'restarted WebSocket handshake');
|
||||
await delay(50);
|
||||
|
||||
const stateBeforeFinalClose = client.getConnectionStatus().state;
|
||||
if (stateBeforeFinalClose !== 'connecting') {
|
||||
throw new Error(
|
||||
`stale initial continuation replaced restarted state with ${stateBeforeFinalClose}`,
|
||||
);
|
||||
}
|
||||
if (endpointPulls !== 2) {
|
||||
throw new Error(`expected two endpoint pulls after immediate restart, received ${endpointPulls}`);
|
||||
}
|
||||
if (callbackCount !== 0) {
|
||||
throw new Error(`stale connection invoked ${callbackCount} lifecycle callbacks`);
|
||||
}
|
||||
|
||||
client.close({ force: true });
|
||||
await delay(100);
|
||||
if (endpointPulls !== 2) {
|
||||
throw new Error(`old continuation triggered a third endpoint pull (${endpointPulls})`);
|
||||
}
|
||||
return { callbackCount, endpointPulls, stateBeforeFinalClose };
|
||||
}
|
||||
|
||||
if (mode === 'close') {
|
||||
// close() happens while ws is still CONNECTING. The patched SDK must
|
||||
// abort that pending socket and cancel the old reConnect(true)
|
||||
// continuation before it can pull another endpoint.
|
||||
client.close({ force: true });
|
||||
await delay(150);
|
||||
}
|
||||
else {
|
||||
// Let the handshake watchdog terminate the blackholed CONNECTING socket.
|
||||
// The retained `error` listener must prevent an uncaught EventEmitter
|
||||
// error. autoReconnect=false keeps this scenario to one endpoint pull.
|
||||
await delay(150);
|
||||
client.close({ force: true });
|
||||
}
|
||||
|
||||
if (endpointPulls !== 1) {
|
||||
throw new Error(`expected one endpoint pull after close, received ${endpointPulls}`);
|
||||
}
|
||||
return endpointPulls;
|
||||
}
|
||||
finally {
|
||||
client.close({ force: true });
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate, label) {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const handshakeTimeoutPulls = await runScenario('timeout');
|
||||
const closeDuringHandshakePulls = await runScenario('close');
|
||||
const immediateRestart = await runScenario('restart');
|
||||
const duplicateStart = await runScenario('duplicate-start');
|
||||
const connectedRepeatStart = await runScenario('connected-repeat');
|
||||
const failedRestart = await runScenario('failed-restart');
|
||||
process.stdout.write(JSON.stringify({
|
||||
handshakeTimeoutPulls,
|
||||
closeDuringHandshakePulls,
|
||||
immediateRestart,
|
||||
duplicateStart,
|
||||
connectedRepeatStart,
|
||||
failedRestart,
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -321,14 +321,14 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
sessionId: 'session-one',
|
||||
blank: false,
|
||||
cwd: '/tmp/target',
|
||||
projections: { values: { title: null } },
|
||||
projections: { asOfSeq: -1, values: { title: null } },
|
||||
},
|
||||
{
|
||||
sessionId: 'session-two',
|
||||
blank: true,
|
||||
origin: 'subagent',
|
||||
cwd: '/tmp/different',
|
||||
projections: { values: { title: 'Second session' } },
|
||||
projections: { asOfSeq: 0, values: { title: 'Second session' } },
|
||||
},
|
||||
{
|
||||
sessionId: 'cwd-only',
|
||||
|
|
@ -350,6 +350,7 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
blank: true,
|
||||
origin: 'subagent',
|
||||
summaryAvailable: true,
|
||||
lastSeq: 0,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-missing',
|
||||
|
|
@ -366,6 +367,7 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
blank: false,
|
||||
origin: null,
|
||||
summaryAvailable: true,
|
||||
lastSeq: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
125
test/channels/feishu/lark-sdk-handshake-patch.test.mjs
Normal file
125
test/channels/feishu/lark-sdk-handshake-patch.test.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
import {
|
||||
larkSdkHandshakePatch,
|
||||
patchLarkSdkHandshakeSource,
|
||||
} from '../../../plugin-src/host/lark-sdk-handshake-patch.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const testDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test('Lark SDK patch applies every reviewed lifecycle fix to both vendor builds', async () => {
|
||||
const sdkRoot = dirname(require.resolve('@larksuiteoapi/node-sdk/package.json'));
|
||||
|
||||
for (const flavor of ['es', 'lib']) {
|
||||
const sdkPath = resolve(sdkRoot, flavor, 'index.js');
|
||||
const source = await readFile(sdkPath, 'utf8');
|
||||
const patched = patchLarkSdkHandshakeSource(source, sdkPath);
|
||||
|
||||
assert.match(patched, /this\.pendingWsInstance = null;/u);
|
||||
assert.match(patched, /this\.pendingWsInstance = wsInstance;/u);
|
||||
assert.match(patched, /this\.pendingWsInstance === wsInstance/u);
|
||||
assert.match(patched, /wsInstance\.removeAllListeners\(['"]open['"]\);/u);
|
||||
assert.match(patched, /pendingWsInstance\.terminate\(\);/u);
|
||||
assert.match(
|
||||
patched,
|
||||
/finally \{\s+if \(currentGeneration === this\.reconnectGeneration\) \{\s+this\.isConnecting = false;/u,
|
||||
);
|
||||
assert.match(
|
||||
patched,
|
||||
/if \(this\.terminalError\) \{\s+this\.isConnecting = false;[^]*?if \(this\.isConnecting \|\|[^]*?liveWsInstance\.readyState !== WebSocket\.CLOSED/u,
|
||||
);
|
||||
assert.match(patched, /currentGeneration !== this\.reconnectGeneration \|\| result\.cancelled/u);
|
||||
assert.match(patched, /wsInstance\.on\(['"]error['"]/u);
|
||||
assert.doesNotMatch(
|
||||
patched,
|
||||
/handshake timeout[^]*?wsInstance\.removeAllListeners\(\);[^]*?settleOnce\(false\);/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Lark SDK handshake patch fails closed when the reviewed vendor shape changes', () => {
|
||||
assert.throws(
|
||||
() => patchLarkSdkHandshakeSource('wsInstance.terminate();', 'changed-sdk.js'),
|
||||
/expected exactly one reviewed WSClient pending-socket state marker, found 0/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('patched real SDK survives handshake timeout and close without uncaught or zombie reconnects', async () => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'dsh-lark-sdk-test-'));
|
||||
const outputPath = join(temporaryDirectory, 'scenario.mjs');
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [resolve(testDirectory, 'fixtures/lark-sdk-close-during-handshake.mjs')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node22'],
|
||||
mainFields: ['module', 'main'],
|
||||
plugins: [larkSdkHandshakePatch],
|
||||
outfile: outputPath,
|
||||
logLevel: 'silent',
|
||||
banner: {
|
||||
js: [
|
||||
"import { createRequire as __dshCreateRequire } from 'node:module';",
|
||||
"import { dirname as __dshDirname } from 'node:path';",
|
||||
"import { fileURLToPath as __dshFileURLToPath } from 'node:url';",
|
||||
'const require = __dshCreateRequire(import.meta.url);',
|
||||
'const __filename = __dshFileURLToPath(import.meta.url);',
|
||||
'const __dirname = __dshDirname(__filename);',
|
||||
].join('\n'),
|
||||
},
|
||||
});
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [outputPath], {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(JSON.parse(stdout), {
|
||||
handshakeTimeoutPulls: 1,
|
||||
closeDuringHandshakePulls: 1,
|
||||
immediateRestart: {
|
||||
callbackCount: 0,
|
||||
endpointPulls: 2,
|
||||
stateBeforeFinalClose: 'connecting',
|
||||
},
|
||||
duplicateStart: {
|
||||
acceptedConnections: 1,
|
||||
activeSocketsAfterClose: 0,
|
||||
activeSocketsBeforeClose: 1,
|
||||
callbackCount: 0,
|
||||
endpointPulls: 1,
|
||||
stateBeforeClose: 'connecting',
|
||||
},
|
||||
connectedRepeatStart: {
|
||||
acceptedConnections: 1,
|
||||
activeSocketsAfterClose: 0,
|
||||
activeSocketsBeforeClose: 1,
|
||||
callbackCount: 1,
|
||||
endpointPulls: 1,
|
||||
stateBeforeClose: 'connected',
|
||||
},
|
||||
failedRestart: {
|
||||
callbackCountBeforeRestart: 1,
|
||||
endpointPulls: 2,
|
||||
stateAfterRestart: 'connecting',
|
||||
},
|
||||
});
|
||||
assert.equal(stderr, '');
|
||||
}
|
||||
finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue