mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb633d68e | ||
|
|
6d181105f2 | ||
|
|
498f8fb4a7 | ||
|
|
cef59137e4 | ||
|
|
f70978bfcf |
@@ -254,6 +254,69 @@ describe("ChromiumCdp", () => {
|
|||||||
expect(cdp.isAttached(7)).toBe(false);
|
expect(cdp.isAttached(7)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
])("waits for detach before reconnecting a returned tab (detach rejects: %s)", async (rejectDetach) => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
const attached = new Set<number>();
|
||||||
|
let finishDetach!: () => void;
|
||||||
|
vi.mocked(api.attach).mockImplementation(async ({ tabId }) => {
|
||||||
|
if (attached.has(tabId!)) throw new Error("Another debugger is already attached");
|
||||||
|
attached.add(tabId!);
|
||||||
|
});
|
||||||
|
vi.mocked(api.detach).mockImplementation(async ({ tabId }) => {
|
||||||
|
attached.delete(tabId!);
|
||||||
|
});
|
||||||
|
vi.mocked(api.detach).mockImplementationOnce(
|
||||||
|
({ tabId }) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
finishDetach = () => {
|
||||||
|
attached.delete(tabId!);
|
||||||
|
if (rejectDetach) reject(new Error("tab already detached"));
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
cdp.trackSessionTab("aa11", 7);
|
||||||
|
await cdp.ensureAttached(7);
|
||||||
|
const returning = cdp.releaseSessionTab("aa11", 7);
|
||||||
|
await vi.waitFor(() => expect(api.detach).toHaveBeenCalledOnce());
|
||||||
|
let duplicateDetachFinished = false;
|
||||||
|
const duplicateDetach = cdp.detach(7).then(() => {
|
||||||
|
duplicateDetachFinished = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
cdp.trackSessionTab("bb22", 7);
|
||||||
|
// Register rejection handlers immediately; the pre-fix driver rejects
|
||||||
|
// both commands while the browser still has the previous attachment.
|
||||||
|
const pending = Promise.allSettled([
|
||||||
|
cdp.send(7, "Runtime.evaluate", { expression: "document.title" }),
|
||||||
|
cdp.send(7, "DOM.getDocument"),
|
||||||
|
]);
|
||||||
|
await cdp.send(8, "DOM.getDocument");
|
||||||
|
const finishedEarly = duplicateDetachFinished;
|
||||||
|
finishDetach();
|
||||||
|
await Promise.all([returning, duplicateDetach]);
|
||||||
|
|
||||||
|
expect(await pending).toEqual([
|
||||||
|
{ status: "fulfilled", value: { ok: true } },
|
||||||
|
{ status: "fulfilled", value: { ok: true } },
|
||||||
|
]);
|
||||||
|
expect(finishedEarly).toBe(false);
|
||||||
|
expect(vi.mocked(api.attach).mock.calls.filter(([target]) => target.tabId === 7)).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(cdp.isAttached(7)).toBe(true);
|
||||||
|
expect(cdp.isAttached(8)).toBe(true);
|
||||||
|
await cdp.detachSession("bb22");
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||||
|
expect(cdp.isAttached(7)).toBe(false);
|
||||||
|
expect(attached.has(7)).toBe(false);
|
||||||
|
expect(cdp.isAttached(8)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("detachAll() iterates every cached tab", async () => {
|
it("detachAll() iterates every cached tab", async () => {
|
||||||
const { api } = fakeApi();
|
const { api } = fakeApi();
|
||||||
const cdp = new ChromiumCdp(api);
|
const cdp = new ChromiumCdp(api);
|
||||||
@@ -651,6 +714,155 @@ describe("ChromiumCdp", () => {
|
|||||||
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("releases only the returning session's claim on a tab", async () => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
await cdp.ensureAttached(2);
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
cdp.trackSessionTab("bb22", 1);
|
||||||
|
cdp.trackSessionTab("aa11", 2);
|
||||||
|
|
||||||
|
await cdp.releaseSessionTab("unknown", 1);
|
||||||
|
await cdp.releaseSessionTab("aa11", 1);
|
||||||
|
expect(api.detach).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await cdp.releaseSessionTab("bb22", 1);
|
||||||
|
await cdp.releaseSessionTab("bb22", 1);
|
||||||
|
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
|
||||||
|
expect(cdp.isAttached(2)).toBe(true);
|
||||||
|
|
||||||
|
await cdp.detachSession("aa11");
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
let finishAttach!: () => void;
|
||||||
|
vi.mocked(api.attach).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishAttach = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
const attaching = cdp.ensureAttached(1);
|
||||||
|
const releasing = cdp.releaseSessionTab("aa11", 1);
|
||||||
|
if (newOwner) cdp.trackSessionTab("bb22", 1);
|
||||||
|
finishAttach();
|
||||||
|
await Promise.all([attaching, releasing]);
|
||||||
|
|
||||||
|
expect(cdp.isAttached(1)).toBe(newOwner);
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
let controlled = true;
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
cdp.trackSessionTab("bb22", 1);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "before return",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
|
||||||
|
vi.mocked(api.sendCommand).mockClear();
|
||||||
|
|
||||||
|
controlled = false;
|
||||||
|
await cdp.releaseSessionTab("aa11", 1);
|
||||||
|
expect(cdp.isAttached(1)).toBe(true);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "after return",
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
|
||||||
|
|
||||||
|
controlled = true;
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "alert",
|
||||||
|
message: "borrowed again",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
|
||||||
|
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
{ tabId: 1 },
|
||||||
|
"Page.handleJavaScriptDialog",
|
||||||
|
{ accept: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores dialog events after detach, including a pending eligibility check", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
let resolveEligibility!: (value: boolean) => void;
|
||||||
|
const shouldAutoAcceptDialog = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolveEligibility = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
await cdp.detach(1);
|
||||||
|
resolveEligibility(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
|
||||||
|
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"Page.handleJavaScriptDialog",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
const shouldAutoAcceptDialog = vi.fn(async () => {
|
||||||
|
throw new Error("tab is gone");
|
||||||
|
});
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
vi.mocked(api.sendCommand).mockClear();
|
||||||
|
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore dialog state when an acceptance completes after detach", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
let finishAccept!: () => void;
|
||||||
|
vi.mocked(api.sendCommand).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishAccept = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
await cdp.detach(1);
|
||||||
|
finishAccept();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
expect(cdp.dialogCursor(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("detachSession only detaches tabs no other session owns", async () => {
|
it("detachSession only detaches tabs no other session owns", async () => {
|
||||||
const { api } = fakeApi();
|
const { api } = fakeApi();
|
||||||
const cdp = new ChromiumCdp(api);
|
const cdp = new ChromiumCdp(api);
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export class ChromiumCdp {
|
|||||||
private readonly api: CdpDebuggerApi;
|
private readonly api: CdpDebuggerApi;
|
||||||
private readonly attachedTabs = new Set<number>();
|
private readonly attachedTabs = new Set<number>();
|
||||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||||
|
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly tabOwners = new Map<number, Set<string>>();
|
private readonly tabOwners = new Map<number, Set<string>>();
|
||||||
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
|
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
|
||||||
private readonly dialogSequences = new Map<number, number>();
|
private readonly dialogSequences = new Map<number, number>();
|
||||||
@@ -182,7 +183,13 @@ export class ChromiumCdp {
|
|||||||
private networkSubscription: { dispose(): void } | null = null;
|
private networkSubscription: { dispose(): void } | null = null;
|
||||||
private frameTargetSubscription: { dispose(): void } | null = null;
|
private frameTargetSubscription: { dispose(): void } | null = null;
|
||||||
|
|
||||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
constructor(
|
||||||
|
api: CdpDebuggerApi = chromeDebuggerApi,
|
||||||
|
private readonly options: {
|
||||||
|
/** CDP observation alone does not authorize dismissing user dialogs. */
|
||||||
|
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.bindAutoDetach();
|
this.bindAutoDetach();
|
||||||
this.bindDialogHandler();
|
this.bindDialogHandler();
|
||||||
@@ -193,6 +200,10 @@ export class ChromiumCdp {
|
|||||||
|
|
||||||
/** Attach to `tabId` if we haven't already in this driver. */
|
/** Attach to `tabId` if we haven't already in this driver. */
|
||||||
async ensureAttached(tabId: number): Promise<void> {
|
async ensureAttached(tabId: number): Promise<void> {
|
||||||
|
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||||
|
// New observers must wait before opening the next connection to that tab.
|
||||||
|
const detaching = this.detachInFlight.get(tabId);
|
||||||
|
if (detaching) await detaching;
|
||||||
if (this.attachedTabs.has(tabId)) return;
|
if (this.attachedTabs.has(tabId)) return;
|
||||||
const existing = this.attachInFlight.get(tabId);
|
const existing = this.attachInFlight.get(tabId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -425,6 +436,11 @@ export class ChromiumCdp {
|
|||||||
|
|
||||||
/** Detach if attached; never throws. */
|
/** Detach if attached; never throws. */
|
||||||
async detach(tabId: number): Promise<void> {
|
async detach(tabId: number): Promise<void> {
|
||||||
|
const existing = this.detachInFlight.get(tabId);
|
||||||
|
if (existing) {
|
||||||
|
await existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.attachInFlight.delete(tabId);
|
this.attachInFlight.delete(tabId);
|
||||||
if (!this.attachedTabs.has(tabId)) return;
|
if (!this.attachedTabs.has(tabId)) return;
|
||||||
this.attachedTabs.delete(tabId);
|
this.attachedTabs.delete(tabId);
|
||||||
@@ -432,13 +448,19 @@ export class ChromiumCdp {
|
|||||||
this.clearConsoleState(tabId);
|
this.clearConsoleState(tabId);
|
||||||
this.clearNetworkState(tabId);
|
this.clearNetworkState(tabId);
|
||||||
this.clearFrameState(tabId);
|
this.clearFrameState(tabId);
|
||||||
try {
|
const detach = (async () => {
|
||||||
await this.api.detach({ tabId });
|
try {
|
||||||
} catch (err) {
|
await this.api.detach({ tabId });
|
||||||
// Tab may already be gone — Chrome auto-detaches on close. Log
|
} catch (err) {
|
||||||
// at debug so production builds aren't noisy.
|
// Tab may already be gone — Chrome auto-detaches on close. Log
|
||||||
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
|
// at debug so production builds aren't noisy.
|
||||||
}
|
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
this.detachInFlight.delete(tabId);
|
||||||
|
});
|
||||||
|
this.detachInFlight.set(tabId, detach);
|
||||||
|
await detach;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
|
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
|
||||||
@@ -453,6 +475,17 @@ export class ChromiumCdp {
|
|||||||
this.tabOwners.set(tabId, owners);
|
this.tabOwners.set(tabId, owners);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release one session's claim, preserving attachments still used by another. */
|
||||||
|
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
|
||||||
|
const owners = this.tabOwners.get(tabId);
|
||||||
|
if (!owners?.delete(sessionId) || owners.size > 0) return;
|
||||||
|
this.tabOwners.delete(tabId);
|
||||||
|
// An observation may still be attaching when the tab is returned. Wait
|
||||||
|
// for it so detach cannot miss the attachment or remove a new owner's claim.
|
||||||
|
await this.attachInFlight.get(tabId)?.catch(() => {});
|
||||||
|
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
||||||
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -639,13 +672,23 @@ export class ChromiumCdp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const parsed = parseDialogOpeningParams(params);
|
const parsed = parseDialogOpeningParams(params);
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
this.options.shouldAutoAcceptDialog &&
|
||||||
|
!(await this.options.shouldAutoAcceptDialog(tabId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The tab may have been returned while its current scope was checked.
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
||||||
if (parsed.type === "prompt") {
|
if (parsed.type === "prompt") {
|
||||||
handleParams.promptText = parsed.defaultPrompt ?? "";
|
handleParams.promptText = parsed.defaultPrompt ?? "";
|
||||||
}
|
}
|
||||||
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
||||||
this.dialogSequences.set(tabId, sequence);
|
this.dialogSequences.set(tabId, sequence);
|
||||||
this.appendDialog(tabId, {
|
this.appendDialog(tabId, {
|
||||||
@@ -835,15 +878,9 @@ export class ChromiumCdp {
|
|||||||
|
|
||||||
/** Detach tabs only when no other live session has claimed them. */
|
/** Detach tabs only when no other live session has claimed them. */
|
||||||
async detachSession(sessionId: string): Promise<void> {
|
async detachSession(sessionId: string): Promise<void> {
|
||||||
const tabsToDetach: number[] = [];
|
await Promise.all(
|
||||||
for (const [tabId, owners] of this.tabOwners) {
|
Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
|
||||||
owners.delete(sessionId);
|
);
|
||||||
if (owners.size === 0) {
|
|
||||||
this.tabOwners.delete(tabId);
|
|
||||||
tabsToDetach.push(tabId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ export default defineBackground(() => {
|
|||||||
const controller = new ConnectionController();
|
const controller = new ConnectionController();
|
||||||
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
||||||
const sessions = new SessionManager();
|
const sessions = new SessionManager();
|
||||||
const cdp = new ChromiumCdp();
|
const cdp = new ChromiumCdp(undefined, {
|
||||||
|
shouldAutoAcceptDialog: async (tabId) => {
|
||||||
|
const tab = await chrome.tabs.get(tabId);
|
||||||
|
return sessions.findByWindowId(tab.windowId) !== null;
|
||||||
|
},
|
||||||
|
});
|
||||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||||
let overlayGeneration = 0;
|
let overlayGeneration = 0;
|
||||||
const controlModes = new Map<string, OverlayMode>();
|
const controlModes = new Map<string, OverlayMode>();
|
||||||
|
|||||||
@@ -747,6 +747,135 @@ describe("ToolDispatcher", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ tab_id: 7, code: "not_found" },
|
||||||
|
{ tab_id: undefined, code: "invalid_params" },
|
||||||
|
])("keeps hover when tab_return is rejected with $code", async ({ tab_id, code }) => {
|
||||||
|
const sendMessage = vi.fn(async () => undefined);
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
sendMessage,
|
||||||
|
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true })),
|
||||||
|
query: vi.fn(async () => [{ id: 7, windowId: 100, active: true }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sessions.start("aa11");
|
||||||
|
const cdp = {
|
||||||
|
send: vi.fn(async <T>() => ({}) as T),
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
cdp: cdp as unknown as TestDispatcherCdp,
|
||||||
|
});
|
||||||
|
const helpers = dispatcher as unknown as {
|
||||||
|
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||||
|
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||||
|
withHoverReassert: <T>(
|
||||||
|
params: { session_id: string; tab_id?: number },
|
||||||
|
work: () => Promise<T>,
|
||||||
|
) => Promise<T>;
|
||||||
|
};
|
||||||
|
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
|
||||||
|
await helpers.setHoverBypass("aa11", 7, true);
|
||||||
|
dispatcher.start();
|
||||||
|
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id }));
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(sent[0]).toMatchObject({ error: { code } });
|
||||||
|
expect(cdp.releaseSessionTab).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessage).not.toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
|
||||||
|
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||||
|
expect(cdp.send).toHaveBeenCalledExactlyOnceWith(7, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
dispatcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the returned tab's hover before moving it and leaves other tabs alone", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => undefined);
|
||||||
|
const move = vi.fn(async () => {
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
|
||||||
|
return { id: 7, windowId: 200, index: 4 };
|
||||||
|
});
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: { sendMessage, move },
|
||||||
|
windows: { get: vi.fn(async () => ({ id: 200 })) },
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await sessions.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const cdp = {
|
||||||
|
send: vi.fn(async <T>() => ({}) as T),
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
cdp: cdp as unknown as TestDispatcherCdp,
|
||||||
|
});
|
||||||
|
const helpers = dispatcher as unknown as {
|
||||||
|
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||||
|
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||||
|
withHoverReassert: <T>(
|
||||||
|
params: { session_id: string; tab_id?: number },
|
||||||
|
work: () => Promise<T>,
|
||||||
|
) => Promise<T>;
|
||||||
|
};
|
||||||
|
for (const [sessionId, tabId] of [
|
||||||
|
["aa11", 7],
|
||||||
|
["aa11", 8],
|
||||||
|
["bb22", 9],
|
||||||
|
] as const) {
|
||||||
|
helpers.rememberHover(sessionId, { tab_id: tabId, x: 10, y: 20 });
|
||||||
|
await helpers.setHoverBypass(sessionId, tabId, true);
|
||||||
|
}
|
||||||
|
dispatcher.start();
|
||||||
|
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id: 7 }));
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(sent[0]).toMatchObject({ result: { tab_id: 7, returned_to_window_id: 200 } });
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 7);
|
||||||
|
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||||
|
expect(cdp.send).not.toHaveBeenCalled();
|
||||||
|
for (const [sessionId, tabId] of [
|
||||||
|
["aa11", 8],
|
||||||
|
["bb22", 9],
|
||||||
|
] as const) {
|
||||||
|
expect(sendMessage).not.toHaveBeenCalledWith(
|
||||||
|
tabId,
|
||||||
|
expect.objectContaining({ enabled: false }),
|
||||||
|
);
|
||||||
|
await helpers.withHoverReassert({ session_id: sessionId, tab_id: tabId }, async () => ({}));
|
||||||
|
expect(cdp.send).toHaveBeenLastCalledWith(tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("transfers hover overlay bypass ownership between sessions", async () => {
|
it("transfers hover overlay bypass ownership between sessions", async () => {
|
||||||
vi.stubGlobal("chrome", {
|
vi.stubGlobal("chrome", {
|
||||||
tabs: {
|
tabs: {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
|
import type { CdpRunner } from "@/tools/shared";
|
||||||
|
import { handleFill } from "../interaction";
|
||||||
|
|
||||||
|
interface ScriptParams {
|
||||||
|
functionDeclaration: string;
|
||||||
|
arguments?: Array<{ value: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(markup = '<input value="old">') {
|
||||||
|
document.body.innerHTML = markup;
|
||||||
|
const element = document.body.firstElementChild as HTMLInputElement;
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||||
|
const manager = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: async () => 100,
|
||||||
|
remove: async () => {},
|
||||||
|
ensureActiveTab: async () => 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await manager.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 12, { tabId: 4 });
|
||||||
|
const script = vi.fn(async (params: ScriptParams): Promise<unknown> => {
|
||||||
|
try {
|
||||||
|
const fn = new Function(`return (${params.functionDeclaration})`)();
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value: await fn.apply(
|
||||||
|
element,
|
||||||
|
(params.arguments ?? []).map((arg) => arg.value),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { exceptionDetails: { text: "test page script failed" } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const insert = vi.fn(async (text: string) => {
|
||||||
|
const focused = document.activeElement as HTMLInputElement;
|
||||||
|
if (focused.readOnly || focused.disabled) return;
|
||||||
|
const start = focused.selectionStart ?? focused.value.length;
|
||||||
|
const end = focused.selectionEnd ?? start;
|
||||||
|
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
|
||||||
|
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
|
||||||
|
focused.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
const release = vi.fn(async () => ({}));
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
switch (method) {
|
||||||
|
case "DOM.describeNode":
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
nodeName: element.tagName,
|
||||||
|
attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "DOM.scrollIntoViewIfNeeded":
|
||||||
|
return {};
|
||||||
|
case "DOM.focus":
|
||||||
|
element.focus();
|
||||||
|
return {};
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
return { object: { objectId: "fill-target" } };
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
return script(params as ScriptParams);
|
||||||
|
case "Input.insertText":
|
||||||
|
await insert((params as { text: string }).text);
|
||||||
|
return {};
|
||||||
|
case "Runtime.releaseObject":
|
||||||
|
return release();
|
||||||
|
default:
|
||||||
|
throw new Error(`unexpected ${method}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
script,
|
||||||
|
insert,
|
||||||
|
release,
|
||||||
|
send,
|
||||||
|
fill: (value = "hello", clearBefore = true, signal?: AbortSignal) =>
|
||||||
|
handleFill(
|
||||||
|
manager,
|
||||||
|
{ session_id: "aa11", ref: "e1", value, clear_before: clearBefore },
|
||||||
|
{
|
||||||
|
cdp: { send: send as CdpRunner["send"] },
|
||||||
|
tabsApi: {
|
||||||
|
get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||||
|
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fill result verification", () => {
|
||||||
|
it.each([
|
||||||
|
"readonly",
|
||||||
|
"disabled",
|
||||||
|
])("preserves a %s field before rejecting it", async (attribute) => {
|
||||||
|
const h = await setup(`<input ${attribute} value="old">`);
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /not editable/ });
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"range",
|
||||||
|
"date",
|
||||||
|
"file",
|
||||||
|
])("rejects an unsupported %s input before clearing", async (type) => {
|
||||||
|
const h = await setup(`<input type="${type}">`);
|
||||||
|
const before = h.element.value;
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /input type/ });
|
||||||
|
expect(h.element.value).toBe(before);
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an overlong value before clearing", async () => {
|
||||||
|
const h = await setup('<input maxlength="3" value="old">');
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /maxlength/ });
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not type into a field that gained focus during clearing", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const other = document.createElement("input");
|
||||||
|
document.body.append(other);
|
||||||
|
h.element.addEventListener("input", () => other.focus());
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /lost focus/ });
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"remove",
|
||||||
|
"readonly",
|
||||||
|
"restore",
|
||||||
|
"microtask-restore",
|
||||||
|
])("stops when clearing causes the page to %s the target", async (action) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener("input", () => {
|
||||||
|
if (action === "remove") h.element.remove();
|
||||||
|
if (action === "readonly") h.element.readOnly = true;
|
||||||
|
if (action === "restore") h.element.value = "restored";
|
||||||
|
if (action === "microtask-restore")
|
||||||
|
queueMicrotask(() => {
|
||||||
|
h.element.value = "restored";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when insertText is acknowledged without changing the value", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.insert.mockImplementation(async () => {});
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects truncation when maxlength changes after preparation", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener(
|
||||||
|
"input",
|
||||||
|
() => {
|
||||||
|
h.element.maxLength = 3;
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
expect(h.element.value).toBe("hel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"input",
|
||||||
|
"change",
|
||||||
|
"microtask-change",
|
||||||
|
])("detects a value reverted by the %s handler", async (event) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener(event === "microtask-change" ? "change" : event, () => {
|
||||||
|
if (event === "microtask-change")
|
||||||
|
queueMicrotask(() => {
|
||||||
|
h.element.value = "";
|
||||||
|
});
|
||||||
|
else h.element.value = "";
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares content rather than length without including field values in the error", async () => {
|
||||||
|
const h = await setup('<input type="password">');
|
||||||
|
h.element.addEventListener("change", () => {
|
||||||
|
h.element.value = "other-secret";
|
||||||
|
});
|
||||||
|
const result = await h.fill("given-secret");
|
||||||
|
expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report success for a target replaced by a change handler", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode()));
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
1, 2, 3, 4,
|
||||||
|
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
|
||||||
|
const h = await setup();
|
||||||
|
const run = h.script.getMockImplementation()!;
|
||||||
|
let count = 0;
|
||||||
|
h.script.mockImplementation(async (params) =>
|
||||||
|
++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params),
|
||||||
|
);
|
||||||
|
const result = await h.fill();
|
||||||
|
expect(result).toMatchObject({ code: "cdp_failed" });
|
||||||
|
expect(JSON.stringify(result)).not.toContain("page secret");
|
||||||
|
if (phase <= 2) expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2, 4])("rejects missing results at phase %s", async (phase) => {
|
||||||
|
const h = await setup();
|
||||||
|
const run = h.script.getMockImplementation()!;
|
||||||
|
let count = 0;
|
||||||
|
h.script.mockImplementation(async (params) =>
|
||||||
|
++count === phase ? { result: { type: "undefined" } } : run(params),
|
||||||
|
);
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
|
||||||
|
if (phase <= 2) expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener(event, () => {
|
||||||
|
queueMicrotask(() =>
|
||||||
|
queueMicrotask(() => {
|
||||||
|
h.element.value = "reverted";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
|
||||||
|
if (event === "input") expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"<input>",
|
||||||
|
'<input type="password">',
|
||||||
|
"<textarea></textarea>",
|
||||||
|
])("verifies successful text in %s", async (markup) => {
|
||||||
|
const h = await setup(markup);
|
||||||
|
expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 });
|
||||||
|
expect(h.element.value).toBe("你好🙂");
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies append against the old value plus the requested text", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.focus();
|
||||||
|
h.element.setSelectionRange(3, 3);
|
||||||
|
expect(await h.fill("!", false)).toMatchObject({ value_length: 1 });
|
||||||
|
expect(h.element.value).toBe("old!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["<textarea></textarea>", "a\r\nb", "a\nb"],
|
||||||
|
["<input>", "a\nb", "a b"],
|
||||||
|
["<input>", "a\r\nb", "a b"],
|
||||||
|
[
|
||||||
|
'<input type="email" multiple>',
|
||||||
|
" a@example.com , b@example.com ",
|
||||||
|
"a@example.com,b@example.com",
|
||||||
|
],
|
||||||
|
])("accepts native normalization for %s", async (markup, requested, normalized) => {
|
||||||
|
if (markup.startsWith("<textarea")) {
|
||||||
|
// Happy DOM does not implement Chrome's textarea line-ending normalization.
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
|
||||||
|
vi.spyOn(HTMLTextAreaElement.prototype, "value", "set").mockImplementation(function (
|
||||||
|
this: HTMLTextAreaElement,
|
||||||
|
value,
|
||||||
|
) {
|
||||||
|
setter.call(this, value.replace(/\r\n?/g, "\n"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const h = await setup(markup);
|
||||||
|
h.insert.mockImplementation(async () => {
|
||||||
|
h.element.value = normalized;
|
||||||
|
});
|
||||||
|
expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count an editable padding break as an extra typed character", async () => {
|
||||||
|
const h = await setup('<div contenteditable="true" tabindex="0"></div>');
|
||||||
|
// These two layout/editability properties are missing in Happy DOM.
|
||||||
|
let innerText = "";
|
||||||
|
Object.defineProperty(h.element, "isContentEditable", { get: () => true });
|
||||||
|
Object.defineProperty(h.element, "innerText", { get: () => innerText });
|
||||||
|
h.insert.mockImplementation(async () => {
|
||||||
|
h.element.innerHTML = "one<div><br></div>";
|
||||||
|
innerText = "one\n\n";
|
||||||
|
});
|
||||||
|
expect(await h.fill("one\n")).toMatchObject({ value_length: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-append selection before typing", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.focus();
|
||||||
|
h.element.setSelectionRange(1, 1);
|
||||||
|
expect(await h.fill("!", false)).toMatchObject({ code: "cdp_failed" });
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => {
|
||||||
|
const h = await setup();
|
||||||
|
expect(await h.fill("", clearBefore)).toMatchObject({ value_length: 0 });
|
||||||
|
expect(h.element.value).toBe(clearBefore ? "" : "old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels after insertion without sending change events", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const controller = new AbortController();
|
||||||
|
h.insert.mockImplementation(async () => controller.abort());
|
||||||
|
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(h.script).toHaveBeenCalledTimes(2);
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels after resolving the remote object without clearing the field", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const send = h.send.getMockImplementation()!;
|
||||||
|
h.send.mockImplementation(async (tabId, method, params) => {
|
||||||
|
const result = await send(tabId, method, params);
|
||||||
|
if (method === "DOM.resolveNode") controller.abort();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.script).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors cancellation during verification", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const controller = new AbortController();
|
||||||
|
h.element.addEventListener("change", () => controller.abort());
|
||||||
|
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the verified outcome when releasing the remote object fails", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.release.mockRejectedValueOnce(new Error("target closed"));
|
||||||
|
expect(await h.fill()).toMatchObject({ value_length: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -540,6 +540,18 @@ describe("handleHover", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function successfulFillScript(params: unknown) {
|
||||||
|
const args = (params as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value:
|
||||||
|
args.length === 2
|
||||||
|
? { before: "", expected: args[0].value, valueLength: String(args[0].value).length }
|
||||||
|
: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("handleFill", () => {
|
describe("handleFill", () => {
|
||||||
it("returns not_found for unknown ref", async () => {
|
it("returns not_found for unknown ref", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
@@ -586,7 +598,8 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
||||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
"Runtime.callFunctionOn": successfulFillScript,
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -600,13 +613,12 @@ describe("handleFill", () => {
|
|||||||
expect(res.used_ref).toBe("e1");
|
expect(res.used_ref).toBe("e1");
|
||||||
const insert = fake.sent.find((c) => c.method === "Input.insertText");
|
const insert = fake.sent.find((c) => c.method === "Input.insertText");
|
||||||
expect(insert?.params).toEqual({ text: "hello" });
|
expect(insert?.params).toEqual({ text: "hello" });
|
||||||
// clear_before defaults to true → callFunctionOn invoked once to
|
// Preparation, readiness, notifications, and final verification.
|
||||||
// clear, once to fire input/change after typing.
|
|
||||||
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
|
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
|
||||||
expect(callFns.length).toBeGreaterThanOrEqual(2);
|
expect(callFns).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clear_before=false skips the wipe call", async () => {
|
it("passes clear_before=false to preparation and verifies the result", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
ctx.refStore.set("e1", 1, { tabId: 4 });
|
ctx.refStore.set("e1", 1, { tabId: 4 });
|
||||||
@@ -618,12 +630,11 @@ describe("handleFill", () => {
|
|||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
||||||
"Runtime.callFunctionOn": (p) => {
|
"Runtime.callFunctionOn": (p) => {
|
||||||
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
|
const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
|
||||||
// No "this.value = ''" clearing on a no-clear path; only the
|
if (args.length === 2) expect(args[1].value).toBe(false);
|
||||||
// post-input dispatchEvent.
|
return successfulFillScript(p);
|
||||||
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
|
|
||||||
return { result: { type: "undefined" } };
|
|
||||||
},
|
},
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -650,7 +661,8 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
||||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
"Runtime.callFunctionOn": successfulFillScript,
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
|
|||||||
@@ -257,11 +257,15 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
moves: [],
|
moves: [],
|
||||||
};
|
};
|
||||||
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
|
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
|
||||||
|
const cdp = {
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
const res = await handleSessionStop(
|
const res = await handleSessionStop(
|
||||||
sm,
|
sm,
|
||||||
{ session_id: "aa11" },
|
{ session_id: "aa11" },
|
||||||
{ tabManagement: { tabs, windows } },
|
{ cdp, tabManagement: { tabs, windows } },
|
||||||
);
|
);
|
||||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
|
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
|
||||||
@@ -269,6 +273,8 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
expect(sm.has("aa11")).toBe(true);
|
expect(sm.has("aa11")).toBe(true);
|
||||||
expect(ctx.borrowedTabs.has(1)).toBe(true);
|
expect(ctx.borrowedTabs.has(1)).toBe(true);
|
||||||
expect(ctx.borrowedTabs.has(2)).toBe(false);
|
expect(ctx.borrowedTabs.has(2)).toBe(false);
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 2);
|
||||||
|
expect(cdp.detachSession).not.toHaveBeenCalled();
|
||||||
expect(aw.remove).not.toHaveBeenCalled();
|
expect(aw.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
|
import { handleHover } from "../interaction";
|
||||||
import {
|
import {
|
||||||
type AgentOverlayResetApi,
|
type AgentOverlayResetApi,
|
||||||
type ChromeWindowsApi,
|
type ChromeWindowsApi,
|
||||||
@@ -547,6 +549,148 @@ describe("handleTabBorrow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleTabReturn", () => {
|
describe("handleTabReturn", () => {
|
||||||
|
it.each([
|
||||||
|
"not_found",
|
||||||
|
"cdp_failed",
|
||||||
|
])("detaches after selector resolution fails with %s", async (code) => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api } = makeTabMutationApi(state);
|
||||||
|
const { api: windows } = makeWindowsApi(state);
|
||||||
|
const debuggerApi = {
|
||||||
|
attach: vi.fn(async () => {}),
|
||||||
|
detach: vi.fn(async () => {}),
|
||||||
|
sendCommand: vi.fn(async (_target: unknown, method: string) => {
|
||||||
|
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
|
||||||
|
if (method === "DOM.querySelector") {
|
||||||
|
if (code === "cdp_failed") throw new Error("selector query failed");
|
||||||
|
return { nodeId: 0 };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
onEvent: { addListener: vi.fn(), removeListener: vi.fn() },
|
||||||
|
onDetach: { addListener: vi.fn(), removeListener: vi.fn() },
|
||||||
|
};
|
||||||
|
const cdp = new ChromiumCdp(debuggerApi as unknown as CdpDebuggerApi);
|
||||||
|
|
||||||
|
const hover = await handleHover(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7, selector: "#missing" },
|
||||||
|
{
|
||||||
|
cdp,
|
||||||
|
tabsApi: { get: api.get, query: vi.fn(async () => []) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(hover).toMatchObject({ code });
|
||||||
|
expect(cdp.isAttached(7)).toBe(true);
|
||||||
|
|
||||||
|
const returned = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows,
|
||||||
|
cdp,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(returned).toMatchObject({ returned_to_window_id: 200 });
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(false);
|
||||||
|
expect(cdp.isAttached(7)).toBe(false);
|
||||||
|
expect(debuggerApi.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 7 });
|
||||||
|
cdp.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours cancellation while preparing a validated return", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windows } = makeWindowsApi(state);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const beforeReturn = vi.fn(async () => {
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
const result = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows,
|
||||||
|
signal: controller.signal,
|
||||||
|
beforeReturn,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(beforeReturn).toHaveBeenCalledExactlyOnceWith("aa11", 7);
|
||||||
|
expect(spies.move).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"original",
|
||||||
|
"fallback",
|
||||||
|
"cleanup_failed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
])("releases CDP only after a successful return (%s)", async (mode) => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windowsApi } = makeWindowsApi(state, { lastFocused: 500 });
|
||||||
|
const success = mode !== "failed" && mode !== "cancelled";
|
||||||
|
if (mode === "fallback") spies.move.mockRejectedValueOnce(new Error("original move failed"));
|
||||||
|
if (mode === "failed") spies.move.mockRejectedValue(new Error("move failed"));
|
||||||
|
let windowAtRelease: number | undefined;
|
||||||
|
const cdp = {
|
||||||
|
releaseSessionTab: vi.fn(async () => {
|
||||||
|
windowAtRelease = state.tabs.get(7)?.windowId;
|
||||||
|
if (mode === "cleanup_failed") throw new Error("debugger release failed");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (mode === "cancelled") controller.abort();
|
||||||
|
|
||||||
|
const result = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows: windowsApi,
|
||||||
|
cdp,
|
||||||
|
signal: controller.signal,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect("code" in result).toBe(!success);
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(!success);
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledTimes(success ? 1 : 0);
|
||||||
|
if (success) {
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7);
|
||||||
|
expect(windowAtRelease).toBe(mode === "fallback" ? 500 : 200);
|
||||||
|
expect(spies.move).toHaveBeenCalledTimes(mode === "fallback" ? 2 : 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
|
|||||||
@@ -345,7 +345,11 @@ export class ToolDispatcher {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
case "tool.tab_return":
|
case "tool.tab_return":
|
||||||
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
|
return handleTabReturn(this.sessions, req.params as TabReturnParams, {
|
||||||
|
signal,
|
||||||
|
cdp: this.cdp,
|
||||||
|
beforeReturn: (sessionId, tabId) => this.releaseHoverLatch(sessionId, tabId),
|
||||||
|
});
|
||||||
case "tool.window_resize":
|
case "tool.window_resize":
|
||||||
return handleWindowResize(
|
return handleWindowResize(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export async function resolveBackendNode(
|
|||||||
}
|
}
|
||||||
// selector path
|
// selector path
|
||||||
try {
|
try {
|
||||||
|
// Selector lookup itself attaches CDP, even when no element is found.
|
||||||
|
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
|
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
@@ -607,6 +609,36 @@ type SelectMutationResult =
|
|||||||
// tool.fill
|
// tool.fill
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FillPreparation {
|
||||||
|
before?: string;
|
||||||
|
expected?: string;
|
||||||
|
valueLength?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FillScriptReply<T> {
|
||||||
|
result?: { value?: T };
|
||||||
|
exceptionDetails?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILL_EDITABLE_FUNCTION = `function() {
|
||||||
|
const tag = this.tagName.toLowerCase();
|
||||||
|
const supported = tag === 'input'
|
||||||
|
? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type)
|
||||||
|
: tag === 'textarea' || this.isContentEditable;
|
||||||
|
return this.isConnected && supported && !this.readOnly && !this.matches(':disabled');
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Chrome renders a trailing editable newline with an empty <div><br></div>.
|
||||||
|
// innerText includes the padding break; it is not an extra typed character.
|
||||||
|
const FILL_VALUE_FUNCTION = `function() {
|
||||||
|
if (!this.isContentEditable) return this.value;
|
||||||
|
const value = this.innerText;
|
||||||
|
const tail = this.lastChild;
|
||||||
|
const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR';
|
||||||
|
return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value;
|
||||||
|
}`;
|
||||||
|
|
||||||
export async function handleFill(
|
export async function handleFill(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: FillParams,
|
params: FillParams,
|
||||||
@@ -676,57 +708,172 @@ export async function handleFill(
|
|||||||
const clearBefore = params.clear_before ?? true;
|
const clearBefore = params.clear_before ?? true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (clearBefore) {
|
|
||||||
// Clear input/textarea value or wipe contenteditable innerText,
|
|
||||||
// then fire `input` so frameworks observe the empty state.
|
|
||||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
|
||||||
objectId,
|
|
||||||
functionDeclaration: `function() {
|
|
||||||
if (this.isContentEditable) { this.textContent = ''; }
|
|
||||||
else {
|
|
||||||
const proto = this instanceof HTMLTextAreaElement
|
|
||||||
? HTMLTextAreaElement.prototype
|
|
||||||
: HTMLInputElement.prototype;
|
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
||||||
if (descriptor && descriptor.set) descriptor.set.call(this, '');
|
|
||||||
else this.value = '';
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}`,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
|
// Check editability before clearing. Keep the expected result tied to
|
||||||
|
// this object, including the existing value on the append path.
|
||||||
|
const prepared = await nodeCdp.send<FillScriptReply<FillPreparation>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function(value, clearBefore) {
|
||||||
|
const tag = this.tagName.toLowerCase();
|
||||||
|
const native = tag === 'input' || tag === 'textarea';
|
||||||
|
if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { error: 'fill target is not editable or its input type is unsupported' };
|
||||||
|
const focused = () => this.getRootNode().activeElement === this && this.ownerDocument.hasFocus();
|
||||||
|
if (!focused()) return { error: 'fill target does not have focus' };
|
||||||
|
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
|
||||||
|
if (!clearBefore && value !== '' && native && typeof this.selectionStart === 'number' &&
|
||||||
|
(this.selectionStart !== before.length || this.selectionEnd !== before.length)) {
|
||||||
|
return { error: 'fill append requires the caret at the end of the field' };
|
||||||
|
}
|
||||||
|
let expected = before + value;
|
||||||
|
if (native) {
|
||||||
|
// Use the browser's own value sanitization (e.g. textarea
|
||||||
|
// line endings) without changing the live control.
|
||||||
|
const normalizer = this.ownerDocument.createElement(tag);
|
||||||
|
if (tag === 'input') {
|
||||||
|
normalizer.type = this.type;
|
||||||
|
normalizer.multiple = this.multiple;
|
||||||
|
}
|
||||||
|
// insertText treats line breaks in a single-line input as spaces.
|
||||||
|
if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' ');
|
||||||
|
normalizer.value = expected;
|
||||||
|
if (expected !== '' && normalizer.value === '' && this.type === 'number') {
|
||||||
|
return { error: 'fill value is not valid for a number input' };
|
||||||
|
}
|
||||||
|
expected = normalizer.value;
|
||||||
|
if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) {
|
||||||
|
return { error: 'fill value exceeds the target maxlength' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expected = expected.replace(/\\r\\n?/g, '\\n');
|
||||||
|
}
|
||||||
|
if (clearBefore) {
|
||||||
|
if (native) {
|
||||||
|
const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, '');
|
||||||
|
} else {
|
||||||
|
this.textContent = '';
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
return { before, expected, valueLength: Math.max(0, expected.length - before.length) };
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: params.value }, { value: clearBefore }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (prepared.exceptionDetails) {
|
||||||
|
return { code: "cdp_failed", message: "fill preparation script failed" };
|
||||||
|
}
|
||||||
|
const preparation = prepared.result?.value;
|
||||||
|
if (
|
||||||
|
preparation?.error ||
|
||||||
|
typeof preparation?.before !== "string" ||
|
||||||
|
typeof preparation?.expected !== "string" ||
|
||||||
|
typeof preparation.valueLength !== "number"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: preparation?.error ?? "fill preparation returned an unexpected result",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// A separate call runs after the clearing event's microtasks drain.
|
||||||
|
// Confirm the target before sending input to the focused element.
|
||||||
|
const ready = await nodeCdp.send<FillScriptReply<boolean>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function(before) {
|
||||||
|
return (${FILL_EDITABLE_FUNCTION}).call(this) &&
|
||||||
|
this.getRootNode().activeElement === this && this.ownerDocument.hasFocus() &&
|
||||||
|
(${FILL_VALUE_FUNCTION}).call(this) === before;
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: preparation.before }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (ready.exceptionDetails || ready.result?.value !== true) {
|
||||||
|
return {
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: "fill target changed, lost focus, or could not be checked before typing",
|
||||||
|
};
|
||||||
|
}
|
||||||
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
||||||
// box, much more reliably than per-key `dispatchKeyEvent`.
|
// box, much more reliably than per-key `dispatchKeyEvent`.
|
||||||
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
if (params.value !== "") {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
||||||
|
}
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
// Fire `input` + `change` so React / Vue controlled inputs commit.
|
// Keep the existing notifications, then read in a separate call so
|
||||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
// nested microtasks queued by the page cannot race the verification.
|
||||||
objectId,
|
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
|
||||||
functionDeclaration: `function() {
|
target.tabId,
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
"Runtime.callFunctionOn",
|
||||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
{
|
||||||
}`,
|
objectId,
|
||||||
returnByValue: true,
|
functionDeclaration: `function() {
|
||||||
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (notified.exceptionDetails) {
|
||||||
|
return { code: "cdp_failed", message: "fill notification script failed" };
|
||||||
|
}
|
||||||
|
const verified = await nodeCdp.send<FillScriptReply<boolean>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function(expected) {
|
||||||
|
return this.isConnected && (${FILL_VALUE_FUNCTION}).call(this) === expected;
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: preparation.expected }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (verified.exceptionDetails || verified.result?.value !== true) {
|
||||||
|
return {
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: verified.exceptionDetails
|
||||||
|
? "fill verification script failed"
|
||||||
|
: "fill could not verify the expected value; observe the page before retrying",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
|
tab_id: target.tabId,
|
||||||
|
used_ref: node.usedRef,
|
||||||
|
used_selector: node.usedSelector,
|
||||||
|
value_length: preparation.valueLength,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
|
||||||
tab_id: target.tabId,
|
|
||||||
used_ref: node.usedRef,
|
|
||||||
used_selector: node.usedSelector,
|
|
||||||
value_length: params.value.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type SessionManager, SessionStartCleanupError } from "@/session-manager
|
|||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { clearRecordingForSession } from "./record";
|
import { clearRecordingForSession } from "./record";
|
||||||
import type { ChromeTabsApi } from "./shared";
|
import type { CdpRunner, ChromeTabsApi } from "./shared";
|
||||||
import { isRpcError } from "./shared";
|
import { isRpcError } from "./shared";
|
||||||
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export interface SessionStopResult {
|
|||||||
|
|
||||||
export interface SessionStopDeps {
|
export interface SessionStopDeps {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
cdp?: {
|
cdp?: Pick<CdpRunner, "releaseSessionTab"> & {
|
||||||
detachSession(sessionId: string): Promise<void>;
|
detachSession(sessionId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -209,6 +209,7 @@ export async function handleSessionStop(
|
|||||||
try {
|
try {
|
||||||
const tabManagement = {
|
const tabManagement = {
|
||||||
...(deps.tabManagement ?? {}),
|
...(deps.tabManagement ?? {}),
|
||||||
|
cdp: deps.cdp ?? deps.tabManagement?.cdp,
|
||||||
signal: deps.signal,
|
signal: deps.signal,
|
||||||
isAgentWindowId:
|
isAgentWindowId:
|
||||||
deps.tabManagement?.isAgentWindowId ??
|
deps.tabManagement?.isAgentWindowId ??
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface CdpRunner {
|
|||||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||||
|
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||||
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { isRpcError, lookupSession } from "./shared";
|
import { type CdpRunner, isRpcError, lookupSession } from "./shared";
|
||||||
|
|
||||||
export type TabScope = "user" | "agent" | "all";
|
export type TabScope = "user" | "agent" | "all";
|
||||||
|
|
||||||
@@ -287,6 +287,10 @@ export interface TabManagementDeps {
|
|||||||
approveBorrow?: BorrowConfirmationApprover;
|
approveBorrow?: BorrowConfirmationApprover;
|
||||||
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
|
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
|
||||||
agentOverlayReset?: AgentOverlayResetApi;
|
agentOverlayReset?: AgentOverlayResetApi;
|
||||||
|
/** Releases this session's CDP claim after a borrowed tab is returned. */
|
||||||
|
cdp?: Pick<CdpRunner, "releaseSessionTab">;
|
||||||
|
/** Runs after tab_return validation, before moving the borrowed tab. */
|
||||||
|
beforeReturn?: (sessionId: string, tabId: number) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Reports whether `windowId` is any live session's Agent Window.
|
* Reports whether `windowId` is any live session's Agent Window.
|
||||||
* `tab_return`'s fallback window picker uses this to avoid moving a
|
* `tab_return`'s fallback window picker uses this to avoid moving a
|
||||||
@@ -945,16 +949,22 @@ async function cleanupUnusedFallbackWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAgentOverlaysInReturnedTab(
|
async function releaseReturnedTabState(
|
||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
deps: TabManagementDeps,
|
deps: TabManagementDeps,
|
||||||
): void {
|
): Promise<void> {
|
||||||
void getAgentOverlayResetApi(deps)
|
void getAgentOverlayResetApi(deps)
|
||||||
.resetAgentOverlays(tabId, ctx.sessionId)
|
.resetAgentOverlays(tabId, ctx.sessionId)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.debug("[bsk tab_return] agent overlay reset failed", err);
|
console.debug("[bsk tab_return] agent overlay reset failed", err);
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tabId);
|
||||||
|
} catch (err) {
|
||||||
|
// A successful move must not be retried because debugger cleanup failed.
|
||||||
|
console.debug("[bsk tab_return] CDP release failed", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1028,7 +1038,7 @@ export async function returnBorrowedTab(
|
|||||||
});
|
});
|
||||||
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
||||||
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
|
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
|
||||||
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
|
await releaseReturnedTabState(ctx, tabId, deps);
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
toWindowId: targetWindowId,
|
toWindowId: targetWindowId,
|
||||||
@@ -1070,7 +1080,7 @@ export async function returnBorrowedTab(
|
|||||||
});
|
});
|
||||||
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
||||||
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
|
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
|
||||||
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
|
await releaseReturnedTabState(ctx, tabId, deps);
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
toWindowId: target.windowId,
|
toWindowId: target.windowId,
|
||||||
@@ -1116,6 +1126,7 @@ export async function handleTabReturn(
|
|||||||
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
|
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
await deps.beforeReturn?.(ctx.sessionId, params.tab_id);
|
||||||
const outcome = await returnBorrowedTab(ctx, params.tab_id, {
|
const outcome = await returnBorrowedTab(ctx, params.tab_id, {
|
||||||
...deps,
|
...deps,
|
||||||
isAgentWindowId:
|
isAgentWindowId:
|
||||||
|
|||||||
Reference in New Issue
Block a user