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";...@@ -7,9 +7,10 @@ import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import * as Path from "effect/Path";9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
10import type * as Scope from "effect/Scope";11import type * as Scope from "effect/Scope";
11import { TestClock } from "effect/testing";12import { TestClock } from "effect/testing";
12import { beforeEach, describe, expect, vi } from "vite-plus/test";13import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1314
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as BrowserSession from "./BrowserSession.ts";16import * as BrowserSession from "./BrowserSession.ts";
...@@ -17,7 +18,7 @@ import * as PreviewManager from "./Manager.ts";...@@ -17,7 +18,7 @@ import * as PreviewManager from "./Manager.ts";
1718
18const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } =19const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } =
19 vi.hoisted(() => ({20 vi.hoisted(() => ({
20 createFromPath: vi.fn(() => ({ isEmpty: () => false })),21 createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
21 fromId: vi.fn(() => null),22 fromId: vi.fn(() => null),
22 mkdir: vi.fn((_path: string) => undefined),23 mkdir: vi.fn((_path: string) => undefined),
23 showItemInFolder: vi.fn(),24 showItemInFolder: vi.fn(),
...@@ -79,6 +80,7 @@ const layer = PreviewManager.layer.pipe(...@@ -79,6 +80,7 @@ const layer = PreviewManager.layer.pipe(
79 Layer.provideMerge(fileSystemLayer),80 Layer.provideMerge(fileSystemLayer),
80 Layer.provideMerge(Path.layer),81 Layer.provideMerge(Path.layer),
81);82);
83const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
8284
83const withManager = <A>(85const withManager = <A>(
84 use: (86 use: (
...@@ -237,6 +239,20 @@ describe("PreviewManager", () => {...@@ -237,6 +239,20 @@ describe("PreviewManager", () => {
237 expect(artifact.path).toMatch(239 expect(artifact.path).toMatch(
238 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,240 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
239 );241 );
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 });
240 }),256 }),
241 ),257 ),
242 );258 );
...@@ -302,9 +318,12 @@ describe("PreviewManager", () => {...@@ -302,9 +318,12 @@ describe("PreviewManager", () => {
302 expect(Exit.isFailure(exit)).toBe(true);318 expect(Exit.isFailure(exit)).toBe(true);
303 if (Exit.isSuccess(exit)) return;319 if (Exit.isSuccess(exit)) return;
304 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));320 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
305 expect(error.cause).toMatchObject({321 expect(error).toMatchObject({
306 message: "Preview artifact path is outside the configured artifact directory.",322 _tag: "PreviewArtifactPathOutsideDirectoryError",
323 artifactPath: "/tmp/t3/dev/settings.json",
324 artifactDirectory: "/tmp/t3/dev/browser-artifacts",
307 });325 });
326 expect("cause" in error).toBe(false);
308 }),327 }),
309 ),328 ),
310 );329 );
...@@ -324,8 +343,20 @@ describe("PreviewManager", () => {...@@ -324,8 +343,20 @@ describe("PreviewManager", () => {
324 expect(Exit.isFailure(exit)).toBe(true);343 expect(Exit.isFailure(exit)).toBe(true);
325 if (Exit.isSuccess(exit)) return;344 if (Exit.isSuccess(exit)) return;
326 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));345 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
327 expect(error.cause).toMatchObject({346 expect(error).toMatchObject({
328 message: "Preview artifact path is outside the configured artifact directory.",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,
329 });360 });
330 }),361 }),
331 ),362 ),
...@@ -466,10 +497,174 @@ describe("PreviewManager", () => {...@@ -466,10 +497,174 @@ describe("PreviewManager", () => {
466 expect(Exit.isFailure(exit)).toBe(true);497 expect(Exit.isFailure(exit)).toBe(true);
467 if (Exit.isSuccess(exit)) return;498 if (Exit.isSuccess(exit)) return;
468 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));499 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
469 expect(error.cause).toMatchObject({500 expect(error).toMatchObject({
470 name: "PreviewAutomationControlInterruptedError",501 _tag: "PreviewAutomationControlInterruptedError",
502 operation: "click",
503 tabId: "tab_1",
504 webContentsId: 42,
471 });505 });
506 expect(error).toBeInstanceOf(Error);
507 if (error instanceof Error) {
508 expect(error.name).toBe("PreviewAutomationControlInterruptedError");
509 }
510 expect("cause" in error).toBe(false);
472 }),511 }),
473 ),512 ),
474 );513 );
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 });
475});670});
apps/desktop/src/preview/Manager.ts+762-357
...@@ -148,22 +148,58 @@ interface CdpEvaluationResult {...@@ -148,22 +148,58 @@ interface CdpEvaluationResult {
148 };148 };
149}149}
150150
151const automationError = (151export const PreviewAutomationSelectorKind = Schema.Literals([
152 tag:152 "focused-element",
153 | "PreviewAutomationExecutionError"153 "selector",
154 | "PreviewAutomationInvalidSelectorError"154 "locator",
155 | "PreviewAutomationResultTooLargeError"155]);
156 | "PreviewAutomationTimeoutError"156export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type;
157 | "PreviewAutomationControlInterruptedError",157
158 message: string,158export const PreviewAutomationEvaluationDetailKind = Schema.Literals([
159 detail?: unknown,159 "exception-description",
160): Error & { detail?: unknown } => {160 "exception-text",
161 const error = new Error(message) as Error & { detail?: unknown };161 "unknown",
162 error.name = tag;162]);
163 if (detail !== undefined) error.detail = detail;163export type PreviewAutomationEvaluationDetailKind =
164 return error;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 };
165};186};
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
167const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {203const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
168 if (typeof value !== "object" || value === null) return null;204 if (typeof value !== "object" || value === null) return null;
169 const rect = value as Record<string, unknown>;205 const rect = value as Record<string, unknown>;
...@@ -194,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {...@@ -194,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
194};230};
195231
196const captureAnnotationScreenshot = (232const captureAnnotationScreenshot = (
233 tabId: string,
197 wc: Electron.WebContents,234 wc: Electron.WebContents,
198 cropRect: PreviewAnnotationRect | null,235 cropRect: PreviewAnnotationRect | null,
199): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>236): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>
...@@ -209,7 +246,13 @@ const captureAnnotationScreenshot = (...@@ -209,7 +246,13 @@ const captureAnnotationScreenshot = (
209 }246 }
210 : undefined,247 : undefined,
211 ),248 ),
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 }),
213 }).pipe(256 }).pipe(
214 Effect.map((image) => {257 Effect.map((image) => {
215 const size = image.getSize();258 const size = image.getSize();
...@@ -341,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -341,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
341 const runFork = Effect.runForkWith(context);384 const runFork = Effect.runForkWith(context);
342 const resolvedArtifactDirectory = path.resolve(artifactDirectory);385 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
343 const playwrightInstallExpression = yield* Effect.cached(386 const playwrightInstallExpression = yield* Effect.cached(
344 playwrightInjectedRuntimeInstallExpression().pipe(387 playwrightInjectedRuntimeInstallExpression(),
345 Effect.mapError(
346 (cause) =>
347 new PreviewManagerError({
348 operation: "ensurePlaywrightInjected",
349 cause,
350 }),
351 ),
352 ),
353 );388 );
354389
355 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);390 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
...@@ -377,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -377,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
377 const pointerSequenceRef = yield* Ref.make(0);412 const pointerSequenceRef = yield* Ref.make(0);
378 const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none());413 const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none());
379414
380 const fail = (operation: string, cause: unknown): PreviewManagerError =>415 const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>
381 new PreviewManagerError({ operation, cause });416 Effect.try({
382 const attempt = <A>(operation: string, evaluate: () => A) =>417 try: evaluate,
383 Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) });418 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
384 const attemptPromise = <A>(operation: string, evaluate: () => PromiseLike<A>) =>419 });
385 Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) });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 });
386 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));428 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
387 const currentMillis = Clock.currentTimeMillis;429 const currentMillis = Clock.currentTimeMillis;
388 const encodeJson = (operation: string, value: unknown) =>430 const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>
389 encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause)));431 encodeUnknownJson(value).pipe(
432 Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })),
433 );
390 const nextCounter = (ref: Ref.Ref<number>) =>434 const nextCounter = (ref: Ref.Ref<number>) =>
391 Ref.modify(ref, (value) => [value, value + 1] as const);435 Ref.modify(ref, (value) => [value, value + 1] as const);
392 const replaceMap = <K, V>(436 const replaceMap = <K, V>(
...@@ -432,23 +476,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -432,23 +476,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
432 const tabs = yield* SynchronizedRef.get(tabsRef);476 const tabs = yield* SynchronizedRef.get(tabsRef);
433 const tab = tabs.get(tabId);477 const tab = tabs.get(tabId);
434 if (!tab) {478 if (!tab) {
435 return yield* fail("requireWebContents", new PreviewTabNotFoundError({ tabId }));479 return yield* new PreviewTabNotFoundError({ tabId });
436 }480 }
437 if (tab.webContentsId == null) {481 if (tab.webContentsId == null) {
438 return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError({ tabId }));482 return yield* new PreviewWebviewNotInitializedError({ tabId });
439 }483 }
440 const wc = webContents.fromId(tab.webContentsId);484 const wc = webContents.fromId(tab.webContentsId);
441 if (!wc) {485 if (!wc) {
442 return yield* fail(486 return yield* new PreviewWebContentsNotFoundError({
443 "requireWebContents",487 tabId,
444 new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId }),488 webContentsId: tab.webContentsId,
445 );489 });
446 }490 }
447 return wc;491 return wc;
448 });492 });
449493
450 const resolveArtifactPath = (artifactPath: string) =>494 const resolveArtifactPath = (artifactPath: string) =>
451 attempt("resolveArtifactPath", () => {495 attempt({ operation: "resolveArtifactPath", artifactPath }, () => {
452 const resolvedPath = path.resolve(artifactPath);496 const resolvedPath = path.resolve(artifactPath);
453 const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath);497 const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath);
454 if (498 if (
...@@ -464,10 +508,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -464,10 +508,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
464 Effect.flatMap((resolvedPath) =>508 Effect.flatMap((resolvedPath) =>
465 resolvedPath === null509 resolvedPath === null
466 ? Effect.fail(510 ? Effect.fail(
467 fail(511 new PreviewArtifactPathOutsideDirectoryError({
468 "resolveArtifactPath",512 artifactPath,
469 new Error("Preview artifact path is outside the configured artifact directory."),513 artifactDirectory: resolvedArtifactDirectory,
470 ),514 }),
471 )515 )
472 : Effect.succeed(resolvedPath),516 : Effect.succeed(resolvedPath),
473 ),517 ),
...@@ -636,129 +680,137 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -636,129 +680,137 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
636 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (680 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
637 wc: Electron.WebContents,681 wc: Electron.WebContents,
638 ) {682 ) {
639 return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => {683 return yield* SynchronizedRef.modifyEffect(
640 const existing = sessions.get(wc.id);684 controlSessionsRef,
641 if (existing) return Effect.succeed([existing, sessions] as const);685 (
642 if (wc.isDevToolsOpened()) {686 sessions,
643 return Effect.fail(687 ): Effect.Effect<
644 fail(688 readonly [BrowserControlSession, ReadonlyMap<number, BrowserControlSession>],
645 "ensureControlSession",689 PreviewManagerError
646 automationError(690 > => {
647 "PreviewAutomationExecutionError",691 const existing = sessions.get(wc.id);
648 "Close preview DevTools before using agent browser control.",692 if (existing) return Effect.succeed([existing, sessions] as const);
649 ),693 if (wc.isDevToolsOpened()) {
650 ),694 return Effect.fail(
651 );695 new PreviewAutomationDevToolsOpenError({
652 }696 webContentsId: wc.id,
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 });
736 }),697 }),
737 );698 );
738 yield* attempt("attachDebuggerListeners", () => {699 }
739 wc.debugger.on("message", onMessage);700 if (wc.debugger.isAttached()) {
740 wc.debugger.attach("1.3");701 return Effect.fail(
741 });702 new PreviewAutomationDebuggerAttachedError({
742 yield* Effect.all(703 webContentsId: wc.id,
743 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(704 }),
744 (method) =>705 );
745 attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)),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 },
746 ),768 ),
747 { concurrency: "unbounded", discard: true },
748 );769 );
749 return [770 const control: BrowserControlSession = {
750 control,771 webContentsId: wc.id,
751 replaceMap(sessions, (copy) => {772 semaphore,
752 copy.set(wc.id, control);773 scope,
753 }),774 onMessage,
754 ] as const;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 );
755 });810 });
756 return yield* initialize().pipe(811 return createControlSession();
757 Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),812 },
758 );813 );
759 });
760 return createControlSession();
761 });
762 });814 });
763815
764 const pushAction = (tabId: string, event: PreviewAutomationActionEvent) =>816 const pushAction = (tabId: string, event: PreviewAutomationActionEvent) =>
...@@ -808,26 +860,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -808,26 +860,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
808 function* (method, commandParams) {860 function* (method, commandParams) {
809 const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;861 const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
810 if (before !== epoch) {862 if (before !== epoch) {
811 return yield* fail(863 return yield* new PreviewAutomationControlInterruptedError({
812 action,864 operation: action,
813 automationError(865 tabId,
814 "PreviewAutomationControlInterruptedError",866 webContentsId: wc.id,
815 "Browser control was interrupted by human input.",867 });
816 ),
817 );
818 }868 }
819 const result = yield* attemptPromise(action, () =>869 const result = yield* attemptPromise(
820 wc.debugger.sendCommand(method, commandParams),870 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },
871 () => wc.debugger.sendCommand(method, commandParams),
821 );872 );
822 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;873 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
823 if (after !== epoch) {874 if (after !== epoch) {
824 return yield* fail(875 return yield* new PreviewAutomationControlInterruptedError({
825 action,876 operation: action,
826 automationError(877 tabId,
827 "PreviewAutomationControlInterruptedError",878 webContentsId: wc.id,
828 "Browser control was interrupted by human input.",879 });
829 ),
830 );
831 }880 }
832 return result;881 return result;
833 },882 },
...@@ -846,15 +895,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -846,15 +895,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
846 });895 });
847 } else {896 } else {
848 const error = Option.getOrNull(Cause.findErrorOption(exit.cause));897 const error = Option.getOrNull(Cause.findErrorOption(exit.cause));
849 const underlying = isPreviewManagerError(error) ? error.cause : error;898 const interrupted = isPreviewAutomationControlInterruptedError(error);
850 const interrupted =899 const errorMessage = isPreviewOperationError(error)
851 underlying instanceof Error &&900 ? PreviewOperationError.toTimelineMessage(error)
852 underlying.name === "PreviewAutomationControlInterruptedError";901 : isPreviewAutomationEvaluationError(error)
902 ? PreviewAutomationEvaluationError.toTimelineMessage(error)
903 : isPreviewAutomationInvalidSelectorError(error)
904 ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error)
905 : error instanceof Error
906 ? error.message
907 : String(error);
853 yield* replaceAction(tabId, {908 yield* replaceAction(tabId, {
854 ...actionEvent,909 ...actionEvent,
855 status: interrupted ? "interrupted" : "failed",910 status: interrupted ? "interrupted" : "failed",
856 completedAt,911 completedAt,
857 error: underlying instanceof Error ? underlying.message : String(underlying),912 error: errorMessage,
858 });913 });
859 }914 }
860 const tabs = yield* SynchronizedRef.get(tabsRef);915 const tabs = yield* SynchronizedRef.get(tabsRef);
...@@ -864,6 +919,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -864,6 +919,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
864 });919 });
865920
866 const evaluateWithDebugger = <A = unknown>(921 const evaluateWithDebugger = <A = unknown>(
922 tabId: string,
867 send: SendCommand,923 send: SendCommand,
868 expression: string,924 expression: string,
869 returnByValue: boolean,925 returnByValue: boolean,
...@@ -877,19 +933,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -877,19 +933,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
877 }).pipe(933 }).pipe(
878 Effect.flatMap((rawResponse) => {934 Effect.flatMap((rawResponse) => {
879 const response = rawResponse as CdpEvaluationResult;935 const response = rawResponse as CdpEvaluationResult;
880 return response.exceptionDetails936 if (!response.exceptionDetails) {
881 ? Effect.fail(937 return Effect.succeed(response.result?.value as A);
882 fail(938 }
883 "evaluate",939 const detail = previewAutomationEvaluationDetail(response.exceptionDetails);
884 automationError(940 return Effect.fail(
885 "PreviewAutomationExecutionError",941 new PreviewAutomationEvaluationError({
886 response.exceptionDetails.exception?.description ??942 tabId,
887 response.exceptionDetails.text ??943 detailKind: detail.detailKind,
888 "JavaScript evaluation failed.",944 detailLength: detail.detail?.length ?? 0,
889 ),945 cause: response.exceptionDetails,
890 ),946 }),
891 )947 );
892 : Effect.succeed(response.result?.value as A);
893 }),948 }),
894 );949 );
895950
...@@ -898,17 +953,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -898,17 +953,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
898 readonly locator?: string | undefined;953 readonly locator?: string | undefined;
899 }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null);954 }): 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
901 const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* (972 const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* (
973 tabId: string,
902 send: SendCommand,974 send: SendCommand,
903 ) {975 ) {
904 const installed = yield* evaluateWithDebugger<boolean>(976 const installed = yield* evaluateWithDebugger<boolean>(
977 tabId,
905 send,978 send,
906 "Boolean(globalThis.__t3PlaywrightInjected)",979 "Boolean(globalThis.__t3PlaywrightInjected)",
907 true,980 true,
908 );981 );
909 if (installed) return;982 if (installed) return;
910 const expression = yield* playwrightInstallExpression;983 const expression = yield* playwrightInstallExpression.pipe(
911 yield* evaluateWithDebugger(send, expression, true);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);
912 });994 });
913995
914 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (996 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
...@@ -1060,7 +1142,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1060,7 +1142,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1060 };1142 };
1061 yield* Scope.addFinalizer(1143 yield* Scope.addFinalizer(
1062 scope,1144 scope,
1063 attempt("detachListeners", () => {1145 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
1064 wc.off("did-navigate", sync);1146 wc.off("did-navigate", sync);
1065 wc.off("did-navigate-in-page", sync);1147 wc.off("did-navigate-in-page", sync);
1066 wc.off("page-title-updated", sync);1148 wc.off("page-title-updated", sync);
...@@ -1072,7 +1154,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1072,7 +1154,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1072 }).pipe(Effect.ignore),1154 }).pipe(Effect.ignore),
1073 );1155 );
1074 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {1156 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
1075 yield* attempt("attachListeners", () => {1157 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1076 wc.on("did-navigate", sync);1158 wc.on("did-navigate", sync);
1077 wc.on("did-navigate-in-page", sync);1159 wc.on("did-navigate-in-page", sync);
1078 wc.on("page-title-updated", sync);1160 wc.on("page-title-updated", sync);
...@@ -1081,7 +1163,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1081,7 +1163,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1081 wc.on("did-fail-load", failed as never);1163 wc.on("did-fail-load", failed as never);
1082 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);1164 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1083 wc.setWindowOpenHandler(({ url }) => {1165 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 );
1085 return { action: "deny" };1171 return { action: "deny" };
1086 });1172 });
1087 wc.on("before-input-event", beforeInput);1173 wc.on("before-input-event", beforeInput);
...@@ -1162,7 +1248,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1162,7 +1248,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1162 ) {1248 ) {
1163 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);1249 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1164 if (!tab) {1250 if (!tab) {
1165 return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId }));1251 return yield* new PreviewTabNotFoundError({ tabId });
1166 }1252 }
1167 const wc = webContents.fromId(webContentsId);1253 const wc = webContents.fromId(webContentsId);
1168 const mainWindow = yield* Ref.get(mainWindowRef);1254 const mainWindow = yield* Ref.get(mainWindowRef);
...@@ -1171,15 +1257,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1171,15 +1257,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1171 wc.getType() !== "webview" ||1257 wc.getType() !== "webview" ||
1172 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)1258 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
1173 ) {1259 ) {
1174 return yield* fail(1260 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId });
1175 "registerWebview",
1176 new PreviewWebContentsNotFoundError({ tabId, webContentsId }),
1177 );
1178 }1261 }
1179 const attached = yield* Ref.get(attachedRef);1262 const attached = yield* Ref.get(attachedRef);
1180 const annotationTheme = yield* Ref.get(annotationThemeRef);1263 const annotationTheme = yield* Ref.get(annotationThemeRef);
1181 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {1264 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
1182 yield* attempt("registerWebview.sendTheme", () =>1265 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1183 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1266 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1184 );1267 );
1185 return;1268 return;
...@@ -1225,16 +1308,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1225,16 +1308,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1225 ] as const;1308 ] as const;
1226 });1309 });
1227 if (Option.isNone(registration)) {1310 if (Option.isNone(registration)) {
1228 return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId }));1311 return yield* new PreviewTabNotFoundError({ tabId });
1229 }1312 }
1230 const { state: registered, pendingUrl } = registration.value;1313 const { state: registered, pendingUrl } = registration.value;
1231 yield* emit(tabId, registered);1314 yield* emit(tabId, registered);
1232 if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {1315 if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
1233 yield* attempt("registerWebview.restoreZoom", () =>1316 yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () =>
1234 wc.setZoomFactor(registered.zoomFactor),1317 wc.setZoomFactor(registered.zoomFactor),
1235 ).pipe(Effect.ignore);1318 ).pipe(Effect.ignore);
1236 }1319 }
1237 yield* attempt("registerWebview.sendTheme", () =>1320 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1238 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1321 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1239 );1322 );
1240 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;1323 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;
...@@ -1245,15 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1245,15 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1245 wc.getURL() !== pendingUrl1328 wc.getURL() !== pendingUrl
1246 ) {1329 ) {
1247 runFork(1330 runFork(
1248 attemptPromise("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe(1331 attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () =>
1249 Effect.ignore,1332 wc.loadURL(pendingUrl),
1250 ),1333 ).pipe(Effect.ignore),
1251 );1334 );
1252 }1335 }
1253 });1336 });
12541337
1255 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {1338 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 );
1257 const updatedAt = yield* currentIso;1342 const updatedAt = yield* currentIso;
1258 const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => {1343 const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1259 const current = tabs.get(tabId);1344 const current = tabs.get(tabId);
...@@ -1294,10 +1379,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1294,10 +1379,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1294 return;1379 return;
1295 }1380 }
1296 if (wc.getURL() === url) {1381 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 );
1298 return;1385 return;
1299 }1386 }
1300 yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url));1387 yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () =>
1388 wc.loadURL(url),
1389 );
1301 });1390 });
13021391
1303 const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* (1392 const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* (
...@@ -1306,7 +1395,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1306,7 +1395,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1306 use: (wc: Electron.WebContents) => void,1395 use: (wc: Electron.WebContents) => void,
1307 ) {1396 ) {
1308 const wc = yield* requireWebContents(tabId);1397 const wc = yield* requireWebContents(tabId);
1309 yield* attempt(operation, () => use(wc));1398 yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc));
1310 });1399 });
13111400
1312 const goBack = (tabId: string) =>1401 const goBack = (tabId: string) =>
...@@ -1324,11 +1413,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1324,11 +1413,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1324 const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) {1413 const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) {
1325 const wc = yield* requireWebContents(tabId);1414 const wc = yield* requireWebContents(tabId);
1326 if (wc.isDevToolsOpened()) {1415 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 );
1328 return;1419 return;
1329 }1420 }
1330 yield* detachControlSession(wc.id);1421 yield* detachControlSession(wc.id);
1331 yield* attempt("openDevTools", () => {1422 yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => {
1332 wc.once("devtools-closed", () => {1423 wc.once("devtools-closed", () => {
1333 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));1424 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));
1334 });1425 });
...@@ -1348,9 +1439,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1348,9 +1439,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1348 const wc = webContents.fromId(tab.webContentsId);1439 const wc = webContents.fromId(tab.webContentsId);
1349 return !wc || wc.isDestroyed()1440 return !wc || wc.isDestroyed()
1350 ? Effect.void1441 ? Effect.void
1351 : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe(1442 : attempt(
1352 Effect.ignore,1443 {
1353 );1444 operation: "setAnnotationTheme",
1445 tabId: tab.tabId,
1446 webContentsId: tab.webContentsId,
1447 },
1448 () => wc.send(ANNOTATION_THEME_CHANNEL, theme),
1449 ).pipe(Effect.ignore);
1354 },1450 },
1355 { discard: true },1451 { discard: true },
1356 );1452 );
...@@ -1363,7 +1459,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1363,7 +1459,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1363 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(1459 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
1364 (resume) => {1460 (resume) => {
1365 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {1461 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
1366 yield* attempt("pickElement.cleanup", () => {1462 yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => {
1367 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);1463 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
1368 wc.off("destroyed", onDestroyed);1464 wc.off("destroyed", onDestroyed);
1369 wc.off("did-start-navigation", onNavigated);1465 wc.off("did-start-navigation", onNavigated);
...@@ -1392,9 +1488,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1392,9 +1488,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1392 if (activeTab?.webContentsId != null) {1488 if (activeTab?.webContentsId != null) {
1393 const activeWc = webContents.fromId(activeTab.webContentsId);1489 const activeWc = webContents.fromId(activeTab.webContentsId);
1394 if (activeWc && !activeWc.isDestroyed()) {1490 if (activeWc && !activeWc.isDestroyed()) {
1395 yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe(1491 yield* attempt(
1396 Effect.ignore,1492 {
1397 );1493 operation: "cancelPickElement",
1494 tabId,
1495 webContentsId: activeWc.id,
1496 },
1497 () => activeWc.send(CANCEL_PICK_CHANNEL),
1498 ).pipe(Effect.ignore);
1398 }1499 }
1399 }1500 }
1400 resume(Effect.succeed(null));1501 resume(Effect.succeed(null));
...@@ -1408,15 +1509,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1408,15 +1509,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1408 }1509 }
1409 const cropRect = normalizeCaptureRect(args[1]);1510 const cropRect = normalizeCaptureRect(args[1]);
1410 runFork(1511 runFork(
1411 captureAnnotationScreenshot(wc, cropRect).pipe(1512 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(
1412 Effect.matchEffect({1513 Effect.matchEffect({
1413 onFailure: () => Effect.sync(() => settle(payload)),1514 onFailure: () => Effect.sync(() => settle(payload)),
1414 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),1515 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),
1415 }),1516 }),
1416 Effect.ensuring(1517 Effect.ensuring(
1417 attempt("pickElement.captureComplete", () => {1518 attempt(
1418 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);1519 { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id },
1419 }).pipe(Effect.ignore),1520 () => {
1521 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);
1522 },
1523 ).pipe(Effect.ignore),
1420 ),1524 ),
1421 ),1525 ),
1422 );1526 );
...@@ -1431,7 +1535,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1431,7 +1535,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1431 if (isMainFrame) settle(null);1535 if (isMainFrame) settle(null);
1432 };1536 };
1433 const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () {1537 const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () {
1434 yield* attempt("pickElement.register", () => {1538 yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => {
1435 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);1539 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
1436 wc.once("destroyed", onDestroyed);1540 wc.once("destroyed", onDestroyed);
1437 wc.once("did-start-navigation", onNavigated);1541 wc.once("did-start-navigation", onNavigated);
...@@ -1468,7 +1572,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1468,7 +1572,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1468 if (tab.webContentsId != null) {1572 if (tab.webContentsId != null) {
1469 const wc = webContents.fromId(tab.webContentsId);1573 const wc = webContents.fromId(tab.webContentsId);
1470 if (wc && !wc.isDestroyed()) {1574 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 );
1472 }1578 }
1473 }1579 }
1474 yield* update(tabId, { zoomFactor: next });1580 yield* update(tabId, { zoomFactor: next });
...@@ -1481,17 +1587,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1481,17 +1587,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1481 const [createdAt, millis, image] = yield* Effect.all([1587 const [createdAt, millis, image] = yield* Effect.all([
1482 currentIso,1588 currentIso,
1483 currentMillis,1589 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 ),
1485 ]);1598 ]);
1486 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;1599 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;
1487 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`);1600 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`);
1488 const data = image.toPNG();1601 const data = image.toPNG();
1489 yield* fileSystem1602 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
1490 .makeDirectory(resolvedArtifactDirectory, { recursive: true })1603 Effect.mapError(
1491 .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause)));1604 (cause) =>
1492 yield* fileSystem1605 new PreviewOperationError({
1493 .writeFile(artifactPath, data)1606 operation: "captureScreenshot.makeDirectory",
1494 .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause)));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 );
1495 return {1626 return {
1496 id,1627 id,
1497 tabId,1628 tabId,
...@@ -1518,10 +1649,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1518,10 +1649,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1518 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {1649 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
1519 const recordingTabId = yield* Ref.get(recordingTabIdRef);1650 const recordingTabId = yield* Ref.get(recordingTabIdRef);
1520 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {1651 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
1521 return yield* fail(1652 return yield* new PreviewRecordingAlreadyActiveError({
1522 "startRecording",1653 requestedTabId: tabId,
1523 new Error("Only one browser recording can be active per window."),1654 activeTabId: recordingTabId.value,
1524 );1655 });
1525 }1656 }
1526 const wc = yield* requireWebContents(tabId);1657 const wc = yield* requireWebContents(tabId);
1527 yield* withControlSession(tabId, wc, "recording.start", startScreencast);1658 yield* withControlSession(tabId, wc, "recording.start", startScreencast);
...@@ -1547,12 +1678,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1547,12 +1678,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1547 const id = `browser-recording-${millis.toString(36)}`;1678 const id = `browser-recording-${millis.toString(36)}`;
1548 const extension = mimeType.includes("mp4") ? "mp4" : "webm";1679 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
1549 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);1680 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
1550 yield* fileSystem1681 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
1551 .makeDirectory(resolvedArtifactDirectory, { recursive: true })1682 Effect.mapError(
1552 .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause)));1683 (cause) =>
1553 yield* fileSystem1684 new PreviewOperationError({
1554 .writeFile(artifactPath, data)1685 operation: "saveRecording.makeDirectory",
1555 .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause)));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 );
1556 return {1703 return {
1557 id,1704 id,
1558 tabId,1705 tabId,
...@@ -1609,6 +1756,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1609,6 +1756,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1609 visibleText: string;1756 visibleText: string;
1610 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];1757 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
1611 }>(1758 }>(
1759 tabId,
1612 send,1760 send,
1613 `(() => {1761 `(() => {
1614 const selectorFor = (element) => {1762 const selectorFor = (element) => {
...@@ -1665,7 +1813,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1665,7 +1813,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1665 );1813 );
1666 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([1814 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
1667 send("Accessibility.getFullAXTree"),1815 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 ),
1669 Ref.get(diagnosticsRef),1824 Ref.get(diagnosticsRef),
1670 Ref.get(actionTimelineRef),1825 Ref.get(actionTimelineRef),
1671 ]);1826 ]);
...@@ -1702,6 +1857,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1702,6 +1857,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1702 });1857 });
17031858
1704 const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* (1859 const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* (
1860 tabId: string,
1705 send: SendCommand,1861 send: SendCommand,
1706 input: PreviewAutomationClickInput,1862 input: PreviewAutomationClickInput,
1707 ) {1863 ) {
...@@ -1709,11 +1865,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1709,11 +1865,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1709 return { x: input.x!, y: input.y! };1865 return { x: input.x!, y: input.y! };
1710 }1866 }
1711 const locator = automationLocator(input)!;1867 const locator = automationLocator(input)!;
1712 yield* ensurePlaywrightInjected(send);1868 yield* ensurePlaywrightInjected(tabId, send);
1713 const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator);1869 const locatorJson = yield* encodeJson(
1870 { operation: "automationClick.encodeLocator", tabId },
1871 locator,
1872 );
1714 const point = yield* evaluateWithDebugger<1873 const point = yield* evaluateWithDebugger<
1715 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }1874 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
1716 >(1875 >(
1876 tabId,
1717 send,1877 send,
1718 `(() => {1878 `(() => {
1719 try {1879 try {
...@@ -1734,21 +1894,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1734,21 +1894,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1734 true,1894 true,
1735 );1895 );
1736 if ("invalidSelector" in point) {1896 if ("invalidSelector" in point) {
1737 return yield* fail(1897 return yield* new PreviewAutomationInvalidSelectorError({
1738 "automationClick",1898 operation: "click",
1739 automationError("PreviewAutomationInvalidSelectorError", point.message, {1899 tabId,
1740 selector: locator,1900 ...automationSelectorDiagnostics(input),
1741 }),1901 reasonLength: point.message.length,
1742 );1902 cause: point,
1903 });
1743 }1904 }
1744 if ("notFound" in point) {1905 if ("notFound" in point) {
1745 return yield* fail(1906 return yield* new PreviewAutomationTargetNotFoundError({
1746 "automationClick",1907 operation: "click",
1747 automationError(1908 tabId,
1748 "PreviewAutomationExecutionError",1909 ...automationSelectorDiagnostics(input),
1749 `No element matches locator ${locator}.`,1910 });
1750 ),
1751 );
1752 }1911 }
1753 return point;1912 return point;
1754 });1913 });
...@@ -1773,20 +1932,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1773,20 +1932,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1773 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],1932 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
1774 { concurrency: 2, discard: true },1933 { concurrency: 2, discard: true },
1775 );1934 );
1776 const point = yield* resolveClickPoint(send, input);1935 const point = yield* resolveClickPoint(tabId, send, input);
1777 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(1936 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
1937 tabId,
1778 send,1938 send,
1779 "({ width: window.innerWidth, height: window.innerHeight })",1939 "({ width: window.innerWidth, height: window.innerHeight })",
1780 true,1940 true,
1781 );1941 );
1782 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {1942 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
1783 return yield* fail(1943 return yield* new PreviewAutomationCoordinatesOutsideViewportError({
1784 "automationClick",1944 tabId,
1785 automationError(1945 x: point.x,
1786 "PreviewAutomationExecutionError",1946 y: point.y,
1787 `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`,1947 viewportWidth: viewport.width,
1788 ),1948 viewportHeight: viewport.height,
1789 );1949 });
1790 }1950 }
1791 const moveSequence = yield* nextCounter(pointerSequenceRef);1951 const moveSequence = yield* nextCounter(pointerSequenceRef);
1792 const moveCreatedAt = yield* currentIso;1952 const moveCreatedAt = yield* currentIso;
...@@ -1834,15 +1994,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1834,15 +1994,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1834 });1994 });
18351995
1836 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (1996 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (
1997 tabId: string,
1837 send: SendCommand,1998 send: SendCommand,
1838 input: PreviewAutomationTypeInput,1999 input: PreviewAutomationTypeInput,
1839 ) {2000 ) {
1840 const locator = automationLocator(input);2001 const locator = automationLocator(input);
1841 if (locator) yield* ensurePlaywrightInjected(send);2002 if (locator) yield* ensurePlaywrightInjected(tabId, send);
1842 const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null;2003 const locatorJson = locator
2004 ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator)
2005 : null;
1843 const result = yield* evaluateWithDebugger<2006 const result = yield* evaluateWithDebugger<
1844 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }2007 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1845 >(2008 >(
2009 tabId,
1846 send,2010 send,
1847 `(() => {2011 `(() => {
1848 try {2012 try {
...@@ -1862,23 +2026,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1862,23 +2026,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1862 true,2026 true,
1863 );2027 );
1864 if ("invalidSelector" in result) {2028 if ("invalidSelector" in result) {
1865 return yield* fail(2029 return yield* new PreviewAutomationInvalidSelectorError({
1866 "automationType",2030 operation: "type",
1867 automationError("PreviewAutomationInvalidSelectorError", result.message, {2031 tabId,
1868 selector: input.selector ?? "",2032 ...automationSelectorDiagnostics(input),
1869 }),2033 reasonLength: result.message.length,
1870 );2034 cause: result,
2035 });
1871 }2036 }
1872 if ("notFound" in result) {2037 if ("notFound" in result) {
1873 return yield* fail(2038 return yield* new PreviewAutomationTargetNotFoundError({
1874 "automationType",2039 operation: "type",
1875 automationError(2040 tabId,
1876 "PreviewAutomationExecutionError",2041 ...automationSelectorDiagnostics(input),
1877 locator2042 });
1878 ? `No element matches locator ${locator}.`
1879 : "No element is focused in the preview.",
1880 ),
1881 );
1882 }2043 }
1883 });2044 });
18842045
...@@ -1888,10 +2049,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1888,10 +2049,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1888 send: SendCommand,2049 send: SendCommand,
1889 ) {2050 ) {
1890 yield* send("Runtime.enable");2051 yield* send("Runtime.enable");
1891 yield* focusAutomationTarget(send, input);2052 yield* focusAutomationTarget(tabId, send, input);
1892 yield* send("Input.insertText", { text: input.text });2053 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 );
1894 yield* evaluateWithDebugger(2058 yield* evaluateWithDebugger(
2059 tabId,
1895 send,2060 send,
1896 `(() => {2061 `(() => {
1897 const element = document.activeElement;2062 const element = document.activeElement;
...@@ -1959,13 +2124,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1959,13 +2124,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1959 ) {2124 ) {
1960 yield* send("Runtime.enable");2125 yield* send("Runtime.enable");
1961 const locator = automationLocator(input);2126 const locator = automationLocator(input);
1962 if (locator) yield* ensurePlaywrightInjected(send);2127 if (locator) yield* ensurePlaywrightInjected(tabId, send);
1963 const locatorJson = locator2128 const locatorJson = locator
1964 ? yield* encodeJson("automationScroll.encodeLocator", locator)2129 ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator)
1965 : null;2130 : null;
1966 const result = yield* evaluateWithDebugger<2131 const result = yield* evaluateWithDebugger<
1967 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }2132 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1968 >(2133 >(
2134 tabId,
1969 send,2135 send,
1970 `(() => {2136 `(() => {
1971 try {2137 try {
...@@ -1980,21 +2146,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1980,21 +2146,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1980 true,2146 true,
1981 );2147 );
1982 if ("invalidSelector" in result) {2148 if ("invalidSelector" in result) {
1983 return yield* fail(2149 return yield* new PreviewAutomationInvalidSelectorError({
1984 "automationScroll",2150 operation: "scroll",
1985 automationError("PreviewAutomationInvalidSelectorError", result.message, {2151 tabId,
1986 selector: input.selector ?? "",2152 ...automationSelectorDiagnostics(input),
1987 }),2153 reasonLength: result.message.length,
1988 );2154 cause: result,
2155 });
1989 }2156 }
1990 if ("notFound" in result) {2157 if ("notFound" in result) {
1991 return yield* fail(2158 return yield* new PreviewAutomationTargetNotFoundError({
1992 "automationScroll",2159 operation: "scroll",
1993 automationError(2160 tabId,
1994 "PreviewAutomationExecutionError",2161 ...automationSelectorDiagnostics(input),
1995 `No element matches locator ${locator}.`,2162 });
1996 ),
1997 );
1998 }2163 }
1999 });2164 });
20002165
...@@ -2009,24 +2174,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2009,24 +2174,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2009 });2174 });
20102175
2011 const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")(2176 const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")(
2012 function* (input: PreviewAutomationEvaluateInput, send: SendCommand) {2177 function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) {
2013 yield* send("Runtime.enable");2178 yield* send("Runtime.enable");
2014 const value = yield* evaluateWithDebugger(2179 const value = yield* evaluateWithDebugger(
2180 tabId,
2015 send,2181 send,
2016 input.expression,2182 input.expression,
2017 input.returnByValue ?? true,2183 input.returnByValue ?? true,
2018 input.awaitPromise ?? true,2184 input.awaitPromise ?? true,
2019 );2185 );
2020 const serialized = yield* encodeJson("automationEvaluate.encodeResult", value);2186 const serialized = yield* encodeJson(
2021 if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) {2187 { operation: "automationEvaluate.encodeResult", tabId },
2022 return yield* fail(2188 value,
2023 "automationEvaluate",2189 );
2024 automationError(2190 const actualBytes = Buffer.byteLength(serialized, "utf8");
2025 "PreviewAutomationResultTooLargeError",2191 if (actualBytes > MAX_EVALUATION_BYTES) {
2026 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,2192 return yield* new PreviewAutomationResultTooLargeError({
2027 { maximumBytes: MAX_EVALUATION_BYTES },2193 tabId,
2028 ),2194 actualBytes,
2029 );2195 maximumBytes: MAX_EVALUATION_BYTES,
2196 });
2030 }2197 }
2031 return value;2198 return value;
2032 },2199 },
...@@ -2038,23 +2205,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2038,23 +2205,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2038 ) {2205 ) {
2039 const wc = yield* requireWebContents(tabId);2206 const wc = yield* requireWebContents(tabId);
2040 return yield* withControlSession(tabId, wc, "evaluate", (send) =>2207 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
2041 performAutomationEvaluate(input, send),2208 performAutomationEvaluate(tabId, input, send),
2042 );2209 );
2043 });2210 });
20442211
2045 const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* (2212 const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* (
2213 tabId: string,
2046 input: PreviewAutomationWaitForInput,2214 input: PreviewAutomationWaitForInput,
2047 send: SendCommand,2215 send: SendCommand,
2048 ) {2216 ) {
2049 const timeoutMs = input.timeoutMs ?? 15_000;2217 const timeoutMs = input.timeoutMs ?? 15_000;
2050 yield* send("Runtime.enable");2218 yield* send("Runtime.enable");
2051 const locator = automationLocator(input);2219 const locator = automationLocator(input);
2052 if (locator) yield* ensurePlaywrightInjected(send);2220 if (locator) yield* ensurePlaywrightInjected(tabId, send);
2053 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([2221 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
2054 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),2222 locator
2055 input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null),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),
2056 input.urlIncludes2228 input.urlIncludes
2057 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)2229 ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes)
2058 : Effect.succeed(null),2230 : Effect.succeed(null),
2059 ]);2231 ]);
2060 const deadline = (yield* currentMillis) + timeoutMs;2232 const deadline = (yield* currentMillis) + timeoutMs;
...@@ -2062,6 +2234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2062,6 +2234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2062 const result = yield* evaluateWithDebugger<2234 const result = yield* evaluateWithDebugger<
2063 { matched: boolean } | { invalidSelector: true; message: string }2235 { matched: boolean } | { invalidSelector: true; message: string }
2064 >(2236 >(
2237 tabId,
2065 send,2238 send,
2066 `(() => {2239 `(() => {
2067 try {2240 try {
...@@ -2080,23 +2253,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2080,23 +2253,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2080 true,2253 true,
2081 );2254 );
2082 if ("invalidSelector" in result) {2255 if ("invalidSelector" in result) {
2083 return yield* fail(2256 return yield* new PreviewAutomationInvalidSelectorError({
2084 "automationWaitFor",2257 operation: "waitFor",
2085 automationError("PreviewAutomationInvalidSelectorError", result.message, {2258 tabId,
2086 selector: input.selector ?? "",2259 ...automationSelectorDiagnostics(input),
2087 }),2260 reasonLength: result.message.length,
2088 );2261 cause: result,
2262 });
2089 }2263 }
2090 if (result.matched) return;2264 if (result.matched) return;
2091 yield* Effect.sleep(100);2265 yield* Effect.sleep(100);
2092 }2266 }
2093 return yield* fail(2267 return yield* new PreviewAutomationTimeoutError({
2094 "automationWaitFor",2268 tabId,
2095 automationError(2269 timeoutMs,
2096 "PreviewAutomationTimeoutError",2270 });
2097 `Preview condition did not match within ${timeoutMs}ms.`,
2098 ),
2099 );
2100 });2271 });
21012272
2102 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (2273 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
...@@ -2105,7 +2276,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2105,7 +2276,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2105 ) {2276 ) {
2106 const wc = yield* requireWebContents(tabId);2277 const wc = yield* requireWebContents(tabId);
2107 yield* withControlSession(tabId, wc, "waitFor", (send) =>2278 yield* withControlSession(tabId, wc, "waitFor", (send) =>
2108 performAutomationWaitFor(input, send),2279 performAutomationWaitFor(tabId, input, send),
2109 );2280 );
2110 });2281 });
21112282
...@@ -2113,23 +2284,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2113,23 +2284,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2113 artifactPath: string,2284 artifactPath: string,
2114 ) {2285 ) {
2115 const resolvedPath = yield* resolveArtifactPath(artifactPath);2286 const resolvedPath = yield* resolveArtifactPath(artifactPath);
2116 yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath));2287 yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () =>
2288 shell.showItemInFolder(resolvedPath),
2289 );
2117 });2290 });
21182291
2119 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (2292 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
2120 artifactPath: string,2293 artifactPath: string,
2121 ) {2294 ) {
2122 const resolvedPath = yield* resolveArtifactPath(artifactPath);2295 const resolvedPath = yield* resolveArtifactPath(artifactPath);
2123 const image = yield* attempt("copyArtifactToClipboard.load", () =>2296 const image = yield* attempt(
2124 nativeImage.createFromPath(resolvedPath),2297 { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath },
2298 () => nativeImage.createFromPath(resolvedPath),
2125 );2299 );
2126 if (image.isEmpty()) {2300 if (image.isEmpty()) {
2127 return yield* fail(2301 return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
2128 "copyArtifactToClipboard",
2129 new Error("Preview artifact could not be loaded as an image."),
2130 );
2131 }2302 }
2132 yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image));2303 yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () =>
2304 clipboard.writeImage(image),
2305 );
2133 });2306 });
21342307
2135 const subscribe = <A>(2308 const subscribe = <A>(
...@@ -2228,19 +2401,234 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass<P...@@ -2228,19 +2401,234 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass<P
2228 }2401 }
2229}2402}
22302403
2231export class PreviewManagerError extends Schema.TaggedErrorClass<PreviewManagerError>()(2404export class PreviewOperationError extends Schema.TaggedErrorClass<PreviewOperationError>()(
2232 "PreviewManagerError",2405 "PreviewOperationError",
2233 {2406 {
2234 operation: Schema.String,2407 operation: Schema.String,
2408 tabId: Schema.optional(Schema.String),
2409 webContentsId: Schema.optional(Schema.Number),
2410 artifactPath: Schema.optional(Schema.String),
2235 cause: Schema.Defect(),2411 cause: Schema.Defect(),
2236 },2412 },
2237) {2413) {
2414 static toTimelineMessage(error: PreviewOperationError): string {
2415 return error.cause instanceof Error ? error.cause.message : String(error.cause);
2416 }
2417
2238 override get message(): string {2418 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(", ")})`}`;
2240 }2425 }
2241}2426}
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
2245export class PreviewManager extends Context.Service<2633export class PreviewManager extends Context.Service<
2246 PreviewManager,2634 PreviewManager,
...@@ -2329,16 +2717,17 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -2329,16 +2717,17 @@ export const make = Effect.gen(function* PreviewManagerMake() {
2329 const environment = yield* DesktopEnvironment.DesktopEnvironment;2717 const environment = yield* DesktopEnvironment.DesktopEnvironment;
2330 const browserSession = yield* BrowserSession.BrowserSession;2718 const browserSession = yield* BrowserSession.BrowserSession;
2331 const operations = yield* makeNativeOperations(environment.browserArtifactsDir);2719 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
2338 return PreviewManager.of({2721 return PreviewManager.of({
2339 setMainWindow: operations.setMainWindow,2722 setMainWindow: operations.setMainWindow,
2340 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {2723 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 );
2342 }),2731 }),
2343 isBrowserPartition: browserSession.isPartition,2732 isBrowserPartition: browserSession.isPartition,
2344 createTab: operations.createTab,2733 createTab: operations.createTab,
...@@ -2354,13 +2743,29 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -2354,13 +2743,29 @@ export const make = Effect.gen(function* PreviewManagerMake() {
2354 hardReload: operations.hardReload,2743 hardReload: operations.hardReload,
2355 openDevTools: operations.openDevTools,2744 openDevTools: operations.openDevTools,
2356 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {2745 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 );
2358 }),2753 }),
2359 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {2754 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 );
2361 }),2760 }),
2362 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {2761 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 );
2364 }),2769 }),
2365 setAnnotationTheme: operations.setAnnotationTheme,2770 setAnnotationTheme: operations.setAnnotationTheme,
2366 pickElement: operations.pickElement,2771 pickElement: operations.pickElement,
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts+54
...@@ -3,10 +3,14 @@ import * as Effect from "effect/Effect";...@@ -3,10 +3,14 @@ import * as Effect from "effect/Effect";
3import { describe, expect } from "vite-plus/test";3import { describe, expect } from "vite-plus/test";
44
5import {5import {
6 extractPlaywrightInjectedRuntimeSource,
6 playwrightInjectedRuntimeInstallExpression,7 playwrightInjectedRuntimeInstallExpression,
7 playwrightInjectedRuntimeSource,8 playwrightInjectedRuntimeSource,
8} from "./PlaywrightInjectedRuntime.ts";9} from "./PlaywrightInjectedRuntime.ts";
910
11const bundleWithSourceLiteral = (literal: string): string =>
12 `const source3 = ${literal};\n }\n});`;
13
10describe("playwright injected runtime", () => {14describe("playwright injected runtime", () => {
11 effectIt.effect("extracts the pinned runtime from playwright-core", () =>15 effectIt.effect("extracts the pinned runtime from playwright-core", () =>
12 Effect.gen(function* () {16 Effect.gen(function* () {
...@@ -23,4 +27,54 @@ describe("playwright injected runtime", () => {...@@ -23,4 +27,54 @@ describe("playwright injected runtime", () => {
23 expect(expression).toContain('testIdAttributeName":"data-testid');27 expect(expression).toContain('testIdAttributeName":"data-testid');
24 }),28 }),
25 );29 );
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 );
26});80});
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts+170-50
...@@ -4,69 +4,180 @@ import * as NodeModule from "node:module";...@@ -4,69 +4,180 @@ import * as NodeModule from "node:module";
4import * as NodePath from "node:path";4import * as NodePath from "node:path";
5import * as NodeVM from "node:vm";5import * as NodeVM from "node:vm";
66
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
109
11const require = NodeModule.createRequire(import.meta.url);10const require = NodeModule.createRequire(import.meta.url);
12const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);11const 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(20export class PlaywrightPackageResolveError extends Schema.TaggedErrorClass<PlaywrightPackageResolveError>()(
15 "PlaywrightInjectedRuntimeError",21 "PlaywrightPackageResolveError",
16)<{22 {
17 readonly operation: string;23 specifier: Schema.String,
18 readonly cause: unknown;24 cause: Schema.Defect(),
19}> {25 },
20 override get message() {26) {
21 return `Playwright injected runtime operation failed: ${this.operation}`;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`;
22 }78 }
23}79}
2480
25const fail = (operation: string, cause: unknown) =>81export class PlaywrightSourceValidationError extends Schema.TaggedErrorClass<PlaywrightSourceValidationError>()(
26 new PlaywrightInjectedRuntimeError({ operation, cause });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
28export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")(165export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")(
29 function* () {166 function* () {
30 const packageJsonPath = yield* Effect.try({167 const packageJsonPath = yield* Effect.try({
31 try: () => require.resolve("playwright-core/package.json"),168 try: () => require.resolve(PLAYWRIGHT_PACKAGE_SPECIFIER),
32 catch: (cause) => fail("resolvePackage", cause),169 catch: (cause) =>
170 new PlaywrightPackageResolveError({
171 specifier: PLAYWRIGHT_PACKAGE_SPECIFIER,
172 cause,
173 }),
33 });174 });
175 const bundlePath = NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js");
34 const coreBundle = yield* Effect.tryPromise({176 const coreBundle = yield* Effect.tryPromise({
35 try: () =>177 try: () => NodeFSP.readFile(bundlePath, "utf8"),
36 NodeFSP.readFile(178 catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }),
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),
62 });179 });
63 if (typeof source !== "string" || source.length < 100_000) {180 return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath);
64 return yield* fail(
65 "validateSource",
66 new Error("Playwright injected runtime extraction returned invalid source."),
67 );
68 }
69 return source;
70 },181 },
71);182);
72183
...@@ -76,14 +187,23 @@ export const playwrightInjectedRuntimeInstallExpression = Effect.fn(...@@ -76,14 +187,23 @@ export const playwrightInjectedRuntimeInstallExpression = Effect.fn(
76 const source = yield* playwrightInjectedRuntimeSource();187 const source = yield* playwrightInjectedRuntimeSource();
77 const options = yield* encodeUnknownJson({188 const options = yield* encodeUnknownJson({
78 isUnderTest: false,189 isUnderTest: false,
79 sdkLanguage: "javascript",190 sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE,
80 testIdAttributeName: "data-testid",191 testIdAttributeName: "data-testid",
81 stableRafCount: 1,192 stableRafCount: 1,
82 browserName: "chromium",193 browserName: PLAYWRIGHT_BROWSER_NAME,
83 shouldPrependErrorPrefix: false,194 shouldPrependErrorPrefix: false,
84 isUtilityWorld: false,195 isUtilityWorld: false,
85 customEngines: [],196 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 );
87 return `(() => {207 return `(() => {
88 if (globalThis.__t3PlaywrightInjected) return true;208 if (globalThis.__t3PlaywrightInjected) return true;
89 const module = { exports: {} };209 const module = { exports: {} };