authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:55:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:55:10-07:00
log90bd5ee0c13782e56ccbeded52b0cc6c50deafad
tree330da2f385a2bd215ebc7f74df50cca78a2c6c34
parent430ece4c0aa49ede37bb3bd29f451fde97b1b64a
signature Signed by PGP key B5690EEEBB952194

[codex] structure desktop preview failures (#3244)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 1189 insertions(+), 415 deletions(-)

apps/desktop/src/preview/Manager.test.ts+203-8
......@@ -7,9 +7,10 @@ import * as Fiber from "effect/Fiber";
77import * as Layer from "effect/Layer";
88import * as Option from "effect/Option";
99import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
1011import type * as Scope from "effect/Scope";
1112import { TestClock } from "effect/testing";
12import { beforeEach, describe, expect, vi } from "vite-plus/test";
13import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1314
1415import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1516import * as BrowserSession from "./BrowserSession.ts";
......@@ -17,7 +18,7 @@ import * as PreviewManager from "./Manager.ts";
1718
1819const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } =
1920 vi.hoisted(() => ({
20 createFromPath: vi.fn(() => ({ isEmpty: () => false })),
21 createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
2122 fromId: vi.fn(() => null),
2223 mkdir: vi.fn((_path: string) => undefined),
2324 showItemInFolder: vi.fn(),
......@@ -79,6 +80,7 @@ const layer = PreviewManager.layer.pipe(
7980 Layer.provideMerge(fileSystemLayer),
8081 Layer.provideMerge(Path.layer),
8182);
83const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
8284
8385const withManager = <A>(
8486 use: (
......@@ -237,6 +239,20 @@ describe("PreviewManager", () => {
237239 expect(artifact.path).toMatch(
238240 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
239241 );
242
243 const captureCause = new Error("capture failed");
244 capturePage.mockRejectedValueOnce(captureCause);
245 const exit = yield* Effect.exit(manager.captureScreenshot("tab_1"));
246 expect(Exit.isFailure(exit)).toBe(true);
247 if (Exit.isSuccess(exit)) return;
248 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
249 expect(error).toMatchObject({
250 _tag: "PreviewOperationError",
251 operation: "captureScreenshot.capturePage",
252 tabId: "tab_1",
253 webContentsId: 42,
254 cause: captureCause,
255 });
240256 }),
241257 ),
242258 );
......@@ -302,9 +318,12 @@ describe("PreviewManager", () => {
302318 expect(Exit.isFailure(exit)).toBe(true);
303319 if (Exit.isSuccess(exit)) return;
304320 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
305 expect(error.cause).toMatchObject({
306 message: "Preview artifact path is outside the configured artifact directory.",
321 expect(error).toMatchObject({
322 _tag: "PreviewArtifactPathOutsideDirectoryError",
323 artifactPath: "/tmp/t3/dev/settings.json",
324 artifactDirectory: "/tmp/t3/dev/browser-artifacts",
307325 });
326 expect("cause" in error).toBe(false);
308327 }),
309328 ),
310329 );
......@@ -324,8 +343,20 @@ describe("PreviewManager", () => {
324343 expect(Exit.isFailure(exit)).toBe(true);
325344 if (Exit.isSuccess(exit)) return;
326345 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
327 expect(error.cause).toMatchObject({
328 message: "Preview artifact path is outside the configured artifact directory.",
346 expect(error).toMatchObject({
347 _tag: "PreviewArtifactPathOutsideDirectoryError",
348 artifactPath: "/tmp/t3/dev/settings.json",
349 artifactDirectory: "/tmp/t3/dev/browser-artifacts",
350 });
351 expect("cause" in error).toBe(false);
352
353 createFromPath.mockReturnValueOnce({ isEmpty: () => true });
354 const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath));
355 expect(Exit.isFailure(invalidImageExit)).toBe(true);
356 if (Exit.isSuccess(invalidImageExit)) return;
357 expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({
358 _tag: "PreviewArtifactImageLoadError",
359 artifactPath,
329360 });
330361 }),
331362 ),
......@@ -466,10 +497,174 @@ describe("PreviewManager", () => {
466497 expect(Exit.isFailure(exit)).toBe(true);
467498 if (Exit.isSuccess(exit)) return;
468499 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
469 expect(error.cause).toMatchObject({
470 name: "PreviewAutomationControlInterruptedError",
500 expect(error).toMatchObject({
501 _tag: "PreviewAutomationControlInterruptedError",
502 operation: "click",
503 tabId: "tab_1",
504 webContentsId: 42,
471505 });
506 expect(error).toBeInstanceOf(Error);
507 if (error instanceof Error) {
508 expect(error.name).toBe("PreviewAutomationControlInterruptedError");
509 }
510 expect("cause" in error).toBe(false);
472511 }),
473512 ),
474513 );
514
515 effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () =>
516 withManager((manager) =>
517 Effect.gen(function* () {
518 const text = "ReferenceError: fallbackDetail is not defined";
519 const exceptionDetails = {
520 text,
521 exception: { description: "" },
522 };
523 const sendCommand = vi.fn(async (method: string) =>
524 method === "Runtime.evaluate" ? { exceptionDetails } : undefined,
525 );
526 fromId.mockReturnValue({
527 id: 42,
528 isDestroyed: () => false,
529 getType: () => "webview",
530 getURL: () => "https://example.com",
531 getTitle: () => "Example",
532 isLoading: () => false,
533 isDevToolsOpened: () => false,
534 getZoomFactor: () => 1,
535 setZoomFactor: vi.fn(),
536 on: vi.fn(),
537 off: vi.fn(),
538 ipc: { on: vi.fn(), off: vi.fn() },
539 send: webviewSend,
540 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
541 setWindowOpenHandler: vi.fn(),
542 debugger: {
543 isAttached: () => false,
544 attach: vi.fn(),
545 sendCommand,
546 on: vi.fn(),
547 off: vi.fn(),
548 },
549 } as never);
550
551 yield* manager.createTab("tab_1");
552 yield* manager.registerWebview("tab_1", 42);
553 const exit = yield* Effect.exit(
554 manager.automationEvaluate("tab_1", { expression: "fallbackDetail" }),
555 );
556
557 expect(Exit.isFailure(exit)).toBe(true);
558 if (Exit.isSuccess(exit)) return;
559 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
560 expect(error).toMatchObject({
561 _tag: "PreviewAutomationEvaluationError",
562 detailKind: "exception-text",
563 detailLength: text.length,
564 cause: exceptionDetails,
565 });
566 }),
567 ),
568 );
569});
570
571describe("PreviewOperationError", () => {
572 it("keeps timeline detail separate from its structured message", () => {
573 const cause = new Error("CDP command failed with an invalid node id");
574 const error = new PreviewManager.PreviewOperationError({
575 operation: "click.DOM.resolveNode",
576 tabId: "tab_1",
577 webContentsId: 42,
578 cause,
579 });
580
581 expect(error.message).not.toContain(cause.message);
582 expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message);
583 });
584});
585
586describe("Preview automation diagnostics", () => {
587 it("keeps browser exception detail out of structural diagnostics", () => {
588 const secret = "unrelated-browser-payload-secret";
589 const detail = "ReferenceError: missingValue is not defined";
590 const cause = {
591 text: "Uncaught Error",
592 exception: { description: detail },
593 unsafePayload: secret,
594 };
595 const error = new PreviewManager.PreviewAutomationEvaluationError({
596 tabId: "tab_1",
597 detailKind: "exception-description",
598 detailLength: detail.length,
599 cause,
600 });
601
602 const encoded = encodePreviewManagerError(error);
603 const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & {
604 readonly cause?: unknown;
605 };
606
607 expect(error.cause).toBe(cause);
608 expect(encodedCause).toStrictEqual(cause);
609 expect(error.message).toBe("Preview JavaScript evaluation failed in tab tab_1");
610 expect(error.message).not.toContain(secret);
611 expect(JSON.stringify(encodedDiagnostics)).not.toContain(secret);
612 expect("detail" in error).toBe(false);
613 expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).toBe(detail);
614 expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).not.toContain(
615 secret,
616 );
617 });
618
619 it("retains bounded selector diagnostics without exposing selector or reason text", () => {
620 const selector = "role=button[name='selector-secret']";
621 const reason = "Unexpected token near reason-secret";
622 const cause = { invalidSelector: true as const, message: reason };
623 const error = new PreviewManager.PreviewAutomationInvalidSelectorError({
624 operation: "click",
625 tabId: "tab_1",
626 selectorKind: "locator",
627 selectorLength: selector.length,
628 reasonLength: reason.length,
629 cause,
630 });
631
632 const encoded = encodePreviewManagerError(error);
633 const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & {
634 readonly cause?: unknown;
635 };
636
637 expect(error.cause).toBe(cause);
638 expect(encodedCause).toStrictEqual(cause);
639 expect(error).toMatchObject({
640 selectorKind: "locator",
641 selectorLength: selector.length,
642 reasonLength: reason.length,
643 });
644 expect(error.detail).toEqual({
645 selectorKind: "locator",
646 selectorLength: selector.length,
647 });
648 expect(error.message).not.toContain("secret");
649 expect(JSON.stringify(encodedDiagnostics)).not.toContain("secret");
650 expect("selector" in error).toBe(false);
651 expect("reason" in error).toBe(false);
652 expect(PreviewManager.PreviewAutomationInvalidSelectorError.toTimelineMessage(error)).toBe(
653 reason,
654 );
655 });
656
657 it("does not retain a missing target locator", () => {
658 const selector = "[data-token='target-secret']";
659 const error = new PreviewManager.PreviewAutomationTargetNotFoundError({
660 operation: "scroll",
661 tabId: "tab_1",
662 selectorKind: "selector",
663 selectorLength: selector.length,
664 });
665
666 expect(error.message).not.toContain(selector);
667 expect(JSON.stringify(error)).not.toContain(selector);
668 expect("locator" in error).toBe(false);
669 });
475670});
apps/desktop/src/preview/Manager.ts+762-357
......@@ -148,22 +148,58 @@ interface CdpEvaluationResult {
148148 };
149149}
150150
151const automationError = (
152 tag:
153 | "PreviewAutomationExecutionError"
154 | "PreviewAutomationInvalidSelectorError"
155 | "PreviewAutomationResultTooLargeError"
156 | "PreviewAutomationTimeoutError"
157 | "PreviewAutomationControlInterruptedError",
158 message: string,
159 detail?: unknown,
160): Error & { detail?: unknown } => {
161 const error = new Error(message) as Error & { detail?: unknown };
162 error.name = tag;
163 if (detail !== undefined) error.detail = detail;
164 return error;
151export const PreviewAutomationSelectorKind = Schema.Literals([
152 "focused-element",
153 "selector",
154 "locator",
155]);
156export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type;
157
158export const PreviewAutomationEvaluationDetailKind = Schema.Literals([
159 "exception-description",
160 "exception-text",
161 "unknown",
162]);
163export type PreviewAutomationEvaluationDetailKind =
164 typeof PreviewAutomationEvaluationDetailKind.Type;
165
166const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => {
167 if (typeof exceptionDetails !== "object" || exceptionDetails === null) {
168 return { detailKind: "unknown" as const };
169 }
170 const details = exceptionDetails as Record<string, unknown>;
171 const exception = details["exception"];
172 const description =
173 typeof exception === "object" &&
174 exception !== null &&
175 typeof (exception as Record<string, unknown>)["description"] === "string"
176 ? (exception as Record<string, unknown>)["description"]
177 : undefined;
178 if (typeof description === "string" && description.length > 0) {
179 return { detailKind: "exception-description" as const, detail: description };
180 }
181 const text = details["text"];
182 if (typeof text === "string" && text.length > 0) {
183 return { detailKind: "exception-text" as const, detail: text };
184 }
185 return { detailKind: "unknown" as const };
165186};
166187
188const previewAutomationTargetLabel = (
189 selectorKind: PreviewAutomationSelectorKind,
190 selectorLength?: number,
191) =>
192 selectorKind === "focused-element"
193 ? "the focused element"
194 : `${selectorKind} (${selectorLength ?? 0} characters)`;
195
196interface PreviewOperationContext {
197 readonly operation: string;
198 readonly tabId?: string;
199 readonly webContentsId?: number;
200 readonly artifactPath?: string;
201}
202
167203const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
168204 if (typeof value !== "object" || value === null) return null;
169205 const rect = value as Record<string, unknown>;
......@@ -194,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
194230};
195231
196232const captureAnnotationScreenshot = (
233 tabId: string,
197234 wc: Electron.WebContents,
198235 cropRect: PreviewAnnotationRect | null,
199236): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>
......@@ -209,7 +246,13 @@ const captureAnnotationScreenshot = (
209246 }
210247 : undefined,
211248 ),
212 catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }),
249 catch: (cause) =>
250 new PreviewOperationError({
251 operation: "captureAnnotationScreenshot",
252 tabId,
253 webContentsId: wc.id,
254 cause,
255 }),
213256 }).pipe(
214257 Effect.map((image) => {
215258 const size = image.getSize();
......@@ -341,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
341384 const runFork = Effect.runForkWith(context);
342385 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
343386 const playwrightInstallExpression = yield* Effect.cached(
344 playwrightInjectedRuntimeInstallExpression().pipe(
345 Effect.mapError(
346 (cause) =>
347 new PreviewManagerError({
348 operation: "ensurePlaywrightInjected",
349 cause,
350 }),
351 ),
352 ),
387 playwrightInjectedRuntimeInstallExpression(),
353388 );
354389
355390 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
......@@ -377,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
377412 const pointerSequenceRef = yield* Ref.make(0);
378413 const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none());
379414
380 const fail = (operation: string, cause: unknown): PreviewManagerError =>
381 new PreviewManagerError({ operation, cause });
382 const attempt = <A>(operation: string, evaluate: () => A) =>
383 Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) });
384 const attemptPromise = <A>(operation: string, evaluate: () => PromiseLike<A>) =>
385 Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) });
415 const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>
416 Effect.try({
417 try: evaluate,
418 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
419 });
420 const attemptPromise = <A>(
421 errorContext: PreviewOperationContext,
422 evaluate: () => PromiseLike<A>,
423 ) =>
424 Effect.tryPromise({
425 try: evaluate,
426 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
427 });
386428 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
387429 const currentMillis = Clock.currentTimeMillis;
388 const encodeJson = (operation: string, value: unknown) =>
389 encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause)));
430 const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>
431 encodeUnknownJson(value).pipe(
432 Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })),
433 );
390434 const nextCounter = (ref: Ref.Ref<number>) =>
391435 Ref.modify(ref, (value) => [value, value + 1] as const);
392436 const replaceMap = <K, V>(
......@@ -432,23 +476,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
432476 const tabs = yield* SynchronizedRef.get(tabsRef);
433477 const tab = tabs.get(tabId);
434478 if (!tab) {
435 return yield* fail("requireWebContents", new PreviewTabNotFoundError({ tabId }));
479 return yield* new PreviewTabNotFoundError({ tabId });
436480 }
437481 if (tab.webContentsId == null) {
438 return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError({ tabId }));
482 return yield* new PreviewWebviewNotInitializedError({ tabId });
439483 }
440484 const wc = webContents.fromId(tab.webContentsId);
441485 if (!wc) {
442 return yield* fail(
443 "requireWebContents",
444 new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId }),
445 );
486 return yield* new PreviewWebContentsNotFoundError({
487 tabId,
488 webContentsId: tab.webContentsId,
489 });
446490 }
447491 return wc;
448492 });
449493
450494 const resolveArtifactPath = (artifactPath: string) =>
451 attempt("resolveArtifactPath", () => {
495 attempt({ operation: "resolveArtifactPath", artifactPath }, () => {
452496 const resolvedPath = path.resolve(artifactPath);
453497 const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath);
454498 if (
......@@ -464,10 +508,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
464508 Effect.flatMap((resolvedPath) =>
465509 resolvedPath === null
466510 ? Effect.fail(
467 fail(
468 "resolveArtifactPath",
469 new Error("Preview artifact path is outside the configured artifact directory."),
470 ),
511 new PreviewArtifactPathOutsideDirectoryError({
512 artifactPath,
513 artifactDirectory: resolvedArtifactDirectory,
514 }),
471515 )
472516 : Effect.succeed(resolvedPath),
473517 ),
......@@ -636,129 +680,137 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
636680 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
637681 wc: Electron.WebContents,
638682 ) {
639 return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => {
640 const existing = sessions.get(wc.id);
641 if (existing) return Effect.succeed([existing, sessions] as const);
642 if (wc.isDevToolsOpened()) {
643 return Effect.fail(
644 fail(
645 "ensureControlSession",
646 automationError(
647 "PreviewAutomationExecutionError",
648 "Close preview DevTools before using agent browser control.",
649 ),
650 ),
651 );
652 }
653 if (wc.debugger.isAttached()) {
654 return Effect.fail(
655 fail(
656 "ensureControlSession",
657 automationError(
658 "PreviewAutomationExecutionError",
659 "Preview control cannot attach because another debugger owns this page.",
660 ),
661 ),
662 );
663 }
664 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
665 const semaphore = yield* Semaphore.make(1);
666 const scope = yield* Scope.fork(parentScope, "sequential");
667 const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* (
668 method: string,
669 params: Record<string, unknown>,
670 ) {
671 if (method === "Page.screencastFrame") {
672 const sessionId = params["sessionId"];
673 if (typeof sessionId === "number") {
674 yield* attemptPromise("ackScreencastFrame", () =>
675 wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
676 ).pipe(Effect.ignore);
677 }
678 const tabId = yield* tabIdForWebContents(wc.id);
679 const metadata =
680 typeof params["metadata"] === "object" && params["metadata"] !== null
681 ? (params["metadata"] as Record<string, unknown>)
682 : {};
683 if (tabId && typeof params["data"] === "string") {
684 const receivedAt = yield* currentIso;
685 const listeners = yield* Ref.get(recordingFrameListenersRef);
686 const frame: DesktopPreviewRecordingFrame = {
687 tabId,
688 data: params["data"],
689 width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
690 height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
691 receivedAt,
692 };
693 yield* Effect.forEach(
694 listeners,
695 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),
696 { discard: true },
697 );
698 }
699 }
700 yield* captureDiagnosticMessage(wc.id, method, params);
701 });
702 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
703 runFork(handleDebuggerMessage(method, params));
704 };
705 yield* Scope.addFinalizer(
706 scope,
707 Effect.all(
708 [
709 Ref.update(diagnosticsRef, (diagnostics) =>
710 replaceMap(diagnostics, (copy) => {
711 copy.delete(wc.id);
712 }),
713 ),
714 attempt("detachControlSession", () => {
715 wc.debugger.off("message", onMessage);
716 if (wc.debugger.isAttached()) wc.debugger.detach();
717 }).pipe(Effect.ignore),
718 ],
719 { discard: true },
720 ),
721 );
722 const control: BrowserControlSession = {
723 webContentsId: wc.id,
724 semaphore,
725 scope,
726 onMessage,
727 };
728 const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {
729 yield* Ref.update(diagnosticsRef, (diagnostics) =>
730 replaceMap(diagnostics, (copy) => {
731 copy.set(wc.id, {
732 consoleEntries: [],
733 networkEntries: [],
734 requests: new Map(),
735 });
683 return yield* SynchronizedRef.modifyEffect(
684 controlSessionsRef,
685 (
686 sessions,
687 ): Effect.Effect<
688 readonly [BrowserControlSession, ReadonlyMap<number, BrowserControlSession>],
689 PreviewManagerError
690 > => {
691 const existing = sessions.get(wc.id);
692 if (existing) return Effect.succeed([existing, sessions] as const);
693 if (wc.isDevToolsOpened()) {
694 return Effect.fail(
695 new PreviewAutomationDevToolsOpenError({
696 webContentsId: wc.id,
736697 }),
737698 );
738 yield* attempt("attachDebuggerListeners", () => {
739 wc.debugger.on("message", onMessage);
740 wc.debugger.attach("1.3");
741 });
742 yield* Effect.all(
743 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(
744 (method) =>
745 attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)),
699 }
700 if (wc.debugger.isAttached()) {
701 return Effect.fail(
702 new PreviewAutomationDebuggerAttachedError({
703 webContentsId: wc.id,
704 }),
705 );
706 }
707 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
708 const semaphore = yield* Semaphore.make(1);
709 const scope = yield* Scope.fork(parentScope, "sequential");
710 const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(
711 function* (method: string, params: Record<string, unknown>) {
712 if (method === "Page.screencastFrame") {
713 const sessionId = params["sessionId"];
714 if (typeof sessionId === "number") {
715 yield* attemptPromise(
716 {
717 operation: "ackScreencastFrame",
718 webContentsId: wc.id,
719 },
720 () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
721 ).pipe(Effect.ignore);
722 }
723 const tabId = yield* tabIdForWebContents(wc.id);
724 const metadata =
725 typeof params["metadata"] === "object" && params["metadata"] !== null
726 ? (params["metadata"] as Record<string, unknown>)
727 : {};
728 if (tabId && typeof params["data"] === "string") {
729 const receivedAt = yield* currentIso;
730 const listeners = yield* Ref.get(recordingFrameListenersRef);
731 const frame: DesktopPreviewRecordingFrame = {
732 tabId,
733 data: params["data"],
734 width:
735 typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
736 height:
737 typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
738 receivedAt,
739 };
740 yield* Effect.forEach(
741 listeners,
742 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),
743 { discard: true },
744 );
745 }
746 }
747 yield* captureDiagnosticMessage(wc.id, method, params);
748 },
749 );
750 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
751 runFork(handleDebuggerMessage(method, params));
752 };
753 yield* Scope.addFinalizer(
754 scope,
755 Effect.all(
756 [
757 Ref.update(diagnosticsRef, (diagnostics) =>
758 replaceMap(diagnostics, (copy) => {
759 copy.delete(wc.id);
760 }),
761 ),
762 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
763 wc.debugger.off("message", onMessage);
764 if (wc.debugger.isAttached()) wc.debugger.detach();
765 }).pipe(Effect.ignore),
766 ],
767 { discard: true },
746768 ),
747 { concurrency: "unbounded", discard: true },
748769 );
749 return [
750 control,
751 replaceMap(sessions, (copy) => {
752 copy.set(wc.id, control);
753 }),
754 ] as const;
770 const control: BrowserControlSession = {
771 webContentsId: wc.id,
772 semaphore,
773 scope,
774 onMessage,
775 };
776 const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {
777 yield* Ref.update(diagnosticsRef, (diagnostics) =>
778 replaceMap(diagnostics, (copy) => {
779 copy.set(wc.id, {
780 consoleEntries: [],
781 networkEntries: [],
782 requests: new Map(),
783 });
784 }),
785 );
786 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
787 wc.debugger.on("message", onMessage);
788 wc.debugger.attach("1.3");
789 });
790 yield* Effect.all(
791 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(
792 (method) =>
793 attemptPromise(
794 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },
795 () => wc.debugger.sendCommand(method),
796 ),
797 ),
798 { concurrency: "unbounded", discard: true },
799 );
800 return [
801 control,
802 replaceMap(sessions, (copy) => {
803 copy.set(wc.id, control);
804 }),
805 ] as const;
806 });
807 return yield* initialize().pipe(
808 Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),
809 );
755810 });
756 return yield* initialize().pipe(
757 Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),
758 );
759 });
760 return createControlSession();
761 });
811 return createControlSession();
812 },
813 );
762814 });
763815
764816 const pushAction = (tabId: string, event: PreviewAutomationActionEvent) =>
......@@ -808,26 +860,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
808860 function* (method, commandParams) {
809861 const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
810862 if (before !== epoch) {
811 return yield* fail(
812 action,
813 automationError(
814 "PreviewAutomationControlInterruptedError",
815 "Browser control was interrupted by human input.",
816 ),
817 );
863 return yield* new PreviewAutomationControlInterruptedError({
864 operation: action,
865 tabId,
866 webContentsId: wc.id,
867 });
818868 }
819 const result = yield* attemptPromise(action, () =>
820 wc.debugger.sendCommand(method, commandParams),
869 const result = yield* attemptPromise(
870 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },
871 () => wc.debugger.sendCommand(method, commandParams),
821872 );
822873 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
823874 if (after !== epoch) {
824 return yield* fail(
825 action,
826 automationError(
827 "PreviewAutomationControlInterruptedError",
828 "Browser control was interrupted by human input.",
829 ),
830 );
875 return yield* new PreviewAutomationControlInterruptedError({
876 operation: action,
877 tabId,
878 webContentsId: wc.id,
879 });
831880 }
832881 return result;
833882 },
......@@ -846,15 +895,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
846895 });
847896 } else {
848897 const error = Option.getOrNull(Cause.findErrorOption(exit.cause));
849 const underlying = isPreviewManagerError(error) ? error.cause : error;
850 const interrupted =
851 underlying instanceof Error &&
852 underlying.name === "PreviewAutomationControlInterruptedError";
898 const interrupted = isPreviewAutomationControlInterruptedError(error);
899 const errorMessage = isPreviewOperationError(error)
900 ? PreviewOperationError.toTimelineMessage(error)
901 : isPreviewAutomationEvaluationError(error)
902 ? PreviewAutomationEvaluationError.toTimelineMessage(error)
903 : isPreviewAutomationInvalidSelectorError(error)
904 ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error)
905 : error instanceof Error
906 ? error.message
907 : String(error);
853908 yield* replaceAction(tabId, {
854909 ...actionEvent,
855910 status: interrupted ? "interrupted" : "failed",
856911 completedAt,
857 error: underlying instanceof Error ? underlying.message : String(underlying),
912 error: errorMessage,
858913 });
859914 }
860915 const tabs = yield* SynchronizedRef.get(tabsRef);
......@@ -864,6 +919,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
864919 });
865920
866921 const evaluateWithDebugger = <A = unknown>(
922 tabId: string,
867923 send: SendCommand,
868924 expression: string,
869925 returnByValue: boolean,
......@@ -877,19 +933,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
877933 }).pipe(
878934 Effect.flatMap((rawResponse) => {
879935 const response = rawResponse as CdpEvaluationResult;
880 return response.exceptionDetails
881 ? Effect.fail(
882 fail(
883 "evaluate",
884 automationError(
885 "PreviewAutomationExecutionError",
886 response.exceptionDetails.exception?.description ??
887 response.exceptionDetails.text ??
888 "JavaScript evaluation failed.",
889 ),
890 ),
891 )
892 : Effect.succeed(response.result?.value as A);
936 if (!response.exceptionDetails) {
937 return Effect.succeed(response.result?.value as A);
938 }
939 const detail = previewAutomationEvaluationDetail(response.exceptionDetails);
940 return Effect.fail(
941 new PreviewAutomationEvaluationError({
942 tabId,
943 detailKind: detail.detailKind,
944 detailLength: detail.detail?.length ?? 0,
945 cause: response.exceptionDetails,
946 }),
947 );
893948 }),
894949 );
895950
......@@ -898,17 +953,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
898953 readonly locator?: string | undefined;
899954 }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null);
900955
956 const automationSelectorDiagnostics = (input: {
957 readonly selector?: string | undefined;
958 readonly locator?: string | undefined;
959 }): {
960 readonly selectorKind: PreviewAutomationSelectorKind;
961 readonly selectorLength?: number;
962 } => {
963 if (input.locator !== undefined) {
964 return { selectorKind: "locator", selectorLength: input.locator.length };
965 }
966 if (input.selector !== undefined) {
967 return { selectorKind: "selector", selectorLength: input.selector.length };
968 }
969 return { selectorKind: "focused-element" };
970 };
971
901972 const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* (
973 tabId: string,
902974 send: SendCommand,
903975 ) {
904976 const installed = yield* evaluateWithDebugger<boolean>(
977 tabId,
905978 send,
906979 "Boolean(globalThis.__t3PlaywrightInjected)",
907980 true,
908981 );
909982 if (installed) return;
910 const expression = yield* playwrightInstallExpression;
911 yield* evaluateWithDebugger(send, expression, true);
983 const expression = yield* playwrightInstallExpression.pipe(
984 Effect.mapError(
985 (cause) =>
986 new PreviewOperationError({
987 operation: "ensurePlaywrightInjected",
988 tabId,
989 cause,
990 }),
991 ),
992 );
993 yield* evaluateWithDebugger(tabId, send, expression, true);
912994 });
913995
914996 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
......@@ -1060,7 +1142,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
10601142 };
10611143 yield* Scope.addFinalizer(
10621144 scope,
1063 attempt("detachListeners", () => {
1145 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
10641146 wc.off("did-navigate", sync);
10651147 wc.off("did-navigate-in-page", sync);
10661148 wc.off("page-title-updated", sync);
......@@ -1072,7 +1154,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
10721154 }).pipe(Effect.ignore),
10731155 );
10741156 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
1075 yield* attempt("attachListeners", () => {
1157 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
10761158 wc.on("did-navigate", sync);
10771159 wc.on("did-navigate-in-page", sync);
10781160 wc.on("page-title-updated", sync);
......@@ -1081,7 +1163,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
10811163 wc.on("did-fail-load", failed as never);
10821164 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
10831165 wc.setWindowOpenHandler(({ url }) => {
1084 runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore));
1166 runFork(
1167 attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
1168 wc.loadURL(url),
1169 ).pipe(Effect.ignore),
1170 );
10851171 return { action: "deny" };
10861172 });
10871173 wc.on("before-input-event", beforeInput);
......@@ -1162,7 +1248,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11621248 ) {
11631249 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
11641250 if (!tab) {
1165 return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId }));
1251 return yield* new PreviewTabNotFoundError({ tabId });
11661252 }
11671253 const wc = webContents.fromId(webContentsId);
11681254 const mainWindow = yield* Ref.get(mainWindowRef);
......@@ -1171,15 +1257,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11711257 wc.getType() !== "webview" ||
11721258 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
11731259 ) {
1174 return yield* fail(
1175 "registerWebview",
1176 new PreviewWebContentsNotFoundError({ tabId, webContentsId }),
1177 );
1260 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId });
11781261 }
11791262 const attached = yield* Ref.get(attachedRef);
11801263 const annotationTheme = yield* Ref.get(annotationThemeRef);
11811264 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
1182 yield* attempt("registerWebview.sendTheme", () =>
1265 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
11831266 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
11841267 );
11851268 return;
......@@ -1225,16 +1308,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12251308 ] as const;
12261309 });
12271310 if (Option.isNone(registration)) {
1228 return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId }));
1311 return yield* new PreviewTabNotFoundError({ tabId });
12291312 }
12301313 const { state: registered, pendingUrl } = registration.value;
12311314 yield* emit(tabId, registered);
12321315 if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
1233 yield* attempt("registerWebview.restoreZoom", () =>
1316 yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () =>
12341317 wc.setZoomFactor(registered.zoomFactor),
12351318 ).pipe(Effect.ignore);
12361319 }
1237 yield* attempt("registerWebview.sendTheme", () =>
1320 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
12381321 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
12391322 );
12401323 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;
......@@ -1245,15 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12451328 wc.getURL() !== pendingUrl
12461329 ) {
12471330 runFork(
1248 attemptPromise("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe(
1249 Effect.ignore,
1250 ),
1331 attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () =>
1332 wc.loadURL(pendingUrl),
1333 ).pipe(Effect.ignore),
12511334 );
12521335 }
12531336 });
12541337
12551338 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
1256 const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl));
1339 const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () =>
1340 normalizePreviewUrl(rawUrl),
1341 );
12571342 const updatedAt = yield* currentIso;
12581343 const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
12591344 const current = tabs.get(tabId);
......@@ -1294,10 +1379,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12941379 return;
12951380 }
12961381 if (wc.getURL() === url) {
1297 yield* attempt("navigate.reload", () => wc.reload());
1382 yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () =>
1383 wc.reload(),
1384 );
12981385 return;
12991386 }
1300 yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url));
1387 yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () =>
1388 wc.loadURL(url),
1389 );
13011390 });
13021391
13031392 const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* (
......@@ -1306,7 +1395,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13061395 use: (wc: Electron.WebContents) => void,
13071396 ) {
13081397 const wc = yield* requireWebContents(tabId);
1309 yield* attempt(operation, () => use(wc));
1398 yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc));
13101399 });
13111400
13121401 const goBack = (tabId: string) =>
......@@ -1324,11 +1413,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13241413 const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) {
13251414 const wc = yield* requireWebContents(tabId);
13261415 if (wc.isDevToolsOpened()) {
1327 yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus());
1416 yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () =>
1417 wc.devToolsWebContents?.focus(),
1418 );
13281419 return;
13291420 }
13301421 yield* detachControlSession(wc.id);
1331 yield* attempt("openDevTools", () => {
1422 yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => {
13321423 wc.once("devtools-closed", () => {
13331424 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));
13341425 });
......@@ -1348,9 +1439,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13481439 const wc = webContents.fromId(tab.webContentsId);
13491440 return !wc || wc.isDestroyed()
13501441 ? Effect.void
1351 : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe(
1352 Effect.ignore,
1353 );
1442 : attempt(
1443 {
1444 operation: "setAnnotationTheme",
1445 tabId: tab.tabId,
1446 webContentsId: tab.webContentsId,
1447 },
1448 () => wc.send(ANNOTATION_THEME_CHANNEL, theme),
1449 ).pipe(Effect.ignore);
13541450 },
13551451 { discard: true },
13561452 );
......@@ -1363,7 +1459,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13631459 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
13641460 (resume) => {
13651461 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
1366 yield* attempt("pickElement.cleanup", () => {
1462 yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => {
13671463 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
13681464 wc.off("destroyed", onDestroyed);
13691465 wc.off("did-start-navigation", onNavigated);
......@@ -1392,9 +1488,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13921488 if (activeTab?.webContentsId != null) {
13931489 const activeWc = webContents.fromId(activeTab.webContentsId);
13941490 if (activeWc && !activeWc.isDestroyed()) {
1395 yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe(
1396 Effect.ignore,
1397 );
1491 yield* attempt(
1492 {
1493 operation: "cancelPickElement",
1494 tabId,
1495 webContentsId: activeWc.id,
1496 },
1497 () => activeWc.send(CANCEL_PICK_CHANNEL),
1498 ).pipe(Effect.ignore);
13981499 }
13991500 }
14001501 resume(Effect.succeed(null));
......@@ -1408,15 +1509,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14081509 }
14091510 const cropRect = normalizeCaptureRect(args[1]);
14101511 runFork(
1411 captureAnnotationScreenshot(wc, cropRect).pipe(
1512 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(
14121513 Effect.matchEffect({
14131514 onFailure: () => Effect.sync(() => settle(payload)),
14141515 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),
14151516 }),
14161517 Effect.ensuring(
1417 attempt("pickElement.captureComplete", () => {
1418 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);
1419 }).pipe(Effect.ignore),
1518 attempt(
1519 { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id },
1520 () => {
1521 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);
1522 },
1523 ).pipe(Effect.ignore),
14201524 ),
14211525 ),
14221526 );
......@@ -1431,7 +1535,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14311535 if (isMainFrame) settle(null);
14321536 };
14331537 const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () {
1434 yield* attempt("pickElement.register", () => {
1538 yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => {
14351539 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
14361540 wc.once("destroyed", onDestroyed);
14371541 wc.once("did-start-navigation", onNavigated);
......@@ -1468,7 +1572,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14681572 if (tab.webContentsId != null) {
14691573 const wc = webContents.fromId(tab.webContentsId);
14701574 if (wc && !wc.isDestroyed()) {
1471 yield* attempt("applyZoom", () => wc.setZoomFactor(next));
1575 yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () =>
1576 wc.setZoomFactor(next),
1577 );
14721578 }
14731579 }
14741580 yield* update(tabId, { zoomFactor: next });
......@@ -1481,17 +1587,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14811587 const [createdAt, millis, image] = yield* Effect.all([
14821588 currentIso,
14831589 currentMillis,
1484 attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()),
1590 attemptPromise(
1591 {
1592 operation: "captureScreenshot.capturePage",
1593 tabId,
1594 webContentsId: wc.id,
1595 },
1596 () => wc.capturePage(),
1597 ),
14851598 ]);
14861599 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;
14871600 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`);
14881601 const data = image.toPNG();
1489 yield* fileSystem
1490 .makeDirectory(resolvedArtifactDirectory, { recursive: true })
1491 .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause)));
1492 yield* fileSystem
1493 .writeFile(artifactPath, data)
1494 .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause)));
1602 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
1603 Effect.mapError(
1604 (cause) =>
1605 new PreviewOperationError({
1606 operation: "captureScreenshot.makeDirectory",
1607 tabId,
1608 webContentsId: wc.id,
1609 artifactPath,
1610 cause,
1611 }),
1612 ),
1613 );
1614 yield* fileSystem.writeFile(artifactPath, data).pipe(
1615 Effect.mapError(
1616 (cause) =>
1617 new PreviewOperationError({
1618 operation: "captureScreenshot.writeFile",
1619 tabId,
1620 webContentsId: wc.id,
1621 artifactPath,
1622 cause,
1623 }),
1624 ),
1625 );
14951626 return {
14961627 id,
14971628 tabId,
......@@ -1518,10 +1649,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15181649 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
15191650 const recordingTabId = yield* Ref.get(recordingTabIdRef);
15201651 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
1521 return yield* fail(
1522 "startRecording",
1523 new Error("Only one browser recording can be active per window."),
1524 );
1652 return yield* new PreviewRecordingAlreadyActiveError({
1653 requestedTabId: tabId,
1654 activeTabId: recordingTabId.value,
1655 });
15251656 }
15261657 const wc = yield* requireWebContents(tabId);
15271658 yield* withControlSession(tabId, wc, "recording.start", startScreencast);
......@@ -1547,12 +1678,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15471678 const id = `browser-recording-${millis.toString(36)}`;
15481679 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
15491680 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
1550 yield* fileSystem
1551 .makeDirectory(resolvedArtifactDirectory, { recursive: true })
1552 .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause)));
1553 yield* fileSystem
1554 .writeFile(artifactPath, data)
1555 .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause)));
1681 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
1682 Effect.mapError(
1683 (cause) =>
1684 new PreviewOperationError({
1685 operation: "saveRecording.makeDirectory",
1686 tabId,
1687 artifactPath,
1688 cause,
1689 }),
1690 ),
1691 );
1692 yield* fileSystem.writeFile(artifactPath, data).pipe(
1693 Effect.mapError(
1694 (cause) =>
1695 new PreviewOperationError({
1696 operation: "saveRecording.writeFile",
1697 tabId,
1698 artifactPath,
1699 cause,
1700 }),
1701 ),
1702 );
15561703 return {
15571704 id,
15581705 tabId,
......@@ -1609,6 +1756,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16091756 visibleText: string;
16101757 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
16111758 }>(
1759 tabId,
16121760 send,
16131761 `(() => {
16141762 const selectorFor = (element) => {
......@@ -1665,7 +1813,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16651813 );
16661814 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
16671815 send("Accessibility.getFullAXTree"),
1668 attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()),
1816 attemptPromise(
1817 {
1818 operation: "automationSnapshot.capturePage",
1819 tabId,
1820 webContentsId: wc.id,
1821 },
1822 () => wc.capturePage(),
1823 ),
16691824 Ref.get(diagnosticsRef),
16701825 Ref.get(actionTimelineRef),
16711826 ]);
......@@ -1702,6 +1857,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17021857 });
17031858
17041859 const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* (
1860 tabId: string,
17051861 send: SendCommand,
17061862 input: PreviewAutomationClickInput,
17071863 ) {
......@@ -1709,11 +1865,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17091865 return { x: input.x!, y: input.y! };
17101866 }
17111867 const locator = automationLocator(input)!;
1712 yield* ensurePlaywrightInjected(send);
1713 const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator);
1868 yield* ensurePlaywrightInjected(tabId, send);
1869 const locatorJson = yield* encodeJson(
1870 { operation: "automationClick.encodeLocator", tabId },
1871 locator,
1872 );
17141873 const point = yield* evaluateWithDebugger<
17151874 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
17161875 >(
1876 tabId,
17171877 send,
17181878 `(() => {
17191879 try {
......@@ -1734,21 +1894,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17341894 true,
17351895 );
17361896 if ("invalidSelector" in point) {
1737 return yield* fail(
1738 "automationClick",
1739 automationError("PreviewAutomationInvalidSelectorError", point.message, {
1740 selector: locator,
1741 }),
1742 );
1897 return yield* new PreviewAutomationInvalidSelectorError({
1898 operation: "click",
1899 tabId,
1900 ...automationSelectorDiagnostics(input),
1901 reasonLength: point.message.length,
1902 cause: point,
1903 });
17431904 }
17441905 if ("notFound" in point) {
1745 return yield* fail(
1746 "automationClick",
1747 automationError(
1748 "PreviewAutomationExecutionError",
1749 `No element matches locator ${locator}.`,
1750 ),
1751 );
1906 return yield* new PreviewAutomationTargetNotFoundError({
1907 operation: "click",
1908 tabId,
1909 ...automationSelectorDiagnostics(input),
1910 });
17521911 }
17531912 return point;
17541913 });
......@@ -1773,20 +1932,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17731932 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
17741933 { concurrency: 2, discard: true },
17751934 );
1776 const point = yield* resolveClickPoint(send, input);
1935 const point = yield* resolveClickPoint(tabId, send, input);
17771936 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
1937 tabId,
17781938 send,
17791939 "({ width: window.innerWidth, height: window.innerHeight })",
17801940 true,
17811941 );
17821942 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
1783 return yield* fail(
1784 "automationClick",
1785 automationError(
1786 "PreviewAutomationExecutionError",
1787 `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`,
1788 ),
1789 );
1943 return yield* new PreviewAutomationCoordinatesOutsideViewportError({
1944 tabId,
1945 x: point.x,
1946 y: point.y,
1947 viewportWidth: viewport.width,
1948 viewportHeight: viewport.height,
1949 });
17901950 }
17911951 const moveSequence = yield* nextCounter(pointerSequenceRef);
17921952 const moveCreatedAt = yield* currentIso;
......@@ -1834,15 +1994,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18341994 });
18351995
18361996 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (
1997 tabId: string,
18371998 send: SendCommand,
18381999 input: PreviewAutomationTypeInput,
18392000 ) {
18402001 const locator = automationLocator(input);
1841 if (locator) yield* ensurePlaywrightInjected(send);
1842 const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null;
2002 if (locator) yield* ensurePlaywrightInjected(tabId, send);
2003 const locatorJson = locator
2004 ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator)
2005 : null;
18432006 const result = yield* evaluateWithDebugger<
18442007 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
18452008 >(
2009 tabId,
18462010 send,
18472011 `(() => {
18482012 try {
......@@ -1862,23 +2026,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18622026 true,
18632027 );
18642028 if ("invalidSelector" in result) {
1865 return yield* fail(
1866 "automationType",
1867 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1868 selector: input.selector ?? "",
1869 }),
1870 );
2029 return yield* new PreviewAutomationInvalidSelectorError({
2030 operation: "type",
2031 tabId,
2032 ...automationSelectorDiagnostics(input),
2033 reasonLength: result.message.length,
2034 cause: result,
2035 });
18712036 }
18722037 if ("notFound" in result) {
1873 return yield* fail(
1874 "automationType",
1875 automationError(
1876 "PreviewAutomationExecutionError",
1877 locator
1878 ? `No element matches locator ${locator}.`
1879 : "No element is focused in the preview.",
1880 ),
1881 );
2038 return yield* new PreviewAutomationTargetNotFoundError({
2039 operation: "type",
2040 tabId,
2041 ...automationSelectorDiagnostics(input),
2042 });
18822043 }
18832044 });
18842045
......@@ -1888,10 +2049,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18882049 send: SendCommand,
18892050 ) {
18902051 yield* send("Runtime.enable");
1891 yield* focusAutomationTarget(send, input);
2052 yield* focusAutomationTarget(tabId, send, input);
18922053 yield* send("Input.insertText", { text: input.text });
1893 const textJson = yield* encodeJson("automationType.encodeText", input.text);
2054 const textJson = yield* encodeJson(
2055 { operation: "automationType.encodeText", tabId },
2056 input.text,
2057 );
18942058 yield* evaluateWithDebugger(
2059 tabId,
18952060 send,
18962061 `(() => {
18972062 const element = document.activeElement;
......@@ -1959,13 +2124,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19592124 ) {
19602125 yield* send("Runtime.enable");
19612126 const locator = automationLocator(input);
1962 if (locator) yield* ensurePlaywrightInjected(send);
2127 if (locator) yield* ensurePlaywrightInjected(tabId, send);
19632128 const locatorJson = locator
1964 ? yield* encodeJson("automationScroll.encodeLocator", locator)
2129 ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator)
19652130 : null;
19662131 const result = yield* evaluateWithDebugger<
19672132 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
19682133 >(
2134 tabId,
19692135 send,
19702136 `(() => {
19712137 try {
......@@ -1980,21 +2146,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19802146 true,
19812147 );
19822148 if ("invalidSelector" in result) {
1983 return yield* fail(
1984 "automationScroll",
1985 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1986 selector: input.selector ?? "",
1987 }),
1988 );
2149 return yield* new PreviewAutomationInvalidSelectorError({
2150 operation: "scroll",
2151 tabId,
2152 ...automationSelectorDiagnostics(input),
2153 reasonLength: result.message.length,
2154 cause: result,
2155 });
19892156 }
19902157 if ("notFound" in result) {
1991 return yield* fail(
1992 "automationScroll",
1993 automationError(
1994 "PreviewAutomationExecutionError",
1995 `No element matches locator ${locator}.`,
1996 ),
1997 );
2158 return yield* new PreviewAutomationTargetNotFoundError({
2159 operation: "scroll",
2160 tabId,
2161 ...automationSelectorDiagnostics(input),
2162 });
19982163 }
19992164 });
20002165
......@@ -2009,24 +2174,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20092174 });
20102175
20112176 const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")(
2012 function* (input: PreviewAutomationEvaluateInput, send: SendCommand) {
2177 function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) {
20132178 yield* send("Runtime.enable");
20142179 const value = yield* evaluateWithDebugger(
2180 tabId,
20152181 send,
20162182 input.expression,
20172183 input.returnByValue ?? true,
20182184 input.awaitPromise ?? true,
20192185 );
2020 const serialized = yield* encodeJson("automationEvaluate.encodeResult", value);
2021 if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) {
2022 return yield* fail(
2023 "automationEvaluate",
2024 automationError(
2025 "PreviewAutomationResultTooLargeError",
2026 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
2027 { maximumBytes: MAX_EVALUATION_BYTES },
2028 ),
2029 );
2186 const serialized = yield* encodeJson(
2187 { operation: "automationEvaluate.encodeResult", tabId },
2188 value,
2189 );
2190 const actualBytes = Buffer.byteLength(serialized, "utf8");
2191 if (actualBytes > MAX_EVALUATION_BYTES) {
2192 return yield* new PreviewAutomationResultTooLargeError({
2193 tabId,
2194 actualBytes,
2195 maximumBytes: MAX_EVALUATION_BYTES,
2196 });
20302197 }
20312198 return value;
20322199 },
......@@ -2038,23 +2205,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20382205 ) {
20392206 const wc = yield* requireWebContents(tabId);
20402207 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
2041 performAutomationEvaluate(input, send),
2208 performAutomationEvaluate(tabId, input, send),
20422209 );
20432210 });
20442211
20452212 const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* (
2213 tabId: string,
20462214 input: PreviewAutomationWaitForInput,
20472215 send: SendCommand,
20482216 ) {
20492217 const timeoutMs = input.timeoutMs ?? 15_000;
20502218 yield* send("Runtime.enable");
20512219 const locator = automationLocator(input);
2052 if (locator) yield* ensurePlaywrightInjected(send);
2220 if (locator) yield* ensurePlaywrightInjected(tabId, send);
20532221 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
2054 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),
2055 input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null),
2222 locator
2223 ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator)
2224 : Effect.succeed(null),
2225 input.text
2226 ? encodeJson({ operation: "automationWaitFor.encodeText", tabId }, input.text)
2227 : Effect.succeed(null),
20562228 input.urlIncludes
2057 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)
2229 ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes)
20582230 : Effect.succeed(null),
20592231 ]);
20602232 const deadline = (yield* currentMillis) + timeoutMs;
......@@ -2062,6 +2234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20622234 const result = yield* evaluateWithDebugger<
20632235 { matched: boolean } | { invalidSelector: true; message: string }
20642236 >(
2237 tabId,
20652238 send,
20662239 `(() => {
20672240 try {
......@@ -2080,23 +2253,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20802253 true,
20812254 );
20822255 if ("invalidSelector" in result) {
2083 return yield* fail(
2084 "automationWaitFor",
2085 automationError("PreviewAutomationInvalidSelectorError", result.message, {
2086 selector: input.selector ?? "",
2087 }),
2088 );
2256 return yield* new PreviewAutomationInvalidSelectorError({
2257 operation: "waitFor",
2258 tabId,
2259 ...automationSelectorDiagnostics(input),
2260 reasonLength: result.message.length,
2261 cause: result,
2262 });
20892263 }
20902264 if (result.matched) return;
20912265 yield* Effect.sleep(100);
20922266 }
2093 return yield* fail(
2094 "automationWaitFor",
2095 automationError(
2096 "PreviewAutomationTimeoutError",
2097 `Preview condition did not match within ${timeoutMs}ms.`,
2098 ),
2099 );
2267 return yield* new PreviewAutomationTimeoutError({
2268 tabId,
2269 timeoutMs,
2270 });
21002271 });
21012272
21022273 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
......@@ -2105,7 +2276,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
21052276 ) {
21062277 const wc = yield* requireWebContents(tabId);
21072278 yield* withControlSession(tabId, wc, "waitFor", (send) =>
2108 performAutomationWaitFor(input, send),
2279 performAutomationWaitFor(tabId, input, send),
21092280 );
21102281 });
21112282
......@@ -2113,23 +2284,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
21132284 artifactPath: string,
21142285 ) {
21152286 const resolvedPath = yield* resolveArtifactPath(artifactPath);
2116 yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath));
2287 yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () =>
2288 shell.showItemInFolder(resolvedPath),
2289 );
21172290 });
21182291
21192292 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
21202293 artifactPath: string,
21212294 ) {
21222295 const resolvedPath = yield* resolveArtifactPath(artifactPath);
2123 const image = yield* attempt("copyArtifactToClipboard.load", () =>
2124 nativeImage.createFromPath(resolvedPath),
2296 const image = yield* attempt(
2297 { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath },
2298 () => nativeImage.createFromPath(resolvedPath),
21252299 );
21262300 if (image.isEmpty()) {
2127 return yield* fail(
2128 "copyArtifactToClipboard",
2129 new Error("Preview artifact could not be loaded as an image."),
2130 );
2301 return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
21312302 }
2132 yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image));
2303 yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () =>
2304 clipboard.writeImage(image),
2305 );
21332306 });
21342307
21352308 const subscribe = <A>(
......@@ -2228,19 +2401,234 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass<P
22282401 }
22292402}
22302403
2231export class PreviewManagerError extends Schema.TaggedErrorClass<PreviewManagerError>()(
2232 "PreviewManagerError",
2404export class PreviewOperationError extends Schema.TaggedErrorClass<PreviewOperationError>()(
2405 "PreviewOperationError",
22332406 {
22342407 operation: Schema.String,
2408 tabId: Schema.optional(Schema.String),
2409 webContentsId: Schema.optional(Schema.Number),
2410 artifactPath: Schema.optional(Schema.String),
22352411 cause: Schema.Defect(),
22362412 },
22372413) {
2414 static toTimelineMessage(error: PreviewOperationError): string {
2415 return error.cause instanceof Error ? error.cause.message : String(error.cause);
2416 }
2417
22382418 override get message(): string {
2239 return `Desktop preview operation failed: ${this.operation}`;
2419 const context = [
2420 this.tabId === undefined ? undefined : `tab ${this.tabId}`,
2421 this.webContentsId === undefined ? undefined : `WebContents ${this.webContentsId}`,
2422 this.artifactPath === undefined ? undefined : `artifact ${this.artifactPath}`,
2423 ].filter((value): value is string => value !== undefined);
2424 return `Desktop preview operation failed: ${this.operation}${context.length === 0 ? "" : ` (${context.join(", ")})`}`;
22402425 }
22412426}
22422427
2243const isPreviewManagerError = Schema.is(PreviewManagerError);
2428export const isPreviewOperationError = Schema.is(PreviewOperationError);
2429
2430export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedErrorClass<PreviewArtifactPathOutsideDirectoryError>()(
2431 "PreviewArtifactPathOutsideDirectoryError",
2432 {
2433 artifactPath: Schema.String,
2434 artifactDirectory: Schema.String,
2435 },
2436) {
2437 override get message(): string {
2438 return `Preview artifact path ${this.artifactPath} is outside ${this.artifactDirectory}`;
2439 }
2440}
2441
2442export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass<PreviewArtifactImageLoadError>()(
2443 "PreviewArtifactImageLoadError",
2444 { artifactPath: Schema.String },
2445) {
2446 override get message(): string {
2447 return `Preview artifact could not be loaded as an image: ${this.artifactPath}`;
2448 }
2449}
2450
2451export class PreviewRecordingAlreadyActiveError extends Schema.TaggedErrorClass<PreviewRecordingAlreadyActiveError>()(
2452 "PreviewRecordingAlreadyActiveError",
2453 {
2454 requestedTabId: Schema.String,
2455 activeTabId: Schema.String,
2456 },
2457) {
2458 override get message(): string {
2459 return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`;
2460 }
2461}
2462
2463export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass<PreviewAutomationDevToolsOpenError>()(
2464 "PreviewAutomationDevToolsOpenError",
2465 { webContentsId: Schema.Number },
2466) {
2467 override get message(): string {
2468 return `Close preview DevTools before using agent browser control for WebContents ${this.webContentsId}`;
2469 }
2470}
2471
2472export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedErrorClass<PreviewAutomationDebuggerAttachedError>()(
2473 "PreviewAutomationDebuggerAttachedError",
2474 { webContentsId: Schema.Number },
2475) {
2476 override get message(): string {
2477 return `Preview control cannot attach to WebContents ${this.webContentsId} because another debugger owns it`;
2478 }
2479}
2480
2481export class PreviewAutomationEvaluationError extends Schema.TaggedErrorClass<PreviewAutomationEvaluationError>()(
2482 "PreviewAutomationEvaluationError",
2483 {
2484 tabId: Schema.String,
2485 detailKind: PreviewAutomationEvaluationDetailKind,
2486 detailLength: Schema.Number,
2487 cause: Schema.Defect(),
2488 },
2489) {
2490 static toTimelineMessage(error: PreviewAutomationEvaluationError): string {
2491 return previewAutomationEvaluationDetail(error.cause).detail ?? error.message;
2492 }
2493
2494 override get message(): string {
2495 return `Preview JavaScript evaluation failed in tab ${this.tabId}`;
2496 }
2497}
2498
2499export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotFoundError>()(
2500 "PreviewAutomationTargetNotFoundError",
2501 {
2502 operation: Schema.String,
2503 tabId: Schema.String,
2504 selectorKind: PreviewAutomationSelectorKind,
2505 selectorLength: Schema.optionalKey(Schema.Number),
2506 },
2507) {
2508 override get message(): string {
2509 const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
2510 return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`;
2511 }
2512}
2513
2514export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()(
2515 "PreviewAutomationCoordinatesOutsideViewportError",
2516 {
2517 tabId: Schema.String,
2518 x: Schema.Number,
2519 y: Schema.Number,
2520 viewportWidth: Schema.Number,
2521 viewportHeight: Schema.Number,
2522 },
2523) {
2524 override get message(): string {
2525 return `Click coordinates (${this.x}, ${this.y}) are outside the ${this.viewportWidth}x${this.viewportHeight} preview viewport for tab ${this.tabId}`;
2526 }
2527}
2528
2529export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()(
2530 "PreviewAutomationInvalidSelectorError",
2531 {
2532 operation: Schema.String,
2533 tabId: Schema.String,
2534 selectorKind: PreviewAutomationSelectorKind,
2535 selectorLength: Schema.optionalKey(Schema.Number),
2536 reasonLength: Schema.Number,
2537 cause: Schema.Defect(),
2538 },
2539) {
2540 static toTimelineMessage(error: PreviewAutomationInvalidSelectorError): string {
2541 if (typeof error.cause !== "object" || error.cause === null) return error.message;
2542 const reason = (error.cause as Record<string, unknown>)["message"];
2543 return typeof reason === "string" && reason.length > 0 ? reason : error.message;
2544 }
2545
2546 get detail(): {
2547 readonly selectorKind: PreviewAutomationSelectorKind;
2548 readonly selectorLength?: number;
2549 } {
2550 return {
2551 selectorKind: this.selectorKind,
2552 ...(this.selectorLength === undefined ? {} : { selectorLength: this.selectorLength }),
2553 };
2554 }
2555
2556 override get message(): string {
2557 const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
2558 return `Preview automation ${this.operation} rejected ${target} in tab ${this.tabId}`;
2559 }
2560}
2561
2562export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()(
2563 "PreviewAutomationResultTooLargeError",
2564 {
2565 tabId: Schema.String,
2566 actualBytes: Schema.Number,
2567 maximumBytes: Schema.Number,
2568 },
2569) {
2570 get detail(): { readonly maximumBytes: number } {
2571 return { maximumBytes: this.maximumBytes };
2572 }
2573
2574 override get message(): string {
2575 return `Preview evaluation result in tab ${this.tabId} was ${this.actualBytes} bytes; maximum is ${this.maximumBytes} bytes`;
2576 }
2577}
2578
2579export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()(
2580 "PreviewAutomationTimeoutError",
2581 {
2582 tabId: Schema.String,
2583 timeoutMs: Schema.Number,
2584 },
2585) {
2586 override get message(): string {
2587 return `Preview condition did not match within ${this.timeoutMs}ms in tab ${this.tabId}`;
2588 }
2589}
2590
2591export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()(
2592 "PreviewAutomationControlInterruptedError",
2593 {
2594 operation: Schema.String,
2595 tabId: Schema.String,
2596 webContentsId: Schema.Number,
2597 },
2598) {
2599 override get message(): string {
2600 return `Preview automation ${this.operation} was interrupted by human input in tab ${this.tabId}`;
2601 }
2602}
2603
2604export const PreviewManagerError = Schema.Union([
2605 PreviewTabNotFoundError,
2606 PreviewWebContentsNotFoundError,
2607 PreviewWebviewNotInitializedError,
2608 PreviewOperationError,
2609 PreviewArtifactPathOutsideDirectoryError,
2610 PreviewArtifactImageLoadError,
2611 PreviewRecordingAlreadyActiveError,
2612 PreviewAutomationDevToolsOpenError,
2613 PreviewAutomationDebuggerAttachedError,
2614 PreviewAutomationEvaluationError,
2615 PreviewAutomationTargetNotFoundError,
2616 PreviewAutomationCoordinatesOutsideViewportError,
2617 PreviewAutomationInvalidSelectorError,
2618 PreviewAutomationResultTooLargeError,
2619 PreviewAutomationTimeoutError,
2620 PreviewAutomationControlInterruptedError,
2621]);
2622export type PreviewManagerError = typeof PreviewManagerError.Type;
2623
2624export const isPreviewManagerError = Schema.is(PreviewManagerError);
2625export const isPreviewAutomationControlInterruptedError = Schema.is(
2626 PreviewAutomationControlInterruptedError,
2627);
2628export const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError);
2629export const isPreviewAutomationInvalidSelectorError = Schema.is(
2630 PreviewAutomationInvalidSelectorError,
2631);
22442632
22452633export class PreviewManager extends Context.Service<
22462634 PreviewManager,
......@@ -2329,16 +2717,17 @@ export const make = Effect.gen(function* PreviewManagerMake() {
23292717 const environment = yield* DesktopEnvironment.DesktopEnvironment;
23302718 const browserSession = yield* BrowserSession.BrowserSession;
23312719 const operations = yield* makeNativeOperations(environment.browserArtifactsDir);
2332 const browserSessionEffect = <A>(
2333 operation: string,
2334 effect: Effect.Effect<A, BrowserSession.BrowserSessionError>,
2335 ): Effect.Effect<A, PreviewManagerError> =>
2336 effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause })));
23372720
23382721 return PreviewManager.of({
23392722 setMainWindow: operations.setMainWindow,
23402723 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {
2341 return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope));
2724 return yield* browserSession
2725 .getSession(scope)
2726 .pipe(
2727 Effect.mapError(
2728 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
2729 ),
2730 );
23422731 }),
23432732 isBrowserPartition: browserSession.isPartition,
23442733 createTab: operations.createTab,
......@@ -2354,13 +2743,29 @@ export const make = Effect.gen(function* PreviewManagerMake() {
23542743 hardReload: operations.hardReload,
23552744 openDevTools: operations.openDevTools,
23562745 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
2357 yield* browserSessionEffect("clearCookies", browserSession.clearCookies());
2746 yield* browserSession
2747 .clearCookies()
2748 .pipe(
2749 Effect.mapError(
2750 (cause) => new PreviewOperationError({ operation: "clearCookies", cause }),
2751 ),
2752 );
23582753 }),
23592754 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {
2360 yield* browserSessionEffect("clearCache", browserSession.clearCache());
2755 yield* browserSession
2756 .clearCache()
2757 .pipe(
2758 Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })),
2759 );
23612760 }),
23622761 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {
2363 return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope));
2762 return yield* browserSession
2763 .getPartition(scope)
2764 .pipe(
2765 Effect.mapError(
2766 (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
2767 ),
2768 );
23642769 }),
23652770 setAnnotationTheme: operations.setAnnotationTheme,
23662771 pickElement: operations.pickElement,
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts+54
......@@ -3,10 +3,14 @@ import * as Effect from "effect/Effect";
33import { describe, expect } from "vite-plus/test";
44
55import {
6 extractPlaywrightInjectedRuntimeSource,
67 playwrightInjectedRuntimeInstallExpression,
78 playwrightInjectedRuntimeSource,
89} from "./PlaywrightInjectedRuntime.ts";
910
11const bundleWithSourceLiteral = (literal: string): string =>
12 `const source3 = ${literal};\n }\n});`;
13
1014describe("playwright injected runtime", () => {
1115 effectIt.effect("extracts the pinned runtime from playwright-core", () =>
1216 Effect.gen(function* () {
......@@ -23,4 +27,54 @@ describe("playwright injected runtime", () => {
2327 expect(expression).toContain('testIdAttributeName":"data-testid');
2428 }),
2529 );
30
31 effectIt.effect("reports a missing source marker without an artificial cause", () =>
32 Effect.gen(function* () {
33 const error = yield* Effect.flip(
34 extractPlaywrightInjectedRuntimeSource("const source = 'missing';", "/tmp/coreBundle.js"),
35 );
36
37 expect(error).toMatchObject({
38 _tag: "PlaywrightSourceMarkerNotFoundError",
39 bundlePath: "/tmp/coreBundle.js",
40 marker: "source3 = ",
41 });
42 expect("cause" in error).toBe(false);
43 }),
44 );
45
46 effectIt.effect("keeps source validation metadata cause-free", () =>
47 Effect.gen(function* () {
48 const error = yield* Effect.flip(
49 extractPlaywrightInjectedRuntimeSource(
50 bundleWithSourceLiteral('"short"'),
51 "/tmp/coreBundle.js",
52 ),
53 );
54
55 expect(error).toMatchObject({
56 _tag: "PlaywrightSourceValidationError",
57 bundlePath: "/tmp/coreBundle.js",
58 actualType: "string",
59 actualLength: 5,
60 minimumLength: 100_000,
61 });
62 expect("cause" in error).toBe(false);
63 }),
64 );
65
66 effectIt.effect("preserves the source evaluation cause", () =>
67 Effect.gen(function* () {
68 const error = yield* Effect.flip(
69 extractPlaywrightInjectedRuntimeSource(bundleWithSourceLiteral("("), "/tmp/coreBundle.js"),
70 );
71
72 expect(error).toMatchObject({
73 _tag: "PlaywrightSourceEvaluationError",
74 bundlePath: "/tmp/coreBundle.js",
75 timeoutMs: 1_000,
76 cause: expect.objectContaining({ name: "SyntaxError" }),
77 });
78 }),
79 );
2680});
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts+170-50
......@@ -4,69 +4,180 @@ import * as NodeModule from "node:module";
44import * as NodePath from "node:path";
55import * as NodeVM from "node:vm";
66
7import * as Data from "effect/Data";
87import * as Effect from "effect/Effect";
98import * as Schema from "effect/Schema";
109
1110const require = NodeModule.createRequire(import.meta.url);
1211const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
12const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json";
13const PLAYWRIGHT_SOURCE_MARKER = "source3 = ";
14const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});";
15const PLAYWRIGHT_SOURCE_MINIMUM_LENGTH = 100_000;
16const PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS = 1_000;
17const PLAYWRIGHT_SDK_LANGUAGE = "javascript";
18const PLAYWRIGHT_BROWSER_NAME = "chromium";
1319
14export class PlaywrightInjectedRuntimeError extends Data.TaggedError(
15 "PlaywrightInjectedRuntimeError",
16)<{
17 readonly operation: string;
18 readonly cause: unknown;
19}> {
20 override get message() {
21 return `Playwright injected runtime operation failed: ${this.operation}`;
20export class PlaywrightPackageResolveError extends Schema.TaggedErrorClass<PlaywrightPackageResolveError>()(
21 "PlaywrightPackageResolveError",
22 {
23 specifier: Schema.String,
24 cause: Schema.Defect(),
25 },
26) {
27 override get message(): string {
28 return `Failed to resolve Playwright package: ${this.specifier}`;
29 }
30}
31
32export class PlaywrightCoreBundleReadError extends Schema.TaggedErrorClass<PlaywrightCoreBundleReadError>()(
33 "PlaywrightCoreBundleReadError",
34 {
35 bundlePath: Schema.String,
36 cause: Schema.Defect(),
37 },
38) {
39 override get message(): string {
40 return `Failed to read Playwright core bundle: ${this.bundlePath}`;
41 }
42}
43
44export class PlaywrightSourceMarkerNotFoundError extends Schema.TaggedErrorClass<PlaywrightSourceMarkerNotFoundError>()(
45 "PlaywrightSourceMarkerNotFoundError",
46 {
47 bundlePath: Schema.String,
48 marker: Schema.String,
49 },
50) {
51 override get message(): string {
52 return `Playwright injected runtime marker ${JSON.stringify(this.marker)} was not found in ${this.bundlePath}`;
53 }
54}
55
56export class PlaywrightSourceTerminatorNotFoundError extends Schema.TaggedErrorClass<PlaywrightSourceTerminatorNotFoundError>()(
57 "PlaywrightSourceTerminatorNotFoundError",
58 {
59 bundlePath: Schema.String,
60 terminator: Schema.String,
61 },
62) {
63 override get message(): string {
64 return `Playwright injected runtime terminator ${JSON.stringify(this.terminator)} was not found in ${this.bundlePath}`;
65 }
66}
67
68export class PlaywrightSourceEvaluationError extends Schema.TaggedErrorClass<PlaywrightSourceEvaluationError>()(
69 "PlaywrightSourceEvaluationError",
70 {
71 bundlePath: Schema.String,
72 timeoutMs: Schema.Number,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Failed to evaluate the Playwright injected runtime literal from ${this.bundlePath} within ${this.timeoutMs}ms`;
2278 }
2379}
2480
25const fail = (operation: string, cause: unknown) =>
26 new PlaywrightInjectedRuntimeError({ operation, cause });
81export class PlaywrightSourceValidationError extends Schema.TaggedErrorClass<PlaywrightSourceValidationError>()(
82 "PlaywrightSourceValidationError",
83 {
84 bundlePath: Schema.String,
85 actualType: Schema.String,
86 actualLength: Schema.NullOr(Schema.Number),
87 minimumLength: Schema.Number,
88 },
89) {
90 override get message(): string {
91 const actual =
92 this.actualLength === null
93 ? this.actualType
94 : `${this.actualType} with ${this.actualLength} characters`;
95 return `Playwright injected runtime from ${this.bundlePath} was ${actual}; expected a string with at least ${this.minimumLength} characters`;
96 }
97}
98
99export class PlaywrightOptionsEncodeError extends Schema.TaggedErrorClass<PlaywrightOptionsEncodeError>()(
100 "PlaywrightOptionsEncodeError",
101 {
102 sdkLanguage: Schema.String,
103 browserName: Schema.String,
104 cause: Schema.Defect(),
105 },
106) {
107 override get message(): string {
108 return `Failed to encode ${this.browserName} Playwright injected runtime options for ${this.sdkLanguage}`;
109 }
110}
111
112export const PlaywrightInjectedRuntimeError = Schema.Union([
113 PlaywrightPackageResolveError,
114 PlaywrightCoreBundleReadError,
115 PlaywrightSourceMarkerNotFoundError,
116 PlaywrightSourceTerminatorNotFoundError,
117 PlaywrightSourceEvaluationError,
118 PlaywrightSourceValidationError,
119 PlaywrightOptionsEncodeError,
120]);
121export type PlaywrightInjectedRuntimeError = typeof PlaywrightInjectedRuntimeError.Type;
122
123export const extractPlaywrightInjectedRuntimeSource = Effect.fn(
124 "PlaywrightInjectedRuntime.extractSource",
125)(function* (coreBundle: string, bundlePath: string) {
126 const start = coreBundle.indexOf(PLAYWRIGHT_SOURCE_MARKER);
127 if (start < 0) {
128 return yield* new PlaywrightSourceMarkerNotFoundError({
129 bundlePath,
130 marker: PLAYWRIGHT_SOURCE_MARKER,
131 });
132 }
133 const literalStart = start + PLAYWRIGHT_SOURCE_MARKER.length;
134 const literalEnd = coreBundle.indexOf(PLAYWRIGHT_SOURCE_TERMINATOR, literalStart);
135 if (literalEnd < 0) {
136 return yield* new PlaywrightSourceTerminatorNotFoundError({
137 bundlePath,
138 terminator: PLAYWRIGHT_SOURCE_TERMINATOR,
139 });
140 }
141 const literal = coreBundle.slice(literalStart, literalEnd);
142 const source = yield* Effect.try({
143 try: () =>
144 NodeVM.runInNewContext(literal, Object.create(null), {
145 timeout: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS,
146 }),
147 catch: (cause) =>
148 new PlaywrightSourceEvaluationError({
149 bundlePath,
150 timeoutMs: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS,
151 cause,
152 }),
153 });
154 if (typeof source !== "string" || source.length < PLAYWRIGHT_SOURCE_MINIMUM_LENGTH) {
155 return yield* new PlaywrightSourceValidationError({
156 bundlePath,
157 actualType: typeof source,
158 actualLength: typeof source === "string" ? source.length : null,
159 minimumLength: PLAYWRIGHT_SOURCE_MINIMUM_LENGTH,
160 });
161 }
162 return source;
163});
27164
28165export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")(
29166 function* () {
30167 const packageJsonPath = yield* Effect.try({
31 try: () => require.resolve("playwright-core/package.json"),
32 catch: (cause) => fail("resolvePackage", cause),
168 try: () => require.resolve(PLAYWRIGHT_PACKAGE_SPECIFIER),
169 catch: (cause) =>
170 new PlaywrightPackageResolveError({
171 specifier: PLAYWRIGHT_PACKAGE_SPECIFIER,
172 cause,
173 }),
33174 });
175 const bundlePath = NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js");
34176 const coreBundle = yield* Effect.tryPromise({
35 try: () =>
36 NodeFSP.readFile(
37 NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"),
38 "utf8",
39 ),
40 catch: (cause) => fail("readCoreBundle", cause),
41 });
42 const marker = "source3 = ";
43 const start = coreBundle.indexOf(marker);
44 if (start < 0) {
45 return yield* fail(
46 "findSourceMarker",
47 new Error("Playwright injected runtime marker was not found."),
48 );
49 }
50 const literalStart = start + marker.length;
51 const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart);
52 if (literalEnd < 0) {
53 return yield* fail(
54 "findSourceTerminator",
55 new Error("Playwright injected runtime terminator was not found."),
56 );
57 }
58 const literal = coreBundle.slice(literalStart, literalEnd);
59 const source = yield* Effect.try({
60 try: () => NodeVM.runInNewContext(literal, Object.create(null), { timeout: 1_000 }),
61 catch: (cause) => fail("evaluateSourceLiteral", cause),
177 try: () => NodeFSP.readFile(bundlePath, "utf8"),
178 catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }),
62179 });
63 if (typeof source !== "string" || source.length < 100_000) {
64 return yield* fail(
65 "validateSource",
66 new Error("Playwright injected runtime extraction returned invalid source."),
67 );
68 }
69 return source;
180 return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath);
70181 },
71182);
72183
......@@ -76,14 +187,23 @@ export const playwrightInjectedRuntimeInstallExpression = Effect.fn(
76187 const source = yield* playwrightInjectedRuntimeSource();
77188 const options = yield* encodeUnknownJson({
78189 isUnderTest: false,
79 sdkLanguage: "javascript",
190 sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE,
80191 testIdAttributeName: "data-testid",
81192 stableRafCount: 1,
82 browserName: "chromium",
193 browserName: PLAYWRIGHT_BROWSER_NAME,
83194 shouldPrependErrorPrefix: false,
84195 isUtilityWorld: false,
85196 customEngines: [],
86 }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause)));
197 }).pipe(
198 Effect.mapError(
199 (cause) =>
200 new PlaywrightOptionsEncodeError({
201 sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE,
202 browserName: PLAYWRIGHT_BROWSER_NAME,
203 cause,
204 }),
205 ),
206 );
87207 return `(() => {
88208 if (globalThis.__t3PlaywrightInjected) return true;
89209 const module = { exports: {} };