From 9d17de6bdbe495796670e12bfc8b1ac8520c144d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 12 Mar 2026 20:01:54 +0000 Subject: [PATCH] Matrix: reuse shared clients for encrypted actions --- .../matrix/src/matrix/actions/client.test.ts | 109 ++++++++---------- .../matrix/src/matrix/client-bootstrap.ts | 37 ++---- .../matrix/client-resolver.test-helpers.ts | 24 +--- .../matrix/src/matrix/monitor/events.test.ts | 79 +++++++++++++ .../matrix/src/matrix/monitor/events.ts | 21 ++++ extensions/matrix/src/matrix/send.test.ts | 8 ++ .../matrix/src/matrix/send/client.test.ts | 59 ++++------ 7 files changed, 189 insertions(+), 148 deletions(-) diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts index 9a50a1f4ddd..52f13787b42 100644 --- a/extensions/matrix/src/matrix/actions/client.test.ts +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -11,9 +11,8 @@ const { loadConfigMock, getMatrixRuntimeMock, getActiveMatrixClientMock, - createMatrixClientMock, + resolveSharedMatrixClientMock, isBunRuntimeMock, - resolveMatrixAuthMock, resolveMatrixAuthContextMock, } = matrixClientResolverMocks; @@ -26,9 +25,8 @@ vi.mock("../active-client.js", () => ({ })); vi.mock("../client.js", () => ({ - createMatrixClient: createMatrixClientMock, + resolveSharedMatrixClient: resolveSharedMatrixClientMock, isBunRuntime: () => isBunRuntimeMock(), - resolveMatrixAuth: resolveMatrixAuthMock, resolveMatrixAuthContext: resolveMatrixAuthContextMock, })); @@ -56,42 +54,41 @@ describe("action client helpers", () => { vi.unstubAllEnvs(); }); - it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => { + it("reuses the shared client pool when no active monitor client is registered", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok"); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default"); - expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1); - expect(createMatrixClientMock).toHaveBeenCalledTimes(1); - expect(createMatrixClientMock).toHaveBeenCalledWith( - expect.objectContaining({ - autoBootstrapCrypto: false, - }), - ); - const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; - expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + expect(resolveSharedMatrixClientMock).toHaveBeenCalledTimes(1); + expect(resolveSharedMatrixClientMock).toHaveBeenCalledWith({ + cfg: {}, + timeoutMs: undefined, + accountId: "default", + }); + const sharedClient = await resolveSharedMatrixClientMock.mock.results[0]?.value; + expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(1); + expect(sharedClient.stop).not.toHaveBeenCalled(); expect(result).toBe("ok"); }); it("skips one-off room preparation when readiness is disabled", async () => { await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {}); - const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; - expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled(); - expect(oneOffClient.start).not.toHaveBeenCalled(); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + const sharedClient = await resolveSharedMatrixClientMock.mock.results[0]?.value; + expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled(); + expect(sharedClient.start).not.toHaveBeenCalled(); + expect(sharedClient.stop).not.toHaveBeenCalled(); }); it("starts one-off clients when started readiness is required", async () => { await withStartedActionClient({ accountId: "default" }, async () => {}); - const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; - expect(oneOffClient.start).toHaveBeenCalledTimes(1); - expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled(); - expect(oneOffClient.stop).not.toHaveBeenCalled(); - expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1); + const sharedClient = await resolveSharedMatrixClientMock.mock.results[0]?.value; + expect(sharedClient.start).toHaveBeenCalledTimes(1); + expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled(); + expect(sharedClient.stop).not.toHaveBeenCalled(); + expect(sharedClient.stopAndPersist).not.toHaveBeenCalled(); }); it("reuses active monitor client when available", async () => { @@ -104,8 +101,7 @@ describe("action client helpers", () => { }); expect(result).toBe("ok"); - expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); - expect(createMatrixClientMock).not.toHaveBeenCalled(); + expect(resolveSharedMatrixClientMock).not.toHaveBeenCalled(); expect(activeClient.stop).not.toHaveBeenCalled(); }); @@ -149,30 +145,14 @@ describe("action client helpers", () => { encryption: true, }, }); - resolveMatrixAuthMock.mockResolvedValue({ - accountId: "ops", - homeserver: "https://ops.example.org", - userId: "@ops:example.org", - accessToken: "ops-token", - password: undefined, - deviceId: "OPSDEVICE", - encryption: true, - }); - await withResolvedActionClient({}, async () => {}); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); - expect(resolveMatrixAuthMock).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "ops", - }), - ); - expect(createMatrixClientMock).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "ops", - homeserver: "https://ops.example.org", - }), - ); + expect(resolveSharedMatrixClientMock).toHaveBeenCalledWith({ + cfg: loadConfigMock(), + timeoutMs: undefined, + accountId: "ops", + }); }); it("uses explicit cfg instead of loading runtime config", async () => { @@ -191,29 +171,30 @@ describe("action client helpers", () => { cfg: explicitCfg, accountId: "ops", }); - expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ + expect(resolveSharedMatrixClientMock).toHaveBeenCalledWith({ cfg: explicitCfg, + timeoutMs: undefined, accountId: "ops", }); }); - it("stops one-off action clients after wrapped calls succeed", async () => { - const oneOffClient = createMockMatrixClient(); - createMatrixClientMock.mockResolvedValue(oneOffClient); + it("does not stop shared action clients after wrapped calls succeed", async () => { + const sharedClient = createMockMatrixClient(); + resolveSharedMatrixClientMock.mockResolvedValue(sharedClient); const result = await withResolvedActionClient({ accountId: "default" }, async (client) => { - expect(client).toBe(oneOffClient); + expect(client).toBe(sharedClient); return "ok"; }); expect(result).toBe("ok"); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); - expect(oneOffClient.stopAndPersist).not.toHaveBeenCalled(); + expect(sharedClient.stop).not.toHaveBeenCalled(); + expect(sharedClient.stopAndPersist).not.toHaveBeenCalled(); }); - it("still stops one-off action clients when the wrapped call throws", async () => { - const oneOffClient = createMockMatrixClient(); - createMatrixClientMock.mockResolvedValue(oneOffClient); + it("keeps shared action clients alive when the wrapped call throws", async () => { + const sharedClient = createMockMatrixClient(); + resolveSharedMatrixClientMock.mockResolvedValue(sharedClient); await expect( withResolvedActionClient({ accountId: "default" }, async () => { @@ -221,26 +202,26 @@ describe("action client helpers", () => { }), ).rejects.toThrow("boom"); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); - expect(oneOffClient.stopAndPersist).not.toHaveBeenCalled(); + expect(sharedClient.stop).not.toHaveBeenCalled(); + expect(sharedClient.stopAndPersist).not.toHaveBeenCalled(); }); it("resolves room ids before running wrapped room actions", async () => { - const oneOffClient = createMockMatrixClient(); - createMatrixClientMock.mockResolvedValue(oneOffClient); + const sharedClient = createMockMatrixClient(); + resolveSharedMatrixClientMock.mockResolvedValue(sharedClient); resolveMatrixRoomIdMock.mockResolvedValue("!room:example.org"); const result = await withResolvedRoomAction( "room:#ops:example.org", { accountId: "default" }, async (client, resolvedRoom) => { - expect(client).toBe(oneOffClient); + expect(client).toBe(sharedClient); return resolvedRoom; }, ); - expect(resolveMatrixRoomIdMock).toHaveBeenCalledWith(oneOffClient, "room:#ops:example.org"); + expect(resolveMatrixRoomIdMock).toHaveBeenCalledWith(sharedClient, "room:#ops:example.org"); expect(result).toBe("!room:example.org"); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + expect(sharedClient.stop).not.toHaveBeenCalled(); }); }); diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 3ddd615be6b..8a435e94ecc 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -1,12 +1,7 @@ import { getMatrixRuntime } from "../runtime.js"; import type { CoreConfig } from "../types.js"; import { getActiveMatrixClient } from "./active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveMatrixAuthContext, -} from "./client.js"; +import { isBunRuntime, resolveMatrixAuthContext, resolveSharedMatrixClient } from "./client.js"; import type { MatrixClient } from "./sdk.js"; type ResolvedRuntimeMatrixClient = { @@ -19,19 +14,19 @@ type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist"; type MatrixResolvedClientHook = ( client: MatrixClient, - context: { createdForOneOff: boolean }, + context: { preparedByDefault: boolean }, ) => Promise | void; async function ensureResolvedClientReadiness(params: { client: MatrixClient; readiness?: MatrixRuntimeClientReadiness; - createdForOneOff: boolean; + preparedByDefault: boolean; }): Promise { if (params.readiness === "started") { await params.client.start(); return; } - if (params.readiness === "prepared" || (!params.readiness && params.createdForOneOff)) { + if (params.readiness === "prepared" || (!params.readiness && params.preparedByDefault)) { await params.client.prepareForOneOff(); } } @@ -51,7 +46,7 @@ async function resolveRuntimeMatrixClient(opts: { }): Promise { ensureMatrixNodeRuntime(); if (opts.client) { - await opts.onResolved?.(opts.client, { createdForOneOff: false }); + await opts.onResolved?.(opts.client, { preparedByDefault: false }); return { client: opts.client, stopOnDone: false }; } @@ -62,27 +57,17 @@ async function resolveRuntimeMatrixClient(opts: { }); const active = getActiveMatrixClient(authContext.accountId); if (active) { - await opts.onResolved?.(active, { createdForOneOff: false }); + await opts.onResolved?.(active, { preparedByDefault: false }); return { client: active, stopOnDone: false }; } - const auth = await resolveMatrixAuth({ + const client = await resolveSharedMatrixClient({ cfg, + timeoutMs: opts.timeoutMs, accountId: authContext.accountId, }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - password: auth.password, - deviceId: auth.deviceId, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, - accountId: auth.accountId, - autoBootstrapCrypto: false, - }); - await opts.onResolved?.(client, { createdForOneOff: true }); - return { client, stopOnDone: true }; + await opts.onResolved?.(client, { preparedByDefault: true }); + return { client, stopOnDone: false }; } export async function resolveRuntimeMatrixClientWithReadiness(opts: { @@ -101,7 +86,7 @@ export async function resolveRuntimeMatrixClientWithReadiness(opts: { await ensureResolvedClientReadiness({ client, readiness: opts.readiness, - createdForOneOff: context.createdForOneOff, + preparedByDefault: context.preparedByDefault, }); }, }); diff --git a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts index e13fce252d0..9b4256131e2 100644 --- a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts +++ b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts @@ -5,9 +5,8 @@ type MatrixClientResolverMocks = { loadConfigMock: Mock<() => unknown>; getMatrixRuntimeMock: Mock<() => unknown>; getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>; - createMatrixClientMock: Mock<(...args: unknown[]) => Promise>; + resolveSharedMatrixClientMock: Mock<(...args: unknown[]) => Promise>; isBunRuntimeMock: Mock<() => boolean>; - resolveMatrixAuthMock: Mock<(...args: unknown[]) => Promise>; resolveMatrixAuthContextMock: Mock< (params: { cfg: unknown; accountId?: string | null }) => unknown >; @@ -17,9 +16,8 @@ export const matrixClientResolverMocks: MatrixClientResolverMocks = { loadConfigMock: vi.fn(() => ({})), getMatrixRuntimeMock: vi.fn(), getActiveMatrixClientMock: vi.fn(), - createMatrixClientMock: vi.fn(), + resolveSharedMatrixClientMock: vi.fn(), isBunRuntimeMock: vi.fn(() => false), - resolveMatrixAuthMock: vi.fn(), resolveMatrixAuthContextMock: vi.fn(), }; @@ -43,9 +41,8 @@ export function primeMatrixClientResolverMocks(params?: { loadConfigMock, getMatrixRuntimeMock, getActiveMatrixClientMock, - createMatrixClientMock, + resolveSharedMatrixClientMock, isBunRuntimeMock, - resolveMatrixAuthMock, resolveMatrixAuthContextMock, } = matrixClientResolverMocks; @@ -59,15 +56,6 @@ export function primeMatrixClientResolverMocks(params?: { deviceId: "DEVICE123", encryption: false, }; - const defaultAuth = { - accountId, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - password: undefined, - deviceId: "DEVICE123", - encryption: false, - }; const client = params?.client ?? createMockMatrixClient(); vi.clearAllMocks(); @@ -96,11 +84,7 @@ export function primeMatrixClientResolverMocks(params?: { }, }), ); - resolveMatrixAuthMock.mockResolvedValue({ - ...defaultAuth, - ...params?.auth, - }); - createMatrixClientMock.mockResolvedValue(client); + resolveSharedMatrixClientMock.mockResolvedValue(client); return client; } diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index fb7e28cde4e..349129d4843 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -7,6 +7,7 @@ import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void; +type FailedDecryptListener = (roomId: string, event: MatrixRawEvent, error: Error) => Promise; function getSentNoticeBody(sendMessage: ReturnType, index = 0): string { const calls = sendMessage.mock.calls as unknown[][]; @@ -92,6 +93,9 @@ function createHarness(params?: { logger, formatNativeDependencyHint, roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined, + failedDecryptListener: listeners.get("room.failed_decryption") as + | FailedDecryptListener + | undefined, }; } @@ -399,4 +403,79 @@ describe("registerMatrixMonitorEvents verification routing", () => { { roomId: "!room:example.org" }, ); }); + + it("adds self-device guidance when decrypt failures come from the same Matrix user", async () => { + const { logger, failedDecryptListener } = createHarness({ + accountId: "ops", + selfUserId: "@gumadeiras:matrix.example.org", + }); + if (!failedDecryptListener) { + throw new Error("room.failed_decryption listener was not registered"); + } + + await failedDecryptListener( + "!room:example.org", + { + event_id: "$enc-self", + sender: "@gumadeiras:matrix.example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ); + + expect(logger.warn).toHaveBeenNthCalledWith( + 1, + "Failed to decrypt message", + expect.objectContaining({ + roomId: "!room:example.org", + eventId: "$enc-self", + sender: "@gumadeiras:matrix.example.org", + senderMatchesOwnUser: true, + }), + ); + expect(logger.warn).toHaveBeenNthCalledWith( + 2, + "matrix: failed to decrypt a message from this same Matrix user. This usually means another Matrix device did not share the room key, or another OpenClaw runtime is using the same account. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.", + { + roomId: "!room:example.org", + eventId: "$enc-self", + sender: "@gumadeiras:matrix.example.org", + }, + ); + }); + + it("does not add self-device guidance for decrypt failures from another sender", async () => { + const { logger, failedDecryptListener } = createHarness({ + accountId: "ops", + selfUserId: "@gumadeiras:matrix.example.org", + }); + if (!failedDecryptListener) { + throw new Error("room.failed_decryption listener was not registered"); + } + + await failedDecryptListener( + "!room:example.org", + { + event_id: "$enc-other", + sender: "@alice:matrix.example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to decrypt message", + expect.objectContaining({ + roomId: "!room:example.org", + eventId: "$enc-other", + sender: "@alice:matrix.example.org", + senderMatchesOwnUser: false, + }), + ); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index f4718efc59f..09f88220a82 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -7,6 +7,14 @@ import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; import { createMatrixVerificationEventRouter } from "./verification-events.js"; +function formatMatrixSelfDecryptionHint(accountId: string): string { + return ( + "matrix: failed to decrypt a message from this same Matrix user. " + + "This usually means another Matrix device did not share the room key, or another OpenClaw runtime is using the same account. " + + `Check 'openclaw matrix verify status --verbose --account ${accountId}' and 'openclaw matrix devices list --account ${accountId}'.` + ); +} + export function registerMatrixMonitorEvents(params: { cfg: CoreConfig; client: MatrixClient; @@ -60,11 +68,24 @@ export function registerMatrixMonitorEvents(params: { client.on( "room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => { + const selfUserId = + typeof client.getUserId === "function" ? ((await client.getUserId()) ?? null) : null; + const sender = typeof event.sender === "string" ? event.sender : null; + const senderMatchesOwnUser = Boolean(selfUserId && sender && selfUserId === sender); logger.warn("Failed to decrypt message", { roomId, eventId: event.event_id, + sender, + senderMatchesOwnUser, error: error.message, }); + if (senderMatchesOwnUser) { + logger.warn(formatMatrixSelfDecryptionHint(auth.accountId), { + roomId, + eventId: event.event_id, + sender, + }); + } logVerboseMessage( `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, ); diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 66cab1d9bdd..5b0f9ff8a07 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -54,6 +54,10 @@ const makeClient = () => { getEvent, uploadContent, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + prepareForOneOff: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(() => undefined), + stopAndPersist: vi.fn(async () => undefined), } as unknown as import("./sdk.js").MatrixClient; return { client, sendMessage, sendEvent, getEvent, uploadContent }; }; @@ -464,6 +468,10 @@ describe("sendTypingMatrix", () => { const setTyping = vi.fn().mockResolvedValue(undefined); const client = { setTyping, + prepareForOneOff: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(() => undefined), + stopAndPersist: vi.fn(async () => undefined), } as unknown as import("./sdk.js").MatrixClient; await sendTypingMatrix("room:!room:example", true, undefined, client); diff --git a/extensions/matrix/src/matrix/send/client.test.ts b/extensions/matrix/src/matrix/send/client.test.ts index 6a282dc6eb0..ce3b13263f1 100644 --- a/extensions/matrix/src/matrix/send/client.test.ts +++ b/extensions/matrix/src/matrix/send/client.test.ts @@ -8,9 +8,8 @@ import { const { getMatrixRuntimeMock, getActiveMatrixClientMock, - createMatrixClientMock, + resolveSharedMatrixClientMock, isBunRuntimeMock, - resolveMatrixAuthMock, resolveMatrixAuthContextMock, } = matrixClientResolverMocks; @@ -19,9 +18,8 @@ vi.mock("../active-client.js", () => ({ })); vi.mock("../client.js", () => ({ - createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), + resolveSharedMatrixClient: (...args: unknown[]) => resolveSharedMatrixClientMock(...args), isBunRuntime: () => isBunRuntimeMock(), - resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args), resolveMatrixAuthContext: resolveMatrixAuthContextMock, })); @@ -45,22 +43,21 @@ describe("withResolvedMatrixClient", () => { vi.unstubAllEnvs(); }); - it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => { + it("reuses the shared client pool when no active monitor client is registered", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); const result = await withResolvedMatrixClient({ accountId: "default" }, async () => "ok"); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default"); - expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1); - expect(createMatrixClientMock).toHaveBeenCalledTimes(1); - expect(createMatrixClientMock).toHaveBeenCalledWith( - expect.objectContaining({ - autoBootstrapCrypto: false, - }), - ); - const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; - expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + expect(resolveSharedMatrixClientMock).toHaveBeenCalledTimes(1); + expect(resolveSharedMatrixClientMock).toHaveBeenCalledWith({ + cfg: {}, + timeoutMs: undefined, + accountId: "default", + }); + const sharedClient = await resolveSharedMatrixClientMock.mock.results[0]?.value; + expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(1); + expect(sharedClient.stop).not.toHaveBeenCalled(); expect(result).toBe("ok"); }); @@ -74,8 +71,7 @@ describe("withResolvedMatrixClient", () => { }); expect(result).toBe("ok"); - expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); - expect(createMatrixClientMock).not.toHaveBeenCalled(); + expect(resolveSharedMatrixClientMock).not.toHaveBeenCalled(); expect(activeClient.stop).not.toHaveBeenCalled(); }); @@ -86,28 +82,14 @@ describe("withResolvedMatrixClient", () => { accountId: "ops", resolved: {}, }); - resolveMatrixAuthMock.mockResolvedValue({ - accountId: "ops", - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - password: undefined, - deviceId: "DEVICE123", - encryption: false, - }); - await withResolvedMatrixClient({}, async () => {}); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); - expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ + expect(resolveSharedMatrixClientMock).toHaveBeenCalledWith({ cfg: {}, + timeoutMs: undefined, accountId: "ops", }); - expect(createMatrixClientMock).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "ops", - }), - ); }); it("uses explicit cfg instead of loading runtime config", async () => { @@ -126,15 +108,16 @@ describe("withResolvedMatrixClient", () => { cfg: explicitCfg, accountId: "ops", }); - expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ + expect(resolveSharedMatrixClientMock).toHaveBeenCalledWith({ cfg: explicitCfg, + timeoutMs: undefined, accountId: "ops", }); }); - it("still stops one-off matrix clients when wrapped sends fail", async () => { - const oneOffClient = createMockMatrixClient(); - createMatrixClientMock.mockResolvedValue(oneOffClient); + it("keeps shared matrix clients alive when wrapped sends fail", async () => { + const sharedClient = createMockMatrixClient(); + resolveSharedMatrixClientMock.mockResolvedValue(sharedClient); await expect( withResolvedMatrixClient({ accountId: "default" }, async () => { @@ -142,6 +125,6 @@ describe("withResolvedMatrixClient", () => { }), ).rejects.toThrow("boom"); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + expect(sharedClient.stop).not.toHaveBeenCalled(); }); });