| 1 | // @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. |
| 2 | |
| 3 | import { |
| 4 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 5 | type PreviewAutomationClickInput, |
| 6 | type PreviewAutomationConsoleEntry, |
| 7 | type PreviewAutomationDragInput, |
| 8 | type PreviewAutomationEvaluateInput, |
| 9 | type PreviewAutomationHoverInput, |
| 10 | type PreviewAutomationNetworkEntry, |
| 11 | type PreviewAutomationPressInput, |
| 12 | type PreviewAutomationScrollInput, |
| 13 | type PreviewAutomationSelectInput, |
| 14 | type PreviewAutomationSelectResult, |
| 15 | type PreviewAutomationSnapshot, |
| 16 | type PreviewAutomationTypeInput, |
| 17 | type PreviewAutomationUploadInput, |
| 18 | type PreviewAutomationWaitForInput, |
| 19 | } from "@t3tools/contracts"; |
| 20 | import { constVoid } from "effect/Function"; |
| 21 | import type { CDPSession, Locator, Page } from "playwright-core"; |
| 22 | import * as NodeCrypto from "node:crypto"; |
| 23 | import { BrowserControlInterrupted } from "./SessionControl.ts"; |
| 24 | |
| 25 | const MAX_EVALUATION_BYTES = 64_000; |
| 26 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 27 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 28 | const WAIT_POLL_MS = 100; |
| 29 | export const DIAGNOSTIC_BUFFER_LIMIT = 200; |
| 30 | |
| 31 | export class ServerBrowserOperationError extends Error { |
| 32 | readonly tag: string; |
| 33 | readonly detail: unknown; |
| 34 | |
| 35 | constructor(tag: string, message: string, detail?: unknown) { |
| 36 | super(message); |
| 37 | this.tag = tag; |
| 38 | this.detail = detail; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | export const toOperationError = (cause: unknown): ServerBrowserOperationError => { |
| 43 | if (cause instanceof BrowserControlInterrupted) |
| 44 | return new ServerBrowserOperationError( |
| 45 | "PreviewAutomationControlInterruptedError", |
| 46 | cause.message, |
| 47 | cause.reason, |
| 48 | ); |
| 49 | if (cause instanceof ServerBrowserOperationError) return cause; |
| 50 | const message = cause instanceof Error ? cause.message : String(cause); |
| 51 | const firstLine = message.split("\n")[0] ?? message; |
| 52 | if (cause instanceof Error && cause.name === "TimeoutError") { |
| 53 | return new ServerBrowserOperationError("PreviewAutomationTimeoutError", firstLine); |
| 54 | } |
| 55 | if ( |
| 56 | /while parsing selector|Unknown engine|Unexpected token|strict mode violation/i.test(message) |
| 57 | ) { |
| 58 | return new ServerBrowserOperationError("PreviewAutomationInvalidSelectorError", firstLine); |
| 59 | } |
| 60 | if (/not an <input>|not editable|not an editable/i.test(message)) { |
| 61 | return new ServerBrowserOperationError("PreviewAutomationTargetNotEditableError", firstLine); |
| 62 | } |
| 63 | return new ServerBrowserOperationError("PreviewAutomationExecutionError", firstLine); |
| 64 | }; |
| 65 | |
| 66 | const DEFAULT_TIMEOUT_MS = 15_000; |
| 67 | |
| 68 | const pageRefs = new WeakMap<Page, { generation: string; refs: Map<string, string> }>(); |
| 69 | // A compact runtime namespace prevents old refs from aliasing after a server restart. |
| 70 | const refNamespace = NodeCrypto.randomUUID().slice(0, 8); |
| 71 | let snapshotSequence = 0; |
| 72 | const nextRefGeneration = () => `${refNamespace}-${(++snapshotSequence).toString(36)}`; |
| 73 | |
| 74 | /** A takeover revokes previously issued refs even when the document stays unchanged. */ |
| 75 | export const invalidateRefs = (page: Page) => { |
| 76 | const state = pageRefs.get(page); |
| 77 | if (state) { |
| 78 | state.generation = nextRefGeneration(); |
| 79 | state.refs.clear(); |
| 80 | } |
| 81 | }; |
| 82 | |
| 83 | const refsFor = (page: Page) => { |
| 84 | let state = pageRefs.get(page); |
| 85 | if (!state) { |
| 86 | state = { generation: nextRefGeneration(), refs: new Map<string, string>() }; |
| 87 | pageRefs.set(page, state); |
| 88 | page.on("framenavigated", () => invalidateRefs(page)); |
| 89 | page.on("framedetached", () => invalidateRefs(page)); |
| 90 | } |
| 91 | return state; |
| 92 | }; |
| 93 | |
| 94 | const targetLocator = ( |
| 95 | page: Page, |
| 96 | input: { readonly locator?: string | undefined; readonly selector?: string | undefined }, |
| 97 | ): Locator | null => { |
| 98 | const selector = input.locator ?? input.selector; |
| 99 | if (selector === undefined) return null; |
| 100 | // Ignore quoted/escaped CSS values when looking for a selector-engine boundary. |
| 101 | const engines = selector.replace( |
| 102 | /\\.|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, |
| 103 | " ", |
| 104 | ); |
| 105 | if (/(?:^|>>)\s*\*?aria-ref\s*=/.test(engines)) { |
| 106 | const ref = /^\s*aria-ref\s*=\s*(\S+)\s*$/.exec(selector)?.[1]; |
| 107 | const nativeRef = ref === undefined ? undefined : pageRefs.get(page)?.refs.get(ref); |
| 108 | if (nativeRef === undefined) { |
| 109 | throw new ServerBrowserOperationError( |
| 110 | "PreviewAutomationInvalidSelectorError", |
| 111 | "This element ref is stale or belongs to another tab. Take a fresh snapshot and use its locator.", |
| 112 | { staleRef: true }, |
| 113 | ); |
| 114 | } |
| 115 | return page.locator(`aria-ref=${nativeRef}`); |
| 116 | } |
| 117 | // Playwright's strict locators reject ambiguous controls rather than acting on row one. |
| 118 | return page.locator(selector); |
| 119 | }; |
| 120 | |
| 121 | /** Shows the agent's pointer to viewers before an action lands at that point. */ |
| 122 | export type PointerReporter = ( |
| 123 | point: { readonly x: number; readonly y: number }, |
| 124 | phase: "move" | "click", |
| 125 | ) => Promise<void>; |
| 126 | const noPointer: PointerReporter = async () => {}; |
| 127 | |
| 128 | /** The viewport point an action will hit: the target's center after scrolling it into view. */ |
| 129 | const targetPoint = async ( |
| 130 | page: Page, |
| 131 | locator: Locator | null, |
| 132 | input: { readonly x?: number | undefined; readonly y?: number | undefined }, |
| 133 | timeout: number, |
| 134 | ) => { |
| 135 | if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 }; |
| 136 | await locator.scrollIntoViewIfNeeded({ timeout }); |
| 137 | const box = await locator.boundingBox({ timeout }); |
| 138 | if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; |
| 139 | const viewport = page.viewportSize(); |
| 140 | return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 }; |
| 141 | }; |
| 142 | |
| 143 | const SNAPSHOT_SCRIPT = `(() => { |
| 144 | return { |
| 145 | url: location.href, |
| 146 | title: document.title, |
| 147 | loading: document.readyState !== "complete", |
| 148 | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), |
| 149 | interactiveElements: [], |
| 150 | }; |
| 151 | })()`; |
| 152 | |
| 153 | // Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. |
| 154 | export const captureViewport = async ( |
| 155 | page: Page, |
| 156 | cdp: CDPSession, |
| 157 | options: { readonly format: "png" | "jpeg"; readonly quality?: number; readonly scale: number }, |
| 158 | ) => { |
| 159 | let clip; |
| 160 | if (options.scale < 1) { |
| 161 | const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; |
| 162 | const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); |
| 163 | clip = { |
| 164 | x: cssVisualViewport.pageX, |
| 165 | y: cssVisualViewport.pageY, |
| 166 | ...viewport, |
| 167 | scale: options.scale, |
| 168 | }; |
| 169 | } |
| 170 | const { data } = await cdp.send("Page.captureScreenshot", { |
| 171 | format: options.format, |
| 172 | ...(options.quality === undefined ? {} : { quality: options.quality }), |
| 173 | ...(clip ? { clip } : {}), |
| 174 | }); |
| 175 | return data; |
| 176 | }; |
| 177 | |
| 178 | export const snapshot = async (input: { |
| 179 | readonly page: Page; |
| 180 | readonly cdp: CDPSession; |
| 181 | readonly renderScale: number; |
| 182 | readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>; |
| 183 | readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>; |
| 184 | readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; |
| 185 | }): Promise<PreviewAutomationSnapshot> => { |
| 186 | const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; |
| 187 | const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * input.renderScale)); |
| 188 | const state = refsFor(input.page); |
| 189 | invalidateRefs(input.page); |
| 190 | const generation = state.generation; |
| 191 | const [page, tree, data] = await Promise.all([ |
| 192 | input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< |
| 193 | Pick< |
| 194 | PreviewAutomationSnapshot, |
| 195 | "url" | "title" | "loading" | "visibleText" | "interactiveElements" |
| 196 | > |
| 197 | >, |
| 198 | input.page.ariaSnapshot({ mode: "ai", boxes: true, timeout: DEFAULT_TIMEOUT_MS }), |
| 199 | captureViewport(input.page, input.cdp, { format: "png", scale }), |
| 200 | ]); |
| 201 | if (state.generation !== generation) { |
| 202 | throw new ServerBrowserOperationError( |
| 203 | "PreviewAutomationExecutionError", |
| 204 | "The page changed while capturing its snapshot. Take another snapshot.", |
| 205 | ); |
| 206 | } |
| 207 | const accessibilityTree = tree |
| 208 | .slice(0, MAX_VISIBLE_TEXT_LENGTH) |
| 209 | .replace(/\[ref=((?:f\d+)?e\d+)\]/g, (_match, nativeRef: string) => { |
| 210 | const ref = `t3-${generation}-${nativeRef}`; |
| 211 | state.refs.set(ref, nativeRef); |
| 212 | return `[ref=${ref}]`; |
| 213 | }); |
| 214 | return { |
| 215 | ...page, |
| 216 | accessibilityTree, |
| 217 | consoleEntries: [...input.consoleEntries], |
| 218 | networkEntries: [...input.networkEntries], |
| 219 | actionTimeline: [...input.actionTimeline], |
| 220 | screenshot: { |
| 221 | mimeType: "image/png", |
| 222 | data, |
| 223 | width: Math.round(viewport.width * input.renderScale * scale), |
| 224 | height: Math.round(viewport.height * input.renderScale * scale), |
| 225 | }, |
| 226 | }; |
| 227 | }; |
| 228 | |
| 229 | /** |
| 230 | * A click whose handler opens a dialog does not finish until the dialog is |
| 231 | * resolved, so it returns as soon as the dialog opens; status then reports it. |
| 232 | */ |
| 233 | export const click = async ( |
| 234 | page: Page, |
| 235 | input: PreviewAutomationClickInput, |
| 236 | pointer: PointerReporter = noPointer, |
| 237 | ): Promise<{ readonly x: number; readonly y: number }> => { |
| 238 | const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 239 | const locator = targetLocator(page, input); |
| 240 | const point = await targetPoint(page, locator, input, timeout); |
| 241 | await pointer(point, "click"); |
| 242 | const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const; |
| 243 | const clicked = |
| 244 | locator === null |
| 245 | ? page.mouse.click(point.x, point.y, options) |
| 246 | : locator.click({ ...options, timeout }); |
| 247 | let onDialog = constVoid; |
| 248 | const dialogOpened = new Promise<"dialog">((resolve) => { |
| 249 | onDialog = () => resolve("dialog"); |
| 250 | page.once("dialog", onDialog); |
| 251 | }); |
| 252 | try { |
| 253 | if ((await Promise.race([clicked, dialogOpened])) === "dialog") void clicked.catch(constVoid); |
| 254 | } finally { |
| 255 | page.off("dialog", onDialog); |
| 256 | } |
| 257 | return point; |
| 258 | }; |
| 259 | |
| 260 | export const type = async (page: Page, input: PreviewAutomationTypeInput) => { |
| 261 | const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 262 | const locator = targetLocator(page, input); |
| 263 | if (locator !== null && input.clear) { |
| 264 | await locator.fill(input.text, { timeout }); |
| 265 | return; |
| 266 | } |
| 267 | // A shadow host also matches :focus; use its innermost focused descendant. |
| 268 | const target = locator ?? page.locator("*:focus").last(); |
| 269 | const focused = |
| 270 | (locator !== null || (await target.count()) > 0) && |
| 271 | (await target.evaluate( |
| 272 | (element) => { |
| 273 | // Like the desktop host: an enabled text control or contenteditable |
| 274 | // that actually takes focus. Anything else would swallow the text or |
| 275 | // send it to whichever field had focus before. |
| 276 | const control = element as unknown as { |
| 277 | readonly type?: string; |
| 278 | readonly disabled?: boolean; |
| 279 | readonly readOnly?: boolean; |
| 280 | readonly isContentEditable?: boolean; |
| 281 | readonly focus?: () => void; |
| 282 | }; |
| 283 | const nonText = [ |
| 284 | "button", |
| 285 | "checkbox", |
| 286 | "color", |
| 287 | "file", |
| 288 | "hidden", |
| 289 | "image", |
| 290 | "radio", |
| 291 | "range", |
| 292 | "reset", |
| 293 | "submit", |
| 294 | ]; |
| 295 | const textControl = |
| 296 | element.tagName === "TEXTAREA" || |
| 297 | (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text")); |
| 298 | if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) { |
| 299 | return false; |
| 300 | } |
| 301 | control.focus?.(); |
| 302 | const root = element.getRootNode() as { readonly activeElement?: typeof element | null }; |
| 303 | const active = root.activeElement; |
| 304 | return active != null && (active === element || element.contains(active)); |
| 305 | }, |
| 306 | undefined, |
| 307 | { timeout }, |
| 308 | )); |
| 309 | if (focused !== true) { |
| 310 | throw new ServerBrowserOperationError( |
| 311 | "PreviewAutomationTargetNotEditableError", |
| 312 | "The target is not an enabled text field, so no text was typed.", |
| 313 | ); |
| 314 | } |
| 315 | if (input.clear) { |
| 316 | await page.keyboard.press("ControlOrMeta+A"); |
| 317 | await page.keyboard.press("Delete"); |
| 318 | } |
| 319 | await page.keyboard.insertText(input.text); |
| 320 | }; |
| 321 | |
| 322 | /** Hovering by locator moves the real mouse, so CSS :hover and pointer events both apply. */ |
| 323 | export const hover = async ( |
| 324 | page: Page, |
| 325 | input: PreviewAutomationHoverInput, |
| 326 | pointer: PointerReporter = noPointer, |
| 327 | ) => { |
| 328 | const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 329 | const locator = targetLocator(page, input); |
| 330 | await pointer(await targetPoint(page, locator, input, timeout), "move"); |
| 331 | if (locator === null) { |
| 332 | await page.mouse.move(input.x ?? 0, input.y ?? 0); |
| 333 | return; |
| 334 | } |
| 335 | await locator.hover({ timeout }); |
| 336 | }; |
| 337 | |
| 338 | export const select = async ( |
| 339 | page: Page, |
| 340 | input: PreviewAutomationSelectInput, |
| 341 | ): Promise<PreviewAutomationSelectResult> => { |
| 342 | const locator = targetLocator(page, input)!; |
| 343 | const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 344 | // Each entry matches an option by value first, then by its visible label. |
| 345 | const options = await locator.evaluate( |
| 346 | (element) => |
| 347 | element.tagName === "SELECT" |
| 348 | ? Array.from( |
| 349 | (element as unknown as { options: ArrayLike<{ value: string; label: string }> }) |
| 350 | .options, |
| 351 | ({ value, label }) => ({ value, label: label.trim() }), |
| 352 | ) |
| 353 | : null, |
| 354 | undefined, |
| 355 | { timeout }, |
| 356 | ); |
| 357 | const values = |
| 358 | options && |
| 359 | input.values.map( |
| 360 | (entry) => |
| 361 | ( |
| 362 | options.find((option) => option.value === entry) ?? |
| 363 | options.find((option) => option.label === entry.trim()) |
| 364 | )?.value ?? entry, |
| 365 | ); |
| 366 | if (values === null) |
| 367 | throw new ServerBrowserOperationError( |
| 368 | "PreviewAutomationTargetNotEditableError", |
| 369 | "This element is not a <select>. Click a custom dropdown, then click its option.", |
| 370 | ); |
| 371 | return { selected: await locator.selectOption(values, { timeout }) }; |
| 372 | }; |
| 373 | |
| 374 | export const drag = async ( |
| 375 | page: Page, |
| 376 | input: PreviewAutomationDragInput, |
| 377 | pointer: PointerReporter = noPointer, |
| 378 | ) => { |
| 379 | const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 380 | const source = targetLocator(page, { locator: input.source })!; |
| 381 | const target = targetLocator(page, { locator: input.target })!; |
| 382 | await pointer(await targetPoint(page, source, {}, timeout), "move"); |
| 383 | // The cursor travels with the drag; the page sees one continuous gesture. Observe the drag's |
| 384 | // outcome as it starts: a rejection nobody handles while the cursor moves exits the server. |
| 385 | const dragFailure = source.dragTo(target, { timeout }).then( |
| 386 | () => null, |
| 387 | (error: unknown) => ({ error }), |
| 388 | ); |
| 389 | const end = await target.boundingBox({ timeout }).catch(() => null); |
| 390 | if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move"); |
| 391 | const failure = await dragFailure; |
| 392 | if (failure) throw failure.error; |
| 393 | }; |
| 394 | |
| 395 | /** Sets files on one file input; false when no locator or selector names one. */ |
| 396 | export const setInputFiles = async (page: Page, input: PreviewAutomationUploadInput) => { |
| 397 | const locator = targetLocator(page, input); |
| 398 | if (locator === null) return false; |
| 399 | await locator.setInputFiles([...input.paths], { timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS }); |
| 400 | return true; |
| 401 | }; |
| 402 | |
| 403 | export const press = async (page: Page, input: PreviewAutomationPressInput) => { |
| 404 | await page.keyboard.press([...(input.modifiers ?? []), input.key].join("+")); |
| 405 | }; |
| 406 | |
| 407 | export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => { |
| 408 | const delta = [input.deltaX ?? 0, input.deltaY ?? 0] as const; |
| 409 | const locator = targetLocator(page, input); |
| 410 | if (locator === null) { |
| 411 | // Page-side code is passed as source: the server compiles without DOM types. |
| 412 | await page.evaluate(`scrollBy(${delta[0]}, ${delta[1]})`); |
| 413 | return; |
| 414 | } |
| 415 | await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); |
| 416 | }; |
| 417 | |
| 418 | /** |
| 419 | * Runs an agent's expression, bounded by its request's deadline. Past it, the |
| 420 | * page's script is terminated and the call fails, so the tab's control queue |
| 421 | * frees for the next request instead of waiting on the script. |
| 422 | */ |
| 423 | export const evaluate = async ( |
| 424 | cdp: CDPSession, |
| 425 | input: PreviewAutomationEvaluateInput, |
| 426 | timeoutMs: number, |
| 427 | ) => { |
| 428 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 429 | const expired = new Promise<never>((_, reject) => { |
| 430 | timer = setTimeout(() => { |
| 431 | // Stops a busy script; an awaited promise is abandoned and settles unread. |
| 432 | void cdp.send("Runtime.terminateExecution").catch(constVoid); |
| 433 | reject( |
| 434 | new ServerBrowserOperationError( |
| 435 | "PreviewAutomationTimeoutError", |
| 436 | `Evaluation did not finish within ${timeoutMs}ms and was stopped.`, |
| 437 | ), |
| 438 | ); |
| 439 | }, timeoutMs); |
| 440 | }); |
| 441 | const evaluation = cdp.send("Runtime.evaluate", { |
| 442 | expression: input.expression, |
| 443 | awaitPromise: input.awaitPromise ?? true, |
| 444 | returnByValue: input.returnByValue ?? true, |
| 445 | }); |
| 446 | void evaluation.catch(constVoid); |
| 447 | const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); |
| 448 | if (result.exceptionDetails) { |
| 449 | throw new ServerBrowserOperationError( |
| 450 | "PreviewAutomationExecutionError", |
| 451 | result.exceptionDetails.exception?.description ?? result.exceptionDetails.text, |
| 452 | ); |
| 453 | } |
| 454 | const value = |
| 455 | "value" in result.result ? result.result.value : (result.result.description ?? null); |
| 456 | const actualBytes = Buffer.byteLength(JSON.stringify(value ?? null), "utf8"); |
| 457 | if (actualBytes > MAX_EVALUATION_BYTES) { |
| 458 | throw new ServerBrowserOperationError( |
| 459 | "PreviewAutomationResultTooLargeError", |
| 460 | `Evaluation result is ${actualBytes} bytes; the limit is ${MAX_EVALUATION_BYTES}.`, |
| 461 | { maximumBytes: MAX_EVALUATION_BYTES }, |
| 462 | ); |
| 463 | } |
| 464 | return value; |
| 465 | }; |
| 466 | |
| 467 | export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => { |
| 468 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 469 | const deadline = Date.now() + timeoutMs; |
| 470 | const locator = targetLocator(page, input); |
| 471 | const { text, urlIncludes } = input; |
| 472 | const checks: Array<() => Promise<boolean>> = []; |
| 473 | // Like the desktop host: the selector must match an element, visible or not. |
| 474 | if (locator !== null) checks.push(async () => (await locator.count()) > 0); |
| 475 | if (text !== undefined) { |
| 476 | // A navigation can destroy the context mid-check; that counts as not yet. |
| 477 | checks.push(() => |
| 478 | page |
| 479 | .evaluate(`(document.body?.innerText ?? "").includes(${JSON.stringify(text)})`) |
| 480 | .then((found) => found === true) |
| 481 | .catch(() => false), |
| 482 | ); |
| 483 | } |
| 484 | if (urlIncludes !== undefined) checks.push(async () => page.url().includes(urlIncludes)); |
| 485 | for (;;) { |
| 486 | const results = await Promise.all(checks.map((check) => check())); |
| 487 | if (results.every(Boolean)) return; |
| 488 | if (Date.now() >= deadline) { |
| 489 | throw new ServerBrowserOperationError( |
| 490 | "PreviewAutomationTimeoutError", |
| 491 | `Waited ${timeoutMs}ms without every condition holding.`, |
| 492 | ); |
| 493 | } |
| 494 | await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)); |
| 495 | } |
| 496 | }; |
| 497 | |
| 498 | const NET_ERROR_CODES: Readonly<Record<string, number>> = { |
| 499 | ERR_FAILED: -2, |
| 500 | ERR_TIMED_OUT: -7, |
| 501 | ERR_CONNECTION_CLOSED: -100, |
| 502 | ERR_CONNECTION_RESET: -101, |
| 503 | ERR_CONNECTION_REFUSED: -102, |
| 504 | ERR_NAME_NOT_RESOLVED: -105, |
| 505 | ERR_INTERNET_DISCONNECTED: -106, |
| 506 | ERR_ADDRESS_UNREACHABLE: -109, |
| 507 | ERR_CERT_AUTHORITY_INVALID: -202, |
| 508 | ERR_EMPTY_RESPONSE: -324, |
| 509 | }; |
| 510 | |
| 511 | export const parseNetError = (errorText: string) => { |
| 512 | const description = /ERR_[A-Z_]+/.exec(errorText)?.[0] ?? errorText; |
| 513 | return { description, code: NET_ERROR_CODES[description] ?? -2 }; |
| 514 | }; |
| 515 | |
| 516 | // Browser-side encoding avoids ffmpeg. Resizes are letterboxed into the first frame; |
| 517 | // the agent cursor is drawn here so it never changes the page under test. |
| 518 | export const RECORDING_ENCODER_SCRIPT = `(() => { |
| 519 | const canvas = document.createElement("canvas"); |
| 520 | const context = canvas.getContext("2d"); |
| 521 | const chunks = []; |
| 522 | let recorder = null; |
| 523 | let started = null; |
| 524 | let stopped = null; |
| 525 | let sizeBytes = 0; |
| 526 | let tooLarge = false; |
| 527 | let frame = null; |
| 528 | let cursor = null; |
| 529 | let ring = null; |
| 530 | 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"); |
| 531 | let ringTimer = null; |
| 532 | let scale = 1; |
| 533 | let offsetX = 0; |
| 534 | let offsetY = 0; |
| 535 | const paint = () => { |
| 536 | if (!frame) return; |
| 537 | const fit = Math.min(canvas.width / frame.width, canvas.height / frame.height); |
| 538 | const width = frame.width * fit; |
| 539 | const height = frame.height * fit; |
| 540 | offsetX = (canvas.width - width) / 2; |
| 541 | offsetY = (canvas.height - height) / 2; |
| 542 | if (width < canvas.width || height < canvas.height) { |
| 543 | context.fillStyle = "#000"; |
| 544 | context.fillRect(0, 0, canvas.width, canvas.height); |
| 545 | } |
| 546 | context.drawImage(frame, offsetX, offsetY, width, height); |
| 547 | scale = (width / frame.width) * frame.cssScale; |
| 548 | if (ring) { |
| 549 | const age = (performance.now() - ring.at) / 400; |
| 550 | if (age < 1) { |
| 551 | context.beginPath(); |
| 552 | context.arc(offsetX + ring.x * scale, offsetY + ring.y * scale, (8 + 18 * age) * scale, 0, Math.PI * 2); |
| 553 | context.strokeStyle = "rgba(59,130,246," + (0.9 * (1 - age)) + ")"; |
| 554 | context.lineWidth = 3 * scale; |
| 555 | context.stroke(); |
| 556 | } |
| 557 | } |
| 558 | if (cursor) { |
| 559 | // The agent cursor (lucide MousePointer2, as in the live panel), never the human arrow. |
| 560 | const s = 20 / 24 * scale; |
| 561 | context.save(); |
| 562 | context.translate(offsetX + cursor.x * scale - 2 * scale, offsetY + cursor.y * scale - 2 * scale); |
| 563 | context.scale(s, s); |
| 564 | context.lineWidth = 2; |
| 565 | context.lineJoin = "round"; |
| 566 | context.lineCap = "round"; |
| 567 | context.fillStyle = "#fff"; |
| 568 | context.strokeStyle = "#2563eb"; |
| 569 | context.shadowColor = "rgba(0,0,0,0.25)"; |
| 570 | context.shadowBlur = 2; |
| 571 | context.fill(AGENT_CURSOR_PATH); |
| 572 | context.shadowColor = "transparent"; |
| 573 | context.stroke(AGENT_CURSOR_PATH); |
| 574 | context.restore(); |
| 575 | } |
| 576 | }; |
| 577 | window.__t3Recorder = { |
| 578 | async frame(base64, cssWidth) { |
| 579 | if (tooLarge) return false; |
| 580 | const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); |
| 581 | const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); |
| 582 | if (tooLarge) { bitmap.close(); return false; } |
| 583 | if (!recorder) { |
| 584 | canvas.width = bitmap.width; |
| 585 | canvas.height = bitmap.height; |
| 586 | const mimeType = ["video/mp4;codecs=avc1.640033", "video/webm;codecs=vp9", "video/webm"].find((type) => MediaRecorder.isTypeSupported(type)); |
| 587 | const bitsPerSecond = Math.min(50e6, Math.max(2.5e6, bitmap.width * bitmap.height * 30 * 0.05)); |
| 588 | recorder = new MediaRecorder(canvas.captureStream(30), { mimeType, videoBitsPerSecond: bitsPerSecond }); |
| 589 | started = new Promise((resolve, reject) => { |
| 590 | recorder.onstart = resolve; |
| 591 | recorder.onerror = (event) => reject(event.error); |
| 592 | }); |
| 593 | stopped = new Promise((resolve) => { recorder.onstop = resolve; }); |
| 594 | recorder.ondataavailable = (event) => { |
| 595 | if (tooLarge || event.data.size === 0) return; |
| 596 | sizeBytes += event.data.size; |
| 597 | if (sizeBytes > ${PROVIDER_SEND_TURN_MAX_FILE_BYTES}) { |
| 598 | tooLarge = true; |
| 599 | chunks.length = 0; |
| 600 | clearInterval(ringTimer); |
| 601 | frame?.close(); |
| 602 | frame = null; |
| 603 | if (recorder.state !== "inactive") recorder.stop(); |
| 604 | for (const track of recorder.stream.getTracks()) track.stop(); |
| 605 | return; |
| 606 | } |
| 607 | chunks.push(event.data); |
| 608 | }; |
| 609 | recorder.start(1000); |
| 610 | } |
| 611 | // Frame px per page CSS px, before fitting into the canvas. |
| 612 | bitmap.cssScale = bitmap.width / cssWidth; |
| 613 | frame?.close(); |
| 614 | frame = bitmap; |
| 615 | paint(); |
| 616 | await started; |
| 617 | return true; |
| 618 | }, |
| 619 | cursor(x, y, click) { |
| 620 | if (tooLarge) return; |
| 621 | cursor = { x, y }; |
| 622 | if (click) { |
| 623 | ring = { x, y, at: performance.now() }; |
| 624 | clearInterval(ringTimer); |
| 625 | ringTimer = setInterval(() => { |
| 626 | paint(); |
| 627 | if (performance.now() - ring.at > 400) { ring = null; clearInterval(ringTimer); paint(); } |
| 628 | }, 33); |
| 629 | } |
| 630 | paint(); |
| 631 | }, |
| 632 | async stop() { |
| 633 | if (!recorder) return { mimeType: null, count: 0, bytes: 0 }; |
| 634 | if (recorder.state !== "inactive") { |
| 635 | // Consume the cached frame, then wait for the final paint to reach the |
| 636 | // capture stream. Animation ticks alone can stop before it is delivered. |
| 637 | const video = document.createElement("video"); |
| 638 | video.muted = true; |
| 639 | video.srcObject = recorder.stream; |
| 640 | const nextFrame = () => Promise.race([ |
| 641 | new Promise(resolve => video.requestVideoFrameCallback(resolve)), |
| 642 | stopped, |
| 643 | ]); |
| 644 | const initial = nextFrame(); |
| 645 | void video.play().catch(() => {}); |
| 646 | await initial; |
| 647 | if (recorder.state !== "inactive") { |
| 648 | const delivered = nextFrame(); |
| 649 | recorder.stream.getVideoTracks()[0].requestFrame(); |
| 650 | paint(); |
| 651 | await delivered; |
| 652 | } |
| 653 | video.pause(); |
| 654 | video.srcObject = null; |
| 655 | if (recorder.state !== "inactive") recorder.stop(); |
| 656 | } |
| 657 | await stopped; |
| 658 | return { mimeType: recorder.mimeType, count: chunks.length, bytes: sizeBytes }; |
| 659 | }, |
| 660 | async chunk(index) { |
| 661 | const bytes = new Uint8Array(await chunks[index].arrayBuffer()); |
| 662 | let binary = ""; |
| 663 | for (let offset = 0; offset < bytes.length; offset += 0x8000) { |
| 664 | binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); |
| 665 | } |
| 666 | return btoa(binary); |
| 667 | }, |
| 668 | }; |
| 669 | })()`; |