Skip to content
Source
Sign inSign up

ServerBrowserPage.ts

1,201 lines46.8 KB

1// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime.
2​
3import {
4 type BrowserRecordingFrameRate,
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
6 type PreviewAutomationClickInput,
7 type PreviewAutomationConsoleEntry,
8 type PreviewAutomationDragInput,
9 type PreviewAutomationEvaluateInput,
10 type PreviewAutomationHoverInput,
11 type PreviewAutomationNetworkEntry,
12 type PreviewAutomationPressInput,
13 type PreviewAutomationScrollInput,
14 type PreviewAutomationSelectInput,
15 type PreviewAutomationSelectResult,
16 type PreviewAutomationSnapshot,
17 type PreviewAutomationTypeInput,
18 type PreviewAutomationUploadInput,
19 type PreviewAutomationWaitForInput,
20} from "@t3tools/contracts";
21import { constVoid } from "effect/Function";
22import * as Schema from "effect/Schema";
23import type { CDPSession, ConsoleMessage, Frame, Locator, Page } from "playwright-core";
24import * as NodeCrypto from "node:crypto";
25import { BrowserControlInterrupted } from "./SessionControl.ts";
26​
27const isBrowserControlInterrupted = Schema.is(BrowserControlInterrupted);
28​
29const MAX_EVALUATION_BYTES = 64_000;
30const MAX_VISIBLE_TEXT_LENGTH = 20_000;
31const MAX_SCREENSHOT_WIDTH = 1280;
32// Two attempts must fit in a snapshot's 10-second deadline.
33const SCREENSHOT_TIMEOUT_MS = 3_000;
34const WAIT_POLL_MS = 100;
35export const DIAGNOSTIC_BUFFER_LIMIT = 200;
36​
37export class ServerBrowserOperationError extends Schema.TaggedError<ServerBrowserOperationError>()(
38 "ServerBrowserOperationError",
39 {
40 tag: Schema.String,
41 message: Schema.String,
42 detail: Schema.optional(Schema.Unknown),
43 cause: Schema.optional(Schema.Defect()),
44 },
45) {}
46​
47const isServerBrowserOperationError = Schema.is(ServerBrowserOperationError);
48​
49export const toOperationError = (
50 cause: unknown,
51 timeoutMs?: number,
52): ServerBrowserOperationError => {
53 if (isBrowserControlInterrupted(cause))
54 return new ServerBrowserOperationError({
55 tag: "PreviewAutomationControlInterruptedError",
56 message: cause.message,
57 detail: cause.reason,
58 });
59 if (isServerBrowserOperationError(cause)) return cause;
60 const message = cause instanceof Error ? cause.message : String(cause);
61 const firstLine = message.split("\n")[0] ?? message;
62 if (cause instanceof Error && cause.name === "TimeoutError") {
63 return new ServerBrowserOperationError({
64 tag: "PreviewAutomationTimeoutError",
65 message: firstLine,
66 detail: timeoutMs === undefined ? undefined : { timeoutMs },
67 });
68 }
69 if (/while parsing selector|Unknown engine|Unexpected token/i.test(message)) {
70 return new ServerBrowserOperationError({
71 tag: "PreviewAutomationInvalidSelectorError",
72 message: firstLine,
73 });
74 }
75 if (/not an <input>|not editable|not an editable/i.test(message)) {
76 return new ServerBrowserOperationError({
77 tag: "PreviewAutomationTargetNotEditableError",
78 message: firstLine,
79 });
80 }
81 return new ServerBrowserOperationError({
82 tag: "PreviewAutomationExecutionError",
83 message: firstLine,
84 });
85};
86​
87const DEFAULT_TIMEOUT_MS = 15_000;
88const TARGET_COUNT_MS = 250;
89const MAX_TARGET_CHARS = 200;
90​
91const pageRefs = new WeakMap<Page, { generation: string; refs: Map<string, string> }>();
92// A compact runtime namespace prevents old refs from aliasing after a server restart.
93const refNamespace = NodeCrypto.randomUUID().slice(0, 8);
94let snapshotSequence = 0;
95const nextRefGeneration = () => `${refNamespace}-${(++snapshotSequence).toString(36)}`;
96​
97/** A takeover revokes previously issued refs even when the document stays unchanged. */
98export const invalidateRefs = (page: Page) => {
99 const state = pageRefs.get(page);
100 if (state) {
101 state.generation = nextRefGeneration();
102 state.refs.clear();
103 }
104};
105​
106const refsFor = (page: Page) => {
107 let state = pageRefs.get(page);
108 if (!state) {
109 state = { generation: nextRefGeneration(), refs: new Map<string, string>() };
110 pageRefs.set(page, state);
111 page.on("framenavigated", () => invalidateRefs(page));
112 page.on("framedetached", () => invalidateRefs(page));
113 }
114 return state;
115};
116​
117const targetLocator = (
118 page: Page,
119 input: { readonly locator?: string | undefined; readonly selector?: string | undefined },
120): Locator | null => {
121 const selector = input.locator ?? input.selector;
122 if (selector === undefined) return null;
123 const engines = selector.replace(
124 /\\.|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g,
125 " ",
126 );
127 if (/(?:^|>>)\s*\*?aria-ref\s*=/.test(engines)) {
128 const ref = /^\s*aria-ref\s*=\s*(\S+)\s*$/.exec(selector)?.[1];
129 const nativeRef = ref === undefined ? undefined : pageRefs.get(page)?.refs.get(ref);
130 if (nativeRef === undefined) {
131 throw new ServerBrowserOperationError({
132 tag: "PreviewAutomationInvalidSelectorError",
133 message:
134 "This element ref is stale or belongs to another tab. Take a fresh snapshot and use its locator.",
135 detail: { staleRef: true },
136 });
137 }
138 return page.locator(`aria-ref=${nativeRef}`);
139 }
140 // Playwright's strict locators reject ambiguous controls rather than acting on row one.
141 return page.locator(selector);
142};
143​
144export type PointerReporter = (
145 point: { readonly x: number; readonly y: number },
146 phase: "move" | "click",
147) => Promise<void>;
148const noPointer: PointerReporter = async () => {};
149​
150const targetPoint = async (
151 page: Page,
152 locator: Locator | null,
153 input: { readonly x?: number | undefined; readonly y?: number | undefined },
154 timeout: number,
155) => {
156 if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 };
157 await locator.scrollIntoViewIfNeeded({ timeout });
158 const box = await locator.boundingBox({ timeout });
159 if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
160 const viewport = page.viewportSize();
161 return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 };
162};
163​
164const SNAPSHOT_SCRIPT = `(() => {
165 return {
166 url: location.href,
167 title: document.title,
168 loading: document.readyState !== "complete",
169 visibleText: document.body?.innerText || "",
170 interactiveElements: [],
171 };
172})()`;
173​
174const MASKED_VALUE = "••••••••";
175​
176/**
177 * A `data:` URL carries its content, which can be a whole file, and URLs and diagnostics
178 * reach tool results and provider logs. Keeps only the media type: the payload can hold any
179 * character and parameters are page-chosen too.
180 */
181export const withoutDataPayload = (url: string) => {
182 // Read the scheme as a browser would: it ignores leading controls and spaces, and tabs and
183 // newlines anywhere. A snapshot's YAML value may be quoted and escaped; an escape that isn't a
184 // tab or newline still decodes to something that can't start a scheme.
185 const value = url
186 .replace(/^["']/, "")
187 .replace(/\\(x[\da-f]{2}|u[\da-f]{4}|.)/gi, (_escape, code: string) =>
188 code.length > 1
189 ? String.fromCharCode(Number.parseInt(code.slice(1), 16))
190 : ({ t: "\t", n: "\n", r: "\r", '"': '"', "\\": "\\" }[code] ?? "\u0001"),
191 )
192 .replace(/[\t\n\r]/g, "")
193 .replace(/^[\u0000-\u0020]+/, "");
194 const parsed = /^data:([^,;]*)/i.exec(value);
195 if (!parsed) return url;
196 const type = parsed[1]!.trim();
197 const mediaType = /^[\w.+-]{1,64}\/[\w.+-]{1,64}$/.test(type) ? type : "";
198 return `data:${mediaType},…`;
199};
200​
201/**
202 * Chromium's own console messages, which carry no arguments, quote `data:` URLs whole, as in a
203 * blocked navigation or a CSP violation. Everything after the first one goes, since Chromium
204 * accepts any type and a payload can hold any character. What a page logs itself is its own
205 * text, like anything else it shows, so it stays.
206 */
207export const consoleText = (message: ConsoleMessage) =>
208 message.args().length > 0
209 ? message.text()
210 : message.text().replace(/(?<![a-z\d])data:[^,;]*[,;][\s\S]*/i, withoutDataPayload);
211​
212// Split on LF alone: a value keeps any U+2028 that a multiline regex would treat as a line end.
213const withoutTreeDataPayloads = (tree: string) =>
214 tree
215 .split("\n")
216 .map((line) => {
217 const url = /^(\s*- \/url: )(.*)$/s.exec(line);
218 return url ? url[1] + withoutDataPayload(url[2]!) : line;
219 })
220 .join("\n");
221​
222// The broker waits 15s for the host; a snapshot, retry included, answers well before.
223const SNAPSHOT_TIMEOUT_MS = 10_000;
224const FRAME_ANSWER_MS = 1_000;
225​
226// Include password and CSS-masked text in open shadow roots, normalized like accessible text.
227const DOTTED_SCRIPT = `(() => {
228 const values = [];
229 const visit = (root) => {
230 for (const element of root.querySelectorAll("*")) {
231 if (element.shadowRoot) visit(element.shadowRoot);
232 const field = element.localName === "input" || element.localName === "textarea";
233 if (!field && !element.isContentEditable) continue;
234 const dots = getComputedStyle(element).getPropertyValue("-webkit-text-security");
235 if (element.type !== "password" && (dots === "none" || dots === "")) continue;
236 // Raw as page text shows it, and normalized as the tree does.
237 for (const value of field ? [element.value, element.defaultValue] : [element.textContent ?? ""])
238 values.push(value, value.replace(/[\\u200b\\u00ad]/g, "").trim().replace(/\\s+/g, " "));
239 }
240 };
241 visit(document);
242 return [...new Set(values.filter((value) => value.trim()))];
243})()`;
244​
245const pageChanged = () =>
246 new ServerBrowserOperationError({
247 tag: "PreviewAutomationExecutionError",
248 message: "The page changed while capturing its snapshot. Take another snapshot.",
249 });
250​
251const tooBusy = () =>
252 new ServerBrowserOperationError({
253 tag: "PreviewAutomationTimeoutError",
254 message:
255 "The page was too busy to capture a snapshot in time. Wait for it to settle and take another snapshot.",
256 });
257​
258const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken.";
259​
260/** Reuse one CDP session per out-of-process frame across snapshots. */
261const frameSessions = new WeakMap<Frame, Promise<CDPSession>>();
262​
263/** Returns undefined on failure or frame timeout. */
264const answered = <A>(promise: Promise<A>) => {
265 let timer: ReturnType<typeof setTimeout> | undefined;
266 const late = new Promise<undefined>((resolve) => {
267 timer = setTimeout(() => resolve(undefined), FRAME_ANSWER_MS);
268 });
269 return Promise.race([promise.catch(() => undefined), late]).finally(() => clearTimeout(timer));
270};
271​
272/** Isolated worlds prevent spoofing; desktop out-of-process frames use page worlds. Missing own-frame reads or changed frames fail; other missing reads return undefined. */
273const openReaders = async (page: Page, cdp: CDPSession) => {
274 const frames = page.frames();
275 const owners = new Map<Frame, CDPSession | null | undefined>(
276 await Promise.all(
277 frames.map(async (frame) => {
278 if (frame === page.mainFrame()) return [frame, cdp] as const;
279 const session = frameSessions.get(frame) ?? page.context().newCDPSession(frame);
280 frameSessions.set(frame, session);
281 try {
282 return [frame, await session] as const;
283 } catch (cause) {
284 frameSessions.delete(frame);
285 // CdpRelay supports only the page; other refusals fall back to the parent session, failing if the frame is absent.
286 const desktop = String(cause).includes("Only this preview tab's page can be attached");
287 return [frame, desktop ? null : undefined] as const;
288 }
289 }),
290 ),
291 );
292 const ownerOf = (frame: Frame): CDPSession | null => {
293 const owner = owners.get(frame);
294 if (owner !== undefined) return owner;
295 const parent = frame.parentFrame();
296 return parent ? ownerOf(parent) : null;
297 };
298 const sessionReader = (frame: Frame, session: CDPSession) => {
299 const owned = frames.filter((each) => ownerOf(each) === session);
300 const open = async () => {
301 const { frameTree } = await session.send("Page.getFrameTree");
302 const ids = (node: typeof frameTree): Array<string> => [
303 node.frame.id,
304 ...(node.childFrames ?? []).flatMap(ids),
305 ];
306 // A frame Playwright knows but this session doesn't would go unread.
307 if (ids(frameTree).length !== owned.length) throw pageChanged();
308 return Promise.all(
309 ids(frameTree).map(async (frameId) => {
310 const world = await session.send("Page.createIsolatedWorld", {
311 frameId,
312 worldName: "t3-preview",
313 });
314 return world.executionContextId;
315 }),
316 );
317 };
318 let contexts: ReturnType<typeof open> | undefined;
319 const read = async () => {
320 const values = (contexts ??= open()).then((ids) =>
321 Promise.all(
322 ids.map(async (contextId) => {
323 const { result, exceptionDetails } = await session.send("Runtime.evaluate", {
324 contextId,
325 expression: DOTTED_SCRIPT,
326 returnByValue: true,
327 });
328 if (exceptionDetails) throw pageChanged();
329 return result.value as Array<string>;
330 }),
331 ),
332 );
333 try {
334 return (await values).flat();
335 } catch (cause) {
336 // The frame may have moved to another process; the next snapshot opens a new session.
337 if (session !== cdp) {
338 frameSessions.delete(frame);
339 void session.detach().catch(constVoid);
340 }
341 throw cause;
342 }
343 };
344 return { frames: owned, read };
345 };
346 const readers = [
347 sessionReader(page.mainFrame(), cdp),
348 ...frames.flatMap((frame) => {
349 const owner = owners.get(frame);
350 if (owner) return owner === cdp ? [] : [sessionReader(frame, owner)];
351 const own = () => frame.evaluate(DOTTED_SCRIPT) as Promise<Array<string>>;
352 return ownerOf(frame) === null ? [{ frames: [frame], read: own }] : [];
353 }),
354 ];
355 return {
356 readers,
357 read: async (before?: ReadonlyArray<unknown>) => {
358 const now = page.frames();
359 if (now.length !== frames.length || now.some((frame, i) => frame !== frames[i])) {
360 throw pageChanged();
361 }
362 return Promise.all(
363 readers.map((reader, i) => {
364 if (i === 0) return reader.read().catch(() => Promise.reject(pageChanged()));
365 return before && !before[i] ? undefined : answered(reader.read());
366 }),
367 );
368 },
369 };
370};
371​
372/** Short values need word boundaries to avoid masking unrelated text. */
373const dottedPattern = (values: ReadonlyArray<string>) => {
374 const sources = values
375 .flatMap((value) => {
376 const json = JSON.stringify(value).slice(1, -1);
377 // Playwright writes control characters like JSON does, with \xNN for the unnamed ones.
378 const quoted = value.replace(/[\\"\p{Cc}]/gu, (char) =>
379 '\\"\b\f\n\r\t'.includes(char)
380 ? JSON.stringify(char).slice(1, -1)
381 : `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`,
382 );
383 const forms = [value, json, quoted].flatMap((form) => [form, form.replaceAll("'", "''")]);
384 return [...new Set(forms)].map((form) => ({ form, word: value.length < 4 }));
385 })
386 // Longest first, so a form inside another never leaves the rest showing.
387 .toSorted((a, b) => b.form.length - a.form.length)
388 .map(({ form, word }) => {
389 const literal = form.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
390 return word ? `(?<![\\p{L}\\p{N}_])${literal}(?![\\p{L}\\p{N}_])` : literal;
391 });
392 if (sources.length === 0) return undefined;
393 const pattern = new RegExp(sources.join("|"), "gu");
394 try {
395 // Force regex compilation for one-byte and two-byte strings now; size errors name every value.
396 pattern.test("");
397 pattern.test("\u2022");
398 } catch {
399 throw new ServerBrowserOperationError({
400 tag: "PreviewAutomationExecutionError",
401 message: VALUE_TOO_LONG,
402 });
403 }
404 return pattern;
405};
406​
407// Playwright single-quotes keys containing ': ' and leaves /.../ names unquoted. Recognize only its attributes so name text cannot spoof them.
408const TREE_LINE =
409 /^(\s*- '?[^\s"':]+)( "(?:[^"\\]|\\.)*"| \/(?:.*?\/)?)?((?: \[(?:active|checked(?:=mixed)?|disabled|expanded|level=\d+|pressed(?:=mixed)?|selected|ref=(?:f\d+)?e\d+|cursor=pointer|box=-?\d+,-?\d+,\d+,\d+)\])*'?)(:.*)?$/;
410​
411/** Preserve roles and attributes so refs work; unquoted /.../ names can make matches cross boundaries. */
412const maskTree = (tree: string, dotted: RegExp) =>
413 tree
414 .split("\n")
415 .map((line) => {
416 const node = TREE_LINE.exec(line);
417 const name = node?.[1]?.length ?? 0;
418 const attributes = name + (node?.[2]?.length ?? 0);
419 const end = attributes + (node?.[3]?.length ?? 0);
420 const kept = (from: number, to: number) => to <= name || (from >= attributes && to <= end);
421 return line.replace(dotted, (match: string, offset: number) =>
422 kept(offset, offset + match.length) ? match : MASKED_VALUE,
423 );
424 })
425 .join("\n");
426​
427/** Chromium sizes the capture, so the reported size is read from the PNG header. */
428const pngSize = (base64: string) => {
429 const header = Buffer.from(base64.slice(0, 32), "base64");
430 return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
431};
432​
433/**
434 * A page's display ratio and CSS viewport, read in an isolated world since page script can
435 * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom.
436 */
437const readPageMetrics = async (cdp: CDPSession) => {
438 const { frameTree } = await cdp.send("Page.getFrameTree");
439 const { executionContextId } = await cdp.send("Page.createIsolatedWorld", {
440 frameId: frameTree.frame.id,
441 worldName: "t3-preview-metrics",
442 });
443 let { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics");
444 // Desktop zoom applies asynchronously; bracket the CSS metrics to reject a torn read.
445 for (let attempt = 0; attempt < 3; attempt += 1) {
446 const zoom = cssVisualViewport.zoom ?? 1;
447 const { result } = await cdp.send("Runtime.evaluate", {
448 expression: "({ ratio: devicePixelRatio, width: innerWidth, height: innerHeight })",
449 contextId: executionContextId,
450 returnByValue: true,
451 });
452 ({ cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"));
453 if ((cssVisualViewport.zoom ?? 1) === zoom) {
454 const { ratio, width, height } = result.value as {
455 readonly ratio: number;
456 readonly width: number;
457 readonly height: number;
458 };
459 return { ratio, width, height, zoom };
460 }
461 }
462 throw new Error(
463 "The page's zoom kept changing while the snapshot read its size. Try the snapshot again.",
464 );
465};
466​
467// Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter`
468// in any capture it shrinks.
469export const captureViewport = async (
470 cdp: CDPSession,
471 options: { readonly format: "png" | "jpeg"; readonly quality?: number },
472) => {
473 const capture = cdp.send("Page.captureScreenshot", {
474 format: options.format,
475 ...(options.quality === undefined ? {} : { quality: options.quality }),
476 });
477 let timer: ReturnType<typeof setTimeout> | undefined;
478 const expired = new Promise<never>((_, reject) => {
479 timer = setTimeout(
480 () =>
481 reject(
482 new ServerBrowserOperationError({
483 tag: "PreviewAutomationExecutionError",
484 message:
485 "The browser is not producing screenshot frames. For a desktop tab, show or restore the T3 Code window and try again.",
486 }),
487 ),
488 SCREENSHOT_TIMEOUT_MS,
489 );
490 });
491 // Hidden native guests can leave CDP captures pending forever. This deadline also
492 // releases a viewer's still-capture lock, which an agent snapshot may be waiting for.
493 const { data } = await Promise.race([capture, expired]).finally(() => clearTimeout(timer));
494 return data;
495};
496​
497export const captureFinalRecordingFrame = (cdp: CDPSession, quality: number) =>
498 answered(
499 cdp
500 .send("Runtime.evaluate", {
501 // Capture the destination of finite transitions, not their initially transparent frame.
502 expression: `(async () => {
503 const paint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
504 await paint();
505 await Promise.allSettled(document.getAnimations()
506 .filter(animation => animation.playState === "running" && animation.effect?.getComputedTiming().iterations !== Infinity)
507 .map(animation => animation.finished));
508 await paint();
509 })()`,
510 awaitPromise: true,
511 })
512 .then(() => captureViewport(cdp, { format: "jpeg", quality })),
513 );
514​
515/** Runs in the page's own isolated world, as the server has no image library. */
516const SHRINK_PNG = `async function (png, width, height) {
517 const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0));
518 const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" }));
519 // Namespaced, as an XML document's createElement makes a plain element with no getContext.
520 const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
521 canvas.width = width;
522 canvas.height = height;
523 const context = canvas.getContext("2d");
524 context.imageSmoothingQuality = "high";
525 context.drawImage(bitmap, 0, 0, width, height);
526 bitmap.close();
527 return canvas.toDataURL("image/png").slice("data:image/png;base64,".length);
528}`;
529​
530const shrinkPng = async (cdp: CDPSession, png: string, scale: number) => {
531 const size = pngSize(png);
532 const { frameTree } = await cdp.send("Page.getFrameTree");
533 const { executionContextId } = await cdp.send("Page.createIsolatedWorld", {
534 frameId: frameTree.frame.id,
535 worldName: "t3-preview-screenshot",
536 });
537 const { result, exceptionDetails } = await cdp.send("Runtime.callFunctionOn", {
538 functionDeclaration: SHRINK_PNG,
539 executionContextId,
540 arguments: [
541 { value: png },
542 { value: Math.round(size.width * scale) },
543 { value: Math.round(size.height * scale) },
544 ],
545 awaitPromise: true,
546 returnByValue: true,
547 });
548 if (exceptionDetails) throw new Error("The snapshot could not shrink its screenshot.");
549 return result.value as string;
550};
551​
552type SnapshotInput = {
553 readonly page: Page;
554 readonly cdp: CDPSession;
555 /** Headless pages render at the scale the server set; a desktop page at its display's. */
556 readonly renderScale: number | "display";
557 readonly fullScreenshot?: boolean | undefined;
558 readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
559 readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
560 readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"];
561};
562​
563/** The scale a page renders at and its viewport in device-independent pixels. */
564const renderingOf = async (input: SnapshotInput) => {
565 if (input.renderScale !== "display") {
566 const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 };
567 return { renderScale: input.renderScale, viewport };
568 }
569 // The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels.
570 const page = await readPageMetrics(input.cdp);
571 return {
572 renderScale: page.ratio / page.zoom,
573 viewport: {
574 width: Math.round(page.width * page.zoom),
575 height: Math.round(page.height * page.zoom),
576 },
577 };
578};
579​
580const capture = async (
581 input: SnapshotInput,
582 deadline: number,
583): Promise<PreviewAutomationSnapshot> => {
584 const { renderScale, viewport } = await renderingOf(input);
585 // A late metrics reply must not begin a capture after viewers have resumed.
586 if (Date.now() >= deadline) throw tooBusy();
587 const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale));
588 const state = refsFor(input.page);
589 invalidateRefs(input.page);
590 const generation = state.generation;
591 const { readers, read } = await openReaders(input.page, input.cdp);
592 const before = await read();
593 // Bracket the tree with value reads to detect changes; await tree completion before retrying failed captures.
594 const pageAndScreenshot = Promise.all([
595 input.page.evaluate(SNAPSHOT_SCRIPT) as Promise<
596 Pick<
597 PreviewAutomationSnapshot,
598 "url" | "title" | "loading" | "visibleText" | "interactiveElements"
599 >
600 >,
601 captureViewport(input.cdp, { format: "png" }).then(async (full) =>
602 scale < 1
603 ? {
604 data: await shrinkPng(input.cdp, full, scale),
605 full: input.fullScreenshot ? full : undefined,
606 }
607 : { data: full, full: undefined },
608 ),
609 ]);
610 void pageAndScreenshot.catch(constVoid);
611 // Leaves time for the second read; a frame that never loads holds the tree until then.
612 const tree = await input.page.ariaSnapshot({
613 mode: "ai",
614 boxes: true,
615 timeout: Math.max(1, deadline - Date.now() - 2_000),
616 });
617 const after = await read(before);
618 // A value that changed meanwhile may be in the tree under neither reading.
619 if (after.some((values, i) => values && JSON.stringify(values) !== JSON.stringify(before[i])))
620 throw pageChanged();
621 const dotted = dottedPattern(after.flatMap((values) => values ?? []));
622 // Unread frames can expose values only if their content appears in the tree.
623 const unread = readers.flatMap((reader, i) => (after[i] ? [] : reader.frames));
624 const filled = /^\s*- iframe (?:\[active\] )?\[ref=(\S+?)\].*:$/gm;
625 for (const [, ref] of unread.length ? tree.matchAll(filled) : []) {
626 const iframe = await input.page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 2_000 });
627 if (!iframe) throw pageChanged();
628 const frame = await iframe.contentFrame();
629 void iframe.dispose().catch(constVoid);
630 if (frame && unread.includes(frame)) throw tooBusy();
631 }
632 const [page, { full, data }] = await pageAndScreenshot;
633 if (state.generation !== generation) throw pageChanged();
634 const mask = (text: string) => (dotted ? text.replace(dotted, MASKED_VALUE) : text);
635 // Redacted before the cut, so a payload crossing it can't leave its start showing, and before
636 // masking, which could hide the `data:` scheme behind a password value's dots.
637 const redacted = withoutTreeDataPayloads(tree);
638 const accessibilityTree = (dotted ? maskTree(redacted, dotted) : redacted)
639 .slice(0, MAX_VISIBLE_TEXT_LENGTH)
640 .replace(/\[ref=((?:f\d+)?e\d+)\]/g, (_match, nativeRef: string) => {
641 const ref = `t3-${generation}-${nativeRef}`;
642 state.refs.set(ref, nativeRef);
643 return `[ref=${ref}]`;
644 });
645 return {
646 ...page,
647 url: withoutDataPayload(page.url),
648 // Mask text before truncating so a value crossing the cut cannot leak its prefix.
649 visibleText: mask(page.visibleText).slice(0, MAX_VISIBLE_TEXT_LENGTH),
650 accessibilityTree,
651 consoleEntries: [...input.consoleEntries],
652 networkEntries: [...input.networkEntries],
653 actionTimeline: [...input.actionTimeline],
654 screenshot: {
655 mimeType: "image/png",
656 data,
657 ...pngSize(data),
658 },
659 ...(full !== undefined ? { fullScreenshot: { data: full, ...pngSize(full) } } : {}),
660 };
661};
662​
663/** Snapshots begun per page; observers' snapshots aren't queued, so two can overlap. */
664const snapshotTurns = new WeakMap<Page, number>();
665​
666export const snapshot = async (input: SnapshotInput): Promise<PreviewAutomationSnapshot> => {
667 const deadline = Date.now() + SNAPSHOT_TIMEOUT_MS;
668 let timer: ReturnType<typeof setTimeout> | undefined;
669 const expired = new Promise<never>((_, reject) => {
670 timer = setTimeout(() => reject(tooBusy()), SNAPSHOT_TIMEOUT_MS);
671 });
672 const turn = (snapshotTurns.get(input.page) ?? 0) + 1;
673 snapshotTurns.set(input.page, turn);
674 // Retry only before the deadline and the next snapshot, whose refs a later retry would revoke.
675 const captured = capture(input, deadline).catch((cause: unknown) =>
676 Date.now() < deadline &&
677 snapshotTurns.get(input.page) === turn &&
678 !(cause instanceof Error && cause.message === VALUE_TOO_LONG)
679 ? capture(input, deadline)
680 : Promise.reject(cause),
681 );
682 try {
683 return await Promise.race([captured, expired]);
684 } catch (cause) {
685 throw cause instanceof Error && cause.name === "TimeoutError" ? tooBusy() : cause;
686 } finally {
687 clearTimeout(timer);
688 }
689};
690​
691/** Return when a dialog opens; waiting for the action to finish would block until dialog resolution. */
692export const click = async (
693 page: Page,
694 input: PreviewAutomationClickInput,
695 pointer: PointerReporter = noPointer,
696): Promise<{ readonly x: number; readonly y: number }> => {
697 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
698 const locator = targetLocator(page, input);
699 try {
700 return await clickTarget(page, locator, input, timeout, pointer);
701 } catch (cause) {
702 throw locator === null ? cause : await namedTargetTimeout(locator, input, cause);
703 }
704};
705​
706/**
707 * Playwright's timeout names neither the target nor why it waited. Counting only after the
708 * wait failed keeps auto-wait intact, but the count describes the page after the timeout, not
709 * what the click did: Playwright can dispatch the click and then time out waiting for the
710 * navigation it started.
711 */
712const namedTargetTimeout = async (
713 locator: Locator,
714 input: { readonly locator?: string | undefined; readonly selector?: string | undefined },
715 cause: unknown,
716) => {
717 if (!(cause instanceof Error && cause.name === "TimeoutError")) return cause;
718 const selector = JSON.stringify(input.locator ?? input.selector);
719 // The warning leads and the target is capped, so a reply cut short still carries the warning.
720 const target =
721 selector.length > MAX_TARGET_CHARS ? `${selector.slice(0, MAX_TARGET_CHARS)}...` : selector;
722 // The host's reply must still beat the broker's short grace after the operation timeout.
723 let timer: ReturnType<typeof setTimeout> | undefined;
724 const matches = await Promise.race([
725 locator.count().catch(() => undefined),
726 new Promise<undefined>((resolve) => {
727 timer = setTimeout(() => resolve(undefined), TARGET_COUNT_MS);
728 }),
729 ]).finally(() => clearTimeout(timer));
730 const reason =
731 matches === undefined
732 ? `The click may have happened; check the page before retrying. Whether ${target} still matches an element is unknown.`
733 : matches === 0
734 ? `The click may have happened and removed its target or navigated; check the page before retrying. No element matches ${target} after the timeout; take a fresh snapshot and use its locator.`
735 : `The click may have happened; check the page before retrying. ${target} still matches an element after the timeout.`;
736 return new ServerBrowserOperationError({
737 tag: "PreviewAutomationTimeoutError",
738 message: reason,
739 detail: { reason },
740 cause,
741 });
742};
743​
744const clickTarget = async (
745 page: Page,
746 locator: Locator | null,
747 input: PreviewAutomationClickInput,
748 timeout: number,
749 pointer: PointerReporter,
750) => {
751 const point = await targetPoint(page, locator, input, timeout);
752 await pointer(point, "click");
753 const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const;
754 const clicked =
755 locator === null
756 ? page.mouse.click(point.x, point.y, options)
757 : locator.click({ ...options, timeout });
758 let onDialog = constVoid;
759 const dialogOpened = new Promise<"dialog">((resolve) => {
760 onDialog = () => resolve("dialog");
761 page.once("dialog", onDialog);
762 });
763 try {
764 if ((await Promise.race([clicked, dialogOpened])) === "dialog") void clicked.catch(constVoid);
765 } finally {
766 page.off("dialog", onDialog);
767 }
768 return point;
769};
770​
771export const type = async (page: Page, input: PreviewAutomationTypeInput) => {
772 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
773 const locator = targetLocator(page, input);
774 if (locator !== null && input.clear) {
775 await locator.fill(input.text, { timeout });
776 return;
777 }
778 // A shadow host also matches :focus; use its innermost focused descendant.
779 const target = locator ?? page.locator("*:focus").last();
780 const focused =
781 (locator !== null || (await target.count()) > 0) &&
782 (await target.evaluate(
783 (element) => {
784 // Require an enabled, focused text target to avoid losing input or sending it to the previous field.
785 const control = element as unknown as {
786 readonly type?: string;
787 readonly disabled?: boolean;
788 readonly readOnly?: boolean;
789 readonly isContentEditable?: boolean;
790 readonly focus?: () => void;
791 };
792 const nonText = [
793 "button",
794 "checkbox",
795 "color",
796 "file",
797 "hidden",
798 "image",
799 "radio",
800 "range",
801 "reset",
802 "submit",
803 ];
804 const textControl =
805 element.tagName === "TEXTAREA" ||
806 (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text"));
807 if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) {
808 return false;
809 }
810 control.focus?.();
811 const root = element.getRootNode() as { readonly activeElement?: typeof element | null };
812 const active = root.activeElement;
813 return active != null && (active === element || element.contains(active));
814 },
815 undefined,
816 { timeout },
817 ));
818 if (focused !== true) {
819 throw new ServerBrowserOperationError({
820 tag: "PreviewAutomationTargetNotEditableError",
821 message: "The target is not an enabled text field, so no text was typed.",
822 });
823 }
824 if (input.clear) {
825 await page.keyboard.press("ControlOrMeta+A");
826 await page.keyboard.press("Delete");
827 }
828 await page.keyboard.insertText(input.text);
829};
830​
831export const hover = async (
832 page: Page,
833 input: PreviewAutomationHoverInput,
834 pointer: PointerReporter = noPointer,
835) => {
836 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
837 const locator = targetLocator(page, input);
838 await pointer(await targetPoint(page, locator, input, timeout), "move");
839 if (locator === null) {
840 await page.mouse.move(input.x ?? 0, input.y ?? 0);
841 return;
842 }
843 await locator.hover({ timeout });
844};
845​
846export const select = async (
847 page: Page,
848 input: PreviewAutomationSelectInput,
849): Promise<PreviewAutomationSelectResult> => {
850 const locator = targetLocator(page, input)!;
851 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
852 const options = await locator.evaluate(
853 (element) =>
854 element.tagName === "SELECT"
855 ? Array.from(
856 (element as unknown as { options: ArrayLike<{ value: string; label: string }> })
857 .options,
858 ({ value, label }) => ({ value, label: label.trim() }),
859 )
860 : null,
861 undefined,
862 { timeout },
863 );
864 const values =
865 options &&
866 input.values.map(
867 (entry) =>
868 (
869 options.find((option) => option.value === entry) ??
870 options.find((option) => option.label === entry.trim())
871 )?.value ?? entry,
872 );
873 if (values === null)
874 throw new ServerBrowserOperationError({
875 tag: "PreviewAutomationTargetNotEditableError",
876 message: "This element is not a <select>. Click a custom dropdown, then click its option.",
877 });
878 return { selected: await locator.selectOption(values, { timeout }) };
879};
880​
881export const drag = async (
882 page: Page,
883 input: PreviewAutomationDragInput,
884 pointer: PointerReporter = noPointer,
885) => {
886 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
887 const source = targetLocator(page, { locator: input.source })!;
888 const target = targetLocator(page, { locator: input.target })!;
889 await pointer(await targetPoint(page, source, {}, timeout), "move");
890 // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server.
891 const dragFailure = source.dragTo(target, { timeout }).then(
892 () => null,
893 (error: unknown) => ({ error }),
894 );
895 const end = await target.boundingBox({ timeout }).catch(() => null);
896 if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move");
897 const failure = await dragFailure;
898 if (failure) throw failure.error;
899};
900​
901export const setInputFiles = async (page: Page, input: PreviewAutomationUploadInput) => {
902 const locator = targetLocator(page, input);
903 if (locator === null) return false;
904 await locator.setInputFiles([...input.paths], { timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS });
905 return true;
906};
907​
908export const press = async (page: Page, input: PreviewAutomationPressInput) => {
909 await page.keyboard.press([...(input.modifiers ?? []), input.key].join("+"));
910};
911​
912export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => {
913 const delta = [input.deltaX ?? 0, input.deltaY ?? 0] as const;
914 const locator = targetLocator(page, input);
915 if (locator === null) {
916 // Page-side code is passed as source: the server compiles without DOM types.
917 await page.evaluate(`scrollBy(${delta[0]}, ${delta[1]})`);
918 return;
919 }
920 await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta);
921};
922​
923/** Terminate expired page scripts so a hung expression cannot block the tab's control queue. */
924export const evaluate = async (
925 cdp: CDPSession,
926 input: PreviewAutomationEvaluateInput,
927 timeoutMs: number,
928) => {
929 let timer: ReturnType<typeof setTimeout> | undefined;
930 let timedOut = false;
931 const expired = new Promise<never>((_, reject) => {
932 timer = setTimeout(() => {
933 timedOut = true;
934 // Stops a busy script; an awaited promise is abandoned and settles unread.
935 void cdp.send("Runtime.terminateExecution").catch(constVoid);
936 reject(
937 new ServerBrowserOperationError({
938 tag: "PreviewAutomationTimeoutError",
939 message: `Evaluation did not finish within ${timeoutMs}ms and was stopped.`,
940 }),
941 );
942 }, timeoutMs);
943 });
944 const params = {
945 expression: input.expression,
946 awaitPromise: input.awaitPromise ?? true,
947 returnByValue: true,
948 };
949 // The devtools console's replMode allows top-level await but returns a trailing promise unawaited,
950 // so it only retries what the page's parser rejected. A parse error has a scriptId but no stack and
951 // ran nothing; a browser-internal rejection has neither.
952 const evaluation = cdp.send("Runtime.evaluate", params).then((classic) => {
953 const failure = classic.exceptionDetails;
954 const unparsed =
955 failure?.exception?.className === "SyntaxError" &&
956 failure.scriptId !== undefined &&
957 failure.stackTrace === undefined;
958 return unparsed && !timedOut && input.expression.includes("await")
959 ? cdp.send("Runtime.evaluate", { ...params, replMode: true })
960 : classic;
961 });
962 void evaluation.catch(constVoid);
963 const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer));
964 if (result.exceptionDetails) {
965 throw new ServerBrowserOperationError({
966 tag: "PreviewAutomationExecutionError",
967 message: result.exceptionDetails.exception?.description ?? result.exceptionDetails.text,
968 });
969 }
970 const value =
971 "value" in result.result ? result.result.value : (result.result.description ?? null);
972 const actualBytes = Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
973 if (actualBytes > MAX_EVALUATION_BYTES) {
974 throw new ServerBrowserOperationError({
975 tag: "PreviewAutomationResultTooLargeError",
976 message: `Evaluation result is ${actualBytes} bytes; the limit is ${MAX_EVALUATION_BYTES}.`,
977 detail: { maximumBytes: MAX_EVALUATION_BYTES },
978 });
979 }
980 return value;
981};
982​
983export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => {
984 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
985 const locator = targetLocator(page, input);
986 const { text, urlIncludes } = input;
987 const checks: Array<() => Promise<boolean>> = [];
988 if (locator !== null) checks.push(async () => (await locator.count()) > 0);
989 if (text !== undefined) {
990 // A navigation can destroy the context mid-check; that counts as not yet.
991 checks.push(() =>
992 page
993 .evaluate(`(document.body?.innerText ?? "").includes(${JSON.stringify(text)})`)
994 .then((found) => found === true)
995 .catch(() => false),
996 );
997 }
998 if (urlIncludes !== undefined) checks.push(async () => page.url().includes(urlIncludes));
999 // `count()` and `evaluate()` take no timeout; a stalled check would hold the tab's control queue.
1000 let timer: ReturnType<typeof setTimeout> | undefined;
1001 const expired = new Promise<never>((_, reject) => {
1002 timer = setTimeout(
1003 () =>
1004 reject(
1005 new ServerBrowserOperationError({
1006 tag: "PreviewAutomationTimeoutError",
1007 message: `Waited ${timeoutMs}ms without every condition holding.`,
1008 }),
1009 ),
1010 timeoutMs,
1011 );
1012 });
1013 try {
1014 for (;;) {
1015 const results = await Promise.race([Promise.all(checks.map((check) => check())), expired]);
1016 if (results.every(Boolean)) return;
1017 await Promise.race([new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)), expired]);
1019 } finally {
1020 clearTimeout(timer);
1023​
1024const NET_ERROR_CODES: Readonly<Record<string, number>> = {
1025 ERR_FAILED: -2,
1026 ERR_TIMED_OUT: -7,
1027 ERR_CONNECTION_CLOSED: -100,
1028 ERR_CONNECTION_RESET: -101,
1029 ERR_CONNECTION_REFUSED: -102,
1030 ERR_NAME_NOT_RESOLVED: -105,
1031 ERR_INTERNET_DISCONNECTED: -106,
1032 ERR_ADDRESS_UNREACHABLE: -109,
1033 ERR_CERT_AUTHORITY_INVALID: -202,
1034 ERR_EMPTY_RESPONSE: -324,
1036​
1037export const parseNetError = (errorText: string) => {
1038 // Names can hold digits, as in ERR_HTTP2_PROTOCOL_ERROR.
1039 const description = /ERR_[A-Z\d_]+/.exec(errorText)?.[0] ?? errorText;
1040 return { description, code: NET_ERROR_CODES[description] ?? -2 };
1042​
1043export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => `(() => {
1044 const canvas = document.createElement("canvas");
1045 const context = canvas.getContext("2d");
1046 const chunks = [];
1047 let recorder = null;
1048 let started = null;
1049 let stopped = null;
1050 let sizeBytes = 0;
1051 let tooLarge = false;
1052 let frame = null;
1053 let cursor = null;
1054 let ring = null;
1055 const AGENT_CURSOR_PATH = new Path2D("M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z");
1056 let ringTimer = null;
1057 let scale = 1;
1058 let offsetX = 0;
1059 let offsetY = 0;
1060 const paint = () => {
1061 if (!frame) return;
1062 const fit = Math.min(canvas.width / frame.width, canvas.height / frame.height);
1063 const width = frame.width * fit;
1064 const height = frame.height * fit;
1065 offsetX = (canvas.width - width) / 2;
1066 offsetY = (canvas.height - height) / 2;
1067 if (width < canvas.width || height < canvas.height) {
1068 context.fillStyle = "#000";
1069 context.fillRect(0, 0, canvas.width, canvas.height);
1071 context.drawImage(frame, offsetX, offsetY, width, height);
1072 scale = (width / frame.width) * frame.cssScale;
1073 if (ring) {
1074 const age = (performance.now() - ring.at) / 400;
1075 if (age < 1) {
1076 context.beginPath();
1077 context.arc(offsetX + ring.x * scale, offsetY + ring.y * scale, (8 + 18 * age) * scale, 0, Math.PI * 2);
1078 context.strokeStyle = "rgba(59,130,246," + (0.9 * (1 - age)) + ")";
1079 context.lineWidth = 3 * scale;
1080 context.stroke();
1083 if (cursor) {
1084 // The agent cursor (lucide MousePointer2, as in the live panel), never the human arrow.
1085 const s = 20 / 24 * scale;
1086 context.save();
1087 context.translate(offsetX + cursor.x * scale - 2 * scale, offsetY + cursor.y * scale - 2 * scale);
1088 context.scale(s, s);
1089 context.lineWidth = 2;
1090 context.lineJoin = "round";
1091 context.lineCap = "round";
1092 context.fillStyle = "#fff";
1093 context.strokeStyle = "#2563eb";
1094 context.shadowColor = "rgba(0,0,0,0.25)";
1095 context.shadowBlur = 2;
1096 context.fill(AGENT_CURSOR_PATH);
1097 context.shadowColor = "transparent";
1098 context.stroke(AGENT_CURSOR_PATH);
1099 context.restore();
1101 };
1102 window.__t3Recorder = {
1103 async frame(base64, cssWidth) {
1104 if (tooLarge) return false;
1105 const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
1106 const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" }));
1107 if (tooLarge) { bitmap.close(); return false; }
1108 if (!recorder) {
1109 canvas.width = bitmap.width;
1110 canvas.height = bitmap.height;
1111 const mimeType = ["video/mp4;codecs=avc1.640033", "video/webm;codecs=vp9", "video/webm"].find((type) => MediaRecorder.isTypeSupported(type));
1112 const bitsPerSecond = Math.min(50e6, Math.max(2.5e6, bitmap.width * bitmap.height * ${frameRate} * 0.05));
1113 recorder = new MediaRecorder(canvas.captureStream(${frameRate}), { mimeType, videoBitsPerSecond: bitsPerSecond });
1114 started = new Promise((resolve, reject) => {
1115 recorder.onstart = resolve;
1116 recorder.onerror = (event) => reject(event.error);
1117 });
1118 stopped = new Promise((resolve) => { recorder.onstop = resolve; });
1119 recorder.ondataavailable = (event) => {
1120 if (tooLarge || event.data.size === 0) return;
1121 sizeBytes += event.data.size;
1122 if (sizeBytes > ${PROVIDER_SEND_TURN_MAX_FILE_BYTES}) {
1123 tooLarge = true;
1124 chunks.length = 0;
1125 clearInterval(ringTimer);
1126 frame?.close();
1127 frame = null;
1128 if (recorder.state !== "inactive") recorder.stop();
1129 for (const track of recorder.stream.getTracks()) track.stop();
1130 return;
1132 chunks.push(event.data);
1133 };
1134 recorder.start(1000);
1136 // Frame px per page CSS px, before fitting into the canvas.
1137 bitmap.cssScale = bitmap.width / cssWidth;
1138 frame?.close();
1139 frame = bitmap;
1140 paint();
1141 await started;
1142 return true;
1143 },
1144 cursor(x, y, click) {
1145 if (tooLarge) return;
1146 cursor = { x, y };
1147 if (click) {
1148 ring = { x, y, at: performance.now() };
1149 clearInterval(ringTimer);
1150 ringTimer = setInterval(() => {
1151 paint();
1152 if (performance.now() - ring.at > 400) { ring = null; clearInterval(ringTimer); paint(); }
1153 }, 33);
1155 paint();
1156 },
1157 async stop() {
1158 if (!recorder) return { mimeType: null, count: 0, bytes: 0 };
1159 if (recorder.state !== "inactive") {
1160 // Consume the cached frame, then wait for the final paint to reach the
1161 // capture stream. Animation ticks alone can stop before it is delivered.
1162 const video = document.createElement("video");
1163 video.muted = true;
1164 video.srcObject = recorder.stream;
1165 const nextFrame = () => Promise.race([
1166 new Promise(resolve => video.requestVideoFrameCallback(resolve)),
1167 stopped,
1168 ]);
1169 const initial = nextFrame();
1170 void video.play().catch(() => {});
1171 await initial;
1172 if (recorder.state !== "inactive") {
1173 const delivered = nextFrame();
1174 recorder.stream.getVideoTracks()[0].requestFrame();
1175 paint();
1176 await delivered;
1178 video.pause();
1179 video.srcObject = null;
1180 if (recorder.state !== "inactive") recorder.stop();
1182 await stopped;
1183 return { mimeType: recorder.mimeType, count: chunks.length, bytes: sizeBytes };
1184 },
1185 async chunk(index) {
1186 const bytes = new Uint8Array(await chunks[index].arrayBuffer());
1187 if (typeof bytes.toBase64 === "function") return bytes.toBase64();
1188 let binary = "";
1189 for (let offset = 0; offset < bytes.length; offset += 0x8000) {
1190 binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
1192 return btoa(binary);
1193 },
1194 download() {
1195 const link = document.createElement("a");
1196 link.href = URL.createObjectURL(new Blob(chunks, { type: recorder.mimeType }));
1197 link.download = "recording";
1198 link.click();
1199 },
1200 };
1201})()`;