libretto 0.6.9 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/cli/cli.js +2 -0
  2. package/dist/cli/commands/auth.js +535 -0
  3. package/dist/cli/commands/billing.js +74 -0
  4. package/dist/cli/commands/browser.js +8 -3
  5. package/dist/cli/commands/deploy.js +2 -7
  6. package/dist/cli/commands/execution.js +99 -136
  7. package/dist/cli/commands/snapshot.js +38 -126
  8. package/dist/cli/core/ai-model.js +0 -3
  9. package/dist/cli/core/auth-fetch.js +195 -0
  10. package/dist/cli/core/auth-storage.js +52 -0
  11. package/dist/cli/core/browser.js +128 -202
  12. package/dist/cli/core/daemon/config.js +6 -0
  13. package/dist/cli/core/daemon/daemon.js +298 -0
  14. package/dist/cli/core/daemon/exec.js +86 -0
  15. package/dist/cli/core/daemon/index.js +16 -0
  16. package/dist/cli/core/daemon/ipc.js +171 -0
  17. package/dist/cli/core/daemon/pages.js +15 -0
  18. package/dist/cli/core/daemon/snapshot.js +86 -0
  19. package/dist/cli/core/daemon/spawn.js +90 -0
  20. package/dist/cli/core/exec-compiler.js +111 -0
  21. package/dist/cli/core/prompt.js +72 -0
  22. package/dist/cli/core/providers/libretto-cloud.js +2 -6
  23. package/dist/cli/core/readonly-exec.js +1 -1
  24. package/dist/cli/router.js +4 -0
  25. package/dist/cli/workers/run-integration-runtime.js +0 -5
  26. package/dist/shared/state/session-state.d.ts +1 -0
  27. package/dist/shared/state/session-state.js +2 -1
  28. package/docs/browser-automation-approaches.md +435 -0
  29. package/docs/releasing.md +117 -0
  30. package/package.json +4 -3
  31. package/skills/libretto/SKILL.md +14 -1
  32. package/skills/libretto-readonly/SKILL.md +1 -1
  33. package/src/cli/cli.ts +2 -0
  34. package/src/cli/commands/auth.ts +787 -0
  35. package/src/cli/commands/billing.ts +133 -0
  36. package/src/cli/commands/browser.ts +8 -2
  37. package/src/cli/commands/deploy.ts +2 -7
  38. package/src/cli/commands/execution.ts +126 -186
  39. package/src/cli/commands/snapshot.ts +46 -143
  40. package/src/cli/core/ai-model.ts +4 -5
  41. package/src/cli/core/auth-fetch.ts +283 -0
  42. package/src/cli/core/auth-storage.ts +102 -0
  43. package/src/cli/core/browser.ts +159 -242
  44. package/src/cli/core/daemon/config.ts +46 -0
  45. package/src/cli/core/daemon/daemon.ts +429 -0
  46. package/src/cli/core/daemon/exec.ts +128 -0
  47. package/src/cli/core/daemon/index.ts +24 -0
  48. package/src/cli/core/daemon/ipc.ts +294 -0
  49. package/src/cli/core/daemon/pages.ts +21 -0
  50. package/src/cli/core/daemon/snapshot.ts +114 -0
  51. package/src/cli/core/daemon/spawn.ts +171 -0
  52. package/src/cli/core/exec-compiler.ts +169 -0
  53. package/src/cli/core/prompt.ts +94 -0
  54. package/src/cli/core/providers/libretto-cloud.ts +2 -6
  55. package/src/cli/core/readonly-exec.ts +2 -1
  56. package/src/cli/router.ts +4 -0
  57. package/src/cli/workers/run-integration-runtime.ts +0 -6
  58. package/src/shared/state/session-state.ts +1 -0
  59. package/dist/cli/core/browser-daemon.js +0 -122
  60. package/src/cli/core/browser-daemon.ts +0 -198
