Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6c44766
feat(dialog): give agents control over JavaScript dialogs
ArcueidMP Sep 30, 2026
d8304f3
docs(skill): keep dialog guidance within Windows byte budgets
ArcueidMP Sep 30, 2026
8a8998a
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 10, 2026
10a22f7
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 10, 2026
375b7a8
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 12, 2026
4d0f76e
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 14, 2026
b654697
ci: rerun browser video regression
ArcueidMP Oct 14, 2026
8ccb639
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 14, 2026
b6204f0
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 14, 2026
a959de6
test(dsh): use the bsk_session name in focus mapping tests
ArcueidMP Oct 14, 2026
be74cce
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 15, 2026
66de5ed
test(video): wait for frame presentation after seeking
ArcueidMP Oct 15, 2026
424845d
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 20, 2026
9fe77bd
test(video): wait for frame presentation in capture previews
ArcueidMP Oct 20, 2026
4de5e3a
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 21, 2026
0a8aa27
fix(cli): keep hyphen-leading option values out of the --json scan
ArcueidMP Oct 21, 2026
35b2bee
Merge upstream/main and preserve dialog control under shared cooperation
ArcueidMP Oct 22, 2026
a035f69
Merge upstream/main into codex/issue-359-js-dialogs
ArcueidMP Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.

## [Unreleased]

### Added

- CLI/Extension/DSH: inspect, accept, dismiss and fill native JavaScript dialogs
([#359](https://github.com/Tencent/BrowserSkill/issues/359)). Shared tabs leave
all four dialog types pending; decisions require the observed dialog ID and
respect human control. Outside shared control, confirm/prompt wait for a
decision and `session start --no-auto-dialog` also leaves alert/beforeunload
pending. Blocked commands report the dialog without replaying the original
browser action. Requires protocol 1.5 support.

### Fixed

- CLI: argument parsing failures now honor `--json`, returning an
Expand Down
63 changes: 60 additions & 3 deletions apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,62 @@ describe("ChromiumCdp", () => {
expect(enableCalls).toHaveLength(2);
});

it("interrupts a renderer domain enable when a pending dialog blocks it", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(7);
let finish!: () => void;
vi.mocked(api.sendCommand).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const result = cdp.send(7, "DOMSnapshot.enable");
const rejected = expect(result).rejects.toThrow("dialog is pending");
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
onEvent.fire({ tabId: 7 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "Pending",
});
await rejected;
finish();
await cdp.detach(7);
cdp.dispose();
});

it("keeps a dialog-blocked native read fenced beyond its ordinary read deadline", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(7);
vi.useFakeTimers();
let finish!: () => void;
vi.mocked(api.sendCommand).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
try {
const result = cdp.send(7, "DOMSnapshot.enable");
const rejected = expect(result).rejects.toThrow("dialog is pending");
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
onEvent.fire({ tabId: 7 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "Pending",
});
await rejected;
await vi.advanceTimersByTimeAsync(20_000);
expect(cdp.dialogExecutionPending(7)).toBe(true);
finish();
await vi.waitFor(() => expect(cdp.dialogExecutionPending(7)).toBe(false));
} finally {
await cdp.detach(7);
cdp.dispose();
vi.useRealTimers();
}
});

