Keep desktop browser tabs stable across focus and automation (#2907)

* fix(desktop): preserve browser tabs across focus changes

Electron can replace a guest WebContents when a webview is reparented, which reloads the page and can race automation against a destroyed target. Keep each browser guest on one permanent paintable surface and make user and agent resizing share one canonical viewport.

* fix(desktop): recover browser surfaces after lifecycle changes

* fix(desktop): preserve resident browser lifecycle state

* fix(desktop): seed inactive browser viewport
This commit is contained in:
Mohamed Boudra
2026-08-06 00:05:21 +02:00
committed by GitHub
parent 7f7e8923eb
commit eb95a6eea6
12 changed files with 929 additions and 84 deletions
@@ -20,11 +20,14 @@ class FakeDebugger {
public blockCommands = false;
public readonly blockedCommandNames = new Set<string>();
public readonly failedCommandNames = new Set<string>();
public readonly failedCommandErrors = new Map<string, Error>();
public readonly promptDialogs: unknown[] = [];
public failPromptDrain = false;
public beforeCommand: ((command: string) => void) | null = null;
private messageListener:
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
| null = null;
private detachListener: (() => void) | null = null;
private readonly blockedCommands: Array<() => void> = [];
public isAttached(): boolean {
@@ -37,6 +40,11 @@ class FakeDebugger {
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
this.commands.push({ command, params: params ?? {} });
this.beforeCommand?.(command);
const commandError = this.failedCommandErrors.get(command);
if (commandError) {
throw commandError;
}
if (this.failedCommandNames.has(command)) {
throw new Error(`${command} failed`);
}
@@ -58,10 +66,15 @@ class FakeDebugger {
}
public on(
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
event: "message" | "detach",
listener:
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
| (() => void),
): void {
expect(event).toBe("message");
if (event === "detach") {
this.detachListener = listener;
return;
}
this.messageListener = listener;
}
@@ -72,6 +85,11 @@ class FakeDebugger {
this.messageListener({}, method, params);
}
public emitDetach(): void {
this.attachedProtocolVersions.length = 0;
this.detachListener?.();
}
public finishNextCommand(): void {
const resolve = this.blockedCommands.shift();
if (!resolve) {
@@ -92,6 +110,7 @@ type ConsoleMessageListener = (
class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
public readonly loadedUrls: string[] = [];
public readonly captures: Array<{
rect: Rectangle | undefined;
options: { stayHidden?: boolean } | undefined;
@@ -138,7 +157,9 @@ class FakeWebContents {
return null;
}
public async loadURL(): Promise<void> {}
public async loadURL(url: string): Promise<void> {
this.loadedUrls.push(url);
}
public goBack(): void {}
@@ -186,6 +207,7 @@ class FakeWebContents {
public destroy(): void {
this.destroyed = true;
this.debugger.emitDetach();
this.destroyedListener?.();
}
}
@@ -571,6 +593,54 @@ describe("browser automation IPC adapter", () => {
);
warn.mockRestore();
});
test("does not run the command after the debugger target closes during setup", async () => {
const contents = new FakeWebContents(31);
contents.debugger.beforeCommand = (command) => {
if (command === "Page.enable") {
contents.destroy();
}
};
contents.debugger.failedCommandErrors.set(
"Page.enable",
new Error("target closed while handling command"),
);
const tab = adaptWebContents(contents);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(
tab.captureDialogs?.(() => tab.loadURL("https://replacement.example.com")),
).rejects.toThrow("target closed while handling command");
expect(contents.loadedUrls).toEqual([]);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
test("runs without dialog capture after a live debugger session detaches", async () => {
const contents = new FakeWebContents(32);
const tab = adaptWebContents(contents);
await expect(tab.captureDialogs?.(async () => "captured")).resolves.toEqual({
result: "captured",
dialogs: [],
});
contents.debugger.emitDetach();
contents.debugger.failedCommandNames.add("Page.enable");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(
tab.captureDialogs?.(() => tab.loadURL("https://replacement.example.com")),
).resolves.toEqual({ result: undefined, dialogs: [] });
expect(contents.loadedUrls).toEqual(["https://replacement.example.com"]);
expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
expect(warn).toHaveBeenCalledWith(
"[browser-automation] Dialog capture unavailable; running command without it",
{ contentsId: 32, error: expect.any(Error) },
);
warn.mockRestore();
});
});
class FakeHostWebContents {
@@ -74,6 +74,7 @@ interface WebContentsDebugger {
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
): void;
on?(event: "detach", listener: () => void): void;
}
interface ConsoleMessageEmitter {
@@ -188,6 +189,7 @@ function getDialogMonitor(
class DialogMonitor {
private enabled = false;
private listenerRegistered = false;
private detachGeneration = 0;
private readonly activeCollectors: DialogCollector[] = [];
public constructor(
@@ -200,10 +202,14 @@ class DialogMonitor {
task: () => Promise<T>,
): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> {
const collector: DialogCollector = { dialogs: [] };
const setupDetachGeneration = this.detachGeneration;
try {
await this.enable();
await this.installPromptShim();
} catch (error) {
if (this.contents.isDestroyed() || this.detachGeneration !== setupDetachGeneration) {
throw error;
}
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
contentsId: this.contentsId,
error,
@@ -244,6 +250,10 @@ class DialogMonitor {
}
void this.handleOpening(params ?? {});
});
this.contents.debugger.on("detach", () => {
this.enabled = false;
this.detachGeneration += 1;
});
}
await this.sendDebugCommand("Page.enable");
this.enabled = true;