Skip to content

Commit 5a88eea

Browse files
Merge branch 'main' into factory/issue-34459
2 parents 4d233b1 + f31ce3e commit 5a88eea

263 files changed

Lines changed: 12595 additions & 1607 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"acorn": "8.16.0",
3838
"cross-env": "^10.1.0",
3939
"electron-builder": "26.16.1",
40+
"jsdom": "^30.0.1",
4041
"tailwindcss": "^4.0.0",
4142
"vite-plus": "catalog:"
4243
},

‎apps/desktop/src/preview/DesktopBrowserHost.test.ts‎

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -607,20 +607,21 @@ describe("DesktopBrowserHost", () => {
607607
params: { sessionId: "t3-preview-page-1" },
608608
});
609609
expect(yield* Queue.take(replies)).toBe(3);
610-
expect(painting).toEqual([true, false]);
610+
expect(painting).toEqual([true]);
611611

612612
// The agent's screenshot waits its turn; painting lets the abandoned one finish first.
613613
yield* command(host, 4, "Page.captureScreenshot");
614614
yield* settle;
615-
expect(painting).toEqual([true, false, true]);
615+
expect(painting).toEqual([true]);
616616
debuggee.release();
617617
expect(yield* Queue.take(replies)).toBe(2);
618618
yield* settle;
619619
expect(debuggee.sent).toEqual([undefined, 4]);
620+
expect(painting).toEqual([true]);
620621
debuggee.release();
621622
expect(yield* Queue.take(replies)).toBe(4);
622623
yield* settle;
623-
expect(painting).toEqual([true, false, true, false]);
624+
expect(painting).toEqual([true, false]);
624625
}).pipe(Effect.scoped),
625626
);
626627

@@ -645,14 +646,19 @@ describe("DesktopBrowserHost", () => {
645646
expect(yield* Queue.take(replies)).toBe(2);
646647
yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" });
647648
yield* settle;
648-
expect(painting).toEqual([true, false, true]);
649+
expect(painting).toEqual([true]);
649650
yield* raw({
650651
id: 4,
651652
method: "Target.detachFromTarget",
652653
params: { sessionId: "t3-preview-page-1" },
653654
});
654655
expect(yield* Queue.take(replies)).toBe(4);
655-
expect(painting).toEqual([true, false, true, false]);
656+
657+
// The agent's answers late; the viewer's is dropped unsent, so nothing is left to paint.
658+
debuggee.release();
659+
yield* settle;
660+
expect(debuggee.sent).toEqual([1]);
661+
expect(painting).toEqual([true, false]);
656662
yield* TestClock.adjust("1 minute");
657663
expect(debuggee.focusEmulation).toEqual([true, false]);
658664
}).pipe(Effect.scoped),
@@ -682,7 +688,7 @@ describe("DesktopBrowserHost", () => {
682688
}).pipe(Effect.scoped),
683689
);
684690

