fix(feishu): stabilize post-merge card workflows

This commit is contained in:
xmanrui 2026-08-24 17:56:06 +08:00
parent 9d1dc71794
commit bd9c58cf9e
19 changed files with 3372 additions and 456 deletions

File diff suppressed because it is too large Load diff

View file

@ -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' },

View file

@ -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;

View file

@ -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;
});

View file

@ -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,
},
],
});

View 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 });
}
});