diff --git a/extensions/googlechat/src/channel.startup.test.ts b/extensions/googlechat/src/channel.startup.test.ts index 521cbb94c5f..11c46aa663a 100644 --- a/extensions/googlechat/src/channel.startup.test.ts +++ b/extensions/googlechat/src/channel.startup.test.ts @@ -1,6 +1,10 @@ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/googlechat"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { + abortStartedAccount, + expectPendingUntilAbort, + startAccountAndTrackLifecycle, +} from "../../test-utils/start-account-lifecycle.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ @@ -39,29 +43,25 @@ describe("googlechatPlugin gateway.startAccount", () => { }, }; - const patches: ChannelAccountSnapshot[] = []; - const abort = new AbortController(); - const task = googlechatPlugin.gateway!.startAccount!( - createStartAccountContext({ - account, - abortSignal: abort.signal, - statusPatchSink: (next) => patches.push({ ...next }), - }), - ); - let settled = false; - void task.then(() => { - settled = true; + const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: googlechatPlugin.gateway!.startAccount!, + account, }); - await vi.waitFor(() => { - expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce(); + await expectPendingUntilAbort({ + waitForStarted: () => + vi.waitFor(() => { + expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce(); + }), + isSettled, + abort, + task, + assertBeforeAbort: () => { + expect(unregister).not.toHaveBeenCalled(); + }, + assertAfterAbort: () => { + expect(unregister).toHaveBeenCalledOnce(); + }, }); - expect(settled).toBe(false); - expect(unregister).not.toHaveBeenCalled(); - - abort.abort(); - await task; - - expect(unregister).toHaveBeenCalledOnce(); expect(patches.some((entry) => entry.running === true)).toBe(true); expect(patches.some((entry) => entry.running === false)).toBe(true); }); diff --git a/extensions/irc/src/channel.startup.test.ts b/extensions/irc/src/channel.startup.test.ts index ef972f64c0e..7b4416d1892 100644 --- a/extensions/irc/src/channel.startup.test.ts +++ b/extensions/irc/src/channel.startup.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { + expectStopPendingUntilAbort, + startAccountAndTrackLifecycle, +} from "../../test-utils/start-account-lifecycle.js"; import type { ResolvedIrcAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ @@ -41,27 +44,20 @@ describe("ircPlugin gateway.startAccount", () => { config: {} as ResolvedIrcAccount["config"], }; - const abort = new AbortController(); - const task = ircPlugin.gateway!.startAccount!( - createStartAccountContext({ - account, - abortSignal: abort.signal, - }), - ); - let settled = false; - void task.then(() => { - settled = true; + const { abort, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: ircPlugin.gateway!.startAccount!, + account, }); - await vi.waitFor(() => { - expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce(); + await expectStopPendingUntilAbort({ + waitForStarted: () => + vi.waitFor(() => { + expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce(); + }), + isSettled, + abort, + task, + stop, }); - expect(settled).toBe(false); - expect(stop).not.toHaveBeenCalled(); - - abort.abort(); - await task; - - expect(stop).toHaveBeenCalledOnce(); }); }); diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts index 79b3cd77cd5..5fd0607e753 100644 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { + expectStopPendingUntilAbort, + startAccountAndTrackLifecycle, +} from "../../test-utils/start-account-lifecycle.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ @@ -40,28 +44,20 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => { it("keeps startAccount pending until abort, then stops the monitor", async () => { const stop = vi.fn(); hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); - const abort = new AbortController(); - - const task = nextcloudTalkPlugin.gateway!.startAccount!( - createStartAccountContext({ - account: buildAccount(), - abortSignal: abort.signal, - }), - ); - let settled = false; - void task.then(() => { - settled = true; + const { abort, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: nextcloudTalkPlugin.gateway!.startAccount!, + account: buildAccount(), }); - await vi.waitFor(() => { - expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + await expectStopPendingUntilAbort({ + waitForStarted: () => + vi.waitFor(() => { + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + }), + isSettled, + abort, + task, + stop, }); - expect(settled).toBe(false); - expect(stop).not.toHaveBeenCalled(); - - abort.abort(); - await task; - - expect(stop).toHaveBeenCalledOnce(); }); it("stops immediately when startAccount receives an already-aborted signal", async () => { diff --git a/extensions/test-utils/start-account-lifecycle.ts b/extensions/test-utils/start-account-lifecycle.ts new file mode 100644 index 00000000000..6ce1c734736 --- /dev/null +++ b/extensions/test-utils/start-account-lifecycle.ts @@ -0,0 +1,72 @@ +import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/test-utils"; +import { expect, vi } from "vitest"; +import { createStartAccountContext } from "./start-account-context.js"; + +export function startAccountAndTrackLifecycle(params: { + startAccount: (ctx: ChannelGatewayContext) => Promise; + account: TAccount; +}) { + const patches: ChannelAccountSnapshot[] = []; + const abort = new AbortController(); + const task = params.startAccount( + createStartAccountContext({ + account: params.account, + abortSignal: abort.signal, + statusPatchSink: (next) => patches.push({ ...next }), + }), + ); + let settled = false; + void task.then(() => { + settled = true; + }); + return { + abort, + patches, + task, + isSettled: () => settled, + }; +} + +export async function abortStartedAccount(params: { + abort: AbortController; + task: Promise; +}) { + params.abort.abort(); + await params.task; +} + +export async function expectPendingUntilAbort(params: { + waitForStarted: () => Promise; + isSettled: () => boolean; + abort: AbortController; + task: Promise; + assertBeforeAbort?: () => void; + assertAfterAbort?: () => void; +}) { + await params.waitForStarted(); + expect(params.isSettled()).toBe(false); + params.assertBeforeAbort?.(); + await abortStartedAccount({ abort: params.abort, task: params.task }); + params.assertAfterAbort?.(); +} + +export async function expectStopPendingUntilAbort(params: { + waitForStarted: () => Promise; + isSettled: () => boolean; + abort: AbortController; + task: Promise; + stop: ReturnType; +}) { + await expectPendingUntilAbort({ + waitForStarted: params.waitForStarted, + isSettled: params.isSettled, + abort: params.abort, + task: params.task, + assertBeforeAbort: () => { + expect(params.stop).not.toHaveBeenCalled(); + }, + assertAfterAbort: () => { + expect(params.stop).toHaveBeenCalledOnce(); + }, + }); +} diff --git a/extensions/zalo/src/channel.startup.test.ts b/extensions/zalo/src/channel.startup.test.ts index 65e413f0f4f..ea0718d29a2 100644 --- a/extensions/zalo/src/channel.startup.test.ts +++ b/extensions/zalo/src/channel.startup.test.ts @@ -1,6 +1,9 @@ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { + expectPendingUntilAbort, + startAccountAndTrackLifecycle, +} from "../../test-utils/start-account-lifecycle.js"; import type { ResolvedZaloAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ @@ -57,37 +60,28 @@ describe("zaloPlugin gateway.startAccount", () => { }), ); - const patches: ChannelAccountSnapshot[] = []; - const abort = new AbortController(); - const task = zaloPlugin.gateway!.startAccount!( - createStartAccountContext({ - account: buildAccount(), - abortSignal: abort.signal, - statusPatchSink: (next) => patches.push({ ...next }), - }), - ); - - let settled = false; - void task.then(() => { - settled = true; + const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: zaloPlugin.gateway!.startAccount!, + account: buildAccount(), }); - await vi.waitFor(() => { - expect(hoisted.probeZalo).toHaveBeenCalledOnce(); - expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce(); + await expectPendingUntilAbort({ + waitForStarted: () => + vi.waitFor(() => { + expect(hoisted.probeZalo).toHaveBeenCalledOnce(); + expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce(); + }), + isSettled, + abort, + task, }); - expect(settled).toBe(false); expect(patches).toContainEqual( expect.objectContaining({ accountId: "default", }), ); - - abort.abort(); - await task; - - expect(settled).toBe(true); + expect(isSettled()).toBe(true); expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith( expect.objectContaining({ token: "test-token",