diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 81b98f4f4e8..cc83d5f2e37 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -7,9 +7,10 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import { TestClock } from "effect/testing"; -import { beforeEach, describe, expect, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as BrowserSession from "./BrowserSession.ts"; @@ -17,7 +18,7 @@ import * as PreviewManager from "./Manager.ts"; const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = vi.hoisted(() => ({ - createFromPath: vi.fn(() => ({ isEmpty: () => false })), + createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), fromId: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -79,6 +80,7 @@ const layer = PreviewManager.layer.pipe( Layer.provideMerge(fileSystemLayer), Layer.provideMerge(Path.layer), ); +const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); const withManager = ( use: ( @@ -237,6 +239,20 @@ describe("PreviewManager", () => { expect(artifact.path).toMatch( /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, ); + + const captureCause = new Error("capture failed"); + capturePage.mockRejectedValueOnce(captureCause); + const exit = yield* Effect.exit(manager.captureScreenshot("tab_1")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "captureScreenshot.capturePage", + tabId: "tab_1", + webContentsId: 42, + cause: captureCause, + }); }), ), ); @@ -302,9 +318,12 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - message: "Preview artifact path is outside the configured artifact directory.", + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", }); + expect("cause" in error).toBe(false); }), ), ); @@ -324,8 +343,20 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - message: "Preview artifact path is outside the configured artifact directory.", + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", + }); + expect("cause" in error).toBe(false); + + createFromPath.mockReturnValueOnce({ isEmpty: () => true }); + const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); + expect(Exit.isFailure(invalidImageExit)).toBe(true); + if (Exit.isSuccess(invalidImageExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({ + _tag: "PreviewArtifactImageLoadError", + artifactPath, }); }), ), @@ -466,10 +497,174 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - name: "PreviewAutomationControlInterruptedError", + expect(error).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + operation: "click", + tabId: "tab_1", + webContentsId: 42, }); + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.name).toBe("PreviewAutomationControlInterruptedError"); + } + expect("cause" in error).toBe(false); }), ), ); + + effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () => + withManager((manager) => + Effect.gen(function* () { + const text = "ReferenceError: fallbackDetail is not defined"; + const exceptionDetails = { + text, + exception: { description: "" }, + }; + const sendCommand = vi.fn(async (method: string) => + method === "Runtime.evaluate" ? { exceptionDetails } : undefined, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const exit = yield* Effect.exit( + manager.automationEvaluate("tab_1", { expression: "fallbackDetail" }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewAutomationEvaluationError", + detailKind: "exception-text", + detailLength: text.length, + cause: exceptionDetails, + }); + }), + ), + ); +}); + +describe("PreviewOperationError", () => { + it("keeps timeline detail separate from its structured message", () => { + const cause = new Error("CDP command failed with an invalid node id"); + const error = new PreviewManager.PreviewOperationError({ + operation: "click.DOM.resolveNode", + tabId: "tab_1", + webContentsId: 42, + cause, + }); + + expect(error.message).not.toContain(cause.message); + expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); + }); +}); + +describe("Preview automation diagnostics", () => { + it("keeps browser exception detail out of structural diagnostics", () => { + const secret = "unrelated-browser-payload-secret"; + const detail = "ReferenceError: missingValue is not defined"; + const cause = { + text: "Uncaught Error", + exception: { description: detail }, + unsafePayload: secret, + }; + const error = new PreviewManager.PreviewAutomationEvaluationError({ + tabId: "tab_1", + detailKind: "exception-description", + detailLength: detail.length, + cause, + }); + + const encoded = encodePreviewManagerError(error); + const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { + readonly cause?: unknown; + }; + + expect(error.cause).toBe(cause); + expect(encodedCause).toStrictEqual(cause); + expect(error.message).toBe("Preview JavaScript evaluation failed in tab tab_1"); + expect(error.message).not.toContain(secret); + expect(JSON.stringify(encodedDiagnostics)).not.toContain(secret); + expect("detail" in error).toBe(false); + expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).toBe(detail); + expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).not.toContain( + secret, + ); + }); + + it("retains bounded selector diagnostics without exposing selector or reason text", () => { + const selector = "role=button[name='selector-secret']"; + const reason = "Unexpected token near reason-secret"; + const cause = { invalidSelector: true as const, message: reason }; + const error = new PreviewManager.PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId: "tab_1", + selectorKind: "locator", + selectorLength: selector.length, + reasonLength: reason.length, + cause, + }); + + const encoded = encodePreviewManagerError(error); + const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { + readonly cause?: unknown; + }; + + expect(error.cause).toBe(cause); + expect(encodedCause).toStrictEqual(cause); + expect(error).toMatchObject({ + selectorKind: "locator", + selectorLength: selector.length, + reasonLength: reason.length, + }); + expect(error.detail).toEqual({ + selectorKind: "locator", + selectorLength: selector.length, + }); + expect(error.message).not.toContain("secret"); + expect(JSON.stringify(encodedDiagnostics)).not.toContain("secret"); + expect("selector" in error).toBe(false); + expect("reason" in error).toBe(false); + expect(PreviewManager.PreviewAutomationInvalidSelectorError.toTimelineMessage(error)).toBe( + reason, + ); + }); + + it("does not retain a missing target locator", () => { + const selector = "[data-token='target-secret']"; + const error = new PreviewManager.PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId: "tab_1", + selectorKind: "selector", + selectorLength: selector.length, + }); + + expect(error.message).not.toContain(selector); + expect(JSON.stringify(error)).not.toContain(selector); + expect("locator" in error).toBe(false); + }); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6d25fc9b2c0..bb3e1fcef93 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -148,22 +148,58 @@ interface CdpEvaluationResult { }; } -const automationError = ( - tag: - | "PreviewAutomationExecutionError" - | "PreviewAutomationInvalidSelectorError" - | "PreviewAutomationResultTooLargeError" - | "PreviewAutomationTimeoutError" - | "PreviewAutomationControlInterruptedError", - message: string, - detail?: unknown, -): Error & { detail?: unknown } => { - const error = new Error(message) as Error & { detail?: unknown }; - error.name = tag; - if (detail !== undefined) error.detail = detail; - return error; +export const PreviewAutomationSelectorKind = Schema.Literals([ + "focused-element", + "selector", + "locator", +]); +export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; + +export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ + "exception-description", + "exception-text", + "unknown", +]); +export type PreviewAutomationEvaluationDetailKind = + typeof PreviewAutomationEvaluationDetailKind.Type; + +const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => { + if (typeof exceptionDetails !== "object" || exceptionDetails === null) { + return { detailKind: "unknown" as const }; + } + const details = exceptionDetails as Record; + const exception = details["exception"]; + const description = + typeof exception === "object" && + exception !== null && + typeof (exception as Record)["description"] === "string" + ? (exception as Record)["description"] + : undefined; + if (typeof description === "string" && description.length > 0) { + return { detailKind: "exception-description" as const, detail: description }; + } + const text = details["text"]; + if (typeof text === "string" && text.length > 0) { + return { detailKind: "exception-text" as const, detail: text }; + } + return { detailKind: "unknown" as const }; }; +const previewAutomationTargetLabel = ( + selectorKind: PreviewAutomationSelectorKind, + selectorLength?: number, +) => + selectorKind === "focused-element" + ? "the focused element" + : `${selectorKind} (${selectorLength ?? 0} characters)`; + +interface PreviewOperationContext { + readonly operation: string; + readonly tabId?: string; + readonly webContentsId?: number; + readonly artifactPath?: string; +} + const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; const rect = value as Record; @@ -194,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; const captureAnnotationScreenshot = ( + tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect => @@ -209,7 +246,13 @@ const captureAnnotationScreenshot = ( } : undefined, ), - catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }), + catch: (cause) => + new PreviewOperationError({ + operation: "captureAnnotationScreenshot", + tabId, + webContentsId: wc.id, + cause, + }), }).pipe( Effect.map((image) => { const size = image.getSize(); @@ -341,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); const playwrightInstallExpression = yield* Effect.cached( - playwrightInjectedRuntimeInstallExpression().pipe( - Effect.mapError( - (cause) => - new PreviewManagerError({ - operation: "ensurePlaywrightInjected", - cause, - }), - ), - ), + playwrightInjectedRuntimeInstallExpression(), ); const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); @@ -377,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pointerSequenceRef = yield* Ref.make(0); const recordingTabIdRef = yield* Ref.make>(Option.none()); - const fail = (operation: string, cause: unknown): PreviewManagerError => - new PreviewManagerError({ operation, cause }); - const attempt = (operation: string, evaluate: () => A) => - Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); - const attemptPromise = (operation: string, evaluate: () => PromiseLike) => - Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => + Effect.try({ + try: evaluate, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); + const attemptPromise = ( + errorContext: PreviewOperationContext, + evaluate: () => PromiseLike, + ) => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; - const encodeJson = (operation: string, value: unknown) => - encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause))); + const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => + encodeUnknownJson(value).pipe( + Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })), + ); const nextCounter = (ref: Ref.Ref) => Ref.modify(ref, (value) => [value, value + 1] as const); const replaceMap = ( @@ -432,23 +476,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const tabs = yield* SynchronizedRef.get(tabsRef); const tab = tabs.get(tabId); if (!tab) { - return yield* fail("requireWebContents", new PreviewTabNotFoundError({ tabId })); + return yield* new PreviewTabNotFoundError({ tabId }); } if (tab.webContentsId == null) { - return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError({ tabId })); + return yield* new PreviewWebviewNotInitializedError({ tabId }); } const wc = webContents.fromId(tab.webContentsId); if (!wc) { - return yield* fail( - "requireWebContents", - new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId }), - ); + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: tab.webContentsId, + }); } return wc; }); const resolveArtifactPath = (artifactPath: string) => - attempt("resolveArtifactPath", () => { + attempt({ operation: "resolveArtifactPath", artifactPath }, () => { const resolvedPath = path.resolve(artifactPath); const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); if ( @@ -464,10 +508,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.flatMap((resolvedPath) => resolvedPath === null ? Effect.fail( - fail( - "resolveArtifactPath", - new Error("Preview artifact path is outside the configured artifact directory."), - ), + new PreviewArtifactPathOutsideDirectoryError({ + artifactPath, + artifactDirectory: resolvedArtifactDirectory, + }), ) : Effect.succeed(resolvedPath), ), @@ -636,129 +680,137 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( wc: Electron.WebContents, ) { - return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { - const existing = sessions.get(wc.id); - if (existing) return Effect.succeed([existing, sessions] as const); - if (wc.isDevToolsOpened()) { - return Effect.fail( - fail( - "ensureControlSession", - automationError( - "PreviewAutomationExecutionError", - "Close preview DevTools before using agent browser control.", - ), - ), - ); - } - if (wc.debugger.isAttached()) { - return Effect.fail( - fail( - "ensureControlSession", - automationError( - "PreviewAutomationExecutionError", - "Preview control cannot attach because another debugger owns this page.", - ), - ), - ); - } - const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { - const semaphore = yield* Semaphore.make(1); - const scope = yield* Scope.fork(parentScope, "sequential"); - const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( - method: string, - params: Record, - ) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise("ackScreencastFrame", () => - wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), - { discard: true }, - ); - } - } - yield* captureDiagnosticMessage(wc.id, method, params); - }); - const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - runFork(handleDebuggerMessage(method, params)); - }; - yield* Scope.addFinalizer( - scope, - Effect.all( - [ - Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.delete(wc.id); - }), - ), - attempt("detachControlSession", () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); - }).pipe(Effect.ignore), - ], - { discard: true }, - ), - ); - const control: BrowserControlSession = { - webContentsId: wc.id, - semaphore, - scope, - onMessage, - }; - const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { - yield* Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.set(wc.id, { - consoleEntries: [], - networkEntries: [], - requests: new Map(), - }); + return yield* SynchronizedRef.modifyEffect( + controlSessionsRef, + ( + sessions, + ): Effect.Effect< + readonly [BrowserControlSession, ReadonlyMap], + PreviewManagerError + > => { + const existing = sessions.get(wc.id); + if (existing) return Effect.succeed([existing, sessions] as const); + if (wc.isDevToolsOpened()) { + return Effect.fail( + new PreviewAutomationDevToolsOpenError({ + webContentsId: wc.id, }), ); - yield* attempt("attachDebuggerListeners", () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); - }); - yield* Effect.all( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( - (method) => - attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), + } + if (wc.debugger.isAttached()) { + return Effect.fail( + new PreviewAutomationDebuggerAttachedError({ + webContentsId: wc.id, + }), + ); + } + const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { + const semaphore = yield* Semaphore.make(1); + const scope = yield* Scope.fork(parentScope, "sequential"); + const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( + function* (method: string, params: Record) { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise( + { + operation: "ackScreencastFrame", + webContentsId: wc.id, + }, + () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record) + : {}; + if (tabId && typeof params["data"] === "string") { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: + typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: + typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + { discard: true }, + ); + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }, + ); + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { + runFork(handleDebuggerMessage(method, params)); + }; + yield* Scope.addFinalizer( + scope, + Effect.all( + [ + Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(wc.id); + }), + ), + attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { + wc.debugger.off("message", onMessage); + if (wc.debugger.isAttached()) wc.debugger.detach(); + }).pipe(Effect.ignore), + ], + { discard: true }, ), - { concurrency: "unbounded", discard: true }, ); - return [ - control, - replaceMap(sessions, (copy) => { - copy.set(wc.id, control); - }), - ] as const; + const control: BrowserControlSession = { + webContentsId: wc.id, + semaphore, + scope, + onMessage, + }; + const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.set(wc.id, { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }); + }), + ); + yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { + wc.debugger.on("message", onMessage); + wc.debugger.attach("1.3"); + }); + yield* Effect.all( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wc.debugger.sendCommand(method), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return [ + control, + replaceMap(sessions, (copy) => { + copy.set(wc.id, control); + }), + ] as const; + }); + return yield* initialize().pipe( + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); }); - return yield* initialize().pipe( - Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), - ); - }); - return createControlSession(); - }); + return createControlSession(); + }, + ); }); const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => @@ -808,26 +860,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function function* (method, commandParams) { const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (before !== epoch) { - return yield* fail( - action, - automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ), - ); + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); } - const result = yield* attemptPromise(action, () => - wc.debugger.sendCommand(method, commandParams), + const result = yield* attemptPromise( + { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, + () => wc.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { - return yield* fail( - action, - automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ), - ); + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); } return result; }, @@ -846,15 +895,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); } else { const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); - const underlying = isPreviewManagerError(error) ? error.cause : error; - const interrupted = - underlying instanceof Error && - underlying.name === "PreviewAutomationControlInterruptedError"; + const interrupted = isPreviewAutomationControlInterruptedError(error); + const errorMessage = isPreviewOperationError(error) + ? PreviewOperationError.toTimelineMessage(error) + : isPreviewAutomationEvaluationError(error) + ? PreviewAutomationEvaluationError.toTimelineMessage(error) + : isPreviewAutomationInvalidSelectorError(error) + ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error) + : error instanceof Error + ? error.message + : String(error); yield* replaceAction(tabId, { ...actionEvent, status: interrupted ? "interrupted" : "failed", completedAt, - error: underlying instanceof Error ? underlying.message : String(underlying), + error: errorMessage, }); } const tabs = yield* SynchronizedRef.get(tabsRef); @@ -864,6 +919,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const evaluateWithDebugger = ( + tabId: string, send: SendCommand, expression: string, returnByValue: boolean, @@ -877,19 +933,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe( Effect.flatMap((rawResponse) => { const response = rawResponse as CdpEvaluationResult; - return response.exceptionDetails - ? Effect.fail( - fail( - "evaluate", - automationError( - "PreviewAutomationExecutionError", - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text ?? - "JavaScript evaluation failed.", - ), - ), - ) - : Effect.succeed(response.result?.value as A); + if (!response.exceptionDetails) { + return Effect.succeed(response.result?.value as A); + } + const detail = previewAutomationEvaluationDetail(response.exceptionDetails); + return Effect.fail( + new PreviewAutomationEvaluationError({ + tabId, + detailKind: detail.detailKind, + detailLength: detail.detail?.length ?? 0, + cause: response.exceptionDetails, + }), + ); }), ); @@ -898,17 +953,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function readonly locator?: string | undefined; }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); + const automationSelectorDiagnostics = (input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): { + readonly selectorKind: PreviewAutomationSelectorKind; + readonly selectorLength?: number; + } => { + if (input.locator !== undefined) { + return { selectorKind: "locator", selectorLength: input.locator.length }; + } + if (input.selector !== undefined) { + return { selectorKind: "selector", selectorLength: input.selector.length }; + } + return { selectorKind: "focused-element" }; + }; + const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( + tabId: string, send: SendCommand, ) { const installed = yield* evaluateWithDebugger( + tabId, send, "Boolean(globalThis.__t3PlaywrightInjected)", true, ); if (installed) return; - const expression = yield* playwrightInstallExpression; - yield* evaluateWithDebugger(send, expression, true); + const expression = yield* playwrightInstallExpression.pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "ensurePlaywrightInjected", + tabId, + cause, + }), + ), + ); + yield* evaluateWithDebugger(tabId, send, expression, true); }); const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( @@ -1060,7 +1142,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* Scope.addFinalizer( scope, - attempt("detachListeners", () => { + attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { wc.off("did-navigate", sync); wc.off("did-navigate-in-page", sync); wc.off("page-title-updated", sync); @@ -1072,7 +1154,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { - yield* attempt("attachListeners", () => { + yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { wc.on("did-navigate", sync); wc.on("did-navigate-in-page", sync); wc.on("page-title-updated", sync); @@ -1081,7 +1163,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.setWindowOpenHandler(({ url }) => { - runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); + runFork( + attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ).pipe(Effect.ignore), + ); return { action: "deny" }; }); wc.on("before-input-event", beforeInput); @@ -1162,7 +1248,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { - return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); + return yield* new PreviewTabNotFoundError({ tabId }); } const wc = webContents.fromId(webContentsId); const mainWindow = yield* Ref.get(mainWindowRef); @@ -1171,15 +1257,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { - return yield* fail( - "registerWebview", - new PreviewWebContentsNotFoundError({ tabId, webContentsId }), - ); + return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { - yield* attempt("registerWebview.sendTheme", () => + yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); return; @@ -1225,16 +1308,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(registration)) { - return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); + return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; yield* emit(tabId, registered); if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - yield* attempt("registerWebview.restoreZoom", () => + yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () => wc.setZoomFactor(registered.zoomFactor), ).pipe(Effect.ignore); } - yield* attempt("registerWebview.sendTheme", () => + yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; @@ -1245,15 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getURL() !== pendingUrl ) { runFork( - attemptPromise("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe( - Effect.ignore, - ), + attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () => + wc.loadURL(pendingUrl), + ).pipe(Effect.ignore), ); } }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { - const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl)); + const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => + normalizePreviewUrl(rawUrl), + ); const updatedAt = yield* currentIso; const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); @@ -1294,10 +1379,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (wc.getURL() === url) { - yield* attempt("navigate.reload", () => wc.reload()); + yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => + wc.reload(), + ); return; } - yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url)); + yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ); }); const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( @@ -1306,7 +1395,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function use: (wc: Electron.WebContents) => void, ) { const wc = yield* requireWebContents(tabId); - yield* attempt(operation, () => use(wc)); + yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); }); const goBack = (tabId: string) => @@ -1324,11 +1413,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); if (wc.isDevToolsOpened()) { - yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus()); + yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () => + wc.devToolsWebContents?.focus(), + ); return; } yield* detachControlSession(wc.id); - yield* attempt("openDevTools", () => { + yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); }); @@ -1348,9 +1439,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = webContents.fromId(tab.webContentsId); return !wc || wc.isDestroyed() ? Effect.void - : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe( - Effect.ignore, - ); + : attempt( + { + operation: "setAnnotationTheme", + tabId: tab.tabId, + webContentsId: tab.webContentsId, + }, + () => wc.send(ANNOTATION_THEME_CHANNEL, theme), + ).pipe(Effect.ignore); }, { discard: true }, ); @@ -1363,7 +1459,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { - yield* attempt("pickElement.cleanup", () => { + yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); @@ -1392,9 +1488,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (activeTab?.webContentsId != null) { const activeWc = webContents.fromId(activeTab.webContentsId); if (activeWc && !activeWc.isDestroyed()) { - yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe( - Effect.ignore, - ); + yield* attempt( + { + operation: "cancelPickElement", + tabId, + webContentsId: activeWc.id, + }, + () => activeWc.send(CANCEL_PICK_CHANNEL), + ).pipe(Effect.ignore); } } resume(Effect.succeed(null)); @@ -1408,15 +1509,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const cropRect = normalizeCaptureRect(args[1]); runFork( - captureAnnotationScreenshot(wc, cropRect).pipe( + captureAnnotationScreenshot(tabId, wc, cropRect).pipe( Effect.matchEffect({ onFailure: () => Effect.sync(() => settle(payload)), onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), }), Effect.ensuring( - attempt("pickElement.captureComplete", () => { - if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); - }).pipe(Effect.ignore), + attempt( + { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, + () => { + if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + }, + ).pipe(Effect.ignore), ), ), ); @@ -1431,7 +1535,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - yield* attempt("pickElement.register", () => { + yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); wc.once("did-start-navigation", onNavigated); @@ -1468,7 +1572,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.webContentsId != null) { const wc = webContents.fromId(tab.webContentsId); if (wc && !wc.isDestroyed()) { - yield* attempt("applyZoom", () => wc.setZoomFactor(next)); + yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(next), + ); } } yield* update(tabId, { zoomFactor: next }); @@ -1481,17 +1587,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()), + attemptPromise( + { + operation: "captureScreenshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), ]); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); const data = image.toPNG(); - yield* fileSystem - .makeDirectory(resolvedArtifactDirectory, { recursive: true }) - .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause))); - yield* fileSystem - .writeFile(artifactPath, data) - .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause))); + yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "captureScreenshot.makeDirectory", + tabId, + webContentsId: wc.id, + artifactPath, + cause, + }), + ), + ); + yield* fileSystem.writeFile(artifactPath, data).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "captureScreenshot.writeFile", + tabId, + webContentsId: wc.id, + artifactPath, + cause, + }), + ), + ); return { id, tabId, @@ -1518,10 +1649,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { const recordingTabId = yield* Ref.get(recordingTabIdRef); if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { - return yield* fail( - "startRecording", - new Error("Only one browser recording can be active per window."), - ); + return yield* new PreviewRecordingAlreadyActiveError({ + requestedTabId: tabId, + activeTabId: recordingTabId.value, + }); } const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "recording.start", startScreencast); @@ -1547,12 +1678,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const id = `browser-recording-${millis.toString(36)}`; const extension = mimeType.includes("mp4") ? "mp4" : "webm"; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); - yield* fileSystem - .makeDirectory(resolvedArtifactDirectory, { recursive: true }) - .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause))); - yield* fileSystem - .writeFile(artifactPath, data) - .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause))); + yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "saveRecording.makeDirectory", + tabId, + artifactPath, + cause, + }), + ), + ); + yield* fileSystem.writeFile(artifactPath, data).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "saveRecording.writeFile", + tabId, + artifactPath, + cause, + }), + ), + ); return { id, tabId, @@ -1609,6 +1756,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function visibleText: string; interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; }>( + tabId, send, `(() => { const selectorFor = (element) => { @@ -1665,7 +1813,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), + attemptPromise( + { + operation: "automationSnapshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), ]); @@ -1702,6 +1857,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( + tabId: string, send: SendCommand, input: PreviewAutomationClickInput, ) { @@ -1709,11 +1865,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { x: input.x!, y: input.y! }; } const locator = automationLocator(input)!; - yield* ensurePlaywrightInjected(send); - const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator); + yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = yield* encodeJson( + { operation: "automationClick.encodeLocator", tabId }, + locator, + ); const point = yield* evaluateWithDebugger< { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1734,21 +1894,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in point) { - return yield* fail( - "automationClick", - automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: locator, - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: point.message.length, + cause: point, + }); } if ("notFound" in point) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + }); } return point; }); @@ -1773,20 +1932,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], { concurrency: 2, discard: true }, ); - const point = yield* resolveClickPoint(send, input); + const point = yield* resolveClickPoint(tabId, send, input); const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( + tabId, send, "({ width: window.innerWidth, height: window.innerHeight })", true, ); if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, - ), - ); + return yield* new PreviewAutomationCoordinatesOutsideViewportError({ + tabId, + x: point.x, + y: point.y, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }); } const moveSequence = yield* nextCounter(pointerSequenceRef); const moveCreatedAt = yield* currentIso; @@ -1834,15 +1994,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + tabId: string, send: SendCommand, input: PreviewAutomationTypeInput, ) { const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); - const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null; + if (locator) yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = locator + ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) + : null; const result = yield* evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1862,23 +2026,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationType", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if ("notFound" in result) { - return yield* fail( - "automationType", - automationError( - "PreviewAutomationExecutionError", - locator - ? `No element matches locator ${locator}.` - : "No element is focused in the preview.", - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + }); } }); @@ -1888,10 +2049,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function send: SendCommand, ) { yield* send("Runtime.enable"); - yield* focusAutomationTarget(send, input); + yield* focusAutomationTarget(tabId, send, input); yield* send("Input.insertText", { text: input.text }); - const textJson = yield* encodeJson("automationType.encodeText", input.text); + const textJson = yield* encodeJson( + { operation: "automationType.encodeText", tabId }, + input.text, + ); yield* evaluateWithDebugger( + tabId, send, `(() => { const element = document.activeElement; @@ -1959,13 +2124,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); + if (locator) yield* ensurePlaywrightInjected(tabId, send); const locatorJson = locator - ? yield* encodeJson("automationScroll.encodeLocator", locator) + ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator) : null; const result = yield* evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1980,21 +2146,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationScroll", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if ("notFound" in result) { - return yield* fail( - "automationScroll", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + }); } }); @@ -2009,24 +2174,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( - function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { + function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) { yield* send("Runtime.enable"); const value = yield* evaluateWithDebugger( + tabId, send, input.expression, input.returnByValue ?? true, input.awaitPromise ?? true, ); - const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); - if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { - return yield* fail( - "automationEvaluate", - automationError( - "PreviewAutomationResultTooLargeError", - `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, - { maximumBytes: MAX_EVALUATION_BYTES }, - ), - ); + const serialized = yield* encodeJson( + { operation: "automationEvaluate.encodeResult", tabId }, + value, + ); + const actualBytes = Buffer.byteLength(serialized, "utf8"); + if (actualBytes > MAX_EVALUATION_BYTES) { + return yield* new PreviewAutomationResultTooLargeError({ + tabId, + actualBytes, + maximumBytes: MAX_EVALUATION_BYTES, + }); } return value; }, @@ -2038,23 +2205,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); return yield* withControlSession(tabId, wc, "evaluate", (send) => - performAutomationEvaluate(input, send), + performAutomationEvaluate(tabId, input, send), ); }); const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( + tabId: string, input: PreviewAutomationWaitForInput, send: SendCommand, ) { const timeoutMs = input.timeoutMs ?? 15_000; yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); + if (locator) yield* ensurePlaywrightInjected(tabId, send); const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ - locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), - input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), + locator + ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator) + : Effect.succeed(null), + input.text + ? encodeJson({ operation: "automationWaitFor.encodeText", tabId }, input.text) + : Effect.succeed(null), input.urlIncludes - ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) + ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes) : Effect.succeed(null), ]); const deadline = (yield* currentMillis) + timeoutMs; @@ -2062,6 +2234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const result = yield* evaluateWithDebugger< { matched: boolean } | { invalidSelector: true; message: string } >( + tabId, send, `(() => { try { @@ -2080,23 +2253,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationWaitFor", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "waitFor", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if (result.matched) return; yield* Effect.sleep(100); } - return yield* fail( - "automationWaitFor", - automationError( - "PreviewAutomationTimeoutError", - `Preview condition did not match within ${timeoutMs}ms.`, - ), - ); + return yield* new PreviewAutomationTimeoutError({ + tabId, + timeoutMs, + }); }); const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( @@ -2105,7 +2276,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "waitFor", (send) => - performAutomationWaitFor(input, send), + performAutomationWaitFor(tabId, input, send), ); }); @@ -2113,23 +2284,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); - yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath)); + yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () => + shell.showItemInFolder(resolvedPath), + ); }); const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); - const image = yield* attempt("copyArtifactToClipboard.load", () => - nativeImage.createFromPath(resolvedPath), + const image = yield* attempt( + { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath }, + () => nativeImage.createFromPath(resolvedPath), ); if (image.isEmpty()) { - return yield* fail( - "copyArtifactToClipboard", - new Error("Preview artifact could not be loaded as an image."), - ); + return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); } - yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image)); + yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () => + clipboard.writeImage(image), + ); }); const subscribe = ( @@ -2228,19 +2401,234 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass

()( - "PreviewManagerError", +export class PreviewOperationError extends Schema.TaggedErrorClass()( + "PreviewOperationError", { operation: Schema.String, + tabId: Schema.optional(Schema.String), + webContentsId: Schema.optional(Schema.Number), + artifactPath: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { + static toTimelineMessage(error: PreviewOperationError): string { + return error.cause instanceof Error ? error.cause.message : String(error.cause); + } + override get message(): string { - return `Desktop preview operation failed: ${this.operation}`; + const context = [ + this.tabId === undefined ? undefined : `tab ${this.tabId}`, + this.webContentsId === undefined ? undefined : `WebContents ${this.webContentsId}`, + this.artifactPath === undefined ? undefined : `artifact ${this.artifactPath}`, + ].filter((value): value is string => value !== undefined); + return `Desktop preview operation failed: ${this.operation}${context.length === 0 ? "" : ` (${context.join(", ")})`}`; } } -const isPreviewManagerError = Schema.is(PreviewManagerError); +export const isPreviewOperationError = Schema.is(PreviewOperationError); + +export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedErrorClass()( + "PreviewArtifactPathOutsideDirectoryError", + { + artifactPath: Schema.String, + artifactDirectory: Schema.String, + }, +) { + override get message(): string { + return `Preview artifact path ${this.artifactPath} is outside ${this.artifactDirectory}`; + } +} + +export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass()( + "PreviewArtifactImageLoadError", + { artifactPath: Schema.String }, +) { + override get message(): string { + return `Preview artifact could not be loaded as an image: ${this.artifactPath}`; + } +} + +export class PreviewRecordingAlreadyActiveError extends Schema.TaggedErrorClass()( + "PreviewRecordingAlreadyActiveError", + { + requestedTabId: Schema.String, + activeTabId: Schema.String, + }, +) { + override get message(): string { + return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`; + } +} + +export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass()( + "PreviewAutomationDevToolsOpenError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `Close preview DevTools before using agent browser control for WebContents ${this.webContentsId}`; + } +} + +export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedErrorClass()( + "PreviewAutomationDebuggerAttachedError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `Preview control cannot attach to WebContents ${this.webContentsId} because another debugger owns it`; + } +} + +export class PreviewAutomationEvaluationError extends Schema.TaggedErrorClass()( + "PreviewAutomationEvaluationError", + { + tabId: Schema.String, + detailKind: PreviewAutomationEvaluationDetailKind, + detailLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewAutomationEvaluationError): string { + return previewAutomationEvaluationDetail(error.cause).detail ?? error.message; + } + + override get message(): string { + return `Preview JavaScript evaluation failed in tab ${this.tabId}`; + } +} + +export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetNotFoundError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + }, +) { + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`; + } +} + +export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass()( + "PreviewAutomationCoordinatesOutsideViewportError", + { + tabId: Schema.String, + x: Schema.Number, + y: Schema.Number, + viewportWidth: Schema.Number, + viewportHeight: Schema.Number, + }, +) { + override get message(): string { + return `Click coordinates (${this.x}, ${this.y}) are outside the ${this.viewportWidth}x${this.viewportHeight} preview viewport for tab ${this.tabId}`; + } +} + +export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( + "PreviewAutomationInvalidSelectorError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + reasonLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewAutomationInvalidSelectorError): string { + if (typeof error.cause !== "object" || error.cause === null) return error.message; + const reason = (error.cause as Record)["message"]; + return typeof reason === "string" && reason.length > 0 ? reason : error.message; + } + + get detail(): { + readonly selectorKind: PreviewAutomationSelectorKind; + readonly selectorLength?: number; + } { + return { + selectorKind: this.selectorKind, + ...(this.selectorLength === undefined ? {} : { selectorLength: this.selectorLength }), + }; + } + + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation ${this.operation} rejected ${target} in tab ${this.tabId}`; + } +} + +export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( + "PreviewAutomationResultTooLargeError", + { + tabId: Schema.String, + actualBytes: Schema.Number, + maximumBytes: Schema.Number, + }, +) { + get detail(): { readonly maximumBytes: number } { + return { maximumBytes: this.maximumBytes }; + } + + override get message(): string { + return `Preview evaluation result in tab ${this.tabId} was ${this.actualBytes} bytes; maximum is ${this.maximumBytes} bytes`; + } +} + +export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationTimeoutError", + { + tabId: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Preview condition did not match within ${this.timeoutMs}ms in tab ${this.tabId}`; + } +} + +export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( + "PreviewAutomationControlInterruptedError", + { + operation: Schema.String, + tabId: Schema.String, + webContentsId: Schema.Number, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} was interrupted by human input in tab ${this.tabId}`; + } +} + +export const PreviewManagerError = Schema.Union([ + PreviewTabNotFoundError, + PreviewWebContentsNotFoundError, + PreviewWebviewNotInitializedError, + PreviewOperationError, + PreviewArtifactPathOutsideDirectoryError, + PreviewArtifactImageLoadError, + PreviewRecordingAlreadyActiveError, + PreviewAutomationDevToolsOpenError, + PreviewAutomationDebuggerAttachedError, + PreviewAutomationEvaluationError, + PreviewAutomationTargetNotFoundError, + PreviewAutomationCoordinatesOutsideViewportError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTimeoutError, + PreviewAutomationControlInterruptedError, +]); +export type PreviewManagerError = typeof PreviewManagerError.Type; + +export const isPreviewManagerError = Schema.is(PreviewManagerError); +export const isPreviewAutomationControlInterruptedError = Schema.is( + PreviewAutomationControlInterruptedError, +); +export const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError); +export const isPreviewAutomationInvalidSelectorError = Schema.is( + PreviewAutomationInvalidSelectorError, +); export class PreviewManager extends Context.Service< PreviewManager, @@ -2329,16 +2717,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; const operations = yield* makeNativeOperations(environment.browserArtifactsDir); - const browserSessionEffect = ( - operation: string, - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); return PreviewManager.of({ setMainWindow: operations.setMainWindow, getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { - return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + return yield* browserSession + .getSession(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), + ), + ); }), isBrowserPartition: browserSession.isPartition, createTab: operations.createTab, @@ -2354,13 +2743,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { - yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); + yield* browserSession + .clearCookies() + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), + ), + ); }), clearCache: Effect.fn("PreviewManager.clearCache")(function* () { - yield* browserSessionEffect("clearCache", browserSession.clearCache()); + yield* browserSession + .clearCache() + .pipe( + Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), + ); }), getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { - return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope)); + return yield* browserSession + .getPartition(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), + ), + ); }), setAnnotationTheme: operations.setAnnotationTheme, pickElement: operations.pickElement, diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts index 33915dba0be..cd7fee1e3c7 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -3,10 +3,14 @@ import * as Effect from "effect/Effect"; import { describe, expect } from "vite-plus/test"; import { + extractPlaywrightInjectedRuntimeSource, playwrightInjectedRuntimeInstallExpression, playwrightInjectedRuntimeSource, } from "./PlaywrightInjectedRuntime.ts"; +const bundleWithSourceLiteral = (literal: string): string => + `const source3 = ${literal};\n }\n});`; + describe("playwright injected runtime", () => { effectIt.effect("extracts the pinned runtime from playwright-core", () => Effect.gen(function* () { @@ -23,4 +27,54 @@ describe("playwright injected runtime", () => { expect(expression).toContain('testIdAttributeName":"data-testid'); }), ); + + effectIt.effect("reports a missing source marker without an artificial cause", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource("const source = 'missing';", "/tmp/coreBundle.js"), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceMarkerNotFoundError", + bundlePath: "/tmp/coreBundle.js", + marker: "source3 = ", + }); + expect("cause" in error).toBe(false); + }), + ); + + effectIt.effect("keeps source validation metadata cause-free", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource( + bundleWithSourceLiteral('"short"'), + "/tmp/coreBundle.js", + ), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceValidationError", + bundlePath: "/tmp/coreBundle.js", + actualType: "string", + actualLength: 5, + minimumLength: 100_000, + }); + expect("cause" in error).toBe(false); + }), + ); + + effectIt.effect("preserves the source evaluation cause", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource(bundleWithSourceLiteral("("), "/tmp/coreBundle.js"), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceEvaluationError", + bundlePath: "/tmp/coreBundle.js", + timeoutMs: 1_000, + cause: expect.objectContaining({ name: "SyntaxError" }), + }); + }), + ); }); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index e940ce55906..ff1531f08f3 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -4,69 +4,180 @@ import * as NodeModule from "node:module"; import * as NodePath from "node:path"; import * as NodeVM from "node:vm"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; const require = NodeModule.createRequire(import.meta.url); const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json"; +const PLAYWRIGHT_SOURCE_MARKER = "source3 = "; +const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});"; +const PLAYWRIGHT_SOURCE_MINIMUM_LENGTH = 100_000; +const PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS = 1_000; +const PLAYWRIGHT_SDK_LANGUAGE = "javascript"; +const PLAYWRIGHT_BROWSER_NAME = "chromium"; -export class PlaywrightInjectedRuntimeError extends Data.TaggedError( - "PlaywrightInjectedRuntimeError", -)<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { - return `Playwright injected runtime operation failed: ${this.operation}`; +export class PlaywrightPackageResolveError extends Schema.TaggedErrorClass()( + "PlaywrightPackageResolveError", + { + specifier: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve Playwright package: ${this.specifier}`; + } +} + +export class PlaywrightCoreBundleReadError extends Schema.TaggedErrorClass()( + "PlaywrightCoreBundleReadError", + { + bundlePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read Playwright core bundle: ${this.bundlePath}`; + } +} + +export class PlaywrightSourceMarkerNotFoundError extends Schema.TaggedErrorClass()( + "PlaywrightSourceMarkerNotFoundError", + { + bundlePath: Schema.String, + marker: Schema.String, + }, +) { + override get message(): string { + return `Playwright injected runtime marker ${JSON.stringify(this.marker)} was not found in ${this.bundlePath}`; + } +} + +export class PlaywrightSourceTerminatorNotFoundError extends Schema.TaggedErrorClass()( + "PlaywrightSourceTerminatorNotFoundError", + { + bundlePath: Schema.String, + terminator: Schema.String, + }, +) { + override get message(): string { + return `Playwright injected runtime terminator ${JSON.stringify(this.terminator)} was not found in ${this.bundlePath}`; + } +} + +export class PlaywrightSourceEvaluationError extends Schema.TaggedErrorClass()( + "PlaywrightSourceEvaluationError", + { + bundlePath: Schema.String, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to evaluate the Playwright injected runtime literal from ${this.bundlePath} within ${this.timeoutMs}ms`; } } -const fail = (operation: string, cause: unknown) => - new PlaywrightInjectedRuntimeError({ operation, cause }); +export class PlaywrightSourceValidationError extends Schema.TaggedErrorClass()( + "PlaywrightSourceValidationError", + { + bundlePath: Schema.String, + actualType: Schema.String, + actualLength: Schema.NullOr(Schema.Number), + minimumLength: Schema.Number, + }, +) { + override get message(): string { + const actual = + this.actualLength === null + ? this.actualType + : `${this.actualType} with ${this.actualLength} characters`; + return `Playwright injected runtime from ${this.bundlePath} was ${actual}; expected a string with at least ${this.minimumLength} characters`; + } +} + +export class PlaywrightOptionsEncodeError extends Schema.TaggedErrorClass()( + "PlaywrightOptionsEncodeError", + { + sdkLanguage: Schema.String, + browserName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode ${this.browserName} Playwright injected runtime options for ${this.sdkLanguage}`; + } +} + +export const PlaywrightInjectedRuntimeError = Schema.Union([ + PlaywrightPackageResolveError, + PlaywrightCoreBundleReadError, + PlaywrightSourceMarkerNotFoundError, + PlaywrightSourceTerminatorNotFoundError, + PlaywrightSourceEvaluationError, + PlaywrightSourceValidationError, + PlaywrightOptionsEncodeError, +]); +export type PlaywrightInjectedRuntimeError = typeof PlaywrightInjectedRuntimeError.Type; + +export const extractPlaywrightInjectedRuntimeSource = Effect.fn( + "PlaywrightInjectedRuntime.extractSource", +)(function* (coreBundle: string, bundlePath: string) { + const start = coreBundle.indexOf(PLAYWRIGHT_SOURCE_MARKER); + if (start < 0) { + return yield* new PlaywrightSourceMarkerNotFoundError({ + bundlePath, + marker: PLAYWRIGHT_SOURCE_MARKER, + }); + } + const literalStart = start + PLAYWRIGHT_SOURCE_MARKER.length; + const literalEnd = coreBundle.indexOf(PLAYWRIGHT_SOURCE_TERMINATOR, literalStart); + if (literalEnd < 0) { + return yield* new PlaywrightSourceTerminatorNotFoundError({ + bundlePath, + terminator: PLAYWRIGHT_SOURCE_TERMINATOR, + }); + } + const literal = coreBundle.slice(literalStart, literalEnd); + const source = yield* Effect.try({ + try: () => + NodeVM.runInNewContext(literal, Object.create(null), { + timeout: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS, + }), + catch: (cause) => + new PlaywrightSourceEvaluationError({ + bundlePath, + timeoutMs: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS, + cause, + }), + }); + if (typeof source !== "string" || source.length < PLAYWRIGHT_SOURCE_MINIMUM_LENGTH) { + return yield* new PlaywrightSourceValidationError({ + bundlePath, + actualType: typeof source, + actualLength: typeof source === "string" ? source.length : null, + minimumLength: PLAYWRIGHT_SOURCE_MINIMUM_LENGTH, + }); + } + return source; +}); export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")( function* () { const packageJsonPath = yield* Effect.try({ - try: () => require.resolve("playwright-core/package.json"), - catch: (cause) => fail("resolvePackage", cause), + try: () => require.resolve(PLAYWRIGHT_PACKAGE_SPECIFIER), + catch: (cause) => + new PlaywrightPackageResolveError({ + specifier: PLAYWRIGHT_PACKAGE_SPECIFIER, + cause, + }), }); + const bundlePath = NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"); const coreBundle = yield* Effect.tryPromise({ - try: () => - NodeFSP.readFile( - NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"), - "utf8", - ), - catch: (cause) => fail("readCoreBundle", cause), - }); - const marker = "source3 = "; - const start = coreBundle.indexOf(marker); - if (start < 0) { - return yield* fail( - "findSourceMarker", - new Error("Playwright injected runtime marker was not found."), - ); - } - const literalStart = start + marker.length; - const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); - if (literalEnd < 0) { - return yield* fail( - "findSourceTerminator", - new Error("Playwright injected runtime terminator was not found."), - ); - } - const literal = coreBundle.slice(literalStart, literalEnd); - const source = yield* Effect.try({ - try: () => NodeVM.runInNewContext(literal, Object.create(null), { timeout: 1_000 }), - catch: (cause) => fail("evaluateSourceLiteral", cause), + try: () => NodeFSP.readFile(bundlePath, "utf8"), + catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }), }); - if (typeof source !== "string" || source.length < 100_000) { - return yield* fail( - "validateSource", - new Error("Playwright injected runtime extraction returned invalid source."), - ); - } - return source; + return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath); }, ); @@ -76,14 +187,23 @@ export const playwrightInjectedRuntimeInstallExpression = Effect.fn( const source = yield* playwrightInjectedRuntimeSource(); const options = yield* encodeUnknownJson({ isUnderTest: false, - sdkLanguage: "javascript", + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, testIdAttributeName: "data-testid", stableRafCount: 1, - browserName: "chromium", + browserName: PLAYWRIGHT_BROWSER_NAME, shouldPrependErrorPrefix: false, isUtilityWorld: false, customEngines: [], - }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause))); + }).pipe( + Effect.mapError( + (cause) => + new PlaywrightOptionsEncodeError({ + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, + browserName: PLAYWRIGHT_BROWSER_NAME, + cause, + }), + ), + ); return `(() => { if (globalThis.__t3PlaywrightInjected) return true; const module = { exports: {} };