| ... | ... | @@ -148,22 +148,58 @@ interface CdpEvaluationResult { |
| 148 | 148 | }; |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | | const automationError = ( |
| 152 | | tag: |
| 153 | | | "PreviewAutomationExecutionError" |
| 154 | | | "PreviewAutomationInvalidSelectorError" |
| 155 | | | "PreviewAutomationResultTooLargeError" |
| 156 | | | "PreviewAutomationTimeoutError" |
| 157 | | | "PreviewAutomationControlInterruptedError", |
| 158 | | message: string, |
| 159 | | detail?: unknown, |
| 160 | | ): Error & { detail?: unknown } => { |
| 161 | | const error = new Error(message) as Error & { detail?: unknown }; |
| 162 | | error.name = tag; |
| 163 | | if (detail !== undefined) error.detail = detail; |
| 164 | | return error; |
| 151 | export const PreviewAutomationSelectorKind = Schema.Literals([ |
| 152 | "focused-element", |
| 153 | "selector", |
| 154 | "locator", |
| 155 | ]); |
| 156 | export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; |
| 157 | |
| 158 | export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ |
| 159 | "exception-description", |
| 160 | "exception-text", |
| 161 | "unknown", |
| 162 | ]); |
| 163 | export type PreviewAutomationEvaluationDetailKind = |
| 164 | typeof PreviewAutomationEvaluationDetailKind.Type; |
| 165 | |
| 166 | const 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 | }; |
| 166 | 187 | |
| 188 | const previewAutomationTargetLabel = ( |
| 189 | selectorKind: PreviewAutomationSelectorKind, |
| 190 | selectorLength?: number, |
| 191 | ) => |
| 192 | selectorKind === "focused-element" |
| 193 | ? "the focused element" |
| 194 | : `${selectorKind} (${selectorLength ?? 0} characters)`; |
| 195 | |
| 196 | interface PreviewOperationContext { |
| 197 | readonly operation: string; |
| 198 | readonly tabId?: string; |
| 199 | readonly webContentsId?: number; |
| 200 | readonly artifactPath?: string; |
| 201 | } |
| 202 | |
| 167 | 203 | const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { |
| 168 | 204 | if (typeof value !== "object" || value === null) return null; |
| 169 | 205 | const rect = value as Record<string, unknown>; |
| ... | ... | @@ -194,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { |
| 194 | 230 | }; |
| 195 | 231 | |
| 196 | 232 | const captureAnnotationScreenshot = ( |
| 233 | tabId: string, |
| 197 | 234 | wc: Electron.WebContents, |
| 198 | 235 | cropRect: PreviewAnnotationRect | null, |
| 199 | 236 | ): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> => |
| ... | ... | @@ -209,7 +246,13 @@ const captureAnnotationScreenshot = ( |
| 209 | 246 | } |
| 210 | 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 | 256 | }).pipe( |
| 214 | 257 | Effect.map((image) => { |
| 215 | 258 | const size = image.getSize(); |
| ... | ... | @@ -341,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 341 | 384 | const runFork = Effect.runForkWith(context); |
| 342 | 385 | const resolvedArtifactDirectory = path.resolve(artifactDirectory); |
| 343 | 386 | const playwrightInstallExpression = yield* Effect.cached( |
| 344 | | playwrightInjectedRuntimeInstallExpression().pipe( |
| 345 | | Effect.mapError( |
| 346 | | (cause) => |
| 347 | | new PreviewManagerError({ |
| 348 | | operation: "ensurePlaywrightInjected", |
| 349 | | cause, |
| 350 | | }), |
| 351 | | ), |
| 352 | | ), |
| 387 | playwrightInjectedRuntimeInstallExpression(), |
| 353 | 388 | ); |
| 354 | 389 | |
| 355 | 390 | const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); |
| ... | ... | @@ -377,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 377 | 412 | const pointerSequenceRef = yield* Ref.make(0); |
| 378 | 413 | const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none()); |
| 379 | 414 | |
| 380 | | const fail = (operation: string, cause: unknown): PreviewManagerError => |
| 381 | | new PreviewManagerError({ operation, cause }); |
| 382 | | const attempt = <A>(operation: string, evaluate: () => A) => |
| 383 | | Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); |
| 384 | | const attemptPromise = <A>(operation: string, evaluate: () => PromiseLike<A>) => |
| 385 | | Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) }); |
| 415 | const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) => |
| 416 | Effect.try({ |
| 417 | try: evaluate, |
| 418 | catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), |
| 419 | }); |
| 420 | const attemptPromise = <A>( |
| 421 | errorContext: PreviewOperationContext, |
| 422 | evaluate: () => PromiseLike<A>, |
| 423 | ) => |
| 424 | Effect.tryPromise({ |
| 425 | try: evaluate, |
| 426 | catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), |
| 427 | }); |
| 386 | 428 | const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); |
| 387 | 429 | const currentMillis = Clock.currentTimeMillis; |
| 388 | | const encodeJson = (operation: string, value: unknown) => |
| 389 | | encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause))); |
| 430 | const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => |
| 431 | encodeUnknownJson(value).pipe( |
| 432 | Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })), |
| 433 | ); |
| 390 | 434 | const nextCounter = (ref: Ref.Ref<number>) => |
| 391 | 435 | Ref.modify(ref, (value) => [value, value + 1] as const); |
| 392 | 436 | const replaceMap = <K, V>( |
| ... | ... | @@ -432,23 +476,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 432 | 476 | const tabs = yield* SynchronizedRef.get(tabsRef); |
| 433 | 477 | const tab = tabs.get(tabId); |
| 434 | 478 | if (!tab) { |
| 435 | | return yield* fail("requireWebContents", new PreviewTabNotFoundError({ tabId })); |
| 479 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 436 | 480 | } |
| 437 | 481 | if (tab.webContentsId == null) { |
| 438 | | return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError({ tabId })); |
| 482 | return yield* new PreviewWebviewNotInitializedError({ tabId }); |
| 439 | 483 | } |
| 440 | 484 | const wc = webContents.fromId(tab.webContentsId); |
| 441 | 485 | if (!wc) { |
| 442 | | return yield* fail( |
| 443 | | "requireWebContents", |
| 444 | | new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId }), |
| 445 | | ); |
| 486 | return yield* new PreviewWebContentsNotFoundError({ |
| 487 | tabId, |
| 488 | webContentsId: tab.webContentsId, |
| 489 | }); |
| 446 | 490 | } |
| 447 | 491 | return wc; |
| 448 | 492 | }); |
| 449 | 493 | |
| 450 | 494 | const resolveArtifactPath = (artifactPath: string) => |
| 451 | | attempt("resolveArtifactPath", () => { |
| 495 | attempt({ operation: "resolveArtifactPath", artifactPath }, () => { |
| 452 | 496 | const resolvedPath = path.resolve(artifactPath); |
| 453 | 497 | const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); |
| 454 | 498 | if ( |
| ... | ... | @@ -464,10 +508,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 464 | 508 | Effect.flatMap((resolvedPath) => |
| 465 | 509 | resolvedPath === null |
| 466 | 510 | ? Effect.fail( |
| 467 | | fail( |
| 468 | | "resolveArtifactPath", |
| 469 | | new Error("Preview artifact path is outside the configured artifact directory."), |
| 470 | | ), |
| 511 | new PreviewArtifactPathOutsideDirectoryError({ |
| 512 | artifactPath, |
| 513 | artifactDirectory: resolvedArtifactDirectory, |
| 514 | }), |
| 471 | 515 | ) |
| 472 | 516 | : Effect.succeed(resolvedPath), |
| 473 | 517 | ), |
| ... | ... | @@ -636,129 +680,137 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 636 | 680 | const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( |
| 637 | 681 | wc: Electron.WebContents, |
| 638 | 682 | ) { |
| 639 | | return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { |
| 640 | | const existing = sessions.get(wc.id); |
| 641 | | if (existing) return Effect.succeed([existing, sessions] as const); |
| 642 | | if (wc.isDevToolsOpened()) { |
| 643 | | return Effect.fail( |
| 644 | | fail( |
| 645 | | "ensureControlSession", |
| 646 | | automationError( |
| 647 | | "PreviewAutomationExecutionError", |
| 648 | | "Close preview DevTools before using agent browser control.", |
| 649 | | ), |
| 650 | | ), |
| 651 | | ); |
| 652 | | } |
| 653 | | if (wc.debugger.isAttached()) { |
| 654 | | return Effect.fail( |
| 655 | | fail( |
| 656 | | "ensureControlSession", |
| 657 | | automationError( |
| 658 | | "PreviewAutomationExecutionError", |
| 659 | | "Preview control cannot attach because another debugger owns this page.", |
| 660 | | ), |
| 661 | | ), |
| 662 | | ); |
| 663 | | } |
| 664 | | const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { |
| 665 | | const semaphore = yield* Semaphore.make(1); |
| 666 | | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 667 | | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( |
| 668 | | method: string, |
| 669 | | params: Record<string, unknown>, |
| 670 | | ) { |
| 671 | | if (method === "Page.screencastFrame") { |
| 672 | | const sessionId = params["sessionId"]; |
| 673 | | if (typeof sessionId === "number") { |
| 674 | | yield* attemptPromise("ackScreencastFrame", () => |
| 675 | | wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), |
| 676 | | ).pipe(Effect.ignore); |
| 677 | | } |
| 678 | | const tabId = yield* tabIdForWebContents(wc.id); |
| 679 | | const metadata = |
| 680 | | typeof params["metadata"] === "object" && params["metadata"] !== null |
| 681 | | ? (params["metadata"] as Record<string, unknown>) |
| 682 | | : {}; |
| 683 | | if (tabId && typeof params["data"] === "string") { |
| 684 | | const receivedAt = yield* currentIso; |
| 685 | | const listeners = yield* Ref.get(recordingFrameListenersRef); |
| 686 | | const frame: DesktopPreviewRecordingFrame = { |
| 687 | | tabId, |
| 688 | | data: params["data"], |
| 689 | | width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, |
| 690 | | height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, |
| 691 | | receivedAt, |
| 692 | | }; |
| 693 | | yield* Effect.forEach( |
| 694 | | listeners, |
| 695 | | (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), |
| 696 | | { discard: true }, |
| 697 | | ); |
| 698 | | } |
| 699 | | } |
| 700 | | yield* captureDiagnosticMessage(wc.id, method, params); |
| 701 | | }); |
| 702 | | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 703 | | runFork(handleDebuggerMessage(method, params)); |
| 704 | | }; |
| 705 | | yield* Scope.addFinalizer( |
| 706 | | scope, |
| 707 | | Effect.all( |
| 708 | | [ |
| 709 | | Ref.update(diagnosticsRef, (diagnostics) => |
| 710 | | replaceMap(diagnostics, (copy) => { |
| 711 | | copy.delete(wc.id); |
| 712 | | }), |
| 713 | | ), |
| 714 | | attempt("detachControlSession", () => { |
| 715 | | wc.debugger.off("message", onMessage); |
| 716 | | if (wc.debugger.isAttached()) wc.debugger.detach(); |
| 717 | | }).pipe(Effect.ignore), |
| 718 | | ], |
| 719 | | { discard: true }, |
| 720 | | ), |
| 721 | | ); |
| 722 | | const control: BrowserControlSession = { |
| 723 | | webContentsId: wc.id, |
| 724 | | semaphore, |
| 725 | | scope, |
| 726 | | onMessage, |
| 727 | | }; |
| 728 | | const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { |
| 729 | | yield* Ref.update(diagnosticsRef, (diagnostics) => |
| 730 | | replaceMap(diagnostics, (copy) => { |
| 731 | | copy.set(wc.id, { |
| 732 | | consoleEntries: [], |
| 733 | | networkEntries: [], |
| 734 | | requests: new Map(), |
| 735 | | }); |
| 683 | return yield* SynchronizedRef.modifyEffect( |
| 684 | controlSessionsRef, |
| 685 | ( |
| 686 | sessions, |
| 687 | ): Effect.Effect< |
| 688 | readonly [BrowserControlSession, ReadonlyMap<number, BrowserControlSession>], |
| 689 | PreviewManagerError |
| 690 | > => { |
| 691 | const existing = sessions.get(wc.id); |
| 692 | if (existing) return Effect.succeed([existing, sessions] as const); |
| 693 | if (wc.isDevToolsOpened()) { |
| 694 | return Effect.fail( |
| 695 | new PreviewAutomationDevToolsOpenError({ |
| 696 | webContentsId: wc.id, |
| 736 | 697 | }), |
| 737 | 698 | ); |
| 738 | | yield* attempt("attachDebuggerListeners", () => { |
| 739 | | wc.debugger.on("message", onMessage); |
| 740 | | wc.debugger.attach("1.3"); |
| 741 | | }); |
| 742 | | yield* Effect.all( |
| 743 | | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( |
| 744 | | (method) => |
| 745 | | attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), |
| 699 | } |
| 700 | if (wc.debugger.isAttached()) { |
| 701 | return Effect.fail( |
| 702 | new PreviewAutomationDebuggerAttachedError({ |
| 703 | webContentsId: wc.id, |
| 704 | }), |
| 705 | ); |
| 706 | } |
| 707 | const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { |
| 708 | const semaphore = yield* Semaphore.make(1); |
| 709 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 710 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( |
| 711 | function* (method: string, params: Record<string, unknown>) { |
| 712 | if (method === "Page.screencastFrame") { |
| 713 | const sessionId = params["sessionId"]; |
| 714 | if (typeof sessionId === "number") { |
| 715 | yield* attemptPromise( |
| 716 | { |
| 717 | operation: "ackScreencastFrame", |
| 718 | webContentsId: wc.id, |
| 719 | }, |
| 720 | () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), |
| 721 | ).pipe(Effect.ignore); |
| 722 | } |
| 723 | const tabId = yield* tabIdForWebContents(wc.id); |
| 724 | const metadata = |
| 725 | typeof params["metadata"] === "object" && params["metadata"] !== null |
| 726 | ? (params["metadata"] as Record<string, unknown>) |
| 727 | : {}; |
| 728 | if (tabId && typeof params["data"] === "string") { |
| 729 | const receivedAt = yield* currentIso; |
| 730 | const listeners = yield* Ref.get(recordingFrameListenersRef); |
| 731 | const frame: DesktopPreviewRecordingFrame = { |
| 732 | tabId, |
| 733 | data: params["data"], |
| 734 | width: |
| 735 | typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, |
| 736 | height: |
| 737 | typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, |
| 738 | receivedAt, |
| 739 | }; |
| 740 | yield* Effect.forEach( |
| 741 | listeners, |
| 742 | (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), |
| 743 | { discard: true }, |
| 744 | ); |
| 745 | } |
| 746 | } |
| 747 | yield* captureDiagnosticMessage(wc.id, method, params); |
| 748 | }, |
| 749 | ); |
| 750 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 751 | runFork(handleDebuggerMessage(method, params)); |
| 752 | }; |
| 753 | yield* Scope.addFinalizer( |
| 754 | scope, |
| 755 | Effect.all( |
| 756 | [ |
| 757 | Ref.update(diagnosticsRef, (diagnostics) => |
| 758 | replaceMap(diagnostics, (copy) => { |
| 759 | copy.delete(wc.id); |
| 760 | }), |
| 761 | ), |
| 762 | attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { |
| 763 | wc.debugger.off("message", onMessage); |
| 764 | if (wc.debugger.isAttached()) wc.debugger.detach(); |
| 765 | }).pipe(Effect.ignore), |
| 766 | ], |
| 767 | { discard: true }, |
| 746 | 768 | ), |
| 747 | | { concurrency: "unbounded", discard: true }, |
| 748 | 769 | ); |
| 749 | | return [ |
| 750 | | control, |
| 751 | | replaceMap(sessions, (copy) => { |
| 752 | | copy.set(wc.id, control); |
| 753 | | }), |
| 754 | | ] as const; |
| 770 | const control: BrowserControlSession = { |
| 771 | webContentsId: wc.id, |
| 772 | semaphore, |
| 773 | scope, |
| 774 | onMessage, |
| 775 | }; |
| 776 | const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { |
| 777 | yield* Ref.update(diagnosticsRef, (diagnostics) => |
| 778 | replaceMap(diagnostics, (copy) => { |
| 779 | copy.set(wc.id, { |
| 780 | consoleEntries: [], |
| 781 | networkEntries: [], |
| 782 | requests: new Map(), |
| 783 | }); |
| 784 | }), |
| 785 | ); |
| 786 | yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { |
| 787 | wc.debugger.on("message", onMessage); |
| 788 | wc.debugger.attach("1.3"); |
| 789 | }); |
| 790 | yield* Effect.all( |
| 791 | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( |
| 792 | (method) => |
| 793 | attemptPromise( |
| 794 | { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, |
| 795 | () => wc.debugger.sendCommand(method), |
| 796 | ), |
| 797 | ), |
| 798 | { concurrency: "unbounded", discard: true }, |
| 799 | ); |
| 800 | return [ |
| 801 | control, |
| 802 | replaceMap(sessions, (copy) => { |
| 803 | copy.set(wc.id, control); |
| 804 | }), |
| 805 | ] as const; |
| 806 | }); |
| 807 | return yield* initialize().pipe( |
| 808 | Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), |
| 809 | ); |
| 755 | 810 | }); |
| 756 | | return yield* initialize().pipe( |
| 757 | | Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), |
| 758 | | ); |
| 759 | | }); |
| 760 | | return createControlSession(); |
| 761 | | }); |
| 811 | return createControlSession(); |
| 812 | }, |
| 813 | ); |
| 762 | 814 | }); |
| 763 | 815 | |
| 764 | 816 | const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => |
| ... | ... | @@ -808,26 +860,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 808 | 860 | function* (method, commandParams) { |
| 809 | 861 | const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; |
| 810 | 862 | if (before !== epoch) { |
| 811 | | return yield* fail( |
| 812 | | action, |
| 813 | | automationError( |
| 814 | | "PreviewAutomationControlInterruptedError", |
| 815 | | "Browser control was interrupted by human input.", |
| 816 | | ), |
| 817 | | ); |
| 863 | return yield* new PreviewAutomationControlInterruptedError({ |
| 864 | operation: action, |
| 865 | tabId, |
| 866 | webContentsId: wc.id, |
| 867 | }); |
| 818 | 868 | } |
| 819 | | const result = yield* attemptPromise(action, () => |
| 820 | | wc.debugger.sendCommand(method, commandParams), |
| 869 | const result = yield* attemptPromise( |
| 870 | { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, |
| 871 | () => wc.debugger.sendCommand(method, commandParams), |
| 821 | 872 | ); |
| 822 | 873 | const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; |
| 823 | 874 | if (after !== epoch) { |
| 824 | | return yield* fail( |
| 825 | | action, |
| 826 | | automationError( |
| 827 | | "PreviewAutomationControlInterruptedError", |
| 828 | | "Browser control was interrupted by human input.", |
| 829 | | ), |
| 830 | | ); |
| 875 | return yield* new PreviewAutomationControlInterruptedError({ |
| 876 | operation: action, |
| 877 | tabId, |
| 878 | webContentsId: wc.id, |
| 879 | }); |
| 831 | 880 | } |
| 832 | 881 | return result; |
| 833 | 882 | }, |
| ... | ... | @@ -846,15 +895,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 846 | 895 | }); |
| 847 | 896 | } else { |
| 848 | 897 | const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); |
| 849 | | const underlying = isPreviewManagerError(error) ? error.cause : error; |
| 850 | | const interrupted = |
| 851 | | underlying instanceof Error && |
| 852 | | underlying.name === "PreviewAutomationControlInterruptedError"; |
| 898 | const interrupted = isPreviewAutomationControlInterruptedError(error); |
| 899 | const errorMessage = isPreviewOperationError(error) |
| 900 | ? PreviewOperationError.toTimelineMessage(error) |
| 901 | : isPreviewAutomationEvaluationError(error) |
| 902 | ? PreviewAutomationEvaluationError.toTimelineMessage(error) |
| 903 | : isPreviewAutomationInvalidSelectorError(error) |
| 904 | ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error) |
| 905 | : error instanceof Error |
| 906 | ? error.message |
| 907 | : String(error); |
| 853 | 908 | yield* replaceAction(tabId, { |
| 854 | 909 | ...actionEvent, |
| 855 | 910 | status: interrupted ? "interrupted" : "failed", |
| 856 | 911 | completedAt, |
| 857 | | error: underlying instanceof Error ? underlying.message : String(underlying), |
| 912 | error: errorMessage, |
| 858 | 913 | }); |
| 859 | 914 | } |
| 860 | 915 | const tabs = yield* SynchronizedRef.get(tabsRef); |
| ... | ... | @@ -864,6 +919,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 864 | 919 | }); |
| 865 | 920 | |
| 866 | 921 | const evaluateWithDebugger = <A = unknown>( |
| 922 | tabId: string, |
| 867 | 923 | send: SendCommand, |
| 868 | 924 | expression: string, |
| 869 | 925 | returnByValue: boolean, |
| ... | ... | @@ -877,19 +933,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 877 | 933 | }).pipe( |
| 878 | 934 | Effect.flatMap((rawResponse) => { |
| 879 | 935 | const response = rawResponse as CdpEvaluationResult; |
| 880 | | return response.exceptionDetails |
| 881 | | ? Effect.fail( |
| 882 | | fail( |
| 883 | | "evaluate", |
| 884 | | automationError( |
| 885 | | "PreviewAutomationExecutionError", |
| 886 | | response.exceptionDetails.exception?.description ?? |
| 887 | | response.exceptionDetails.text ?? |
| 888 | | "JavaScript evaluation failed.", |
| 889 | | ), |
| 890 | | ), |
| 891 | | ) |
| 892 | | : Effect.succeed(response.result?.value as A); |
| 936 | if (!response.exceptionDetails) { |
| 937 | return Effect.succeed(response.result?.value as A); |
| 938 | } |
| 939 | const detail = previewAutomationEvaluationDetail(response.exceptionDetails); |
| 940 | return Effect.fail( |
| 941 | new PreviewAutomationEvaluationError({ |
| 942 | tabId, |
| 943 | detailKind: detail.detailKind, |
| 944 | detailLength: detail.detail?.length ?? 0, |
| 945 | cause: response.exceptionDetails, |
| 946 | }), |
| 947 | ); |
| 893 | 948 | }), |
| 894 | 949 | ); |
| 895 | 950 | |
| ... | ... | @@ -898,17 +953,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 898 | 953 | readonly locator?: string | undefined; |
| 899 | 954 | }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); |
| 900 | 955 | |
| 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 | 972 | const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( |
| 973 | tabId: string, |
| 902 | 974 | send: SendCommand, |
| 903 | 975 | ) { |
| 904 | 976 | const installed = yield* evaluateWithDebugger<boolean>( |
| 977 | tabId, |
| 905 | 978 | send, |
| 906 | 979 | "Boolean(globalThis.__t3PlaywrightInjected)", |
| 907 | 980 | true, |
| 908 | 981 | ); |
| 909 | 982 | if (installed) return; |
| 910 | | const expression = yield* playwrightInstallExpression; |
| 911 | | yield* evaluateWithDebugger(send, expression, true); |
| 983 | const expression = yield* playwrightInstallExpression.pipe( |
| 984 | Effect.mapError( |
| 985 | (cause) => |
| 986 | new PreviewOperationError({ |
| 987 | operation: "ensurePlaywrightInjected", |
| 988 | tabId, |
| 989 | cause, |
| 990 | }), |
| 991 | ), |
| 992 | ); |
| 993 | yield* evaluateWithDebugger(tabId, send, expression, true); |
| 912 | 994 | }); |
| 913 | 995 | |
| 914 | 996 | const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( |
| ... | ... | @@ -1060,7 +1142,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1060 | 1142 | }; |
| 1061 | 1143 | yield* Scope.addFinalizer( |
| 1062 | 1144 | scope, |
| 1063 | | attempt("detachListeners", () => { |
| 1145 | attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { |
| 1064 | 1146 | wc.off("did-navigate", sync); |
| 1065 | 1147 | wc.off("did-navigate-in-page", sync); |
| 1066 | 1148 | wc.off("page-title-updated", sync); |
| ... | ... | @@ -1072,7 +1154,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1072 | 1154 | }).pipe(Effect.ignore), |
| 1073 | 1155 | ); |
| 1074 | 1156 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| 1075 | | yield* attempt("attachListeners", () => { |
| 1157 | yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { |
| 1076 | 1158 | wc.on("did-navigate", sync); |
| 1077 | 1159 | wc.on("did-navigate-in-page", sync); |
| 1078 | 1160 | wc.on("page-title-updated", sync); |
| ... | ... | @@ -1081,7 +1163,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1081 | 1163 | wc.on("did-fail-load", failed as never); |
| 1082 | 1164 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1083 | 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 | 1171 | return { action: "deny" }; |
| 1086 | 1172 | }); |
| 1087 | 1173 | wc.on("before-input-event", beforeInput); |
| ... | ... | @@ -1162,7 +1248,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1162 | 1248 | ) { |
| 1163 | 1249 | const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); |
| 1164 | 1250 | if (!tab) { |
| 1165 | | return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); |
| 1251 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1166 | 1252 | } |
| 1167 | 1253 | const wc = webContents.fromId(webContentsId); |
| 1168 | 1254 | const mainWindow = yield* Ref.get(mainWindowRef); |
| ... | ... | @@ -1171,15 +1257,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1171 | 1257 | wc.getType() !== "webview" || |
| 1172 | 1258 | (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) |
| 1173 | 1259 | ) { |
| 1174 | | return yield* fail( |
| 1175 | | "registerWebview", |
| 1176 | | new PreviewWebContentsNotFoundError({ tabId, webContentsId }), |
| 1177 | | ); |
| 1260 | return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); |
| 1178 | 1261 | } |
| 1179 | 1262 | const attached = yield* Ref.get(attachedRef); |
| 1180 | 1263 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1181 | 1264 | if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { |
| 1182 | | yield* attempt("registerWebview.sendTheme", () => |
| 1265 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1183 | 1266 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1184 | 1267 | ); |
| 1185 | 1268 | return; |
| ... | ... | @@ -1225,16 +1308,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1225 | 1308 | ] as const; |
| 1226 | 1309 | }); |
| 1227 | 1310 | if (Option.isNone(registration)) { |
| 1228 | | return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); |
| 1311 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1229 | 1312 | } |
| 1230 | 1313 | const { state: registered, pendingUrl } = registration.value; |
| 1231 | 1314 | yield* emit(tabId, registered); |
| 1232 | 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 | 1317 | wc.setZoomFactor(registered.zoomFactor), |
| 1235 | 1318 | ).pipe(Effect.ignore); |
| 1236 | 1319 | } |
| 1237 | | yield* attempt("registerWebview.sendTheme", () => |
| 1320 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1238 | 1321 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1239 | 1322 | ); |
| 1240 | 1323 | const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; |
| ... | ... | @@ -1245,15 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1245 | 1328 | wc.getURL() !== pendingUrl |
| 1246 | 1329 | ) { |
| 1247 | 1330 | runFork( |
| 1248 | | attemptPromise("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe( |
| 1249 | | Effect.ignore, |
| 1250 | | ), |
| 1331 | attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () => |
| 1332 | wc.loadURL(pendingUrl), |
| 1333 | ).pipe(Effect.ignore), |
| 1251 | 1334 | ); |
| 1252 | 1335 | } |
| 1253 | 1336 | }); |
| 1254 | 1337 | |
| 1255 | 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 | 1342 | const updatedAt = yield* currentIso; |
| 1258 | 1343 | const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { |
| 1259 | 1344 | const current = tabs.get(tabId); |
| ... | ... | @@ -1294,10 +1379,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1294 | 1379 | return; |
| 1295 | 1380 | } |
| 1296 | 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 | 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 | }); |
| 1302 | 1391 | |
| 1303 | 1392 | const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( |
| ... | ... | @@ -1306,7 +1395,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1306 | 1395 | use: (wc: Electron.WebContents) => void, |
| 1307 | 1396 | ) { |
| 1308 | 1397 | const wc = yield* requireWebContents(tabId); |
| 1309 | | yield* attempt(operation, () => use(wc)); |
| 1398 | yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); |
| 1310 | 1399 | }); |
| 1311 | 1400 | |
| 1312 | 1401 | const goBack = (tabId: string) => |
| ... | ... | @@ -1324,11 +1413,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1324 | 1413 | const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { |
| 1325 | 1414 | const wc = yield* requireWebContents(tabId); |
| 1326 | 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 | 1419 | return; |
| 1329 | 1420 | } |
| 1330 | 1421 | yield* detachControlSession(wc.id); |
| 1331 | | yield* attempt("openDevTools", () => { |
| 1422 | yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { |
| 1332 | 1423 | wc.once("devtools-closed", () => { |
| 1333 | 1424 | if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); |
| 1334 | 1425 | }); |
| ... | ... | @@ -1348,9 +1439,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1348 | 1439 | const wc = webContents.fromId(tab.webContentsId); |
| 1349 | 1440 | return !wc || wc.isDestroyed() |
| 1350 | 1441 | ? Effect.void |
| 1351 | | : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe( |
| 1352 | | Effect.ignore, |
| 1353 | | ); |
| 1442 | : attempt( |
| 1443 | { |
| 1444 | operation: "setAnnotationTheme", |
| 1445 | tabId: tab.tabId, |
| 1446 | webContentsId: tab.webContentsId, |
| 1447 | }, |
| 1448 | () => wc.send(ANNOTATION_THEME_CHANNEL, theme), |
| 1449 | ).pipe(Effect.ignore); |
| 1354 | 1450 | }, |
| 1355 | 1451 | { discard: true }, |
| 1356 | 1452 | ); |
| ... | ... | @@ -1363,7 +1459,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1363 | 1459 | return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( |
| 1364 | 1460 | (resume) => { |
| 1365 | 1461 | const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { |
| 1366 | | yield* attempt("pickElement.cleanup", () => { |
| 1462 | yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { |
| 1367 | 1463 | wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); |
| 1368 | 1464 | wc.off("destroyed", onDestroyed); |
| 1369 | 1465 | wc.off("did-start-navigation", onNavigated); |
| ... | ... | @@ -1392,9 +1488,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1392 | 1488 | if (activeTab?.webContentsId != null) { |
| 1393 | 1489 | const activeWc = webContents.fromId(activeTab.webContentsId); |
| 1394 | 1490 | if (activeWc && !activeWc.isDestroyed()) { |
| 1395 | | yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe( |
| 1396 | | Effect.ignore, |
| 1397 | | ); |
| 1491 | yield* attempt( |
| 1492 | { |
| 1493 | operation: "cancelPickElement", |
| 1494 | tabId, |
| 1495 | webContentsId: activeWc.id, |
| 1496 | }, |
| 1497 | () => activeWc.send(CANCEL_PICK_CHANNEL), |
| 1498 | ).pipe(Effect.ignore); |
| 1398 | 1499 | } |
| 1399 | 1500 | } |
| 1400 | 1501 | resume(Effect.succeed(null)); |
| ... | ... | @@ -1408,15 +1509,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1408 | 1509 | } |
| 1409 | 1510 | const cropRect = normalizeCaptureRect(args[1]); |
| 1410 | 1511 | runFork( |
| 1411 | | captureAnnotationScreenshot(wc, cropRect).pipe( |
| 1512 | captureAnnotationScreenshot(tabId, wc, cropRect).pipe( |
| 1412 | 1513 | Effect.matchEffect({ |
| 1413 | 1514 | onFailure: () => Effect.sync(() => settle(payload)), |
| 1414 | 1515 | onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), |
| 1415 | 1516 | }), |
| 1416 | 1517 | Effect.ensuring( |
| 1417 | | attempt("pickElement.captureComplete", () => { |
| 1418 | | if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); |
| 1419 | | }).pipe(Effect.ignore), |
| 1518 | attempt( |
| 1519 | { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, |
| 1520 | () => { |
| 1521 | if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); |
| 1522 | }, |
| 1523 | ).pipe(Effect.ignore), |
| 1420 | 1524 | ), |
| 1421 | 1525 | ), |
| 1422 | 1526 | ); |
| ... | ... | @@ -1431,7 +1535,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1431 | 1535 | if (isMainFrame) settle(null); |
| 1432 | 1536 | }; |
| 1433 | 1537 | const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { |
| 1434 | | yield* attempt("pickElement.register", () => { |
| 1538 | yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { |
| 1435 | 1539 | wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); |
| 1436 | 1540 | wc.once("destroyed", onDestroyed); |
| 1437 | 1541 | wc.once("did-start-navigation", onNavigated); |
| ... | ... | @@ -1468,7 +1572,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1468 | 1572 | if (tab.webContentsId != null) { |
| 1469 | 1573 | const wc = webContents.fromId(tab.webContentsId); |
| 1470 | 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 | 1580 | yield* update(tabId, { zoomFactor: next }); |
| ... | ... | @@ -1481,17 +1587,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1481 | 1587 | const [createdAt, millis, image] = yield* Effect.all([ |
| 1482 | 1588 | currentIso, |
| 1483 | 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 | 1599 | const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; |
| 1487 | 1600 | const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); |
| 1488 | 1601 | const data = image.toPNG(); |
| 1489 | | yield* fileSystem |
| 1490 | | .makeDirectory(resolvedArtifactDirectory, { recursive: true }) |
| 1491 | | .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause))); |
| 1492 | | yield* fileSystem |
| 1493 | | .writeFile(artifactPath, data) |
| 1494 | | .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause))); |
| 1602 | yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( |
| 1603 | Effect.mapError( |
| 1604 | (cause) => |
| 1605 | new PreviewOperationError({ |
| 1606 | operation: "captureScreenshot.makeDirectory", |
| 1607 | tabId, |
| 1608 | webContentsId: wc.id, |
| 1609 | artifactPath, |
| 1610 | cause, |
| 1611 | }), |
| 1612 | ), |
| 1613 | ); |
| 1614 | yield* fileSystem.writeFile(artifactPath, data).pipe( |
| 1615 | Effect.mapError( |
| 1616 | (cause) => |
| 1617 | new PreviewOperationError({ |
| 1618 | operation: "captureScreenshot.writeFile", |
| 1619 | tabId, |
| 1620 | webContentsId: wc.id, |
| 1621 | artifactPath, |
| 1622 | cause, |
| 1623 | }), |
| 1624 | ), |
| 1625 | ); |
| 1495 | 1626 | return { |
| 1496 | 1627 | id, |
| 1497 | 1628 | tabId, |
| ... | ... | @@ -1518,10 +1649,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1518 | 1649 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { |
| 1519 | 1650 | const recordingTabId = yield* Ref.get(recordingTabIdRef); |
| 1520 | 1651 | if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { |
| 1521 | | return yield* fail( |
| 1522 | | "startRecording", |
| 1523 | | new Error("Only one browser recording can be active per window."), |
| 1524 | | ); |
| 1652 | return yield* new PreviewRecordingAlreadyActiveError({ |
| 1653 | requestedTabId: tabId, |
| 1654 | activeTabId: recordingTabId.value, |
| 1655 | }); |
| 1525 | 1656 | } |
| 1526 | 1657 | const wc = yield* requireWebContents(tabId); |
| 1527 | 1658 | yield* withControlSession(tabId, wc, "recording.start", startScreencast); |
| ... | ... | @@ -1547,12 +1678,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1547 | 1678 | const id = `browser-recording-${millis.toString(36)}`; |
| 1548 | 1679 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; |
| 1549 | 1680 | const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); |
| 1550 | | yield* fileSystem |
| 1551 | | .makeDirectory(resolvedArtifactDirectory, { recursive: true }) |
| 1552 | | .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause))); |
| 1553 | | yield* fileSystem |
| 1554 | | .writeFile(artifactPath, data) |
| 1555 | | .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause))); |
| 1681 | yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( |
| 1682 | Effect.mapError( |
| 1683 | (cause) => |
| 1684 | new PreviewOperationError({ |
| 1685 | operation: "saveRecording.makeDirectory", |
| 1686 | tabId, |
| 1687 | artifactPath, |
| 1688 | cause, |
| 1689 | }), |
| 1690 | ), |
| 1691 | ); |
| 1692 | yield* fileSystem.writeFile(artifactPath, data).pipe( |
| 1693 | Effect.mapError( |
| 1694 | (cause) => |
| 1695 | new PreviewOperationError({ |
| 1696 | operation: "saveRecording.writeFile", |
| 1697 | tabId, |
| 1698 | artifactPath, |
| 1699 | cause, |
| 1700 | }), |
| 1701 | ), |
| 1702 | ); |
| 1556 | 1703 | return { |
| 1557 | 1704 | id, |
| 1558 | 1705 | tabId, |
| ... | ... | @@ -1609,6 +1756,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1609 | 1756 | visibleText: string; |
| 1610 | 1757 | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; |
| 1611 | 1758 | }>( |
| 1759 | tabId, |
| 1612 | 1760 | send, |
| 1613 | 1761 | `(() => { |
| 1614 | 1762 | const selectorFor = (element) => { |
| ... | ... | @@ -1665,7 +1813,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1665 | 1813 | ); |
| 1666 | 1814 | const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ |
| 1667 | 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 | 1824 | Ref.get(diagnosticsRef), |
| 1670 | 1825 | Ref.get(actionTimelineRef), |
| 1671 | 1826 | ]); |
| ... | ... | @@ -1702,6 +1857,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1702 | 1857 | }); |
| 1703 | 1858 | |
| 1704 | 1859 | const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( |
| 1860 | tabId: string, |
| 1705 | 1861 | send: SendCommand, |
| 1706 | 1862 | input: PreviewAutomationClickInput, |
| 1707 | 1863 | ) { |
| ... | ... | @@ -1709,11 +1865,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1709 | 1865 | return { x: input.x!, y: input.y! }; |
| 1710 | 1866 | } |
| 1711 | 1867 | const locator = automationLocator(input)!; |
| 1712 | | yield* ensurePlaywrightInjected(send); |
| 1713 | | const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator); |
| 1868 | yield* ensurePlaywrightInjected(tabId, send); |
| 1869 | const locatorJson = yield* encodeJson( |
| 1870 | { operation: "automationClick.encodeLocator", tabId }, |
| 1871 | locator, |
| 1872 | ); |
| 1714 | 1873 | const point = yield* evaluateWithDebugger< |
| 1715 | 1874 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } |
| 1716 | 1875 | >( |
| 1876 | tabId, |
| 1717 | 1877 | send, |
| 1718 | 1878 | `(() => { |
| 1719 | 1879 | try { |
| ... | ... | @@ -1734,21 +1894,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1734 | 1894 | true, |
| 1735 | 1895 | ); |
| 1736 | 1896 | if ("invalidSelector" in point) { |
| 1737 | | return yield* fail( |
| 1738 | | "automationClick", |
| 1739 | | automationError("PreviewAutomationInvalidSelectorError", point.message, { |
| 1740 | | selector: locator, |
| 1741 | | }), |
| 1742 | | ); |
| 1897 | return yield* new PreviewAutomationInvalidSelectorError({ |
| 1898 | operation: "click", |
| 1899 | tabId, |
| 1900 | ...automationSelectorDiagnostics(input), |
| 1901 | reasonLength: point.message.length, |
| 1902 | cause: point, |
| 1903 | }); |
| 1743 | 1904 | } |
| 1744 | 1905 | if ("notFound" in point) { |
| 1745 | | return yield* fail( |
| 1746 | | "automationClick", |
| 1747 | | automationError( |
| 1748 | | "PreviewAutomationExecutionError", |
| 1749 | | `No element matches locator ${locator}.`, |
| 1750 | | ), |
| 1751 | | ); |
| 1906 | return yield* new PreviewAutomationTargetNotFoundError({ |
| 1907 | operation: "click", |
| 1908 | tabId, |
| 1909 | ...automationSelectorDiagnostics(input), |
| 1910 | }); |
| 1752 | 1911 | } |
| 1753 | 1912 | return point; |
| 1754 | 1913 | }); |
| ... | ... | @@ -1773,20 +1932,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1773 | 1932 | [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], |
| 1774 | 1933 | { concurrency: 2, discard: true }, |
| 1775 | 1934 | ); |
| 1776 | | const point = yield* resolveClickPoint(send, input); |
| 1935 | const point = yield* resolveClickPoint(tabId, send, input); |
| 1777 | 1936 | const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( |
| 1937 | tabId, |
| 1778 | 1938 | send, |
| 1779 | 1939 | "({ width: window.innerWidth, height: window.innerHeight })", |
| 1780 | 1940 | true, |
| 1781 | 1941 | ); |
| 1782 | 1942 | if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { |
| 1783 | | return yield* fail( |
| 1784 | | "automationClick", |
| 1785 | | automationError( |
| 1786 | | "PreviewAutomationExecutionError", |
| 1787 | | `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, |
| 1788 | | ), |
| 1789 | | ); |
| 1943 | return yield* new PreviewAutomationCoordinatesOutsideViewportError({ |
| 1944 | tabId, |
| 1945 | x: point.x, |
| 1946 | y: point.y, |
| 1947 | viewportWidth: viewport.width, |
| 1948 | viewportHeight: viewport.height, |
| 1949 | }); |
| 1790 | 1950 | } |
| 1791 | 1951 | const moveSequence = yield* nextCounter(pointerSequenceRef); |
| 1792 | 1952 | const moveCreatedAt = yield* currentIso; |
| ... | ... | @@ -1834,15 +1994,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1834 | 1994 | }); |
| 1835 | 1995 | |
| 1836 | 1996 | const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( |
| 1997 | tabId: string, |
| 1837 | 1998 | send: SendCommand, |
| 1838 | 1999 | input: PreviewAutomationTypeInput, |
| 1839 | 2000 | ) { |
| 1840 | 2001 | const locator = automationLocator(input); |
| 1841 | | if (locator) yield* ensurePlaywrightInjected(send); |
| 1842 | | const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null; |
| 2002 | if (locator) yield* ensurePlaywrightInjected(tabId, send); |
| 2003 | const locatorJson = locator |
| 2004 | ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) |
| 2005 | : null; |
| 1843 | 2006 | const result = yield* evaluateWithDebugger< |
| 1844 | 2007 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } |
| 1845 | 2008 | >( |
| 2009 | tabId, |
| 1846 | 2010 | send, |
| 1847 | 2011 | `(() => { |
| 1848 | 2012 | try { |
| ... | ... | @@ -1862,23 +2026,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1862 | 2026 | true, |
| 1863 | 2027 | ); |
| 1864 | 2028 | if ("invalidSelector" in result) { |
| 1865 | | return yield* fail( |
| 1866 | | "automationType", |
| 1867 | | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 1868 | | selector: input.selector ?? "", |
| 1869 | | }), |
| 1870 | | ); |
| 2029 | return yield* new PreviewAutomationInvalidSelectorError({ |
| 2030 | operation: "type", |
| 2031 | tabId, |
| 2032 | ...automationSelectorDiagnostics(input), |
| 2033 | reasonLength: result.message.length, |
| 2034 | cause: result, |
| 2035 | }); |
| 1871 | 2036 | } |
| 1872 | 2037 | if ("notFound" in result) { |
| 1873 | | return yield* fail( |
| 1874 | | "automationType", |
| 1875 | | automationError( |
| 1876 | | "PreviewAutomationExecutionError", |
| 1877 | | locator |
| 1878 | | ? `No element matches locator ${locator}.` |
| 1879 | | : "No element is focused in the preview.", |
| 1880 | | ), |
| 1881 | | ); |
| 2038 | return yield* new PreviewAutomationTargetNotFoundError({ |
| 2039 | operation: "type", |
| 2040 | tabId, |
| 2041 | ...automationSelectorDiagnostics(input), |
| 2042 | }); |
| 1882 | 2043 | } |
| 1883 | 2044 | }); |
| 1884 | 2045 | |
| ... | ... | @@ -1888,10 +2049,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1888 | 2049 | send: SendCommand, |
| 1889 | 2050 | ) { |
| 1890 | 2051 | yield* send("Runtime.enable"); |
| 1891 | | yield* focusAutomationTarget(send, input); |
| 2052 | yield* focusAutomationTarget(tabId, send, input); |
| 1892 | 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 | 2058 | yield* evaluateWithDebugger( |
| 2059 | tabId, |
| 1895 | 2060 | send, |
| 1896 | 2061 | `(() => { |
| 1897 | 2062 | const element = document.activeElement; |
| ... | ... | @@ -1959,13 +2124,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1959 | 2124 | ) { |
| 1960 | 2125 | yield* send("Runtime.enable"); |
| 1961 | 2126 | const locator = automationLocator(input); |
| 1962 | | if (locator) yield* ensurePlaywrightInjected(send); |
| 2127 | if (locator) yield* ensurePlaywrightInjected(tabId, send); |
| 1963 | 2128 | const locatorJson = locator |
| 1964 | | ? yield* encodeJson("automationScroll.encodeLocator", locator) |
| 2129 | ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator) |
| 1965 | 2130 | : null; |
| 1966 | 2131 | const result = yield* evaluateWithDebugger< |
| 1967 | 2132 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } |
| 1968 | 2133 | >( |
| 2134 | tabId, |
| 1969 | 2135 | send, |
| 1970 | 2136 | `(() => { |
| 1971 | 2137 | try { |
| ... | ... | @@ -1980,21 +2146,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1980 | 2146 | true, |
| 1981 | 2147 | ); |
| 1982 | 2148 | if ("invalidSelector" in result) { |
| 1983 | | return yield* fail( |
| 1984 | | "automationScroll", |
| 1985 | | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 1986 | | selector: input.selector ?? "", |
| 1987 | | }), |
| 1988 | | ); |
| 2149 | return yield* new PreviewAutomationInvalidSelectorError({ |
| 2150 | operation: "scroll", |
| 2151 | tabId, |
| 2152 | ...automationSelectorDiagnostics(input), |
| 2153 | reasonLength: result.message.length, |
| 2154 | cause: result, |
| 2155 | }); |
| 1989 | 2156 | } |
| 1990 | 2157 | if ("notFound" in result) { |
| 1991 | | return yield* fail( |
| 1992 | | "automationScroll", |
| 1993 | | automationError( |
| 1994 | | "PreviewAutomationExecutionError", |
| 1995 | | `No element matches locator ${locator}.`, |
| 1996 | | ), |
| 1997 | | ); |
| 2158 | return yield* new PreviewAutomationTargetNotFoundError({ |
| 2159 | operation: "scroll", |
| 2160 | tabId, |
| 2161 | ...automationSelectorDiagnostics(input), |
| 2162 | }); |
| 1998 | 2163 | } |
| 1999 | 2164 | }); |
| 2000 | 2165 | |
| ... | ... | @@ -2009,24 +2174,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2009 | 2174 | }); |
| 2010 | 2175 | |
| 2011 | 2176 | const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( |
| 2012 | | function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { |
| 2177 | function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) { |
| 2013 | 2178 | yield* send("Runtime.enable"); |
| 2014 | 2179 | const value = yield* evaluateWithDebugger( |
| 2180 | tabId, |
| 2015 | 2181 | send, |
| 2016 | 2182 | input.expression, |
| 2017 | 2183 | input.returnByValue ?? true, |
| 2018 | 2184 | input.awaitPromise ?? true, |
| 2019 | 2185 | ); |
| 2020 | | const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); |
| 2021 | | if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { |
| 2022 | | return yield* fail( |
| 2023 | | "automationEvaluate", |
| 2024 | | automationError( |
| 2025 | | "PreviewAutomationResultTooLargeError", |
| 2026 | | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, |
| 2027 | | { maximumBytes: MAX_EVALUATION_BYTES }, |
| 2028 | | ), |
| 2029 | | ); |
| 2186 | const serialized = yield* encodeJson( |
| 2187 | { operation: "automationEvaluate.encodeResult", tabId }, |
| 2188 | value, |
| 2189 | ); |
| 2190 | const actualBytes = Buffer.byteLength(serialized, "utf8"); |
| 2191 | if (actualBytes > MAX_EVALUATION_BYTES) { |
| 2192 | return yield* new PreviewAutomationResultTooLargeError({ |
| 2193 | tabId, |
| 2194 | actualBytes, |
| 2195 | maximumBytes: MAX_EVALUATION_BYTES, |
| 2196 | }); |
| 2030 | 2197 | } |
| 2031 | 2198 | return value; |
| 2032 | 2199 | }, |
| ... | ... | @@ -2038,23 +2205,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2038 | 2205 | ) { |
| 2039 | 2206 | const wc = yield* requireWebContents(tabId); |
| 2040 | 2207 | return yield* withControlSession(tabId, wc, "evaluate", (send) => |
| 2041 | | performAutomationEvaluate(input, send), |
| 2208 | performAutomationEvaluate(tabId, input, send), |
| 2042 | 2209 | ); |
| 2043 | 2210 | }); |
| 2044 | 2211 | |
| 2045 | 2212 | const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( |
| 2213 | tabId: string, |
| 2046 | 2214 | input: PreviewAutomationWaitForInput, |
| 2047 | 2215 | send: SendCommand, |
| 2048 | 2216 | ) { |
| 2049 | 2217 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 2050 | 2218 | yield* send("Runtime.enable"); |
| 2051 | 2219 | const locator = automationLocator(input); |
| 2052 | | if (locator) yield* ensurePlaywrightInjected(send); |
| 2220 | if (locator) yield* ensurePlaywrightInjected(tabId, send); |
| 2053 | 2221 | const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ |
| 2054 | | locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), |
| 2055 | | input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), |
| 2222 | locator |
| 2223 | ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator) |
| 2224 | : Effect.succeed(null), |
| 2225 | input.text |
| 2226 | ? encodeJson({ operation: "automationWaitFor.encodeText", tabId }, input.text) |
| 2227 | : Effect.succeed(null), |
| 2056 | 2228 | input.urlIncludes |
| 2057 | | ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) |
| 2229 | ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes) |
| 2058 | 2230 | : Effect.succeed(null), |
| 2059 | 2231 | ]); |
| 2060 | 2232 | const deadline = (yield* currentMillis) + timeoutMs; |
| ... | ... | @@ -2062,6 +2234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2062 | 2234 | const result = yield* evaluateWithDebugger< |
| 2063 | 2235 | { matched: boolean } | { invalidSelector: true; message: string } |
| 2064 | 2236 | >( |
| 2237 | tabId, |
| 2065 | 2238 | send, |
| 2066 | 2239 | `(() => { |
| 2067 | 2240 | try { |
| ... | ... | @@ -2080,23 +2253,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2080 | 2253 | true, |
| 2081 | 2254 | ); |
| 2082 | 2255 | if ("invalidSelector" in result) { |
| 2083 | | return yield* fail( |
| 2084 | | "automationWaitFor", |
| 2085 | | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 2086 | | selector: input.selector ?? "", |
| 2087 | | }), |
| 2088 | | ); |
| 2256 | return yield* new PreviewAutomationInvalidSelectorError({ |
| 2257 | operation: "waitFor", |
| 2258 | tabId, |
| 2259 | ...automationSelectorDiagnostics(input), |
| 2260 | reasonLength: result.message.length, |
| 2261 | cause: result, |
| 2262 | }); |
| 2089 | 2263 | } |
| 2090 | 2264 | if (result.matched) return; |
| 2091 | 2265 | yield* Effect.sleep(100); |
| 2092 | 2266 | } |
| 2093 | | return yield* fail( |
| 2094 | | "automationWaitFor", |
| 2095 | | automationError( |
| 2096 | | "PreviewAutomationTimeoutError", |
| 2097 | | `Preview condition did not match within ${timeoutMs}ms.`, |
| 2098 | | ), |
| 2099 | | ); |
| 2267 | return yield* new PreviewAutomationTimeoutError({ |
| 2268 | tabId, |
| 2269 | timeoutMs, |
| 2270 | }); |
| 2100 | 2271 | }); |
| 2101 | 2272 | |
| 2102 | 2273 | const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( |
| ... | ... | @@ -2105,7 +2276,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2105 | 2276 | ) { |
| 2106 | 2277 | const wc = yield* requireWebContents(tabId); |
| 2107 | 2278 | yield* withControlSession(tabId, wc, "waitFor", (send) => |
| 2108 | | performAutomationWaitFor(input, send), |
| 2279 | performAutomationWaitFor(tabId, input, send), |
| 2109 | 2280 | ); |
| 2110 | 2281 | }); |
| 2111 | 2282 | |
| ... | ... | @@ -2113,23 +2284,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2113 | 2284 | artifactPath: string, |
| 2114 | 2285 | ) { |
| 2115 | 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 | }); |
| 2118 | 2291 | |
| 2119 | 2292 | const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( |
| 2120 | 2293 | artifactPath: string, |
| 2121 | 2294 | ) { |
| 2122 | 2295 | const resolvedPath = yield* resolveArtifactPath(artifactPath); |
| 2123 | | const image = yield* attempt("copyArtifactToClipboard.load", () => |
| 2124 | | nativeImage.createFromPath(resolvedPath), |
| 2296 | const image = yield* attempt( |
| 2297 | { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath }, |
| 2298 | () => nativeImage.createFromPath(resolvedPath), |
| 2125 | 2299 | ); |
| 2126 | 2300 | if (image.isEmpty()) { |
| 2127 | | return yield* fail( |
| 2128 | | "copyArtifactToClipboard", |
| 2129 | | new Error("Preview artifact could not be loaded as an image."), |
| 2130 | | ); |
| 2301 | return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); |
| 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 | }); |
| 2134 | 2307 | |
| 2135 | 2308 | const subscribe = <A>( |
| ... | ... | @@ -2228,19 +2401,234 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass<P |
| 2228 | 2401 | } |
| 2229 | 2402 | } |
| 2230 | 2403 | |
| 2231 | | export class PreviewManagerError extends Schema.TaggedErrorClass<PreviewManagerError>()( |
| 2232 | | "PreviewManagerError", |
| 2404 | export class PreviewOperationError extends Schema.TaggedErrorClass<PreviewOperationError>()( |
| 2405 | "PreviewOperationError", |
| 2233 | 2406 | { |
| 2234 | 2407 | operation: Schema.String, |
| 2408 | tabId: Schema.optional(Schema.String), |
| 2409 | webContentsId: Schema.optional(Schema.Number), |
| 2410 | artifactPath: Schema.optional(Schema.String), |
| 2235 | 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 | 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 | } |
| 2242 | 2427 | |
| 2243 | | const isPreviewManagerError = Schema.is(PreviewManagerError); |
| 2428 | export const isPreviewOperationError = Schema.is(PreviewOperationError); |
| 2429 | |
| 2430 | export 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 | |
| 2442 | export 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 | |
| 2451 | export 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 | |
| 2463 | export 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 | |
| 2472 | export 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 | |
| 2481 | export 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 | |
| 2499 | export 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 | |
| 2514 | export 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 | |
| 2529 | export 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 | |
| 2562 | export 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 | |
| 2579 | export 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 | |
| 2591 | export 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 | |
| 2604 | export 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 | ]); |
| 2622 | export type PreviewManagerError = typeof PreviewManagerError.Type; |
| 2623 | |
| 2624 | export const isPreviewManagerError = Schema.is(PreviewManagerError); |
| 2625 | export const isPreviewAutomationControlInterruptedError = Schema.is( |
| 2626 | PreviewAutomationControlInterruptedError, |
| 2627 | ); |
| 2628 | export const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError); |
| 2629 | export const isPreviewAutomationInvalidSelectorError = Schema.is( |
| 2630 | PreviewAutomationInvalidSelectorError, |
| 2631 | ); |
| 2244 | 2632 | |
| 2245 | 2633 | export class PreviewManager extends Context.Service< |
| 2246 | 2634 | PreviewManager, |
| ... | ... | @@ -2329,16 +2717,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 2329 | 2717 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 2330 | 2718 | const browserSession = yield* BrowserSession.BrowserSession; |
| 2331 | 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 }))); |
| 2337 | 2720 | |
| 2338 | 2721 | return PreviewManager.of({ |
| 2339 | 2722 | setMainWindow: operations.setMainWindow, |
| 2340 | 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 | 2732 | isBrowserPartition: browserSession.isPartition, |
| 2344 | 2733 | createTab: operations.createTab, |
| ... | ... | @@ -2354,13 +2743,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 2354 | 2743 | hardReload: operations.hardReload, |
| 2355 | 2744 | openDevTools: operations.openDevTools, |
| 2356 | 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 | 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 | 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 | 2770 | setAnnotationTheme: operations.setAnnotationTheme, |
| 2366 | 2771 | pickElement: operations.pickElement, |