685-
it.effect("stops painting for a screenshot still queued when the agent's turn ends", () =>
691+
it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () =>
686692
Effect.gen(function* () {
687693
const host = yield* DesktopBrowserHost.make;
688694
const debuggee = makeDebuggee();
@@ -697,15 +703,18 @@ describe("DesktopBrowserHost", () => {
697703
yield* settle;
698704
yield* turn(false);
699705
yield* settle;
700-
expect(painting).toEqual([true, false]);
706+
expect(painting).toEqual([true]);
701707

702-
// The first answers late; the second still goes in turn, but holds nothing.
708+
// The first answers late; the second still goes in turn, painting but holding no focus.
703709
debuggee.releaseFirst();
704710
yield* settle;
705711
expect(debuggee.sent).toEqual([1, 2]);
706-
expect(painting).toEqual([true, false]);
707712
yield* TestClock.adjust("1 minute");
708713
expect(debuggee.focusEmulation).toEqual([true, false]);
714+
expect(painting).toEqual([true]);
715+
debuggee.release();
716+
yield* settle;
717+
expect(painting).toEqual([true, false]);
709718
}).pipe(Effect.scoped),
710719
);
711720

@@ -952,7 +961,7 @@ describe("DesktopBrowserHost", () => {
952961
}).pipe(Effect.scoped),
953962
);
954963

955-
it.effect("stops painting for a screenshot Chromium never answers", () =>
964+
it.effect("stops painting for a screenshot Chromium never answers once its document goes", () =>
956965
Effect.gen(function* () {
957966
const host = yield* DesktopBrowserHost.make;
958967
const debuggee = makeDebuggee();
@@ -961,32 +970,34 @@ describe("DesktopBrowserHost", () => {
961970
const turn = (active: boolean) =>
962971
host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
963972

964-
// The server gave up on it, and the agent's turn ends.
973+
// The server gave up on it, and the agent's turn ends; Chromium still holds the capture.
965974
yield* turn(true);
966975
yield* command(host, 1, "Page.captureScreenshot");
967976
yield* settle;
968977
yield* turn(false);
969978
yield* settle;
970-
expect(painting).toEqual([true, false]);
979+
expect(painting).toEqual([true]);
971980

972981
// The page loads a new document, which also frees the screenshot turn.
973982
debuggee.navigate();
983+
expect(painting).toEqual([true, false]);
974984
yield* command(host, 2, "Page.captureScreenshot");
975985
yield* settle;
976986
debuggee.navigate();
977987
yield* settle;
978988
expect(painting).toEqual([true, false, true, false]);
979989

980-
// The server releases the relay.
990+
// The server releases the relay; Chromium still holds the capture.
981991
yield* command(host, 3, "Page.captureScreenshot");
982992
yield* settle;
983993
yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
984994
yield* settle;
985-
expect(painting).toEqual([true, false, true, false, true, false]);
995+
expect(painting).toEqual([true, false, true, false, true]);
986996

987997
// The guest detaches; a replacement starts with no screenshot pending.
988998
const replacement = makeDebuggee();
989999
host.attach(key, replacement.tab);
1000+
expect(painting).toEqual([true, false, true, false, true, false]);
9901001
yield* command(host, 4, "Page.captureScreenshot");
9911002
yield* settle;
9921003
host.detach(key);

‎apps/desktop/src/preview/DesktopBrowserHost.ts‎

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DesktopBrowserCommand,
55
DesktopBrowserEvent,
66
type DesktopBrowserEvent as DesktopBrowserEventType,
7+
type PreviewAutomationLoadError,
78
} from "@t3tools/contracts";
89
import * as Context from "effect/Context";
910
import * as NodePath from "node:path";
@@ -48,7 +49,7 @@ interface AttachedTab {
4849
readonly debuggee: DesktopBrowserTabDebugger;
4950
relay: CdpRelayConnection | null;
5051
screenshotTurn: object | null;
51-
/** Each holds focus and painting while `held` and whoever queued it still waits on it. */
52+
/** Each keeps the guest painting while whoever queued it still waits, and focused while `held`. */
5253
readonly waitingScreenshots: Array<{
5354
readonly start: () => void;
5455
readonly live: () => boolean;
@@ -61,10 +62,9 @@ interface AttachedTab {
6162
focusChange: Promise<void> | null;
6263
focusUses: number;
6364
/** Each pending command, by the extra session waiting on it; the main connection's have none. */
64-
readonly focusPending: Map<
65-
object,
66-
{ readonly owner: string | undefined; readonly paints: boolean }
67-
>;
65+
readonly focusPending: Map<object, string | undefined>;
66+
/** Screenshots Chromium was sent and has not answered; each keeps its wake lock until then. */
67+
readonly capturing: Set<object>;
6868
/** Whether the client keeps the guest painting, as published on `painting`. */
6969
painting: boolean;
7070
/**
@@ -93,6 +93,7 @@ export class DesktopBrowserHost extends Context.Service<
9393
readonly handleCommandLine: (line: string) => Effect.Effect<void>;
9494
readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
9595
readonly detach: (key: DesktopBrowserTabKey) => void;
96+
readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void;
9697
readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void;
9798
readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
9899
readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
@@ -148,7 +149,7 @@ export const make = Effect.gen(function* () {
148149
tab: AttachedTab,
149150
relay: CdpRelayConnection,
150151
ownerLive: () => boolean,
151-
/** Sends only while `current` holds; holds focus and painting only while `held`. */
152+
/** Sends only while `current` holds; holds focus only while `held`. */
152153
capture: (current: () => boolean, held: boolean) => Promise<unknown>,
153154
) =>
154155
new Promise<unknown>((resolve, reject) => {
@@ -236,38 +237,42 @@ export const make = Effect.gen(function* () {
236237
const active =
237238
tabs.get(keyOf(tab.key)) === tab &&
238239
(tab.screencasts.size > 0 ||
239-
waitingHolds(tab) ||
240-
[...tab.focusPending.values()].some((hold) => hold.paints));
240+
tab.capturing.size > 0 ||
241+
tab.waitingScreenshots.some((waiting) => waiting.live()));
241242
if (tab.painting === active) return;
242243
tab.painting = active;
243244
runFork(PubSub.publish(painting, { key: tab.key, active }));
244245
};
245246

246247
/** A command may wait on the page's next frame, so the guest stays focused until it settles. */
247-
const holdFocus = <A>(
248-
tab: AttachedTab,
249-
method: string,
250-
owner: string | undefined,
251-
work: () => Promise<A>,
252-
) => {
248+
const holdFocus = <A>(tab: AttachedTab, owner: string | undefined, work: () => Promise<A>) => {
253249
const pending = {};
254-
tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" });
250+
tab.focusPending.set(pending, owner);
255251
tab.focusUses += 1;
256252
setFocusEmulation(tab, true);
257253
releaseFocusWhenIdle(tab);
258-
syncPainting(tab);
259254
return work().finally(() => {
260255
if (!tab.focusPending.delete(pending)) return;
261256
tab.focusUses += 1;
262257
releaseFocusWhenIdle(tab);
258+
});
259+
};
260+
261+
/** Chromium answers a capture, and frees its wake lock, only once the guest paints a frame. */
262+
const paintUntilAnswered = <A>(tab: AttachedTab, send: () => Promise<A>) => {
263+
const sent = {};
264+
tab.capturing.add(sent);
265+
syncPainting(tab);
266+
return send().finally(() => {
267+
tab.capturing.delete(sent);
263268
syncPainting(tab);
264269
});
265270
};
266271

267272
/**
268273
* Chromium can leave a command unanswered, so the screenshot guard's release points
269-
* also end every hold: a released relay has no one waiting, and a new document answers
270-
* nothing pending from the old one. A released relay's screencast has no viewer to stop it.
274+
* also end every focus hold: a released relay has no one waiting, and a new document
275+
* answers nothing pending from the old one. A released relay's screencast has no viewer.
271276
*/
272277
const dropFocusHolds = (tab: AttachedTab) => {
273278
tab.focusPending.clear();
@@ -285,24 +290,24 @@ export const make = Effect.gen(function* () {
285290
/** A detached session, such as a closed viewer, waits on nothing it sent. */
286291
const dropSessionHolds = (tab: AttachedTab, owner: string) => {
287292
tab.screencasts.delete(owner);
288-
for (const [pending, hold] of tab.focusPending) {
289-
if (hold.owner === owner) tab.focusPending.delete(pending);
293+
for (const [pending, holder] of tab.focusPending) {
294+
if (holder === owner) tab.focusPending.delete(pending);
290295
}
291296
releaseFocusWhenIdle(tab);
292297
syncPainting(tab);
293298
};
294299

295300
/**
296301
* A command the server stopped waiting on, such as a timed-out screenshot or evaluation,
297-
* may never settle; the turn's end ends every hold. The agent also sends on its own extra
302+
* may never settle; the turn's end ends every focus hold. The agent also sends on its own extra
298303
* session, so this cannot spare other sessions; a viewer's screencast still holds.
299304
*/
300305
const endAgentTurn = (threadId: string) => {
301306
agentTurns.delete(threadId);
302307
for (const tab of tabs.values()) {
303308
if (tab.key.threadId !== threadId) continue;
304309
tab.agentUsed = false;
305-
// Still sent in turn, so the queue keeps moving, but no longer kept painting.
310+
// Still sent in turn, so the queue keeps moving, but no longer kept focused.
306311
for (const waiting of tab.waitingScreenshots) waiting.held = false;
307312
dropFocusHolds(tab);
308313
}
@@ -323,8 +328,10 @@ export const make = Effect.gen(function* () {
323328
if (sessionId === undefined && method === "Page.startScreencast") {
324329
tab.screencasts.add(owner);
325330
if (owner !== undefined) streamingOwners.add(owner);
331+
syncPainting(tab);
326332
} else if (sessionId === undefined && method === "Page.stopScreencast") {
327333
tab.screencasts.delete(owner);
334+
syncPainting(tab);
328335
}
329336
// A person's input through a viewer, or Playwright's own housekeeping, is not the agent's.
330337
if (
@@ -337,9 +344,11 @@ export const make = Effect.gen(function* () {
337344
const forward = (current?: () => boolean) => {
338345
const change = tab.focusChange;
339346
const run = () =>
340-
tab.relay === relay && (current === undefined || current())
341-
? send(method, params, sessionId)
342-
: Promise.reject(new Error("The page this screenshot was for has moved on."));
347+
tab.relay !== relay || (current !== undefined && !current())
348+
? Promise.reject(new Error("The page this screenshot was for has moved on."))
349+
: current === undefined
350+
? send(method, params, sessionId)
351+
: paintUntilAnswered(tab, () => send(method, params, sessionId));
343352
return change === null ? run() : change.then(run);
344353
};
345354
return method === "Page.captureScreenshot"
@@ -348,17 +357,17 @@ export const make = Effect.gen(function* () {
348357
relay,
349358
() => owner === undefined || !detachedOwners.has(owner),
350359
(current, held) =>
351-
held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current),
360+
held ? holdFocus(tab, owner, () => forward(current)) : forward(current),
352361
)
353362
: method === "Page.startScreencast"
354-
? holdFocus(tab, method, owner, forward).catch((cause: unknown) => {
363+
? holdFocus(tab, owner, forward).catch((cause: unknown) => {
355364
// A viewer can outlive a refused stream; nothing is streaming for it.
356365
if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner);
357366
releaseFocusWhenIdle(tab);
358367
syncPainting(tab);
359368
throw cause;
360369
})
361-
: holdFocus(tab, method, owner, forward);
370+
: holdFocus(tab, owner, forward);
362371
},
363372
sessionDetached: (owner) => {
364373
detachedOwners.add(owner);
@@ -445,6 +454,7 @@ export const make = Effect.gen(function* () {
445454
focusChange: null,
446455
focusUses: 0,
447456
focusPending: new Map(),
457+
capturing: new Set(),
448458
painting: false,
449459
agentUsed: false,
450460
focusReleasing: false,
@@ -459,7 +469,8 @@ export const make = Effect.gen(function* () {
459469
// A screenshot pending when the page loads a new document, a reload included, can
460470
// stay unanswered. A crashed page lands here once it reloads, well after its renderer died.
461471
onNewDocument: () => {
462-
// Before the next screenshot starts, so its own hold survives.
472+
// Before the next screenshot starts, so its own holds survive.
473+
tab.capturing.clear();
463474
dropFocusHolds(tab);
464475
if (tab.screenshotTurn !== null) nextScreenshot(tab);
465476
},
@@ -523,6 +534,10 @@ export const make = Effect.gen(function* () {
523534
if (tabs.get(id)?.stopWaiting === null) emit({ type: "shown", ...key, shown });
524535
};
525536

537+
const loadFailed: DesktopBrowserHost["Service"]["loadFailed"] = (key, failure) => {
538+
if (tabs.has(keyOf(key))) emit({ type: "loadFailed", ...key, ...failure });
539+
};
540+
526541
const handleCommandLine = (line: string) =>
527542
Effect.sync(() => {
528543
const command = decodeCommand(line);
@@ -588,6 +603,7 @@ export const make = Effect.gen(function* () {
588603
handleCommandLine,
589604
attach,
590605
detach,
606+
loadFailed,
591607
setShown,
592608
placeDownload,
593609
humanStartedDownload,

0 commit comments

Comments
 (0)