it("records javascriptDialogOpening and auto-accepts", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
Expand Down Expand Up @@ -1060,7 +1116,7 @@ describe("ChromiumCdp", () => {
cdp.trackSessionTab("bb22", 1);
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
type: "alert",
message: "before return",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
Expand All @@ -1070,7 +1126,7 @@ describe("ChromiumCdp", () => {
await cdp.releaseSessionTab("aa11", 1);
expect(cdp.isAttached(1)).toBe(true);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
type: "alert",
message: "after return",
});
await Promise.resolve();
Expand Down Expand Up @@ -1125,7 +1181,7 @@ describe("ChromiumCdp", () => {
await cdp.ensureAttached(1);
vi.mocked(api.sendCommand).mockClear();

onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await Promise.resolve();

expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
Expand All @@ -1146,6 +1202,7 @@ describe("ChromiumCdp", () => {
);

onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await vi.waitFor(() => expect(finishAccept).toBeTypeOf("function"));
await cdp.detach(1);
finishAccept();
await Promise.resolve();
Expand Down
150 changes: 150 additions & 0 deletions apps/extension/src/browser-driver/__tests__/javascript-dialogs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import { DialogPendingError, JavaScriptDialogs } from "../javascript-dialogs";

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}

describe("JavaScript dialog decisions", () => {
it.each([
"confirm",
"prompt",
])("keeps %s pending without answering for the agent", async (type) => {
const send = vi.fn(async () => {});
const dialogs = new JavaScriptDialogs(send, () => true);
await dialogs.opened({ tabId: 7 }, { type, message: "Decide", url: "https://example.test/" });
expect(send).not.toHaveBeenCalled();
expect(dialogs.pending(7)).toMatchObject({ type, message: "Decide", sequence: 1 });
expect(dialogs.since(7, 0)).toEqual([]);
});

it.each(["alert", "beforeunload"])("auto-accepts %s only when policy permits", async (type) => {
const send = vi.fn(async () => {});
const dialogs = new JavaScriptDialogs(send, () => true);
await dialogs.opened({ tabId: 7 }, { type, message: "auto" });
expect(send).toHaveBeenCalledWith({ tabId: 7 }, { accept: true });
expect(dialogs.since(7, 0)[0]).toMatchObject({ type, handled: "accepted" });
const manual = new JavaScriptDialogs(send, () => false);
send.mockClear();
await manual.opened({ tabId: 8 }, { type });
expect(manual.pending(8)?.type).toBe(type);
expect(send).not.toHaveBeenCalled();
});

it.each([
undefined,
"",
"Ada",
])("preserves omitted versus explicit prompt text: %s", async (text) => {
const send = vi.fn(async () => {});
const dialogs = new JavaScriptDialogs(send, () => true);
const defaultPrompt = "x".repeat(5000);
await dialogs.opened({ tabId: 7, sessionId: "iframe" }, { type: "prompt", defaultPrompt });
expect(dialogs.pending(7)?.default_prompt?.length).toBeLessThan(5000);
await dialogs.handle(7, dialogs.pending(7)!.id, true, text);
expect(send).toHaveBeenCalledWith(
{ tabId: 7, sessionId: "iframe" },
{
accept: true,
promptText: text ?? defaultPrompt,
},
);
expect(dialogs.pending(7)).toBeNull();
});

it("records manual closing and rejects a stale decision without answering the next dialog", async () => {
const send = vi.fn(async () => {});
const dialogs = new JavaScriptDialogs(send, () => true);
await dialogs.opened({ tabId: 7 }, { type: "confirm" });
const oldId = dialogs.pending(7)!.id;
dialogs.closed({ tabId: 7 }, { result: false });
expect(dialogs.since(7, 0)[0]?.handled).toBe("dismissed");
await dialogs.opened({ tabId: 7 }, { type: "prompt" });
await expect(dialogs.handle(7, oldId, true)).rejects.toThrow("changed");
expect(send).not.toHaveBeenCalled();
await dialogs.handle(7, dialogs.pending(7)!.id, false);
expect(send).toHaveBeenCalledWith({ tabId: 7 }, { accept: false });
});

it("returns promptly from blocked native calls, retains their execution fence, and never replays cleanup", async () => {
const native = deferred<unknown>();
const run = vi.fn(() => native.promise);
const dialogs = new JavaScriptDialogs(
async () => {
native.resolve({});
},
() => true,
);
const release = { type: "mouseReleased", button: "left" };
const waiting = dialogs.run({ tabId: 7 }, "Input.dispatchMouseEvent", release, run);
const rejected = expect(waiting).rejects.toBeInstanceOf(DialogPendingError);
await dialogs.opened({ tabId: 7 }, { type: "confirm" });
await rejected;
await expect(
dialogs.run({ tabId: 7 }, "Input.dispatchMouseEvent", release, run),
).rejects.toBeInstanceOf(DialogPendingError);
expect(run).toHaveBeenCalledOnce();
expect(dialogs.executionPending(7)).toBe(true);
await dialogs.handle(7, dialogs.pending(7)!.id, false);
expect(dialogs.executionPending(7)).toBe(false);
await expect(dialogs.run({ tabId: 7 }, "Runtime.evaluate", {}, async () => 42)).resolves.toBe(
42,
);
});

it("does not mistake a closed dialog for completion of its original script", async () => {
const native = deferred<unknown>();
const dialogs = new JavaScriptDialogs(
async () => {},
() => true,
);
const rejected = expect(
dialogs.run({ tabId: 7 }, "Runtime.evaluate", {}, () => native.promise),
).rejects.toBeInstanceOf(DialogPendingError);
await dialogs.opened({ tabId: 7 }, { type: "confirm" });
await rejected;
dialogs.closed({ tabId: 7 }, { result: true });
const another = vi.fn(async () => 42);
await expect(dialogs.run({ tabId: 7 }, "Runtime.evaluate", {}, another)).rejects.toThrow(
"still finishing",
);
expect(another).not.toHaveBeenCalled();
native.resolve({});
await vi.waitFor(() => expect(dialogs.executionPending(7)).toBe(false));
});

it("does not erase the next dialog when the previous handle response arrives late", async () => {
const response = deferred<unknown>();
const dialogs = new JavaScriptDialogs(
() => response.promise,
() => true,
);
await dialogs.opened({ tabId: 7 }, { type: "confirm", message: "first" });
const handling = dialogs.handle(7, dialogs.pending(7)!.id, true);
dialogs.closed({ tabId: 7 }, { result: true });
await dialogs.opened({ tabId: 7 }, { type: "prompt", message: "second" });
response.resolve({});
await handling;
expect(dialogs.pending(7)?.message).toBe("second");
expect(dialogs.since(7, 0)).toHaveLength(1);
});

it("clears state on detach and ignores a late automatic-policy lookup", async () => {
const policy = deferred<boolean>();
const send = vi.fn(async () => {});
const dialogs = new JavaScriptDialogs(send, () => policy.promise);
const opening = dialogs.opened({ tabId: 7 }, { type: "alert" });
dialogs.clear(7);
policy.resolve(true);
await opening;
expect(send).not.toHaveBeenCalled();
expect(dialogs.cursor(7)).toBe(0);
expect(dialogs.pending(7)).toBeNull();
});
});
Loading
Loading