mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c10aab75 | ||
|
|
1eeb55af02 | ||
|
|
4992a54864 | ||
|
|
8b195eb0df | ||
|
|
eae4db1729 | ||
|
|
e961395be9 | ||
|
|
a6d1d02acc | ||
|
|
5ebe609c40 | ||
|
|
321494d93f | ||
|
|
f2184e74c5 | ||
|
|
eeb633d68e | ||
|
|
6d181105f2 | ||
|
|
ebad0ed1b5 | ||
|
|
498f8fb4a7 | ||
|
|
cef59137e4 | ||
|
|
f70978bfcf | ||
|
|
1498cd425c | ||
|
|
e831d65db3 | ||
|
|
47ac947f18 | ||
|
|
69d64c4eb1 | ||
|
|
99c997d6d5 | ||
|
|
93853ebc65 | ||
|
|
248bb90a2d | ||
|
|
6e6c178434 | ||
|
|
386ef91c24 | ||
|
|
0dc11b30ea | ||
|
|
759eebd4b6 | ||
|
|
618bd6a0df | ||
|
|
a77353bce8 | ||
|
|
91621b2545 | ||
|
|
a4fed136e3 |
@@ -37,6 +37,21 @@ jobs:
|
|||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
|
windows-daemon:
|
||||||
|
name: Windows daemon regression tests
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Run Windows named-pipe tests
|
||||||
|
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
|
||||||
|
|
||||||
|
- name: Run Windows process liveness tests
|
||||||
|
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend lint, typecheck, tests, build
|
name: Frontend lint, typecheck, tests, build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
|
|||||||
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
||||||
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
|
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
|
||||||
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { isAgentControlledTab, SessionManager } from "@/session-manager/manager";
|
||||||
import {
|
import {
|
||||||
attachBorrowNotificationButtonHandler,
|
attachBorrowNotificationButtonHandler,
|
||||||
attachBorrowNotificationClickHandler,
|
attachBorrowNotificationClickHandler,
|
||||||
@@ -41,6 +41,8 @@ import {
|
|||||||
attachRecordStepListener,
|
attachRecordStepListener,
|
||||||
type RecordRuntimeDeps,
|
type RecordRuntimeDeps,
|
||||||
} from "@/tools/record";
|
} from "@/tools/record";
|
||||||
|
import { chromeTabsApi } from "@/tools/shared";
|
||||||
|
import { chromeTabMutationApi } from "@/tools/tabs";
|
||||||
import { detectBrowserMeta } from "@/transport/handshake";
|
import { detectBrowserMeta } from "@/transport/handshake";
|
||||||
import type { Transport } from "@/transport/transport";
|
import type { Transport } from "@/transport/transport";
|
||||||
import { WSTransport } from "@/transport/ws-transport";
|
import { WSTransport } from "@/transport/ws-transport";
|
||||||
@@ -49,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>();
|
||||||
@@ -80,6 +87,24 @@ export default defineBackground(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative overlay state for a specific tab. Agent Window tabs are
|
||||||
|
* free by default; only tabs explicitly claimed through session startup,
|
||||||
|
* `tab_create`, or `tab_borrow` receive the control overlay.
|
||||||
|
*/
|
||||||
|
function overlayStateForTab(tabId?: number, windowId?: number): OverlayAgentStateMessage {
|
||||||
|
if (typeof tabId === "number" && typeof windowId === "number") {
|
||||||
|
const ctx = sessions.findByWindowId(windowId);
|
||||||
|
if (ctx && isAgentControlledTab(ctx, tabId)) return overlayStateForWindow(windowId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: OVERLAY_AGENT_STATE,
|
||||||
|
sessionId: null,
|
||||||
|
mode: "hidden",
|
||||||
|
generation: overlayGeneration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function pushOverlayStateToTab(
|
async function pushOverlayStateToTab(
|
||||||
tabId: number,
|
tabId: number,
|
||||||
state: OverlayAgentStateMessage,
|
state: OverlayAgentStateMessage,
|
||||||
@@ -91,13 +116,18 @@ export default defineBackground(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pushOverlayStateForTab(tabId: number, windowId?: number): Promise<void> {
|
||||||
|
const state = overlayStateForTab(tabId, windowId);
|
||||||
|
await pushOverlayStateToTab(tabId, state);
|
||||||
|
}
|
||||||
|
|
||||||
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
|
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
|
||||||
const state = overlayStateForWindow(windowId);
|
|
||||||
const tabs = await chrome.tabs.query({ windowId });
|
const tabs = await chrome.tabs.query({ windowId });
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tabs.map((tab) =>
|
tabs.map((tab) => {
|
||||||
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
|
if (typeof tab.id !== "number") return Promise.resolve();
|
||||||
),
|
return pushOverlayStateForTab(tab.id, windowId);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +166,30 @@ export default defineBackground(() => {
|
|||||||
if (typeof tab.windowId !== "number") return;
|
if (typeof tab.windowId !== "number") return;
|
||||||
pushOverlayStateForAgentWindow(tab.windowId);
|
pushOverlayStateForAgentWindow(tab.windowId);
|
||||||
});
|
});
|
||||||
|
// Newly observed tabs are free unless the agent's own creation path has
|
||||||
|
// already claimed their concrete id. This listener only renders that
|
||||||
|
// state; it never infers or mutates ownership from event ordering.
|
||||||
|
chrome.tabs.onCreated.addListener((tab) => {
|
||||||
|
if (typeof tab.windowId !== "number" || typeof tab.id !== "number") return;
|
||||||
|
if (!sessions.findByWindowId(tab.windowId)) return;
|
||||||
|
void pushOverlayStateForTab(tab.id, tab.windowId);
|
||||||
|
});
|
||||||
|
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||||
|
sessions.forgetClosedTab(tabId, { isWindowClosing: removeInfo.isWindowClosing });
|
||||||
|
});
|
||||||
// Re-sync the storage.session flag on SW startup so a previous SW's
|
// Re-sync the storage.session flag on SW startup so a previous SW's
|
||||||
// stale `true` does not keep waking us on every page load until the
|
// stale `true` does not keep waking us on every page load until the
|
||||||
// first mutation (review M4/M5 round 3 m-R3-1).
|
// first mutation (review M4/M5 round 3 m-R3-1).
|
||||||
void sessionsLive.refresh();
|
void sessionsLive.refresh();
|
||||||
const cleanupAfterDisconnect = createDisconnectCleanup({
|
const cleanupAfterDisconnect = createDisconnectCleanup({
|
||||||
manager: sessions,
|
manager: sessions,
|
||||||
sessionStopDeps: { cdp },
|
// Same contract as the dispatcher's `tool.session_stop`: without these
|
||||||
|
// deps the agent-tab cleanup and the window-release path are dead code.
|
||||||
|
sessionStopDeps: {
|
||||||
|
cdp,
|
||||||
|
tabManagement: { tabs: chromeTabMutationApi },
|
||||||
|
tabsQuery: chromeTabsApi,
|
||||||
|
},
|
||||||
onSessionsChanged: () => {
|
onSessionsChanged: () => {
|
||||||
void sessionsLive.syncFromManager();
|
void sessionsLive.syncFromManager();
|
||||||
},
|
},
|
||||||
@@ -176,6 +223,9 @@ export default defineBackground(() => {
|
|||||||
recording: recordDeps,
|
recording: recordDeps,
|
||||||
onSessionsChanged: onOverlaySessionStateChanged,
|
onSessionsChanged: onOverlaySessionStateChanged,
|
||||||
onBrowserControlResumed,
|
onBrowserControlResumed,
|
||||||
|
onAgentTabClaimed: (tabId, windowId) => {
|
||||||
|
void pushOverlayStateForTab(tabId, windowId);
|
||||||
|
},
|
||||||
approveBorrow: (ctx) =>
|
approveBorrow: (ctx) =>
|
||||||
requestBorrowConfirmation(ctx.tabId, {
|
requestBorrowConfirmation(ctx.tabId, {
|
||||||
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
||||||
@@ -309,7 +359,11 @@ export default defineBackground(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.kind === OVERLAY_MSG_READY) {
|
if (msg.kind === OVERLAY_MSG_READY) {
|
||||||
sendResponse(overlayStateForWindow(sender.tab?.windowId));
|
// Resolve from explicit tab ownership before sending so free tabs never
|
||||||
|
// flash the control mask.
|
||||||
|
if (typeof sender.tab?.id === "number") {
|
||||||
|
void pushOverlayStateForTab(sender.tab.id, sender.tab.windowId);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
* between the content-script control overlay and the background SW.
|
* between the content-script control overlay and the background SW.
|
||||||
*
|
*
|
||||||
* Content script → background:
|
* Content script → background:
|
||||||
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
|
* - `{ kind: "overlay.ready" }` → background decides the per-tab state first
|
||||||
* overlay state for the sender's window.
|
* (see overlayStateForTab) and pushes the authoritative overlay state for
|
||||||
|
* that tab. It also proactively pushes on tab create and control-mode
|
||||||
|
* change, so a tab usually receives state before it ever sends ready.
|
||||||
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
|
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
|
||||||
* daemon (via a `session.user_interrupt` WS event) to cancel
|
* daemon (via a `session.user_interrupt` WS event) to cancel
|
||||||
* every inflight + queued tool call for that session with
|
* every inflight + queued tool call for that session with
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe("disconnect session cleanup", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => nextWindowId++),
|
create: vi.fn(async () => nextWindowId++),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
remove,
|
remove,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ describe("disconnect session cleanup", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 100),
|
create: vi.fn(async () => 100),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
remove: vi.fn(() => removeGate),
|
remove: vi.fn(() => removeGate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await manager.start("aa11");
|
await manager.start("aa11");
|
||||||
@@ -73,7 +73,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ctx = await manager.start("aa11");
|
const ctx = await manager.start("aa11");
|
||||||
@@ -87,6 +87,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
windowEvents: events.api,
|
windowEvents: events.api,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
manager.forgetClosedTab(7, { isWindowClosing: true });
|
||||||
events.emit(4242);
|
events.emit(4242);
|
||||||
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const transport = fakeTransport();
|
const transport = fakeTransport();
|
||||||
@@ -129,7 +130,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const transport = fakeTransport();
|
const transport = fakeTransport();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
||||||
import { SessionManager } from "../manager";
|
import { isAgentControlledTab, SessionManager } from "../manager";
|
||||||
|
|
||||||
function fakeAgentWindow(): AgentWindowApi & {
|
function fakeAgentWindow(): AgentWindowApi & {
|
||||||
createMock: ReturnType<typeof vi.fn>;
|
createMock: ReturnType<typeof vi.fn>;
|
||||||
@@ -13,7 +13,7 @@ function fakeAgentWindow(): AgentWindowApi & {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const removeMock = vi.fn(async (_id: number) => {});
|
const removeMock = vi.fn(async (_id: number) => {});
|
||||||
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
|
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
|
||||||
return {
|
return {
|
||||||
create: createMock,
|
create: createMock,
|
||||||
remove: removeMock,
|
remove: removeMock,
|
||||||
@@ -151,6 +151,76 @@ describe("SessionManager", () => {
|
|||||||
expect(sm.list()).toEqual([]);
|
expect(sm.list()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explicit tab claims", () => {
|
||||||
|
it("claims the exact home tab returned by AgentWindowApi", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
expect(ctx.agentCreatedTabs).toEqual(new Set([0]));
|
||||||
|
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every other tab free without classification state", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
expect(isAgentControlledTab(ctx, 99)).toBe(false);
|
||||||
|
expect(ctx).not.toHaveProperty("userTabs");
|
||||||
|
expect(ctx).not.toHaveProperty("pendingAgentTabCount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a committed borrow as an explicit claim", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||||
|
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||||
|
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 3 });
|
||||||
|
expect(isAgentControlledTab(ctx, 42)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets an agent-created tab after Chrome removes it", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(42);
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
expect(ctx.agentCreatedTabs.has(42)).toBe(false);
|
||||||
|
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets closed borrows idempotently without affecting other tabs or sessions", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const a = await sm.start("aa11");
|
||||||
|
const b = await sm.start("bb22");
|
||||||
|
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 0 });
|
||||||
|
a.borrowedTabs.set(43, { tabId: 43, originalWindowId: 7, originalIndex: 1 });
|
||||||
|
b.borrowedTabs.set(44, { tabId: 44, originalWindowId: 8, originalIndex: 0 });
|
||||||
|
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
sm.forgetClosedTab(999);
|
||||||
|
|
||||||
|
expect(isAgentControlledTab(a, 42)).toBe(false);
|
||||||
|
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||||
|
expect([...a.borrowedTabs.keys()]).toEqual([43]);
|
||||||
|
expect([...b.borrowedTabs.keys()]).toEqual([44]);
|
||||||
|
expect(sm.list()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents an in-flight borrow from reclaiming a closed tab", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||||
|
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||||
|
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 0 }),
|
||||||
|
).toThrow(/reservation disappeared/);
|
||||||
|
reservation.release();
|
||||||
|
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||||
|
expect(ctx.borrowedTabs.has(42)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("findBorrowingSession", () => {
|
describe("findBorrowingSession", () => {
|
||||||
it("returns null when no session has borrowed the tab", async () => {
|
it("returns null when no session has borrowed the tab", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ export interface AgentWindowApi {
|
|||||||
* Guarantee the Agent Window has an active, CDP-navigable tab.
|
* Guarantee the Agent Window has an active, CDP-navigable tab.
|
||||||
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
|
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
|
||||||
* so sessions bootstrap with `about:blank` instead.
|
* so sessions bootstrap with `about:blank` instead.
|
||||||
|
*
|
||||||
|
* Resolves with the id of the activated (or newly created) tab, so callers
|
||||||
|
* can track the session's home tab without re-querying Chrome.
|
||||||
*/
|
*/
|
||||||
ensureActiveTab(windowId: number, url: string): Promise<void>;
|
ensureActiveTab(windowId: number, url: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creation hints for a new Agent Window. */
|
/** Creation hints for a new Agent Window. */
|
||||||
@@ -49,15 +52,19 @@ export const chromeAgentWindowApi: AgentWindowApi = {
|
|||||||
// cancellation success while the Agent Window remains open.
|
// cancellation success while the Agent Window remains open.
|
||||||
await chrome.windows.remove(windowId);
|
await chrome.windows.remove(windowId);
|
||||||
},
|
},
|
||||||
async ensureActiveTab(windowId: number, url: string): Promise<void> {
|
async ensureActiveTab(windowId: number, url: string): Promise<number> {
|
||||||
const tabs = await chrome.tabs.query({ windowId });
|
const tabs = await chrome.tabs.query({ windowId });
|
||||||
const first = tabs.find((t) => typeof t.id === "number");
|
const first = tabs.find((t) => typeof t.id === "number");
|
||||||
if (first?.id) {
|
if (first?.id !== undefined) {
|
||||||
if (!first.active) {
|
if (!first.active) {
|
||||||
await chrome.tabs.update(first.id, { active: true });
|
await chrome.tabs.update(first.id, { active: true });
|
||||||
}
|
}
|
||||||
return;
|
return first.id;
|
||||||
}
|
}
|
||||||
await chrome.tabs.create({ windowId, url, active: true });
|
const created = await chrome.tabs.create({ windowId, url, active: true });
|
||||||
|
if (typeof created?.id !== "number") {
|
||||||
|
throw new Error("[bh] chrome.tabs.create returned no tab id");
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,20 @@ export interface SessionContext {
|
|||||||
agentWindowId: number;
|
agentWindowId: number;
|
||||||
refStore: RefStore;
|
refStore: RefStore;
|
||||||
borrowedTabs: Map<number, BorrowedTab>;
|
borrowedTabs: Map<number, BorrowedTab>;
|
||||||
|
/**
|
||||||
|
* Tabs explicitly claimed by the agent because it created them. This
|
||||||
|
* includes the Agent Window's home tab and tabs created by `tool.tab_create`.
|
||||||
|
* Tabs opened through Chrome UI never enter this set.
|
||||||
|
*/
|
||||||
|
agentCreatedTabs: Set<number>;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this session has explicitly claimed control of `tabId`. */
|
||||||
|
export function isAgentControlledTab(ctx: SessionContext, tabId: number): boolean {
|
||||||
|
return ctx.agentCreatedTabs.has(tabId) || ctx.borrowedTabs.has(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
export interface BorrowedTab {
|
export interface BorrowedTab {
|
||||||
tabId: number;
|
tabId: number;
|
||||||
originalWindowId: number;
|
originalWindowId: number;
|
||||||
@@ -106,6 +117,19 @@ export class SessionManager {
|
|||||||
return Array.from(this.sessions.values());
|
return Array.from(this.sessions.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a tab Chrome has removed, including any uncommitted borrow.
|
||||||
|
* Whole-window closures keep committed borrows until the window-removed
|
||||||
|
* handler reports which user tabs could not be returned.
|
||||||
|
*/
|
||||||
|
forgetClosedTab(tabId: number, { isWindowClosing = false } = {}): void {
|
||||||
|
this.borrowReservations.delete(tabId);
|
||||||
|
for (const ctx of this.sessions.values()) {
|
||||||
|
ctx.agentCreatedTabs.delete(tabId);
|
||||||
|
if (!isWindowClosing) ctx.borrowedTabs.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up whether `tabId` is currently borrowed by some *other*
|
* Look up whether `tabId` is currently borrowed by some *other*
|
||||||
* session than the one calling. Used by M8 `tab_borrow` to refuse
|
* session than the one calling. Used by M8 `tab_borrow` to refuse
|
||||||
@@ -179,7 +203,7 @@ export class SessionManager {
|
|||||||
const { signal: _signal, ...createOptions } = opts;
|
const { signal: _signal, ...createOptions } = opts;
|
||||||
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
|
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
|
||||||
throwIfSessionStartAborted(opts.signal);
|
throwIfSessionStartAborted(opts.signal);
|
||||||
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
const homeTabId = await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
||||||
throwIfSessionStartAborted(opts.signal);
|
throwIfSessionStartAborted(opts.signal);
|
||||||
|
|
||||||
const ctx: SessionContext = {
|
const ctx: SessionContext = {
|
||||||
@@ -187,6 +211,10 @@ export class SessionManager {
|
|||||||
agentWindowId: windowId,
|
agentWindowId: windowId,
|
||||||
refStore: new RefStore(),
|
refStore: new RefStore(),
|
||||||
borrowedTabs: new Map(),
|
borrowedTabs: new Map(),
|
||||||
|
// The home tab is the session's first explicit claim. Every other
|
||||||
|
// tab remains free until `tab_create` or `tab_borrow` identifies it
|
||||||
|
// by its concrete Chrome tab id.
|
||||||
|
agentCreatedTabs: new Set([homeTabId]),
|
||||||
createdAtMs: this.now(),
|
createdAtMs: this.now(),
|
||||||
};
|
};
|
||||||
this.sessions.set(sessionId, ctx);
|
this.sessions.set(sessionId, ctx);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -158,7 +158,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -181,7 +181,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create,
|
create,
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -199,7 +199,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -213,6 +213,47 @@ describe("ToolDispatcher", () => {
|
|||||||
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => {
|
||||||
|
// Regression guard for the deps that session_stop reads directly: when
|
||||||
|
// `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher,
|
||||||
|
// the agent-tab cleanup and the release decision silently no-op and the
|
||||||
|
// Agent Window is closed even though a user tab is still open (issue #57).
|
||||||
|
const removeTab = vi.fn(async () => {});
|
||||||
|
const closeWindow = vi.fn(async () => {});
|
||||||
|
// After cleanup the window still holds one tab (id 99) that is neither
|
||||||
|
// the home tab nor an agent-created tab — i.e. a genuine user tab.
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
remove: removeTab,
|
||||||
|
query: vi.fn(async () => [{ id: 99, windowId: 4242, active: true }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 4242),
|
||||||
|
remove: closeWindow,
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sessions.start("aa11");
|
||||||
|
// Register the exact tab id returned by the agent's creation path.
|
||||||
|
sessions.get("aa11")?.agentCreatedTabs.add(7);
|
||||||
|
|
||||||
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
|
dispatcher.start();
|
||||||
|
|
||||||
|
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
// The agent tab is closed through the real chrome.tabs surface...
|
||||||
|
expect(removeTab).toHaveBeenCalledWith(7);
|
||||||
|
// ...and the window is released rather than closed, so the user's tab survives.
|
||||||
|
expect(closeWindow).not.toHaveBeenCalled();
|
||||||
|
expect(sessions.has("aa11")).toBe(false);
|
||||||
|
expect(sent[0]).toEqual({ id: "r-1", result: { window_released: true } });
|
||||||
|
});
|
||||||
|
|
||||||
it("routes tool.console through the CDP console buffer", async () => {
|
it("routes tool.console through the CDP console buffer", async () => {
|
||||||
vi.stubGlobal("chrome", {
|
vi.stubGlobal("chrome", {
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -224,7 +265,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -287,7 +328,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ctx = await sessions.start("aa11");
|
const ctx = await sessions.start("aa11");
|
||||||
@@ -380,7 +421,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -416,7 +457,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove,
|
remove,
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -464,7 +505,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -484,7 +525,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -508,7 +549,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -525,7 +566,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -542,7 +583,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const onSessionsChanged = vi.fn();
|
const onSessionsChanged = vi.fn();
|
||||||
@@ -559,16 +600,32 @@ describe("ToolDispatcher", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
|
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
create: vi.fn(async (props: chrome.tabs.CreateProperties) => ({
|
||||||
|
id: 7,
|
||||||
|
windowId: props.windowId,
|
||||||
|
url: props.url,
|
||||||
|
active: props.active,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
const sessions = new SessionManager({
|
const sessions = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const onBrowserControlResumed = vi.fn();
|
const onBrowserControlResumed = vi.fn();
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions, onBrowserControlResumed });
|
const onAgentTabClaimed = vi.fn();
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
onBrowserControlResumed,
|
||||||
|
onAgentTabClaimed,
|
||||||
|
});
|
||||||
dispatcher.start();
|
dispatcher.start();
|
||||||
|
|
||||||
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
|
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
|
||||||
@@ -585,6 +642,7 @@ describe("ToolDispatcher", () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
|
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
|
||||||
|
expect(onAgentTabClaimed).toHaveBeenCalledWith(7, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
|
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
|
||||||
@@ -598,7 +656,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
@@ -680,7 +738,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cdp = {
|
const cdp = {
|
||||||
@@ -730,6 +788,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: {
|
||||||
@@ -741,7 +928,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -778,7 +965,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -830,7 +1017,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -864,7 +1051,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
transport.send = () => {
|
transport.send = () => {
|
||||||
@@ -886,7 +1073,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 5555),
|
create: vi.fn(async () => 5555),
|
||||||
remove,
|
remove,
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
transport.send = () => {
|
transport.send = () => {
|
||||||
@@ -914,7 +1101,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(() => createPromise),
|
create: vi.fn(() => createPromise),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -965,7 +1152,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -986,7 +1173,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(() => createPromise),
|
create: vi.fn(() => createPromise),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function sessions() {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 100),
|
create: vi.fn(async () => 100),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
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;
|
||||||
|
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" } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Number/email controls have a browser caret even though their selection
|
||||||
|
// getters return null. Model that caret instead of assuming it is at the end.
|
||||||
|
const hiddenCaret = { start: 0, end: 0 };
|
||||||
|
const moveToEnd = vi.fn(async () => {
|
||||||
|
if (typeof element.selectionStart === "number") {
|
||||||
|
element.setSelectionRange(element.value.length, element.value.length);
|
||||||
|
} else {
|
||||||
|
hiddenCaret.start = hiddenCaret.end = element.value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const insert = vi.fn(async (text: string) => {
|
||||||
|
const focused = document.activeElement as HTMLInputElement;
|
||||||
|
if (focused.readOnly || focused.disabled) return;
|
||||||
|
const start = focused.selectionStart ?? hiddenCaret.start;
|
||||||
|
const end = focused.selectionEnd ?? hiddenCaret.end;
|
||||||
|
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
|
||||||
|
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
|
||||||
|
hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
|
||||||
|
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 "Input.dispatchKeyEvent":
|
||||||
|
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
|
||||||
|
await moveToEnd();
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
case "Runtime.releaseObject":
|
||||||
|
return release();
|
||||||
|
default:
|
||||||
|
throw new Error(`unexpected ${method}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
hiddenCaret,
|
||||||
|
moveToEnd,
|
||||||
|
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: "invalid_params",
|
||||||
|
data: { reason: "target_not_fillable" },
|
||||||
|
});
|
||||||
|
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: "invalid_params",
|
||||||
|
data: { reason: "target_not_fillable" },
|
||||||
|
});
|
||||||
|
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: "invalid_params",
|
||||||
|
message: /maxlength/,
|
||||||
|
data: { reason: "fill_value_invalid" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
])("classifies a DOM.focus rejection on a disabled=%s target", async (disabled) => {
|
||||||
|
const h = await setup(`<input ${disabled ? "disabled" : ""} value="old">`);
|
||||||
|
const send = h.send.getMockImplementation()!;
|
||||||
|
h.send.mockImplementation(async (tabId, method, params) => {
|
||||||
|
if (method === "DOM.focus") throw new Error("Element is not focusable");
|
||||||
|
return send(tabId, method, params);
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: disabled ? "invalid_params" : "cdp_failed",
|
||||||
|
data: { reason: disabled ? "target_not_fillable" : "fill_failed" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills a background document without requiring foreground browser focus", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
expect(await h.fill()).toMatchObject({ value_length: 5 });
|
||||||
|
expect(h.element.value).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus once after clearing without typing into another field", 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({ value_length: 5 });
|
||||||
|
expect(h.element.value).toBe("hello");
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"focus",
|
||||||
|
"value",
|
||||||
|
])("rechecks %s after restoring focus, without retrying again", async (action) => {
|
||||||
|
const h = await setup();
|
||||||
|
const other = document.createElement("input");
|
||||||
|
document.body.append(other);
|
||||||
|
h.element.addEventListener(
|
||||||
|
"input",
|
||||||
|
() => {
|
||||||
|
other.focus();
|
||||||
|
h.element.addEventListener("focus", () => {
|
||||||
|
if (action === "focus") queueMicrotask(() => other.focus());
|
||||||
|
else h.element.value = "changed";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: action === "focus" ? "fill_focus_lost" : "fill_target_changed" },
|
||||||
|
});
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
data: { reason: "fill_target_changed" },
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
data: { reason: "fill_target_changed" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
1, 2, 3, 4, 5,
|
||||||
|
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
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", data: { reason: "fill_failed" } });
|
||||||
|
expect(JSON.stringify(result)).not.toContain("page secret");
|
||||||
|
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2, 3, 5])("rejects missing results at phase %s", async (phase) => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
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 <= 3) 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: 4 });
|
||||||
|
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.each([
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
[0, 3],
|
||||||
|
])("appends after moving selection %s:%s to the end", async (start, end) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.focus();
|
||||||
|
h.element.setSelectionRange(start, end);
|
||||||
|
expect(await h.fill("🙂", false)).toMatchObject({ value_length: 5 });
|
||||||
|
expect(h.element.value).toBe("old🙂");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
["number", "email"].flatMap((type) =>
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
[0, 2],
|
||||||
|
].map(([start, end]) => ({ type, start, end })),
|
||||||
|
),
|
||||||
|
)("appends to $type from selection $start:$end without using its unsupported API", async ({
|
||||||
|
type,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}) => {
|
||||||
|
const h = await setup(`<input type="${type}" value="12">`);
|
||||||
|
Object.assign(h.hiddenCaret, { start, end });
|
||||||
|
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
|
||||||
|
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
|
||||||
|
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
|
||||||
|
throw new DOMException("unsupported", "InvalidStateError");
|
||||||
|
});
|
||||||
|
expect(await h.fill("3", false)).toMatchObject({ value_length: 3 });
|
||||||
|
expect(h.element.value).toBe("123");
|
||||||
|
expect(selection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"number",
|
||||||
|
"email",
|
||||||
|
])("does not report successful %s append when the caret command is ignored", async (type) => {
|
||||||
|
const h = await setup(`<input type="${type}" value="12">`);
|
||||||
|
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
|
||||||
|
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
|
||||||
|
h.moveToEnd.mockResolvedValueOnce(undefined);
|
||||||
|
expect(await h.fill("3", false)).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("312");
|
||||||
|
expect(h.insert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid number text before clearing", async () => {
|
||||||
|
const h = await setup('<input type="number" value="12">');
|
||||||
|
expect(await h.fill("abc")).toMatchObject({
|
||||||
|
code: "invalid_params",
|
||||||
|
data: { reason: "fill_value_invalid" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("12");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports page formatting as unconfirmed without retrying the write", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener("change", () => {
|
||||||
|
h.element.value = h.element.value.toUpperCase();
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("HELLO");
|
||||||
|
expect(h.insert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["focus", false],
|
||||||
|
["focus", true],
|
||||||
|
["value", false],
|
||||||
|
["value", true],
|
||||||
|
["cancel", false],
|
||||||
|
["cancel", true],
|
||||||
|
["throw", false],
|
||||||
|
["throw", true],
|
||||||
|
] as const)("stops when positioning triggers %s (clear=%s) and releases the key", async (action, clearBefore) => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const other = document.createElement("input");
|
||||||
|
document.body.append(other);
|
||||||
|
const send = h.send.getMockImplementation()!;
|
||||||
|
h.send.mockImplementation(async (tabId, method, params) => {
|
||||||
|
const reply = await send(tabId, method, params);
|
||||||
|
if (
|
||||||
|
method === "Input.dispatchKeyEvent" &&
|
||||||
|
(params as { type: string }).type === "rawKeyDown"
|
||||||
|
) {
|
||||||
|
if (action === "focus") other.focus();
|
||||||
|
if (action === "value") h.element.value = "changed";
|
||||||
|
if (action === "cancel") controller.abort();
|
||||||
|
if (action === "throw") throw new Error("dispatch failed");
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
const result = await h.fill("!", clearBefore, controller.signal);
|
||||||
|
expect(result).toMatchObject({ code: action === "cancel" ? "cancelled" : "cdp_failed" });
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.send).toHaveBeenCalledWith(
|
||||||
|
4,
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
expect.objectContaining({ type: "keyUp" }),
|
||||||
|
);
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
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: clearBefore ? 0 : 3 });
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +540,21 @@ describe("handleHover", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function successfulFillScript(params: unknown) {
|
||||||
|
const script = params as { arguments?: Array<{ value: unknown }>; functionDeclaration: string };
|
||||||
|
const args = script.arguments ?? [];
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value:
|
||||||
|
args.length === 2
|
||||||
|
? { before: "", expected: args[0].value }
|
||||||
|
: script.functionDeclaration.startsWith("function(expected)")
|
||||||
|
? { connected: true, matches: true, valueLength: String(args[0].value).length }
|
||||||
|
: "ready",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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 +601,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 +616,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
|
// Foreground replacement needs no extra caret-positioning round trip.
|
||||||
// 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 +633,12 @@ 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" } };
|
|
||||||
},
|
},
|
||||||
|
"Input.dispatchKeyEvent": () => ({}),
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -650,7 +665,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(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function fixture() {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 100),
|
create: vi.fn(async () => 100),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 4),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await manager.start("test");
|
await manager.start("test");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
import { handleSessionStop } from "../session";
|
import { handleSessionStop } from "../session";
|
||||||
|
import type { ChromeTabsApi } from "../shared";
|
||||||
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
|
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
|
||||||
|
|
||||||
|
/** Build a read-only query api over a FakeState. */
|
||||||
|
function makeQuery(state: FakeState): ChromeTabsApi {
|
||||||
|
return {
|
||||||
|
get: vi.fn(async (id) => {
|
||||||
|
const t = state.tabs.get(id);
|
||||||
|
if (!t) throw new Error(`tab ${id} not found`);
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
query: vi.fn(async (q: chrome.tabs.QueryInfo) => {
|
||||||
|
const w = q.windowId;
|
||||||
|
return Array.from(state.tabs.values()).filter(
|
||||||
|
(t) => typeof w !== "number" || t.windowId === w,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function fakeAgentWindow(ids: number[]) {
|
function fakeAgentWindow(ids: number[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const create = vi.fn(async () => {
|
const create = vi.fn(async () => {
|
||||||
@@ -11,7 +29,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const remove = vi.fn(async () => {});
|
const remove = vi.fn(async () => {});
|
||||||
const ensureActiveTab = vi.fn(async () => {});
|
const ensureActiveTab = vi.fn(async () => 0);
|
||||||
return { create, remove, ensureActiveTab };
|
return { create, remove, ensureActiveTab };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +48,9 @@ function makeApis(
|
|||||||
} {
|
} {
|
||||||
const tabs: TabMutationApi = {
|
const tabs: TabMutationApi = {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async (id: number) => {
|
||||||
|
state.tabs.delete(id);
|
||||||
|
}),
|
||||||
update: vi.fn(async (_id, _p) => undefined),
|
update: vi.fn(async (_id, _p) => undefined),
|
||||||
get: vi.fn(async (id) => {
|
get: vi.fn(async (id) => {
|
||||||
const t = state.tabs.get(id);
|
const t = state.tabs.get(id);
|
||||||
@@ -237,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]);
|
||||||
@@ -249,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,4 +293,275 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
|
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
|
||||||
expect(ctx.refStore.isEmpty()).toBe(true);
|
expect(ctx.refStore.isEmpty()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"before teardown",
|
||||||
|
"during its return",
|
||||||
|
"after its return failed",
|
||||||
|
"before its turn in the return loop",
|
||||||
|
])("finishes stopping when a borrowed tab closes %s", async (timing) => {
|
||||||
|
const aw = fakeAgentWindow([100]);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(1, { tabId: 1, originalWindowId: 200, originalIndex: 0 });
|
||||||
|
ctx.borrowedTabs.set(2, { tabId: 2, originalWindowId: 200, originalIndex: 1 });
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[1, { id: 1, windowId: 100 } as chrome.tabs.Tab],
|
||||||
|
[2, { id: 2, windowId: 100 } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const cdp = { detachSession: vi.fn(async () => {}) };
|
||||||
|
const closeTab = (tabId: number) => {
|
||||||
|
state.tabs.delete(tabId);
|
||||||
|
sm.forgetClosedTab(tabId);
|
||||||
|
};
|
||||||
|
const baseMove = tabs.move;
|
||||||
|
tabs.move = vi.fn(async (tabId, props) => {
|
||||||
|
if (tabId === 1 && timing === "during its return") closeTab(1);
|
||||||
|
if (tabId === 1 && timing === "after its return failed") {
|
||||||
|
throw new Error("simulated move failure");
|
||||||
|
}
|
||||||
|
if (tabId === 2 && timing === "after its return failed") closeTab(1);
|
||||||
|
if (tabId === 1 && timing === "before its turn in the return loop") closeTab(2);
|
||||||
|
if (!state.tabs.has(tabId)) throw new Error(`tab ${tabId} not found`);
|
||||||
|
return baseMove(tabId, props);
|
||||||
|
});
|
||||||
|
if (timing === "before teardown") closeTab(1);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{
|
||||||
|
cdp,
|
||||||
|
tabManagement: {
|
||||||
|
tabs,
|
||||||
|
windows,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
tabsQuery: makeQuery(state),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
const closedTabId = timing === "before its turn in the return loop" ? 2 : 1;
|
||||||
|
expect(res.returned_tab_ids).toEqual([closedTabId === 1 ? 2 : 1]);
|
||||||
|
expect(res.return_failures).toBeUndefined();
|
||||||
|
expect(ctx.borrowedTabs.size).toBe(0);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(100);
|
||||||
|
if (timing === "before teardown" || timing === "before its turn in the return loop") {
|
||||||
|
expect(tabs.move).not.toHaveBeenCalledWith(closedTabId, expect.anything());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleSessionStop window release (issue #57)", () => {
|
||||||
|
const agentWindowId = 100;
|
||||||
|
|
||||||
|
it("releases the window when user-created tabs remain", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
// ensureActiveTab returns the home tab id (10). We override the fake to
|
||||||
|
// return a known id.
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
ctx.agentCreatedTabs.add(12);
|
||||||
|
// user tab 99 (created via Chrome UI, NOT in agentCreatedTabs)
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[12, { id: 12, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[99, { id: 99, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
expect(res.window_released).toBe(true);
|
||||||
|
// agent tabs + home removed, user tab 99 kept
|
||||||
|
expect((tabs.remove as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]).sort()).toEqual([
|
||||||
|
10, 11, 12,
|
||||||
|
]);
|
||||||
|
expect(state.tabs.has(99)).toBe(true);
|
||||||
|
// window released (dropOnly), not closed
|
||||||
|
expect(aw.remove).not.toHaveBeenCalled();
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the window (not release) when no user tabs remain", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is non-fatal when an agent tab is already gone", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11); // no longer in state → remove throws
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab]]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
|
||||||
|
if (id === 11) throw new Error("tab 11 already closed");
|
||||||
|
state.tabs.delete(id); // home (10) removed normally
|
||||||
|
});
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
// user tab 10? no — 10 is home, removed. window would be empty → close.
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to close-window when tabsQuery is missing", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows } }, // no tabsQuery
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
// No query available → conservatively close the window.
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to close-window when tabsQuery.query throws", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab]]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const query = makeQuery(state);
|
||||||
|
(query.query as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||||
|
throw new Error("query failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks agentCreatedTabs across tab_create / tab_close", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
// Simulate handleTabCreate adding, then handleTabClose removing.
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
expect(ctx.agentCreatedTabs.has(11)).toBe(true);
|
||||||
|
ctx.agentCreatedTabs.delete(11); // as handleTabClose does on success
|
||||||
|
expect(ctx.agentCreatedTabs.has(11)).toBe(false);
|
||||||
|
// Unknown id delete is a safe no-op (user-closed tab).
|
||||||
|
expect(() => ctx.agentCreatedTabs.delete(999)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the window (not release) when an agent tab fails to close", async () => {
|
||||||
|
// Regression: a leaked agent tab (still in agentCreatedTabs, but
|
||||||
|
// still present because Step 4 remove() threw) must NOT be mistaken
|
||||||
|
// for a user tab. Otherwise the window would be released (dropOnly)
|
||||||
|
// and the agent tab would leak (issue #57 regression).
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11); // Step 4 remove() will throw for this tab
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
|
||||||
|
if (id === 11) throw new Error("tab 11 failed to close");
|
||||||
|
state.tabs.delete(id); // home (10) removed normally; 11 lingers
|
||||||
|
});
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
// The leaked agent tab must NOT keep the window open.
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow() {
|
|||||||
return {
|
return {
|
||||||
create: vi.fn(async () => 100),
|
create: vi.fn(async () => 100),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
remove: async () => {},
|
remove: async () => {},
|
||||||
ensureActiveTab: async () => {},
|
ensureActiveTab: async () => 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -23,7 +25,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +223,7 @@ describe("handleTabCreate", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
|
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
|
||||||
|
expect(sm.get("aa11")?.agentCreatedTabs.has(10)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards explicit url + index + active=false", async () => {
|
it("forwards explicit url + index + active=false", async () => {
|
||||||
@@ -256,6 +259,41 @@ describe("handleTabCreate", () => {
|
|||||||
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
|
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
|
||||||
expect(res).toMatchObject({ code: "invalid_params" });
|
expect(res).toMatchObject({ code: "invalid_params" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not claim any tab when chrome.tabs.create fails", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
await sm.start("aa11");
|
||||||
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
spies.create.mockRejectedValueOnce(new Error("chrome.tabs.create failed"));
|
||||||
|
|
||||||
|
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
||||||
|
expect(res).toMatchObject({ code: "protocol_error" });
|
||||||
|
|
||||||
|
expect(sm.get("aa11")?.agentCreatedTabs).toEqual(new Set([1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims the returned id even when a user tab appears during tab creation", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
let resolveCreate!: (tab: chrome.tabs.Tab) => void;
|
||||||
|
const createResult = new Promise<chrome.tabs.Tab>((resolve) => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
});
|
||||||
|
spies.create.mockImplementationOnce(() => createResult);
|
||||||
|
|
||||||
|
const pending = handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
||||||
|
// A user can create an unrelated same-window tab while Chrome is still
|
||||||
|
// resolving the agent request. There is no pending slot for it to steal.
|
||||||
|
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
||||||
|
resolveCreate({ id: 10, windowId: 100, active: true } as chrome.tabs.Tab);
|
||||||
|
|
||||||
|
await expect(pending).resolves.toMatchObject({ tab_id: 10 });
|
||||||
|
expect(ctx.agentCreatedTabs.has(10)).toBe(true);
|
||||||
|
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleTabClose", () => {
|
describe("handleTabClose", () => {
|
||||||
@@ -511,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");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const remove = vi.fn(async () => {});
|
const remove = vi.fn(async () => {});
|
||||||
const ensureActiveTab = vi.fn(async () => {});
|
const ensureActiveTab = vi.fn(async () => 1);
|
||||||
return { create, remove, ensureActiveTab };
|
return { create, remove, ensureActiveTab };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
|
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
|
||||||
import {
|
import {
|
||||||
type BorrowConfirmationApprover,
|
type BorrowConfirmationApprover,
|
||||||
|
chromeTabMutationApi,
|
||||||
handleTabBorrow,
|
handleTabBorrow,
|
||||||
handleTabClose,
|
handleTabClose,
|
||||||
handleTabCreate,
|
handleTabCreate,
|
||||||
@@ -119,6 +120,8 @@ export interface DispatcherDeps {
|
|||||||
onSessionsChanged?: () => void;
|
onSessionsChanged?: () => void;
|
||||||
/** Invoked before a tool that dispatches page input or mutates browser state is forwarded. */
|
/** Invoked before a tool that dispatches page input or mutates browser state is forwarded. */
|
||||||
onBrowserControlResumed?: (sessionId: string) => void;
|
onBrowserControlResumed?: (sessionId: string) => void;
|
||||||
|
/** Invoked after a tab is explicitly claimed so its overlay can be refreshed immediately. */
|
||||||
|
onAgentTabClaimed?: (tabId: number, windowId: number) => void;
|
||||||
/** User approval for `tool.tab_borrow` (overlay in content script). */
|
/** User approval for `tool.tab_borrow` (overlay in content script). */
|
||||||
approveBorrow?: BorrowConfirmationApprover;
|
approveBorrow?: BorrowConfirmationApprover;
|
||||||
/** i18n notification copy for `tool.request_help` (resolved per-call). */
|
/** i18n notification copy for `tool.request_help` (resolved per-call). */
|
||||||
@@ -148,6 +151,7 @@ export class ToolDispatcher {
|
|||||||
private readonly recording?: RecordRuntimeDeps;
|
private readonly recording?: RecordRuntimeDeps;
|
||||||
private readonly onSessionsChanged?: () => void;
|
private readonly onSessionsChanged?: () => void;
|
||||||
private readonly onBrowserControlResumed?: (sessionId: string) => void;
|
private readonly onBrowserControlResumed?: (sessionId: string) => void;
|
||||||
|
private readonly onAgentTabClaimed?: (tabId: number, windowId: number) => void;
|
||||||
private readonly approveBorrow?: BorrowConfirmationApprover;
|
private readonly approveBorrow?: BorrowConfirmationApprover;
|
||||||
private readonly helpNotificationCopy?: () => { title: string; body: string };
|
private readonly helpNotificationCopy?: () => { title: string; body: string };
|
||||||
private subscription: { dispose(): void } | null = null;
|
private subscription: { dispose(): void } | null = null;
|
||||||
@@ -168,6 +172,7 @@ export class ToolDispatcher {
|
|||||||
this.recording = deps.recording;
|
this.recording = deps.recording;
|
||||||
this.onSessionsChanged = deps.onSessionsChanged;
|
this.onSessionsChanged = deps.onSessionsChanged;
|
||||||
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
||||||
|
this.onAgentTabClaimed = deps.onAgentTabClaimed;
|
||||||
this.approveBorrow = deps.approveBorrow;
|
this.approveBorrow = deps.approveBorrow;
|
||||||
this.helpNotificationCopy = deps.helpNotificationCopy;
|
this.helpNotificationCopy = deps.helpNotificationCopy;
|
||||||
}
|
}
|
||||||
@@ -303,13 +308,25 @@ export class ToolDispatcher {
|
|||||||
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
|
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
|
||||||
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
|
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
|
||||||
cdp: this.cdp,
|
cdp: this.cdp,
|
||||||
|
// Must be wired in production: the agent-tab cleanup and the
|
||||||
|
// window-release decision (issue #57) read these deps directly
|
||||||
|
// and silently no-op when they are absent.
|
||||||
|
tabManagement: { tabs: chromeTabMutationApi },
|
||||||
|
tabsQuery: chromeTabsApi,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case "tool.tab_list":
|
case "tool.tab_list":
|
||||||
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
|
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
|
||||||
case "tool.tab_create":
|
case "tool.tab_create": {
|
||||||
return handleTabCreate(this.sessions, req.params as TabCreateParams, { signal });
|
const result = await handleTabCreate(this.sessions, req.params as TabCreateParams, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!isRpcError(result)) {
|
||||||
|
this.onAgentTabClaimed?.(result.tab_id, result.window_id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
case "tool.tab_close":
|
case "tool.tab_close":
|
||||||
return this.withHoverReleaseForRequest(
|
return this.withHoverReleaseForRequest(
|
||||||
req.params as TabCloseParams,
|
req.params as TabCloseParams,
|
||||||
@@ -318,13 +335,22 @@ export class ToolDispatcher {
|
|||||||
);
|
);
|
||||||
case "tool.tab_select":
|
case "tool.tab_select":
|
||||||
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
|
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
|
||||||
case "tool.tab_borrow":
|
case "tool.tab_borrow": {
|
||||||
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
|
const result = await handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
|
||||||
signal,
|
signal,
|
||||||
approveBorrow: this.approveBorrow,
|
approveBorrow: this.approveBorrow,
|
||||||
});
|
});
|
||||||
|
if (!isRpcError(result)) {
|
||||||
|
this.onAgentTabClaimed?.(result.tab_id, result.agent_window_id);
|
||||||
|
}
|
||||||
|
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,53 @@ type SelectMutationResult =
|
|||||||
// tool.fill
|
// tool.fill
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FillFailureReason =
|
||||||
|
| "target_not_fillable"
|
||||||
|
| "fill_value_invalid"
|
||||||
|
| "fill_target_changed"
|
||||||
|
| "fill_focus_lost"
|
||||||
|
| "fill_value_mismatch"
|
||||||
|
| "fill_failed";
|
||||||
|
|
||||||
|
type FillPreparation =
|
||||||
|
| { before: string; expected: string }
|
||||||
|
| { reason: FillFailureReason; error: string };
|
||||||
|
|
||||||
|
type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost";
|
||||||
|
|
||||||
|
function fillError(reason: FillFailureReason, message: string): RpcError {
|
||||||
|
return rpcError(
|
||||||
|
reason === "target_not_fillable" || reason === "fill_value_invalid"
|
||||||
|
? "invalid_params"
|
||||||
|
: "cdp_failed",
|
||||||
|
reason,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -658,12 +707,8 @@ export async function handleFill(
|
|||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
|
||||||
code: "cdp_failed",
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
@@ -676,57 +721,252 @@ export async function handleFill(
|
|||||||
const clearBefore = params.clear_before ?? true;
|
const clearBefore = params.clear_before ?? true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (clearBefore) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
// Clear input/textarea value or wipe contenteditable innerText,
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
// then fire `input` so frameworks observe the empty state.
|
}
|
||||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
try {
|
||||||
objectId,
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||||
functionDeclaration: `function() {
|
} catch (error) {
|
||||||
if (this.isContentEditable) { this.textContent = ''; }
|
// Chrome rejects DOM.focus for disabled controls, including inherited
|
||||||
else {
|
// fieldset state. Classify the live target before treating this as a
|
||||||
const proto = this instanceof HTMLTextAreaElement
|
// browser failure; the normal path needs no extra round trip.
|
||||||
? HTMLTextAreaElement.prototype
|
const editable = await nodeCdp.send<FillScriptReply<boolean>>(
|
||||||
: HTMLInputElement.prototype;
|
target.tabId,
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
"Runtime.callFunctionOn",
|
||||||
if (descriptor && descriptor.set) descriptor.set.call(this, '');
|
{ objectId, functionDeclaration: FILL_EDITABLE_FUNCTION, returnByValue: true },
|
||||||
else this.value = '';
|
);
|
||||||
}
|
if (throwIfAborted(deps.signal)) {
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}`,
|
}
|
||||||
returnByValue: true,
|
if (!editable.exceptionDetails && editable.result?.value === false) {
|
||||||
});
|
return fillError(
|
||||||
|
"target_not_fillable",
|
||||||
|
"fill target is not editable or its input type is unsupported",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
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 { reason: 'target_not_fillable', error: 'fill target is not editable or its input type is unsupported' };
|
||||||
|
if (this.getRootNode().activeElement !== this) return { reason: 'fill_focus_lost', error: 'fill target does not have focus' };
|
||||||
|
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
|
||||||
|
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 { reason: 'fill_value_invalid', 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 { reason: 'fill_value_invalid', 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 };
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: params.value }, { value: clearBefore }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (prepared.exceptionDetails) {
|
||||||
|
return fillError("fill_failed", "fill preparation script failed");
|
||||||
|
}
|
||||||
|
const preparation = prepared.result?.value;
|
||||||
|
if (preparation && "error" in preparation) {
|
||||||
|
return fillError(preparation.reason, preparation.error);
|
||||||
|
}
|
||||||
|
if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") {
|
||||||
|
return fillError("fill_failed", "fill preparation returned an unexpected result");
|
||||||
|
}
|
||||||
|
// A separate call runs after the clearing event's microtasks drain.
|
||||||
|
// DOM focus works in background tabs. Use document.hasFocus() only to
|
||||||
|
// detect deferred focus events, never to reject background input.
|
||||||
|
const checkReady = async (): Promise<FillReadiness | RpcError> => {
|
||||||
|
const reply = await nodeCdp.send<FillScriptReply<FillReadiness>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function(before) {
|
||||||
|
if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
|
||||||
|
(${FILL_VALUE_FUNCTION}).call(this) !== before) return 'fill_target_changed';
|
||||||
|
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
|
||||||
|
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: preparation.before }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const state = reply.result?.value;
|
||||||
|
if (
|
||||||
|
reply.exceptionDetails ||
|
||||||
|
(state !== "ready" &&
|
||||||
|
state !== "background" &&
|
||||||
|
state !== "fill_target_changed" &&
|
||||||
|
state !== "fill_focus_lost")
|
||||||
|
) {
|
||||||
|
return fillError(
|
||||||
|
"fill_failed",
|
||||||
|
"fill readiness script failed or returned an unexpected result",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
let ready = await checkReady();
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
// Restore focus once only if clearing left this same target editable
|
||||||
|
// and unchanged. Recheck after focus handlers have run.
|
||||||
|
if (clearBefore && ready === "fill_focus_lost") {
|
||||||
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
ready = await checkReady();
|
||||||
|
}
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (typeof ready !== "string") return ready;
|
||||||
|
if (ready !== "ready" && ready !== "background") {
|
||||||
|
return fillError(ready, "fill target changed or lost focus before typing");
|
||||||
|
}
|
||||||
|
if (params.value !== "" && (!clearBefore || ready === "background")) {
|
||||||
|
// Native editing commands also support number/email inputs, whose
|
||||||
|
// selection APIs cannot set a caret, and multiline contenteditables.
|
||||||
|
// Also do this for background replacement: CDP input focuses the
|
||||||
|
// renderer, delivering deferred focus events before it inserts text.
|
||||||
|
// Use a command-only event so this does not invoke an End shortcut.
|
||||||
|
try {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
|
type: "rawKeyDown",
|
||||||
|
commands: ["moveToEndOfDocument"],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" });
|
||||||
|
}
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
// Focus and key handlers can change the target or move focus too.
|
||||||
|
ready = await checkReady();
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (typeof ready !== "string") return ready;
|
||||||
|
if (ready !== "ready" && ready !== "background") {
|
||||||
|
return fillError(ready, "fill target changed or lost focus while positioning the caret");
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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.
|
||||||
|
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
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 fillError("fill_failed", "fill notification script failed");
|
||||||
|
}
|
||||||
|
const verified = await nodeCdp.send<
|
||||||
|
FillScriptReply<{ connected: boolean; matches: boolean; valueLength: number }>
|
||||||
|
>(target.tabId, "Runtime.callFunctionOn", {
|
||||||
objectId,
|
objectId,
|
||||||
functionDeclaration: `function() {
|
functionDeclaration: `function(expected) {
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
const value = (${FILL_VALUE_FUNCTION}).call(this);
|
||||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
return { connected: this.isConnected, matches: value === expected, valueLength: value.length };
|
||||||
}`,
|
}`,
|
||||||
|
arguments: [{ value: preparation.expected }],
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
const verification = verified.result?.value;
|
||||||
|
if (
|
||||||
|
verified.exceptionDetails ||
|
||||||
|
typeof verification?.connected !== "boolean" ||
|
||||||
|
typeof verification?.matches !== "boolean" ||
|
||||||
|
typeof verification?.valueLength !== "number"
|
||||||
|
) {
|
||||||
|
return fillError(
|
||||||
|
"fill_failed",
|
||||||
|
"fill verification script failed or returned an unexpected result",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!verification.connected) {
|
||||||
|
return fillError("fill_target_changed", "fill target was removed or replaced during input");
|
||||||
|
}
|
||||||
|
if (!verification.matches) {
|
||||||
|
return fillError(
|
||||||
|
"fill_value_mismatch",
|
||||||
|
"fill could not verify the expected value; observe the page before deciding whether to retry",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
|
tab_id: target.tabId,
|
||||||
|
used_ref: node.usedRef,
|
||||||
|
used_selector: node.usedSelector,
|
||||||
|
value_length: verification.valueLength,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
|
||||||
code: "cdp_failed",
|
} finally {
|
||||||
message: err instanceof Error ? err.message : String(err),
|
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,6 +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 { CdpRunner, ChromeTabsApi } from "./shared";
|
||||||
import { isRpcError } from "./shared";
|
import { isRpcError } from "./shared";
|
||||||
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
||||||
|
|
||||||
@@ -73,11 +74,17 @@ export interface SessionStopResult {
|
|||||||
/** Tab ids whose return path failed; those entries remain borrowed so
|
/** Tab ids whose return path failed; those entries remain borrowed so
|
||||||
* shutdown can be retried without closing the Agent Window. */
|
* shutdown can be retried without closing the Agent Window. */
|
||||||
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
|
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
|
||||||
|
/**
|
||||||
|
* True when the Agent Window was *released* to the user (instead of being
|
||||||
|
* closed) because it still contained user-created tabs after the agent's own
|
||||||
|
* tabs were closed. Single-session scope.
|
||||||
|
*/
|
||||||
|
window_released?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +94,13 @@ export interface SessionStopDeps {
|
|||||||
* without a real browser.
|
* without a real browser.
|
||||||
*/
|
*/
|
||||||
tabManagement?: TabManagementDeps;
|
tabManagement?: TabManagementDeps;
|
||||||
|
/**
|
||||||
|
* Read-only Chrome tabs API used to *query* remaining tabs before deciding
|
||||||
|
* whether to release the window. Kept separate from `tabManagement.tabs`
|
||||||
|
* (a `TabMutationApi` that has no `query`) so the mutation interface stays
|
||||||
|
* pure. Tests can inject a fake `ChromeTabsApi` too.
|
||||||
|
*/
|
||||||
|
tabsQuery?: ChromeTabsApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,8 +166,10 @@ export async function handleSessionStart(
|
|||||||
* cleanly (review parity with M6).
|
* cleanly (review parity with M6).
|
||||||
* 3. Detach CDP sessions the extension still holds for this
|
* 3. Detach CDP sessions the extension still holds for this
|
||||||
* session (no-op if M6/M7 didn't attach to any tab).
|
* session (no-op if M6/M7 didn't attach to any tab).
|
||||||
* 4. Close the Agent Window. SessionManager.stop() removes the
|
* 4. Close the agent-created tabs and the home tab (design §3.2).
|
||||||
* Chrome window and forgets the context.
|
* 5. If any tab remains — those are user-created tabs — release the
|
||||||
|
* window to the user (dropOnly) instead of closing it, so user
|
||||||
|
* tabs survive. Otherwise close the window as before.
|
||||||
*
|
*
|
||||||
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
|
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
|
||||||
* may still be there, so closing the window would risk losing user
|
* may still be there, so closing the window would risk losing user
|
||||||
@@ -188,15 +204,19 @@ export async function handleSessionStop(
|
|||||||
const returnFailures: SessionStopResult["return_failures"] = [];
|
const returnFailures: SessionStopResult["return_failures"] = [];
|
||||||
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
|
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
|
||||||
for (const tabId of borrowedIds) {
|
for (const tabId of borrowedIds) {
|
||||||
|
// A close event may have removed this borrow while another tab returned.
|
||||||
|
if (!ctx.borrowedTabs.has(tabId)) continue;
|
||||||
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 ??
|
||||||
((windowId: number) => manager.findByWindowId(windowId) !== null),
|
((windowId: number) => manager.findByWindowId(windowId) !== null),
|
||||||
};
|
};
|
||||||
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
|
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
|
||||||
|
if (!ctx.borrowedTabs.has(tabId)) continue;
|
||||||
if (typeof outcome === "object" && "code" in outcome) {
|
if (typeof outcome === "object" && "code" in outcome) {
|
||||||
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
|
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
|
||||||
returnFailures?.push({
|
returnFailures?.push({
|
||||||
@@ -209,6 +229,7 @@ export async function handleSessionStop(
|
|||||||
ctx.borrowedTabs.delete(tabId);
|
ctx.borrowedTabs.delete(tabId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!ctx.borrowedTabs.has(tabId)) continue;
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
|
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
|
||||||
returnFailures?.push({
|
returnFailures?.push({
|
||||||
@@ -219,8 +240,15 @@ export async function handleSessionStop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A previously failed tab may have closed during a later return. Only
|
||||||
|
// confirmed close events remove borrows; ordinary move failures stay retryable.
|
||||||
|
const pendingReturnFailures = returnFailures.filter((failure) =>
|
||||||
|
ctx.borrowedTabs.has(failure.tab_id),
|
||||||
|
);
|
||||||
if (deps.signal?.aborted) {
|
if (deps.signal?.aborted) {
|
||||||
const nonCancelFailures = returnFailures?.filter((failure) => failure.code !== "cancelled");
|
const nonCancelFailures = pendingReturnFailures.filter(
|
||||||
|
(failure) => failure.code !== "cancelled",
|
||||||
|
);
|
||||||
if (nonCancelFailures && nonCancelFailures.length > 0) {
|
if (nonCancelFailures && nonCancelFailures.length > 0) {
|
||||||
return {
|
return {
|
||||||
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
|
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
|
||||||
@@ -232,8 +260,8 @@ export async function handleSessionStop(
|
|||||||
|
|
||||||
const result: SessionStopResult = {};
|
const result: SessionStopResult = {};
|
||||||
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
|
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
|
||||||
if (returnFailures && returnFailures.length > 0) {
|
if (pendingReturnFailures.length > 0) {
|
||||||
result.return_failures = returnFailures;
|
result.return_failures = pendingReturnFailures;
|
||||||
// A failed return means at least one borrowed user tab may still be
|
// A failed return means at least one borrowed user tab may still be
|
||||||
// inside the Agent Window. Keep the session/window alive so the user
|
// inside the Agent Window. Keep the session/window alive so the user
|
||||||
// can retry `bsk session stop` or explicitly `bsk tab return` after the
|
// can retry `bsk session stop` or explicitly `bsk tab return` after the
|
||||||
@@ -253,8 +281,66 @@ export async function handleSessionStop(
|
|||||||
return { code: "cancelled", message: "session_stop aborted before window close" };
|
return { code: "cancelled", message: "session_stop aborted before window close" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: close the Agent Window and drop the context.
|
// Step 4: close every tab explicitly created by the agent, including the
|
||||||
await manager.stop(params.session_id);
|
// home tab. `tabsApi` is a
|
||||||
|
// TabMutationApi (remove only); `queryApi` is a separate read-only
|
||||||
|
// ChromeTabsApi. If neither is injected, we conservatively fall back to
|
||||||
|
// closing the window (see Step 5).
|
||||||
|
const tabsApi = deps.tabManagement?.tabs;
|
||||||
|
const queryApi = deps.tabsQuery;
|
||||||
|
|
||||||
|
if (tabsApi) {
|
||||||
|
// Close each agent-created tab that still exists.
|
||||||
|
const agentCreatedTabIds = Array.from(ctx.agentCreatedTabs);
|
||||||
|
for (const tabId of agentCreatedTabIds) {
|
||||||
|
try {
|
||||||
|
await tabsApi.remove(tabId);
|
||||||
|
ctx.agentCreatedTabs.delete(tabId);
|
||||||
|
} catch (err) {
|
||||||
|
// Tab may already be gone (closed by the user). Non-fatal.
|
||||||
|
console.warn(`[bsk session_stop] failed to close agent tab ${tabId}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: decide whether to release (keep) the window or close it.
|
||||||
|
let shouldRelease = false;
|
||||||
|
if (queryApi) {
|
||||||
|
try {
|
||||||
|
const liveWindowTabs = await queryApi.query({ windowId: ctx.agentWindowId });
|
||||||
|
// Only genuine *user* tabs count toward keeping the window open.
|
||||||
|
// An agent tab that failed to close in Step 4 may still be present
|
||||||
|
// here; if we counted it as a reason to release (dropOnly), the
|
||||||
|
// window would be kept and that agent tab would leak (issue #57
|
||||||
|
// regression). Exclude any id still tracked in agentCreatedTabs.
|
||||||
|
const userTabs = liveWindowTabs.filter((t) => {
|
||||||
|
if (t.id === undefined) return false;
|
||||||
|
return !ctx.agentCreatedTabs.has(t.id);
|
||||||
|
});
|
||||||
|
const leakedAgentTabs = liveWindowTabs.filter(
|
||||||
|
(t) => t.id !== undefined && ctx.agentCreatedTabs.has(t.id),
|
||||||
|
);
|
||||||
|
if (leakedAgentTabs.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[bsk session_stop] ${leakedAgentTabs.length} agent tab(s) failed to close; forcing window close instead of release`,
|
||||||
|
leakedAgentTabs.map((t) => t.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
shouldRelease = userTabs.length > 0;
|
||||||
|
} catch {
|
||||||
|
// Query failed (e.g. window already gone) — conservatively close it.
|
||||||
|
shouldRelease = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRelease) {
|
||||||
|
// Keep the window + its user tabs; only drop the session binding.
|
||||||
|
await manager.stop(params.session_id, { dropOnly: true });
|
||||||
|
result.window_released = true;
|
||||||
|
} else {
|
||||||
|
// Window is empty (or we couldn't verify state) — close it.
|
||||||
|
await manager.stop(params.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -369,6 +373,7 @@ function buildCreateProps(
|
|||||||
* Returns the created tab on success, or an `RpcError` on failure.
|
* Returns the created tab on success, or an `RpcError` on failure.
|
||||||
*/
|
*/
|
||||||
async function createTabAndCleanup(
|
async function createTabAndCleanup(
|
||||||
|
ctx: SessionContext,
|
||||||
deps: TabManagementDeps,
|
deps: TabManagementDeps,
|
||||||
createProps: chrome.tabs.CreateProperties,
|
createProps: chrome.tabs.CreateProperties,
|
||||||
): Promise<CreatedChromeTab | RpcError> {
|
): Promise<CreatedChromeTab | RpcError> {
|
||||||
@@ -381,25 +386,28 @@ async function createTabAndCleanup(
|
|||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (aborted(deps.signal, "tab_create")) {
|
|
||||||
// We already opened the tab; close it on abort so we don't leak.
|
|
||||||
if (typeof tab.id === "number") {
|
|
||||||
try {
|
|
||||||
await getTabsApi(deps).remove(tab.id);
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
return rpcError(
|
|
||||||
"protocol_error",
|
|
||||||
"cleanup_failed",
|
|
||||||
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
|
|
||||||
{ resource_type: "tab", resource_id: tab.id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { code: "cancelled", message: "tab_create aborted" };
|
|
||||||
}
|
|
||||||
if (typeof tab.id !== "number") {
|
if (typeof tab.id !== "number") {
|
||||||
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
|
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
|
||||||
}
|
}
|
||||||
|
// Claim the concrete id returned by Chrome. Ownership never depends on
|
||||||
|
// matching this request to an asynchronous onCreated event.
|
||||||
|
ctx.agentCreatedTabs.add(tab.id);
|
||||||
|
if (aborted(deps.signal, "tab_create")) {
|
||||||
|
try {
|
||||||
|
await getTabsApi(deps).remove(tab.id);
|
||||||
|
ctx.agentCreatedTabs.delete(tab.id);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
// Keep the claim when cleanup fails so session_stop can retry instead
|
||||||
|
// of releasing an agent-owned tab to the user.
|
||||||
|
return rpcError(
|
||||||
|
"protocol_error",
|
||||||
|
"cleanup_failed",
|
||||||
|
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
|
||||||
|
{ resource_type: "tab", resource_id: tab.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { code: "cancelled", message: "tab_create aborted" };
|
||||||
|
}
|
||||||
return { ...tab, id: tab.id };
|
return { ...tab, id: tab.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +434,7 @@ export async function handleTabCreate(
|
|||||||
const paramErr = validateTabCreateParams(params);
|
const paramErr = validateTabCreateParams(params);
|
||||||
if (paramErr) return paramErr;
|
if (paramErr) return paramErr;
|
||||||
|
|
||||||
const tab = await createTabAndCleanup(deps, buildCreateProps(ctx, params));
|
const tab = await createTabAndCleanup(ctx, deps, buildCreateProps(ctx, params));
|
||||||
if (isRpcError(tab)) return tab;
|
if (isRpcError(tab)) return tab;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -526,6 +534,9 @@ export async function handleTabClose(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await getTabsApi(deps).remove(params.tab_id);
|
await getTabsApi(deps).remove(params.tab_id);
|
||||||
|
// Keep the tracking set accurate so session_stop won't try to close a
|
||||||
|
// tab that's already gone (design §3.1).
|
||||||
|
ctx.agentCreatedTabs.delete(params.tab_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "protocol_error",
|
code: "protocol_error",
|
||||||
@@ -753,13 +764,6 @@ async function executeBorrowCore(
|
|||||||
);
|
);
|
||||||
if (moveErr) return moveErr;
|
if (moveErr) return moveErr;
|
||||||
|
|
||||||
// Best-effort activation — failure is non-fatal.
|
|
||||||
try {
|
|
||||||
await p.tabsApi.update(p.tabId, { active: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.debug("[bsk tab_borrow] activate after move failed", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { originalWindowId, originalIndex };
|
return { originalWindowId, originalIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +827,12 @@ export async function handleTabBorrow(
|
|||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Activate after commit so overlay/event observers see the tab as claimed.
|
||||||
|
try {
|
||||||
|
await tabsApi.update(params.tab_id, { active: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("[bsk tab_borrow] activate after move failed", err);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tab_id: params.tab_id,
|
tab_id: params.tab_id,
|
||||||
original_window_id: coreResult.originalWindowId,
|
original_window_id: coreResult.originalWindowId,
|
||||||
@@ -939,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1022,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,
|
||||||
@@ -1064,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,
|
||||||
@@ -1110,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:
|
||||||
|
|||||||
@@ -215,21 +215,29 @@ function makeCdp(snapshot: unknown) {
|
|||||||
if (method === "Runtime.evaluate") {
|
if (method === "Runtime.evaluate") {
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
value: {
|
deepSerializedValue: {
|
||||||
controls: [
|
type: "array",
|
||||||
|
value: [
|
||||||
{
|
{
|
||||||
state: "filled",
|
type: "array",
|
||||||
sensitive: true,
|
value: [
|
||||||
defaultValue: "hunter2",
|
{ type: "node", value: { backendNodeId: 13 } },
|
||||||
value: "hunter2",
|
{
|
||||||
placeholder: "secret",
|
type: "string",
|
||||||
|
value: JSON.stringify({
|
||||||
|
state: "filled",
|
||||||
|
sensitive: true,
|
||||||
|
placeholder: "secret",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
childFrames: [],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (method === "Runtime.releaseObjectGroup") return {};
|
||||||
throw new Error(`unexpected ${method}`);
|
throw new Error(`unexpected ${method}`);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
@@ -273,8 +281,11 @@ describe("captureViewModel", () => {
|
|||||||
expect(cdp.send).toHaveBeenCalledWith(
|
expect(cdp.send).toHaveBeenCalledWith(
|
||||||
4,
|
4,
|
||||||
"Runtime.evaluate",
|
"Runtime.evaluate",
|
||||||
expect.objectContaining({ returnByValue: true }),
|
expect.objectContaining({
|
||||||
|
serializationOptions: expect.objectContaining({ serialization: "deep" }),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
expect(cdp.send).toHaveBeenCalledWith(4, "Runtime.releaseObjectGroup", expect.anything());
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
|
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
|
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
|
||||||
|
import { captureViewModel } from "../capture";
|
||||||
|
|
||||||
|
interface Control {
|
||||||
|
id: number;
|
||||||
|
value: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(groups: Control[][]) {
|
||||||
|
const strings: string[] = [];
|
||||||
|
const str = (value: string) => {
|
||||||
|
let index = strings.indexOf(value);
|
||||||
|
if (index < 0) index = strings.push(value) - 1;
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
const documents = groups.map((controls, groupIndex) => ({
|
||||||
|
frameId: `frame-${groupIndex}`,
|
||||||
|
nodes: {
|
||||||
|
parentIndex: [-1, 0, ...controls.map(() => 1)],
|
||||||
|
nodeName: [str("html"), str("body"), ...controls.map(() => str("input"))],
|
||||||
|
backendNodeId: [8000 + groupIndex * 2, 8001 + groupIndex * 2, ...controls.map((c) => c.id)],
|
||||||
|
attributes: [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
...controls.map((c) => [
|
||||||
|
str("type"),
|
||||||
|
str(c.type ?? "text"),
|
||||||
|
str("placeholder"),
|
||||||
|
str("snapshot placeholder"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
inputValue: {
|
||||||
|
index: controls.map((_, i) => i + 2),
|
||||||
|
value: controls.map((c) => str(c.value)),
|
||||||
|
},
|
||||||
|
contentDocumentIndex: { index: [] as number[], value: [] as number[] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const root = documents[0].nodes;
|
||||||
|
for (let i = 1; i < documents.length; i++) {
|
||||||
|
root.contentDocumentIndex.index.push(root.backendNodeId.length);
|
||||||
|
root.contentDocumentIndex.value.push(i);
|
||||||
|
root.parentIndex.push(1);
|
||||||
|
root.nodeName.push(str("iframe"));
|
||||||
|
root.backendNodeId.push(9000 + i);
|
||||||
|
root.attributes.push([]);
|
||||||
|
}
|
||||||
|
return { strings, documents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(value: string) {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "live placeholder",
|
||||||
|
state: value ? "filled" : "empty",
|
||||||
|
sensitive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reply(entries: Array<[number, unknown]>) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
deepSerializedValue: {
|
||||||
|
type: "array",
|
||||||
|
value: entries.map(([backendNodeId, fields]) => ({
|
||||||
|
type: "array",
|
||||||
|
value: [
|
||||||
|
{ type: "node", value: { backendNodeId } },
|
||||||
|
{ type: "string", value: JSON.stringify(fields) },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeCdp(groups: Control[][], evaluate: (params: Record<string, unknown>) => unknown) {
|
||||||
|
const snap = snapshot(groups);
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
if (method === "DOMSnapshot.captureSnapshot") return snap;
|
||||||
|
if (method === "Runtime.evaluate") return evaluate(params as Record<string, unknown>);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
return { send, cdp: { send: send as CdpRunner["send"] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("form state identity", () => {
|
||||||
|
it("matches reordered controls by identity and preserves controls missing from the batch", async () => {
|
||||||
|
// The snapshot includes a shadow input. The runtime query skips it,
|
||||||
|
// encounters a new input, and sees the remaining inputs in a new order.
|
||||||
|
const { cdp } = fakeCdp(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{ id: 10, value: "" },
|
||||||
|
{ id: 11, value: "old email" },
|
||||||
|
{ id: 12, value: "old city" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
() =>
|
||||||
|
reply([
|
||||||
|
[99, state("new control")],
|
||||||
|
[12, state("上海")],
|
||||||
|
[11, state("alice@example.com")],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const captured = await captureViewModel(cdp, 7);
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 10)).toMatchObject({
|
||||||
|
formValue: "",
|
||||||
|
formState: "empty",
|
||||||
|
formPlaceholder: "snapshot placeholder",
|
||||||
|
});
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||||
|
formValue: "alice@example.com",
|
||||||
|
});
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
|
||||||
|
expect(captured.nodes.some((n) => n.backendNodeId === 99)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
|
||||||
|
const { cdp } = fakeCdp(
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
[{ id: 11, value: "inaccessible frame" }],
|
||||||
|
[{ id: 21, value: "old second" }],
|
||||||
|
[{ id: 31, value: "old third" }],
|
||||||
|
],
|
||||||
|
() =>
|
||||||
|
reply([
|
||||||
|
[31, state("third frame")],
|
||||||
|
[21, state("second frame")],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const captured = await captureViewModel(cdp, 7);
|
||||||
|
const controls = [...captured.iframeNodes.values()].flat();
|
||||||
|
expect(controls.find((n) => n.backendNodeId === 11)?.formValue).toBe("inaccessible frame");
|
||||||
|
expect(controls.find((n) => n.backendNodeId === 21)?.formValue).toBe("second frame");
|
||||||
|
expect(controls.find((n) => n.backendNodeId === 31)?.formValue).toBe("third frame");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps identical backend ids in different OOPIF targets separate", async () => {
|
||||||
|
const sendToTarget = vi.fn(async (target, method) => {
|
||||||
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot([[{ id: 11, value: "old" }]]);
|
||||||
|
if (method === "Runtime.evaluate") return reply([[11, state(target.sessionId)]]);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const send = vi.fn(async () => {
|
||||||
|
throw new Error("unexpected top-level target");
|
||||||
|
});
|
||||||
|
const cdp = { send, sendToTarget } as unknown as CdpRunner;
|
||||||
|
for (const sessionId of ["left-frame", "right-frame"]) {
|
||||||
|
const captured = await captureViewModel(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7);
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
|
||||||
|
expect(sendToTarget).toHaveBeenCalledWith(
|
||||||
|
{ tabId: 7, sessionId },
|
||||||
|
"Runtime.releaseObjectGroup",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"unsupported",
|
||||||
|
"legacy",
|
||||||
|
"malformed",
|
||||||
|
"invalid-state",
|
||||||
|
])("preserves snapshot data when enrichment is %s", async (mode) => {
|
||||||
|
const { cdp, send } = fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
|
||||||
|
if (mode === "unsupported") throw new Error("serialization unsupported");
|
||||||
|
if (mode === "legacy") return { result: { value: { controls: [state("wrong value")] } } };
|
||||||
|
const result = reply([
|
||||||
|
[11, mode === "invalid-state" ? { value: "wrong value" } : state("wrong value")],
|
||||||
|
]);
|
||||||
|
if (mode === "malformed")
|
||||||
|
result.result.deepSerializedValue.value[0].value[1].value = "not json";
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const captured = await captureViewModel(cdp, 7);
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||||
|
formValue: "snapshot value",
|
||||||
|
formPlaceholder: "snapshot placeholder",
|
||||||
|
});
|
||||||
|
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the batch's remote objects when capture is cancelled", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { cdp, send } = fakeCdp([[{ id: 11, value: "old" }]], () => {
|
||||||
|
controller.abort();
|
||||||
|
return reply([[11, state("new")]]);
|
||||||
|
});
|
||||||
|
await expect(captureViewModel(cdp, 7, { signal: controller.signal })).rejects.toMatchObject({
|
||||||
|
name: "AbortError",
|
||||||
|
});
|
||||||
|
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips entries without node identity while still applying valid entries", async () => {
|
||||||
|
const { cdp } = fakeCdp(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{ id: 11, value: "snapshot value" },
|
||||||
|
{ id: 12, value: "old value" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
const result = reply([
|
||||||
|
[11, state("wrong value")],
|
||||||
|
[12, state("live value")],
|
||||||
|
]);
|
||||||
|
const element = result.result.deepSerializedValue.value[0].value[0];
|
||||||
|
delete (element.value as { backendNodeId?: number }).backendNodeId;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const captured = await captureViewModel(cdp, 7);
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"password",
|
||||||
|
"text",
|
||||||
|
])("redacts a sensitive %s control after identity matching", async (type) => {
|
||||||
|
const { cdp } = fakeCdp([[{ id: 11, value: "secret", type }]], () =>
|
||||||
|
reply([[11, { ...state("secret"), sensitive: true }]]),
|
||||||
|
);
|
||||||
|
const captured = await captureViewModel(cdp, 7);
|
||||||
|
const control = captured.nodes.find((n) => n.backendNodeId === 11);
|
||||||
|
expect(control?.formState).toBe("filled");
|
||||||
|
expect(control?.formValue).toBeUndefined();
|
||||||
|
expect(control?.formDefaultValue).toBeUndefined();
|
||||||
|
expect(control?.attrs.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one bounded batch without per-control CDP queries", async () => {
|
||||||
|
const controls = Array.from({ length: 251 }, (_, i) => ({ id: i + 1, value: "old" }));
|
||||||
|
const elements = controls.map((c) => ({
|
||||||
|
backendNodeId: c.id,
|
||||||
|
tagName: "INPUT",
|
||||||
|
type: "text",
|
||||||
|
value: `live ${c.id}`,
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "",
|
||||||
|
}));
|
||||||
|
const { cdp, send } = fakeCdp([controls], (params) => {
|
||||||
|
const doc = {
|
||||||
|
querySelectorAll: (selector: string) =>
|
||||||
|
selector === "input,textarea,select" ? elements : [],
|
||||||
|
};
|
||||||
|
const entries = new Function("document", `return ${params.expression}`)(doc) as Array<
|
||||||
|
[{ backendNodeId: number }, string]
|
||||||
|
>;
|
||||||
|
expect(entries).toHaveLength(250);
|
||||||
|
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
|
||||||
|
});
|
||||||
|
const captured = await captureViewModel(cdp, 7);
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
|
||||||
|
expect(captured.nodes.find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
|
||||||
|
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
send.mock.calls.filter(([, method]) => method === "Runtime.releaseObjectGroup"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(send).not.toHaveBeenCalledWith(7, "DOM.resolveNode", expect.anything());
|
||||||
|
expect(send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -190,9 +190,9 @@ interface CapturedFormState {
|
|||||||
sensitive?: boolean;
|
sensitive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CapturedFrameFormState {
|
interface DeepSerializedValue {
|
||||||
controls: CapturedFormState[];
|
type: string;
|
||||||
childFrames: CapturedFrameFormState[];
|
value?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formStateBatchExpression(maxControls: number): string {
|
function formStateBatchExpression(maxControls: number): string {
|
||||||
@@ -215,43 +215,63 @@ function formStateBatchExpression(maxControls: number): string {
|
|||||||
...(sensitive ? {} : { value: rawValue, defaultValue }),
|
...(sensitive ? {} : { value: rawValue, defaultValue }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
const controls = [];
|
||||||
const collect = (doc) => {
|
const collect = (doc) => {
|
||||||
const controls = [];
|
|
||||||
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
|
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
controls.push(controlState(el));
|
// Deep serialization supplies the node's backend id. Keep the
|
||||||
|
// state as JSON so decoding needs no general-purpose V8 deserializer.
|
||||||
|
controls.push([el, JSON.stringify(controlState(el))]);
|
||||||
remaining -= 1;
|
remaining -= 1;
|
||||||
}
|
}
|
||||||
const childFrames = [];
|
|
||||||
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
let childDoc = null;
|
let childDoc = null;
|
||||||
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
|
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
|
||||||
childFrames.push(childDoc && remaining > 0 ? collect(childDoc) : { controls: [], childFrames: [] });
|
if (childDoc) collect(childDoc);
|
||||||
}
|
}
|
||||||
return { controls, childFrames };
|
|
||||||
};
|
};
|
||||||
return collect(document);
|
collect(document);
|
||||||
|
return controls;
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenFrameFormStates(
|
function formStatesByBackendId(result: RuntimeEvaluateReply): Map<number, CapturedFormState> {
|
||||||
frame: CapturedFrameFormState | undefined,
|
const states = new Map<number, CapturedFormState>();
|
||||||
out: CapturedFormState[][] = [],
|
const serialized = result.result?.deepSerializedValue;
|
||||||
): CapturedFormState[][] {
|
if (serialized?.type !== "array" || !Array.isArray(serialized.value)) return states;
|
||||||
if (!frame) return out;
|
for (const entry of serialized.value as DeepSerializedValue[]) {
|
||||||
out.push(Array.isArray(frame.controls) ? frame.controls : []);
|
if (entry?.type !== "array" || !Array.isArray(entry.value)) continue;
|
||||||
for (const child of Array.isArray(frame.childFrames) ? frame.childFrames : []) {
|
const [element, json] = entry.value as DeepSerializedValue[];
|
||||||
flattenFrameFormStates(child, out);
|
if (element?.type !== "node" || json?.type !== "string" || typeof json.value !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const backendNodeId = (element.value as { backendNodeId?: number } | undefined)?.backendNodeId;
|
||||||
|
if (typeof backendNodeId !== "number" || !Number.isSafeInteger(backendNodeId)) continue;
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(json.value) as CapturedFormState | null;
|
||||||
|
if (
|
||||||
|
!state ||
|
||||||
|
!["empty", "filled", "default"].includes(state.state ?? "") ||
|
||||||
|
typeof state.sensitive !== "boolean" ||
|
||||||
|
typeof state.placeholder !== "string" ||
|
||||||
|
(!state.sensitive &&
|
||||||
|
(typeof state.value !== "string" || typeof state.defaultValue !== "string"))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
states.set(backendNodeId, state);
|
||||||
|
} catch {
|
||||||
|
// A malformed entry must not overwrite the snapshot's own state.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): void {
|
function applyFormStates(nodes: CapturedNode[], states: Map<number, CapturedFormState>): void {
|
||||||
let index = 0;
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (!isFormControlTag(node.tag)) continue;
|
if (!isFormControlTag(node.tag)) continue;
|
||||||
const state = states[index];
|
const state = states.get(node.backendNodeId);
|
||||||
index += 1;
|
|
||||||
if (!state) continue;
|
if (!state) continue;
|
||||||
node.formState = state.state;
|
node.formState = state.state;
|
||||||
node.formPlaceholder = state.placeholder ?? "";
|
node.formPlaceholder = state.placeholder ?? "";
|
||||||
@@ -276,26 +296,37 @@ async function enrichFormControlStates(
|
|||||||
nodes.some((node) => isFormControlTag(node.tag)),
|
nodes.some((node) => isFormControlTag(node.tag)),
|
||||||
);
|
);
|
||||||
if (!hasControls) return;
|
if (!hasControls) return;
|
||||||
|
const objectGroup = `bsk-vom-forms-${crypto.randomUUID()}`;
|
||||||
try {
|
try {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
|
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
|
||||||
returnByValue: true,
|
objectGroup,
|
||||||
|
serializationOptions: {
|
||||||
|
serialization: "deep",
|
||||||
|
maxDepth: 3,
|
||||||
|
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
|
// Backend ids are scoped to this capture's CDP target. OOPIF targets
|
||||||
for (let i = 0; i < frameNodeGroups.length; i += 1) {
|
// use their own captureViewModel call and never share this lookup.
|
||||||
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
|
const states = formStatesByBackendId(result);
|
||||||
|
for (const nodes of frameNodeGroups) {
|
||||||
|
applyFormStates(nodes, states);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) throw error;
|
if (isAbortError(error)) throw error;
|
||||||
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
|
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
|
||||||
|
} finally {
|
||||||
|
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeEvaluateReply {
|
interface RuntimeEvaluateReply {
|
||||||
result?: {
|
result?: {
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
|
deepSerializedValue?: DeepSerializedValue;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export type RpcErrorReason =
|
|||||||
| "ref_not_found"
|
| "ref_not_found"
|
||||||
| "selector_not_found"
|
| "selector_not_found"
|
||||||
| "target_not_fillable"
|
| "target_not_fillable"
|
||||||
|
| "fill_value_invalid"
|
||||||
|
| "fill_target_changed"
|
||||||
|
| "fill_focus_lost"
|
||||||
|
| "fill_value_mismatch"
|
||||||
|
| "fill_failed"
|
||||||
| "target_not_select"
|
| "target_not_select"
|
||||||
| "option_not_found"
|
| "option_not_found"
|
||||||
| "single_select_value_count"
|
| "single_select_value_count"
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ intended. Read `bsk upload --help` and `bsk download --help` for all flags and e
|
|||||||
- Stale ref: observe again and retry the intended action once.
|
- Stale ref: observe again and retry the intended action once.
|
||||||
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
|
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
|
||||||
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
|
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
|
||||||
|
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
|
||||||
|
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
|
||||||
|
the remaining difference; do not blindly repeat fill or immediately request human help. For other
|
||||||
|
fill errors, follow the returned hint and inspect current state before retrying.
|
||||||
- Unsupported command: continue with available capabilities; suggest updating only when the missing
|
- Unsupported command: continue with available capabilities; suggest updating only when the missing
|
||||||
command is necessary.
|
command is necessary.
|
||||||
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
|
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ pub mod reason {
|
|||||||
pub const REF_NOT_FOUND: &str = "ref_not_found";
|
pub const REF_NOT_FOUND: &str = "ref_not_found";
|
||||||
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
|
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
|
||||||
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
|
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
|
||||||
|
pub const FILL_VALUE_INVALID: &str = "fill_value_invalid";
|
||||||
|
pub const FILL_TARGET_CHANGED: &str = "fill_target_changed";
|
||||||
|
pub const FILL_FOCUS_LOST: &str = "fill_focus_lost";
|
||||||
|
pub const FILL_VALUE_MISMATCH: &str = "fill_value_mismatch";
|
||||||
|
pub const FILL_FAILED: &str = "fill_failed";
|
||||||
pub const TARGET_NOT_SELECT: &str = "target_not_select";
|
pub const TARGET_NOT_SELECT: &str = "target_not_select";
|
||||||
pub const OPTION_NOT_FOUND: &str = "option_not_found";
|
pub const OPTION_NOT_FOUND: &str = "option_not_found";
|
||||||
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
|
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
|
||||||
@@ -255,7 +260,42 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
|
|||||||
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
|
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
|
||||||
summary: "target element is not fillable",
|
summary: "target element is not fillable",
|
||||||
hint: Some(
|
hint: Some(
|
||||||
"choose an input, textarea, or contenteditable element from the latest snapshot",
|
"observe the page and choose an enabled, editable text input, textarea, or contenteditable; use the appropriate interaction for other control types",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
|
(ErrorCode::InvalidParams, reason::FILL_VALUE_INVALID) => RenderInfo {
|
||||||
|
summary: "the requested text does not fit the field constraints",
|
||||||
|
hint: Some(
|
||||||
|
"check the field's input type and maxlength; use a compatible value without silently truncating or changing the user's intended data",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
|
(ErrorCode::CdpFailed, reason::FILL_TARGET_CHANGED) => RenderInfo {
|
||||||
|
summary: "the fill target changed during the action",
|
||||||
|
hint: Some(
|
||||||
|
"observe the current page and field value; if the intended result is missing, use a fresh editable target before retrying",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
|
(ErrorCode::CdpFailed, reason::FILL_FOCUS_LOST) => RenderInfo {
|
||||||
|
summary: "the page moved focus away from the fill target",
|
||||||
|
hint: Some(
|
||||||
|
"observe the page for a dialog or rerender and resolve it before retrying; fill manages field focus and does not require bringing the browser to the foreground",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
|
(ErrorCode::CdpFailed, reason::FILL_VALUE_MISMATCH) => RenderInfo {
|
||||||
|
summary: "the fill result could not be confirmed",
|
||||||
|
hint: Some(
|
||||||
|
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent; otherwise correct the remaining difference. Do not blindly repeat fill or immediately request human help",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
|
(ErrorCode::CdpFailed, reason::FILL_FAILED) => RenderInfo {
|
||||||
|
summary: "fill encountered a browser or page-script error",
|
||||||
|
hint: Some(
|
||||||
|
"observe the page before retrying because the field may already have changed; retry only if the intended result is missing and the target is still available",
|
||||||
),
|
),
|
||||||
exit_code: base.exit_code,
|
exit_code: base.exit_code,
|
||||||
},
|
},
|
||||||
@@ -450,6 +490,56 @@ mod tests {
|
|||||||
ErrorCode::NoBrowserConnected,
|
ErrorCode::NoBrowserConnected,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fill_reasons_provide_recovery_without_changing_exit_codes() {
|
||||||
|
for (code, reason, hint_fragment) in [
|
||||||
|
(
|
||||||
|
ErrorCode::InvalidParams,
|
||||||
|
reason::TARGET_NOT_FILLABLE,
|
||||||
|
"enabled, editable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ErrorCode::InvalidParams,
|
||||||
|
reason::FILL_VALUE_INVALID,
|
||||||
|
"maxlength",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ErrorCode::CdpFailed,
|
||||||
|
reason::FILL_TARGET_CHANGED,
|
||||||
|
"fresh editable target",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ErrorCode::CdpFailed,
|
||||||
|
reason::FILL_FOCUS_LOST,
|
||||||
|
"does not require bringing the browser",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ErrorCode::CdpFailed,
|
||||||
|
reason::FILL_VALUE_MISMATCH,
|
||||||
|
"Continue if the visible result satisfies",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ErrorCode::CdpFailed,
|
||||||
|
reason::FILL_FAILED,
|
||||||
|
"may already have changed",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let data = serde_json::json!({ "reason": reason });
|
||||||
|
let info = info_for_error(code, Some(&data));
|
||||||
|
assert!(info.hint.unwrap().contains(hint_fragment), "{reason}");
|
||||||
|
assert_eq!(info.exit_code, info_for(code).exit_code);
|
||||||
|
}
|
||||||
|
let mismatch = serde_json::json!({ "reason": reason::FILL_VALUE_MISMATCH });
|
||||||
|
let info = info_for_error(ErrorCode::CdpFailed, Some(&mismatch));
|
||||||
|
assert_eq!(info.summary, "the fill result could not be confirmed");
|
||||||
|
assert!(info.hint.unwrap().contains("Do not blindly repeat fill"));
|
||||||
|
let unknown = serde_json::json!({ "reason": "future_fill_reason" });
|
||||||
|
assert_eq!(
|
||||||
|
info_for_error(ErrorCode::CdpFailed, Some(&unknown)),
|
||||||
|
info_for(ErrorCode::CdpFailed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn element_not_visible_overrides_permission_denied_copy() {
|
fn element_not_visible_overrides_permission_denied_copy() {
|
||||||
let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE });
|
let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE });
|
||||||
|
|||||||
@@ -761,6 +761,7 @@ fn stage_windows_replacement(
|
|||||||
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
||||||
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||||
let command = format!("\"{}\"", paths.script_path.display());
|
let command = format!("\"{}\"", paths.script_path.display());
|
||||||
if let Err(err) = Command::new("cmd.exe")
|
if let Err(err) = Command::new("cmd.exe")
|
||||||
.args(["/D", "/S", "/C"])
|
.args(["/D", "/S", "/C"])
|
||||||
@@ -768,7 +769,7 @@ fn stage_windows_replacement(
|
|||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
|
||||||
.spawn()
|
.spawn()
|
||||||
{
|
{
|
||||||
let _ = std::fs::remove_file(&paths.binary_path);
|
let _ = std::fs::remove_file(&paths.binary_path);
|
||||||
@@ -806,7 +807,7 @@ fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemo
|
|||||||
:retry\r\n\
|
:retry\r\n\
|
||||||
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
|
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
|
||||||
if errorlevel 1 (\r\n\
|
if errorlevel 1 (\r\n\
|
||||||
timeout /t 1 /nobreak >nul\r\n\
|
ping -n 2 127.0.0.1 >nul\r\n\
|
||||||
goto retry\r\n\
|
goto retry\r\n\
|
||||||
)\r\n\
|
)\r\n\
|
||||||
{restart}\
|
{restart}\
|
||||||
@@ -1007,6 +1008,8 @@ mod tests {
|
|||||||
|
|
||||||
assert!(script.contains(":retry"));
|
assert!(script.contains(":retry"));
|
||||||
assert!(script.contains("move /Y"));
|
assert!(script.contains("move /Y"));
|
||||||
|
assert!(script.contains("ping -n 2 127.0.0.1"));
|
||||||
|
assert!(!script.contains("timeout"));
|
||||||
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
|
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
|
||||||
assert!(script.contains(r#"del /F /Q "%~f0""#));
|
assert!(script.contains(r#"del /F /Q "%~f0""#));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1399,6 +1399,28 @@ mod windows {
|
|||||||
connected = pipe.connect() => {
|
connected = pipe.connect() => {
|
||||||
match connected {
|
match connected {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
// Keep the connected instance alive until its replacement
|
||||||
|
// exists. Otherwise a fast handler can close the last
|
||||||
|
// instance and make new clients fail with NotFound.
|
||||||
|
loop {
|
||||||
|
match ServerOptions::new()
|
||||||
|
.access_inbound(true)
|
||||||
|
.access_outbound(true)
|
||||||
|
.create(&listener.pipe_name)
|
||||||
|
{
|
||||||
|
Ok(next) => {
|
||||||
|
listener.first = Some(next);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(?err, "create next named-pipe instance failed");
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut shutdown => return,
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
on_open();
|
on_open();
|
||||||
let handler = handler.clone();
|
let handler = handler.clone();
|
||||||
let on_act = on_activity.clone();
|
let on_act = on_activity.clone();
|
||||||
@@ -1467,6 +1489,118 @@ mod windows {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::windows::named_pipe::ClientOptions;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
fn isolated_listener() -> NamedPipeListener {
|
||||||
|
let pipe_name = format!(r"\\.\pipe\bsk-test-{}", uuid::Uuid::new_v4());
|
||||||
|
let first = ServerOptions::new()
|
||||||
|
.first_pipe_instance(true)
|
||||||
|
.create(&pipe_name)
|
||||||
|
.unwrap();
|
||||||
|
NamedPipeListener {
|
||||||
|
pipe_name,
|
||||||
|
first: Some(first),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn next_instance_exists_before_connection_is_handed_off() {
|
||||||
|
let listener = isolated_listener();
|
||||||
|
let name = listener.pipe_name.clone();
|
||||||
|
let probe_name = name.clone();
|
||||||
|
let (probe_tx, probe_rx) = oneshot::channel();
|
||||||
|
let probe_tx = Mutex::new(Some(probe_tx));
|
||||||
|
let (stop_tx, stop_rx) = oneshot::channel();
|
||||||
|
let server = tokio::spawn(serve(
|
||||||
|
listener,
|
||||||
|
crate::daemon::ipc::default_ping_handler(),
|
||||||
|
move || {
|
||||||
|
if let Some(tx) = probe_tx.lock().unwrap().take() {
|
||||||
|
// This callback runs before the connection task can finish.
|
||||||
|
// A second client must already have an instance to open.
|
||||||
|
let result = ClientOptions::new().open(&probe_name).map(drop);
|
||||||
|
let _ = tx.send(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|| {},
|
||||||
|
|| {},
|
||||||
|
async {
|
||||||
|
let _ = stop_rx.await;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let client = ClientOptions::new().open(&name).unwrap();
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(5), probe_rx).await;
|
||||||
|
drop(client);
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
server.await.unwrap();
|
||||||
|
result
|
||||||
|
.expect("accept callback ran")
|
||||||
|
.unwrap()
|
||||||
|
.expect("next pipe instance is ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn rapid_disconnects_and_concurrent_rpc_connections() {
|
||||||
|
let listener = isolated_listener();
|
||||||
|
let name = listener.pipe_name.clone();
|
||||||
|
let (stop_tx, stop_rx) = oneshot::channel();
|
||||||
|
let server = tokio::spawn(serve(
|
||||||
|
listener,
|
||||||
|
crate::daemon::ipc::default_ping_handler(),
|
||||||
|
|| {},
|
||||||
|
|| {},
|
||||||
|
|| {},
|
||||||
|
async {
|
||||||
|
let _ = stop_rx.await;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let exercise = async {
|
||||||
|
// Include clients that close without sending any request.
|
||||||
|
for _ in 0..64 {
|
||||||
|
drop(
|
||||||
|
crate::ipc_client::Client::connect_path(name.clone().into())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut clients = tokio::task::JoinSet::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let name = name.clone();
|
||||||
|
clients.spawn(async move {
|
||||||
|
for _ in 0..32 {
|
||||||
|
let mut client =
|
||||||
|
crate::ipc_client::Client::connect_path(name.clone().into())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reply: bsk_protocol::PingResult = client
|
||||||
|
.call(
|
||||||
|
bsk_protocol::Method::SystemPing,
|
||||||
|
&serde_json::json!({}),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(reply.pong);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
while let Some(result) = clients.join_next().await {
|
||||||
|
result.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(30), exercise).await;
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
server.await.unwrap();
|
||||||
|
result.expect("connection exercise completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -90,17 +90,21 @@ pub fn pid_alive(pid: u32) -> bool {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use windows_sys::Win32::Foundation::CloseHandle;
|
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
|
||||||
use windows_sys::Win32::System::Threading::{
|
use windows_sys::Win32::System::Threading::{
|
||||||
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||||
};
|
};
|
||||||
unsafe {
|
unsafe {
|
||||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
|
||||||
if handle.is_null() {
|
if handle.is_null() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// An exited process can still be opened while another handle
|
||||||
|
// keeps its kernel object alive. Poll its termination signal;
|
||||||
|
// unlike checking STILL_ACTIVE, this also handles exit code 259.
|
||||||
|
let alive = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT;
|
||||||
CloseHandle(handle);
|
CloseHandle(handle);
|
||||||
true
|
alive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,3 +114,39 @@ pub fn pid_alive(pid: u32) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, windows))]
|
||||||
|
mod tests {
|
||||||
|
use super::pid_alive;
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_process_is_alive() {
|
||||||
|
assert!(pid_alive(std::process::id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_process_is_not_alive() {
|
||||||
|
assert!(!pid_alive(0));
|
||||||
|
assert!(!pid_alive(u32::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exited_process_with_retained_handle_is_not_alive() {
|
||||||
|
for exit_code in [0, 259] {
|
||||||
|
let mut child = Command::new("cmd.exe")
|
||||||
|
.args(["/D", "/C", &format!("exit {exit_code}")])
|
||||||
|
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn short-lived process");
|
||||||
|
assert_eq!(child.wait().unwrap().code(), Some(exit_code));
|
||||||
|
// Keep Child (and its process handle) alive during the probe.
|
||||||
|
assert!(
|
||||||
|
!pid_alive(child.id()),
|
||||||
|
"exited process with code {exit_code}"
|
||||||
|
);
|
||||||
|
drop(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ shell's theme cannot bleed back in.
|
|||||||
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
|
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
|
||||||
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
|
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
|
||||||
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
|
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
|
||||||
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
|
frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
|
||||||
stage while the next one loads, and is kept on errors so the card does not flash),
|
stage while the next one loads, and is kept on errors so the card does not flash),
|
||||||
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
|
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
|
||||||
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
|
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
|
||||||
@@ -129,11 +129,32 @@ shell's theme cannot bleed back in.
|
|||||||
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
|
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
|
||||||
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
|
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
|
||||||
in-page card with state intact. Browsers without Document PiP simply hide the button.
|
in-page card with state intact. Browsers without Document PiP simply hide the button.
|
||||||
- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE),
|
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
|
||||||
`POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/<attachmentId>` over
|
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
|
||||||
the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree
|
keep subscribing to state without requesting screenshots. The first screenshot subscriber
|
||||||
packages). All commands for one session — tool calls and frame captures alike — run through a
|
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
|
||||||
per-session FIFO, because the daemon accepts only one unfinished command per session.
|
An already running capture may finish. Demand applies to the service's owned sessions, not just
|
||||||
|
the selected session.
|
||||||
|
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
|
||||||
|
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
|
||||||
|
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
|
||||||
|
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
|
||||||
|
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
|
||||||
|
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
|
||||||
|
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
|
||||||
|
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
|
||||||
|
live clients should initialize and resynchronize from the stream's snapshot instead.
|
||||||
|
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
|
||||||
|
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
|
||||||
|
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
|
||||||
|
session — tool calls and frame captures alike — run through a per-session FIFO, because the
|
||||||
|
daemon accepts only one unfinished command per session.
|
||||||
|
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
|
||||||
|
subscribes to state without requesting screenshots. On an active service, the listener receives
|
||||||
|
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
|
||||||
|
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
|
||||||
|
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
|
||||||
|
demand. Subscribing after service disposal is a no-op.
|
||||||
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
|
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
|
||||||
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
|
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
|
||||||
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
|
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ Continue from returned sequence cursors instead of rereading the same buffer.
|
|||||||
- Unknown tab: list tabs instead of guessing.
|
- Unknown tab: list tabs instead of guessing.
|
||||||
- Unknown session: list owned sessions or start one; never try foreign ids.
|
- Unknown session: list owned sessions or start one; never try foreign ids.
|
||||||
- Timeout: inspect current state before deciding whether one longer purposeful wait is useful.
|
- Timeout: inspect current state before deciding whether one longer purposeful wait is useful.
|
||||||
|
- Fill result unconfirmed: observe the field first; the page may have formatted the value. Continue
|
||||||
|
if the visible result satisfies the user's intent. Otherwise correct the remaining difference;
|
||||||
|
do not blindly repeat fill or immediately request human help. For other fill errors, follow the
|
||||||
|
returned hint and inspect current state before retrying.
|
||||||
- Unrecoverable failure: report the blocker and stop the owned session.
|
- Unrecoverable failure: report the blocker and stop the owned session.
|
||||||
|
|
||||||
Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not
|
Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not
|
||||||
|
|||||||
@@ -142,6 +142,8 @@
|
|||||||
right: 6px;
|
right: 6px;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ import { createPortal } from "react-dom";
|
|||||||
import type { SessionObservation } from "../observation";
|
import type { SessionObservation } from "../observation";
|
||||||
import css from "./ObservationOverlay.module.css";
|
import css from "./ObservationOverlay.module.css";
|
||||||
import type { ObservationClientStore } from "./observation-store";
|
import type { ObservationClientStore } from "./observation-store";
|
||||||
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view";
|
import {
|
||||||
|
focusOf,
|
||||||
|
statusOf,
|
||||||
|
useObservationView,
|
||||||
|
usePip,
|
||||||
|
useThumbnailObservation,
|
||||||
|
} from "./observation-view";
|
||||||
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
|
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
|
||||||
|
|
||||||
// The pure view helpers live in observation-view (shared with the sidebar
|
// The pure view helpers live in observation-view (shared with the sidebar
|
||||||
@@ -341,6 +347,7 @@ export function OverlayBody(props: {
|
|||||||
onHeaderPointerDown,
|
onHeaderPointerDown,
|
||||||
} = props;
|
} = props;
|
||||||
const [interrupting, setInterrupting] = useState(false);
|
const [interrupting, setInterrupting] = useState(false);
|
||||||
|
const viewRef = useThumbnailObservation(store, sessions.length > 0);
|
||||||
|
|
||||||
const thumbId = focus?.thumbnailAttachmentId;
|
const thumbId = focus?.thumbnailAttachmentId;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -373,7 +380,12 @@ export function OverlayBody(props: {
|
|||||||
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
|
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(css.body, "bsk-obs")} data-state={state} data-in-pip={inPip || undefined}>
|
<div
|
||||||
|
ref={viewRef}
|
||||||
|
className={cn(css.body, "bsk-obs")}
|
||||||
|
data-state={state}
|
||||||
|
data-in-pip={inPip || undefined}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css.header}
|
className={css.header}
|
||||||
data-testid="obs-header"
|
data-testid="obs-header"
|
||||||
@@ -421,9 +433,15 @@ export function OverlayBody(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{thumb?.status === "error" ? (
|
{thumb?.status === "error" ? (
|
||||||
<span className={css.badge} aria-label="thumbnail failed">
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.badge}
|
||||||
|
aria-label="Retry thumbnail"
|
||||||
|
title="Frame unavailable — retry"
|
||||||
|
onClick={() => store.retryThumbnail(thumbId)}
|
||||||
|
>
|
||||||
<IconWarn size={12} />
|
<IconWarn size={12} />
|
||||||
</span>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{sessions.length >= 2 ? (
|
{sessions.length >= 2 ? (
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Client-side data layer for the observation overlay: initial state fetch,
|
* Client-side data layer for the observation overlay: an ordered SSE snapshot
|
||||||
* SSE increment stream (with a state refetch on every (re)open — events that
|
* and increment stream (each reconnect starts with a fresh snapshot), on-demand
|
||||||
* fired while the stream was down are otherwise lost forever), on-demand
|
* thumbnail blob loading through the host's ephemeral-frame route,
|
||||||
* thumbnail blob loading through the session-authorized readAttachment path,
|
|
||||||
* and the interrupt call. The last ready blob per session is held until its
|
* and the interrupt call. The last ready blob per session is held until its
|
||||||
* replacement decodes so the overlay can swap frames in place. All I/O is
|
* replacement loads so the overlay can swap frames in place. All I/O is
|
||||||
* injected so tests never touch a network.
|
* injected so tests never touch a network.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -12,8 +11,6 @@ import type { ObservationEvent, SessionObservation } from "../observation";
|
|||||||
|
|
||||||
export interface EventSourceLike {
|
export interface EventSourceLike {
|
||||||
onmessage: ((event: { data: string }) => void) | null;
|
onmessage: ((event: { data: string }) => void) | null;
|
||||||
/** Fires on the initial connect AND on every automatic reconnect. */
|
|
||||||
onopen?: (() => void) | null;
|
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +47,10 @@ export interface OverlaySnapshot {
|
|||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATE_URL = "/bsk-observation/state";
|
|
||||||
const EVENTS_URL = "/bsk-observation/events";
|
const EVENTS_URL = "/bsk-observation/events";
|
||||||
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
||||||
const STOP_URL = "/bsk-observation/stop";
|
const STOP_URL = "/bsk-observation/stop";
|
||||||
|
const THUMBNAIL_RETRY_DELAYS_MS = [1000, 3000];
|
||||||
|
|
||||||
function revoke(url: string | undefined): void {
|
function revoke(url: string | undefined): void {
|
||||||
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
||||||
@@ -66,8 +63,10 @@ export class ObservationClientStore {
|
|||||||
private thumbs = new Map<string, ThumbnailState>();
|
private thumbs = new Map<string, ThumbnailState>();
|
||||||
/** sessionId → the attachment id currently advertised for it. */
|
/** sessionId → the attachment id currently advertised for it. */
|
||||||
private readonly thumbBySession = new Map<string, string>();
|
private readonly thumbBySession = new Map<string, string>();
|
||||||
/** sessionId → last successfully decoded frame (held across the next load). */
|
/** sessionId → last successfully loaded frame (held across the next load). */
|
||||||
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
|
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
|
||||||
|
private readonly retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
private readonly retryCounts = new Map<string, number>();
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private events: EventSourceLike | undefined;
|
private events: EventSourceLike | undefined;
|
||||||
private snapshot: OverlaySnapshot = {
|
private snapshot: OverlaySnapshot = {
|
||||||
@@ -81,6 +80,7 @@ export class ObservationClientStore {
|
|||||||
private started = false;
|
private started = false;
|
||||||
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
|
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
|
||||||
private consumers = 0;
|
private consumers = 0;
|
||||||
|
private thumbnailConsumers = 0;
|
||||||
|
|
||||||
constructor(private readonly deps: ObservationClientDeps) {}
|
constructor(private readonly deps: ObservationClientDeps) {}
|
||||||
|
|
||||||
@@ -133,37 +133,16 @@ export class ObservationClientStore {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** (Re)pull the full state: initial load and every SSE (re)open. */
|
private connectEvents(): void {
|
||||||
private refreshState(): void {
|
const previous = this.events;
|
||||||
void this.deps
|
this.events = undefined;
|
||||||
.fetchFn(STATE_URL)
|
previous?.close();
|
||||||
.then(async (res) => {
|
const events = this.deps.eventSourceFactory(
|
||||||
if (!res.ok) return;
|
`${EVENTS_URL}?thumbnails=${this.thumbnailConsumers > 0 ? "1" : "0"}`,
|
||||||
const body = (await res.json()) as {
|
);
|
||||||
sessions?: SessionObservation[];
|
this.events = events;
|
||||||
available?: boolean;
|
|
||||||
};
|
|
||||||
const sessions = body.sessions ?? [];
|
|
||||||
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
|
|
||||||
// Prune thumbnails of sessions that vanished while we were away.
|
|
||||||
const alive = new Set(sessions.map((s) => s.sessionId));
|
|
||||||
for (const sessionId of [...this.thumbBySession.keys()]) {
|
|
||||||
if (!alive.has(sessionId)) this.dropThumb(sessionId);
|
|
||||||
}
|
|
||||||
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
|
|
||||||
if (typeof body.available === "boolean") this.available = body.available;
|
|
||||||
this.publish();
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Initial fetch + SSE subscription. Idempotent. */
|
|
||||||
start(): void {
|
|
||||||
if (this.started) return;
|
|
||||||
this.started = true;
|
|
||||||
this.refreshState();
|
|
||||||
const events = this.deps.eventSourceFactory(EVENTS_URL);
|
|
||||||
events.onmessage = (message) => {
|
events.onmessage = (message) => {
|
||||||
|
if (!this.started || this.events !== events) return;
|
||||||
let event: ObservationEvent;
|
let event: ObservationEvent;
|
||||||
try {
|
try {
|
||||||
event = JSON.parse(message.data) as ObservationEvent;
|
event = JSON.parse(message.data) as ObservationEvent;
|
||||||
@@ -172,23 +151,24 @@ export class ObservationClientStore {
|
|||||||
}
|
}
|
||||||
this.apply(event);
|
this.apply(event);
|
||||||
};
|
};
|
||||||
events.onopen = () => {
|
}
|
||||||
// Reconnects lose every event fired during the outage — resync.
|
|
||||||
if (this.events === events) this.refreshState();
|
/** Every initial connection and reconnect starts with the server's snapshot. */
|
||||||
};
|
start(): void {
|
||||||
this.events = events;
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
this.connectEvents();
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.events?.close();
|
const previous = this.events;
|
||||||
this.events = undefined;
|
this.events = undefined;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
for (const thumb of this.thumbs.values()) revoke(thumb.url);
|
previous?.close();
|
||||||
this.thumbs.clear();
|
this.clearThumbnails();
|
||||||
this.thumbBySession.clear();
|
|
||||||
this.lastReady.clear();
|
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
|
this.available = true;
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +189,33 @@ export class ObservationClientStore {
|
|||||||
if (this.consumers === 0) this.stop();
|
if (this.consumers === 0) this.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only visible image surfaces request screenshots; metadata watchers do not. */
|
||||||
|
watchThumbnails(): () => void {
|
||||||
|
this.thumbnailConsumers += 1;
|
||||||
|
if (this.thumbnailConsumers === 1 && this.started) this.connectEvents();
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
this.thumbnailConsumers -= 1;
|
||||||
|
if (this.thumbnailConsumers === 0 && this.started) this.connectEvents();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private forgetThumbnail(attachmentId: string): void {
|
||||||
|
clearTimeout(this.retryTimers.get(attachmentId));
|
||||||
|
this.retryTimers.delete(attachmentId);
|
||||||
|
this.retryCounts.delete(attachmentId);
|
||||||
|
revoke(this.thumbs.get(attachmentId)?.url);
|
||||||
|
this.thumbs.delete(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearThumbnails(): void {
|
||||||
|
for (const attachmentId of this.thumbs.keys()) this.forgetThumbnail(attachmentId);
|
||||||
|
this.thumbBySession.clear();
|
||||||
|
this.lastReady.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
||||||
private dropThumb(sessionId: string): void {
|
private dropThumb(sessionId: string): void {
|
||||||
const attachmentId = this.thumbBySession.get(sessionId);
|
const attachmentId = this.thumbBySession.get(sessionId);
|
||||||
@@ -216,39 +223,45 @@ export class ObservationClientStore {
|
|||||||
this.thumbBySession.delete(sessionId);
|
this.thumbBySession.delete(sessionId);
|
||||||
this.lastReady.delete(sessionId);
|
this.lastReady.delete(sessionId);
|
||||||
if (attachmentId !== undefined) {
|
if (attachmentId !== undefined) {
|
||||||
revoke(this.thumbs.get(attachmentId)?.url);
|
this.forgetThumbnail(attachmentId);
|
||||||
this.thumbs.delete(attachmentId);
|
|
||||||
}
|
}
|
||||||
if (held !== undefined && held.attachmentId !== attachmentId) {
|
if (held !== undefined && held.attachmentId !== attachmentId) {
|
||||||
revoke(held.url);
|
this.forgetThumbnail(held.attachmentId);
|
||||||
this.thumbs.delete(held.attachmentId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track the frame a session currently advertises. The last *ready* blob is
|
* Track the frame a session currently advertises. The last *ready* blob is
|
||||||
* kept until the replacement decodes — dropping it on the upsert is what
|
* kept until the replacement loads — dropping it on the upsert is what
|
||||||
* made the overlay flash a placeholder between breaths.
|
* made the overlay flash a placeholder between breaths.
|
||||||
*/
|
*/
|
||||||
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
|
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
|
||||||
|
if (attachmentId === undefined) {
|
||||||
|
this.dropThumb(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const previous = this.thumbBySession.get(sessionId);
|
const previous = this.thumbBySession.get(sessionId);
|
||||||
if (previous === attachmentId) return;
|
if (previous === attachmentId) return;
|
||||||
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId);
|
this.thumbBySession.set(sessionId, attachmentId);
|
||||||
else this.thumbBySession.delete(sessionId);
|
|
||||||
const heldId = this.lastReady.get(sessionId)?.attachmentId;
|
const heldId = this.lastReady.get(sessionId)?.attachmentId;
|
||||||
if (previous !== undefined && previous !== heldId) {
|
if (previous !== undefined && previous !== heldId) {
|
||||||
revoke(this.thumbs.get(previous)?.url);
|
this.forgetThumbnail(previous);
|
||||||
this.thumbs.delete(previous);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private apply(event: ObservationEvent): void {
|
private apply(event: ObservationEvent): void {
|
||||||
if (event.type === "reset") {
|
if (event.type === "snapshot") {
|
||||||
|
this.sessions = new Map(event.sessions.map((session) => [session.sessionId, session]));
|
||||||
|
for (const sessionId of this.thumbBySession.keys()) {
|
||||||
|
if (!this.sessions.has(sessionId)) this.dropThumb(sessionId);
|
||||||
|
}
|
||||||
|
for (const session of event.sessions) {
|
||||||
|
this.trackThumb(session.sessionId, session.thumbnailAttachmentId);
|
||||||
|
}
|
||||||
|
this.available = event.available;
|
||||||
|
} else if (event.type === "reset") {
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
for (const thumb of this.thumbs.values()) revoke(thumb.url);
|
this.clearThumbnails();
|
||||||
this.thumbs.clear();
|
|
||||||
this.thumbBySession.clear();
|
|
||||||
this.lastReady.clear();
|
|
||||||
} else if (event.type === "remove" && event.session !== undefined) {
|
} else if (event.type === "remove" && event.session !== undefined) {
|
||||||
this.sessions.delete(event.session.sessionId);
|
this.sessions.delete(event.session.sessionId);
|
||||||
this.dropThumb(event.session.sessionId);
|
this.dropThumb(event.session.sessionId);
|
||||||
@@ -259,44 +272,77 @@ export class ObservationClientStore {
|
|||||||
this.available = event.available;
|
this.available = event.available;
|
||||||
}
|
}
|
||||||
this.publish();
|
this.publish();
|
||||||
|
if (event.type === "snapshot") {
|
||||||
|
for (const session of event.sessions) this.retryThumbnail(session.thumbnailAttachmentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a thumbnail load is in flight for one attachment reference. New
|
* Ensure a thumbnail load is in flight for one attachment reference. New
|
||||||
* frames replace the old URL only after they decode; failures keep the last
|
* frames replace the old URL only after they load; failures keep the last
|
||||||
* good frame (the caller renders `status: 'error'` as a small badge over
|
* good frame (the caller renders `status: 'error'` as a small badge over
|
||||||
* the old image).
|
* the old image).
|
||||||
*/
|
*/
|
||||||
ensureThumbnail(attachmentId: string | undefined): void {
|
ensureThumbnail(attachmentId: string | undefined): void {
|
||||||
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return;
|
if (
|
||||||
this.thumbs.set(attachmentId, { status: "loading" });
|
!this.started ||
|
||||||
|
attachmentId === undefined ||
|
||||||
|
this.sessionOf(attachmentId) === undefined ||
|
||||||
|
this.thumbs.has(attachmentId)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const loading: ThumbnailState = { status: "loading" };
|
||||||
|
this.thumbs.set(attachmentId, loading);
|
||||||
this.publish();
|
this.publish();
|
||||||
this.deps.loadImage(attachmentId).then(
|
this.deps.loadImage(attachmentId).then(
|
||||||
(url) => {
|
(url) => {
|
||||||
if (!this.thumbs.has(attachmentId)) {
|
if (this.thumbs.get(attachmentId) !== loading) {
|
||||||
// Replaced or removed while loading: never resurrect (or leak) it.
|
// Replaced or removed while loading: never resurrect (or leak) it.
|
||||||
revoke(url);
|
revoke(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.retryCounts.delete(attachmentId);
|
||||||
this.thumbs.set(attachmentId, { status: "ready", url });
|
this.thumbs.set(attachmentId, { status: "ready", url });
|
||||||
const sessionId = this.sessionOf(attachmentId);
|
const sessionId = this.sessionOf(attachmentId);
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
const previous = this.lastReady.get(sessionId);
|
const previous = this.lastReady.get(sessionId);
|
||||||
if (previous !== undefined && previous.attachmentId !== attachmentId) {
|
if (previous !== undefined && previous.attachmentId !== attachmentId) {
|
||||||
revoke(previous.url);
|
this.forgetThumbnail(previous.attachmentId);
|
||||||
this.thumbs.delete(previous.attachmentId);
|
|
||||||
}
|
}
|
||||||
this.lastReady.set(sessionId, { attachmentId, url });
|
this.lastReady.set(sessionId, { attachmentId, url });
|
||||||
}
|
}
|
||||||
this.publish();
|
this.publish();
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.thumbs.set(attachmentId, { status: "error" });
|
if (this.thumbs.get(attachmentId) !== loading) return;
|
||||||
|
const failed: ThumbnailState = { status: "error" };
|
||||||
|
this.thumbs.set(attachmentId, failed);
|
||||||
|
const attempts = this.retryCounts.get(attachmentId) ?? 0;
|
||||||
|
const delay = THUMBNAIL_RETRY_DELAYS_MS[attempts];
|
||||||
|
if (delay !== undefined) {
|
||||||
|
this.retryCounts.set(attachmentId, attempts + 1);
|
||||||
|
this.retryTimers.set(
|
||||||
|
attachmentId,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.retryTimers.delete(attachmentId);
|
||||||
|
if (this.thumbs.get(attachmentId) !== failed) return;
|
||||||
|
this.thumbs.delete(attachmentId);
|
||||||
|
this.ensureThumbnail(attachmentId);
|
||||||
|
}, delay),
|
||||||
|
);
|
||||||
|
}
|
||||||
this.publish();
|
this.publish();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Explicit retry (also used on a fresh connection after an outage). */
|
||||||
|
retryThumbnail(attachmentId: string | undefined): void {
|
||||||
|
if (attachmentId === undefined || this.thumbs.get(attachmentId)?.status !== "error") return;
|
||||||
|
this.forgetThumbnail(attachmentId);
|
||||||
|
this.ensureThumbnail(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Interrupt the current or named session's in-flight call. */
|
/** Interrupt the current or named session's in-flight call. */
|
||||||
async interrupt(sessionId?: string): Promise<boolean> {
|
async interrupt(sessionId?: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* without duplicating hooks.
|
* without duplicating hooks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import type { SessionObservation } from "../observation";
|
import type { SessionObservation } from "../observation";
|
||||||
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
|
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
|
||||||
|
|
||||||
@@ -114,6 +114,49 @@ export function useObservationView(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A mounted metadata watcher is not necessarily a visible screenshot viewer. */
|
||||||
|
export function useThumbnailObservation(store: ObservationClientStore, enabled: boolean) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!enabled || element === null) return;
|
||||||
|
// A portal uses its PiP document's visibility, independently of the main tab.
|
||||||
|
const doc = element.ownerDocument;
|
||||||
|
const Observer = doc.defaultView?.IntersectionObserver;
|
||||||
|
let active = true;
|
||||||
|
let intersecting = Observer === undefined;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const update = () => {
|
||||||
|
if (!active) return;
|
||||||
|
const visible = intersecting && doc.visibilityState !== "hidden";
|
||||||
|
if (visible && release === undefined) release = store.watchThumbnails();
|
||||||
|
else if (!visible && release !== undefined) {
|
||||||
|
release();
|
||||||
|
release = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const observer =
|
||||||
|
Observer === undefined
|
||||||
|
? undefined
|
||||||
|
: new Observer((entries) => {
|
||||||
|
intersecting = entries.some(
|
||||||
|
(entry) => entry.target === element && entry.isIntersecting,
|
||||||
|
);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
observer?.observe(element);
|
||||||
|
doc.addEventListener("visibilitychange", update);
|
||||||
|
update();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
observer?.disconnect();
|
||||||
|
doc.removeEventListener("visibilitychange", update);
|
||||||
|
release?.();
|
||||||
|
};
|
||||||
|
}, [store, enabled]);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipHandle {
|
export interface PipHandle {
|
||||||
/** The open PiP window, or null while the content renders in its carrier. */
|
/** The open PiP window, or null while the content renders in its carrier. */
|
||||||
readonly pipWindow: Window | null;
|
readonly pipWindow: Window | null;
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
* the event allowlist lives in dsh-api-remotes), so the plugin serves its
|
* the event allowlist lives in dsh-api-remotes), so the plugin serves its
|
||||||
* observation channel through the documented `webServer` route seam instead:
|
* observation channel through the documented `webServer` route seam instead:
|
||||||
*
|
*
|
||||||
* GET /bsk-observation/state → { sessions, available }
|
* GET /bsk-observation/state → one-time { sessions, available } read
|
||||||
* GET /bsk-observation/events → SSE stream of ObservationEvent
|
* GET /bsk-observation/events → SSE snapshot, then ObservationEvent increments
|
||||||
|
* (on every connection, including reconnects)
|
||||||
|
* ?thumbnails=0: state only; =1: request screenshots
|
||||||
|
* Omitted parameter defaults to screenshots.
|
||||||
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
||||||
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
|
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
|
||||||
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
||||||
*
|
*
|
||||||
|
* Live clients initialize/resynchronize from the SSE snapshot; a separate
|
||||||
|
* /state read has no ordering guarantee relative to the stream and does not
|
||||||
|
* request screenshots. Closing an SSE connection releases its screenshot demand.
|
||||||
|
*
|
||||||
* Routes exist only when a webServer service is mounted (web composition);
|
* Routes exist only when a webServer service is mounted (web composition);
|
||||||
* headless deployments skip registration entirely.
|
* headless deployments skip registration entirely.
|
||||||
*/
|
*/
|
||||||
@@ -99,6 +106,7 @@ export function registerObservationRoutes(
|
|||||||
if (webServer === undefined) {
|
if (webServer === undefined) {
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
const streams = new Set<() => void>();
|
||||||
const disposers = [
|
const disposers = [
|
||||||
webServer.register({
|
webServer.register({
|
||||||
kind: "exact",
|
kind: "exact",
|
||||||
@@ -129,14 +137,42 @@ export function registerObservationRoutes(
|
|||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
connection: "keep-alive",
|
connection: "keep-alive",
|
||||||
});
|
});
|
||||||
const unsubscribe = observation.subscribe((event) => {
|
const thumbnails =
|
||||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
new URL(req.url ?? "/", "http://localhost").searchParams.get("thumbnails") !== "0";
|
||||||
});
|
let unsubscribe: (() => void) | undefined;
|
||||||
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||||
res.on("close", () => {
|
let closed = false;
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
unsubscribe?.();
|
||||||
});
|
streams.delete(close);
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
const write = (data: string) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
res.write(data);
|
||||||
|
} catch {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streams.add(close);
|
||||||
|
res.on("close", close);
|
||||||
|
res.on("error", close);
|
||||||
|
try {
|
||||||
|
unsubscribe = observation.subscribe(
|
||||||
|
(event) => {
|
||||||
|
write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
},
|
||||||
|
{ thumbnails },
|
||||||
|
);
|
||||||
|
if (closed) unsubscribe();
|
||||||
|
else heartbeat = setInterval(() => write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
|
||||||
|
} catch {
|
||||||
|
close();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
webServer.register({
|
webServer.register({
|
||||||
@@ -228,6 +264,7 @@ export function registerObservationRoutes(
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
return () => {
|
return () => {
|
||||||
|
for (const close of streams) close();
|
||||||
for (const dispose of disposers) dispose();
|
for (const dispose of disposers) dispose();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ export interface SessionObservation {
|
|||||||
dshSessionIds?: string[];
|
dshSessionIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Incremental event carried to subscribers (SSE on the wire). */
|
/** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
|
||||||
export type ObservationEvent =
|
export type ObservationEvent =
|
||||||
|
| { type: "snapshot"; sessions: SessionObservation[]; available: boolean }
|
||||||
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
|
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
|
||||||
| { type: "availability"; available: boolean };
|
| { type: "availability"; available: boolean };
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export class ObservationService {
|
|||||||
private readonly scratchNamespace = randomUUID();
|
private readonly scratchNamespace = randomUUID();
|
||||||
private readonly observations = new Map<string, SessionObservation>();
|
private readonly observations = new Map<string, SessionObservation>();
|
||||||
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
||||||
|
private thumbnailViewers = 0;
|
||||||
private readonly captureTimers = new Map<string, unknown>();
|
private readonly captureTimers = new Map<string, unknown>();
|
||||||
private readonly captureInFlight = new Set<string>();
|
private readonly captureInFlight = new Set<string>();
|
||||||
/** Captures intentionally cancelled to make way for foreground work. */
|
/** Captures intentionally cancelled to make way for foreground work. */
|
||||||
@@ -125,7 +127,7 @@ export class ObservationService {
|
|||||||
return this.deps.scheduler ?? DEFAULT_SCHEDULER;
|
return this.deps.scheduler ?? DEFAULT_SCHEDULER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All current entries (client initial/resync snapshot). */
|
/** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */
|
||||||
getState(): SessionObservation[] {
|
getState(): SessionObservation[] {
|
||||||
return [...this.observations.values()].map((entry) => ({ ...entry }));
|
return [...this.observations.values()].map((entry) => ({ ...entry }));
|
||||||
}
|
}
|
||||||
@@ -141,10 +143,42 @@ export class ObservationService {
|
|||||||
this.emit({ type: "availability", available });
|
this.emit({ type: "availability", available });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to incremental changes; returns an unsubscribe function. */
|
/**
|
||||||
subscribe(listener: (event: ObservationEvent) => void): () => void {
|
* Subscribe to an ordered initial snapshot and subsequent changes. On an
|
||||||
this.listeners.add(listener);
|
* active service, listener receives the snapshot synchronously before this
|
||||||
return () => this.listeners.delete(listener);
|
* method returns. Subscribing after disposal is a no-op.
|
||||||
|
*
|
||||||
|
* `thumbnails` defaults to true for compatibility: this subscription counts
|
||||||
|
* as a screenshot viewer for the service's owned sessions. Pass false for
|
||||||
|
* state-only observation. The first viewer starts capture scheduling.
|
||||||
|
*
|
||||||
|
* @returns An idempotent unsubscribe function releasing this subscription's
|
||||||
|
* screenshot demand. When the last viewer leaves, scheduled/queued captures
|
||||||
|
* are cancelled/skipped; an already running capture may finish.
|
||||||
|
*/
|
||||||
|
subscribe(
|
||||||
|
listener: (event: ObservationEvent) => void,
|
||||||
|
{ thumbnails = true }: { thumbnails?: boolean } = {},
|
||||||
|
): () => void {
|
||||||
|
if (this.disposed) return () => {};
|
||||||
|
// Each subscription owns its lease, even if a caller reuses a callback.
|
||||||
|
const receive = (event: ObservationEvent) => listener(event);
|
||||||
|
this.listeners.add(receive);
|
||||||
|
try {
|
||||||
|
listener({ type: "snapshot", sessions: this.getState(), available: this.available });
|
||||||
|
} catch (error) {
|
||||||
|
this.listeners.delete(receive);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (thumbnails && ++this.thumbnailViewers === 1) {
|
||||||
|
for (const sessionId of this.observations.keys()) this.scheduleCapture(sessionId, 0);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (!this.listeners.delete(receive)) return;
|
||||||
|
if (thumbnails && --this.thumbnailViewers === 0) {
|
||||||
|
for (const sessionId of this.captureTimers.keys()) this.cancelCapture(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(event: ObservationEvent): void {
|
private emit(event: ObservationEvent): void {
|
||||||
@@ -355,6 +389,7 @@ export class ObservationService {
|
|||||||
this.observations.clear();
|
this.observations.clear();
|
||||||
this.emit({ type: "reset" });
|
this.emit({ type: "reset" });
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
|
this.thumbnailViewers = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -369,12 +404,21 @@ export class ObservationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private canCapture(sessionId: string): boolean {
|
||||||
|
const entry = this.observations.get(sessionId);
|
||||||
|
return (
|
||||||
|
this.deps.options.enabled &&
|
||||||
|
!this.disposed &&
|
||||||
|
this.thumbnailViewers > 0 &&
|
||||||
|
!this.foregroundDepth.has(sessionId) &&
|
||||||
|
entry !== undefined &&
|
||||||
|
entry.dead !== true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
||||||
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
||||||
if (!this.deps.options.enabled || this.disposed) return;
|
if (!this.canCapture(sessionId)) return;
|
||||||
if (this.foregroundDepth.has(sessionId)) return;
|
|
||||||
const entry = this.observations.get(sessionId);
|
|
||||||
if (entry === undefined || entry.dead === true) return;
|
|
||||||
this.cancelCapture(sessionId);
|
this.cancelCapture(sessionId);
|
||||||
const now = this.scheduler.now();
|
const now = this.scheduler.now();
|
||||||
const lastSeen = this.lastActivity.get(sessionId) ?? 0;
|
const lastSeen = this.lastActivity.get(sessionId) ?? 0;
|
||||||
@@ -399,9 +443,7 @@ export class ObservationService {
|
|||||||
* Failures keep the previous frame and back off silently.
|
* Failures keep the previous frame and back off silently.
|
||||||
*/
|
*/
|
||||||
private async capture(sessionId: string): Promise<void> {
|
private async capture(sessionId: string): Promise<void> {
|
||||||
if (this.disposed) return;
|
if (!this.canCapture(sessionId)) return;
|
||||||
const current = this.observations.get(sessionId);
|
|
||||||
if (current === undefined || current.dead === true) return;
|
|
||||||
if (this.captureInFlight.has(sessionId)) return;
|
if (this.captureInFlight.has(sessionId)) return;
|
||||||
this.captureInFlight.add(sessionId);
|
this.captureInFlight.add(sessionId);
|
||||||
// Stable within this service, isolated from captures owned by other instances.
|
// Stable within this service, isolated from captures owned by other instances.
|
||||||
@@ -410,8 +452,8 @@ export class ObservationService {
|
|||||||
let writtenPath = outPath;
|
let writtenPath = outPath;
|
||||||
try {
|
try {
|
||||||
const result = await this.deps.queue.run(sessionId, () => {
|
const result = await this.deps.queue.run(sessionId, () => {
|
||||||
// A foreground lease may have arrived while this capture was queued.
|
// The last viewer may have left, or foreground work arrived, while queued.
|
||||||
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
|
if (!this.canCapture(sessionId)) return Promise.resolve(undefined);
|
||||||
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
tag: `observation:${sessionId}`,
|
tag: `observation:${sessionId}`,
|
||||||
@@ -445,6 +487,7 @@ export class ObservationService {
|
|||||||
this.setAvailable(true);
|
this.setAvailable(true);
|
||||||
this.publishFrame(sessionId, entry, data);
|
this.publishFrame(sessionId, entry, data);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (this.disposed || !this.observations.has(sessionId)) return;
|
||||||
// Foreground preemption is healthy scheduling, not browser/daemon
|
// Foreground preemption is healthy scheduling, not browser/daemon
|
||||||
// unavailability; do not let normal tool traffic trip capture backoff.
|
// unavailability; do not let normal tool traffic trip capture backoff.
|
||||||
if (!this.capturePreempted.delete(sessionId)) {
|
if (!this.capturePreempted.delete(sessionId)) {
|
||||||
|
|||||||
@@ -636,7 +636,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
|
|||||||
session: SESSION_PARAM,
|
session: SESSION_PARAM,
|
||||||
noClear: {
|
noClear: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "Skip the default wipe-the-field-first pass (append instead).",
|
description:
|
||||||
|
"Append to the end of the existing value instead of clearing it. " +
|
||||||
|
"The tool moves the caret automatically; no prior click or selection is needed.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ interface Harness {
|
|||||||
emitRaw: (event: unknown) => void;
|
emitRaw: (event: unknown) => void;
|
||||||
fetches: { url: string; init?: { body?: string } }[];
|
fetches: { url: string; init?: { body?: string } }[];
|
||||||
loadImage: ReturnType<typeof vi.fn>;
|
loadImage: ReturnType<typeof vi.fn>;
|
||||||
|
eventUrls: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHarness(initial: SessionObservation[]): Harness {
|
function makeHarness(initial: SessionObservation[]): Harness {
|
||||||
const fetches: Harness["fetches"] = [];
|
const fetches: Harness["fetches"] = [];
|
||||||
|
const eventUrls: string[] = [];
|
||||||
let es: EventSourceLike | undefined;
|
let es: EventSourceLike | undefined;
|
||||||
let current = initial;
|
let current = initial;
|
||||||
const loadImage = vi.fn(async (id: string) => `blob:${id}`);
|
const loadImage = vi.fn(async (id: string) => `blob:${id}`);
|
||||||
@@ -34,9 +36,16 @@ function makeHarness(initial: SessionObservation[]): Harness {
|
|||||||
}
|
}
|
||||||
return { ok: true, json: async () => ({ interrupted: true }) };
|
return { ok: true, json: async () => ({ interrupted: true }) };
|
||||||
},
|
},
|
||||||
eventSourceFactory: () => {
|
eventSourceFactory: (url) => {
|
||||||
es = { onmessage: null, close: vi.fn() };
|
eventUrls.push(url);
|
||||||
return es;
|
const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
|
||||||
|
es = connection;
|
||||||
|
queueMicrotask(() =>
|
||||||
|
connection.onmessage?.({
|
||||||
|
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return connection;
|
||||||
},
|
},
|
||||||
loadImage,
|
loadImage,
|
||||||
});
|
});
|
||||||
@@ -58,10 +67,120 @@ function makeHarness(initial: SessionObservation[]): Harness {
|
|||||||
},
|
},
|
||||||
fetches,
|
fetches,
|
||||||
loadImage,
|
loadImage,
|
||||||
|
eventUrls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("visible thumbnail demand", () => {
|
||||||
|
function intersections() {
|
||||||
|
const observers: Array<{
|
||||||
|
notify: (visible: boolean) => void;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
}> = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"IntersectionObserver",
|
||||||
|
class {
|
||||||
|
private target?: Element;
|
||||||
|
disconnect = vi.fn();
|
||||||
|
constructor(private callback: IntersectionObserverCallback) {
|
||||||
|
observers.push(this);
|
||||||
|
}
|
||||||
|
observe(target: Element) {
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
notify(visible: boolean) {
|
||||||
|
this.callback(
|
||||||
|
[{ target: this.target, isIntersecting: visible } as IntersectionObserverEntry],
|
||||||
|
this as unknown as IntersectionObserver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return observers;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pauses screenshots on collapse while keeping the metadata feed", async () => {
|
||||||
|
const observers = intersections();
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
const { unmount } = render(<ObservationOverlay store={h.store} />);
|
||||||
|
await screen.findByTestId("obs-card");
|
||||||
|
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
|
||||||
|
act(() => observers[0].notify(true));
|
||||||
|
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
|
||||||
|
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
|
||||||
|
expect(h.es().close).not.toHaveBeenCalled();
|
||||||
|
expect(observers[0].disconnect).toHaveBeenCalledOnce();
|
||||||
|
// A callback queued before disconnect must not reclaim screenshot demand.
|
||||||
|
act(() => observers[0].notify(true));
|
||||||
|
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Expand browser observation/ }));
|
||||||
|
act(() => observers[1].notify(true));
|
||||||
|
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
|
||||||
|
unmount();
|
||||||
|
expect(h.es().close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an initial visibility notification delivered after collapse", async () => {
|
||||||
|
const observers = intersections();
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
render(<ObservationOverlay store={h.store} />);
|
||||||
|
await screen.findByTestId("obs-card");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
|
||||||
|
act(() => observers[0].notify(true));
|
||||||
|
expect(h.eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses for a hidden document or hidden panel and resumes when visible", async () => {
|
||||||
|
const observers = intersections();
|
||||||
|
let visibility: DocumentVisibilityState = "visible";
|
||||||
|
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
render(<ObservationOverlay store={h.store} />);
|
||||||
|
await screen.findByTestId("obs-card");
|
||||||
|
act(() => observers[0].notify(true));
|
||||||
|
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
|
||||||
|
act(() => {
|
||||||
|
visibility = "hidden";
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
|
||||||
|
act(() => {
|
||||||
|
visibility = "visible";
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
|
||||||
|
act(() => observers[0].notify(false));
|
||||||
|
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a visible PiP watching when the main document is hidden", async () => {
|
||||||
|
// The detached PiP test document has no window/IntersectionObserver.
|
||||||
|
vi.stubGlobal("IntersectionObserver", undefined);
|
||||||
|
let visibility: DocumentVisibilityState = "visible";
|
||||||
|
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
|
||||||
|
const pipDoc = document.implementation.createHTMLDocument("pip");
|
||||||
|
Object.defineProperty(pipDoc, "visibilityState", { value: "visible" });
|
||||||
|
(window as unknown as Record<string, unknown>).documentPictureInPicture = {
|
||||||
|
requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }),
|
||||||
|
};
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
render(<ObservationOverlay store={h.store} />);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /Pop out/ }));
|
||||||
|
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
|
||||||
|
act(() => {
|
||||||
|
visibility = "hidden";
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ObservationOverlay", () => {
|
describe("ObservationOverlay", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -92,6 +211,16 @@ describe("ObservationOverlay", () => {
|
|||||||
await screen.findByRole("img", { name: "session s1 view" });
|
await screen.findByRole("img", { name: "session s1 view" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers a retry after a thumbnail fails without requiring a new frame id", async () => {
|
||||||
|
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]);
|
||||||
|
h.loadImage.mockRejectedValueOnce(new Error("temporary failure"));
|
||||||
|
render(<ObservationOverlay store={h.store} />);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Retry thumbnail" }));
|
||||||
|
await screen.findByRole("img", { name: "session s1 view" });
|
||||||
|
expect(h.loadImage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.queryByRole("button", { name: "Retry thumbnail" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the current frame on stage while the next thumbnail loads", async () => {
|
it("keeps the current frame on stage while the next thumbnail loads", async () => {
|
||||||
let resolveNext: ((url: string) => void) | undefined;
|
let resolveNext: ((url: string) => void) | undefined;
|
||||||
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
|
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
|
||||||
|
|||||||
@@ -53,8 +53,14 @@ function makeHarness(initial: SessionObservation[]): Harness {
|
|||||||
return { ok: true, json: async () => ({ interrupted: true }) };
|
return { ok: true, json: async () => ({ interrupted: true }) };
|
||||||
},
|
},
|
||||||
eventSourceFactory: () => {
|
eventSourceFactory: () => {
|
||||||
es = { onmessage: null, close: vi.fn() };
|
const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
|
||||||
return es;
|
es = connection;
|
||||||
|
queueMicrotask(() =>
|
||||||
|
connection.onmessage?.({
|
||||||
|
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return connection;
|
||||||
},
|
},
|
||||||
loadImage: async (id: string) => `blob:${id}`,
|
loadImage: async (id: string) => `blob:${id}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
// ObservationClientStore: initial fetch + SSE application, thumbnail loading
|
// ObservationClientStore: ordered SSE snapshots/increments, thumbnail loading
|
||||||
// lifecycle, interrupt wire shape. All I/O faked.
|
// lifecycle, interrupt wire shape. All I/O faked.
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||||
import type { ObservationEvent, SessionObservation } from "../../src/observation";
|
import type { ObservationEvent, SessionObservation } from "../../src/observation";
|
||||||
|
|
||||||
const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 };
|
const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 };
|
||||||
const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 };
|
const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 };
|
||||||
|
const stores: ObservationClientStore[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const store of stores.splice(0)) store.stop();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
function harness(state: SessionObservation[] = []) {
|
function harness(state: SessionObservation[] = []) {
|
||||||
const fetches: {
|
const fetches: {
|
||||||
@@ -14,6 +21,7 @@ function harness(state: SessionObservation[] = []) {
|
|||||||
init?: { method?: string; body?: string; headers?: Record<string, string> };
|
init?: { method?: string; body?: string; headers?: Record<string, string> };
|
||||||
}[] = [];
|
}[] = [];
|
||||||
const esInstances: EventSourceLike[] = [];
|
const esInstances: EventSourceLike[] = [];
|
||||||
|
const eventUrls: string[] = [];
|
||||||
const fetchFn = vi.fn(
|
const fetchFn = vi.fn(
|
||||||
async (
|
async (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -29,11 +37,14 @@ function harness(state: SessionObservation[] = []) {
|
|||||||
const eventSourceFactory = (url: string) => {
|
const eventSourceFactory = (url: string) => {
|
||||||
const es: EventSourceLike = { onmessage: null, close: vi.fn() };
|
const es: EventSourceLike = { onmessage: null, close: vi.fn() };
|
||||||
esInstances.push(es);
|
esInstances.push(es);
|
||||||
|
eventUrls.push(url);
|
||||||
|
queueMicrotask(() => emit(es, { type: "snapshot", sessions: state, available: true }));
|
||||||
return es;
|
return es;
|
||||||
};
|
};
|
||||||
const loadImage = vi.fn(async (id: string) => `blob:url-${id}`);
|
const loadImage = vi.fn(async (id: string) => `blob:url-${id}`);
|
||||||
const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage });
|
const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage });
|
||||||
return { store, fetches, esInstances, loadImage };
|
stores.push(store);
|
||||||
|
return { store, fetches, esInstances, eventUrls, loadImage };
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(es: EventSourceLike, event: ObservationEvent): void {
|
function emit(es: EventSourceLike, event: ObservationEvent): void {
|
||||||
@@ -73,7 +84,9 @@ describe("ObservationClientStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("loads each thumbnail once and reports ready", async () => {
|
it("loads each thumbnail once and reports ready", async () => {
|
||||||
const { store, loadImage } = harness();
|
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-1" }]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
store.ensureThumbnail("att-1");
|
store.ensureThumbnail("att-1");
|
||||||
store.ensureThumbnail("att-1");
|
store.ensureThumbnail("att-1");
|
||||||
expect(loadImage).toHaveBeenCalledTimes(1);
|
expect(loadImage).toHaveBeenCalledTimes(1);
|
||||||
@@ -86,7 +99,9 @@ describe("ObservationClientStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("marks failed thumbnail loads as error", async () => {
|
it("marks failed thumbnail loads as error", async () => {
|
||||||
const { store, loadImage } = harness();
|
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-bad" }]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
loadImage.mockRejectedValueOnce(new Error("nope"));
|
loadImage.mockRejectedValueOnce(new Error("nope"));
|
||||||
store.ensureThumbnail("att-bad");
|
store.ensureThumbnail("att-bad");
|
||||||
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error"));
|
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error"));
|
||||||
@@ -124,9 +139,7 @@ describe("ObservationClientStore", () => {
|
|||||||
|
|
||||||
describe("thumbnail blob URL lifecycle", () => {
|
describe("thumbnail blob URL lifecycle", () => {
|
||||||
function withRevokeSpy() {
|
function withRevokeSpy() {
|
||||||
const revoke = vi.fn();
|
return vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revoke;
|
|
||||||
return revoke;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("holds the last ready frame until the replacement decodes", async () => {
|
it("holds the last ready frame until the replacement decodes", async () => {
|
||||||
@@ -231,16 +244,216 @@ describe("thumbnail blob URL lifecycle", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SSE (re)open resync", () => {
|
describe("SSE snapshot ordering", () => {
|
||||||
it("refetches the full state when the stream (re)opens", async () => {
|
it("ignores events from a stopped connection after a restart", async () => {
|
||||||
|
const { store, esInstances } = harness([OBS_IDLE]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
const old = esInstances[0];
|
||||||
|
store.stop();
|
||||||
|
emit(old, { type: "upsert", session: OBS_BUSY });
|
||||||
|
expect(store.getSnapshot().sessions).toEqual([]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
|
||||||
|
emit(old, { type: "snapshot", sessions: [], available: false });
|
||||||
|
emit(old, { type: "remove", session: OBS_BUSY });
|
||||||
|
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply a queued initial snapshot after stop", async () => {
|
||||||
|
const { store } = harness([OBS_BUSY]);
|
||||||
|
store.start();
|
||||||
|
store.stop();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, subscribed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces state on reconnect before applying subsequent events, without a competing fetch", async () => {
|
||||||
const { store, fetches, esInstances } = harness([OBS_IDLE]);
|
const { store, fetches, esInstances } = harness([OBS_IDLE]);
|
||||||
store.start();
|
store.start();
|
||||||
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
|
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
|
||||||
const before = fetches.filter((f) => f.url === "/bsk-observation/state").length;
|
emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
|
||||||
esInstances[0].onopen?.();
|
expect(store.getSnapshot()).toMatchObject({ sessions: [], available: false });
|
||||||
await vi.waitFor(() =>
|
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
|
||||||
expect(fetches.filter((f) => f.url === "/bsk-observation/state").length).toBe(before + 1),
|
await Promise.resolve();
|
||||||
);
|
expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
|
||||||
|
expect(fetches).toEqual([]);
|
||||||
store.stop();
|
store.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("thumbnail recovery and stale loads", () => {
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: Error) => void;
|
||||||
|
const promise = new Promise<T>((yes, no) => {
|
||||||
|
resolve = yes;
|
||||||
|
reject = no;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("retries a transient failure without needing a different frame id", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
loadImage.mockRejectedValueOnce(new Error("temporary"));
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot().thumbnails.a1.status).toBe("error");
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(loadImage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds automatic retries and allows an explicit recovery", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
loadImage.mockRejectedValue(new Error("offline"));
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(loadImage).toHaveBeenCalledTimes(3);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
expect(loadImage).toHaveBeenCalledTimes(3);
|
||||||
|
loadImage.mockResolvedValue("blob:recovered");
|
||||||
|
store.retryThumbnail("a1");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot().displayFrames.s1.url).toBe("blob:recovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels backoff when a reconnect retries the current frame", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const session = { ...OBS_IDLE, thumbnailAttachmentId: "a1" };
|
||||||
|
const { store, esInstances, loadImage } = harness([session]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
loadImage.mockRejectedValueOnce(new Error("offline"));
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
await Promise.resolve();
|
||||||
|
emit(esInstances[0], { type: "snapshot", sessions: [session], available: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot().thumbnails.a1.status).toBe("ready");
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(loadImage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"remove",
|
||||||
|
"reset",
|
||||||
|
"stop",
|
||||||
|
"missing-thumbnail",
|
||||||
|
"snapshot-removal",
|
||||||
|
])("cleans frames and cancels retries on %s", async (action) => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
const { store, esInstances, loadImage } = harness([
|
||||||
|
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
|
||||||
|
]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
await Promise.resolve();
|
||||||
|
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
|
||||||
|
loadImage.mockRejectedValueOnce(new Error("no frame"));
|
||||||
|
store.ensureThumbnail("a2");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "error", url: "blob:url-a1" });
|
||||||
|
if (action === "stop") store.stop();
|
||||||
|
else if (action === "remove") emit(esInstances[0], { type: "remove", session: OBS_IDLE });
|
||||||
|
else if (action === "reset") emit(esInstances[0], { type: "reset" });
|
||||||
|
else if (action === "missing-thumbnail")
|
||||||
|
emit(esInstances[0], { type: "upsert", session: OBS_IDLE });
|
||||||
|
else emit(esInstances[0], { type: "snapshot", sessions: [], available: true });
|
||||||
|
expect(store.getSnapshot().thumbnails).toEqual({});
|
||||||
|
expect(store.getSnapshot().displayFrames).toEqual({});
|
||||||
|
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:url-a1");
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(loadImage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"resolve",
|
||||||
|
"reject",
|
||||||
|
])("ignores a stale load's %s even when the same id is loaded again", async (outcome) => {
|
||||||
|
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
const { store, esInstances, loadImage } = harness([
|
||||||
|
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
|
||||||
|
]);
|
||||||
|
const old = deferred<string>();
|
||||||
|
loadImage.mockReturnValueOnce(old.promise);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
|
||||||
|
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a1" } });
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
await Promise.resolve();
|
||||||
|
if (outcome === "resolve") old.resolve("blob:stale");
|
||||||
|
else old.reject(new Error("stale error"));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
|
||||||
|
expect(revoke).not.toHaveBeenCalledWith("blob:url-a1");
|
||||||
|
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:stale");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["resolve", "reject"])("ignores an in-flight load's %s after stop", async (outcome) => {
|
||||||
|
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
|
||||||
|
const pending = deferred<string>();
|
||||||
|
loadImage.mockReturnValueOnce(pending.promise);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
store.ensureThumbnail("a1");
|
||||||
|
store.stop();
|
||||||
|
if (outcome === "resolve") pending.resolve("blob:late");
|
||||||
|
else pending.reject(new Error("late error"));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, displayFrames: {} });
|
||||||
|
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:late");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("thumbnail viewer leases", () => {
|
||||||
|
it("keeps metadata subscribed while the last screenshot viewer leaves", async () => {
|
||||||
|
const { store, eventUrls, esInstances } = harness([OBS_IDLE]);
|
||||||
|
store.acquire();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
|
||||||
|
const first = store.watchThumbnails();
|
||||||
|
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
|
||||||
|
const second = store.watchThumbnails();
|
||||||
|
expect(eventUrls).toHaveLength(2);
|
||||||
|
first();
|
||||||
|
first();
|
||||||
|
expect(eventUrls).toHaveLength(2);
|
||||||
|
second();
|
||||||
|
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
|
||||||
|
expect(store.getSnapshot().subscribed).toBe(true);
|
||||||
|
store.release();
|
||||||
|
expect(esInstances.at(-1)?.close).toHaveBeenCalledOnce();
|
||||||
|
expect(store.getSnapshot().subscribed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores delayed snapshots from a replaced stream without clearing the displayed state", async () => {
|
||||||
|
const { store, esInstances } = harness([OBS_IDLE]);
|
||||||
|
store.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
|
||||||
|
const release = store.watchThumbnails();
|
||||||
|
expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
|
||||||
|
await Promise.resolve();
|
||||||
|
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
|
||||||
|
emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
|
||||||
|
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
|
||||||
|
store.stop();
|
||||||
|
release();
|
||||||
|
expect(esInstances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { basename, dirname } from "node:path";
|
import { basename, dirname } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
actionForLabel,
|
actionForLabel,
|
||||||
type ObservationEvent,
|
type ObservationEvent,
|
||||||
@@ -138,6 +138,8 @@ function setup(opts: {
|
|||||||
runner?: FakeRunner;
|
runner?: FakeRunner;
|
||||||
registry?: SessionRegistry;
|
registry?: SessionRegistry;
|
||||||
scheduler?: ReturnType<typeof fakeScheduler>;
|
scheduler?: ReturnType<typeof fakeScheduler>;
|
||||||
|
thumbnails?: boolean;
|
||||||
|
queue?: KeyedExecutor;
|
||||||
}) {
|
}) {
|
||||||
const registry = opts.registry ?? new SessionRegistry(5);
|
const registry = opts.registry ?? new SessionRegistry(5);
|
||||||
const runner = opts.runner ?? fakeRunner();
|
const runner = opts.runner ?? fakeRunner();
|
||||||
@@ -146,13 +148,18 @@ function setup(opts: {
|
|||||||
ctx: fakeCtx(),
|
ctx: fakeCtx(),
|
||||||
runner,
|
runner,
|
||||||
registry,
|
registry,
|
||||||
queue: new KeyedExecutor(),
|
queue: opts.queue ?? new KeyedExecutor(),
|
||||||
options: OPTIONS,
|
options: OPTIONS,
|
||||||
scheduler,
|
scheduler,
|
||||||
});
|
});
|
||||||
const events: ObservationEvent[] = [];
|
const events: ObservationEvent[] = [];
|
||||||
service.subscribe((event) => events.push(event));
|
const unsubscribe = service.subscribe(
|
||||||
return { service, registry, runner, scheduler, events };
|
(event) => {
|
||||||
|
if (event.type !== "snapshot") events.push(event);
|
||||||
|
},
|
||||||
|
{ thumbnails: opts.thumbnails ?? true },
|
||||||
|
);
|
||||||
|
return { service, registry, runner, scheduler, events, unsubscribe };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drive the registry through a real start so the session is owned. */
|
/** Drive the registry through a real start so the session is owned. */
|
||||||
@@ -224,6 +231,111 @@ describe("state machine", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("thumbnail cadence", () => {
|
describe("thumbnail cadence", () => {
|
||||||
|
it("keeps metadata current without screenshots and resumes when a viewer arrives", async () => {
|
||||||
|
const { service, scheduler, runner, events } = setup({ thumbnails: false });
|
||||||
|
service.addSession("s1");
|
||||||
|
service.beginAction("s1", "clicking");
|
||||||
|
service.endAction("s1");
|
||||||
|
const releaseForeground = service.acquireForeground("s1");
|
||||||
|
releaseForeground();
|
||||||
|
expect(service.getState()[0].action).toBe("idle");
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
const snapshots: ObservationEvent[] = [];
|
||||||
|
const leave = service.subscribe((event) => snapshots.push(event));
|
||||||
|
expect(snapshots[0]).toEqual({
|
||||||
|
type: "snapshot",
|
||||||
|
sessions: service.getState(),
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
scheduler.runNext();
|
||||||
|
await waitFor(() => scheduler.pending().length === 1);
|
||||||
|
expect(runner.calls).toHaveLength(1);
|
||||||
|
leave();
|
||||||
|
leave();
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
service.endAction("s1");
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
const resume = service.subscribe(() => {});
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
resume();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not capture in a headless service with no subscribers", () => {
|
||||||
|
const { service, scheduler, unsubscribe } = setup({});
|
||||||
|
unsubscribe();
|
||||||
|
service.addSession("s1");
|
||||||
|
service.endAction("s1");
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
expect(service.getState()).toHaveLength(1);
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps capturing until the last viewer leaves, including reused callbacks", () => {
|
||||||
|
const { service, scheduler } = setup({ thumbnails: false });
|
||||||
|
service.addSession("s1");
|
||||||
|
const listener = () => {};
|
||||||
|
const first = service.subscribe(listener);
|
||||||
|
const second = service.subscribe(listener);
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
first();
|
||||||
|
first();
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
second();
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a queued screenshot if its last viewer leaves before execution", async () => {
|
||||||
|
const queue = new KeyedExecutor();
|
||||||
|
let unblock!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
unblock = resolve;
|
||||||
|
});
|
||||||
|
const busy = queue.run("s1", () => gate);
|
||||||
|
const { service, scheduler, runner, unsubscribe } = setup({ queue });
|
||||||
|
service.addSession("s1");
|
||||||
|
scheduler.runNext();
|
||||||
|
unsubscribe();
|
||||||
|
unblock();
|
||||||
|
await busy;
|
||||||
|
await queue.run("s1", async () => {});
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an already running capture finish without restarting the loop after the last viewer leaves", async () => {
|
||||||
|
const runner = fakeRunner();
|
||||||
|
const run = runner.run.bind(runner);
|
||||||
|
let finish!: () => void;
|
||||||
|
let started = false;
|
||||||
|
runner.run = async (args, options) => {
|
||||||
|
started = true;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
return run(args, options);
|
||||||
|
};
|
||||||
|
const { service, scheduler, unsubscribe } = setup({ runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
scheduler.runNext();
|
||||||
|
await waitFor(() => started);
|
||||||
|
unsubscribe();
|
||||||
|
finish();
|
||||||
|
await waitFor(() => service.getState()[0]?.thumbnailAttachmentId !== undefined);
|
||||||
|
const args = runner.calls[0].args;
|
||||||
|
await waitFor(() => !existsSync(args[args.indexOf("--out") + 1]));
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
const leave = service.subscribe(() => {});
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
leave();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("gives foreground work priority and resumes with one fresh capture", async () => {
|
it("gives foreground work priority and resumes with one fresh capture", async () => {
|
||||||
const scheduler = fakeScheduler();
|
const scheduler = fakeScheduler();
|
||||||
let finishCapture: ((result: BskRunResult) => void) | undefined;
|
let finishCapture: ((result: BskRunResult) => void) | undefined;
|
||||||
@@ -540,6 +652,12 @@ describe("HTTP/SSE interface", () => {
|
|||||||
const eventsRes = fakeRes();
|
const eventsRes = fakeRes();
|
||||||
await eventsRoute?.handler(fakeReq(), eventsRes.res as never);
|
await eventsRoute?.handler(fakeReq(), eventsRes.res as never);
|
||||||
service.beginAction("s1", "clicking");
|
service.beginAction("s1", "clicking");
|
||||||
|
const streamed = eventsRes.chunks.map((chunk) => JSON.parse(chunk.slice(6)));
|
||||||
|
expect(streamed.map((event) => event.type)).toEqual(["snapshot", "upsert"]);
|
||||||
|
expect(streamed[0]).toMatchObject({
|
||||||
|
sessions: [{ sessionId: "s1", action: "idle" }],
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
expect(eventsRes.res.body()).toContain('"action":"clicking"');
|
expect(eventsRes.res.body()).toContain('"action":"clicking"');
|
||||||
eventsRes.res.close();
|
eventsRes.res.close();
|
||||||
|
|
||||||
@@ -566,6 +684,47 @@ describe("HTTP/SSE interface", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("state-only SSE does not capture, and route disposal releases every active viewer", async () => {
|
||||||
|
const { service, scheduler } = setup({ thumbnails: false });
|
||||||
|
service.addSession("s1");
|
||||||
|
const { routes, webServer } = routeHarness();
|
||||||
|
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
|
||||||
|
const route = routes.get("/bsk-observation/events")!;
|
||||||
|
const metadata = fakeRes();
|
||||||
|
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=0" }), metadata.res);
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
const viewing = fakeRes();
|
||||||
|
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=1" }), viewing.res);
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
const endMetadata = vi.spyOn(metadata.res, "end");
|
||||||
|
const endViewing = vi.spyOn(viewing.res, "end");
|
||||||
|
dispose();
|
||||||
|
expect(endMetadata).toHaveBeenCalledOnce();
|
||||||
|
expect(endViewing).toHaveBeenCalledOnce();
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
viewing.res.close();
|
||||||
|
metadata.res.close();
|
||||||
|
expect(endViewing).toHaveBeenCalledOnce();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a viewer when writing its initial snapshot fails", async () => {
|
||||||
|
const { service, scheduler } = setup({ thumbnails: false });
|
||||||
|
service.addSession("s1");
|
||||||
|
const { routes, webServer } = routeHarness();
|
||||||
|
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
|
||||||
|
const response = fakeRes();
|
||||||
|
vi.spyOn(response.res, "write").mockImplementation(() => {
|
||||||
|
throw new Error("closed socket");
|
||||||
|
});
|
||||||
|
const end = vi.spyOn(response.res, "end");
|
||||||
|
await routes.get("/bsk-observation/events")!.handler(fakeReq(), response.res);
|
||||||
|
expect(end).toHaveBeenCalledOnce();
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
|
dispose();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a stop without a sessionId", async () => {
|
it("refuses a stop without a sessionId", async () => {
|
||||||
const registry = new SessionRegistry(5);
|
const registry = new SessionRegistry(5);
|
||||||
own(registry, "s1");
|
own(registry, "s1");
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ describe("parseBskJson", () => {
|
|||||||
expect(() => parseBskJson({ ...base, code: 1, stderr: "boom" }, "x")).toThrow(/boom/);
|
expect(() => parseBskJson({ ...base, code: 1, stderr: "boom" }, "x")).toThrow(/boom/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces fill recovery guidance to the model without retrying", async () => {
|
||||||
|
const hint =
|
||||||
|
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent";
|
||||||
|
const reply = {
|
||||||
|
...base,
|
||||||
|
code: 3,
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: "fill could not verify the expected value",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
hint,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let calls = 0;
|
||||||
|
const result = await runWithSessionBusyRetry(async () => {
|
||||||
|
calls++;
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(() => parseBskJson(result, "fill")).toThrow(hint);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports killed-by-interrupt children (null exit code) as interrupted", () => {
|
it("reports killed-by-interrupt children (null exit code) as interrupted", () => {
|
||||||
expect(() => parseBskJson({ ...base, code: null }, "navigate")).toThrow(/interrupted/);
|
expect(() => parseBskJson({ ...base, code: null }, "navigate")).toThrow(/interrupted/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"default": "./src/react.tsx"
|
"default": "./src/react.tsx"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
@@ -20,5 +24,8 @@
|
|||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"react-i18next": "^15.3.4"
|
"react-i18next": "^15.3.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ declare const chrome:
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
i18n?: {
|
||||||
|
getUILanguage(): string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -31,14 +34,116 @@ function getChromeLocal(): ChromeStorageLocal | undefined {
|
|||||||
return typeof chrome !== "undefined" ? chrome.storage?.local : undefined;
|
return typeof chrome !== "undefined" ? chrome.storage?.local : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Regions whose written Chinese defaults to Traditional. */
|
||||||
|
const TRADITIONAL_CHINESE_REGIONS = new Set(["tw", "hk", "mo"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group resource keys by primary language subtag, so a bare `ko` can find
|
||||||
|
* `ko-KR` without anyone maintaining a side map by hand.
|
||||||
|
*/
|
||||||
|
function buildPrimarySubtagIndex(resourceKeys: string[]): Map<string, string[]> {
|
||||||
|
const index = new Map<string, string[]>();
|
||||||
|
for (const key of resourceKeys) {
|
||||||
|
const primary = key.split(/[-_]/)[0].toLowerCase();
|
||||||
|
const bucket = index.get(primary);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push(key);
|
||||||
|
} else {
|
||||||
|
index.set(primary, [key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chinese is the one language where a single primary subtag maps to several
|
||||||
|
* resources, because Simplified and Traditional differ by script rather than
|
||||||
|
* by region. Every other language resolves straight from the index.
|
||||||
|
*/
|
||||||
|
function resolveChineseVariant(parts: string[], candidates: string[]): string {
|
||||||
|
const byRegion = (region: string): string | undefined =>
|
||||||
|
candidates.find((key) => key.toLowerCase().endsWith(`-${region}`));
|
||||||
|
|
||||||
|
// 1. Script subtag is the most accurate signal (zh-Hans / zh-Hant).
|
||||||
|
if (parts.includes("hans")) {
|
||||||
|
return byRegion("cn") ?? candidates[0];
|
||||||
|
}
|
||||||
|
if (parts.includes("hant")) {
|
||||||
|
return byRegion("tw") ?? candidates[candidates.length - 1];
|
||||||
|
}
|
||||||
|
// 2. Fall back to the region subtag, which is what Chrome actually reports.
|
||||||
|
// Skip parts[0] — the primary subtag `zh` is also two characters long.
|
||||||
|
const region = parts.slice(1).find((part) => part.length === 2);
|
||||||
|
if (region && TRADITIONAL_CHINESE_REGIONS.has(region)) {
|
||||||
|
return byRegion("tw") ?? candidates[0];
|
||||||
|
}
|
||||||
|
// 3. Bare `zh` and any other region default to Simplified.
|
||||||
|
return byRegion("cn") ?? candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a normalizer driven entirely by the resource keys i18next ships, so
|
||||||
|
* registering a new translation needs no change here.
|
||||||
|
*
|
||||||
|
* For a detected BCP 47 tag, in order:
|
||||||
|
*
|
||||||
|
* 1. it already names a resource (`ko-KR`) → unchanged
|
||||||
|
* 2. one resource shares its language (`ko` → `ko-KR`) → that resource
|
||||||
|
* 3. several do (`zh` → `zh-CN` / `zh-TW`) → script, then region
|
||||||
|
* 4. nothing is shipped for the language → unchanged
|
||||||
|
*
|
||||||
|
* Case 4 leaves the tag untouched so i18next applies `fallbackLng`, rather
|
||||||
|
* than silently routing an untranslated language to the wrong bundle.
|
||||||
|
*/
|
||||||
|
export function createLanguageNormalizer(resourceKeys: string[]): (code: string) => string {
|
||||||
|
const keySet = new Set(resourceKeys);
|
||||||
|
const index = buildPrimarySubtagIndex(resourceKeys);
|
||||||
|
|
||||||
|
return (code: string): string => {
|
||||||
|
if (keySet.has(code)) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
const parts = code.split(/[-_]/).map((part) => part.toLowerCase());
|
||||||
|
const candidates = index.get(parts[0]);
|
||||||
|
if (!candidates?.length) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
if (parts[0] === "zh") {
|
||||||
|
return resolveChineseVariant(parts, candidates);
|
||||||
|
}
|
||||||
|
return candidates[0];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* i18next detector that prefers `chrome.i18n.getUILanguage()`, Chrome's
|
||||||
|
* authoritative source for the extension UI language. The popup runs on a
|
||||||
|
* `chrome-extension://` origin where `navigator.language` can disagree with it.
|
||||||
|
*/
|
||||||
|
export const chromeUiLanguageDetector = {
|
||||||
|
name: "chromeUILanguage",
|
||||||
|
lookup(): string | undefined {
|
||||||
|
if (typeof chrome !== "undefined" && chrome.i18n?.getUILanguage) {
|
||||||
|
return chrome.i18n.getUILanguage();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** Avoid page-origin localStorage; use extension storage + navigator only. */
|
/** Avoid page-origin localStorage; use extension storage + navigator only. */
|
||||||
export function getLanguageDetectionOptions(): {
|
export function getLanguageDetectionOptions(resourceKeys: string[]): {
|
||||||
order: string[];
|
order: string[];
|
||||||
caches: string[];
|
caches: string[];
|
||||||
|
convertDetectedLanguage: (lng: string) => string;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
order: ["navigator"],
|
order: ["chromeUILanguage", "navigator"],
|
||||||
caches: [],
|
caches: [],
|
||||||
|
// Normalise the detected tag before i18next resolves it against `resources`.
|
||||||
|
convertDetectedLanguage: createLanguageNormalizer(resourceKeys),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import i18n from "i18next";
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
import { bindChromeStorageLanguageSync, getLanguageDetectionOptions } from "./chrome-storage-sync";
|
import {
|
||||||
|
bindChromeStorageLanguageSync,
|
||||||
|
chromeUiLanguageDetector,
|
||||||
|
getLanguageDetectionOptions,
|
||||||
|
} from "./chrome-storage-sync";
|
||||||
import enUSCommon from "./locales/en-US/common.json";
|
import enUSCommon from "./locales/en-US/common.json";
|
||||||
import enUSExtension from "./locales/en-US/extension.json";
|
import enUSExtension from "./locales/en-US/extension.json";
|
||||||
import zhCNCommon from "./locales/zh-CN/common.json";
|
import zhCNCommon from "./locales/zh-CN/common.json";
|
||||||
@@ -19,12 +23,21 @@ const resources = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const languageDetector = new LanguageDetector();
|
||||||
|
languageDetector.addDetector(chromeUiLanguageDetector);
|
||||||
|
|
||||||
|
// Drive locale normalisation off the keys we actually ship, so registering a
|
||||||
|
// translation is the only step needed to support a new language.
|
||||||
|
const resourceKeys = Object.keys(resources);
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(languageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources,
|
resources,
|
||||||
fallbackLng: "zh-CN",
|
// English is the international default; Chinese users still get Chinese.
|
||||||
|
// `zh` → zh-CN also covers zh-TW/zh-HK until a Traditional resource exists.
|
||||||
|
fallbackLng: { en: ["en-US"], zh: ["zh-CN"], default: ["en-US"] },
|
||||||
defaultNS: "common",
|
defaultNS: "common",
|
||||||
ns: ["common", "extension"],
|
ns: ["common", "extension"],
|
||||||
|
|
||||||
@@ -32,7 +45,7 @@ i18n
|
|||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
detection: getLanguageDetectionOptions(),
|
detection: getLanguageDetectionOptions(resourceKeys),
|
||||||
|
|
||||||
react: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
chromeUiLanguageDetector,
|
||||||
|
createLanguageNormalizer,
|
||||||
|
getLanguageDetectionOptions,
|
||||||
|
} from "../src/chrome-storage-sync";
|
||||||
|
|
||||||
|
/** What `packages/i18n` ships today — kept in sync with `resources` in i18n.ts. */
|
||||||
|
const SHIPPED_RESOURCE_KEYS = ["zh-CN", "en-US"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the locale-resolution table from issue #168: the UI must follow the
|
||||||
|
* browser language instead of falling through to Chinese for every locale
|
||||||
|
* other than `en-US`.
|
||||||
|
*/
|
||||||
|
describe("createLanguageNormalizer", () => {
|
||||||
|
const normalize = createLanguageNormalizer(SHIPPED_RESOURCE_KEYS);
|
||||||
|
|
||||||
|
const cases: Array<[detected: string, resource: string]> = [
|
||||||
|
// English — every variant maps to the single en-US resource.
|
||||||
|
["en", "en-US"],
|
||||||
|
["en-US", "en-US"],
|
||||||
|
["en-GB", "en-US"],
|
||||||
|
["en-CN", "en-US"],
|
||||||
|
["en-AU", "en-US"],
|
||||||
|
|
||||||
|
// Chinese — only `zh-CN` ships today, so every variant resolves to it
|
||||||
|
// directly rather than leaking into the fallback chain. Traditional picks
|
||||||
|
// up `zh-TW` automatically once that bundle is registered (see below).
|
||||||
|
["zh", "zh-CN"],
|
||||||
|
["zh-CN", "zh-CN"],
|
||||||
|
["zh-SG", "zh-CN"],
|
||||||
|
["zh-Hans", "zh-CN"],
|
||||||
|
["zh-Hans-CN", "zh-CN"],
|
||||||
|
["zh-Hans-SG", "zh-CN"],
|
||||||
|
["zh-TW", "zh-CN"],
|
||||||
|
["zh-HK", "zh-CN"],
|
||||||
|
["zh-MO", "zh-CN"],
|
||||||
|
["zh-Hant", "zh-CN"],
|
||||||
|
["zh-Hant-TW", "zh-CN"],
|
||||||
|
["zh-Hant-HK", "zh-CN"],
|
||||||
|
|
||||||
|
// No translation shipped — returned unchanged so i18next applies the
|
||||||
|
// `default` fallback (en-US) rather than Chinese.
|
||||||
|
["ja", "ja"],
|
||||||
|
["ja-JP", "ja-JP"],
|
||||||
|
["fr-FR", "fr-FR"],
|
||||||
|
["de-DE", "de-DE"],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(cases)("maps %s to %s", (detected, resource) => {
|
||||||
|
expect(normalize(detected)).toBe(resource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(normalize("EN-us")).toBe("en-US");
|
||||||
|
expect(normalize("zh-hant-tw")).toBe("zh-CN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The point of the factory: supporting a new language means registering its
|
||||||
|
* bundle in `resources` — never touching the normalizer.
|
||||||
|
*/
|
||||||
|
describe("createLanguageNormalizer extensibility", () => {
|
||||||
|
it("auto-resolves a newly registered language", () => {
|
||||||
|
const normalize = createLanguageNormalizer(["en-US", "zh-CN", "ko-KR"]);
|
||||||
|
expect(normalize("ko")).toBe("ko-KR");
|
||||||
|
expect(normalize("ko-KR")).toBe("ko-KR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disambiguates zh once a Traditional bundle ships", () => {
|
||||||
|
const normalize = createLanguageNormalizer(["en-US", "zh-CN", "zh-TW"]);
|
||||||
|
expect(normalize("zh-Hant")).toBe("zh-TW");
|
||||||
|
expect(normalize("zh-Hant-TW")).toBe("zh-TW");
|
||||||
|
expect(normalize("zh-TW")).toBe("zh-TW");
|
||||||
|
expect(normalize("zh-HK")).toBe("zh-TW");
|
||||||
|
expect(normalize("zh-MO")).toBe("zh-TW");
|
||||||
|
expect(normalize("zh-Hans")).toBe("zh-CN");
|
||||||
|
expect(normalize("zh-CN")).toBe("zh-CN");
|
||||||
|
expect(normalize("zh")).toBe("zh-CN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unregistered languages to fallbackLng", () => {
|
||||||
|
const normalize = createLanguageNormalizer(["en-US", "zh-CN"]);
|
||||||
|
expect(normalize("pt-BR")).toBe("pt-BR");
|
||||||
|
expect(normalize("ru")).toBe("ru");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chromeUiLanguageDetector", () => {
|
||||||
|
it("prefers chrome.i18n.getUILanguage() when available", () => {
|
||||||
|
vi.stubGlobal("chrome", { i18n: { getUILanguage: () => "en-GB" } });
|
||||||
|
expect(chromeUiLanguageDetector.lookup()).toBe("en-GB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined outside a Chrome extension context", () => {
|
||||||
|
vi.stubGlobal("chrome", undefined);
|
||||||
|
expect(chromeUiLanguageDetector.lookup()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the i18n API is unavailable", () => {
|
||||||
|
vi.stubGlobal("chrome", {});
|
||||||
|
expect(chromeUiLanguageDetector.lookup()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLanguageDetectionOptions", () => {
|
||||||
|
it("orders the Chrome UI language ahead of navigator", () => {
|
||||||
|
expect(getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS).order).toEqual([
|
||||||
|
"chromeUILanguage",
|
||||||
|
"navigator",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises whatever the detectors report", () => {
|
||||||
|
const { convertDetectedLanguage } = getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS);
|
||||||
|
expect(convertDetectedLanguage("en-GB")).toBe("en-US");
|
||||||
|
expect(convertDetectedLanguage("zh-TW")).toBe("zh-CN");
|
||||||
|
expect(convertDetectedLanguage("ja-JP")).toBe("ja-JP");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write to page-origin storage", () => {
|
||||||
|
expect(getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS).caches).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+4
@@ -189,6 +189,10 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^15.3.4
|
specifier: ^15.3.4
|
||||||
version: 15.7.4(i18next@24.2.3(typescript@5.9.3))(react@19.2.6)(typescript@5.9.3)
|
version: 15.7.4(i18next@24.2.3(typescript@5.9.3))(react@19.2.6)(typescript@5.9.3)
|
||||||
|
devDependencies:
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.1.6
|
||||||
|
version: 4.1.6(@types/node@25.8.0)(happy-dom@15.11.7)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0))
|
||||||
|
|
||||||
packages/ui:
|
packages/ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ intended. Read `bsk upload --help` and `bsk download --help` for all flags and e
|
|||||||
- Stale ref: observe again and retry the intended action once.
|
- Stale ref: observe again and retry the intended action once.
|
||||||
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
|
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
|
||||||
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
|
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
|
||||||
|
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
|
||||||
|
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
|
||||||
|
the remaining difference; do not blindly repeat fill or immediately request human help. For other
|
||||||
|
fill errors, follow the returned hint and inspect current state before retrying.
|
||||||
- Unsupported command: continue with available capabilities; suggest updating only when the missing
|
- Unsupported command: continue with available capabilities; suggest updating only when the missing
|
||||||
command is necessary.
|
command is necessary.
|
||||||
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
|
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
|
||||||
|
|||||||
Reference in New Issue
Block a user