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// @effect-diagnostics nodeBuiltinImport:off
21import {
32 DesktopPreviewAnnotationThemeInputSchema,
43 DesktopPreviewArtifactInputSchema,
......@@ -195,7 +194,7 @@ export const getPreviewConfig = makeIpcMethod({
195194 const manager = yield* PreviewManager.PreviewManager;
196195 yield* manager.getBrowserSession(environmentId);
197196 return {
198 partition: manager.getBrowserPartition(environmentId),
197 partition: yield* manager.getBrowserPartition(environmentId),
199198 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
200199 preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
201200 };
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 @@
11import type { Session } from "electron";
22import { session } from "electron";
3import { createHash } from "node:crypto";
43import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
55import * as Data from "effect/Data";
66import * as Effect from "effect/Effect";
7import * as Encoding from "effect/Encoding";
78import * as Layer from "effect/Layer";
9import * as SynchronizedRef from "effect/SynchronizedRef";
810
911const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
1012
......@@ -18,7 +20,7 @@ export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")
1820}
1921
2022export interface BrowserSessionShape {
21 readonly getPartition: (scope?: string) => string;
23 readonly getPartition: (scope?: string) => Effect.Effect<string, BrowserSessionError>;
2224 readonly isPartition: (partition: string) => boolean;
2325 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>;
2426 readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>;
......@@ -29,19 +31,25 @@ export class BrowserSession extends Context.Service<BrowserSession, BrowserSessi
2931 "@t3tools/desktop/preview/BrowserSession",
3032) {}
3133
32const make = Effect.fn("BrowserSession.make")(() =>
33 Effect.sync(() => {
34 const sessions = new Map<string, Session>();
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 };
34const make = Effect.gen(function* BrowserSessionMake() {
35 const crypto = yield* Crypto.Crypto;
36 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
3937
40 const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {
41 const partition = getPartition(scope);
38 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {
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) => {
4250 const existing = sessions.get(partition);
43 if (existing) return existing;
44 return yield* Effect.try({
51 if (existing) return Effect.succeed([existing, sessions] as const);
52 return Effect.try({
4553 try: () => {
4654 const browserSession = session.fromPartition(partition);
4755 const userAgent = browserSession
......@@ -53,41 +61,47 @@ const make = Effect.fn("BrowserSession.make")(() =>
5361 const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
5462 callback(allowed.includes(permission));
5563 });
56 sessions.set(partition, browserSession);
57 return browserSession;
64 const next = new Map(sessions);
65 next.set(partition, browserSession);
66 return [browserSession, next] as const;
5867 },
5968 catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }),
6069 });
6170 });
71 });
6272
63 return BrowserSession.of({
64 getPartition,
65 isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
66 getSession,
67 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
68 yield* Effect.tryPromise({
69 try: () =>
70 Promise.all(
71 [...sessions.values()].map((browserSession) =>
72 browserSession.clearStorageData({
73 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
74 }),
75 ),
76 ),
77 catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }),
78 });
79 }),
80 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
81 yield* Effect.tryPromise({
82 try: () =>
83 Promise.all(
84 [...sessions.values()].map((browserSession) => browserSession.clearCache()),
85 ),
86 catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }),
87 });
88 }),
89 });
90 }),
91);
73 return BrowserSession.of({
74 getPartition,
75 isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
76 getSession,
77 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
78 const sessions = yield* SynchronizedRef.get(sessionsRef);
79 yield* Effect.all(
80 [...sessions.values()].map((browserSession) =>
81 Effect.tryPromise({
82 try: () =>
83 browserSession.clearStorageData({
84 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
85 }),
86 catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }),
87 }),
88 ),
89 { concurrency: "unbounded", discard: true },
90 );
91 }),
92 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
93 const sessions = yield* SynchronizedRef.get(sessionsRef);
94 yield* Effect.all(
95 [...sessions.values()].map((browserSession) =>
96 Effect.tryPromise({
97 try: () => browserSession.clearCache(),
98 catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }),
99 }),
100 ),
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 @@
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);
4const mkdir = vi.fn(async () => undefined);
5const writeFile = vi.fn(async () => undefined);
6const showItemInFolder = vi.fn();
7const writeImage = vi.fn();
8const createFromPath = vi.fn(() => ({ isEmpty: () => false }));
9const webviewSend = vi.fn();
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as BrowserSession from "./BrowserSession.ts";
16import * as PreviewManager from "./Manager.ts";
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
1329vi.mock("electron", () => ({
1430 clipboard: {
......@@ -17,18 +33,64 @@ vi.mock("electron", () => ({
1733 nativeImage: {
1834 createFromPath,
1935 },
20 session: {
21 fromPartition: vi.fn(),
22 },
2336 shell: {
2437 showItemInFolder,
2538 },
39 session: {
40 fromPartition: vi.fn(),
41 },
2642 webContents: {
2743 fromId,
2844 },
2945}));
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", () => {
3294 beforeEach(() => {
3395 fromId.mockClear();
3496 mkdir.mockClear();
......@@ -39,245 +101,274 @@ describe("PreviewViewManager automation status", () => {
39101 webviewSend.mockClear();
40102 });
41103
42 it("reports an unregistered webview as temporarily unavailable", async () => {
43 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
44 const manager = new PreviewViewManager();
104 effectIt.effect("reports an unregistered webview as temporarily unavailable", () =>
105 withManager((manager) =>
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({
47 available: false,
48 visible: true,
49 tabId: "tab_1",
50 url: null,
51 title: null,
52 loading: false,
53 });
116 yield* manager.createTab("tab_1");
54117
55 manager.createTab("tab_1");
56
57 expect(manager.automationStatus("tab_1")).toEqual({
58 available: false,
59 visible: true,
60 tabId: "tab_1",
61 url: null,
62 title: null,
63 loading: false,
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);
118 expect(yield* manager.automationStatus("tab_1")).toEqual({
119 available: false,
120 visible: true,
121 tabId: "tab_1",
122 url: null,
123 title: null,
124 loading: false,
125 });
126 expect(fromId).not.toHaveBeenCalled();
83127 }),
84 off: vi.fn(),
85 ipc: { on: vi.fn(), off: vi.fn() },
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);
128 ),
129 );
103130
104 expect(webviewSend).toHaveBeenCalledWith(
105 "preview:annotation-theme",
106 expect.objectContaining({
107 colorScheme: "light",
108 primary: "oklch(0.488 0.217 264)",
109 }),
110 );
131 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
132 withManager((manager) =>
133 Effect.gen(function* () {
134 const png = Buffer.from("preview-png");
135 const capturePage = vi.fn(async () => ({ toPNG: () => png }));
136 const listeners = new Map<string, (...args: never[]) => void>();
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();
115 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", {
116 recursive: true,
117 });
118 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
119 expect(artifact).toMatchObject({
120 tabId: "tab_1",
121 mimeType: "image/png",
122 sizeBytes: png.byteLength,
123 });
124 expect(artifact.path).toMatch(
125 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
126 );
127 });
167 expect(webviewSend).toHaveBeenCalledWith(
168 "preview:annotation-theme",
169 expect.objectContaining({
170 colorScheme: "light",
171 primary: "oklch(0.488 0.217 264)",
172 }),
173 );
128174
129 it("reveals only files inside the configured browser artifact directory", async () => {
130 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
131 const manager = new PreviewViewManager();
132 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
175 const artifact = yield* manager.captureScreenshot("tab_1");
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(
137 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
138 );
139 expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow(
140 "outside the configured artifact directory",
141 );
142 });
192 effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
193 withManager((manager) =>
194 Effect.gen(function* () {
195 yield* manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");
143196
144 it("copies screenshot artifacts to the system clipboard", async () => {
145 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
146 const manager = new PreviewViewManager();
147 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
148 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";
197 expect(showItemInFolder).toHaveBeenCalledWith(
198 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
199 );
200 const exit = yield* Effect.exit(manager.revealArtifact("/tmp/t3/dev/settings.json"));
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);
153 expect(writeImage).toHaveBeenCalledOnce();
154 expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow(
155 "outside the configured artifact directory",
156 );
157 });
216 yield* manager.copyArtifactToClipboard(artifactPath);
217
218 expect(createFromPath).toHaveBeenCalledWith(artifactPath);
219 expect(writeImage).toHaveBeenCalledOnce();
220 const exit = yield* Effect.exit(
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 () => {
160 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
161 const activity: string[] = [];
162 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
163 if (method === "Runtime.evaluate") {
164 return {
165 result: {
166 value: { width: 800, height: 600 },
233 effectIt.effect("emits the resolved pointer target before dispatching an automation click", () =>
234 withManager((manager) =>
235 Effect.gen(function* () {
236 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
237 const activity: string[] = [];
238 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
239 if (method === "Runtime.evaluate") {
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(),
167269 },
168 };
169 }
170 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
171 activity.push("mousePressed");
172 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
173 }
174 return undefined;
175 });
176 fromId.mockReturnValue({
177 id: 42,
178 isDestroyed: () => false,
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);
270 send: webviewSend,
271 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
272 setWindowOpenHandler: vi.fn(),
273 debugger: {
274 isAttached: () => false,
275 attach: vi.fn(),
276 sendCommand,
277 on: vi.fn(),
278 off: vi.fn(),
279 },
280 } as never);
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"]);
214 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
215 type: "mousePressed",
216 x: 120,
217 y: 80,
218 button: "left",
219 clickCount: 1,
220 });
221 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
222 type: "mouseReleased",
223 x: 120,
224 y: 80,
225 button: "left",
226 clickCount: 1,
227 });
228 });
291 expect(activity).toEqual(["move", "click", "mousePressed"]);
292 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
293 type: "mousePressed",
294 x: 120,
295 y: 80,
296 button: "left",
297 clickCount: 1,
298 });
299 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
300 type: "mouseReleased",
301 x: 120,
302 y: 80,
303 button: "left",
304 clickCount: 1,
305 });
306 }),
307 ),
308 );
229309
230 it("still interrupts agent control for a different human pointer event", async () => {
231 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
232 const sendCommand = vi.fn(async (method: string) => {
233 if (method === "Runtime.evaluate") {
234 return {
235 result: {
236 value: { width: 800, height: 600 },
310 effectIt.effect("still interrupts agent control for a different human pointer event", () =>
311 withManager((manager) =>
312 Effect.gen(function* () {
313 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
314 const sendCommand = vi.fn(async (method: string) => {
315 if (method === "Runtime.evaluate") {
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(),
237354 },
238 };
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);
355 } as never);
278356
279 await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({
280 name: "PreviewAutomationControlInterruptedError",
281 });
282 });
357 yield* manager.createTab("tab_1");
358 yield* manager.registerWebview("tab_1", 42);
359
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 );
283374});
apps/desktop/src/preview/Manager.ts+1675-1376
......@@ -1,7 +1,5 @@
1// @effect-diagnostics globalDate:off
2// @effect-diagnostics nodeBuiltinImport:off
31/**
4 * PreviewViewManager — desktop side of the in-app browser preview.
2 * Desktop side of the in-app browser preview.
53 *
64 * Hosts per-tab Chromium WebContents references (the actual <webview>
75 * elements live in the renderer; we only attach listeners and forward state
......@@ -36,14 +34,22 @@ import {
3634 shell,
3735 webContents,
3836} from "electron";
39import { mkdir, writeFile } from "node:fs/promises";
40import { isAbsolute, join, relative, resolve, sep } from "node:path";
41import { setTimeout as sleep } from "node:timers/promises";
37import * as Cause from "effect/Cause";
38import * as Clock from "effect/Clock";
4239import * as Context from "effect/Context";
4340import * as Data from "effect/Data";
41import * as DateTime from "effect/DateTime";
4442import * as Effect from "effect/Effect";
43import * as Exit from "effect/Exit";
44import * as FileSystem from "effect/FileSystem";
4545import * 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";
4651import type * as Scope from "effect/Scope";
52import * as SynchronizedRef from "effect/SynchronizedRef";
4753
4854import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
4955import * as BrowserSession from "./BrowserSession.ts";
......@@ -96,6 +102,7 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200;
96102const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
97103const AGENT_CURSOR_MOVE_MS = 160;
98104const AGENT_CURSOR_CLICK_LEAD_MS = 40;
105const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
99106const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
100107 colorScheme: "light",
101108 radius: "0.625rem",
......@@ -187,35 +194,41 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
187194 };
188195};
189196
190const captureAnnotationScreenshot = async (
197const captureAnnotationScreenshot = (
191198 wc: Electron.WebContents,
192199 cropRect: PreviewAnnotationRect | null,
193) => {
194 const image = await wc.capturePage(
195 cropRect
196 ? {
197 x: cropRect.x,
198 y: cropRect.y,
199 width: cropRect.width,
200 height: cropRect.height,
201 }
202 : undefined,
200): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>
201 Effect.tryPromise({
202 try: () =>
203 wc.capturePage(
204 cropRect
205 ? {
206 x: cropRect.x,
207 y: cropRect.y,
208 width: cropRect.width,
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 }),
203224 );
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
213226const findZoomStep = (current: number): number => {
214 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {
215 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;
216 if (ZOOM_LEVELS[index]! > current) return index - 1;
217 }
218 return ZOOM_LEVELS.length - 1;
227 const index = ZOOM_LEVELS.findIndex(
228 (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current,
229 );
230 if (index < 0) return ZOOM_LEVELS.length - 1;
231 return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
219232};
220233
221234const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
......@@ -241,14 +254,12 @@ interface ManagedListeners {
241254}
242255
243256interface PickSession {
244 readonly resolve: (payload: PreviewAnnotationPayload | null) => void;
245 readonly cleanup: () => void;
257 readonly cancel: Effect.Effect<void>;
246258}
247259
248260interface BrowserControlSession {
249261 readonly webContentsId: number;
250 tail: Promise<void>;
251 initialized: Promise<void>;
262 readonly semaphore: Semaphore.Semaphore;
252263 readonly onMessage: (
253264 event: Electron.Event,
254265 method: string,
......@@ -257,9 +268,9 @@ interface BrowserControlSession {
257268}
258269
259270interface BrowserDiagnostics {
260 readonly consoleEntries: PreviewAutomationConsoleEntry[];
261 readonly networkEntries: PreviewAutomationNetworkEntry[];
262 readonly requests: Map<string, { url: string; method: string }>;
271 readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
272 readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
273 readonly requests: ReadonlyMap<string, { url: string; method: string }>;
263274}
264275
265276type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
......@@ -323,107 +334,796 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal):
323334 );
324335};
325336
326class PreviewViewManager {
327 private annotationTheme = DEFAULT_ANNOTATION_THEME;
328 private artifactDirectory: string | null = null;
329 private mainWindow: BrowserWindow | null = null;
330 private readonly tabs = new Map<string, PreviewTabState>();
331 private readonly attached = new Map<number, ManagedListeners>();
332 private readonly listeners = new Set<Listener>();
333 private readonly pointerEventListeners = new Set<PointerEventListener>();
334 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();
335 /** In-flight preview annotation sessions, keyed by tabId. */
336 private readonly pickSessions = new Map<string, PickSession>();
337 /** One long-lived CDP attachment and serialized command queue per guest. */
338 private readonly controlSessions = new Map<number, BrowserControlSession>();
339 private readonly diagnostics = new Map<number, BrowserDiagnostics>();
340 private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>();
341 private readonly controlEpoch = new Map<string, number>();
342 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();
343 private actionSequence = 0;
344 private pointerSequence = 0;
345 private recordingTabId: string | null = null;
346
347 configureArtifactDirectory(directory: string): void {
348 this.artifactDirectory = resolve(directory);
349 }
337const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
338 artifactDirectory: string,
339) {
340 const fileSystem = yield* FileSystem.FileSystem;
341 const path = yield* Path.Path;
342 const context = yield* Effect.context<never>();
343 const runFork = Effect.runForkWith(context);
344 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
345 const playwrightInstallExpression = yield* Effect.cached(
346 playwrightInjectedRuntimeInstallExpression().pipe(
347 Effect.mapError(
348 (cause) =>
349 new PreviewManagerError({
350 operation: "ensurePlaywrightInjected",
351 cause,
352 }),
353 ),
354 ),
355 );
356
357 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
358 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
359 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
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 {
352 if (!this.artifactDirectory) {
353 throw new Error("Preview artifact directory is not configured.");
403 const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) {
404 const listeners = yield* Ref.get(listenersRef);
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));
354439 }
355 return this.artifactDirectory;
356 }
440 const wc = webContents.fromId(tab.webContentsId);
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 {
359 const directory = this.requireArtifactDirectory();
360 const resolvedPath = resolve(path);
361 const relativePath = relative(directory, resolvedPath);
362 if (
363 relativePath.length === 0 ||
364 relativePath === ".." ||
365 relativePath.startsWith(`..${sep}`) ||
366 isAbsolute(relativePath)
450 const resolveArtifactPath = (artifactPath: string) =>
451 attempt("resolveArtifactPath", () => {
452 const resolvedPath = path.resolve(artifactPath);
453 const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath);
454 if (
455 relativePath.length === 0 ||
456 relativePath === ".." ||
457 relativePath.startsWith(`..${path.sep}`) ||
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>,
367824 ) {
368 throw new Error("Preview artifact path is outside the configured artifact directory.");
369 }
370 return resolvedPath;
371 }
825 const completedAt = yield* currentIso;
826 if (exit._tag === "Success") {
827 yield* replaceAction(tabId, {
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 {
374 const resolvedPath = this.resolveArtifactPath(path);
375 shell.showItemInFolder(resolvedPath);
376 }
851 const evaluateWithDebugger = <A = unknown>(
852 send: SendCommand,
853 expression: string,
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 {
379 const resolvedPath = this.resolveArtifactPath(path);
380 const image = nativeImage.createFromPath(resolvedPath);
381 if (image.isEmpty()) {
382 throw new Error("Preview artifact could not be loaded as an image.");
383 }
384 clipboard.writeImage(image);
385 }
881 const automationLocator = (input: {
882 readonly selector?: string | undefined;
883 readonly locator?: string | undefined;
884 }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null);
386885
387 setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void {
388 this.annotationTheme = theme;
389 for (const tab of this.tabs.values()) {
390 if (tab.webContentsId == null) continue;
391 const wc = webContents.fromId(tab.webContentsId);
392 if (!wc || wc.isDestroyed()) continue;
393 wc.send(ANNOTATION_THEME_CHANNEL, theme);
394 }
395 }
886 const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* (
887 send: SendCommand,
888 ) {
889 const installed = yield* evaluateWithDebugger<boolean>(
890 send,
891 "Boolean(globalThis.__t3PlaywrightInjected)",
892 true,
893 );
894 if (installed) return;
895 const expression = yield* playwrightInstallExpression;
896 yield* evaluateWithDebugger(send, expression, true);
897 });
396898
397 setMainWindow(window: BrowserWindow): void {
398 this.mainWindow = window;
399 }
899 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
900 tabId: string,
901 ) {
902 const session = (yield* Ref.get(pickSessionsRef)).get(tabId);
903 if (session) yield* session.cancel;
904 });
400905
401 createTab(tabId: string): PreviewTabState {
402 const existing = this.tabs.get(tabId);
403 if (existing) return existing;
404 const initial: PreviewTabState = {
405 tabId,
406 webContentsId: null,
407 navStatus: { kind: "Idle" },
408 canGoBack: false,
409 canGoForward: false,
410 zoomFactor: DEFAULT_ZOOM_FACTOR,
411 controller: "none",
412 updatedAt: new Date().toISOString(),
906 const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* (
907 webContentsId: number,
908 ) {
909 const handlers = yield* Ref.modify(attachedRef, (attached) => [
910 attached.get(webContentsId),
911 replaceMap(attached, (copy) => {
912 copy.delete(webContentsId);
913 }),
914 ]);
915 if (!handlers) return;
916 const wc = webContents.fromId(webContentsId);
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 );
4131014 };
414 this.tabs.set(tabId, initial);
415 this.emit(tabId, initial);
416 return initial;
417 }
1015 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1016 runFork(
1017 Effect.gen(function* () {
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 {
420 const tab = this.tabs.get(tabId);
1084 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (
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);
4211118 if (!tab) return;
422 this.cancelPickElement(tabId);
1119 yield* cancelPickElement(tabId);
4231120 if (tab.webContentsId != null) {
424 this.detachControlSession(tab.webContentsId);
425 this.detachListeners(tab.webContentsId);
1121 yield* Effect.all(
1122 [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)],
1123 { concurrency: 2, discard: true },
1124 );
4261125 }
1126 const updatedAt = yield* currentIso;
4271127 const closed: PreviewTabState = {
4281128 ...tab,
4291129 webContentsId: null,
......@@ -432,289 +1132,336 @@ class PreviewViewManager {
4321132 canGoForward: false,
4331133 zoomFactor: DEFAULT_ZOOM_FACTOR,
4341134 controller: "none",
435 updatedAt: new Date().toISOString(),
1135 updatedAt,
4361136 };
437 this.tabs.delete(tabId);
438 this.emit(tabId, closed);
439 }
1137 yield* SynchronizedRef.update(tabsRef, (tabs) =>
1138 replaceMap(tabs, (copy) => {
1139 copy.delete(tabId);
1140 }),
1141 );
1142 yield* emit(tabId, closed);
1143 });
4401144
441 registerWebview(tabId: string, webContentsId: number): void {
442 const tab = this.tabs.get(tabId);
1145 const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* (
1146 tabId: string,
1147 webContentsId: number,
1148 ) {
1149 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
4431150 if (!tab) {
444 throw new PreviewTabNotFoundError(tabId);
1151 return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId));
4451152 }
4461153 const wc = webContents.fromId(webContentsId);
447 if (!wc) {
448 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
449 }
450 // Defence in depth: a malicious renderer could otherwise trick us into
451 // attaching listeners to the main window's WebContents (or any other
452 // process) by passing an arbitrary id.
453 if (wc.getType() !== "webview") {
454 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
455 }
456 if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) {
457 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
1154 const mainWindow = yield* Ref.get(mainWindowRef);
1155 if (
1156 !wc ||
1157 wc.getType() !== "webview" ||
1158 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
1159 ) {
1160 return yield* fail(
1161 "registerWebview",
1162 new PreviewWebContentsNotFoundError(tabId, webContentsId),
1163 );
4581164 }
459 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {
460 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
1165 const attached = yield* Ref.get(attachedRef);
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 );
4611171 return;
4621172 }
4631173 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
464 this.detachControlSession(tab.webContentsId);
465 this.detachListeners(tab.webContentsId);
466 // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`.
467 // Cancel it so the toggle button doesn't get stuck pressed waiting
468 // forever for a click on a webview that no longer hosts the listener.
469 this.cancelPickElement(tabId);
1174 yield* Effect.all(
1175 [
1176 detachControlSession(tab.webContentsId),
1177 detachListeners(tab.webContentsId),
1178 cancelPickElement(tabId),
1179 ],
1180 { concurrency: 3, discard: true },
1181 );
4701182 }
471 this.attachListeners(tabId, wc);
472 void this.ensureControlSession(wc).catch(() => undefined);
473 // Restore the persisted zoom factor onto the freshly-attached WebContents
474 // so a thread-switch + remount lands the user back where they were.
1183 yield* attachListeners(tabId, wc);
1184 runFork(ensureControlSession(wc).pipe(Effect.ignore));
4751185 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
476 try {
477 wc.setZoomFactor(tab.zoomFactor);
478 } catch {
479 // wc may have been torn down between resolution and call.
480 }
1186 yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe(
1187 Effect.ignore,
1188 );
4811189 }
482 this.update(tabId, {
1190 yield* update(tabId, {
4831191 webContentsId,
484 navStatus: this.computeNavStatus(wc),
1192 navStatus: computeNavStatus(wc),
4851193 canGoBack: wc.navigationHistory.canGoBack(),
4861194 canGoForward: wc.navigationHistory.canGoForward(),
4871195 zoomFactor: tab.zoomFactor,
4881196 });
489 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
490 }
1197 yield* attempt("registerWebview.sendTheme", () =>
1198 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1199 );
1200 });
4911201
492 async navigate(tabId: string, rawUrl: string): Promise<void> {
493 const wc = this.requireWebContents(tabId);
494 const url = this.normalizeUrl(rawUrl);
1202 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
1203 const wc = yield* requireWebContents(tabId);
1204 const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl));
4951205 if (wc.getURL() === url) {
496 wc.reload();
1206 yield* attempt("navigate.reload", () => wc.reload());
4971207 return;
4981208 }
499 await wc.loadURL(url);
500 }
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 }
1209 yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url));
1210 });
5151211
516 refresh(tabId: string): void {
517 const wc = this.requireWebContents(tabId);
518 wc.reload();
519 }
1212 const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* (
1213 operation: string,
1214 tabId: string,
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. */
522 hardReload(tabId: string): void {
523 const wc = this.requireWebContents(tabId);
524 wc.reloadIgnoringCache();
525 }
1221 const goBack = (tabId: string) =>
1222 withWebContents("goBack", tabId, (wc) => {
1223 if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
1224 });
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 /**
528 * Open the guest webview's DevTools, detached so it doesn't steal panel
529 * area. Idempotent — re-invoking focuses the existing window.
530 */
531 openDevTools(tabId: string): void {
532 const wc = this.requireWebContents(tabId);
1233 const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) {
1234 const wc = yield* requireWebContents(tabId);
5331235 if (wc.isDevToolsOpened()) {
534 wc.devToolsWebContents?.focus();
1236 yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus());
5351237 return;
5361238 }
537 this.detachControlSession(wc.id);
538 wc.once("devtools-closed", () => {
539 if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined);
1239 yield* detachControlSession(wc.id);
1240 yield* attempt("openDevTools", () => {
1241 wc.once("devtools-closed", () => {
1242 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));
1243 });
1244 wc.openDevTools({ mode: "detach" });
5401245 });
541 wc.openDevTools({ mode: "detach" });
542 }
1246 });
5431247
544 /**
545 * Activate annotation mode for `tabId`. Resolves after the guest submits a
546 * multi-target annotation and the desktop process captures its screenshot,
547 * or with `null` when the user cancels.
548 *
549 * Exactly one pick session may be active per tab — re-invoking while a
550 * pick is in flight cleanly resolves the old session with `null` first.
551 */
552 async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> {
553 const wc = this.requireWebContents(tabId);
554 this.cancelPickElement(tabId);
555 return new Promise<PreviewAnnotationPayload | null>((resolve) => {
556 // `wc.ipc` is the per-WebContents IpcMain that receives messages the
557 // webview's preload sends with `ipcRenderer.send(...)`. We use that
558 // (not the global `wc.on("ipc-message", ...)`, which is for
559 // `sendToHost` and only fires on the host renderer's <webview>
560 // element) so the main process actually observes the picked payload.
561 const cleanup = () => {
562 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
563 wc.off("destroyed", onDestroyed);
564 wc.off("did-start-navigation", onNavigated);
565 this.pickSessions.delete(tabId);
566 };
567 const session: PickSession = { resolve, cleanup };
568 const settle = (payload: PreviewAnnotationPayload | null) => {
569 if (this.pickSessions.get(tabId) !== session) return;
570 cleanup();
571 resolve(payload);
572 };
573 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
574 const payload = args[0];
575 if (payload == null) {
576 settle(null);
577 return;
578 }
579 if (!isPreviewAnnotationPayload(payload)) {
580 settle(null);
581 return;
582 }
583 const cropRect = normalizeCaptureRect(args[1]);
584 void captureAnnotationScreenshot(wc, cropRect)
585 .then((screenshot) => settle({ ...payload, screenshot }))
586 .catch(() => settle(payload))
587 .finally(() => {
588 if (wc.isDestroyed()) return;
589 try {
590 wc.send(ANNOTATION_CAPTURED_CHANNEL);
591 } catch {
592 // The guest may have navigated while capture was in flight.
1248 const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* (
1249 theme: DesktopPreviewAnnotationTheme,
1250 ) {
1251 yield* Ref.set(annotationThemeRef, theme);
1252 const tabs = yield* SynchronizedRef.get(tabsRef);
1253 yield* Effect.forEach(
1254 tabs.values(),
1255 (tab) => {
1256 if (tab.webContentsId == null) return Effect.void;
1257 const wc = webContents.fromId(tab.webContentsId);
1258 return !wc || wc.isDestroyed()
1259 ? Effect.void
1260 : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe(
1261 Effect.ignore,
1262 );
1263 },
1264 { discard: true },
1265 );
1266 });
1267
1268 const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) {
1269 const wc = yield* requireWebContents(tabId);
1270 yield* cancelPickElement(tabId);
1271 const annotationTheme = yield* Ref.get(annotationThemeRef);
1272 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
1273 (resume) => {
1274 const cleanup = Effect.gen(function* () {
1275 yield* attempt("pickElement.cleanup", () => {
1276 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
1277 wc.off("destroyed", onDestroyed);
1278 wc.off("did-start-navigation", onNavigated);
1279 }).pipe(Effect.ignore);
1280 yield* Ref.update(pickSessionsRef, (sessions) =>
1281 replaceMap(sessions, (copy) => {
1282 copy.delete(tabId);
1283 }),
1284 );
1285 });
1286 const settle = (payload: PreviewAnnotationPayload | null) => {
1287 runFork(
1288 Effect.gen(function* () {
1289 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);
1290 if (!active || active.cancel !== cancel) return;
1291 yield* cleanup;
1292 resume(Effect.succeed(payload));
1293 }),
1294 );
1295 };
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 );
5931306 }
594 });
595 };
596 const onDestroyed = () => settle(null);
597 const onNavigated = () => settle(null);
598 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
599 wc.once("destroyed", onDestroyed);
600 // A page navigation (incl. SPA → same-document) tears down the
601 // preload's listeners, so we cancel proactively to avoid hanging.
602 wc.once("did-start-navigation", onNavigated);
603 this.pickSessions.set(tabId, session);
604 // Force-focus the guest webContents BEFORE sending start-pick. Without
605 // this, Electron's input router will deliver the user's first
606 // mousemove/click to the host renderer (where the pick button lives)
607 // instead of to the guest's window listeners — manifest as "the
608 // picker overlay never appears on remote pages I haven't clicked
609 // into yet". The renderer-side handler in `PreviewView` is responsible
610 // for restoring focus to the previously-active host element when the
611 // pick promise resolves so the user's textarea cursor isn't lost.
612 try {
613 if (!wc.isFocused()) wc.focus();
614 } catch {
615 // wc may be torn down; the next try/catch settles.
616 }
617 try {
618 wc.send(START_PICK_CHANNEL, this.annotationTheme);
619 } catch {
620 settle(null);
621 }
622 });
623 }
1307 }
1308 resume(Effect.succeed(null));
1309 });
1310 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
1311 const payload = args[0];
1312 if (!isPreviewAnnotationPayload(payload)) {
1313 settle(null);
1314 return;
1315 }
1316 const cropRect = normalizeCaptureRect(args[1]);
1317 runFork(
1318 captureAnnotationScreenshot(wc, cropRect).pipe(
1319 Effect.matchEffect({
1320 onFailure: () => Effect.sync(() => settle(payload)),
1321 onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })),
1322 }),
1323 Effect.ensuring(
1324 attempt("pickElement.captureComplete", () => {
1325 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);
1326 }).pipe(Effect.ignore),
1327 ),
1328 ),
1329 );
1330 };
1331 const onDestroyed = () => settle(null);
1332 const onNavigated = () => settle(null);
1333 runFork(
1334 Effect.gen(function* () {
1335 yield* attempt("pickElement.register", () => {
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 {
626 const session = this.pickSessions.get(tabId);
627 if (!session) return;
628 session.cleanup();
629 // Best-effort: tell the page to dismiss the overlay even if it's still
630 // alive — keeps the next invoke fresh.
631 const tab = this.tabs.get(tabId);
632 if (tab?.webContentsId != null) {
1359 const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* (
1360 tabId: string,
1361 transform: (current: number) => number,
1362 ) {
1363 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1364 if (!tab) return;
1365 const next = transform(tab.zoomFactor);
1366 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
1367 if (tab.webContentsId != null) {
6331368 const wc = webContents.fromId(tab.webContentsId);
6341369 if (wc && !wc.isDestroyed()) {
635 try {
636 wc.send(CANCEL_PICK_CHANNEL);
637 } catch {
638 // wc may have been torn down; nothing to clean up.
639 }
1370 yield* attempt("applyZoom", () => wc.setZoomFactor(next));
6401371 }
6411372 }
642 session.resolve(null);
643 }
1373 yield* update(tabId, { zoomFactor: next });
1374 });
6441375
645 async startRecording(tabId: string): Promise<void> {
646 if (this.recordingTabId && this.recordingTabId !== tabId) {
647 throw new Error("Only one browser recording can be active per window.");
648 }
649 const wc = this.requireWebContents(tabId);
650 await this.withControlSession(tabId, wc, "recording.start", async (send) => {
651 await send("Page.enable");
652 await send("Page.startScreencast", {
653 format: "jpeg",
654 quality: 80,
655 maxWidth: 1600,
656 maxHeight: 1200,
657 everyNthFrame: 1,
658 });
659 });
660 this.recordingTabId = tabId;
661 }
1376 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
1377 tabId: string,
1378 ) {
1379 const wc = yield* requireWebContents(tabId);
1380 const [createdAt, millis, image] = yield* Effect.all([
1381 currentIso,
1382 currentMillis,
1383 attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()),
1384 ]);
1385 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;
1386 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`);
1387 const data = image.toPNG();
1388 yield* fileSystem
1389 .makeDirectory(resolvedArtifactDirectory, { recursive: true })
1390 .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause)));
1391 yield* fileSystem
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> {
664 const wc = this.requireWebContents(tabId);
665 const createdAt = new Date().toISOString();
666 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`;
667 const directory = this.requireArtifactDirectory();
668 const path = join(directory, `${id}.png`);
669 const data = (await wc.capturePage()).toPNG();
670 await mkdir(directory, { recursive: true });
671 await writeFile(path, data);
672 return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt };
673 }
1404 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
1405 const recordingTabId = yield* Ref.get(recordingTabIdRef);
1406 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
1407 return yield* fail(
1408 "startRecording",
1409 new Error("Only one browser recording can be active per window."),
1410 );
1411 }
1412 const wc = yield* requireWebContents(tabId);
1413 yield* withControlSession(tabId, wc, "recording.start", (send) =>
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> {
676 if (this.recordingTabId !== tabId) return;
677 const wc = this.requireWebContents(tabId);
678 await this.withControlSession(tabId, wc, "recording.stop", async (send) => {
679 await send("Page.stopScreencast");
680 });
681 this.recordingTabId = null;
682 }
1428 const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) {
1429 const recordingTabId = yield* Ref.get(recordingTabIdRef);
1430 if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return;
1431 const wc = yield* requireWebContents(tabId);
1432 yield* withControlSession(tabId, wc, "recording.stop", (send) =>
1433 send("Page.stopScreencast").pipe(Effect.asVoid),
1434 );
1435 yield* Ref.set(recordingTabIdRef, Option.none());
1436 });
6831437
684 async saveRecording(
1438 const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* (
6851439 tabId: string,
6861440 mimeType: string,
6871441 data: Uint8Array,
688 ): Promise<DesktopPreviewRecordingArtifact> {
689 const createdAt = new Date().toISOString();
690 const id = `browser-recording-${Date.now().toString(36)}`;
1442 ) {
1443 const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]);
1444 const id = `browser-recording-${millis.toString(36)}`;
6911445 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
692 const directory = this.requireArtifactDirectory();
693 const path = join(directory, `${id}.${extension}`);
694 await mkdir(directory, { recursive: true });
695 await writeFile(path, data);
696 return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt };
697 }
698
699 onRecordingFrame(listener: RecordingFrameListener): () => void {
700 this.recordingFrameListeners.add(listener);
701 return () => this.recordingFrameListeners.delete(listener);
702 }
703
704 zoomIn(tabId: string): void {
705 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));
706 }
707
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 }
1446 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
1447 yield* fileSystem
1448 .makeDirectory(resolvedArtifactDirectory, { recursive: true })
1449 .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause)));
1450 yield* fileSystem
1451 .writeFile(artifactPath, data)
1452 .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause)));
1453 return {
1454 id,
1455 tabId,
1456 path: artifactPath,
1457 mimeType,
1458 sizeBytes: data.byteLength,
1459 createdAt,
1460 };
1461 });
7151462
716 automationStatus(tabId: string): PreviewAutomationStatus {
717 const tab = this.tabs.get(tabId);
1463 const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) {
1464 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
7181465 if (!tab || tab.webContentsId == null) {
7191466 const navStatus = tab?.navStatus;
7201467 return {
......@@ -727,223 +1474,266 @@ class PreviewViewManager {
7271474 };
7281475 }
7291476 const wc = webContents.fromId(tab.webContentsId);
730 if (!wc || wc.isDestroyed()) {
731 return {
732 available: false,
733 visible: true,
734 tabId,
735 url: null,
736 title: null,
737 loading: false,
738 };
739 }
740 return {
741 available: true,
742 visible: true,
743 tabId,
744 url: wc.getURL() || null,
745 title: wc.getTitle() || null,
746 loading: wc.isLoading(),
747 };
748 }
1477 return !wc || wc.isDestroyed()
1478 ? {
1479 available: false,
1480 visible: true,
1481 tabId,
1482 url: null,
1483 title: null,
1484 loading: false,
1485 }
1486 : {
1487 available: true,
1488 visible: true,
1489 tabId,
1490 url: wc.getURL() || null,
1491 title: wc.getTitle() || null,
1492 loading: wc.isLoading(),
1493 };
1494 });
7491495
750 async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> {
751 const wc = this.requireWebContents(tabId);
752 return this.withControlSession(tabId, wc, "snapshot", async (send) => {
753 await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]);
754 const page = await this.evaluateWithDebugger<{
755 url: string;
756 title: string;
757 loading: boolean;
758 visibleText: string;
759 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
760 }>(
761 send,
762 `(() => {
763 const selectorFor = (element) => {
764 if (element.id) return "#" + CSS.escape(element.id);
765 for (const attribute of ["data-testid", "name"]) {
766 const value = element.getAttribute(attribute);
767 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
768 }
769 const parts = [];
770 let current = element;
771 while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) {
772 let part = current.tagName.toLowerCase();
773 const parent = current.parentElement;
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) + ")";
1496 const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (
1497 tabId: string,
1498 ) {
1499 const wc = yield* requireWebContents(tabId);
1500 return yield* withControlSession(tabId, wc, "snapshot", (send) =>
1501 Effect.gen(function* () {
1502 yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], {
1503 concurrency: 2,
1504 discard: true,
1505 });
1506 const page = yield* evaluateWithDebugger<{
1507 url: string;
1508 title: string;
1509 loading: boolean;
1510 visibleText: string;
1511 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
1512 }>(
1513 send,
1514 `(() => {
1515 const selectorFor = (element) => {
1516 if (element.id) return "#" + CSS.escape(element.id);
1517 for (const attribute of ["data-testid", "name"]) {
1518 const value = element.getAttribute(attribute);
1519 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
7771520 }
778 parts.unshift(part);
779 current = parent;
780 }
781 return parts.join(" > ");
782 };
783 const visible = (element) => {
784 const style = getComputedStyle(element);
785 const rect = element.getBoundingClientRect();
786 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
787 };
788 const elements = Array.from(document.querySelectorAll(
789 "a[href],button,input,textarea,select,[role],[tabindex]"
790 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
791 const rect = element.getBoundingClientRect();
1521 const buildParts = (current, parts = []) => {
1522 if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) {
1523 return parts;
1524 }
1525 const parent = current.parentElement;
1526 const siblings = parent
1527 ? Array.from(parent.children).filter((child) => child.tagName === current.tagName)
1528 : [];
1529 const base = current.tagName.toLowerCase();
1530 const part = siblings.length > 1
1531 ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"
1532 : base;
1533 return buildParts(parent, [part, ...parts]);
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 });
7921557 return {
793 tag: element.tagName.toLowerCase(),
794 role: element.getAttribute("role"),
795 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
796 selector: selectorFor(element),
797 x: rect.x,
798 y: rect.y,
799 width: rect.width,
800 height: rect.height
1558 url: location.href,
1559 title: document.title,
1560 loading: document.readyState !== "complete",
1561 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
1562 interactiveElements: elements
8011563 };
802 });
803 return {
804 url: location.href,
805 title: document.title,
806 loading: document.readyState !== "complete",
807 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
808 interactiveElements: elements
809 };
1564 })()`,
1565 true,
1566 );
1567 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
1568 send("Accessibility.getFullAXTree"),
1569 attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()),
1570 Ref.get(diagnosticsRef),
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 }
8101626 })()`,
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 }),
8121635 );
813 const accessibility = await send("Accessibility.getFullAXTree");
814 let image = await wc.capturePage();
815 let size = image.getSize();
816 if (size.width > MAX_SCREENSHOT_WIDTH) {
817 image = image.resize({ width: MAX_SCREENSHOT_WIDTH });
818 size = image.getSize();
819 }
820 return {
821 ...page,
822 accessibilityTree: accessibility,
823 consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])],
824 networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])],
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 }
1636 }
1637 if ("notFound" in point) {
1638 return yield* fail(
1639 "automationClick",
1640 automationError(
1641 "PreviewAutomationExecutionError",
1642 `No element matches locator ${locator}.`,
1643 ),
1644 );
1645 }
1646 return point;
1647 });
8351648
836 async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> {
837 const wc = this.requireWebContents(tabId);
838 await this.withControlSession(tabId, wc, "click", async (send) => {
839 await Promise.all([
840 send("Runtime.enable"),
841 send("Input.setIgnoreInputEvents", { ignore: false }),
842 ]);
843 let x: number;
844 let y: number;
845 if ("selector" in input || "locator" in input) {
846 await this.ensurePlaywrightInjected(send);
847 const locator = this.automationLocator(input);
848 const point = await this.evaluateWithDebugger<
849 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
850 >(
1649 const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* (
1650 event: DesktopPreviewPointerEvent,
1651 ) {
1652 const listeners = yield* Ref.get(pointerEventListenersRef);
1653 yield* Effect.forEach(
1654 listeners,
1655 (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore),
1656 { discard: true },
1657 );
1658 });
1659
1660 const automationClick = Effect.fn("PreviewManager.automationClick")(function* (
1661 tabId: string,
1662 input: PreviewAutomationClickInput,
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 }>(
8511673 send,
852 `(() => {
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 })()`,
1674 "({ width: window.innerWidth, height: window.innerHeight })",
8681675 true,
8691676 );
870 if ("invalidSelector" in point) {
871 throw automationError("PreviewAutomationInvalidSelectorError", point.message, {
872 selector: locator,
873 });
874 }
875 if ("notFound" in point) {
876 throw automationError(
877 "PreviewAutomationExecutionError",
878 `No element matches locator ${locator}.`,
1677 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
1678 return yield* fail(
1679 "automationClick",
1680 automationError(
1681 "PreviewAutomationExecutionError",
1682 `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`,
1683 ),
8791684 );
8801685 }
881 x = point.x;
882 y = point.y;
883 } else {
884 x = input.x!;
885 y = input.y!;
886 }
887 const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>(
888 send,
889 "({ width: window.innerWidth, height: window.innerHeight })",
890 true,
891 );
892 if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) {
893 throw automationError(
894 "PreviewAutomationExecutionError",
895 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,
896 );
897 }
898 this.emitPointerEvent({
899 tabId,
900 phase: "move",
901 x,
902 y,
903 sequence: this.pointerSequence++,
904 createdAt: new Date().toISOString(),
905 });
906 await sleep(AGENT_CURSOR_MOVE_MS);
907 this.emitPointerEvent({
908 tabId,
909 phase: "click",
910 x,
911 y,
912 sequence: this.pointerSequence++,
913 createdAt: new Date().toISOString(),
914 });
915 await sleep(AGENT_CURSOR_CLICK_LEAD_MS);
916 this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 });
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 }
1686 const moveSequence = yield* nextCounter(pointerSequenceRef);
1687 const moveCreatedAt = yield* currentIso;
1688 yield* emitPointerEvent({
1689 tabId,
1690 phase: "move",
1691 ...point,
1692 sequence: moveSequence,
1693 createdAt: moveCreatedAt,
1694 });
1695 yield* Effect.sleep(AGENT_CURSOR_MOVE_MS);
1696 const clickSequence = yield* nextCounter(pointerSequenceRef);
1697 const clickCreatedAt = yield* currentIso;
1698 yield* emitPointerEvent({
1699 tabId,
1700 phase: "click",
1701 ...point,
1702 sequence: clickSequence,
1703 createdAt: clickCreatedAt,
1704 });
1705 yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS);
1706 yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 });
1707 yield* send("Input.dispatchMouseEvent", {
1708 type: "mousePressed",
1709 ...point,
1710 button: "left",
1711 clickCount: 1,
1712 });
1713 yield* send("Input.dispatchMouseEvent", {
1714 type: "mouseReleased",
1715 ...point,
1716 button: "left",
1717 clickCount: 1,
1718 });
1719 }),
1720 );
1721 });
9331722
934 async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> {
935 const wc = this.requireWebContents(tabId);
936 await this.withControlSession(tabId, wc, "type", async (send) => {
937 await send("Runtime.enable");
938 const locator = this.automationLocator(input);
939 if (locator) await this.ensurePlaywrightInjected(send);
940 const focusResult = await this.evaluateWithDebugger<
941 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
942 >(
943 send,
944 `(() => {
1723 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (
1724 send: SendCommand,
1725 input: PreviewAutomationTypeInput,
1726 ) {
1727 const locator = automationLocator(input);
1728 if (locator) yield* ensurePlaywrightInjected(send);
1729 const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null;
1730 const result = yield* evaluateWithDebugger<
1731 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1732 >(
1733 send,
1734 `(() => {
9451735 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"};
9471737 if (!element) return { notFound: true };
9481738 element.focus();
9491739 if (${input.clear ?? false}) {
......@@ -956,151 +1746,110 @@ class PreviewViewManager {
9561746 return { invalidSelector: true, message: String(error) };
9571747 }
9581748 })()`,
959 true,
960 );
961 if ("invalidSelector" in focusResult) {
962 throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, {
1749 true,
1750 );
1751 if ("invalidSelector" in result) {
1752 return yield* fail(
1753 "automationType",
1754 automationError("PreviewAutomationInvalidSelectorError", result.message, {
9631755 selector: input.selector ?? "",
964 });
965 }
966 if ("notFound" in focusResult) {
967 throw automationError(
1756 }),
1757 );
1758 }
1759 if ("notFound" in result) {
1760 return yield* fail(
1761 "automationType",
1762 automationError(
9681763 "PreviewAutomationExecutionError",
9691764 locator
9701765 ? `No element matches locator ${locator}.`
9711766 : "No element is focused in the preview.",
972 );
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,
1767 ),
9831768 );
984 });
985 }
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 }
1769 }
1770 });
10151771
1016 async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> {
1017 const wc = this.requireWebContents(tabId);
1018 await this.withControlSession(tabId, wc, "scroll", async (send) => {
1019 await send("Runtime.enable");
1020 const locator = this.automationLocator(input);
1021 if (locator) await this.ensurePlaywrightInjected(send);
1022 const result = await this.evaluateWithDebugger<
1023 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1024 >(
1025 send,
1026 `(() => {
1027 try {
1028 const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"};
1029 if (!target) return { notFound: true };
1030 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1031 return { ok: true };
1032 } catch (error) {
1033 return { invalidSelector: true, message: String(error) };
1034 }
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}.`,
1772 const automationType = Effect.fn("PreviewManager.automationType")(function* (
1773 tabId: string,
1774 input: PreviewAutomationTypeInput,
1775 ) {
1776 const wc = yield* requireWebContents(tabId);
1777 yield* withControlSession(tabId, wc, "type", (send) =>
1778 Effect.gen(function* () {
1779 yield* send("Runtime.enable");
1780 yield* focusAutomationTarget(send, input);
1781 yield* send("Input.insertText", { text: input.text });
1782 const textJson = yield* encodeJson("automationType.encodeText", input.text);
1783 yield* evaluateWithDebugger(
1784 send,
1785 `(() => {
1786 const element = document.activeElement;
1787 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} }));
1788 element?.dispatchEvent(new Event("change", { bubbles: true }));
1789 })()`,
1790 false,
10471791 );
1048 }
1049 });
1050 }
1792 }),
1793 );
1794 });
10511795
1052 async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> {
1053 const wc = this.requireWebContents(tabId);
1054 return this.withControlSession(tabId, wc, "evaluate", async (send) => {
1055 await send("Runtime.enable");
1056 const value = await this.evaluateWithDebugger(
1057 send,
1058 input.expression,
1059 input.returnByValue ?? true,
1060 input.awaitPromise ?? true,
1061 );
1062 const serialized = JSON.stringify(value);
1063 if (
1064 serialized !== undefined &&
1065 Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES
1066 ) {
1067 throw automationError(
1068 "PreviewAutomationResultTooLargeError",
1069 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1070 { maximumBytes: MAX_EVALUATION_BYTES },
1071 );
1072 }
1073 return value;
1074 });
1075 }
1796 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
1797 tabId: string,
1798 input: PreviewAutomationPressInput,
1799 ) {
1800 const wc = yield* requireWebContents(tabId);
1801 yield* withControlSession(tabId, wc, "press", (send) =>
1802 Effect.gen(function* () {
1803 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
1804 switch (modifier) {
1805 case "Alt":
1806 return value | 1;
1807 case "Control":
1808 return value | 2;
1809 case "Meta":
1810 return value | 4;
1811 case "Shift":
1812 return value | 8;
1813 }
1814 }, 0);
1815 const key = input.key;
1816 const text = key.length === 1 ? key : undefined;
1817 const params = {
1818 key,
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> {
1078 const wc = this.requireWebContents(tabId);
1079 const timeoutMs = input.timeoutMs ?? 15_000;
1080 await this.withControlSession(tabId, wc, "waitFor", async (send) => {
1081 await send("Runtime.enable");
1082 const locator = this.automationLocator(input);
1083 if (locator) await this.ensurePlaywrightInjected(send);
1084 const deadline = Date.now() + timeoutMs;
1085 while (Date.now() <= deadline) {
1086 const result = await this.evaluateWithDebugger<
1087 { matched: boolean } | { invalidSelector: true; message: string }
1830 const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* (
1831 tabId: string,
1832 input: PreviewAutomationScrollInput,
1833 ) {
1834 const wc = yield* requireWebContents(tabId);
1835 yield* withControlSession(tabId, wc, "scroll", (send) =>
1836 Effect.gen(function* () {
1837 yield* send("Runtime.enable");
1838 const locator = automationLocator(input);
1839 if (locator) yield* ensurePlaywrightInjected(send);
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 }
10881845 >(
10891846 send,
10901847 `(() => {
10911848 try {
1092 const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"};
1093 const textMatched = ${
1094 input.text
1095 ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})`
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 };
1849 const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"};
1850 if (!target) return { notFound: true };
1851 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1852 return { ok: true };
11041853 } catch (error) {
11051854 return { invalidSelector: true, message: String(error) };
11061855 }
......@@ -1108,563 +1857,211 @@ class PreviewViewManager {
11081857 true,
11091858 );
11101859 if ("invalidSelector" in result) {
1111 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
1112 selector: input.selector ?? "",
1113 });
1114 }
1115 if (result.matched) return;
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.",
1860 return yield* fail(
1861 "automationScroll",
1862 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1863 selector: input.selector ?? "",
1864 }),
11691865 );
11701866 }
1171 const result = await wc.debugger.sendCommand(method, commandParams);
1172 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {
1173 throw automationError(
1174 "PreviewAutomationControlInterruptedError",
1175 "Browser control was interrupted by human input.",
1867 if ("notFound" in result) {
1868 return yield* fail(
1869 "automationScroll",
1870 automationError(
1871 "PreviewAutomationExecutionError",
1872 `No element matches locator ${locator}.`,
1873 ),
11761874 );
11771875 }
1178 return result;
1179 };
1180 const result = await use(send);
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 }
1876 }),
1877 );
1878 });
12211879
1222 private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> {
1223 const existing = this.controlSessions.get(wc.id);
1224 if (existing) {
1225 await existing.initialized;
1226 return existing;
1227 }
1228 if (wc.isDevToolsOpened()) {
1229 throw automationError(
1230 "PreviewAutomationExecutionError",
1231 "Close preview DevTools before using agent browser control.",
1232 );
1233 }
1234 if (wc.debugger.isAttached()) {
1235 throw automationError(
1236 "PreviewAutomationExecutionError",
1237 "Preview control cannot attach because another debugger owns this page.",
1238 );
1239 }
1240 const diagnostics: BrowserDiagnostics = {
1241 consoleEntries: [],
1242 networkEntries: [],
1243 requests: new Map(),
1244 };
1245 this.diagnostics.set(wc.id, diagnostics);
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);
1880 const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* (
1881 tabId: string,
1882 input: PreviewAutomationEvaluateInput,
1883 ) {
1884 const wc = yield* requireWebContents(tabId);
1885 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
1886 Effect.gen(function* () {
1887 yield* send("Runtime.enable");
1888 const value = yield* evaluateWithDebugger(
1889 send,
1890 input.expression,
1891 input.returnByValue ?? true,
1892 input.awaitPromise ?? true,
1893 );
1894 const serialized = yield* encodeJson("automationEvaluate.encodeResult", value);
1895 if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) {
1896 return yield* fail(
1897 "automationEvaluate",
1898 automationError(
1899 "PreviewAutomationResultTooLargeError",
1900 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1901 { maximumBytes: MAX_EVALUATION_BYTES },
1902 ),
1903 );
12691904 }
1270 }
1271 this.captureDiagnosticMessage(diagnostics, method, params);
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,
1905 return value;
1906 }),
14641907 );
1465 if (installed) return;
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 }
1908 });
14871909
1488 private expectAgentInput(tabId: string, signal: PreviewInputSignal): void {
1489 const now = Date.now();
1490 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1491 (expected) => expected.expiresAt > now,
1910 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
1911 tabId: string,
1912 input: PreviewAutomationWaitForInput,
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 }),
14921971 );
1493 pending.push({ signal, expiresAt: now + 1_000 });
1494 this.expectedAgentInputs.set(tabId, pending);
1495 }
1972 });
14961973
1497 private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean {
1498 const now = Date.now();
1499 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1500 (expected) => expected.expiresAt > now,
1501 );
1502 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));
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 }
1974 const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* (
1975 artifactPath: string,
1976 ) {
1977 const resolvedPath = yield* resolveArtifactPath(artifactPath);
1978 yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath));
1979 });
15131980
1514 destroy(): void {
1515 for (const tabId of Array.from(this.tabs.keys())) {
1516 this.closeTab(tabId);
1981 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
1982 artifactPath: string,
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 );
15171993 }
1518 this.listeners.clear();
1519 this.expectedAgentInputs.clear();
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 }
1994 yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image));
1995 });
16101996
1611 private isAppShortcut(input: Electron.Input): boolean {
1612 if (input.type !== "keyDown") return false;
1613 return APP_FORWARDED_SHORTCUTS.some(
1614 (shortcut) =>
1615 shortcut.key.toLowerCase() === input.key.toLowerCase() &&
1616 shortcut.meta === input.meta &&
1617 shortcut.shift === input.shift &&
1618 shortcut.control === input.control,
1997 const subscribe = <A>(
1998 ref: Ref.Ref<ReadonlySet<A>>,
1999 listener: A,
2000 ): Effect.Effect<void, never, Scope.Scope> =>
2001 Effect.acquireRelease(
2002 Ref.update(ref, (listeners) => new Set([...listeners, listener])),
2003 () =>
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 },
16192022 );
1620 }
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 }
2023 });
16502024
1651 private emit(tabId: string, state: PreviewTabState): void {
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 }
2025 yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore));
16602026
1661 private normalizeUrl(input: string): string {
1662 // Surface the shared error directly so the IPC caller (and any future
1663 // desktop-side telemetry) gets the `detail` field for free. Defining a
1664 // bespoke desktop class would just lose information.
1665 return normalizePreviewUrl(input);
1666 }
1667}
2027 return {
2028 automationClick,
2029 automationEvaluate,
2030 automationPress,
2031 automationScroll,
2032 automationSnapshot,
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
16692066export class PreviewTabNotFoundError extends Error {
16702067 readonly tabId: string;
......@@ -1725,7 +2122,7 @@ export interface PreviewManagerShape {
17252122 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
17262123 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
17272124 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
1728 readonly getBrowserPartition: (scope?: string) => string;
2125 readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>;
17292126 readonly setAnnotationTheme: (
17302127 theme: DesktopPreviewAnnotationTheme,
17312128 ) => Effect.Effect<void, PreviewManagerError>;
......@@ -1788,29 +2185,10 @@ export class PreviewManager extends Context.Service<PreviewManager, PreviewManag
17882185 "@t3tools/desktop/preview/Manager/PreviewManager",
17892186) {}
17902187
1791const make = Effect.fn("PreviewManager.make")(function* () {
2188const make = Effect.gen(function* PreviewManagerMake() {
17922189 const environment = yield* DesktopEnvironment.DesktopEnvironment;
17932190 const browserSession = yield* BrowserSession.BrowserSession;
1794 const manager = new PreviewViewManager();
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 });
2191 const operations = yield* makeNativeOperations(environment.browserArtifactsDir);
18142192 const browserSessionEffect = <A>(
18152193 operation: string,
18162194 effect: Effect.Effect<A, BrowserSession.BrowserSessionError>,
......@@ -1818,132 +2196,53 @@ const make = Effect.fn("PreviewManager.make")(function* () {
18182196 effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause })));
18192197
18202198 return PreviewManager.of({
1821 setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) {
1822 yield* attempt("setMainWindow", () => manager.setMainWindow(window));
1823 }),
2199 setMainWindow: operations.setMainWindow,
18242200 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {
18252201 return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope));
18262202 }),
18272203 isBrowserPartition: browserSession.isPartition,
1828 createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) {
1829 return yield* attempt("createTab", () => manager.createTab(tabId));
1830 }),
1831 closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) {
1832 yield* attempt("closeTab", () => manager.closeTab(tabId));
1833 }),
1834 registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) {
1835 yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId));
1836 }),
1837 navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) {
1838 yield* attemptPromise("navigate", () => manager.navigate(tabId, url));
1839 }),
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 }),
2204 createTab: operations.createTab,
2205 closeTab: operations.closeTab,
2206 registerWebview: operations.registerWebview,
2207 navigate: operations.navigate,
2208 goBack: operations.goBack,
2209 goForward: operations.goForward,
2210 refresh: operations.refresh,
2211 zoomIn: operations.zoomIn,
2212 zoomOut: operations.zoomOut,
2213 resetZoom: operations.resetZoom,
2214 hardReload: operations.hardReload,
2215 openDevTools: operations.openDevTools,
18642216 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
18652217 yield* browserSessionEffect("clearCookies", browserSession.clearCookies());
18662218 }),
18672219 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {
18682220 yield* browserSessionEffect("clearCache", browserSession.clearCache());
18692221 }),
1870 getBrowserPartition: browserSession.getPartition,
1871 setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) {
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));
2222 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {
2223 return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope));
18882224 }),
1889 startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) {
1890 yield* attemptPromise("startRecording", () => manager.startRecording(tabId));
1891 }),
1892 stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) {
1893 yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId));
1894 }),
1895 saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) {
1896 return yield* attemptPromise("saveRecording", () =>
1897 manager.saveRecording(tabId, mimeType, data),
1898 );
1899 }),
1900 automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) {
1901 return yield* attempt("automationStatus", () => manager.automationStatus(tabId));
1902 }),
1903 automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) {
1904 return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId));
1905 }),
1906 automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) {
1907 yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input));
1908 }),
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),
2225 setAnnotationTheme: operations.setAnnotationTheme,
2226 pickElement: operations.pickElement,
2227 cancelPickElement: operations.cancelPickElement,
2228 captureScreenshot: operations.captureScreenshot,
2229 revealArtifact: operations.revealArtifact,
2230 copyArtifactToClipboard: operations.copyArtifactToClipboard,
2231 startRecording: operations.startRecording,
2232 stopRecording: operations.stopRecording,
2233 saveRecording: operations.saveRecording,
2234 automationStatus: operations.automationStatus,
2235 automationSnapshot: operations.automationSnapshot,
2236 automationClick: operations.automationClick,
2237 automationType: operations.automationType,
2238 automationPress: operations.automationPress,
2239 automationScroll: operations.automationScroll,
2240 automationEvaluate: operations.automationEvaluate,
2241 automationWaitFor: operations.automationWaitFor,
2242 subscribeStateChanges: operations.subscribeStateChanges,
2243 subscribePointerEvents: operations.subscribePointerEvents,
2244 subscribeRecordingFrames: operations.subscribeRecordingFrames,
19412245 });
1942});
2246}).pipe(Effect.withSpan("PreviewManager.make"));
19432247
1944export const layer = Layer.effect(PreviewManager, make());
1945
1946/** Exposed for tests. */
1947export const __testing = {
1948 PreviewViewManager,
1949};
2248export const layer = Layer.effect(PreviewManager, make);
apps/desktop/src/preview/PickPreload.ts+1-1
......@@ -1,4 +1,4 @@
1// @effect-diagnostics globalDate:off
1// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime.
22import { ipcRenderer } from "electron";
33import { getElementContext } from "react-grab/primitives";
44import type {
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts+17-11
......@@ -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
35import {
46 playwrightInjectedRuntimeInstallExpression,
......@@ -6,15 +8,19 @@ import {
68} from "./PlaywrightInjectedRuntime.ts";
79
810describe("playwright injected runtime", () => {
9 it("extracts the pinned runtime from playwright-core", async () => {
10 const source = await playwrightInjectedRuntimeSource();
11 expect(source.length).toBeGreaterThan(100_000);
12 expect(source).toContain("InjectedScript");
13 });
11 effectIt.effect("extracts the pinned runtime from playwright-core", () =>
12 Effect.gen(function* () {
13 const source = yield* playwrightInjectedRuntimeSource();
14 expect(source.length).toBeGreaterThan(100_000);
15 expect(source).toContain("InjectedScript");
16 }),
17 );
1418
15 it("builds an idempotent install expression", async () => {
16 const expression = await playwrightInjectedRuntimeInstallExpression();
17 expect(expression).toContain("__t3PlaywrightInjected");
18 expect(expression).toContain('testIdAttributeName":"data-testid');
19 });
19 effectIt.effect("builds an idempotent install expression", () =>
20 Effect.gen(function* () {
21 const expression = yield* playwrightInjectedRuntimeInstallExpression();
22 expect(expression).toContain("__t3PlaywrightInjected");
23 expect(expression).toContain('testIdAttributeName":"data-testid');
24 }),
25 );
2026});
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts+61-20
......@@ -1,35 +1,76 @@
1// @effect-diagnostics nodeBuiltinImport:off
1// @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection.
2import { readFile } from "node:fs/promises";
23import { createRequire } from "node:module";
34import { dirname, join } from "node:path";
4import { readFile } from "node:fs/promises";
55import { runInNewContext } from "node:vm";
66
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10
711const 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> => {
11 sourcePromise ??= (async () => {
12 const packageJsonPath = require.resolve("playwright-core/package.json");
13 const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8");
25const fail = (operation: string, cause: unknown) =>
26 new PlaywrightInjectedRuntimeError({ operation, cause });
27
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 });
1438 const marker = "source3 = ";
1539 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 }
1746 const literalStart = start + marker.length;
1847 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 }
2054 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 });
2259 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 );
2464 }
2565 return source;
26 })();
27 return sourcePromise;
28};
66 },
67);
2968
30export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => {
31 const source = await playwrightInjectedRuntimeSource();
32 const options = {
69export const playwrightInjectedRuntimeInstallExpression = Effect.fn(
70 "PlaywrightInjectedRuntime.installExpression",
71)(function* () {
72 const source = yield* playwrightInjectedRuntimeSource();
73 const options = yield* encodeUnknownJson({
3374 isUnderTest: false,
3475 sdkLanguage: "javascript",
3576 testIdAttributeName: "data-testid",
......@@ -38,12 +79,12 @@ export const playwrightInjectedRuntimeInstallExpression = async (): Promise<stri
3879 shouldPrependErrorPrefix: false,
3980 isUtilityWorld: false,
4081 customEngines: [],
41 };
82 }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause)));
4283 return `(() => {
4384 if (globalThis.__t3PlaywrightInjected) return true;
4485 const module = { exports: {} };
4586 ${source}
46 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)});
87 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${options});
4788 return true;
4889 })()`;
49};
90});
apps/desktop/src/window/DesktopWindow.test.ts+1-1
......@@ -171,7 +171,7 @@ function makeTestLayer(input: {
171171 getBrowserSession: () => Effect.succeed({} as Electron.Session),
172172 setMainWindow: () => Effect.void,
173173 isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
174 getBrowserPartition: () => "persist:t3code-preview-test",
174 getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),
175175 }),
176176 ),
177177 ),
apps/web/src/components/preview/PreviewAutomationOwner.tsx+2-8
......@@ -1,5 +1,4 @@
11"use client";
2// @effect-diagnostics cryptoRandomUUID:off
32
43import { scopedThreadKey } from "@t3tools/client-runtime";
54import type {
......@@ -10,7 +9,7 @@ import type {
109 PreviewAutomationStatus,
1110 ScopedThreadRef,
1211} from "@t3tools/contracts";
13import { useCallback, useEffect, useRef, useState } from "react";
12import { useCallback, useEffect, useId, useRef } from "react";
1413
1514import { ensureEnvironmentApi } from "~/environmentApi";
1615import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
......@@ -24,11 +23,6 @@ import {
2423
2524import { 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
3226const waitForDesktopOverlay = async (
3327 threadRef: ScopedThreadRef,
3428 timeoutMs: number,
......@@ -118,7 +112,7 @@ export function PreviewAutomationOwner(props: {
118112 readonly visible: boolean;
119113}) {
120114 const { threadRef, visible } = props;
121 const [automationClientId] = useState(newAutomationClientId);
115 const automationClientId = useId();
122116 const ownerStateRef = useRef({ threadRef, visible });
123117 const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>(
124118 async () => undefined,
apps/web/src/reactGrabBoundary.test.ts+1-4
......@@ -1,13 +1,10 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { readFileSync } from "node:fs";
31import { describe, expect, it } from "vite-plus/test";
42
53import packageJson from "../package.json" with { type: "json" };
4import mainSource from "./main.tsx?raw";
65
76describe("React Grab runtime boundary", () => {
87 it("keeps the host renderer free of the React Grab overlay", () => {
9 const mainSource = readFileSync(new URL("./main.tsx", import.meta.url), "utf8");
10
118 expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/);
129 expect(packageJson.dependencies).not.toHaveProperty("react-grab");
1310 expect(packageJson.devDependencies).not.toHaveProperty("react-grab");