diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 3a282937..44aa1bf6 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -179,32 +179,44 @@ export class BrowserManager { if (!queue || queue.length === 0) return; this.processing.add(socialAccountId); - while (queue.length > 0) { - const item = queue.shift()!; - try { - const result = await this.runActionWithDeadline(item.action); - item.resolve(result); - } catch (error) { - item.reject(error instanceof Error ? error : new Error(String(error))); + try { + while (queue.length > 0) { + const item = queue.shift()!; + try { + const result = await this.runActionWithDeadline(item.action); + item.resolve(result); + } catch (error) { + item.reject(error instanceof Error ? error : new Error(String(error))); + } + } + } finally { + this.processing.delete(socialAccountId); + if ((this.queues.get(socialAccountId)?.length ?? 0) === 0) { + this.queues.delete(socialAccountId); } } - this.processing.delete(socialAccountId); } private runActionWithDeadline(action: BrowserAction): Promise { - // cancelAction reuses the same cancelledActionIds set + closes the active - // window, which runAction observes to return a cancelled result. Mirror - // that path on timeout so queued follow-ups are unblocked. return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; + settled = true; this.cancelledActionIds.add(action.id); const activeWindow = this.activeActionWindows.get(action.id); if (activeWindow && !activeWindow.isDestroyed()) { activeWindow.close(); } + this.activeActionWindows.delete(action.id); log.warn(`[browser] action ${action.id} exceeded ${ACTION_TIMEOUT_MS}ms timeout`); + resolve({ + success: false, + cancelled: true, + summary: "Action timed out and was cancelled.", + screenshotPath: null, + error: "Action timed out.", + }); }, ACTION_TIMEOUT_MS); runAction( diff --git a/tests/unit/browser-manager-timeout.test.ts b/tests/unit/browser-manager-timeout.test.ts new file mode 100644 index 00000000..4996d1b8 --- /dev/null +++ b/tests/unit/browser-manager-timeout.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { BrowserAction, BrowserActionResult } from "../../src/main/browser-manager"; +import { BrowserManager } from "../../src/main/browser-manager"; + +const mocks = vi.hoisted(() => ({ + runAction: vi.fn(), + fromPartition: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("electron", () => ({ + BrowserWindow: vi.fn(), + session: { + fromPartition: mocks.fromPartition, + }, +})); + +vi.mock("electron-log/main.js", () => ({ + default: { + info: vi.fn(), + warn: mocks.logWarn, + error: vi.fn(), + }, +})); + +vi.mock("../../src/main/browser/browser-action-executor", () => ({ + assertAllowedUrl: vi.fn(), + attachWindowSecurity: vi.fn(), + getPlatformRules: vi.fn(() => ({ + loginUrl: "https://x.com/i/flow/login", + cookieOrigins: [], + authenticatedCookieNames: [], + })), + waitForLoad: vi.fn(), + runAction: mocks.runAction, +})); + +const ACTION_TIMEOUT_MS = 5 * 60 * 1000; +const tempDirs: string[] = []; + +function createSession() { + return { + setPermissionCheckHandler: vi.fn(), + setPermissionRequestHandler: vi.fn(), + clearStorageData: vi.fn(), + clearCache: vi.fn(), + clearAuthCache: vi.fn(), + cookies: { + get: vi.fn(async () => []), + }, + }; +} + +function createAction(id: string): BrowserAction { + return { + id, + socialAccountId: "social-1", + platform: "twitter", + actionType: "post", + payload: { content: "hello" }, + }; +} + +function createActionWindow() { + const win = { + destroyed: false, + close: vi.fn(() => { + win.destroyed = true; + }), + isDestroyed: vi.fn(() => win.destroyed), + }; + return win; +} + +async function createProfileDir() { + const dir = await mkdtemp(join(tmpdir(), "agentcompany-browser-timeout-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + vi.useRealTimers(); + mocks.runAction.mockReset(); + mocks.fromPartition.mockReset(); + mocks.logWarn.mockReset(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("browser manager action timeout", () => { + it("settles a hung action and starts the next queued action", async () => { + const profileDir = await createProfileDir(); + const firstWindow = createActionWindow(); + const secondResult: BrowserActionResult = { + success: true, + cancelled: false, + summary: "second action completed", + screenshotPath: null, + error: null, + }; + + mocks.fromPartition.mockReturnValue(createSession()); + mocks.runAction + .mockImplementationOnce((action, _screenshotDir, _session, _cancelledActionIds, activeActionWindows) => { + activeActionWindows.set(action.id, firstWindow); + return new Promise(() => {}); + }) + .mockResolvedValueOnce(secondResult); + + vi.useFakeTimers(); + const manager = new BrowserManager(profileDir); + + const first = manager.executeAction(createAction("action-1")); + await Promise.resolve(); + const second = manager.executeAction(createAction("action-2")); + + expect(mocks.runAction).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(ACTION_TIMEOUT_MS); + await expect(first).resolves.toMatchObject({ + success: false, + cancelled: true, + summary: "Action timed out and was cancelled.", + screenshotPath: null, + error: "Action timed out.", + }); + await expect(second).resolves.toEqual(secondResult); + + expect(firstWindow.close).toHaveBeenCalledTimes(1); + expect(mocks.runAction).toHaveBeenCalledTimes(2); + }); +});