@@ -0,0 +1,298 @@
1
+ import {
2
+ chromium
3
+ } from "playwright";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { appendFileSync } from "node:fs";
6
+ import { installSessionTelemetry } from "../session-telemetry.js";
7
+ import {
8
+ createLoggerForSession,
9
+ getSessionDir,
10
+ getSessionNetworkLogPath,
11
+ getSessionActionsLogPath
12
+ } from "../context.js";
13
+ import {
14
+ DaemonServer,
15
+ getDaemonSocketPath
16
+ } from "./ipc.js";
17
+ import { wrapPageForActionLogging } from "../telemetry.js";
18
+ import { handlePages } from "./pages.js";
19
+ import { handleExec, handleReadonlyExec } from "./exec.js";
20
+ import { handleSnapshot } from "./snapshot.js";
21
+ import {
22
+ isConnectConfig
23
+ } from "./config.js";
24
+ function isOperationalPage(page) {
25
+ const url = page.url();
26
+ return !url.startsWith("devtools://") && !url.startsWith("chrome-error://");
27
+ }
28
+ const PROTOCOL_VERSION = 1;
29
+ const REQUEST_TIMEOUT_MS = 6e4;
30
+ class BrowserDaemon {
31
+ constructor(session, externallyManaged, browser, context, page, ipcServer, logger) {
32
+ this.session = session;
33
+ this.externallyManaged = externallyManaged;
34
+ this.browser = browser;
35
+ this.context = context;
36
+ this.page = page;
37
+ this.ipcServer = ipcServer;
38
+ this.logger = logger.withScope("child");
39
+ }
40
+ logger;
41
+ execState = {};
42
+ pageById = /* @__PURE__ */ new Map();
43
+ shuttingDown = false;
44
+ trackPage(page) {
45
+ const id = `page-${Math.random().toString(36).slice(2, 5)}`;
46
+ this.pageById.set(id, page);
47
+ page.on("close", () => this.pageById.delete(id));
48
+ return id;
49
+ }
50
+ // ── Shared initialization ──────────────────────────────────────────
51
+ /**
52
+ * Common setup after the mode-specific code has obtained a browser,
53
+ * context, and page(s). Installs telemetry, action logging, IPC
54
+ * server, page tracking, and the browser disconnect handler.
55
+ */
56
+ static async initialize(args) {
57
+ const {
58
+ session,
59
+ externallyManaged,
60
+ browser,
61
+ context,
62
+ page,
63
+ initialPages,
64
+ navigateUrl
65
+ } = args;
66
+ await mkdir(getSessionDir(session), { recursive: true });
67
+ const networkLogFile = getSessionNetworkLogPath(session);
68
+ const actionsLogFile = getSessionActionsLogPath(session);
69
+ const logger = createLoggerForSession(session);
70
+ try {
71
+ await installSessionTelemetry({
72
+ context,
73
+ initialPage: page,
74
+ includeUserDomActions: true,
75
+ logAction: (entry) => {
76
+ appendFileSync(actionsLogFile, JSON.stringify(entry) + "\n");
77
+ },
78
+ logNetwork: (entry) => {
79
+ appendFileSync(networkLogFile, JSON.stringify(entry) + "\n");
80
+ }
81
+ });
82
+ } catch (err) {
83
+ logger.warn("telemetry-install-failed", {
84
+ session,
85
+ error: err instanceof Error ? err.message : String(err)
86
+ });
87
+ }
88
+ const socketPath = getDaemonSocketPath(session);
89
+ let handler;
90
+ const ipcServer = new DaemonServer(
91
+ socketPath,
92
+ (request) => handler(request)
93
+ );
94
+ const daemon = new BrowserDaemon(
95
+ session,
96
+ externallyManaged,
97
+ browser,
98
+ context,
99
+ page,
100
+ ipcServer,
101
+ logger
102
+ );
103
+ for (const p of initialPages) {
104
+ wrapPageForActionLogging(p, session);
105
+ daemon.trackPage(p);
106
+ }
107
+ context.on("page", (newPage) => {
108
+ wrapPageForActionLogging(newPage, session);
109
+ daemon.trackPage(newPage);
110
+ });
111
+ if (navigateUrl) {
112
+ await page.goto(navigateUrl);
113
+ }
114
+ handler = (request) => daemon.handleRequest(request);
115
+ await ipcServer.listen();
116
+ daemon.logger.info("ipc-server-listening", { socketPath });
117
+ browser.on("disconnected", () => {
118
+ void daemon.shutdown("browser-disconnected-exiting", false);
119
+ });
120
+ return daemon;
121
+ }
122
+ // ── Launch mode ────────────────────────────────────────────────────
123
+ static async launchBrowser(config) {
124
+ const windowPositionArg = config.windowPosition ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}` : void 0;
125
+ const browser = await chromium.launch({
126
+ headless: !config.headed,
127
+ args: [
128
+ "--disable-blink-features=AutomationControlled",
129
+ `--remote-debugging-port=${config.port}`,
130
+ "--remote-debugging-address=127.0.0.1",
131
+ "--no-focus-on-check",
132
+ ...windowPositionArg ? [windowPositionArg] : []
133
+ ]
134
+ });
135
+ const context = await browser.newContext({
136
+ ...config.storageStatePath ? { storageState: config.storageStatePath } : {},
137
+ viewport: {
138
+ width: config.viewport.width,
139
+ height: config.viewport.height
140
+ },
141
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
142
+ });
143
+ const page = await context.newPage();
144
+ page.setDefaultTimeout(3e4);
145
+ page.setDefaultNavigationTimeout(45e3);
146
+ const daemon = await BrowserDaemon.initialize({
147
+ session: config.session,
148
+ externallyManaged: false,
149
+ browser,
150
+ context,
151
+ page,
152
+ initialPages: [page],
153
+ navigateUrl: config.url
154
+ });
155
+ daemon.logger.info("child-launched", {
156
+ port: config.port,
157
+ pid: process.pid,
158
+ session: config.session
159
+ });
160
+ return daemon;
161
+ }
162
+ // ── Connect mode ───────────────────────────────────────────────────
163
+ static async connectToEndpoint(config) {
164
+ const browser = await chromium.connectOverCDP(config.cdpEndpoint);
165
+ const contexts = browser.contexts();
166
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
167
+ const operationalPages = context.pages().filter(isOperationalPage);
168
+ const page = operationalPages.length > 0 ? operationalPages[operationalPages.length - 1] : await context.newPage();
169
+ const daemon = await BrowserDaemon.initialize({
170
+ session: config.session,
171
+ externallyManaged: true,
172
+ browser,
173
+ context,
174
+ page,
175
+ initialPages: operationalPages.length > 0 ? operationalPages : [page],
176
+ navigateUrl: config.url
177
+ });
178
+ daemon.logger.info("child-connected", {
179
+ cdpEndpoint: config.cdpEndpoint,
180
+ url: config.url,
181
+ pid: process.pid,
182
+ session: config.session
183
+ });
184
+ return daemon;
185
+ }
186
+ // ── Lifecycle ──────────────────────────────────────────────────────
187
+ async shutdown(reason, closeBrowser) {
188
+ if (this.shuttingDown) return;
189
+ this.shuttingDown = true;
190
+ this.logger.info(reason, { session: this.session });
191
+ await this.ipcServer.close();
192
+ if (!closeBrowser) return;
193
+ if (this.externallyManaged) {
194
+ try {
195
+ this.browser._connection?.close();
196
+ } catch {
197
+ }
198
+ } else {
199
+ await this.browser.close();
200
+ }
201
+ }
202
+ // ── Page resolution ────────────────────────────────────────────────
203
+ resolveTargetPage(pageId) {
204
+ if (!pageId) {
205
+ if (this.pageById.size > 1) {
206
+ throw new Error(
207
+ `Multiple pages are open in session "${this.session}". Pass --page <id> to target a page (run "libretto pages --session ${this.session}" to list ids).`
208
+ );
209
+ }
210
+ if (this.pageById.size === 1) {
211
+ return this.pageById.values().next().value;
212
+ }
213
+ return this.page;
214
+ }
215
+ const page = this.pageById.get(pageId);
216
+ if (!page) {
217
+ throw new Error(
218
+ `Page "${pageId}" was not found in session "${this.session}". Run "libretto pages --session ${this.session}" to list ids.`
219
+ );
220
+ }
221
+ return page;
222
+ }
223
+ // ── IPC handler ────────────────────────────────────────────────────
224
+ async handleRequest(request) {
225
+ if (request.command === "ping") {
226
+ return { protocolVersion: PROTOCOL_VERSION };
227
+ }
228
+ let timerId;
229
+ return Promise.race([
230
+ this.dispatchCommand(request).finally(() => clearTimeout(timerId)),
231
+ new Promise((_resolve, reject) => {
232
+ timerId = setTimeout(
233
+ () => reject(
234
+ new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)
235
+ ),
236
+ REQUEST_TIMEOUT_MS
237
+ );
238
+ })
239
+ ]);
240
+ }
241
+ async dispatchCommand(request) {
242
+ switch (request.command) {
243
+ case "pages":
244
+ return handlePages(this.pageById, this.page);
245
+ case "exec":
246
+ return handleExec(
247
+ this.resolveTargetPage(request.pageId),
248
+ request.code,
249
+ this.context,
250
+ this.browser,
251
+ this.execState,
252
+ this.session,
253
+ request.visualize
254
+ );
255
+ case "readonly-exec":
256
+ return handleReadonlyExec(
257
+ this.resolveTargetPage(request.pageId),
258
+ request.code
259
+ );
260
+ case "snapshot":
261
+ return handleSnapshot(
262
+ this.resolveTargetPage(request.pageId),
263
+ this.session,
264
+ this.logger,
265
+ request.pageId
266
+ );
267
+ default:
268
+ throw new Error(
269
+ `Unknown command: ${request.command}`
270
+ );
271
+ }
272
+ }
273
+ }
274
+ async function main() {
275
+ const config = JSON.parse(process.argv[2]);
276
+ const daemon = isConnectConfig(config) ? await BrowserDaemon.connectToEndpoint(config) : await BrowserDaemon.launchBrowser(config);
277
+ process.on("SIGTERM", () => {
278
+ void daemon.shutdown("child-sigterm", true);
279
+ });
280
+ process.on("SIGINT", () => {
281
+ void daemon.shutdown("child-sigint", true);
282
+ });
283
+ process.on("uncaughtException", (err) => {
284
+ daemon.logger.error("uncaught-exception", err);
285
+ process.exit(1);
286
+ });
287
+ process.on("unhandledRejection", (reason) => {
288
+ daemon.logger.warn("unhandled-rejection", { reason: String(reason) });
289
+ });
290
+ process.on("exit", (code) => {
291
+ daemon.logger.info("child-exit", {
292
+ code,
293
+ pid: process.pid,
294
+ session: config.session
295
+ });
296
+ });
297
+ }
298
+ await main();
@@ -0,0 +1,86 @@
1
+ import { format, formatWithOptions } from "node:util";
2
+ import { installInstrumentation } from "../../../shared/instrumentation/index.js";
3
+ import { compileExecFunction } from "../exec-compiler.js";
4
+ import { createReadonlyExecHelpers } from "../readonly-exec.js";
5
+ import { readNetworkLog, readActionLog } from "../telemetry.js";
6
+ class DaemonExecError extends Error {
7
+ constructor(message, output) {
8
+ super(message);
9
+ this.output = output;
10
+ this.name = "DaemonExecError";
11
+ }
12
+ }
13
+ function createBufferedConsole() {
14
+ const output = { stdout: "", stderr: "" };
15
+ const writeStdout = (...args) => {
16
+ output.stdout += `${format(...args)}
17
+ `;
18
+ };
19
+ const writeStderr = (...args) => {
20
+ output.stderr += `${format(...args)}
21
+ `;
22
+ };
23
+ const bufferedConsole = {
24
+ ...globalThis.console,
25
+ log: writeStdout,
26
+ info: writeStdout,
27
+ debug: writeStdout,
28
+ dir: (value, options) => {
29
+ output.stdout += `${formatWithOptions(options ?? {}, value)}
30
+ `;
31
+ },
32
+ warn: writeStderr,
33
+ error: writeStderr
34
+ };
35
+ return { console: bufferedConsole, output };
36
+ }
37
+ async function handleExec(targetPage, code, context, browser, execState, session, visualize) {
38
+ const buffered = createBufferedConsole();
39
+ if (visualize) {
40
+ await installInstrumentation(targetPage, { visualize: true });
41
+ }
42
+ const networkLog = (opts = {}) => readNetworkLog(session, opts);
43
+ const actionLog = (opts = {}) => readActionLog(session, opts);
44
+ const helpers = {
45
+ page: targetPage,
46
+ context,
47
+ browser,
48
+ state: execState,
49
+ console: buffered.console,
50
+ networkLog,
51
+ actionLog
52
+ };
53
+ const helperNames = Object.keys(helpers);
54
+ const fn = compileExecFunction(code, helperNames);
55
+ try {
56
+ const result = await fn(...Object.values(helpers));
57
+ return { result, output: buffered.output };
58
+ } catch (error) {
59
+ throw new DaemonExecError(
60
+ error instanceof Error ? error.message : String(error),
61
+ buffered.output
62
+ );
63
+ }
64
+ }
65
+ async function handleReadonlyExec(targetPage, code) {
66
+ const buffered = createBufferedConsole();
67
+ const helpers = createReadonlyExecHelpers(targetPage, {
68
+ console: buffered.console
69
+ });
70
+ const helperNames = Object.keys(helpers);
71
+ const fn = compileExecFunction(code, helperNames);
72
+ try {
73
+ const result = await fn(...Object.values(helpers));
74
+ return { result, output: buffered.output };
75
+ } catch (error) {
76
+ throw new DaemonExecError(
77
+ error instanceof Error ? error.message : String(error),
78
+ buffered.output
79
+ );
80
+ }
81
+ }
82
+ export {
83
+ DaemonExecError,
84
+ handleExec,
85
+ handleReadonlyExec
86
+ };
@@ -0,0 +1,16 @@
1
+ import {
2
+ DaemonServer,
3
+ DaemonClient,
4
+ DaemonClientError,
5
+ getDaemonSocketPath
6
+ } from "./ipc.js";
7
+ import {
8
+ spawnSessionDaemon
9
+ } from "./spawn.js";
10
+ export {
11
+ DaemonClient,
12
+ DaemonClientError,
13
+ DaemonServer,
14
+ getDaemonSocketPath,
15
+ spawnSessionDaemon
16
+ };
@@ -0,0 +1,171 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createServer, connect as netConnect } from "node:net";
3
+ import { unlink } from "node:fs/promises";
4
+ import { REPO_ROOT } from "../context.js";
5
+ class DaemonClientError extends Error {
6
+ constructor(message, output) {
7
+ super(message);
8
+ this.output = output;
9
+ this.name = "DaemonClientError";
10
+ }
11
+ }
12
+ function getDaemonSocketPath(session) {
13
+ const hash = createHash("sha256").update(`${REPO_ROOT}:${session}`).digest("hex").slice(0, 12);
14
+ return `/tmp/libretto-${process.getuid()}-${hash}.sock`;
15
+ }
16
+ class DaemonServer {
17
+ constructor(socketPath, handler) {
18
+ this.socketPath = socketPath;
19
+ this.handler = handler;
20
+ }
21
+ server = null;
22
+ async listen() {
23
+ try {
24
+ await unlink(this.socketPath);
25
+ } catch (err) {
26
+ if (err.code !== "ENOENT") throw err;
27
+ }
28
+ const server = createServer((socket) => {
29
+ let buffer = "";
30
+ socket.on("data", (chunk) => {
31
+ buffer += chunk.toString();
32
+ const newlineIndex = buffer.indexOf("\n");
33
+ if (newlineIndex === -1) return;
34
+ const line = buffer.slice(0, newlineIndex);
35
+ buffer = buffer.slice(newlineIndex + 1);
36
+ void (async () => {
37
+ let response;
38
+ try {
39
+ const request = JSON.parse(line);
40
+ const data = await this.handler(request);
41
+ response = { id: request.id, type: "result", data };
42
+ } catch (err) {
43
+ const id = (() => {
44
+ try {
45
+ return JSON.parse(line).id ?? "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ })();
50
+ response = {
51
+ id,
52
+ type: "error",
53
+ message: err instanceof Error ? err.message : String(err),
54
+ output: err instanceof Error ? err.output : void 0
55
+ };
56
+ }
57
+ socket.end(JSON.stringify(response) + "\n");
58
+ })();
59
+ });
60
+ });
61
+ this.server = server;
62
+ await new Promise((resolve, reject) => {
63
+ server.on("error", reject);
64
+ server.listen(this.socketPath, () => resolve());
65
+ });
66
+ }
67
+ async close() {
68
+ const server = this.server;
69
+ if (!server) return;
70
+ this.server = null;
71
+ await new Promise((resolve, reject) => {
72
+ server.close((err) => err ? reject(err) : resolve());
73
+ });
74
+ try {
75
+ await unlink(this.socketPath);
76
+ } catch (err) {
77
+ if (err.code !== "ENOENT") throw err;
78
+ }
79
+ }
80
+ }
81
+ class DaemonClient {
82
+ constructor(socketPath) {
83
+ this.socketPath = socketPath;
84
+ }
85
+ async send(request) {
86
+ return new Promise((resolve, reject) => {
87
+ const socket = netConnect(this.socketPath);
88
+ let buffer = "";
89
+ socket.on("connect", () => {
90
+ socket.write(JSON.stringify(request) + "\n");
91
+ });
92
+ socket.on("data", (chunk) => {
93
+ buffer += chunk.toString();
94
+ });
95
+ socket.on("end", () => {
96
+ try {
97
+ const response = JSON.parse(buffer.trim());
98
+ resolve(response);
99
+ } catch (err) {
100
+ reject(
101
+ new Error(
102
+ `Failed to parse daemon response: ${err instanceof Error ? err.message : String(err)}`
103
+ )
104
+ );
105
+ }
106
+ });
107
+ socket.on("error", (err) => {
108
+ reject(err);
109
+ });
110
+ });
111
+ }
112
+ generateId() {
113
+ return Math.random().toString(36).slice(2, 10);
114
+ }
115
+ async sendOrThrow(request) {
116
+ const response = await this.send(request);
117
+ if (response.type === "error") {
118
+ throw new DaemonClientError(response.message, response.output);
119
+ }
120
+ return response.data;
121
+ }
122
+ async sendResult(request) {
123
+ const response = await this.send(request);
124
+ if (response.type === "error") {
125
+ return {
126
+ ok: false,
127
+ message: response.message,
128
+ output: response.output
129
+ };
130
+ }
131
+ return { ok: true, data: response.data };
132
+ }
133
+ async ping() {
134
+ try {
135
+ await this.sendOrThrow({ id: this.generateId(), command: "ping" });
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ async pages() {
142
+ return this.sendOrThrow({ id: this.generateId(), command: "pages" });
143
+ }
144
+ async exec(args) {
145
+ return this.sendResult({
146
+ id: this.generateId(),
147
+ command: "exec",
148
+ ...args
149
+ });
150
+ }
151
+ async readonlyExec(args) {
152
+ return this.sendResult({
153
+ id: this.generateId(),
154
+ command: "readonly-exec",
155
+ ...args
156
+ });
157
+ }
158
+ async snapshot(args = {}) {
159
+ return this.sendOrThrow({
160
+ id: this.generateId(),
161
+ command: "snapshot",
162
+ ...args
163
+ });
164
+ }
165
+ }
166
+ export {
167
+ DaemonClient,
168
+ DaemonClientError,
169
+ DaemonServer,
170
+ getDaemonSocketPath
171
+ };
@@ -0,0 +1,15 @@
1
+ function handlePages(pageById, activePage) {
2
+ const results = [];
3
+ const isActiveTracked = [...pageById.values()].includes(activePage);
4
+ const effectiveActive = isActiveTracked ? activePage : [...pageById.values()].at(-1);
5
+ for (const [id, page] of pageById) {
6
+ const url = page.url();
7
+ if (url.startsWith("devtools://") || url.startsWith("chrome-error://"))
8
+ continue;
9
+ results.push({ id, url, active: page === effectiveActive });
10
+ }
11
+ return results;
12
+ }
13
+ export {
14
+ handlePages
15
+ };
@@ -0,0 +1,86 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { getSessionSnapshotRunDir } from "../context.js";
3
+ import {
4
+ resolveSnapshotViewport,
5
+ readSnapshotViewportMetrics,
6
+ shouldForceSnapshotViewport,
7
+ isZeroWidthScreenshotError,
8
+ forceSnapshotViewport
9
+ } from "../../commands/snapshot.js";
10
+ const RENDER_SETTLE_TIMEOUT_MS = 1e4;
11
+ async function handleSnapshot(targetPage, session, logger, pageId) {
12
+ const snapshotRunId = `snapshot-${Date.now()}`;
13
+ const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
14
+ mkdirSync(snapshotRunDir, { recursive: true });
15
+ let title = null;
16
+ try {
17
+ title = await targetPage.title();
18
+ } catch (error) {
19
+ logger.warn("screenshot-title-read-failed", { session, pageId, error });
20
+ }
21
+ let pageUrl = null;
22
+ try {
23
+ pageUrl = targetPage.url();
24
+ } catch (error) {
25
+ logger.warn("screenshot-url-read-failed", { session, pageId, error });
26
+ }
27
+ const pngPath = `${snapshotRunDir}/page.png`;
28
+ const htmlPath = `${snapshotRunDir}/page.html`;
29
+ await Promise.race([
30
+ targetPage.waitForLoadState("networkidle").catch(() => {
31
+ }),
32
+ new Promise((resolve) => setTimeout(resolve, RENDER_SETTLE_TIMEOUT_MS))
33
+ ]);
34
+ const restoreViewport = resolveSnapshotViewport(session, logger);
35
+ const viewportMetrics = await readSnapshotViewportMetrics(targetPage);
36
+ logger.info("screenshot-viewport-metrics", {
37
+ session,
38
+ pageId,
39
+ restoreViewport,
40
+ ...viewportMetrics
41
+ });
42
+ await forceSnapshotViewport(
43
+ targetPage,
44
+ restoreViewport,
45
+ logger,
46
+ session,
47
+ pageId,
48
+ shouldForceSnapshotViewport(viewportMetrics) ? "preflight-invalid-viewport" : "preflight-normalize-viewport"
49
+ );
50
+ try {
51
+ await targetPage.screenshot({ path: pngPath });
52
+ } catch (error) {
53
+ if (!isZeroWidthScreenshotError(error)) {
54
+ throw error;
55
+ }
56
+ await forceSnapshotViewport(
57
+ targetPage,
58
+ restoreViewport,
59
+ logger,
60
+ session,
61
+ pageId,
62
+ "retry-after-zero-width-screenshot-error"
63
+ );
64
+ await targetPage.screenshot({ path: pngPath });
65
+ }
66
+ const htmlContent = await targetPage.content();
67
+ writeFileSync(htmlPath, htmlContent);
68
+ logger.info("screenshot-success", {
69
+ session,
70
+ pageUrl,
71
+ title,
72
+ pngPath,
73
+ htmlPath,
74
+ snapshotRunId
75
+ });
76
+ return {
77
+ pngPath,
78
+ htmlPath,
79
+ snapshotRunId,
80
+ pageUrl: pageUrl ?? "",
81
+ title: title ?? ""
82
+ };
83
+ }
84
+ export {
85
+ handleSnapshot
86
+ };