|
| 1 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 2 | +import { tmpdir } from "node:os"; |
| 3 | +import { join } from "node:path"; |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 5 | +import { LocalRunner } from "./runner.js"; |
| 6 | + |
| 7 | +/** |
| 8 | + * The wiring half of #641, against a REAL Chrome and a REAL closed page. |
| 9 | + * |
| 10 | + * `handoff-status.test.ts` pins the decision; this pins that `browserHandoffStatus` asks it. |
| 11 | + * The two are separate because a poll that reasons correctly in a pure function and still |
| 12 | + * shortcuts in the method is the bug — the method's own `if (page.isClosed()) return { solved: |
| 13 | + * true }` sat below a comment forbidding exactly that, and no test held the ground. |
| 14 | + */ |
| 15 | +describe("browserHandoffStatus when the page the human was handed is gone (#641)", () => { |
| 16 | + let dir: string; |
| 17 | + let runner: LocalRunner; |
| 18 | + |
| 19 | + beforeEach(() => { |
| 20 | + dir = mkdtempSync(join(tmpdir(), "pags-handoff-")); |
| 21 | + runner = new LocalRunner({ host: "127.0.0.1", port: 0, dataDir: dir, headless: true }); |
| 22 | + }); |
| 23 | + |
| 24 | + afterEach(async () => { |
| 25 | + await runner.close(); |
| 26 | + rmSync(dir, { recursive: true, force: true }); |
| 27 | + }, 30_000); |
| 28 | + |
| 29 | + /** |
| 30 | + * Register a real takeover on a real page, then take the browser away underneath it. |
| 31 | + * |
| 32 | + * `close()` shuts the browser context, which closes every page in it, and deliberately |
| 33 | + * leaves `takeovers` alone — so this produces exactly the state the bug is about: a live |
| 34 | + * handoff session holding a dead page. That is the real shape of it too: the human closes |
| 35 | + * the tab, the site replaces it in a popup flow, or Chrome dies mid-handoff. |
| 36 | + */ |
| 37 | + async function handoffThenLoseTheBrowser(reason: string, label: string): Promise<string> { |
| 38 | + const task = runner.createTask({ type: "job.apply_agent", input: {} }); |
| 39 | + const handoff = await runner.browserHandoff(task.id, label, reason); |
| 40 | + expect(handoff.screenshotBase64).toMatch(/^data:image\/jpeg;base64,/); |
| 41 | + expect(runner.listTakeovers()).toContain(task.id); |
| 42 | + expect(runner.store.getTask(task.id)?.status).toBe("needs_human"); |
| 43 | + return task.id; |
| 44 | + } |
| 45 | + |
| 46 | + it("reports a CAPTCHA handoff unsolved — it is no less unsolved for the tab being shut", async () => { |
| 47 | + const taskId = await handoffThenLoseTheBrowser("challenge", "recaptcha"); |
| 48 | + await runner.close(); |
| 49 | + // Was `{ solved: true }`, and the workflow acted on it: it recorded the page as a |
| 50 | + // solved challenge, which mutes captcha re-detection there for the rest of the round. |
| 51 | + expect(await runner.browserHandoffStatus(taskId)).toEqual({ solved: false, challenge: null }); |
| 52 | + }, 120_000); |
| 53 | + |
| 54 | + it("reports a STUCK handoff unsolved, even after the human pressed Resume", async () => { |
| 55 | + const taskId = await handoffThenLoseTheBrowser("stuck", "Save and Continue"); |
| 56 | + // The human says they did the step — `resumeTakeover` sets humanDone for a |
| 57 | + // workflow-driven task without completing it. |
| 58 | + expect(await runner.resumeTakeover(taskId)).toMatchObject({ resumed: true }); |
| 59 | + await runner.close(); |
| 60 | + // Still not resumable: the page that step happened in is gone, so the brain would be |
| 61 | + // handed a fresh blank tab. Unsolved times out into "not resolved in time" → |
| 62 | + // `escalated` → "Needs you", which the owner can see and retry. |
| 63 | + expect(await runner.browserHandoffStatus(taskId)).toEqual({ solved: false, challenge: null }); |
| 64 | + }, 120_000); |
| 65 | + |
| 66 | + it("still delivers a NEEDS_INPUT value supplied after the tab was gone", async () => { |
| 67 | + const taskId = await handoffThenLoseTheBrowser("needs_input", "Years of Python experience"); |
| 68 | + await runner.close(); |
| 69 | + // Nothing has been supplied yet: unsolved, and — the part the old shortcut got wrong |
| 70 | + // in the other direction — carrying no value for the workflow to save. |
| 71 | + expect(await runner.browserHandoffStatus(taskId)).toMatchObject({ solved: false, value: undefined }); |
| 72 | + // The value channel never involved the page, so it still works. |
| 73 | + expect(runner.browserSubmitInput(taskId, "3 years")).toEqual({ ok: true }); |
| 74 | + expect(await runner.browserHandoffStatus(taskId)).toEqual({ solved: true, challenge: null, value: "3 years" }); |
| 75 | + }, 120_000); |
| 76 | +}); |
0 commit comments