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.
- package/dist/cli/cli.js +2 -0
- package/dist/cli/commands/auth.js +535 -0
- package/dist/cli/commands/billing.js +74 -0
- package/dist/cli/commands/browser.js +8 -3
- package/dist/cli/commands/deploy.js +2 -7
- package/dist/cli/commands/execution.js +99 -136
- package/dist/cli/commands/snapshot.js +38 -126
- package/dist/cli/core/ai-model.js +0 -3
- package/dist/cli/core/auth-fetch.js +195 -0
- package/dist/cli/core/auth-storage.js +52 -0
- package/dist/cli/core/browser.js +128 -202
- package/dist/cli/core/daemon/config.js +6 -0
- package/dist/cli/core/daemon/daemon.js +298 -0
- package/dist/cli/core/daemon/exec.js +86 -0
- package/dist/cli/core/daemon/index.js +16 -0
- package/dist/cli/core/daemon/ipc.js +171 -0
- package/dist/cli/core/daemon/pages.js +15 -0
- package/dist/cli/core/daemon/snapshot.js +86 -0
- package/dist/cli/core/daemon/spawn.js +90 -0
- package/dist/cli/core/exec-compiler.js +111 -0
- package/dist/cli/core/prompt.js +72 -0
- package/dist/cli/core/providers/libretto-cloud.js +2 -6
- package/dist/cli/core/readonly-exec.js +1 -1
- package/dist/cli/router.js +4 -0
- package/dist/cli/workers/run-integration-runtime.js +0 -5
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +2 -1
- package/docs/browser-automation-approaches.md +435 -0
- package/docs/releasing.md +117 -0
- package/package.json +4 -3
- package/skills/libretto/SKILL.md +14 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/cli.ts +2 -0
- package/src/cli/commands/auth.ts +787 -0
- package/src/cli/commands/billing.ts +133 -0
- package/src/cli/commands/browser.ts +8 -2
- package/src/cli/commands/deploy.ts +2 -7
- package/src/cli/commands/execution.ts +126 -186
- package/src/cli/commands/snapshot.ts +46 -143
- package/src/cli/core/ai-model.ts +4 -5
- package/src/cli/core/auth-fetch.ts +283 -0
- package/src/cli/core/auth-storage.ts +102 -0
- package/src/cli/core/browser.ts +159 -242
- package/src/cli/core/daemon/config.ts +46 -0
- package/src/cli/core/daemon/daemon.ts +429 -0
- package/src/cli/core/daemon/exec.ts +128 -0
- package/src/cli/core/daemon/index.ts +24 -0
- package/src/cli/core/daemon/ipc.ts +294 -0
- package/src/cli/core/daemon/pages.ts +21 -0
- package/src/cli/core/daemon/snapshot.ts +114 -0
- package/src/cli/core/daemon/spawn.ts +171 -0
- package/src/cli/core/exec-compiler.ts +169 -0
- package/src/cli/core/prompt.ts +94 -0
- package/src/cli/core/providers/libretto-cloud.ts +2 -6
- package/src/cli/core/readonly-exec.ts +2 -1
- package/src/cli/router.ts +4 -0
- package/src/cli/workers/run-integration-runtime.ts +0 -6
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/browser-daemon.js +0 -122
- 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
|
+
};
|