authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 17:49:22-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log70ec39ae8094d06eb7a241a007bbc2b0892c2896
tree6d3a2b980fb3a1c263b2cfd8c9da77c11cc9bef3
parent3d9e8dea7d0d7d775bfc617929d7bb2c5d3e651e

Port preview manager to Effect-based browser sessions

- derive preview partitions through `BrowserSession` - serialize session state and async preview control flow - update tests for screenshot, automation, and partition behavior

11 files changed, 2227 insertions(+), 1703 deletions(-)

apps/desktop/src/ipc/methods/preview.ts+1-2
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import {1import {
3 DesktopPreviewAnnotationThemeInputSchema,2 DesktopPreviewAnnotationThemeInputSchema,
4 DesktopPreviewArtifactInputSchema,3 DesktopPreviewArtifactInputSchema,
...@@ -195,7 +194,7 @@ export const getPreviewConfig = makeIpcMethod({...@@ -195,7 +194,7 @@ export const getPreviewConfig = makeIpcMethod({
195 const manager = yield* PreviewManager.PreviewManager;194 const manager = yield* PreviewManager.PreviewManager;
196 yield* manager.getBrowserSession(environmentId);195 yield* manager.getBrowserSession(environmentId);
197 return {196 return {
198 partition: manager.getBrowserPartition(environmentId),197 partition: yield* manager.getBrowserPartition(environmentId),
199 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,198 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
200 preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,199 preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
201 };200 };
apps/desktop/src/preview/BrowserSession.test.ts created+83
...@@ -0,0 +1,83 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { beforeEach, vi } from "vite-plus/test";
6
7const { fromPartition, sessions } = vi.hoisted(() => ({
8 fromPartition: vi.fn(),
9 sessions: new Map<
10 string,
11 {
12 readonly clearCache: ReturnType<typeof vi.fn>;
13 readonly clearStorageData: ReturnType<typeof vi.fn>;
14 readonly getUserAgent: ReturnType<typeof vi.fn>;
15 readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
16 readonly setUserAgent: ReturnType<typeof vi.fn>;
17 }
18 >(),
19}));
20
21vi.mock("electron", () => ({
22 session: {
23 fromPartition,
24 },
25}));
26
27import * as BrowserSession from "./BrowserSession.ts";
28
29const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer));
30
31describe("BrowserSession", () => {
32 beforeEach(() => {
33 sessions.clear();
34 fromPartition.mockReset();
35 fromPartition.mockImplementation((partition: string) => {
36 const browserSession = {
37 clearCache: vi.fn(() => Promise.resolve()),
38 clearStorageData: vi.fn(() => Promise.resolve()),
39 getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
40 setPermissionRequestHandler: vi.fn(),
41 setUserAgent: vi.fn(),
42 };
43 sessions.set(partition, browserSession);
44 return browserSession;
45 });
46 });
47
48 it.effect("derives deterministic partitions and memoizes sessions", () =>
49 Effect.gen(function* () {
50 const browserSessions = yield* BrowserSession.BrowserSession;
51
52 const partition = yield* browserSessions.getPartition("scope-a");
53 const first = yield* browserSessions.getSession("scope-a");
54 const second = yield* browserSessions.getSession("scope-a");
55
56 assert.strictEqual(partition, "persist:t3code-preview-f051bb2c68cb7b2fe969");
57 assert.strictEqual(first, second);
58 assert.strictEqual(fromPartition.mock.calls.length, 1);
59 }).pipe(Effect.provide(layer)),
60 );
61
62 it.effect("clears storage and cache for every created session", () =>
63 Effect.gen(function* () {
64 const browserSessions = yield* BrowserSession.BrowserSession;
65 yield* browserSessions.getSession("scope-a");
66 yield* browserSessions.getSession("scope-b");
67
68 yield* browserSessions.clearCookies();
69 yield* browserSessions.clearCache();
70
71 assert.strictEqual(sessions.size, 2);
72 for (const browserSession of sessions.values()) {
73 assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1);
74 assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [
75 {
76 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
77 },
78 ]);
79 assert.strictEqual(browserSession.clearCache.mock.calls.length, 1);
80 }
81 }).pipe(Effect.provide(layer)),
82 );
83});
apps/desktop/src/preview/BrowserSession.ts+59-45
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import type { Session } from "electron";1import type { Session } from "electron";
2import { session } from "electron";2import { session } from "electron";
3import { createHash } from "node:crypto";
4import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
5import * as Data from "effect/Data";5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Encoding from "effect/Encoding";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as SynchronizedRef from "effect/SynchronizedRef";
810
9const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";11const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
1012
...@@ -18,7 +20,7 @@ export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")...@@ -18,7 +20,7 @@ export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")
18}20}
1921
20export interface BrowserSessionShape {22export interface BrowserSessionShape {
21 readonly getPartition: (scope?: string) => string;23 readonly getPartition: (scope?: string) => Effect.Effect<string, BrowserSessionError>;
22 readonly isPartition: (partition: string) => boolean;24 readonly isPartition: (partition: string) => boolean;
23 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>;25 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>;
24 readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>;26 readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>;
...@@ -29,19 +31,25 @@ export class BrowserSession extends Context.Service<BrowserSession, BrowserSessi...@@ -29,19 +31,25 @@ export class BrowserSession extends Context.Service<BrowserSession, BrowserSessi
29 "@t3tools/desktop/preview/BrowserSession",31 "@t3tools/desktop/preview/BrowserSession",
30) {}32) {}
3133
32const make = Effect.fn("BrowserSession.make")(() =>34const make = Effect.gen(function* BrowserSessionMake() {
33 Effect.sync(() => {35 const crypto = yield* Crypto.Crypto;
34 const sessions = new Map<string, Session>();36 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
35 const getPartition = (scope = "shared"): string => {
36 const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20);
37 return `${PREVIEW_PARTITION_PREFIX}${digest}`;
38 };
3937
40 const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {38 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {
41 const partition = getPartition(scope);39 const digest = yield* crypto
40 .digest("SHA-256", new TextEncoder().encode(scope))
41 .pipe(
42 Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })),
43 );
44 return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`;
45 });
46
47 const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {
48 const partition = yield* getPartition(scope);
49 return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {
42 const existing = sessions.get(partition);50 const existing = sessions.get(partition);
43 if (existing) return existing;51 if (existing) return Effect.succeed([existing, sessions] as const);
44 return yield* Effect.try({52 return Effect.try({
45 try: () => {53 try: () => {
46 const browserSession = session.fromPartition(partition);54 const browserSession = session.fromPartition(partition);
47 const userAgent = browserSession55 const userAgent = browserSession
...@@ -53,41 +61,47 @@ const make = Effect.fn("BrowserSession.make")(() =>...@@ -53,41 +61,47 @@ const make = Effect.fn("BrowserSession.make")(() =>
53 const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];61 const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
54 callback(allowed.includes(permission));62 callback(allowed.includes(permission));
55 });63 });
56 sessions.set(partition, browserSession);64 const next = new Map(sessions);
57 return browserSession;65 next.set(partition, browserSession);
66 return [browserSession, next] as const;
58 },67 },
59 catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }),68 catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }),
60 });69 });
61 });70 });
71 });
6272
63 return BrowserSession.of({73 return BrowserSession.of({
64 getPartition,74 getPartition,
65 isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),75 isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
66 getSession,76 getSession,
67 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {77 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
68 yield* Effect.tryPromise({78 const sessions = yield* SynchronizedRef.get(sessionsRef);
69 try: () =>79 yield* Effect.all(
70 Promise.all(80 [...sessions.values()].map((browserSession) =>
71 [...sessions.values()].map((browserSession) =>81 Effect.tryPromise({
72 browserSession.clearStorageData({82 try: () =>
73 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],83 browserSession.clearStorageData({
74 }),84 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
75 ),85 }),
76 ),86 catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }),
77 catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }),87 }),
78 });88 ),
79 }),89 { concurrency: "unbounded", discard: true },
80 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {90 );
81 yield* Effect.tryPromise({91 }),
82 try: () =>92 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
83 Promise.all(93 const sessions = yield* SynchronizedRef.get(sessionsRef);
84 [...sessions.values()].map((browserSession) => browserSession.clearCache()),94 yield* Effect.all(
85 ),95 [...sessions.values()].map((browserSession) =>
86 catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }),96 Effect.tryPromise({
87 });97 try: () => browserSession.clearCache(),
88 }),98 catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }),
89 });99 }),
90 }),100 ),
91);101 { concurrency: "unbounded", discard: true },
102 );
103 }),
104 });
105}).pipe(Effect.withSpan("BrowserSession.make"));
92106
93export const layer = Layer.effect(BrowserSession, make());107export const layer = Layer.effect(BrowserSession, make);
apps/desktop/src/preview/Manager.test.ts+326-235
...@@ -1,14 +1,30 @@...@@ -1,14 +1,30 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { it as effectIt } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import type * as Scope from "effect/Scope";
11import { TestClock } from "effect/testing";
12import { beforeEach, describe, expect, vi } from "vite-plus/test";
213
3const fromId = vi.fn(() => null);14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
4const mkdir = vi.fn(async () => undefined);15import * as BrowserSession from "./BrowserSession.ts";
5const writeFile = vi.fn(async () => undefined);16import * as PreviewManager from "./Manager.ts";
6const showItemInFolder = vi.fn();
7const writeImage = vi.fn();
8const createFromPath = vi.fn(() => ({ isEmpty: () => false }));
9const webviewSend = vi.fn();
1017
11vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));18const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } =
19 vi.hoisted(() => ({
20 createFromPath: vi.fn(() => ({ isEmpty: () => false })),
21 fromId: vi.fn(() => null),
22 mkdir: vi.fn((_path: string) => undefined),
23 showItemInFolder: vi.fn(),
24 webviewSend: vi.fn(),
25 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
26 writeImage: vi.fn(),
27 }));
1228
13vi.mock("electron", () => ({29vi.mock("electron", () => ({
14 clipboard: {30 clipboard: {
...@@ -17,18 +33,64 @@ vi.mock("electron", () => ({...@@ -17,18 +33,64 @@ vi.mock("electron", () => ({
17 nativeImage: {33 nativeImage: {
18 createFromPath,34 createFromPath,
19 },35 },
20 session: {
21 fromPartition: vi.fn(),
22 },
23 shell: {36 shell: {
24 showItemInFolder,37 showItemInFolder,
25 },38 },
39 session: {
40 fromPartition: vi.fn(),
41 },
26 webContents: {42 webContents: {
27 fromId,43 fromId,
28 },44 },
29}));45}));
3046
31describe("PreviewViewManager automation status", () => {47const browserSessionLayer = Layer.succeed(
48 BrowserSession.BrowserSession,
49 BrowserSession.BrowserSession.of({
50 getPartition: () => Effect.succeed("persist:t3code-preview-test"),
51 isPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
52 getSession: () => Effect.die("unexpected getSession"),
53 clearCookies: () => Effect.void,
54 clearCache: () => Effect.void,
55 }),
56);
57
58const environmentLayer = Layer.succeed(
59 DesktopEnvironment.DesktopEnvironment,
60 DesktopEnvironment.DesktopEnvironment.of({
61 browserArtifactsDir: "/tmp/t3/dev/browser-artifacts",
62 } as DesktopEnvironment.DesktopEnvironmentShape),
63);
64
65const fileSystemLayer = FileSystem.layerNoop({
66 makeDirectory: (path) =>
67 Effect.sync(() => {
68 mkdir(path);
69 }),
70 writeFile: (path, data) =>
71 Effect.sync(() => {
72 writeFile(path, data);
73 }),
74});
75
76const layer = PreviewManager.layer.pipe(
77 Layer.provideMerge(browserSessionLayer),
78 Layer.provideMerge(environmentLayer),
79 Layer.provideMerge(fileSystemLayer),
80 Layer.provideMerge(Path.layer),
81);
82
83const withManager = <A>(
84 use: (
85 manager: PreviewManager.PreviewManagerShape,
86 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,
87) =>
88 Effect.gen(function* () {
89 const manager = yield* PreviewManager.PreviewManager;
90 return yield* use(manager);
91 }).pipe(Effect.provide(layer), Effect.scoped);
92
93describe("PreviewManager", () => {
32 beforeEach(() => {94 beforeEach(() => {
33 fromId.mockClear();95 fromId.mockClear();
34 mkdir.mockClear();96 mkdir.mockClear();
...@@ -39,245 +101,274 @@ describe("PreviewViewManager automation status", () => {...@@ -39,245 +101,274 @@ describe("PreviewViewManager automation status", () => {
39 webviewSend.mockClear();101 webviewSend.mockClear();
40 });102 });
41103
42 it("reports an unregistered webview as temporarily unavailable", async () => {104 effectIt.effect("reports an unregistered webview as temporarily unavailable", () =>
43 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;105 withManager((manager) =>
44 const manager = new PreviewViewManager();106 Effect.gen(function* () {
107 expect(yield* manager.automationStatus("tab_1")).toEqual({
108 available: false,
109 visible: true,
110 tabId: "tab_1",
111 url: null,
112 title: null,
113 loading: false,
114 });
45115
46 expect(manager.automationStatus("tab_1")).toEqual({116 yield* manager.createTab("tab_1");
47 available: false,
48 visible: true,
49 tabId: "tab_1",
50 url: null,
51 title: null,
52 loading: false,
53 });
54117
55 manager.createTab("tab_1");118 expect(yield* manager.automationStatus("tab_1")).toEqual({
56119 available: false,
57 expect(manager.automationStatus("tab_1")).toEqual({120 visible: true,
58 available: false,121 tabId: "tab_1",
59 visible: true,122 url: null,
60 tabId: "tab_1",123 title: null,
61 url: null,124 loading: false,
62 title: null,125 });
63 loading: false,126 expect(fromId).not.toHaveBeenCalled();
64 });
65 expect(fromId).not.toHaveBeenCalled();
66 });
67
68 it("captures a PNG screenshot into browser artifacts", async () => {
69 const png = Buffer.from("preview-png");
70 const capturePage = vi.fn(async () => ({ toPNG: () => png }));
71 const listeners = new Map<string, (...args: never[]) => void>();
72 fromId.mockReturnValue({
73 id: 42,
74 isDestroyed: () => false,
75 getType: () => "webview",
76 getURL: () => "https://example.com:8443/path?query=value",
77 getTitle: () => "Example",
78 isLoading: () => false,
79 getZoomFactor: () => 1,
80 setZoomFactor: vi.fn(),
81 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
82 listeners.set(event, listener);
83 }),127 }),
84 off: vi.fn(),128 ),
85 ipc: { on: vi.fn(), off: vi.fn() },129 );
86 send: webviewSend,
87 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
88 setWindowOpenHandler: vi.fn(),
89 debugger: {
90 isAttached: () => false,
91 attach: vi.fn(),
92 sendCommand: vi.fn(async () => undefined),
93 on: vi.fn(),
94 off: vi.fn(),
95 },
96 capturePage,
97 } as never);
98 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
99 const manager = new PreviewViewManager();
100 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
101 manager.createTab("tab_1");
102 manager.registerWebview("tab_1", 42);
103130
104 expect(webviewSend).toHaveBeenCalledWith(131 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
105 "preview:annotation-theme",132 withManager((manager) =>
106 expect.objectContaining({133 Effect.gen(function* () {
107 colorScheme: "light",134 const png = Buffer.from("preview-png");
108 primary: "oklch(0.488 0.217 264)",135 const capturePage = vi.fn(async () => ({ toPNG: () => png }));
109 }),136 const listeners = new Map<string, (...args: never[]) => void>();
110 );137 fromId.mockReturnValue({
138 id: 42,
139 isDestroyed: () => false,
140 getType: () => "webview",
141 getURL: () => "https://example.com:8443/path?query=value",
142 getTitle: () => "Example",
143 isLoading: () => false,
144 getZoomFactor: () => 1,
145 setZoomFactor: vi.fn(),
146 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
147 listeners.set(event, listener);
148 }),
149 off: vi.fn(),
150 ipc: { on: vi.fn(), off: vi.fn() },
151 send: webviewSend,
152 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
153 setWindowOpenHandler: vi.fn(),
154 debugger: {
155 isAttached: () => false,
156 attach: vi.fn(),
157 sendCommand: vi.fn(async () => undefined),
158 on: vi.fn(),
159 off: vi.fn(),
160 },
161 capturePage,
162 } as never);
111163
112 const artifact = await manager.captureScreenshot("tab_1");164 yield* manager.createTab("tab_1");
165 yield* manager.registerWebview("tab_1", 42);
113166
114 expect(capturePage).toHaveBeenCalledOnce();167 expect(webviewSend).toHaveBeenCalledWith(
115 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", {168 "preview:annotation-theme",
116 recursive: true,169 expect.objectContaining({
117 });170 colorScheme: "light",
118 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);171 primary: "oklch(0.488 0.217 264)",
119 expect(artifact).toMatchObject({172 }),
120 tabId: "tab_1",173 );
121 mimeType: "image/png",
122 sizeBytes: png.byteLength,
123 });
124 expect(artifact.path).toMatch(
125 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
126 );
127 });
128174
129 it("reveals only files inside the configured browser artifact directory", async () => {175 const artifact = yield* manager.captureScreenshot("tab_1");
130 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
131 const manager = new PreviewViewManager();
132 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
133176
134 manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");177 expect(capturePage).toHaveBeenCalledOnce();
178 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts");
179 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
180 expect(artifact).toMatchObject({
181 tabId: "tab_1",
182 mimeType: "image/png",
183 sizeBytes: png.byteLength,
184 });
185 expect(artifact.path).toMatch(
186 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
187 );
188 }),
189 ),
190 );
135191
136 expect(showItemInFolder).toHaveBeenCalledWith(192 effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
137 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",193 withManager((manager) =>
138 );194 Effect.gen(function* () {
139 expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow(195 yield* manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");
140 "outside the configured artifact directory",
141 );
142 });
143196
144 it("copies screenshot artifacts to the system clipboard", async () => {197 expect(showItemInFolder).toHaveBeenCalledWith(
145 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;198 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
146 const manager = new PreviewViewManager();199 );
147 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");200 const exit = yield* Effect.exit(manager.revealArtifact("/tmp/t3/dev/settings.json"));
148 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";201 expect(Exit.isFailure(exit)).toBe(true);
202 if (Exit.isSuccess(exit)) return;
203 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
204 expect(error.cause).toMatchObject({
205 message: "Preview artifact path is outside the configured artifact directory.",
206 });
207 }),
208 ),
209 );
149210
150 manager.copyArtifactToClipboard(artifactPath);211 effectIt.effect("copies screenshot artifacts to the system clipboard", () =>
212 withManager((manager) =>
213 Effect.gen(function* () {
214 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";
151215
152 expect(createFromPath).toHaveBeenCalledWith(artifactPath);216 yield* manager.copyArtifactToClipboard(artifactPath);
153 expect(writeImage).toHaveBeenCalledOnce();217
154 expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow(218 expect(createFromPath).toHaveBeenCalledWith(artifactPath);
155 "outside the configured artifact directory",219 expect(writeImage).toHaveBeenCalledOnce();
156 );220 const exit = yield* Effect.exit(
157 });221 manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"),
222 );
223 expect(Exit.isFailure(exit)).toBe(true);
224 if (Exit.isSuccess(exit)) return;
225 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
226 expect(error.cause).toMatchObject({
227 message: "Preview artifact path is outside the configured artifact directory.",
228 });
229 }),
230 ),
231 );
158232
159 it("emits the resolved pointer target before dispatching an automation click", async () => {233 effectIt.effect("emits the resolved pointer target before dispatching an automation click", () =>
160 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;234 withManager((manager) =>
161 const activity: string[] = [];235 Effect.gen(function* () {
162 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {236 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
163 if (method === "Runtime.evaluate") {237 const activity: string[] = [];
164 return {238 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
165 result: {239 if (method === "Runtime.evaluate") {
166 value: { width: 800, height: 600 },240 return {
241 result: {
242 value: { width: 800, height: 600 },
243 },
244 };
245 }
246 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
247 activity.push("mousePressed");
248 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
249 }
250 return undefined;
251 });
252 fromId.mockReturnValue({
253 id: 42,
254 isDestroyed: () => false,
255 getType: () => "webview",
256 getURL: () => "https://example.com",
257 getTitle: () => "Example",
258 isLoading: () => false,
259 isDevToolsOpened: () => false,
260 getZoomFactor: () => 1,
261 setZoomFactor: vi.fn(),
262 on: vi.fn(),
263 off: vi.fn(),
264 ipc: {
265 on: vi.fn((channel: string, listener: typeof humanInput) => {
266 if (channel === "preview:human-input") humanInput = listener;
267 }),
268 off: vi.fn(),
167 },269 },
168 };270 send: webviewSend,
169 }271 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
170 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {272 setWindowOpenHandler: vi.fn(),
171 activity.push("mousePressed");273 debugger: {
172 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });274 isAttached: () => false,
173 }275 attach: vi.fn(),
174 return undefined;276 sendCommand,
175 });277 on: vi.fn(),
176 fromId.mockReturnValue({278 off: vi.fn(),
177 id: 42,279 },
178 isDestroyed: () => false,280 } as never);
179 getType: () => "webview",
180 getURL: () => "https://example.com",
181 getTitle: () => "Example",
182 isLoading: () => false,
183 isDevToolsOpened: () => false,
184 getZoomFactor: () => 1,
185 setZoomFactor: vi.fn(),
186 on: vi.fn(),
187 off: vi.fn(),
188 ipc: {
189 on: vi.fn((channel: string, listener: typeof humanInput) => {
190 if (channel === "preview:human-input") humanInput = listener;
191 }),
192 off: vi.fn(),
193 },
194 send: webviewSend,
195 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
196 setWindowOpenHandler: vi.fn(),
197 debugger: {
198 isAttached: () => false,
199 attach: vi.fn(),
200 sendCommand,
201 on: vi.fn(),
202 off: vi.fn(),
203 },
204 } as never);
205 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
206 const manager = new PreviewViewManager();
207 manager.onPointerEvent((event) => activity.push(event.phase));
208 manager.createTab("tab_1");
209 manager.registerWebview("tab_1", 42);
210281
211 await manager.automationClick("tab_1", { x: 120, y: 80 });282 yield* manager.subscribePointerEvents((event) => activity.push(event.phase));
283 yield* manager.createTab("tab_1");
284 yield* manager.registerWebview("tab_1", 42);
285 const click = yield* manager
286 .automationClick("tab_1", { x: 120, y: 80 })
287 .pipe(Effect.forkChild({ startImmediately: true }));
288 yield* TestClock.adjust(200);
289 yield* Fiber.join(click);
212290
213 expect(activity).toEqual(["move", "click", "mousePressed"]);291 expect(activity).toEqual(["move", "click", "mousePressed"]);
214 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {292 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
215 type: "mousePressed",293 type: "mousePressed",
216 x: 120,294 x: 120,
217 y: 80,295 y: 80,
218 button: "left",296 button: "left",
219 clickCount: 1,297 clickCount: 1,
220 });298 });
221 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {299 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
222 type: "mouseReleased",300 type: "mouseReleased",
223 x: 120,301 x: 120,
224 y: 80,302 y: 80,
225 button: "left",303 button: "left",
226 clickCount: 1,304 clickCount: 1,
227 });305 });
228 });306 }),
307 ),
308 );
229309
230 it("still interrupts agent control for a different human pointer event", async () => {310 effectIt.effect("still interrupts agent control for a different human pointer event", () =>
231 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;311 withManager((manager) =>
232 const sendCommand = vi.fn(async (method: string) => {312 Effect.gen(function* () {
233 if (method === "Runtime.evaluate") {313 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
234 return {314 const sendCommand = vi.fn(async (method: string) => {
235 result: {315 if (method === "Runtime.evaluate") {
236 value: { width: 800, height: 600 },316 return {
317 result: {
318 value: { width: 800, height: 600 },
319 },
320 };
321 }
322 if (method === "Input.dispatchMouseEvent") {
323 humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 });
324 }
325 return undefined;
326 });
327 fromId.mockReturnValue({
328 id: 42,
329 isDestroyed: () => false,
330 getType: () => "webview",
331 getURL: () => "https://example.com",
332 getTitle: () => "Example",
333 isLoading: () => false,
334 isDevToolsOpened: () => false,
335 getZoomFactor: () => 1,
336 setZoomFactor: vi.fn(),
337 on: vi.fn(),
338 off: vi.fn(),
339 ipc: {
340 on: vi.fn((channel: string, listener: typeof humanInput) => {
341 if (channel === "preview:human-input") humanInput = listener;
342 }),
343 off: vi.fn(),
344 },
345 send: webviewSend,
346 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
347 setWindowOpenHandler: vi.fn(),
348 debugger: {
349 isAttached: () => false,
350 attach: vi.fn(),
351 sendCommand,
352 on: vi.fn(),
353 off: vi.fn(),
237 },354 },
238 };355 } as never);
239 }
240 if (method === "Input.dispatchMouseEvent") {
241 humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 });
242 }
243 return undefined;
244 });
245 fromId.mockReturnValue({
246 id: 42,
247 isDestroyed: () => false,
248 getType: () => "webview",
249 getURL: () => "https://example.com",
250 getTitle: () => "Example",
251 isLoading: () => false,
252 isDevToolsOpened: () => false,
253 getZoomFactor: () => 1,
254 setZoomFactor: vi.fn(),
255 on: vi.fn(),
256 off: vi.fn(),
257 ipc: {
258 on: vi.fn((channel: string, listener: typeof humanInput) => {
259 if (channel === "preview:human-input") humanInput = listener;
260 }),
261 off: vi.fn(),
262 },
263 send: webviewSend,
264 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
265 setWindowOpenHandler: vi.fn(),
266 debugger: {
267 isAttached: () => false,
268 attach: vi.fn(),
269 sendCommand,
270 on: vi.fn(),
271 off: vi.fn(),
272 },
273 } as never);
274 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
275 const manager = new PreviewViewManager();
276 manager.createTab("tab_1");
277 manager.registerWebview("tab_1", 42);
278356
279 await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({357 yield* manager.createTab("tab_1");
280 name: "PreviewAutomationControlInterruptedError",358 yield* manager.registerWebview("tab_1", 42);
281 });359
282 });360 const click = yield* manager
361 .automationClick("tab_1", { x: 120, y: 80 })
362 .pipe(Effect.forkChild({ startImmediately: true }));
363 yield* TestClock.adjust(200);
364 const exit = yield* Fiber.await(click);
365 expect(Exit.isFailure(exit)).toBe(true);
366 if (Exit.isSuccess(exit)) return;
367 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
368 expect(error.cause).toMatchObject({
369 name: "PreviewAutomationControlInterruptedError",
370 });
371 }),
372 ),
373 );
283});374});
apps/desktop/src/preview/Manager.ts+1675-1376
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1// @effect-diagnostics globalDate:off
2// @effect-diagnostics nodeBuiltinImport:off
3/**1/**
4 * PreviewViewManager — desktop side of the in-app browser preview.2 * Desktop side of the in-app browser preview.
5 *3 *
6 * Hosts per-tab Chromium WebContents references (the actual <webview>4 * Hosts per-tab Chromium WebContents references (the actual <webview>
7 * elements live in the renderer; we only attach listeners and forward state5 * elements live in the renderer; we only attach listeners and forward state
...@@ -36,14 +34,22 @@ import {...@@ -36,14 +34,22 @@ import {
36 shell,34 shell,
37 webContents,35 webContents,
38} from "electron";36} from "electron";
39import { mkdir, writeFile } from "node:fs/promises";37import * as Cause from "effect/Cause";
40import { isAbsolute, join, relative, resolve, sep } from "node:path";38import * as Clock from "effect/Clock";
41import { setTimeout as sleep } from "node:timers/promises";
42import * as Context from "effect/Context";39import * as Context from "effect/Context";
43import * as Data from "effect/Data";40import * as Data from "effect/Data";
41import * as DateTime from "effect/DateTime";
44import * as Effect from "effect/Effect";42import * as Effect from "effect/Effect";
43import * as Exit from "effect/Exit";
44import * as FileSystem from "effect/FileSystem";
45import * as Layer from "effect/Layer";45import * as Layer from "effect/Layer";
46import * as Option from "effect/Option";
47import * as Path from "effect/Path";
48import * as Ref from "effect/Ref";
49import * as Schema from "effect/Schema";
50import * as Semaphore from "effect/Semaphore";
46import type * as Scope from "effect/Scope";51import type * as Scope from "effect/Scope";
52import * as SynchronizedRef from "effect/SynchronizedRef";
4753
48import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";54import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
49import * as BrowserSession from "./BrowserSession.ts";55import * as BrowserSession from "./BrowserSession.ts";
...@@ -96,6 +102,7 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200;...@@ -96,6 +102,7 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200;
96const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;102const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
97const AGENT_CURSOR_MOVE_MS = 160;103const AGENT_CURSOR_MOVE_MS = 160;
98const AGENT_CURSOR_CLICK_LEAD_MS = 40;104const AGENT_CURSOR_CLICK_LEAD_MS = 40;
105const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
99const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {106const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
100 colorScheme: "light",107 colorScheme: "light",
101 radius: "0.625rem",108 radius: "0.625rem",
...@@ -187,35 +194,41 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {...@@ -187,35 +194,41 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
187 };194 };
188};195};
189196
190const captureAnnotationScreenshot = async (197const captureAnnotationScreenshot = (
191 wc: Electron.WebContents,198 wc: Electron.WebContents,
192 cropRect: PreviewAnnotationRect | null,199 cropRect: PreviewAnnotationRect | null,
193) => {200): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>
194 const image = await wc.capturePage(201 Effect.tryPromise({
195 cropRect202 try: () =>
196 ? {203 wc.capturePage(
197 x: cropRect.x,204 cropRect
198 y: cropRect.y,205 ? {
199 width: cropRect.width,206 x: cropRect.x,
200 height: cropRect.height,207 y: cropRect.y,
201 }208 width: cropRect.width,
202 : undefined,209 height: cropRect.height,
210 }
211 : undefined,
212 ),
213 catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }),
214 }).pipe(
215 Effect.map((image) => {
216 const size = image.getSize();
217 return {
218 dataUrl: image.toDataURL(),
219 width: size.width,
220 height: size.height,
221 cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },
222 };
223 }),
203 );224 );
204 const size = image.getSize();
205 return {
206 dataUrl: image.toDataURL(),
207 width: size.width,
208 height: size.height,
209 cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },
210 };
211};
212225
213const findZoomStep = (current: number): number => {226const findZoomStep = (current: number): number => {
214 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {227 const index = ZOOM_LEVELS.findIndex(
215 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;228 (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current,
216 if (ZOOM_LEVELS[index]! > current) return index - 1;229 );
217 }230 if (index < 0) return ZOOM_LEVELS.length - 1;
218 return ZOOM_LEVELS.length - 1;231 return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
219};232};
220233
221const nextZoomLevel = (current: number, direction: "in" | "out"): number => {234const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
...@@ -241,14 +254,12 @@ interface ManagedListeners {...@@ -241,14 +254,12 @@ interface ManagedListeners {
241}254}
242255
243interface PickSession {256interface PickSession {
244 readonly resolve: (payload: PreviewAnnotationPayload | null) => void;257 readonly cancel: Effect.Effect<void>;
245 readonly cleanup: () => void;
246}258}
247259
248interface BrowserControlSession {260interface BrowserControlSession {
249 readonly webContentsId: number;261 readonly webContentsId: number;
250 tail: Promise<void>;262 readonly semaphore: Semaphore.Semaphore;
251 initialized: Promise<void>;
252 readonly onMessage: (263 readonly onMessage: (
253 event: Electron.Event,264 event: Electron.Event,
254 method: string,265 method: string,
...@@ -257,9 +268,9 @@ interface BrowserControlSession {...@@ -257,9 +268,9 @@ interface BrowserControlSession {
257}268}
258269
259interface BrowserDiagnostics {270interface BrowserDiagnostics {
260 readonly consoleEntries: PreviewAutomationConsoleEntry[];271 readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
261 readonly networkEntries: PreviewAutomationNetworkEntry[];272 readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
262 readonly requests: Map<string, { url: string; method: string }>;273 readonly requests: ReadonlyMap<string, { url: string; method: string }>;
263}274}
264275
265type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;276type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
...@@ -323,107 +334,796 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal):...@@ -323,107 +334,796 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal):
323 );334 );
324};335};
325336
326class PreviewViewManager {337const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
327 private annotationTheme = DEFAULT_ANNOTATION_THEME;338 artifactDirectory: string,
328 private artifactDirectory: string | null = null;339) {
329 private mainWindow: BrowserWindow | null = null;340 const fileSystem = yield* FileSystem.FileSystem;
330 private readonly tabs = new Map<string, PreviewTabState>();341 const path = yield* Path.Path;
331 private readonly attached = new Map<number, ManagedListeners>();342 const context = yield* Effect.context<never>();
332 private readonly listeners = new Set<Listener>();343 const runFork = Effect.runForkWith(context);
333 private readonly pointerEventListeners = new Set<PointerEventListener>();344 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
334 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();345 const playwrightInstallExpression = yield* Effect.cached(
335 /** In-flight preview annotation sessions, keyed by tabId. */346 playwrightInjectedRuntimeInstallExpression().pipe(
336 private readonly pickSessions = new Map<string, PickSession>();347 Effect.mapError(
337 /** One long-lived CDP attachment and serialized command queue per guest. */348 (cause) =>
338 private readonly controlSessions = new Map<number, BrowserControlSession>();349 new PreviewManagerError({
339 private readonly diagnostics = new Map<number, BrowserDiagnostics>();350 operation: "ensurePlaywrightInjected",
340 private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>();351 cause,
341 private readonly controlEpoch = new Map<string, number>();352 }),
342 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();353 ),
343 private actionSequence = 0;354 ),
344 private pointerSequence = 0;355 );
345 private recordingTabId: string | null = null;356
346357 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
347 configureArtifactDirectory(directory: string): void {358 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
348 this.artifactDirectory = resolve(directory);359 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
349 }360 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
361 const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());
362 const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());
363 const recordingFrameListenersRef = yield* Ref.make<ReadonlySet<RecordingFrameListener>>(
364 new Set(),
365 );
366 const pickSessionsRef = yield* Ref.make<ReadonlyMap<string, PickSession>>(new Map());
367 const controlSessionsRef = yield* SynchronizedRef.make<
368 ReadonlyMap<number, BrowserControlSession>
369 >(new Map());
370 const diagnosticsRef = yield* Ref.make<ReadonlyMap<number, BrowserDiagnostics>>(new Map());
371 const expectedAgentInputsRef = yield* Ref.make<
372 ReadonlyMap<string, ReadonlyArray<ExpectedAgentInput>>
373 >(new Map());
374 const controlEpochRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
375 const actionTimelineRef = yield* Ref.make<
376 ReadonlyMap<string, ReadonlyArray<PreviewAutomationActionEvent>>
377 >(new Map());
378 const actionSequenceRef = yield* Ref.make(0);
379 const pointerSequenceRef = yield* Ref.make(0);
380 const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none());
381
382 const fail = (operation: string, cause: unknown): PreviewManagerError =>
383 new PreviewManagerError({ operation, cause });
384 const attempt = <A>(operation: string, evaluate: () => A) =>
385 Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) });
386 const attemptPromise = <A>(operation: string, evaluate: () => PromiseLike<A>) =>
387 Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) });
388 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
389 const currentMillis = Clock.currentTimeMillis;
390 const encodeJson = (operation: string, value: unknown) =>
391 encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause)));
392 const nextCounter = (ref: Ref.Ref<number>) =>
393 Ref.modify(ref, (value) => [value, value + 1] as const);
394 const replaceMap = <K, V>(
395 source: ReadonlyMap<K, V>,
396 update: (copy: Map<K, V>) => void,
397 ): ReadonlyMap<K, V> => {
398 const copy = new Map(source);
399 update(copy);
400 return copy;
401 };
350402
351 private requireArtifactDirectory(): string {403 const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) {
352 if (!this.artifactDirectory) {404 const listeners = yield* Ref.get(listenersRef);
353 throw new Error("Preview artifact directory is not configured.");405 yield* Effect.forEach(
406 listeners,
407 (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore),
408 { discard: true },
409 );
410 });
411
412 const update = Effect.fn("PreviewManager.update")(function* (
413 tabId: string,
414 patch: Partial<PreviewTabState>,
415 ) {
416 const updatedAt = yield* currentIso;
417 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
418 const current = tabs.get(tabId);
419 if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
420 const state: PreviewTabState = { ...current, ...patch, updatedAt };
421 return [
422 Option.some(state),
423 replaceMap(tabs, (copy) => {
424 copy.set(tabId, state);
425 }),
426 ] as const;
427 });
428 if (Option.isSome(next)) yield* emit(tabId, next.value);
429 });
430
431 const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (
432 tabId: string,
433 ) {
434 const tabs = yield* SynchronizedRef.get(tabsRef);
435 const tab = tabs.get(tabId);
436 if (!tab) return yield* fail("requireWebContents", new PreviewTabNotFoundError(tabId));
437 if (tab.webContentsId == null) {
438 return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError(tabId));
354 }439 }
355 return this.artifactDirectory;440 const wc = webContents.fromId(tab.webContentsId);
356 }441 if (!wc) {
442 return yield* fail(
443 "requireWebContents",
444 new PreviewWebContentsNotFoundError(tabId, tab.webContentsId),
445 );
446 }
447 return wc;
448 });
357449
358 private resolveArtifactPath(path: string): string {450 const resolveArtifactPath = (artifactPath: string) =>
359 const directory = this.requireArtifactDirectory();451 attempt("resolveArtifactPath", () => {
360 const resolvedPath = resolve(path);452 const resolvedPath = path.resolve(artifactPath);
361 const relativePath = relative(directory, resolvedPath);453 const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath);
362 if (454 if (
363 relativePath.length === 0 ||455 relativePath.length === 0 ||
364 relativePath === ".." ||456 relativePath === ".." ||
365 relativePath.startsWith(`..${sep}`) ||457 relativePath.startsWith(`..${path.sep}`) ||
366 isAbsolute(relativePath)458 path.isAbsolute(relativePath)
459 ) {
460 return null;
461 }
462 return resolvedPath;
463 }).pipe(
464 Effect.flatMap((resolvedPath) =>
465 resolvedPath === null
466 ? Effect.fail(
467 fail(
468 "resolveArtifactPath",
469 new Error("Preview artifact path is outside the configured artifact directory."),
470 ),
471 )
472 : Effect.succeed(resolvedPath),
473 ),
474 );
475
476 const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* (
477 webContentsId: number,
478 ) {
479 const tabs = yield* SynchronizedRef.get(tabsRef);
480 return (
481 Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null
482 );
483 });
484
485 const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> =>
486 [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT);
487
488 const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* (
489 webContentsId: number,
490 method: string,
491 params: Record<string, unknown>,
492 ) {
493 const timestamp = yield* currentIso;
494 yield* Ref.update(diagnosticsRef, (allDiagnostics) => {
495 const current = allDiagnostics.get(webContentsId);
496 if (!current) return allDiagnostics;
497 const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null;
498 const next = (() => {
499 if (method === "Runtime.consoleAPICalled") {
500 const args = Array.isArray(params["args"]) ? params["args"] : [];
501 const text = args
502 .map((arg) => {
503 if (typeof arg !== "object" || arg === null) return String(arg);
504 const value = arg as Record<string, unknown>;
505 return String(value["value"] ?? value["description"] ?? "");
506 })
507 .join(" ");
508 return {
509 ...current,
510 consoleEntries: pushBounded(current.consoleEntries, {
511 level: typeof params["type"] === "string" ? params["type"] : "log",
512 text,
513 timestamp,
514 source: "console",
515 }),
516 };
517 }
518 if (method === "Runtime.exceptionThrown") {
519 const details =
520 typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null
521 ? (params["exceptionDetails"] as Record<string, unknown>)
522 : {};
523 return {
524 ...current,
525 consoleEntries: pushBounded(current.consoleEntries, {
526 level: "error",
527 text: String(details["text"] ?? "Uncaught exception"),
528 timestamp,
529 source: "exception",
530 }),
531 };
532 }
533 if (method === "Log.entryAdded") {
534 const entry =
535 typeof params["entry"] === "object" && params["entry"] !== null
536 ? (params["entry"] as Record<string, unknown>)
537 : {};
538 return {
539 ...current,
540 consoleEntries: pushBounded(current.consoleEntries, {
541 level: typeof entry["level"] === "string" ? entry["level"] : "info",
542 text: String(entry["text"] ?? ""),
543 timestamp,
544 source: typeof entry["source"] === "string" ? entry["source"] : "log",
545 }),
546 };
547 }
548 if (method === "Network.requestWillBeSent" && requestId) {
549 const request =
550 typeof params["request"] === "object" && params["request"] !== null
551 ? (params["request"] as Record<string, unknown>)
552 : {};
553 return {
554 ...current,
555 requests: replaceMap(current.requests, (copy) => {
556 copy.set(requestId, {
557 url: String(request["url"] ?? ""),
558 method: String(request["method"] ?? "GET"),
559 });
560 }),
561 };
562 }
563 if (method === "Network.responseReceived" && requestId) {
564 const request = current.requests.get(requestId);
565 const response =
566 typeof params["response"] === "object" && params["response"] !== null
567 ? (params["response"] as Record<string, unknown>)
568 : {};
569 const status = typeof response["status"] === "number" ? response["status"] : null;
570 return request && status !== null && status >= 400
571 ? {
572 ...current,
573 networkEntries: pushBounded(current.networkEntries, {
574 ...request,
575 status,
576 failed: true,
577 timestamp,
578 }),
579 }
580 : current;
581 }
582 if (method === "Network.loadingFailed" && requestId) {
583 const request = current.requests.get(requestId);
584 return {
585 ...current,
586 requests: replaceMap(current.requests, (copy) => {
587 copy.delete(requestId);
588 }),
589 networkEntries: request
590 ? pushBounded(current.networkEntries, {
591 ...request,
592 status: null,
593 failed: true,
594 errorText: String(params["errorText"] ?? "Network request failed"),
595 timestamp,
596 })
597 : current.networkEntries,
598 };
599 }
600 if (method === "Network.loadingFinished" && requestId) {
601 return {
602 ...current,
603 requests: replaceMap(current.requests, (copy) => {
604 copy.delete(requestId);
605 }),
606 };
607 }
608 return current;
609 })();
610 return replaceMap(allDiagnostics, (copy) => {
611 copy.set(webContentsId, next);
612 });
613 });
614 });
615
616 const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* (
617 webContentsId: number,
618 ) {
619 const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [
620 sessions.get(webContentsId),
621 replaceMap(sessions, (copy) => {
622 copy.delete(webContentsId);
623 }),
624 ]);
625 yield* Ref.update(diagnosticsRef, (diagnostics) =>
626 replaceMap(diagnostics, (copy) => {
627 copy.delete(webContentsId);
628 }),
629 );
630 const wc = webContents.fromId(webContentsId);
631 if (!wc || wc.isDestroyed()) return;
632 if (control) wc.debugger.off("message", control.onMessage);
633 if (!wc.debugger.isAttached()) return;
634 yield* attempt("detachControlSession", () => wc.debugger.detach()).pipe(Effect.ignore);
635 });
636
637 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
638 wc: Electron.WebContents,
639 ) {
640 return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => {
641 const existing = sessions.get(wc.id);
642 if (existing) return Effect.succeed([existing, sessions] as const);
643 if (wc.isDevToolsOpened()) {
644 return Effect.fail(
645 fail(
646 "ensureControlSession",
647 automationError(
648 "PreviewAutomationExecutionError",
649 "Close preview DevTools before using agent browser control.",
650 ),
651 ),
652 );
653 }
654 if (wc.debugger.isAttached()) {
655 return Effect.fail(
656 fail(
657 "ensureControlSession",
658 automationError(
659 "PreviewAutomationExecutionError",
660 "Preview control cannot attach because another debugger owns this page.",
661 ),
662 ),
663 );
664 }
665 return Effect.gen(function* () {
666 const semaphore = yield* Semaphore.make(1);
667 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
668 runFork(
669 Effect.gen(function* () {
670 if (method === "Page.screencastFrame") {
671 const sessionId = params["sessionId"];
672 if (typeof sessionId === "number") {
673 yield* attemptPromise("ackScreencastFrame", () =>
674 wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
675 ).pipe(Effect.ignore);
676 }
677 const tabId = yield* tabIdForWebContents(wc.id);
678 const metadata =
679 typeof params["metadata"] === "object" && params["metadata"] !== null
680 ? (params["metadata"] as Record<string, unknown>)
681 : {};
682 if (tabId && typeof params["data"] === "string") {
683 const receivedAt = yield* currentIso;
684 const listeners = yield* Ref.get(recordingFrameListenersRef);
685 const frame: DesktopPreviewRecordingFrame = {
686 tabId,
687 data: params["data"],
688 width:
689 typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
690 height:
691 typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
692 receivedAt,
693 };
694 yield* Effect.forEach(
695 listeners,
696 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),
697 { discard: true },
698 );
699 }
700 }
701 yield* captureDiagnosticMessage(wc.id, method, params);
702 }),
703 );
704 };
705 const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage };
706 yield* Ref.update(diagnosticsRef, (diagnostics) =>
707 replaceMap(diagnostics, (copy) => {
708 copy.set(wc.id, {
709 consoleEntries: [],
710 networkEntries: [],
711 requests: new Map(),
712 });
713 }),
714 );
715 yield* attempt("attachDebuggerListeners", () => {
716 wc.debugger.on("message", onMessage);
717 wc.debugger.attach("1.3");
718 });
719 yield* Effect.all(
720 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map((method) =>
721 attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)),
722 ),
723 { concurrency: "unbounded", discard: true },
724 ).pipe(
725 Effect.tapError(() =>
726 Effect.all([
727 Ref.update(diagnosticsRef, (diagnostics) =>
728 replaceMap(diagnostics, (copy) => {
729 copy.delete(wc.id);
730 }),
731 ),
732 attempt("detachFailedDebugger", () => {
733 wc.debugger.off("message", onMessage);
734 if (wc.debugger.isAttached()) wc.debugger.detach();
735 }).pipe(Effect.ignore),
736 ]).pipe(Effect.asVoid),
737 ),
738 );
739 return [
740 control,
741 replaceMap(sessions, (copy) => {
742 copy.set(wc.id, control);
743 }),
744 ] as const;
745 });
746 });
747 });
748
749 const pushAction = (tabId: string, event: PreviewAutomationActionEvent) =>
750 Ref.update(actionTimelineRef, (timelines) =>
751 replaceMap(timelines, (copy) => {
752 copy.set(tabId, [...(timelines.get(tabId) ?? []), event].slice(-200));
753 }),
754 );
755 const replaceAction = (tabId: string, event: PreviewAutomationActionEvent) =>
756 Ref.update(actionTimelineRef, (timelines) => {
757 const timeline = timelines.get(tabId);
758 if (!timeline) return timelines;
759 return replaceMap(timelines, (copy) => {
760 copy.set(
761 tabId,
762 timeline.map((candidate) => (candidate.id === event.id ? event : candidate)),
763 );
764 });
765 });
766
767 type SendCommand = (
768 method: string,
769 commandParams?: Record<string, unknown>,
770 ) => Effect.Effect<unknown, PreviewManagerError>;
771
772 const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>(
773 tabId: string,
774 wc: Electron.WebContents,
775 action: string,
776 use: (send: SendCommand) => Effect.Effect<A, PreviewManagerError>,
777 ) {
778 const sequence = yield* nextCounter(actionSequenceRef);
779 const startedAt = yield* currentIso;
780 const millis = yield* currentMillis;
781 const actionEvent: PreviewAutomationActionEvent = {
782 id: `browser-action-${millis.toString(36)}-${sequence.toString(36)}`,
783 action,
784 status: "running",
785 startedAt,
786 };
787 yield* pushAction(tabId, actionEvent);
788 const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
789 const control = yield* ensureControlSession(wc);
790 const execute = Effect.fn("PreviewManager.executeControlAction")(function* () {
791 yield* update(tabId, { controller: "agent" });
792 const send: SendCommand = Effect.fn("PreviewManager.sendCommand")(
793 function* (method, commandParams) {
794 const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
795 if (before !== epoch) {
796 return yield* fail(
797 action,
798 automationError(
799 "PreviewAutomationControlInterruptedError",
800 "Browser control was interrupted by human input.",
801 ),
802 );
803 }
804 const result = yield* attemptPromise(action, () =>
805 wc.debugger.sendCommand(method, commandParams),
806 );
807 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
808 if (after !== epoch) {
809 return yield* fail(
810 action,
811 automationError(
812 "PreviewAutomationControlInterruptedError",
813 "Browser control was interrupted by human input.",
814 ),
815 );
816 }
817 return result;
818 },
819 );
820 return yield* use(send);
821 });
822 const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* (
823 exit: Exit.Exit<A, PreviewManagerError>,
367 ) {824 ) {
368 throw new Error("Preview artifact path is outside the configured artifact directory.");825 const completedAt = yield* currentIso;
369 }826 if (exit._tag === "Success") {
370 return resolvedPath;827 yield* replaceAction(tabId, {
371 }828 ...actionEvent,
829 status: "succeeded",
830 completedAt,
831 });
832 } else {
833 const error = Option.getOrNull(Cause.findErrorOption(exit.cause));
834 const underlying = error instanceof PreviewManagerError ? error.cause : error;
835 const interrupted =
836 underlying instanceof Error &&
837 underlying.name === "PreviewAutomationControlInterruptedError";
838 yield* replaceAction(tabId, {
839 ...actionEvent,
840 status: interrupted ? "interrupted" : "failed",
841 completedAt,
842 error: underlying instanceof Error ? underlying.message : String(underlying),
843 });
844 }
845 const tabs = yield* SynchronizedRef.get(tabsRef);
846 if (tabs.has(tabId)) yield* update(tabId, { controller: "none" });
847 });
848 return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize)));
849 });
372850
373 revealArtifact(path: string): void {851 const evaluateWithDebugger = <A = unknown>(
374 const resolvedPath = this.resolveArtifactPath(path);852 send: SendCommand,
375 shell.showItemInFolder(resolvedPath);853 expression: string,
376 }854 returnByValue: boolean,
855 awaitPromise = true,
856 ): Effect.Effect<A, PreviewManagerError> =>
857 send("Runtime.evaluate", {
858 expression,
859 awaitPromise,
860 returnByValue,
861 userGesture: true,
862 }).pipe(
863 Effect.flatMap((rawResponse) => {
864 const response = rawResponse as CdpEvaluationResult;
865 return response.exceptionDetails
866 ? Effect.fail(
867 fail(
868 "evaluate",
869 automationError(
870 "PreviewAutomationExecutionError",
871 response.exceptionDetails.exception?.description ??
872 response.exceptionDetails.text ??
873 "JavaScript evaluation failed.",
874 ),
875 ),
876 )
877 : Effect.succeed(response.result?.value as A);
878 }),
879 );
377880
378 copyArtifactToClipboard(path: string): void {881 const automationLocator = (input: {
379 const resolvedPath = this.resolveArtifactPath(path);882 readonly selector?: string | undefined;
380 const image = nativeImage.createFromPath(resolvedPath);883 readonly locator?: string | undefined;
381 if (image.isEmpty()) {884 }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null);
382 throw new Error("Preview artifact could not be loaded as an image.");
383 }
384 clipboard.writeImage(image);
385 }
386885
387 setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void {886 const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* (
388 this.annotationTheme = theme;887 send: SendCommand,
389 for (const tab of this.tabs.values()) {888 ) {
390 if (tab.webContentsId == null) continue;889 const installed = yield* evaluateWithDebugger<boolean>(
391 const wc = webContents.fromId(tab.webContentsId);890 send,
392 if (!wc || wc.isDestroyed()) continue;891 "Boolean(globalThis.__t3PlaywrightInjected)",
393 wc.send(ANNOTATION_THEME_CHANNEL, theme);892 true,
394 }893 );
395 }894 if (installed) return;
895 const expression = yield* playwrightInstallExpression;
896 yield* evaluateWithDebugger(send, expression, true);
897 });
396898
397 setMainWindow(window: BrowserWindow): void {899 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
398 this.mainWindow = window;900 tabId: string,
399 }901 ) {
902 const session = (yield* Ref.get(pickSessionsRef)).get(tabId);
903 if (session) yield* session.cancel;
904 });
400905
401 createTab(tabId: string): PreviewTabState {906 const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* (
402 const existing = this.tabs.get(tabId);907 webContentsId: number,
403 if (existing) return existing;908 ) {
404 const initial: PreviewTabState = {909 const handlers = yield* Ref.modify(attachedRef, (attached) => [
405 tabId,910 attached.get(webContentsId),
406 webContentsId: null,911 replaceMap(attached, (copy) => {
407 navStatus: { kind: "Idle" },912 copy.delete(webContentsId);
408 canGoBack: false,913 }),
409 canGoForward: false,914 ]);
410 zoomFactor: DEFAULT_ZOOM_FACTOR,915 if (!handlers) return;
411 controller: "none",916 const wc = webContents.fromId(webContentsId);
412 updatedAt: new Date().toISOString(),917 if (!wc || wc.isDestroyed()) return;
918 yield* attempt("detachListeners", () => {
919 wc.off("did-navigate", handlers.navigate);
920 wc.off("did-navigate-in-page", handlers.navigate);
921 wc.off("page-title-updated", handlers.navigate);
922 wc.off("did-start-loading", handlers.navigate);
923 wc.off("did-stop-loading", handlers.navigate);
924 wc.off("did-fail-load", handlers.failed as never);
925 wc.off("before-input-event", handlers.beforeInput);
926 wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput);
927 }).pipe(Effect.ignore);
928 });
929
930 const isAppShortcut = (input: Electron.Input): boolean =>
931 input.type === "keyDown" &&
932 APP_FORWARDED_SHORTCUTS.some(
933 (shortcut) =>
934 shortcut.key.toLowerCase() === input.key.toLowerCase() &&
935 shortcut.meta === input.meta &&
936 shortcut.shift === input.shift &&
937 shortcut.control === input.control,
938 );
939
940 const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => {
941 const url = wc.getURL();
942 const title = wc.getTitle();
943 if (url === "" || url === "about:blank") return { kind: "Idle" };
944 if (wc.isLoading()) return { kind: "Loading", url, title };
945 return { kind: "Success", url, title };
946 };
947
948 const consumeExpectedAgentInput = Effect.fn("PreviewManager.consumeExpectedAgentInput")(
949 function* (tabId: string, signal: PreviewInputSignal) {
950 const now = yield* currentMillis;
951 return yield* Ref.modify(expectedAgentInputsRef, (allExpected) => {
952 const pending = (allExpected.get(tabId) ?? []).filter(
953 (expected) => expected.expiresAt > now,
954 );
955 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));
956 const matched = index >= 0;
957 const nextPending = matched
958 ? pending.filter((_, pendingIndex) => pendingIndex !== index)
959 : pending;
960 return [
961 matched,
962 replaceMap(allExpected, (copy) => {
963 if (nextPending.length === 0) copy.delete(tabId);
964 else copy.set(tabId, nextPending);
965 }),
966 ] as const;
967 });
968 },
969 );
970
971 const expectAgentInput = Effect.fn("PreviewManager.expectAgentInput")(function* (
972 tabId: string,
973 signal: PreviewInputSignal,
974 ) {
975 const now = yield* currentMillis;
976 yield* Ref.update(expectedAgentInputsRef, (allExpected) =>
977 replaceMap(allExpected, (copy) => {
978 const pending = (allExpected.get(tabId) ?? []).filter(
979 (expected) => expected.expiresAt > now,
980 );
981 copy.set(tabId, [...pending, { signal, expiresAt: now + 1_000 }]);
982 }),
983 );
984 });
985
986 const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* (
987 tabId: string,
988 wc: Electron.WebContents,
989 ) {
990 const sync = () =>
991 runFork(
992 Effect.gen(function* () {
993 if (wc.isDestroyed()) return;
994 yield* update(tabId, {
995 navStatus: computeNavStatus(wc),
996 canGoBack: wc.navigationHistory.canGoBack(),
997 canGoForward: wc.navigationHistory.canGoForward(),
998 });
999 }),
1000 );
1001 const failed = (_event: Event, code: number, description: string): void => {
1002 if (code === -3) return;
1003 runFork(
1004 update(tabId, {
1005 navStatus: {
1006 kind: "LoadFailed",
1007 url: wc.getURL(),
1008 title: wc.getTitle(),
1009 code,
1010 description,
1011 },
1012 }),
1013 );
413 };1014 };
414 this.tabs.set(tabId, initial);1015 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
415 this.emit(tabId, initial);1016 runFork(
416 return initial;1017 Effect.gen(function* () {
417 }1018 if (
1019 isPreviewInputSignal(rawSignal) &&
1020 (yield* consumeExpectedAgentInput(tabId, rawSignal))
1021 ) {
1022 return;
1023 }
1024 yield* Ref.update(controlEpochRef, (epochs) =>
1025 replaceMap(epochs, (copy) => {
1026 copy.set(tabId, (epochs.get(tabId) ?? 0) + 1);
1027 }),
1028 );
1029 yield* update(tabId, { controller: "human" });
1030 yield* Effect.sleep(750);
1031 const tabs = yield* SynchronizedRef.get(tabsRef);
1032 if (tabs.get(tabId)?.controller === "human") {
1033 yield* update(tabId, { controller: "none" });
1034 }
1035 }),
1036 );
1037 };
1038 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1039 runFork(
1040 Effect.gen(function* () {
1041 const mainWindow = yield* Ref.get(mainWindowRef);
1042 if (
1043 !isAppShortcut(input) ||
1044 Option.isNone(mainWindow) ||
1045 mainWindow.value.isDestroyed()
1046 ) {
1047 return;
1048 }
1049 event.preventDefault();
1050 mainWindow.value.webContents.sendInputEvent({
1051 type: "keyDown",
1052 keyCode: input.key,
1053 modifiers: [
1054 ...(input.meta ? (["meta"] as const) : []),
1055 ...(input.shift ? (["shift"] as const) : []),
1056 ...(input.control ? (["control"] as const) : []),
1057 ...(input.alt ? (["alt"] as const) : []),
1058 ],
1059 });
1060 }),
1061 );
1062 };
1063 yield* attempt("attachListeners", () => {
1064 wc.on("did-navigate", sync);
1065 wc.on("did-navigate-in-page", sync);
1066 wc.on("page-title-updated", sync);
1067 wc.on("did-start-loading", sync);
1068 wc.on("did-stop-loading", sync);
1069 wc.on("did-fail-load", failed as never);
1070 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1071 wc.setWindowOpenHandler(({ url }) => {
1072 runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore));
1073 return { action: "deny" };
1074 });
1075 wc.on("before-input-event", beforeInput);
1076 });
1077 yield* Ref.update(attachedRef, (attached) =>
1078 replaceMap(attached, (copy) => {
1079 copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput });
1080 }),
1081 );
1082 });
4181083
419 closeTab(tabId: string): void {1084 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (
420 const tab = this.tabs.get(tabId);1085 window: BrowserWindow,
1086 ) {
1087 yield* Ref.set(mainWindowRef, Option.some(window));
1088 });
1089
1090 const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {
1091 const updatedAt = yield* currentIso;
1092 const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1093 const existing = tabs.get(tabId);
1094 if (existing) return [existing, tabs] as const;
1095 const initial: PreviewTabState = {
1096 tabId,
1097 webContentsId: null,
1098 navStatus: { kind: "Idle" },
1099 canGoBack: false,
1100 canGoForward: false,
1101 zoomFactor: DEFAULT_ZOOM_FACTOR,
1102 controller: "none",
1103 updatedAt,
1104 };
1105 return [
1106 initial,
1107 replaceMap(tabs, (copy) => {
1108 copy.set(tabId, initial);
1109 }),
1110 ] as const;
1111 });
1112 yield* emit(tabId, state);
1113 return state;
1114 });
1115
1116 const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) {
1117 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
421 if (!tab) return;1118 if (!tab) return;
422 this.cancelPickElement(tabId);1119 yield* cancelPickElement(tabId);
423 if (tab.webContentsId != null) {1120 if (tab.webContentsId != null) {
424 this.detachControlSession(tab.webContentsId);1121 yield* Effect.all(
425 this.detachListeners(tab.webContentsId);1122 [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)],
1123 { concurrency: 2, discard: true },
1124 );
426 }1125 }
1126 const updatedAt = yield* currentIso;
427 const closed: PreviewTabState = {1127 const closed: PreviewTabState = {
428 ...tab,1128 ...tab,
429 webContentsId: null,1129 webContentsId: null,
...@@ -432,289 +1132,336 @@ class PreviewViewManager {...@@ -432,289 +1132,336 @@ class PreviewViewManager {
432 canGoForward: false,1132 canGoForward: false,
433 zoomFactor: DEFAULT_ZOOM_FACTOR,1133 zoomFactor: DEFAULT_ZOOM_FACTOR,
434 controller: "none",1134 controller: "none",
435 updatedAt: new Date().toISOString(),1135 updatedAt,
436 };1136 };
437 this.tabs.delete(tabId);1137 yield* SynchronizedRef.update(tabsRef, (tabs) =>
438 this.emit(tabId, closed);1138 replaceMap(tabs, (copy) => {
439 }1139 copy.delete(tabId);
1140 }),
1141 );
1142 yield* emit(tabId, closed);
1143 });
4401144
441 registerWebview(tabId: string, webContentsId: number): void {1145 const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* (
442 const tab = this.tabs.get(tabId);1146 tabId: string,
1147 webContentsId: number,
1148 ) {
1149 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
443 if (!tab) {1150 if (!tab) {
444 throw new PreviewTabNotFoundError(tabId);1151 return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId));
445 }1152 }
446 const wc = webContents.fromId(webContentsId);1153 const wc = webContents.fromId(webContentsId);
447 if (!wc) {1154 const mainWindow = yield* Ref.get(mainWindowRef);
448 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);1155 if (
449 }1156 !wc ||
450 // Defence in depth: a malicious renderer could otherwise trick us into1157 wc.getType() !== "webview" ||
451 // attaching listeners to the main window's WebContents (or any other1158 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
452 // process) by passing an arbitrary id.1159 ) {
453 if (wc.getType() !== "webview") {1160 return yield* fail(
454 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);1161 "registerWebview",
455 }1162 new PreviewWebContentsNotFoundError(tabId, webContentsId),
456 if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) {1163 );
457 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
458 }1164 }
459 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {1165 const attached = yield* Ref.get(attachedRef);
460 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);1166 const annotationTheme = yield* Ref.get(annotationThemeRef);
1167 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
1168 yield* attempt("registerWebview.sendTheme", () =>
1169 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1170 );
461 return;1171 return;
462 }1172 }
463 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {1173 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
464 this.detachControlSession(tab.webContentsId);1174 yield* Effect.all(
465 this.detachListeners(tab.webContentsId);1175 [
466 // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`.1176 detachControlSession(tab.webContentsId),
467 // Cancel it so the toggle button doesn't get stuck pressed waiting1177 detachListeners(tab.webContentsId),
468 // forever for a click on a webview that no longer hosts the listener.1178 cancelPickElement(tabId),
469 this.cancelPickElement(tabId);1179 ],
1180 { concurrency: 3, discard: true },
1181 );
470 }1182 }
471 this.attachListeners(tabId, wc);1183 yield* attachListeners(tabId, wc);
472 void this.ensureControlSession(wc).catch(() => undefined);1184 runFork(ensureControlSession(wc).pipe(Effect.ignore));
473 // Restore the persisted zoom factor onto the freshly-attached WebContents
474 // so a thread-switch + remount lands the user back where they were.
475 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {1185 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
476 try {1186 yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe(
477 wc.setZoomFactor(tab.zoomFactor);1187 Effect.ignore,
478 } catch {1188 );
479 // wc may have been torn down between resolution and call.
480 }
481 }1189 }
482 this.update(tabId, {1190 yield* update(tabId, {
483 webContentsId,1191 webContentsId,
484 navStatus: this.computeNavStatus(wc),1192 navStatus: computeNavStatus(wc),
485 canGoBack: wc.navigationHistory.canGoBack(),1193 canGoBack: wc.navigationHistory.canGoBack(),
486 canGoForward: wc.navigationHistory.canGoForward(),1194 canGoForward: wc.navigationHistory.canGoForward(),
487 zoomFactor: tab.zoomFactor,1195 zoomFactor: tab.zoomFactor,
488 });1196 });
489 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);1197 yield* attempt("registerWebview.sendTheme", () =>
490 }1198 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1199 );
1200 });
4911201
492 async navigate(tabId: string, rawUrl: string): Promise<void> {1202 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
493 const wc = this.requireWebContents(tabId);1203 const wc = yield* requireWebContents(tabId);
494 const url = this.normalizeUrl(rawUrl);1204 const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl));
495 if (wc.getURL() === url) {1205 if (wc.getURL() === url) {
496 wc.reload();1206 yield* attempt("navigate.reload", () => wc.reload());
497 return;1207 return;
498 }1208 }
499 await wc.loadURL(url);1209 yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url));
500 }1210 });
501
502 goBack(tabId: string): void {
503 const wc = this.requireWebContents(tabId);
504 if (wc.navigationHistory.canGoBack()) {
505 wc.navigationHistory.goBack();
506 }
507 }
508
509 goForward(tabId: string): void {
510 const wc = this.requireWebContents(tabId);
511 if (wc.navigationHistory.canGoForward()) {
512 wc.navigationHistory.goForward();
513 }
514 }
5151211
516 refresh(tabId: string): void {1212 const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* (
517 const wc = this.requireWebContents(tabId);1213 operation: string,
518 wc.reload();1214 tabId: string,
519 }1215 use: (wc: Electron.WebContents) => void,
1216 ) {
1217 const wc = yield* requireWebContents(tabId);
1218 yield* attempt(operation, () => use(wc));
1219 });
5201220
521 /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */1221 const goBack = (tabId: string) =>
522 hardReload(tabId: string): void {1222 withWebContents("goBack", tabId, (wc) => {
523 const wc = this.requireWebContents(tabId);1223 if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
524 wc.reloadIgnoringCache();1224 });
525 }1225 const goForward = (tabId: string) =>
1226 withWebContents("goForward", tabId, (wc) => {
1227 if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward();
1228 });
1229 const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload());
1230 const hardReload = (tabId: string) =>
1231 withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache());
5261232
527 /**1233 const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) {
528 * Open the guest webview's DevTools, detached so it doesn't steal panel1234 const wc = yield* requireWebContents(tabId);
529 * area. Idempotent — re-invoking focuses the existing window.
530 */
531 openDevTools(tabId: string): void {
532 const wc = this.requireWebContents(tabId);
533 if (wc.isDevToolsOpened()) {1235 if (wc.isDevToolsOpened()) {
534 wc.devToolsWebContents?.focus();1236 yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus());
535 return;1237 return;
536 }1238 }
537 this.detachControlSession(wc.id);1239 yield* detachControlSession(wc.id);
538 wc.once("devtools-closed", () => {1240 yield* attempt("openDevTools", () => {
539 if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined);1241 wc.once("devtools-closed", () => {
1242 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));
1243 });
1244 wc.openDevTools({ mode: "detach" });
540 });1245 });
541 wc.openDevTools({ mode: "detach" });1246 });
542 }
5431247
544 /**1248 const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* (
545 * Activate annotation mode for `tabId`. Resolves after the guest submits a1249 theme: DesktopPreviewAnnotationTheme,
546 * multi-target annotation and the desktop process captures its screenshot,1250 ) {
547 * or with `null` when the user cancels.1251 yield* Ref.set(annotationThemeRef, theme);
548 *1252 const tabs = yield* SynchronizedRef.get(tabsRef);
549 * Exactly one pick session may be active per tab — re-invoking while a1253 yield* Effect.forEach(
550 * pick is in flight cleanly resolves the old session with `null` first.1254 tabs.values(),
551 */1255 (tab) => {
552 async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> {1256 if (tab.webContentsId == null) return Effect.void;
553 const wc = this.requireWebContents(tabId);1257 const wc = webContents.fromId(tab.webContentsId);
554 this.cancelPickElement(tabId);1258 return !wc || wc.isDestroyed()
555 return new Promise<PreviewAnnotationPayload | null>((resolve) => {1259 ? Effect.void
556 // `wc.ipc` is the per-WebContents IpcMain that receives messages the1260 : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe(
557 // webview's preload sends with `ipcRenderer.send(...)`. We use that1261 Effect.ignore,
558 // (not the global `wc.on("ipc-message", ...)`, which is for1262 );
559 // `sendToHost` and only fires on the host renderer's <webview>1263 },
560 // element) so the main process actually observes the picked payload.1264 { discard: true },
561 const cleanup = () => {1265 );
562 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);1266 });
563 wc.off("destroyed", onDestroyed);1267
564 wc.off("did-start-navigation", onNavigated);1268 const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) {
565 this.pickSessions.delete(tabId);1269 const wc = yield* requireWebContents(tabId);
566 };1270 yield* cancelPickElement(tabId);
567 const session: PickSession = { resolve, cleanup };1271 const annotationTheme = yield* Ref.get(annotationThemeRef);
568 const settle = (payload: PreviewAnnotationPayload | null) => {1272 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
569 if (this.pickSessions.get(tabId) !== session) return;1273 (resume) => {
570 cleanup();1274 const cleanup = Effect.gen(function* () {
571 resolve(payload);1275 yield* attempt("pickElement.cleanup", () => {
572 };1276 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
573 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {1277 wc.off("destroyed", onDestroyed);
574 const payload = args[0];1278 wc.off("did-start-navigation", onNavigated);
575 if (payload == null) {1279 }).pipe(Effect.ignore);
576 settle(null);1280 yield* Ref.update(pickSessionsRef, (sessions) =>
577 return;1281 replaceMap(sessions, (copy) => {
578 }1282 copy.delete(tabId);
579 if (!isPreviewAnnotationPayload(payload)) {1283 }),
580 settle(null);1284 );
581 return;1285 });
582 }1286 const settle = (payload: PreviewAnnotationPayload | null) => {
583 const cropRect = normalizeCaptureRect(args[1]);1287 runFork(
584 void captureAnnotationScreenshot(wc, cropRect)1288 Effect.gen(function* () {
585 .then((screenshot) => settle({ ...payload, screenshot }))1289 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);
586 .catch(() => settle(payload))1290 if (!active || active.cancel !== cancel) return;
587 .finally(() => {1291 yield* cleanup;
588 if (wc.isDestroyed()) return;1292 resume(Effect.succeed(payload));
589 try {1293 }),
590 wc.send(ANNOTATION_CAPTURED_CHANNEL);1294 );
591 } catch {1295 };
592 // The guest may have navigated while capture was in flight.1296 const cancel = Effect.gen(function* () {
1297 yield* cleanup;
1298 const tabs = yield* SynchronizedRef.get(tabsRef);
1299 const activeTab = tabs.get(tabId);
1300 if (activeTab?.webContentsId != null) {
1301 const activeWc = webContents.fromId(activeTab.webContentsId);
1302 if (activeWc && !activeWc.isDestroyed()) {
1303 yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe(
1304 Effect.ignore,
1305 );
593 }1306 }
594 });1307 }
595 };1308 resume(Effect.succeed(null));
596 const onDestroyed = () => settle(null);1309 });
597 const onNavigated = () => settle(null);1310 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
598 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);1311 const payload = args[0];
599 wc.once("destroyed", onDestroyed);1312 if (!isPreviewAnnotationPayload(payload)) {
600 // A page navigation (incl. SPA → same-document) tears down the1313 settle(null);
601 // preload's listeners, so we cancel proactively to avoid hanging.1314 return;
602 wc.once("did-start-navigation", onNavigated);1315 }
603 this.pickSessions.set(tabId, session);1316 const cropRect = normalizeCaptureRect(args[1]);
604 // Force-focus the guest webContents BEFORE sending start-pick. Without1317 runFork(
605 // this, Electron's input router will deliver the user's first1318 captureAnnotationScreenshot(wc, cropRect).pipe(
606 // mousemove/click to the host renderer (where the pick button lives)1319 Effect.matchEffect({
607 // instead of to the guest's window listeners — manifest as "the1320 onFailure: () => Effect.sync(() => settle(payload)),
608 // picker overlay never appears on remote pages I haven't clicked1321 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),
609 // into yet". The renderer-side handler in `PreviewView` is responsible1322 }),
610 // for restoring focus to the previously-active host element when the1323 Effect.ensuring(
611 // pick promise resolves so the user's textarea cursor isn't lost.1324 attempt("pickElement.captureComplete", () => {
612 try {1325 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);
613 if (!wc.isFocused()) wc.focus();1326 }).pipe(Effect.ignore),
614 } catch {1327 ),
615 // wc may be torn down; the next try/catch settles.1328 ),
616 }1329 );
617 try {1330 };
618 wc.send(START_PICK_CHANNEL, this.annotationTheme);1331 const onDestroyed = () => settle(null);
619 } catch {1332 const onNavigated = () => settle(null);
620 settle(null);1333 runFork(
621 }1334 Effect.gen(function* () {
622 });1335 yield* attempt("pickElement.register", () => {
623 }1336 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
1337 wc.once("destroyed", onDestroyed);
1338 wc.once("did-start-navigation", onNavigated);
1339 if (!wc.isFocused()) wc.focus();
1340 wc.send(START_PICK_CHANNEL, annotationTheme);
1341 });
1342 yield* Ref.update(pickSessionsRef, (sessions) =>
1343 replaceMap(sessions, (copy) => {
1344 copy.set(tabId, { cancel });
1345 }),
1346 );
1347 }).pipe(
1348 Effect.catch((error: PreviewManagerError) => {
1349 resume(Effect.fail(error));
1350 return cleanup;
1351 }),
1352 ),
1353 );
1354 return cancel;
1355 },
1356 );
1357 });
6241358
625 cancelPickElement(tabId: string): void {1359 const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* (
626 const session = this.pickSessions.get(tabId);1360 tabId: string,
627 if (!session) return;1361 transform: (current: number) => number,
628 session.cleanup();1362 ) {
629 // Best-effort: tell the page to dismiss the overlay even if it's still1363 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
630 // alive — keeps the next invoke fresh.1364 if (!tab) return;
631 const tab = this.tabs.get(tabId);1365 const next = transform(tab.zoomFactor);
632 if (tab?.webContentsId != null) {1366 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
1367 if (tab.webContentsId != null) {
633 const wc = webContents.fromId(tab.webContentsId);1368 const wc = webContents.fromId(tab.webContentsId);
634 if (wc && !wc.isDestroyed()) {1369 if (wc && !wc.isDestroyed()) {
635 try {1370 yield* attempt("applyZoom", () => wc.setZoomFactor(next));
636 wc.send(CANCEL_PICK_CHANNEL);
637 } catch {
638 // wc may have been torn down; nothing to clean up.
639 }
640 }1371 }
641 }1372 }
642 session.resolve(null);1373 yield* update(tabId, { zoomFactor: next });
643 }1374 });
6441375
645 async startRecording(tabId: string): Promise<void> {1376 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
646 if (this.recordingTabId && this.recordingTabId !== tabId) {1377 tabId: string,
647 throw new Error("Only one browser recording can be active per window.");1378 ) {
648 }1379 const wc = yield* requireWebContents(tabId);
649 const wc = this.requireWebContents(tabId);1380 const [createdAt, millis, image] = yield* Effect.all([
650 await this.withControlSession(tabId, wc, "recording.start", async (send) => {1381 currentIso,
651 await send("Page.enable");1382 currentMillis,
652 await send("Page.startScreencast", {1383 attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()),
653 format: "jpeg",1384 ]);
654 quality: 80,1385 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;
655 maxWidth: 1600,1386 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`);
656 maxHeight: 1200,1387 const data = image.toPNG();
657 everyNthFrame: 1,1388 yield* fileSystem
658 });1389 .makeDirectory(resolvedArtifactDirectory, { recursive: true })
659 });1390 .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause)));
660 this.recordingTabId = tabId;1391 yield* fileSystem
661 }1392 .writeFile(artifactPath, data)
1393 .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause)));
1394 return {
1395 id,
1396 tabId,
1397 path: artifactPath,
1398 mimeType: "image/png" as const,
1399 sizeBytes: data.byteLength,
1400 createdAt,
1401 };
1402 });
6621403
663 async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> {1404 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
664 const wc = this.requireWebContents(tabId);1405 const recordingTabId = yield* Ref.get(recordingTabIdRef);
665 const createdAt = new Date().toISOString();1406 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
666 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`;1407 return yield* fail(
667 const directory = this.requireArtifactDirectory();1408 "startRecording",
668 const path = join(directory, `${id}.png`);1409 new Error("Only one browser recording can be active per window."),
669 const data = (await wc.capturePage()).toPNG();1410 );
670 await mkdir(directory, { recursive: true });1411 }
671 await writeFile(path, data);1412 const wc = yield* requireWebContents(tabId);
672 return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt };1413 yield* withControlSession(tabId, wc, "recording.start", (send) =>
673 }1414 Effect.gen(function* () {
1415 yield* send("Page.enable");
1416 yield* send("Page.startScreencast", {
1417 format: "jpeg",
1418 quality: 80,
1419 maxWidth: 1600,
1420 maxHeight: 1200,
1421 everyNthFrame: 1,
1422 });
1423 }),
1424 );
1425 yield* Ref.set(recordingTabIdRef, Option.some(tabId));
1426 });
6741427
675 async stopRecording(tabId: string): Promise<void> {1428 const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) {
676 if (this.recordingTabId !== tabId) return;1429 const recordingTabId = yield* Ref.get(recordingTabIdRef);
677 const wc = this.requireWebContents(tabId);1430 if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return;
678 await this.withControlSession(tabId, wc, "recording.stop", async (send) => {1431 const wc = yield* requireWebContents(tabId);
679 await send("Page.stopScreencast");1432 yield* withControlSession(tabId, wc, "recording.stop", (send) =>
680 });1433 send("Page.stopScreencast").pipe(Effect.asVoid),
681 this.recordingTabId = null;1434 );
682 }1435 yield* Ref.set(recordingTabIdRef, Option.none());
1436 });
6831437
684 async saveRecording(1438 const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* (
685 tabId: string,1439 tabId: string,
686 mimeType: string,1440 mimeType: string,
687 data: Uint8Array,1441 data: Uint8Array,
688 ): Promise<DesktopPreviewRecordingArtifact> {1442 ) {
689 const createdAt = new Date().toISOString();1443 const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]);
690 const id = `browser-recording-${Date.now().toString(36)}`;1444 const id = `browser-recording-${millis.toString(36)}`;
691 const extension = mimeType.includes("mp4") ? "mp4" : "webm";1445 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
692 const directory = this.requireArtifactDirectory();1446 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
693 const path = join(directory, `${id}.${extension}`);1447 yield* fileSystem
694 await mkdir(directory, { recursive: true });1448 .makeDirectory(resolvedArtifactDirectory, { recursive: true })
695 await writeFile(path, data);1449 .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause)));
696 return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt };1450 yield* fileSystem
697 }1451 .writeFile(artifactPath, data)
6981452 .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause)));
699 onRecordingFrame(listener: RecordingFrameListener): () => void {1453 return {
700 this.recordingFrameListeners.add(listener);1454 id,
701 return () => this.recordingFrameListeners.delete(listener);1455 tabId,
702 }1456 path: artifactPath,
7031457 mimeType,
704 zoomIn(tabId: string): void {1458 sizeBytes: data.byteLength,
705 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));1459 createdAt,
706 }1460 };
7071461 });
708 zoomOut(tabId: string): void {
709 this.applyZoom(tabId, (current) => nextZoomLevel(current, "out"));
710 }
711
712 resetZoom(tabId: string): void {
713 this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR);
714 }
7151462
716 automationStatus(tabId: string): PreviewAutomationStatus {1463 const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) {
717 const tab = this.tabs.get(tabId);1464 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
718 if (!tab || tab.webContentsId == null) {1465 if (!tab || tab.webContentsId == null) {
719 const navStatus = tab?.navStatus;1466 const navStatus = tab?.navStatus;
720 return {1467 return {
...@@ -727,223 +1474,266 @@ class PreviewViewManager {...@@ -727,223 +1474,266 @@ class PreviewViewManager {
727 };1474 };
728 }1475 }
729 const wc = webContents.fromId(tab.webContentsId);1476 const wc = webContents.fromId(tab.webContentsId);
730 if (!wc || wc.isDestroyed()) {1477 return !wc || wc.isDestroyed()
731 return {1478 ? {
732 available: false,1479 available: false,
733 visible: true,1480 visible: true,
734 tabId,1481 tabId,
735 url: null,1482 url: null,
736 title: null,1483 title: null,
737 loading: false,1484 loading: false,
738 };1485 }
739 }1486 : {
740 return {1487 available: true,
741 available: true,1488 visible: true,
742 visible: true,1489 tabId,
743 tabId,1490 url: wc.getURL() || null,
744 url: wc.getURL() || null,1491 title: wc.getTitle() || null,
745 title: wc.getTitle() || null,1492 loading: wc.isLoading(),
746 loading: wc.isLoading(),1493 };
747 };1494 });
748 }
7491495
750 async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> {1496 const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (
751 const wc = this.requireWebContents(tabId);1497 tabId: string,
752 return this.withControlSession(tabId, wc, "snapshot", async (send) => {1498 ) {
753 await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]);1499 const wc = yield* requireWebContents(tabId);
754 const page = await this.evaluateWithDebugger<{1500 return yield* withControlSession(tabId, wc, "snapshot", (send) =>
755 url: string;1501 Effect.gen(function* () {
756 title: string;1502 yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], {
757 loading: boolean;1503 concurrency: 2,
758 visibleText: string;1504 discard: true,
759 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];1505 });
760 }>(1506 const page = yield* evaluateWithDebugger<{
761 send,1507 url: string;
762 `(() => {1508 title: string;
763 const selectorFor = (element) => {1509 loading: boolean;
764 if (element.id) return "#" + CSS.escape(element.id);1510 visibleText: string;
765 for (const attribute of ["data-testid", "name"]) {1511 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
766 const value = element.getAttribute(attribute);1512 }>(
767 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";1513 send,
768 }1514 `(() => {
769 const parts = [];1515 const selectorFor = (element) => {
770 let current = element;1516 if (element.id) return "#" + CSS.escape(element.id);
771 while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) {1517 for (const attribute of ["data-testid", "name"]) {
772 let part = current.tagName.toLowerCase();1518 const value = element.getAttribute(attribute);
773 const parent = current.parentElement;1519 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
774 if (parent) {
775 const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
776 if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")";
777 }1520 }
778 parts.unshift(part);1521 const buildParts = (current, parts = []) => {
779 current = parent;1522 if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) {
780 }1523 return parts;
781 return parts.join(" > ");1524 }
782 };1525 const parent = current.parentElement;
783 const visible = (element) => {1526 const siblings = parent
784 const style = getComputedStyle(element);1527 ? Array.from(parent.children).filter((child) => child.tagName === current.tagName)
785 const rect = element.getBoundingClientRect();1528 : [];
786 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;1529 const base = current.tagName.toLowerCase();
787 };1530 const part = siblings.length > 1
788 const elements = Array.from(document.querySelectorAll(1531 ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"
789 "a[href],button,input,textarea,select,[role],[tabindex]"1532 : base;
790 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {1533 return buildParts(parent, [part, ...parts]);
791 const rect = element.getBoundingClientRect();1534 };
1535 return buildParts(element).join(" > ");
1536 };
1537 const visible = (element) => {
1538 const style = getComputedStyle(element);
1539 const rect = element.getBoundingClientRect();
1540 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
1541 };
1542 const elements = Array.from(document.querySelectorAll(
1543 "a[href],button,input,textarea,select,[role],[tabindex]"
1544 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
1545 const rect = element.getBoundingClientRect();
1546 return {
1547 tag: element.tagName.toLowerCase(),
1548 role: element.getAttribute("role"),
1549 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
1550 selector: selectorFor(element),
1551 x: rect.x,
1552 y: rect.y,
1553 width: rect.width,
1554 height: rect.height
1555 };
1556 });
792 return {1557 return {
793 tag: element.tagName.toLowerCase(),1558 url: location.href,
794 role: element.getAttribute("role"),1559 title: document.title,
795 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",1560 loading: document.readyState !== "complete",
796 selector: selectorFor(element),1561 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
797 x: rect.x,1562 interactiveElements: elements
798 y: rect.y,
799 width: rect.width,
800 height: rect.height
801 };1563 };
802 });1564 })()`,
803 return {1565 true,
804 url: location.href,1566 );
805 title: document.title,1567 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
806 loading: document.readyState !== "complete",1568 send("Accessibility.getFullAXTree"),
807 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),1569 attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()),
808 interactiveElements: elements1570 Ref.get(diagnosticsRef),
809 };1571 Ref.get(actionTimelineRef),
1572 ]);
1573 const sourceSize = sourceImage.getSize();
1574 const image =
1575 sourceSize.width > MAX_SCREENSHOT_WIDTH
1576 ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH })
1577 : sourceImage;
1578 const size = image.getSize();
1579 const browserDiagnostics = diagnostics.get(wc.id);
1580 return {
1581 ...page,
1582 accessibilityTree: accessibility,
1583 consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])],
1584 networkEntries: [...(browserDiagnostics?.networkEntries ?? [])],
1585 actionTimeline: [...(timelines.get(tabId) ?? [])],
1586 screenshot: {
1587 mimeType: "image/png" as const,
1588 data: image.toPNG().toString("base64"),
1589 width: size.width,
1590 height: size.height,
1591 },
1592 };
1593 }),
1594 );
1595 });
1596
1597 const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* (
1598 send: SendCommand,
1599 input: PreviewAutomationClickInput,
1600 ) {
1601 if (!("selector" in input) && !("locator" in input)) {
1602 return { x: input.x!, y: input.y! };
1603 }
1604 const locator = automationLocator(input)!;
1605 yield* ensurePlaywrightInjected(send);
1606 const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator);
1607 const point = yield* evaluateWithDebugger<
1608 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
1609 >(
1610 send,
1611 `(() => {
1612 try {
1613 const injected = globalThis.__t3PlaywrightInjected;
1614 const parsed = injected.parseSelector(${locatorJson});
1615 const element = injected.querySelector(parsed, document, true);
1616 if (!element) return { notFound: true };
1617 const visible = injected.elementState(element, "visible");
1618 const enabled = injected.elementState(element, "enabled");
1619 if (!visible.matches || !enabled.matches) return { notFound: true };
1620 element.scrollIntoView({ block: "center", inline: "center" });
1621 const rect = element.getBoundingClientRect();
1622 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
1623 } catch (error) {
1624 return { invalidSelector: true, message: String(error) };
1625 }
810 })()`,1626 })()`,
811 true,1627 true,
1628 );
1629 if ("invalidSelector" in point) {
1630 return yield* fail(
1631 "automationClick",
1632 automationError("PreviewAutomationInvalidSelectorError", point.message, {
1633 selector: locator,
1634 }),
812 );1635 );
813 const accessibility = await send("Accessibility.getFullAXTree");1636 }
814 let image = await wc.capturePage();1637 if ("notFound" in point) {
815 let size = image.getSize();1638 return yield* fail(
816 if (size.width > MAX_SCREENSHOT_WIDTH) {1639 "automationClick",
817 image = image.resize({ width: MAX_SCREENSHOT_WIDTH });1640 automationError(
818 size = image.getSize();1641 "PreviewAutomationExecutionError",
819 }1642 `No element matches locator ${locator}.`,
820 return {1643 ),
821 ...page,1644 );
822 accessibilityTree: accessibility,1645 }
823 consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])],1646 return point;
824 networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])],1647 });
825 actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])],
826 screenshot: {
827 mimeType: "image/png",
828 data: image.toPNG().toString("base64"),
829 width: size.width,
830 height: size.height,
831 },
832 };
833 });
834 }
8351648
836 async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> {1649 const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* (
837 const wc = this.requireWebContents(tabId);1650 event: DesktopPreviewPointerEvent,
838 await this.withControlSession(tabId, wc, "click", async (send) => {1651 ) {
839 await Promise.all([1652 const listeners = yield* Ref.get(pointerEventListenersRef);
840 send("Runtime.enable"),1653 yield* Effect.forEach(
841 send("Input.setIgnoreInputEvents", { ignore: false }),1654 listeners,
842 ]);1655 (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore),
843 let x: number;1656 { discard: true },
844 let y: number;1657 );
845 if ("selector" in input || "locator" in input) {1658 });
846 await this.ensurePlaywrightInjected(send);1659
847 const locator = this.automationLocator(input);1660 const automationClick = Effect.fn("PreviewManager.automationClick")(function* (
848 const point = await this.evaluateWithDebugger<1661 tabId: string,
849 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }1662 input: PreviewAutomationClickInput,
850 >(1663 ) {
1664 const wc = yield* requireWebContents(tabId);
1665 yield* withControlSession(tabId, wc, "click", (send) =>
1666 Effect.gen(function* () {
1667 yield* Effect.all(
1668 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
1669 { concurrency: 2, discard: true },
1670 );
1671 const point = yield* resolveClickPoint(send, input);
1672 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
851 send,1673 send,
852 `(() => {1674 "({ width: window.innerWidth, height: window.innerHeight })",
853 try {
854 const injected = globalThis.__t3PlaywrightInjected;
855 const parsed = injected.parseSelector(${JSON.stringify(locator)});
856 const element = injected.querySelector(parsed, document, true);
857 if (!element) return { notFound: true };
858 const visible = injected.elementState(element, "visible");
859 const enabled = injected.elementState(element, "enabled");
860 if (!visible.matches || !enabled.matches) return { notFound: true };
861 element.scrollIntoView({ block: "center", inline: "center" });
862 const rect = element.getBoundingClientRect();
863 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
864 } catch (error) {
865 return { invalidSelector: true, message: String(error) };
866 }
867 })()`,
868 true,1675 true,
869 );1676 );
870 if ("invalidSelector" in point) {1677 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
871 throw automationError("PreviewAutomationInvalidSelectorError", point.message, {1678 return yield* fail(
872 selector: locator,1679 "automationClick",
873 });1680 automationError(
874 }1681 "PreviewAutomationExecutionError",
875 if ("notFound" in point) {1682 `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`,
876 throw automationError(1683 ),
877 "PreviewAutomationExecutionError",
878 `No element matches locator ${locator}.`,
879 );1684 );
880 }1685 }
881 x = point.x;1686 const moveSequence = yield* nextCounter(pointerSequenceRef);
882 y = point.y;1687 const moveCreatedAt = yield* currentIso;
883 } else {1688 yield* emitPointerEvent({
884 x = input.x!;1689 tabId,
885 y = input.y!;1690 phase: "move",
886 }1691 ...point,
887 const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>(1692 sequence: moveSequence,
888 send,1693 createdAt: moveCreatedAt,
889 "({ width: window.innerWidth, height: window.innerHeight })",1694 });
890 true,1695 yield* Effect.sleep(AGENT_CURSOR_MOVE_MS);
891 );1696 const clickSequence = yield* nextCounter(pointerSequenceRef);
892 if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) {1697 const clickCreatedAt = yield* currentIso;
893 throw automationError(1698 yield* emitPointerEvent({
894 "PreviewAutomationExecutionError",1699 tabId,
895 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,1700 phase: "click",
896 );1701 ...point,
897 }1702 sequence: clickSequence,
898 this.emitPointerEvent({1703 createdAt: clickCreatedAt,
899 tabId,1704 });
900 phase: "move",1705 yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS);
901 x,1706 yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 });
902 y,1707 yield* send("Input.dispatchMouseEvent", {
903 sequence: this.pointerSequence++,1708 type: "mousePressed",
904 createdAt: new Date().toISOString(),1709 ...point,
905 });1710 button: "left",
906 await sleep(AGENT_CURSOR_MOVE_MS);1711 clickCount: 1,
907 this.emitPointerEvent({1712 });
908 tabId,1713 yield* send("Input.dispatchMouseEvent", {
909 phase: "click",1714 type: "mouseReleased",
910 x,1715 ...point,
911 y,1716 button: "left",
912 sequence: this.pointerSequence++,1717 clickCount: 1,
913 createdAt: new Date().toISOString(),1718 });
914 });1719 }),
915 await sleep(AGENT_CURSOR_CLICK_LEAD_MS);1720 );
916 this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 });1721 });
917 await send("Input.dispatchMouseEvent", {
918 type: "mousePressed",
919 x,
920 y,
921 button: "left",
922 clickCount: 1,
923 });
924 await send("Input.dispatchMouseEvent", {
925 type: "mouseReleased",
926 x,
927 y,
928 button: "left",
929 clickCount: 1,
930 });
931 });
932 }
9331722
934 async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> {1723 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (
935 const wc = this.requireWebContents(tabId);1724 send: SendCommand,
936 await this.withControlSession(tabId, wc, "type", async (send) => {1725 input: PreviewAutomationTypeInput,
937 await send("Runtime.enable");1726 ) {
938 const locator = this.automationLocator(input);1727 const locator = automationLocator(input);
939 if (locator) await this.ensurePlaywrightInjected(send);1728 if (locator) yield* ensurePlaywrightInjected(send);
940 const focusResult = await this.evaluateWithDebugger<1729 const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null;
941 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }1730 const result = yield* evaluateWithDebugger<
942 >(1731 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
943 send,1732 >(
944 `(() => {1733 send,
1734 `(() => {
945 try {1735 try {
946 const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"};1736 const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"};
947 if (!element) return { notFound: true };1737 if (!element) return { notFound: true };
948 element.focus();1738 element.focus();
949 if (${input.clear ?? false}) {1739 if (${input.clear ?? false}) {
...@@ -956,151 +1746,110 @@ class PreviewViewManager {...@@ -956,151 +1746,110 @@ class PreviewViewManager {
956 return { invalidSelector: true, message: String(error) };1746 return { invalidSelector: true, message: String(error) };
957 }1747 }
958 })()`,1748 })()`,
959 true,1749 true,
960 );1750 );
961 if ("invalidSelector" in focusResult) {1751 if ("invalidSelector" in result) {
962 throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, {1752 return yield* fail(
1753 "automationType",
1754 automationError("PreviewAutomationInvalidSelectorError", result.message, {
963 selector: input.selector ?? "",1755 selector: input.selector ?? "",
964 });1756 }),
965 }1757 );
966 if ("notFound" in focusResult) {1758 }
967 throw automationError(1759 if ("notFound" in result) {
1760 return yield* fail(
1761 "automationType",
1762 automationError(
968 "PreviewAutomationExecutionError",1763 "PreviewAutomationExecutionError",
969 locator1764 locator
970 ? `No element matches locator ${locator}.`1765 ? `No element matches locator ${locator}.`
971 : "No element is focused in the preview.",1766 : "No element is focused in the preview.",
972 );1767 ),
973 }
974 await send("Input.insertText", { text: input.text });
975 await this.evaluateWithDebugger(
976 send,
977 `(() => {
978 const element = document.activeElement;
979 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} }));
980 element?.dispatchEvent(new Event("change", { bubbles: true }));
981 })()`,
982 false,
983 );1768 );
984 });1769 }
985 }1770 });
986
987 async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> {
988 const wc = this.requireWebContents(tabId);
989 await this.withControlSession(tabId, wc, "press", async (send) => {
990 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
991 switch (modifier) {
992 case "Alt":
993 return value | 1;
994 case "Control":
995 return value | 2;
996 case "Meta":
997 return value | 4;
998 case "Shift":
999 return value | 8;
1000 }
1001 }, 0);
1002 const key = input.key;
1003 const text = key.length === 1 ? key : undefined;
1004 const params = {
1005 key,
1006 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
1007 modifiers,
1008 ...(text ? { text, unmodifiedText: text } : {}),
1009 };
1010 this.expectAgentInput(tabId, { kind: "key", key, code: params.code });
1011 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
1012 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
1013 });
1014 }
10151771
1016 async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> {1772 const automationType = Effect.fn("PreviewManager.automationType")(function* (
1017 const wc = this.requireWebContents(tabId);1773 tabId: string,
1018 await this.withControlSession(tabId, wc, "scroll", async (send) => {1774 input: PreviewAutomationTypeInput,
1019 await send("Runtime.enable");1775 ) {
1020 const locator = this.automationLocator(input);1776 const wc = yield* requireWebContents(tabId);
1021 if (locator) await this.ensurePlaywrightInjected(send);1777 yield* withControlSession(tabId, wc, "type", (send) =>
1022 const result = await this.evaluateWithDebugger<1778 Effect.gen(function* () {
1023 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }1779 yield* send("Runtime.enable");
1024 >(1780 yield* focusAutomationTarget(send, input);
1025 send,1781 yield* send("Input.insertText", { text: input.text });
1026 `(() => {1782 const textJson = yield* encodeJson("automationType.encodeText", input.text);
1027 try {1783 yield* evaluateWithDebugger(
1028 const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"};1784 send,
1029 if (!target) return { notFound: true };1785 `(() => {
1030 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });1786 const element = document.activeElement;
1031 return { ok: true };1787 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} }));
1032 } catch (error) {1788 element?.dispatchEvent(new Event("change", { bubbles: true }));
1033 return { invalidSelector: true, message: String(error) };1789 })()`,
1034 }1790 false,
1035 })()`,
1036 true,
1037 );
1038 if ("invalidSelector" in result) {
1039 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
1040 selector: input.selector ?? "",
1041 });
1042 }
1043 if ("notFound" in result) {
1044 throw automationError(
1045 "PreviewAutomationExecutionError",
1046 `No element matches locator ${locator}.`,
1047 );1791 );
1048 }1792 }),
1049 });1793 );
1050 }1794 });
10511795
1052 async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> {1796 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
1053 const wc = this.requireWebContents(tabId);1797 tabId: string,
1054 return this.withControlSession(tabId, wc, "evaluate", async (send) => {1798 input: PreviewAutomationPressInput,
1055 await send("Runtime.enable");1799 ) {
1056 const value = await this.evaluateWithDebugger(1800 const wc = yield* requireWebContents(tabId);
1057 send,1801 yield* withControlSession(tabId, wc, "press", (send) =>
1058 input.expression,1802 Effect.gen(function* () {
1059 input.returnByValue ?? true,1803 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
1060 input.awaitPromise ?? true,1804 switch (modifier) {
1061 );1805 case "Alt":
1062 const serialized = JSON.stringify(value);1806 return value | 1;
1063 if (1807 case "Control":
1064 serialized !== undefined &&1808 return value | 2;
1065 Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES1809 case "Meta":
1066 ) {1810 return value | 4;
1067 throw automationError(1811 case "Shift":
1068 "PreviewAutomationResultTooLargeError",1812 return value | 8;
1069 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,1813 }
1070 { maximumBytes: MAX_EVALUATION_BYTES },1814 }, 0);
1071 );1815 const key = input.key;
1072 }1816 const text = key.length === 1 ? key : undefined;
1073 return value;1817 const params = {
1074 });1818 key,
1075 }1819 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
1820 modifiers,
1821 ...(text ? { text, unmodifiedText: text } : {}),
1822 };
1823 yield* expectAgentInput(tabId, { kind: "key", key, code: params.code });
1824 yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
1825 yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
1826 }),
1827 );
1828 });
10761829
1077 async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> {1830 const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* (
1078 const wc = this.requireWebContents(tabId);1831 tabId: string,
1079 const timeoutMs = input.timeoutMs ?? 15_000;1832 input: PreviewAutomationScrollInput,
1080 await this.withControlSession(tabId, wc, "waitFor", async (send) => {1833 ) {
1081 await send("Runtime.enable");1834 const wc = yield* requireWebContents(tabId);
1082 const locator = this.automationLocator(input);1835 yield* withControlSession(tabId, wc, "scroll", (send) =>
1083 if (locator) await this.ensurePlaywrightInjected(send);1836 Effect.gen(function* () {
1084 const deadline = Date.now() + timeoutMs;1837 yield* send("Runtime.enable");
1085 while (Date.now() <= deadline) {1838 const locator = automationLocator(input);
1086 const result = await this.evaluateWithDebugger<1839 if (locator) yield* ensurePlaywrightInjected(send);
1087 { matched: boolean } | { invalidSelector: true; message: string }1840 const locatorJson = locator
1841 ? yield* encodeJson("automationScroll.encodeLocator", locator)
1842 : null;
1843 const result = yield* evaluateWithDebugger<
1844 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1088 >(1845 >(
1089 send,1846 send,
1090 `(() => {1847 `(() => {
1091 try {1848 try {
1092 const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"};1849 const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"};
1093 const textMatched = ${1850 if (!target) return { notFound: true };
1094 input.text1851 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1095 ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})`1852 return { ok: true };
1096 : "true"
1097 };
1098 const urlMatched = ${
1099 input.urlIncludes
1100 ? `location.href.includes(${JSON.stringify(input.urlIncludes)})`
1101 : "true"
1102 };
1103 return { matched: selectorMatched && textMatched && urlMatched };
1104 } catch (error) {1853 } catch (error) {
1105 return { invalidSelector: true, message: String(error) };1854 return { invalidSelector: true, message: String(error) };
1106 }1855 }
...@@ -1108,563 +1857,211 @@ class PreviewViewManager {...@@ -1108,563 +1857,211 @@ class PreviewViewManager {
1108 true,1857 true,
1109 );1858 );
1110 if ("invalidSelector" in result) {1859 if ("invalidSelector" in result) {
1111 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {1860 return yield* fail(
1112 selector: input.selector ?? "",1861 "automationScroll",
1113 });1862 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1114 }1863 selector: input.selector ?? "",
1115 if (result.matched) return;1864 }),
1116 await sleep(100);
1117 }
1118 throw automationError(
1119 "PreviewAutomationTimeoutError",
1120 `Preview condition did not match within ${timeoutMs}ms.`,
1121 );
1122 });
1123 }
1124
1125 private applyZoom(tabId: string, transform: (current: number) => number): void {
1126 const tab = this.tabs.get(tabId);
1127 if (!tab) return;
1128 const next = transform(tab.zoomFactor);
1129 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
1130 if (tab.webContentsId != null) {
1131 const wc = webContents.fromId(tab.webContentsId);
1132 if (wc && !wc.isDestroyed()) {
1133 wc.setZoomFactor(next);
1134 }
1135 }
1136 this.update(tabId, { zoomFactor: next });
1137 }
1138
1139 private async withControlSession<A>(
1140 tabId: string,
1141 wc: Electron.WebContents,
1142 action: string,
1143 use: (
1144 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1145 ) => Promise<A>,
1146 ): Promise<A> {
1147 const actionEvent: PreviewAutomationActionEvent = {
1148 id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`,
1149 action,
1150 status: "running",
1151 startedAt: new Date().toISOString(),
1152 };
1153 this.pushAction(tabId, actionEvent);
1154 const epoch = this.controlEpoch.get(tabId) ?? 0;
1155 const control = await this.ensureControlSession(wc);
1156 let resolveTail: () => void = () => undefined;
1157 const previous = control.tail;
1158 control.tail = new Promise<void>((resolve) => {
1159 resolveTail = resolve;
1160 });
1161 await previous;
1162 this.update(tabId, { controller: "agent" });
1163 try {
1164 const send = async (method: string, commandParams?: Record<string, unknown>) => {
1165 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {
1166 throw automationError(
1167 "PreviewAutomationControlInterruptedError",
1168 "Browser control was interrupted by human input.",
1169 );1865 );
1170 }1866 }
1171 const result = await wc.debugger.sendCommand(method, commandParams);1867 if ("notFound" in result) {
1172 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {1868 return yield* fail(
1173 throw automationError(1869 "automationScroll",
1174 "PreviewAutomationControlInterruptedError",1870 automationError(
1175 "Browser control was interrupted by human input.",1871 "PreviewAutomationExecutionError",
1872 `No element matches locator ${locator}.`,
1873 ),
1176 );1874 );
1177 }1875 }
1178 return result;1876 }),
1179 };1877 );
1180 const result = await use(send);1878 });
1181 this.replaceAction(tabId, {
1182 ...actionEvent,
1183 status: "succeeded",
1184 completedAt: new Date().toISOString(),
1185 });
1186 return result;
1187 } catch (cause) {
1188 const interrupted =
1189 cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError";
1190 this.replaceAction(tabId, {
1191 ...actionEvent,
1192 status: interrupted ? "interrupted" : "failed",
1193 completedAt: new Date().toISOString(),
1194 error: cause instanceof Error ? cause.message : String(cause),
1195 });
1196 if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause;
1197 throw automationError(
1198 "PreviewAutomationExecutionError",
1199 cause instanceof Error ? cause.message : String(cause),
1200 { tabId, cause },
1201 );
1202 } finally {
1203 if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" });
1204 resolveTail();
1205 }
1206 }
1207
1208 private pushAction(tabId: string, event: PreviewAutomationActionEvent): void {
1209 const timeline = this.actionTimeline.get(tabId) ?? [];
1210 timeline.push(event);
1211 if (timeline.length > 200) timeline.splice(0, timeline.length - 200);
1212 this.actionTimeline.set(tabId, timeline);
1213 }
1214
1215 private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void {
1216 const timeline = this.actionTimeline.get(tabId);
1217 if (!timeline) return;
1218 const index = timeline.findIndex((candidate) => candidate.id === event.id);
1219 if (index >= 0) timeline[index] = event;
1220 }
12211879
1222 private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> {1880 const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* (
1223 const existing = this.controlSessions.get(wc.id);1881 tabId: string,
1224 if (existing) {1882 input: PreviewAutomationEvaluateInput,
1225 await existing.initialized;1883 ) {
1226 return existing;1884 const wc = yield* requireWebContents(tabId);
1227 }1885 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
1228 if (wc.isDevToolsOpened()) {1886 Effect.gen(function* () {
1229 throw automationError(1887 yield* send("Runtime.enable");
1230 "PreviewAutomationExecutionError",1888 const value = yield* evaluateWithDebugger(
1231 "Close preview DevTools before using agent browser control.",1889 send,
1232 );1890 input.expression,
1233 }1891 input.returnByValue ?? true,
1234 if (wc.debugger.isAttached()) {1892 input.awaitPromise ?? true,
1235 throw automationError(1893 );
1236 "PreviewAutomationExecutionError",1894 const serialized = yield* encodeJson("automationEvaluate.encodeResult", value);
1237 "Preview control cannot attach because another debugger owns this page.",1895 if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) {
1238 );1896 return yield* fail(
1239 }1897 "automationEvaluate",
1240 const diagnostics: BrowserDiagnostics = {1898 automationError(
1241 consoleEntries: [],1899 "PreviewAutomationResultTooLargeError",
1242 networkEntries: [],1900 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1243 requests: new Map(),1901 { maximumBytes: MAX_EVALUATION_BYTES },
1244 };1902 ),
1245 this.diagnostics.set(wc.id, diagnostics);1903 );
1246 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
1247 if (method === "Page.screencastFrame") {
1248 const frame = params;
1249 const sessionId = frame["sessionId"];
1250 if (typeof sessionId === "number") {
1251 void wc.debugger
1252 .sendCommand("Page.screencastFrameAck", { sessionId })
1253 .catch(() => undefined);
1254 }
1255 const tabId = this.tabIdForWebContents(wc.id);
1256 const metadata =
1257 typeof frame["metadata"] === "object" && frame["metadata"] !== null
1258 ? (frame["metadata"] as Record<string, unknown>)
1259 : {};
1260 if (tabId && typeof frame["data"] === "string") {
1261 const payload: DesktopPreviewRecordingFrame = {
1262 tabId,
1263 data: frame["data"],
1264 width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
1265 height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
1266 receivedAt: new Date().toISOString(),
1267 };
1268 for (const listener of this.recordingFrameListeners) listener(payload);
1269 }1904 }
1270 }1905 return value;
1271 this.captureDiagnosticMessage(diagnostics, method, params);1906 }),
1272 };
1273 const control: BrowserControlSession = {
1274 webContentsId: wc.id,
1275 tail: Promise.resolve(),
1276 initialized: Promise.resolve(),
1277 onMessage,
1278 };
1279 wc.debugger.on("message", onMessage);
1280 control.initialized = (async () => {
1281 wc.debugger.attach("1.3");
1282 await Promise.all([
1283 wc.debugger.sendCommand("Runtime.enable"),
1284 wc.debugger.sendCommand("Accessibility.enable"),
1285 wc.debugger.sendCommand("Network.enable"),
1286 wc.debugger.sendCommand("Log.enable"),
1287 ]);
1288 })();
1289 this.controlSessions.set(wc.id, control);
1290 try {
1291 await control.initialized;
1292 return control;
1293 } catch (cause) {
1294 this.controlSessions.delete(wc.id);
1295 throw cause;
1296 }
1297 }
1298
1299 private detachControlSession(webContentsId: number): void {
1300 const control = this.controlSessions.get(webContentsId);
1301 this.controlSessions.delete(webContentsId);
1302 this.diagnostics.delete(webContentsId);
1303 const wc = webContents.fromId(webContentsId);
1304 if (!wc || wc.isDestroyed()) return;
1305 if (control) {
1306 wc.debugger.off("message", control.onMessage);
1307 }
1308 if (!wc.debugger.isAttached()) return;
1309 try {
1310 wc.debugger.detach();
1311 } catch {
1312 // Target teardown can race detachment.
1313 }
1314 }
1315
1316 private tabIdForWebContents(webContentsId: number): string | null {
1317 for (const [tabId, tab] of this.tabs) {
1318 if (tab.webContentsId === webContentsId) return tabId;
1319 }
1320 return null;
1321 }
1322
1323 private captureDiagnosticMessage(
1324 diagnostics: BrowserDiagnostics,
1325 method: string,
1326 params: Record<string, unknown>,
1327 ): void {
1328 const timestamp = new Date().toISOString();
1329 if (method === "Runtime.consoleAPICalled") {
1330 const args = Array.isArray(params["args"]) ? params["args"] : [];
1331 const text = args
1332 .map((arg) => {
1333 if (typeof arg !== "object" || arg === null) return String(arg);
1334 const value = arg as Record<string, unknown>;
1335 return String(value["value"] ?? value["description"] ?? "");
1336 })
1337 .join(" ");
1338 this.pushBounded(diagnostics.consoleEntries, {
1339 level: typeof params["type"] === "string" ? params["type"] : "log",
1340 text,
1341 timestamp,
1342 source: "console",
1343 });
1344 return;
1345 }
1346 if (method === "Runtime.exceptionThrown") {
1347 const details =
1348 typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null
1349 ? (params["exceptionDetails"] as Record<string, unknown>)
1350 : {};
1351 this.pushBounded(diagnostics.consoleEntries, {
1352 level: "error",
1353 text: String(details["text"] ?? "Uncaught exception"),
1354 timestamp,
1355 source: "exception",
1356 });
1357 return;
1358 }
1359 if (method === "Log.entryAdded") {
1360 const entry =
1361 typeof params["entry"] === "object" && params["entry"] !== null
1362 ? (params["entry"] as Record<string, unknown>)
1363 : {};
1364 this.pushBounded(diagnostics.consoleEntries, {
1365 level: typeof entry["level"] === "string" ? entry["level"] : "info",
1366 text: String(entry["text"] ?? ""),
1367 timestamp,
1368 source: typeof entry["source"] === "string" ? entry["source"] : "log",
1369 });
1370 return;
1371 }
1372 const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null;
1373 if (method === "Network.requestWillBeSent" && requestId) {
1374 const request =
1375 typeof params["request"] === "object" && params["request"] !== null
1376 ? (params["request"] as Record<string, unknown>)
1377 : {};
1378 diagnostics.requests.set(requestId, {
1379 url: String(request["url"] ?? ""),
1380 method: String(request["method"] ?? "GET"),
1381 });
1382 return;
1383 }
1384 if (method === "Network.responseReceived" && requestId) {
1385 const request = diagnostics.requests.get(requestId);
1386 const response =
1387 typeof params["response"] === "object" && params["response"] !== null
1388 ? (params["response"] as Record<string, unknown>)
1389 : {};
1390 const status = typeof response["status"] === "number" ? response["status"] : null;
1391 if (request && status !== null && status >= 400) {
1392 this.pushBounded(diagnostics.networkEntries, {
1393 ...request,
1394 status,
1395 failed: true,
1396 timestamp,
1397 });
1398 }
1399 return;
1400 }
1401 if (method === "Network.loadingFailed" && requestId) {
1402 const request = diagnostics.requests.get(requestId);
1403 if (request) {
1404 this.pushBounded(diagnostics.networkEntries, {
1405 ...request,
1406 status: null,
1407 failed: true,
1408 errorText: String(params["errorText"] ?? "Network request failed"),
1409 timestamp,
1410 });
1411 }
1412 diagnostics.requests.delete(requestId);
1413 return;
1414 }
1415 if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId);
1416 }
1417
1418 private pushBounded<A>(buffer: A[], entry: A): void {
1419 buffer.push(entry);
1420 if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) {
1421 buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT);
1422 }
1423 }
1424
1425 private async evaluateWithDebugger<A = unknown>(
1426 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1427 expression: string,
1428 returnByValue: boolean,
1429 awaitPromise = true,
1430 ): Promise<A> {
1431 const response = (await send("Runtime.evaluate", {
1432 expression,
1433 awaitPromise,
1434 returnByValue,
1435 userGesture: true,
1436 })) as CdpEvaluationResult;
1437 if (response.exceptionDetails) {
1438 throw automationError(
1439 "PreviewAutomationExecutionError",
1440 response.exceptionDetails.exception?.description ??
1441 response.exceptionDetails.text ??
1442 "JavaScript evaluation failed.",
1443 );
1444 }
1445 return response.result?.value as A;
1446 }
1447
1448 private automationLocator(input: {
1449 readonly selector?: string | undefined;
1450 readonly locator?: string | undefined;
1451 }): string | null {
1452 if (input.locator) return input.locator;
1453 if (input.selector) return `css=${input.selector}`;
1454 return null;
1455 }
1456
1457 private async ensurePlaywrightInjected(
1458 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1459 ): Promise<void> {
1460 const installed = await this.evaluateWithDebugger<boolean>(
1461 send,
1462 "Boolean(globalThis.__t3PlaywrightInjected)",
1463 true,
1464 );1907 );
1465 if (installed) return;1908 });
1466 const expression = await playwrightInjectedRuntimeInstallExpression();
1467 await this.evaluateWithDebugger(send, expression, true);
1468 }
1469
1470 onStateChange(listener: Listener): () => void {
1471 this.listeners.add(listener);
1472 return () => {
1473 this.listeners.delete(listener);
1474 };
1475 }
1476
1477 onPointerEvent(listener: PointerEventListener): () => void {
1478 this.pointerEventListeners.add(listener);
1479 return () => {
1480 this.pointerEventListeners.delete(listener);
1481 };
1482 }
1483
1484 private emitPointerEvent(event: DesktopPreviewPointerEvent): void {
1485 for (const listener of this.pointerEventListeners) listener(event);
1486 }
14871909
1488 private expectAgentInput(tabId: string, signal: PreviewInputSignal): void {1910 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
1489 const now = Date.now();1911 tabId: string,
1490 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(1912 input: PreviewAutomationWaitForInput,
1491 (expected) => expected.expiresAt > now,1913 ) {
1914 const wc = yield* requireWebContents(tabId);
1915 const timeoutMs = input.timeoutMs ?? 15_000;
1916 yield* withControlSession(tabId, wc, "waitFor", (send) =>
1917 Effect.gen(function* () {
1918 yield* send("Runtime.enable");
1919 const locator = automationLocator(input);
1920 if (locator) yield* ensurePlaywrightInjected(send);
1921 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
1922 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),
1923 input.text
1924 ? encodeJson("automationWaitFor.encodeText", input.text)
1925 : Effect.succeed(null),
1926 input.urlIncludes
1927 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)
1928 : Effect.succeed(null),
1929 ]);
1930 const deadline = (yield* currentMillis) + timeoutMs;
1931 while ((yield* currentMillis) <= deadline) {
1932 const result = yield* evaluateWithDebugger<
1933 { matched: boolean } | { invalidSelector: true; message: string }
1934 >(
1935 send,
1936 `(() => {
1937 try {
1938 const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"};
1939 const textMatched = ${
1940 textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true"
1941 };
1942 const urlMatched = ${
1943 urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true"
1944 };
1945 return { matched: selectorMatched && textMatched && urlMatched };
1946 } catch (error) {
1947 return { invalidSelector: true, message: String(error) };
1948 }
1949 })()`,
1950 true,
1951 );
1952 if ("invalidSelector" in result) {
1953 return yield* fail(
1954 "automationWaitFor",
1955 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1956 selector: input.selector ?? "",
1957 }),
1958 );
1959 }
1960 if (result.matched) return;
1961 yield* Effect.sleep(100);
1962 }
1963 return yield* fail(
1964 "automationWaitFor",
1965 automationError(
1966 "PreviewAutomationTimeoutError",
1967 `Preview condition did not match within ${timeoutMs}ms.`,
1968 ),
1969 );
1970 }),
1492 );1971 );
1493 pending.push({ signal, expiresAt: now + 1_000 });1972 });
1494 this.expectedAgentInputs.set(tabId, pending);
1495 }
14961973
1497 private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean {1974 const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* (
1498 const now = Date.now();1975 artifactPath: string,
1499 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(1976 ) {
1500 (expected) => expected.expiresAt > now,1977 const resolvedPath = yield* resolveArtifactPath(artifactPath);
1501 );1978 yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath));
1502 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));1979 });
1503 if (index < 0) {
1504 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1505 else this.expectedAgentInputs.set(tabId, pending);
1506 return false;
1507 }
1508 pending.splice(index, 1);
1509 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1510 else this.expectedAgentInputs.set(tabId, pending);
1511 return true;
1512 }
15131980
1514 destroy(): void {1981 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
1515 for (const tabId of Array.from(this.tabs.keys())) {1982 artifactPath: string,
1516 this.closeTab(tabId);1983 ) {
1984 const resolvedPath = yield* resolveArtifactPath(artifactPath);
1985 const image = yield* attempt("copyArtifactToClipboard.load", () =>
1986 nativeImage.createFromPath(resolvedPath),
1987 );
1988 if (image.isEmpty()) {
1989 return yield* fail(
1990 "copyArtifactToClipboard",
1991 new Error("Preview artifact could not be loaded as an image."),
1992 );
1517 }1993 }
1518 this.listeners.clear();1994 yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image));
1519 this.expectedAgentInputs.clear();1995 });
1520 this.pointerEventListeners.clear();
1521 this.recordingFrameListeners.clear();
1522 }
1523
1524 private attachListeners(tabId: string, wc: Electron.WebContents): void {
1525 const sync = () => {
1526 if (wc.isDestroyed()) return;
1527 this.update(tabId, {
1528 navStatus: this.computeNavStatus(wc),
1529 canGoBack: wc.navigationHistory.canGoBack(),
1530 canGoForward: wc.navigationHistory.canGoForward(),
1531 });
1532 };
1533 const failed = (_event: Event, code: number, description: string): void => {
1534 // -3 = ABORTED (user navigated away mid-load); ignore.
1535 if (code === -3) return;
1536 this.update(tabId, {
1537 navStatus: {
1538 kind: "LoadFailed",
1539 url: wc.getURL(),
1540 title: wc.getTitle(),
1541 code,
1542 description,
1543 },
1544 });
1545 };
1546 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1547 if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) {
1548 return;
1549 }
1550 this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1);
1551 this.update(tabId, { controller: "human" });
1552 void sleep(750).then(() => {
1553 if (this.tabs.get(tabId)?.controller === "human") {
1554 this.update(tabId, { controller: "none" });
1555 }
1556 });
1557 };
1558
1559 wc.on("did-navigate", sync);
1560 wc.on("did-navigate-in-page", sync);
1561 wc.on("page-title-updated", sync);
1562 wc.on("did-start-loading", sync);
1563 wc.on("did-stop-loading", sync);
1564 wc.on("did-fail-load", failed as never);
1565 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1566
1567 // Keep external links inside the same view (matches ami's policy).
1568 wc.setWindowOpenHandler(({ url }) => {
1569 void wc.loadURL(url);
1570 return { action: "deny" };
1571 });
1572
1573 // Forward app-level shortcuts to the main window so mod+shift+J etc.
1574 // still toggles the preview panel even when the webview has focus.
1575 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1576 if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) {
1577 event.preventDefault();
1578 this.mainWindow.webContents.sendInputEvent({
1579 type: "keyDown",
1580 keyCode: input.key,
1581 modifiers: [
1582 ...(input.meta ? (["meta"] as const) : []),
1583 ...(input.shift ? (["shift"] as const) : []),
1584 ...(input.control ? (["control"] as const) : []),
1585 ...(input.alt ? (["alt"] as const) : []),
1586 ],
1587 });
1588 }
1589 };
1590 wc.on("before-input-event", beforeInput);
1591
1592 this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput });
1593 }
1594
1595 private detachListeners(webContentsId: number): void {
1596 const handlers = this.attached.get(webContentsId);
1597 if (!handlers) return;
1598 this.attached.delete(webContentsId);
1599 const wc = webContents.fromId(webContentsId);
1600 if (!wc || wc.isDestroyed()) return;
1601 wc.off("did-navigate", handlers.navigate);
1602 wc.off("did-navigate-in-page", handlers.navigate);
1603 wc.off("page-title-updated", handlers.navigate);
1604 wc.off("did-start-loading", handlers.navigate);
1605 wc.off("did-stop-loading", handlers.navigate);
1606 wc.off("did-fail-load", handlers.failed as never);
1607 wc.off("before-input-event", handlers.beforeInput);
1608 wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput);
1609 }
16101996
1611 private isAppShortcut(input: Electron.Input): boolean {1997 const subscribe = <A>(
1612 if (input.type !== "keyDown") return false;1998 ref: Ref.Ref<ReadonlySet<A>>,
1613 return APP_FORWARDED_SHORTCUTS.some(1999 listener: A,
1614 (shortcut) =>2000 ): Effect.Effect<void, never, Scope.Scope> =>
1615 shortcut.key.toLowerCase() === input.key.toLowerCase() &&2001 Effect.acquireRelease(
1616 shortcut.meta === input.meta &&2002 Ref.update(ref, (listeners) => new Set([...listeners, listener])),
1617 shortcut.shift === input.shift &&2003 () =>
1618 shortcut.control === input.control,2004 Ref.update(ref, (listeners) => {
2005 const next = new Set(listeners);
2006 next.delete(listener);
2007 return next;
2008 }),
2009 ).pipe(Effect.asVoid);
2010
2011 const destroy = Effect.fn("PreviewManager.destroy")(function* () {
2012 const tabs = yield* SynchronizedRef.get(tabsRef);
2013 yield* Effect.forEach(tabs.keys(), closeTab, { discard: true });
2014 yield* Effect.all(
2015 [
2016 Ref.set(listenersRef, new Set()),
2017 Ref.set(expectedAgentInputsRef, new Map()),
2018 Ref.set(pointerEventListenersRef, new Set()),
2019 Ref.set(recordingFrameListenersRef, new Set()),
2020 ],
2021 { discard: true },
1619 );2022 );
1620 }2023 });
1621
1622 private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus {
1623 const url = wc.getURL();
1624 const title = wc.getTitle();
1625 if (url === "" || url === "about:blank") return { kind: "Idle" };
1626 if (wc.isLoading()) return { kind: "Loading", url, title };
1627 return { kind: "Success", url, title };
1628 }
1629
1630 private requireWebContents(tabId: string): Electron.WebContents {
1631 const tab = this.tabs.get(tabId);
1632 if (!tab) throw new PreviewTabNotFoundError(tabId);
1633 if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId);
1634 const wc = webContents.fromId(tab.webContentsId);
1635 if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId);
1636 return wc;
1637 }
1638
1639 private update(tabId: string, patch: Partial<PreviewTabState>): void {
1640 const current = this.tabs.get(tabId);
1641 if (!current) return;
1642 const next: PreviewTabState = {
1643 ...current,
1644 ...patch,
1645 updatedAt: new Date().toISOString(),
1646 };
1647 this.tabs.set(tabId, next);
1648 this.emit(tabId, next);
1649 }
16502024
1651 private emit(tabId: string, state: PreviewTabState): void {2025 yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore));
1652 for (const listener of this.listeners) {
1653 try {
1654 listener(tabId, state);
1655 } catch {
1656 // listener errors must not crash the manager
1657 }
1658 }
1659 }
16602026
1661 private normalizeUrl(input: string): string {2027 return {
1662 // Surface the shared error directly so the IPC caller (and any future2028 automationClick,
1663 // desktop-side telemetry) gets the `detail` field for free. Defining a2029 automationEvaluate,
1664 // bespoke desktop class would just lose information.2030 automationPress,
1665 return normalizePreviewUrl(input);2031 automationScroll,
1666 }2032 automationSnapshot,
1667}2033 automationStatus,
2034 automationType,
2035 automationWaitFor,
2036 cancelPickElement,
2037 captureScreenshot,
2038 closeTab,
2039 copyArtifactToClipboard,
2040 createTab,
2041 goBack,
2042 goForward,
2043 hardReload,
2044 navigate,
2045 openDevTools,
2046 pickElement,
2047 refresh,
2048 registerWebview,
2049 resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),
2050 revealArtifact,
2051 saveRecording,
2052 setAnnotationTheme,
2053 setMainWindow,
2054 startRecording,
2055 stopRecording,
2056 subscribePointerEvents: (listener: PointerEventListener) =>
2057 subscribe(pointerEventListenersRef, listener),
2058 subscribeRecordingFrames: (listener: RecordingFrameListener) =>
2059 subscribe(recordingFrameListenersRef, listener),
2060 subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener),
2061 zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")),
2062 zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")),
2063 };
2064});
16682065
1669export class PreviewTabNotFoundError extends Error {2066export class PreviewTabNotFoundError extends Error {
1670 readonly tabId: string;2067 readonly tabId: string;
...@@ -1725,7 +2122,7 @@ export interface PreviewManagerShape {...@@ -1725,7 +2122,7 @@ export interface PreviewManagerShape {
1725 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;2122 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1726 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;2123 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
1727 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;2124 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
1728 readonly getBrowserPartition: (scope?: string) => string;2125 readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>;
1729 readonly setAnnotationTheme: (2126 readonly setAnnotationTheme: (
1730 theme: DesktopPreviewAnnotationTheme,2127 theme: DesktopPreviewAnnotationTheme,
1731 ) => Effect.Effect<void, PreviewManagerError>;2128 ) => Effect.Effect<void, PreviewManagerError>;
...@@ -1788,29 +2185,10 @@ export class PreviewManager extends Context.Service<PreviewManager, PreviewManag...@@ -1788,29 +2185,10 @@ export class PreviewManager extends Context.Service<PreviewManager, PreviewManag
1788 "@t3tools/desktop/preview/Manager/PreviewManager",2185 "@t3tools/desktop/preview/Manager/PreviewManager",
1789) {}2186) {}
17902187
1791const make = Effect.fn("PreviewManager.make")(function* () {2188const make = Effect.gen(function* PreviewManagerMake() {
1792 const environment = yield* DesktopEnvironment.DesktopEnvironment;2189 const environment = yield* DesktopEnvironment.DesktopEnvironment;
1793 const browserSession = yield* BrowserSession.BrowserSession;2190 const browserSession = yield* BrowserSession.BrowserSession;
1794 const manager = new PreviewViewManager();2191 const operations = yield* makeNativeOperations(environment.browserArtifactsDir);
1795 manager.configureArtifactDirectory(environment.browserArtifactsDir);
1796 yield* Effect.addFinalizer(() => Effect.sync(() => manager.destroy()));
1797
1798 const attempt = <A>(
1799 operation: string,
1800 evaluate: () => A,
1801 ): Effect.Effect<A, PreviewManagerError> =>
1802 Effect.try({
1803 try: evaluate,
1804 catch: (cause) => new PreviewManagerError({ operation, cause }),
1805 });
1806 const attemptPromise = <A>(
1807 operation: string,
1808 evaluate: () => Promise<A>,
1809 ): Effect.Effect<A, PreviewManagerError> =>
1810 Effect.tryPromise({
1811 try: evaluate,
1812 catch: (cause) => new PreviewManagerError({ operation, cause }),
1813 });
1814 const browserSessionEffect = <A>(2192 const browserSessionEffect = <A>(
1815 operation: string,2193 operation: string,
1816 effect: Effect.Effect<A, BrowserSession.BrowserSessionError>,2194 effect: Effect.Effect<A, BrowserSession.BrowserSessionError>,
...@@ -1818,132 +2196,53 @@ const make = Effect.fn("PreviewManager.make")(function* () {...@@ -1818,132 +2196,53 @@ const make = Effect.fn("PreviewManager.make")(function* () {
1818 effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause })));2196 effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause })));
18192197
1820 return PreviewManager.of({2198 return PreviewManager.of({
1821 setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) {2199 setMainWindow: operations.setMainWindow,
1822 yield* attempt("setMainWindow", () => manager.setMainWindow(window));
1823 }),
1824 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {2200 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {
1825 return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope));2201 return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope));
1826 }),2202 }),
1827 isBrowserPartition: browserSession.isPartition,2203 isBrowserPartition: browserSession.isPartition,
1828 createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) {2204 createTab: operations.createTab,
1829 return yield* attempt("createTab", () => manager.createTab(tabId));2205 closeTab: operations.closeTab,
1830 }),2206 registerWebview: operations.registerWebview,
1831 closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) {2207 navigate: operations.navigate,
1832 yield* attempt("closeTab", () => manager.closeTab(tabId));2208 goBack: operations.goBack,
1833 }),2209 goForward: operations.goForward,
1834 registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) {2210 refresh: operations.refresh,
1835 yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId));2211 zoomIn: operations.zoomIn,
1836 }),2212 zoomOut: operations.zoomOut,
1837 navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) {2213 resetZoom: operations.resetZoom,
1838 yield* attemptPromise("navigate", () => manager.navigate(tabId, url));2214 hardReload: operations.hardReload,
1839 }),2215 openDevTools: operations.openDevTools,
1840 goBack: Effect.fn("PreviewManager.goBack")(function* (tabId) {
1841 yield* attempt("goBack", () => manager.goBack(tabId));
1842 }),
1843 goForward: Effect.fn("PreviewManager.goForward")(function* (tabId) {
1844 yield* attempt("goForward", () => manager.goForward(tabId));
1845 }),
1846 refresh: Effect.fn("PreviewManager.refresh")(function* (tabId) {
1847 yield* attempt("refresh", () => manager.refresh(tabId));
1848 }),
1849 zoomIn: Effect.fn("PreviewManager.zoomIn")(function* (tabId) {
1850 yield* attempt("zoomIn", () => manager.zoomIn(tabId));
1851 }),
1852 zoomOut: Effect.fn("PreviewManager.zoomOut")(function* (tabId) {
1853 yield* attempt("zoomOut", () => manager.zoomOut(tabId));
1854 }),
1855 resetZoom: Effect.fn("PreviewManager.resetZoom")(function* (tabId) {
1856 yield* attempt("resetZoom", () => manager.resetZoom(tabId));
1857 }),
1858 hardReload: Effect.fn("PreviewManager.hardReload")(function* (tabId) {
1859 yield* attempt("hardReload", () => manager.hardReload(tabId));
1860 }),
1861 openDevTools: Effect.fn("PreviewManager.openDevTools")(function* (tabId) {
1862 yield* attempt("openDevTools", () => manager.openDevTools(tabId));
1863 }),
1864 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {2216 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
1865 yield* browserSessionEffect("clearCookies", browserSession.clearCookies());2217 yield* browserSessionEffect("clearCookies", browserSession.clearCookies());
1866 }),2218 }),
1867 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {2219 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {
1868 yield* browserSessionEffect("clearCache", browserSession.clearCache());2220 yield* browserSessionEffect("clearCache", browserSession.clearCache());
1869 }),2221 }),
1870 getBrowserPartition: browserSession.getPartition,2222 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {
1871 setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) {2223 return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope));
1872 yield* attempt("setAnnotationTheme", () => manager.setAnnotationTheme(theme));
1873 }),
1874 pickElement: Effect.fn("PreviewManager.pickElement")(function* (tabId) {
1875 return yield* attemptPromise("pickElement", () => manager.pickElement(tabId));
1876 }),
1877 cancelPickElement: Effect.fn("PreviewManager.cancelPickElement")(function* (tabId) {
1878 yield* attempt("cancelPickElement", () => manager.cancelPickElement(tabId));
1879 }),
1880 captureScreenshot: Effect.fn("PreviewManager.captureScreenshot")(function* (tabId) {
1881 return yield* attemptPromise("captureScreenshot", () => manager.captureScreenshot(tabId));
1882 }),
1883 revealArtifact: Effect.fn("PreviewManager.revealArtifact")(function* (path) {
1884 yield* attempt("revealArtifact", () => manager.revealArtifact(path));
1885 }),
1886 copyArtifactToClipboard: Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (path) {
1887 yield* attempt("copyArtifactToClipboard", () => manager.copyArtifactToClipboard(path));
1888 }),2224 }),
1889 startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) {2225 setAnnotationTheme: operations.setAnnotationTheme,
1890 yield* attemptPromise("startRecording", () => manager.startRecording(tabId));2226 pickElement: operations.pickElement,
1891 }),2227 cancelPickElement: operations.cancelPickElement,
1892 stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) {2228 captureScreenshot: operations.captureScreenshot,
1893 yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId));2229 revealArtifact: operations.revealArtifact,
1894 }),2230 copyArtifactToClipboard: operations.copyArtifactToClipboard,
1895 saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) {2231 startRecording: operations.startRecording,
1896 return yield* attemptPromise("saveRecording", () =>2232 stopRecording: operations.stopRecording,
1897 manager.saveRecording(tabId, mimeType, data),2233 saveRecording: operations.saveRecording,
1898 );2234 automationStatus: operations.automationStatus,
1899 }),2235 automationSnapshot: operations.automationSnapshot,
1900 automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) {2236 automationClick: operations.automationClick,
1901 return yield* attempt("automationStatus", () => manager.automationStatus(tabId));2237 automationType: operations.automationType,
1902 }),2238 automationPress: operations.automationPress,
1903 automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) {2239 automationScroll: operations.automationScroll,
1904 return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId));2240 automationEvaluate: operations.automationEvaluate,
1905 }),2241 automationWaitFor: operations.automationWaitFor,
1906 automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) {2242 subscribeStateChanges: operations.subscribeStateChanges,
1907 yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input));2243 subscribePointerEvents: operations.subscribePointerEvents,
1908 }),2244 subscribeRecordingFrames: operations.subscribeRecordingFrames,
1909 automationType: Effect.fn("PreviewManager.automationType")(function* (tabId, input) {
1910 yield* attemptPromise("automationType", () => manager.automationType(tabId, input));
1911 }),
1912 automationPress: Effect.fn("PreviewManager.automationPress")(function* (tabId, input) {
1913 yield* attemptPromise("automationPress", () => manager.automationPress(tabId, input));
1914 }),
1915 automationScroll: Effect.fn("PreviewManager.automationScroll")(function* (tabId, input) {
1916 yield* attemptPromise("automationScroll", () => manager.automationScroll(tabId, input));
1917 }),
1918 automationEvaluate: Effect.fn("PreviewManager.automationEvaluate")(function* (tabId, input) {
1919 return yield* attemptPromise("automationEvaluate", () =>
1920 manager.automationEvaluate(tabId, input),
1921 );
1922 }),
1923 automationWaitFor: Effect.fn("PreviewManager.automationWaitFor")(function* (tabId, input) {
1924 yield* attemptPromise("automationWaitFor", () => manager.automationWaitFor(tabId, input));
1925 }),
1926 subscribeStateChanges: (listener) =>
1927 Effect.acquireRelease(
1928 Effect.sync(() => manager.onStateChange(listener)),
1929 (unsubscribe) => Effect.sync(unsubscribe),
1930 ).pipe(Effect.asVoid),
1931 subscribePointerEvents: (listener) =>
1932 Effect.acquireRelease(
1933 Effect.sync(() => manager.onPointerEvent(listener)),
1934 (unsubscribe) => Effect.sync(unsubscribe),
1935 ).pipe(Effect.asVoid),
1936 subscribeRecordingFrames: (listener) =>
1937 Effect.acquireRelease(
1938 Effect.sync(() => manager.onRecordingFrame(listener)),
1939 (unsubscribe) => Effect.sync(unsubscribe),
1940 ).pipe(Effect.asVoid),
1941 });2245 });
1942});2246}).pipe(Effect.withSpan("PreviewManager.make"));
19432247
1944export const layer = Layer.effect(PreviewManager, make());2248export const layer = Layer.effect(PreviewManager, make);
1945
1946/** Exposed for tests. */
1947export const __testing = {
1948 PreviewViewManager,
1949};
apps/desktop/src/preview/PickPreload.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1// @effect-diagnostics globalDate:off1// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime.
2import { ipcRenderer } from "electron";2import { ipcRenderer } from "electron";
3import { getElementContext } from "react-grab/primitives";3import { getElementContext } from "react-grab/primitives";
4import type {4import type {
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts+17-11
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { it as effectIt } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { describe, expect } from "vite-plus/test";
24
3import {5import {
4 playwrightInjectedRuntimeInstallExpression,6 playwrightInjectedRuntimeInstallExpression,
...@@ -6,15 +8,19 @@ import {...@@ -6,15 +8,19 @@ import {
6} from "./PlaywrightInjectedRuntime.ts";8} from "./PlaywrightInjectedRuntime.ts";
79
8describe("playwright injected runtime", () => {10describe("playwright injected runtime", () => {
9 it("extracts the pinned runtime from playwright-core", async () => {11 effectIt.effect("extracts the pinned runtime from playwright-core", () =>
10 const source = await playwrightInjectedRuntimeSource();12 Effect.gen(function* () {
11 expect(source.length).toBeGreaterThan(100_000);13 const source = yield* playwrightInjectedRuntimeSource();
12 expect(source).toContain("InjectedScript");14 expect(source.length).toBeGreaterThan(100_000);
13 });15 expect(source).toContain("InjectedScript");
16 }),
17 );
1418
15 it("builds an idempotent install expression", async () => {19 effectIt.effect("builds an idempotent install expression", () =>
16 const expression = await playwrightInjectedRuntimeInstallExpression();20 Effect.gen(function* () {
17 expect(expression).toContain("__t3PlaywrightInjected");21 const expression = yield* playwrightInjectedRuntimeInstallExpression();
18 expect(expression).toContain('testIdAttributeName":"data-testid');22 expect(expression).toContain("__t3PlaywrightInjected");
19 });23 expect(expression).toContain('testIdAttributeName":"data-testid');
24 }),
25 );
20});26});
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts+61-20
...@@ -1,35 +1,76 @@...@@ -1,35 +1,76 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection.
2import { readFile } from "node:fs/promises";
2import { createRequire } from "node:module";3import { createRequire } from "node:module";
3import { dirname, join } from "node:path";4import { dirname, join } from "node:path";
4import { readFile } from "node:fs/promises";
5import { runInNewContext } from "node:vm";5import { runInNewContext } from "node:vm";
66
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10
7const require = createRequire(import.meta.url);11const require = createRequire(import.meta.url);
8let sourcePromise: Promise<string> | null = null;12const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
13
14export class PlaywrightInjectedRuntimeError extends Data.TaggedError(
15 "PlaywrightInjectedRuntimeError",
16)<{
17 readonly operation: string;
18 readonly cause: unknown;
19}> {
20 override get message() {
21 return `Playwright injected runtime operation failed: ${this.operation}`;
22 }
23}
924
10export const playwrightInjectedRuntimeSource = (): Promise<string> => {25const fail = (operation: string, cause: unknown) =>
11 sourcePromise ??= (async () => {26 new PlaywrightInjectedRuntimeError({ operation, cause });
12 const packageJsonPath = require.resolve("playwright-core/package.json");27
13 const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8");28export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")(
29 function* () {
30 const packageJsonPath = yield* Effect.try({
31 try: () => require.resolve("playwright-core/package.json"),
32 catch: (cause) => fail("resolvePackage", cause),
33 });
34 const coreBundle = yield* Effect.tryPromise({
35 try: () => readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"),
36 catch: (cause) => fail("readCoreBundle", cause),
37 });
14 const marker = "source3 = ";38 const marker = "source3 = ";
15 const start = coreBundle.indexOf(marker);39 const start = coreBundle.indexOf(marker);
16 if (start < 0) throw new Error("Playwright injected runtime marker was not found.");40 if (start < 0) {
41 return yield* fail(
42 "findSourceMarker",
43 new Error("Playwright injected runtime marker was not found."),
44 );
45 }
17 const literalStart = start + marker.length;46 const literalStart = start + marker.length;
18 const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart);47 const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart);
19 if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found.");48 if (literalEnd < 0) {
49 return yield* fail(
50 "findSourceTerminator",
51 new Error("Playwright injected runtime terminator was not found."),
52 );
53 }
20 const literal = coreBundle.slice(literalStart, literalEnd);54 const literal = coreBundle.slice(literalStart, literalEnd);
21 const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 });55 const source = yield* Effect.try({
56 try: () => runInNewContext(literal, Object.create(null), { timeout: 1_000 }),
57 catch: (cause) => fail("evaluateSourceLiteral", cause),
58 });
22 if (typeof source !== "string" || source.length < 100_000) {59 if (typeof source !== "string" || source.length < 100_000) {
23 throw new Error("Playwright injected runtime extraction returned invalid source.");60 return yield* fail(
61 "validateSource",
62 new Error("Playwright injected runtime extraction returned invalid source."),
63 );
24 }64 }
25 return source;65 return source;
26 })();66 },
27 return sourcePromise;67);
28};
2968
30export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => {69export const playwrightInjectedRuntimeInstallExpression = Effect.fn(
31 const source = await playwrightInjectedRuntimeSource();70 "PlaywrightInjectedRuntime.installExpression",
32 const options = {71)(function* () {
72 const source = yield* playwrightInjectedRuntimeSource();
73 const options = yield* encodeUnknownJson({
33 isUnderTest: false,74 isUnderTest: false,
34 sdkLanguage: "javascript",75 sdkLanguage: "javascript",
35 testIdAttributeName: "data-testid",76 testIdAttributeName: "data-testid",
...@@ -38,12 +79,12 @@ export const playwrightInjectedRuntimeInstallExpression = async (): Promise<stri...@@ -38,12 +79,12 @@ export const playwrightInjectedRuntimeInstallExpression = async (): Promise<stri
38 shouldPrependErrorPrefix: false,79 shouldPrependErrorPrefix: false,
39 isUtilityWorld: false,80 isUtilityWorld: false,
40 customEngines: [],81 customEngines: [],
41 };82 }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause)));
42 return `(() => {83 return `(() => {
43 if (globalThis.__t3PlaywrightInjected) return true;84 if (globalThis.__t3PlaywrightInjected) return true;
44 const module = { exports: {} };85 const module = { exports: {} };
45 ${source}86 ${source}
46 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)});87 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${options});
47 return true;88 return true;
48 })()`;89 })()`;
49};90});
apps/desktop/src/window/DesktopWindow.test.ts+1-1
...@@ -171,7 +171,7 @@ function makeTestLayer(input: {...@@ -171,7 +171,7 @@ function makeTestLayer(input: {
171 getBrowserSession: () => Effect.succeed({} as Electron.Session),171 getBrowserSession: () => Effect.succeed({} as Electron.Session),
172 setMainWindow: () => Effect.void,172 setMainWindow: () => Effect.void,
173 isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),173 isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
174 getBrowserPartition: () => "persist:t3code-preview-test",174 getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),
175 }),175 }),
176 ),176 ),
177 ),177 ),
apps/web/src/components/preview/PreviewAutomationOwner.tsx+2-8
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1"use client";1"use client";
2// @effect-diagnostics cryptoRandomUUID:off
32
4import { scopedThreadKey } from "@t3tools/client-runtime";3import { scopedThreadKey } from "@t3tools/client-runtime";
5import type {4import type {
...@@ -10,7 +9,7 @@ import type {...@@ -10,7 +9,7 @@ import type {
10 PreviewAutomationStatus,9 PreviewAutomationStatus,
11 ScopedThreadRef,10 ScopedThreadRef,
12} from "@t3tools/contracts";11} from "@t3tools/contracts";
13import { useCallback, useEffect, useRef, useState } from "react";12import { useCallback, useEffect, useId, useRef } from "react";
1413
15import { ensureEnvironmentApi } from "~/environmentApi";14import { ensureEnvironmentApi } from "~/environmentApi";
16import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
...@@ -24,11 +23,6 @@ import {...@@ -24,11 +23,6 @@ import {
2423
25import { previewBridge } from "./previewBridge";24import { previewBridge } from "./previewBridge";
2625
27const newAutomationClientId = (): string =>
28 typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
29 ? crypto.randomUUID()
30 : `preview-${Math.random().toString(36).slice(2)}`;
31
32const waitForDesktopOverlay = async (26const waitForDesktopOverlay = async (
33 threadRef: ScopedThreadRef,27 threadRef: ScopedThreadRef,
34 timeoutMs: number,28 timeoutMs: number,
...@@ -118,7 +112,7 @@ export function PreviewAutomationOwner(props: {...@@ -118,7 +112,7 @@ export function PreviewAutomationOwner(props: {
118 readonly visible: boolean;112 readonly visible: boolean;
119}) {113}) {
120 const { threadRef, visible } = props;114 const { threadRef, visible } = props;
121 const [automationClientId] = useState(newAutomationClientId);115 const automationClientId = useId();
122 const ownerStateRef = useRef({ threadRef, visible });116 const ownerStateRef = useRef({ threadRef, visible });
123 const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>(117 const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>(
124 async () => undefined,118 async () => undefined,
apps/web/src/reactGrabBoundary.test.ts+1-4
...@@ -1,13 +1,10 @@...@@ -1,13 +1,10 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { readFileSync } from "node:fs";
3import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
42
5import packageJson from "../package.json" with { type: "json" };3import packageJson from "../package.json" with { type: "json" };
4import mainSource from "./main.tsx?raw";
65
7describe("React Grab runtime boundary", () => {6describe("React Grab runtime boundary", () => {
8 it("keeps the host renderer free of the React Grab overlay", () => {7 it("keeps the host renderer free of the React Grab overlay", () => {
9 const mainSource = readFileSync(new URL("./main.tsx", import.meta.url), "utf8");
10
11 expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/);8 expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/);
12 expect(packageJson.dependencies).not.toHaveProperty("react-grab");9 expect(packageJson.dependencies).not.toHaveProperty("react-grab");
13 expect(packageJson.devDependencies).not.toHaveProperty("react-grab");10 expect(packageJson.devDependencies).not.toHaveProperty("react-grab");