patchrome 0.1.0

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/bin/patchrome.js +10 -0
  4. package/dist/build-id.d.ts +2 -0
  5. package/dist/build-id.js +21 -0
  6. package/dist/challenges.d.ts +22 -0
  7. package/dist/challenges.js +97 -0
  8. package/dist/chrome-profiles.d.ts +17 -0
  9. package/dist/chrome-profiles.js +141 -0
  10. package/dist/cli-options.d.ts +131 -0
  11. package/dist/cli-options.js +43 -0
  12. package/dist/cli.d.ts +48 -0
  13. package/dist/cli.js +572 -0
  14. package/dist/client.d.ts +16 -0
  15. package/dist/client.js +210 -0
  16. package/dist/commands.d.ts +58 -0
  17. package/dist/commands.js +1076 -0
  18. package/dist/completions.d.ts +1 -0
  19. package/dist/completions.js +114 -0
  20. package/dist/copy-guard.d.ts +75 -0
  21. package/dist/copy-guard.js +167 -0
  22. package/dist/daemon.d.ts +7 -0
  23. package/dist/daemon.js +313 -0
  24. package/dist/diagnostics.d.ts +44 -0
  25. package/dist/diagnostics.js +117 -0
  26. package/dist/engine.d.ts +51 -0
  27. package/dist/engine.js +257 -0
  28. package/dist/events.d.ts +41 -0
  29. package/dist/events.js +106 -0
  30. package/dist/extract.d.ts +27 -0
  31. package/dist/extract.js +62 -0
  32. package/dist/focus.d.ts +1 -0
  33. package/dist/focus.js +44 -0
  34. package/dist/glob.d.ts +4 -0
  35. package/dist/glob.js +63 -0
  36. package/dist/har.d.ts +105 -0
  37. package/dist/har.js +88 -0
  38. package/dist/history.d.ts +35 -0
  39. package/dist/history.js +277 -0
  40. package/dist/host-platform.d.ts +5 -0
  41. package/dist/host-platform.js +19 -0
  42. package/dist/host-prompts-macos.d.ts +2 -0
  43. package/dist/host-prompts-macos.js +102 -0
  44. package/dist/host-prompts-wsl.d.ts +6 -0
  45. package/dist/host-prompts-wsl.js +64 -0
  46. package/dist/host-prompts.d.ts +3 -0
  47. package/dist/host-prompts.js +25 -0
  48. package/dist/index.d.ts +17 -0
  49. package/dist/index.js +47 -0
  50. package/dist/network.d.ts +54 -0
  51. package/dist/network.js +204 -0
  52. package/dist/origin-storage.d.ts +31 -0
  53. package/dist/origin-storage.js +82 -0
  54. package/dist/paths.d.ts +17 -0
  55. package/dist/paths.js +52 -0
  56. package/dist/pipe.d.ts +9 -0
  57. package/dist/pipe.js +73 -0
  58. package/dist/profile-mode.d.ts +10 -0
  59. package/dist/profile-mode.js +42 -0
  60. package/dist/protocol-help.d.ts +34 -0
  61. package/dist/protocol-help.js +66 -0
  62. package/dist/protocol.d.ts +49 -0
  63. package/dist/protocol.js +89 -0
  64. package/dist/refs.d.ts +9 -0
  65. package/dist/refs.js +46 -0
  66. package/dist/routes.d.ts +20 -0
  67. package/dist/routes.js +106 -0
  68. package/dist/runner.d.ts +20 -0
  69. package/dist/runner.js +81 -0
  70. package/dist/session-name.d.ts +9 -0
  71. package/dist/session-name.js +50 -0
  72. package/dist/session-store.d.ts +5 -0
  73. package/dist/session-store.js +58 -0
  74. package/dist/sessions.d.ts +47 -0
  75. package/dist/sessions.js +171 -0
  76. package/dist/tab-groups.d.ts +9 -0
  77. package/dist/tab-groups.js +13 -0
  78. package/dist/targets.d.ts +43 -0
  79. package/dist/targets.js +229 -0
  80. package/dist/validate.d.ts +3 -0
  81. package/dist/validate.js +31 -0
  82. package/dist/wait.d.ts +24 -0
  83. package/dist/wait.js +88 -0
  84. package/examples/go/go.mod +3 -0
  85. package/examples/go/main.go +104 -0
  86. package/examples/hn-front-page.sh +18 -0
  87. package/examples/hn-front-page.ts +24 -0
  88. package/examples/hn_front_page.py +56 -0
  89. package/extension/tab-groups/manifest.json +8 -0
  90. package/extension/tab-groups/service-worker.js +41 -0
  91. package/package.json +60 -0
  92. package/skills/patchrome/SKILL.md +74 -0
  93. package/skills/patchrome/references/commands.md +130 -0
  94. package/skills/patchrome/references/debugging.md +20 -0
  95. package/skills/patchrome/references/hard-pages.md +49 -0
  96. package/skills/patchrome/references/logins.md +46 -0
  97. package/skills/patchrome/references/scraping.md +51 -0
  98. package/skills/patchrome/references/scripting.md +79 -0
package/dist/daemon.js ADDED
@@ -0,0 +1,313 @@
1
+ import { chmod, mkdir, rm } from "node:fs/promises";
2
+ import { createServer } from "node:net";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { z } from "zod";
6
+ import { currentBuildId } from "./build-id.js";
7
+ import { CopyGuard } from "./copy-guard.js";
8
+ import { detectHostPlatform } from "./host-platform.js";
9
+ import { hostPromptsFor } from "./host-prompts.js";
10
+ import { restoreSession, runCommand } from "./commands.js";
11
+ import { PatchrightEngine } from "./engine.js";
12
+ import { PageDiagnostics } from "./diagnostics.js";
13
+ import { SessionEvents } from "./events.js";
14
+ import { NetworkLog } from "./network.js";
15
+ import { appendHistoryStep, historyFileName, isReplayable, replayStep } from "./history.js";
16
+ import { approvalBundlesDirFrom, auditLogPathFrom, idleMsFrom, profilePaths, sessionFolderName } from "./paths.js";
17
+ import { fixProfileMode } from "./profile-mode.js";
18
+ import { CommandError, isCommandName, } from "./protocol.js";
19
+ import { RouteTable } from "./routes.js";
20
+ import { loadSavedSessions, pruneSessionFolders, saveSessions } from "./session-store.js";
21
+ import { SessionRegistry } from "./sessions.js";
22
+ import { tabGroupColorFor, tabGroupTitleFor } from "./tab-groups.js";
23
+ import { parseJsonInput } from "./validate.js";
24
+ export async function runDaemon(profile, env = process.env, options = {}) {
25
+ const paths = profilePaths(profile, env);
26
+ const idleMs = idleMsFrom(env);
27
+ const log = (message) => process.stdout.write(`${new Date().toISOString()} ${message}\n`);
28
+ await mkdir(paths.chromeProfileDir, { recursive: true });
29
+ await rm(paths.socketPath, { force: true });
30
+ const { mode } = await fixProfileMode(paths, profile, undefined);
31
+ const pruned = await pruneSessionFolders(paths.sessionsDir, Date.now());
32
+ if (pruned.length > 0)
33
+ log(`pruned ${pruned.length} session folders older than 7 days`);
34
+ // Sessions wait here until their first command, so a restart does not reload every site at once.
35
+ const awaitingRestore = new Map((await loadSavedSessions(paths.savedSessionsPath, Date.now())).map((saved) => [saved.name, saved]));
36
+ let isSavingSessions = true;
37
+ let saveTimer;
38
+ const saveSessionsNow = async () => {
39
+ clearTimeout(saveTimer);
40
+ saveTimer = undefined;
41
+ // A session mid-restore stays saved as it was, so a crash during the restore cannot save its first few tabs over it.
42
+ const live = registry.savedSessions().filter((saved) => !awaitingRestore.has(saved.name));
43
+ await saveSessions(paths.savedSessionsPath, [...live, ...awaitingRestore.values()], Date.now()).catch((err) => log(`saving sessions.json failed: ${String(err)}`));
44
+ };
45
+ const scheduleSessionsSave = () => {
46
+ if (!isSavingSessions || saveTimer !== undefined)
47
+ return;
48
+ saveTimer = setTimeout(() => void saveSessionsNow(), 100);
49
+ };
50
+ const engine = new PatchrightEngine();
51
+ const events = new SessionEvents();
52
+ const network = new NetworkLog((entry) => events.publish(entry.session, { kind: "response", tabId: entry.tabId, entry, atMs: Date.now() }));
53
+ const routes = new RouteTable();
54
+ const diagnostics = new PageDiagnostics((tab, pageError) => events.publish(tab.session, { kind: "error", tabId: tab.id, pageError, atMs: Date.now() }));
55
+ const consoleCaptureByTab = new WeakMap();
56
+ // One regroup at a time per session, so two tabs opened together join one group instead of starting two.
57
+ // A tab group is a label for people watching the browser, so a failure is logged and never fails a command.
58
+ // Isolated sessions stay ungrouped: Chrome keeps each CDP browser context out of reach of extensions, and
59
+ // chrome.tabs.get answers "No tab with id" for their tabs.
60
+ const regroupQueues = new Map();
61
+ const regroupTabs = (session) => {
62
+ const regrouped = (regroupQueues.get(session) ?? Promise.resolve())
63
+ .then(async () => {
64
+ await launched;
65
+ const pages = registry.openTabsOf(session).map((tab) => tab.page);
66
+ if (pages.length === 0 || registry.browserContextOf(session) !== undefined)
67
+ return;
68
+ await engine.groupTabs(pages, tabGroupTitleFor(session, registry.labelOf(session)), tabGroupColorFor(session));
69
+ })
70
+ .catch((err) => {
71
+ log(`${session} tab grouping failed: ${String(err).split("\n")[0]}`);
72
+ });
73
+ regroupQueues.set(session, regrouped);
74
+ void regrouped.then(() => {
75
+ if (regroupQueues.get(session) === regrouped)
76
+ regroupQueues.delete(session);
77
+ });
78
+ return regrouped;
79
+ };
80
+ const registry = new SessionRegistry((tab) => {
81
+ network.record(tab);
82
+ routes.track(tab);
83
+ tab.page.on("framenavigated", (frame) => {
84
+ if (frame === tab.page.mainFrame())
85
+ events.publish(tab.session, { kind: "navigation", tabId: tab.id, url: frame.url(), atMs: Date.now() });
86
+ });
87
+ tab.page.on("load", () => events.publish(tab.session, { kind: "load", tabId: tab.id, url: tab.page.url(), atMs: Date.now() }));
88
+ void regroupTabs(tab.session);
89
+ switch (mode) {
90
+ case "stealth":
91
+ break;
92
+ case "debug":
93
+ consoleCaptureByTab.set(tab, engine
94
+ .openCdpSession(tab.page)
95
+ .then((cdp) => diagnostics.record(tab, cdp))
96
+ .catch((err) => {
97
+ log(`console capture failed for ${tab.id}: ${String(err)}`);
98
+ }));
99
+ break;
100
+ }
101
+ }, scheduleSessionsSave);
102
+ registry.reserveTabIds([...awaitingRestore.values()].flatMap((saved) => saved.tabs.map((tab) => tab.id)));
103
+ const buildId = currentBuildId();
104
+ const version = buildId.split("+")[0] ?? buildId;
105
+ const sessionQueues = new Map();
106
+ let idleTimer;
107
+ // The idle clock runs only while no request is in flight, so a slow Chrome launch or a long wait is never cut off.
108
+ let inFlightRequests = 0;
109
+ let isShuttingDown = false;
110
+ const server = createServer((socket) => handleConnection(socket));
111
+ const shutdown = async (reason) => {
112
+ if (isShuttingDown)
113
+ return;
114
+ isShuttingDown = true;
115
+ log(`shutting down: ${reason}`);
116
+ clearTimeout(idleTimer);
117
+ server.close();
118
+ await rm(paths.socketPath, { force: true });
119
+ // Closing Chrome closes every page, which would save empty sessions over the ones to restore.
120
+ await saveSessionsNow();
121
+ isSavingSessions = false;
122
+ await engine.close().catch((err) => log(`engine close failed: ${String(err)}`));
123
+ if (options.exitProcess)
124
+ options.exitProcess(0);
125
+ else
126
+ process.exit(0);
127
+ };
128
+ const resetIdleTimer = () => {
129
+ clearTimeout(idleTimer);
130
+ // A request can arrive while startup is still awaiting after listen, before startup arms the timer.
131
+ if (inFlightRequests > 0)
132
+ return;
133
+ idleTimer = setTimeout(() => void shutdown(`idle for ${idleMs} ms`), idleMs);
134
+ };
135
+ const ctx = {
136
+ engine,
137
+ registry,
138
+ network,
139
+ routes,
140
+ diagnostics,
141
+ events,
142
+ mode,
143
+ trace: { owner: undefined },
144
+ consoleCaptureReady: (tab) => consoleCaptureByTab.get(tab) ?? Promise.resolve(),
145
+ version,
146
+ buildId,
147
+ sessionsDir: paths.sessionsDir,
148
+ profile,
149
+ startedAtMs: Date.now(),
150
+ requestShutdown: () => setImmediate(() => void shutdown("daemon stop")),
151
+ regroupTabs,
152
+ savedSessionNames: () => [...awaitingRestore.keys()].filter((name) => !registry.hasSession(name)),
153
+ forgetSavedSession: (name) => {
154
+ awaitingRestore.delete(name);
155
+ scheduleSessionsSave();
156
+ },
157
+ copyGuard: new CopyGuard({
158
+ prompts: options.prompts ?? hostPromptsFor(detectHostPlatform(), log, approvalBundlesDirFrom(env)),
159
+ auditLogPath: auditLogPathFrom(env),
160
+ profile,
161
+ log,
162
+ }),
163
+ };
164
+ // Listening before Chrome is up lets concurrent starters connect at once; requests wait on launch.
165
+ const launched = engine.launch(paths.chromeProfileDir, mode);
166
+ engine.onClosed(() => void shutdown("browser closed"));
167
+ launched.catch((err) => {
168
+ log(`browser launch failed: ${String(err)}`);
169
+ void shutdown("launch failure");
170
+ });
171
+ await new Promise((resolve, reject) => {
172
+ server.once("error", reject);
173
+ server.listen(paths.socketPath, () => resolve());
174
+ });
175
+ await chmod(paths.socketPath, 0o600);
176
+ // The starter's lock is released once the socket answers, so later starters connect instead of spawning.
177
+ await rm(paths.lockDir, { recursive: true, force: true });
178
+ log(`listening on ${paths.socketPath}, pid ${process.pid}, ${mode} profile`);
179
+ resetIdleTimer();
180
+ for (const signal of ["SIGTERM", "SIGINT"])
181
+ process.on(signal, () => void shutdown(signal));
182
+ // A connection carries one request from the CLI, or many from `pipe` and the library.
183
+ function handleConnection(socket) {
184
+ const disconnected = new AbortController();
185
+ socket.once("close", () => disconnected.abort());
186
+ const lines = createInterface({ input: socket });
187
+ lines.on("line", (line) => {
188
+ inFlightRequests += 1;
189
+ clearTimeout(idleTimer);
190
+ void respond(socket, line, disconnected.signal).finally(() => {
191
+ inFlightRequests -= 1;
192
+ resetIdleTimer();
193
+ });
194
+ });
195
+ socket.on("error", () => { });
196
+ }
197
+ async function recordHistory(request, data) {
198
+ if (request.argv === undefined || !isReplayable(request.command, request.args))
199
+ return;
200
+ const path = join(paths.sessionsDir, sessionFolderName(request.session), historyFileName);
201
+ await appendHistoryStep(path, replayStep(request.command, request.argv, data.replay, Date.now())).catch((err) => log(`${request.session} history write failed: ${String(err)}`));
202
+ }
203
+ // A follow runs until its timeout or until the client disconnects, and outside the session queue, so
204
+ // the same session can keep browsing while it streams.
205
+ function isStreaming(request) {
206
+ return (request.command === "console" && request.args.follow === true) || request.command === "watch";
207
+ }
208
+ async function restoreIfSaved(request) {
209
+ const saved = awaitingRestore.get(request.session);
210
+ if (saved === undefined)
211
+ return;
212
+ if (request.command === "daemon-status" || request.command === "daemon-stop")
213
+ return;
214
+ if (request.command === "session-close" && request.args.pattern === undefined) {
215
+ awaitingRestore.delete(request.session);
216
+ scheduleSessionsSave();
217
+ return;
218
+ }
219
+ const { restored, dropped } = await restoreSession(ctx, saved, request.timeoutMs);
220
+ awaitingRestore.delete(request.session);
221
+ log(`${request.session} restored tabs ${restored.join(" ") || "none"}${dropped.length > 0 ? `, dropped ${dropped.join(" ")}` : ""}`);
222
+ }
223
+ async function respond(socket, line, disconnected) {
224
+ let request;
225
+ try {
226
+ request = parseRequest(line);
227
+ }
228
+ catch (err) {
229
+ return send(socket, { id: -1, ok: false, error: toCommandError(err).toBody() });
230
+ }
231
+ // status and stop still work, so an outdated daemon can be inspected and replaced.
232
+ if (request.buildId !== buildId && request.command !== "daemon-status" && request.command !== "daemon-stop") {
233
+ return send(socket, {
234
+ id: request.id,
235
+ ok: false,
236
+ error: new CommandError("daemon_outdated", `the running daemon is patchrome build ${buildId}, this CLI is ${request.buildId}`, "run `patchrome daemon stop` once no other session is browsing; the next command starts a current daemon").toBody(),
237
+ });
238
+ }
239
+ const call = {
240
+ ...request,
241
+ emit: (stream) => send(socket, { id: request.id, stream }),
242
+ disconnected,
243
+ };
244
+ if (isStreaming(request)) {
245
+ try {
246
+ await launched;
247
+ const { lines, fields } = await runCommand(ctx, call);
248
+ return send(socket, { id: request.id, ok: true, data: { lines, fields } });
249
+ }
250
+ catch (err) {
251
+ return send(socket, { id: request.id, ok: false, error: toCommandError(err).toBody() });
252
+ }
253
+ }
254
+ // Commands from one session run in order; different sessions run concurrently.
255
+ const previous = sessionQueues.get(request.session) ?? Promise.resolve();
256
+ const current = previous.then(async () => {
257
+ try {
258
+ await launched;
259
+ await restoreIfSaved(request);
260
+ const { lines, fields, replay } = await runCommand(ctx, call);
261
+ await recordHistory(request, { lines, fields, replay });
262
+ return { id: request.id, ok: true, data: { lines, fields } };
263
+ }
264
+ catch (err) {
265
+ const error = toCommandError(err);
266
+ log(`${request.session} ${request.command} failed: ${error.code} ${error.message}`);
267
+ return { id: request.id, ok: false, error: error.toBody() };
268
+ }
269
+ });
270
+ const settled = current.catch(() => { });
271
+ sessionQueues.set(request.session, settled);
272
+ void settled.then(() => {
273
+ if (sessionQueues.get(request.session) === settled)
274
+ sessionQueues.delete(request.session);
275
+ });
276
+ send(socket, await current);
277
+ }
278
+ }
279
+ // Patchright 1.63 starts request interception from the CRPage constructor without awaiting or catching it.
280
+ // A page that closes before that CDP call answers rejects with a closed-session ProtocolError nobody holds,
281
+ // which would otherwise crash the daemon and every session in it.
282
+ export function isClosedTargetRejection(reason) {
283
+ // The class leaves `name` as "Error"; only its constructor carries the ProtocolError name.
284
+ return (reason instanceof Error &&
285
+ reason.constructor.name === "ProtocolError" &&
286
+ reason.type === "closed");
287
+ }
288
+ function send(socket, response) {
289
+ if (!socket.destroyed)
290
+ socket.write(`${JSON.stringify(response)}\n`);
291
+ }
292
+ const requestSchema = z.object({
293
+ id: z.number(),
294
+ session: z.string().min(1),
295
+ command: z.string().refine(isCommandName, { error: (issue) => `unknown command ${String(issue.input)}` }),
296
+ args: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.undefined()])),
297
+ timeoutMs: z.number().positive(),
298
+ argv: z.array(z.string()).optional(),
299
+ buildId: z.string(),
300
+ });
301
+ function parseRequest(line) {
302
+ return parseJsonInput(requestSchema, line, "request");
303
+ }
304
+ function toCommandError(err) {
305
+ if (err instanceof CommandError)
306
+ return err;
307
+ if (err instanceof SyntaxError)
308
+ return new CommandError("bad_args", `malformed request: ${err.message}`);
309
+ const message = err instanceof Error ? (err.message.split("\n")[0] ?? err.message) : String(err);
310
+ if (err instanceof Error && err.name === "TimeoutError")
311
+ return new CommandError("timeout", message);
312
+ return new CommandError("bad_args", message);
313
+ }
@@ -0,0 +1,44 @@
1
+ import type { CDPSession } from "patchright";
2
+ import type { Tab } from "./sessions.ts";
3
+ export declare const consoleLevels: readonly ["debug", "info", "warning", "error"];
4
+ export type ConsoleLevel = (typeof consoleLevels)[number];
5
+ export interface ConsoleMessage {
6
+ id: string;
7
+ tabId: string;
8
+ level: ConsoleLevel;
9
+ text: string;
10
+ url: string | undefined;
11
+ line: number | undefined;
12
+ atMs: number;
13
+ }
14
+ export interface PageError {
15
+ id: string;
16
+ tabId: string;
17
+ message: string;
18
+ stack: string | undefined;
19
+ url: string | undefined;
20
+ line: number | undefined;
21
+ atMs: number;
22
+ }
23
+ interface RemoteObject {
24
+ type: string;
25
+ subtype?: string;
26
+ value?: unknown;
27
+ unserializableValue?: string;
28
+ description?: string;
29
+ }
30
+ export declare class PageDiagnostics {
31
+ #private;
32
+ constructor(onPageError?: (tab: Tab, pageError: PageError) => void);
33
+ record(tab: Tab, cdp: CDPSession): Promise<void>;
34
+ messages(session: string, minimumLevel: ConsoleLevel): ConsoleMessage[];
35
+ errors(session: string): PageError[];
36
+ follow(session: string, listener: (message: ConsoleMessage) => void): () => void;
37
+ forget(session: string): void;
38
+ }
39
+ export declare function parseConsoleLevel(raw: string): ConsoleLevel;
40
+ export declare function consoleLevelOf(cdpType: string): ConsoleLevel;
41
+ export declare function isAtLeast(level: ConsoleLevel, minimum: ConsoleLevel): boolean;
42
+ export declare function renderRemoteObject(object: RemoteObject): string;
43
+ export declare function consoleLine(message: ConsoleMessage): string;
44
+ export {};
@@ -0,0 +1,117 @@
1
+ import { CommandError } from "./protocol.js";
2
+ export const consoleLevels = ["debug", "info", "warning", "error"];
3
+ const consoleLimitPerSession = 1000;
4
+ const errorLimitPerSession = 200;
5
+ // Debug profile only. Patchright disables Playwright's console and pageerror events, so capture goes
6
+ // through a CDP session with Runtime enabled, which is exactly the signal a stealth profile must not send.
7
+ export class PageDiagnostics {
8
+ #sessions = new Map();
9
+ #nextMessageId = 1;
10
+ #nextErrorId = 1;
11
+ #onPageError;
12
+ constructor(onPageError = () => { }) {
13
+ this.#onPageError = onPageError;
14
+ }
15
+ async record(tab, cdp) {
16
+ cdp.on("Runtime.consoleAPICalled", (event) => {
17
+ const frame = event.stackTrace?.callFrames[0];
18
+ const message = {
19
+ id: `c${this.#nextMessageId++}`,
20
+ tabId: tab.id,
21
+ level: consoleLevelOf(event.type),
22
+ text: event.args.map(renderRemoteObject).join(" "),
23
+ url: frame?.url || undefined,
24
+ line: frame === undefined ? undefined : frame.lineNumber + 1,
25
+ atMs: Date.now(),
26
+ };
27
+ const diagnostics = this.#sessionFor(tab.session);
28
+ pushCapped(diagnostics.messages, message, consoleLimitPerSession);
29
+ for (const follower of diagnostics.followers)
30
+ follower(message);
31
+ });
32
+ cdp.on("Runtime.exceptionThrown", (event) => {
33
+ const details = event.exceptionDetails;
34
+ const description = details.exception?.description;
35
+ const pageError = {
36
+ id: `x${this.#nextErrorId++}`,
37
+ tabId: tab.id,
38
+ message: (description ?? details.text).split("\n")[0] ?? details.text,
39
+ stack: description ?? formatStack(details.stackTrace),
40
+ url: details.url,
41
+ line: details.lineNumber + 1,
42
+ atMs: Date.now(),
43
+ };
44
+ pushCapped(this.#sessionFor(tab.session).errors, pageError, errorLimitPerSession);
45
+ this.#onPageError(tab, pageError);
46
+ });
47
+ await cdp.send("Runtime.enable");
48
+ }
49
+ messages(session, minimumLevel) {
50
+ return (this.#sessions.get(session)?.messages ?? []).filter((message) => isAtLeast(message.level, minimumLevel));
51
+ }
52
+ errors(session) {
53
+ return this.#sessions.get(session)?.errors ?? [];
54
+ }
55
+ follow(session, listener) {
56
+ const followers = this.#sessionFor(session).followers;
57
+ followers.add(listener);
58
+ return () => followers.delete(listener);
59
+ }
60
+ forget(session) {
61
+ this.#sessions.delete(session);
62
+ }
63
+ #sessionFor(session) {
64
+ let diagnostics = this.#sessions.get(session);
65
+ if (!diagnostics) {
66
+ diagnostics = { messages: [], errors: [], followers: new Set() };
67
+ this.#sessions.set(session, diagnostics);
68
+ }
69
+ return diagnostics;
70
+ }
71
+ }
72
+ export function parseConsoleLevel(raw) {
73
+ if (!consoleLevels.includes(raw)) {
74
+ throw new CommandError("bad_args", `--level ${raw} is not a console level`, `use one of ${consoleLevels.join(", ")}; each includes the levels above it`);
75
+ }
76
+ return raw;
77
+ }
78
+ // CDP reports the console method called; agents filter by severity.
79
+ export function consoleLevelOf(cdpType) {
80
+ if (cdpType === "error" || cdpType === "assert")
81
+ return "error";
82
+ if (cdpType === "warning")
83
+ return "warning";
84
+ if (cdpType === "debug")
85
+ return "debug";
86
+ return "info";
87
+ }
88
+ export function isAtLeast(level, minimum) {
89
+ return consoleLevels.indexOf(level) >= consoleLevels.indexOf(minimum);
90
+ }
91
+ export function renderRemoteObject(object) {
92
+ if (object.unserializableValue !== undefined)
93
+ return object.unserializableValue;
94
+ if (object.type === "string")
95
+ return String(object.value);
96
+ if (object.value !== undefined)
97
+ return JSON.stringify(object.value);
98
+ if (object.type === "undefined")
99
+ return "undefined";
100
+ return object.description ?? object.subtype ?? object.type;
101
+ }
102
+ function formatStack(stackTrace) {
103
+ if (!stackTrace)
104
+ return undefined;
105
+ return stackTrace.callFrames
106
+ .map((frame) => ` at ${frame.functionName || "<anonymous>"} (${frame.url}:${frame.lineNumber + 1}:${frame.columnNumber + 1})`)
107
+ .join("\n");
108
+ }
109
+ function pushCapped(items, item, limit) {
110
+ items.push(item);
111
+ if (items.length > limit)
112
+ items.shift();
113
+ }
114
+ export function consoleLine(message) {
115
+ const where = message.url === undefined ? "" : ` (${message.url}:${message.line})`;
116
+ return `${message.id} ${message.tabId} ${message.level} ${message.text}${where}`;
117
+ }
@@ -0,0 +1,51 @@
1
+ import { type CDPSession, type Cookie, type Page } from "patchright";
2
+ import { type OriginStorage } from "./origin-storage.ts";
3
+ import type { ProfileMode } from "./profile-mode.ts";
4
+ import type { TabGroupColor, TabGroupSummary } from "./tab-groups.ts";
5
+ export interface DebuggingEndpoint {
6
+ httpUrl: string;
7
+ browserWsUrl: string;
8
+ }
9
+ export interface BrowserEngine {
10
+ launch(chromeProfileDir: string, mode: ProfileMode): Promise<void>;
11
+ openBackgroundPage(browserContextId?: string): Promise<Page>;
12
+ openForegroundPage(browserContextId?: string): Promise<Page>;
13
+ cookies(urls: string[] | undefined, browserContextId?: string): Promise<Cookie[]>;
14
+ addCookies(cookies: Cookie[], browserContextId?: string): Promise<void>;
15
+ createIsolatedContext(): Promise<string>;
16
+ disposeIsolatedContext(browserContextId: string): Promise<void>;
17
+ groupTabs(pages: Page[], title: string, color: TabGroupColor): Promise<void>;
18
+ describeTabGroups(pages: Page[]): Promise<TabGroupSummary[]>;
19
+ readProfileCopy(copyUserDataDir: string, origins: string[]): Promise<{
20
+ cookies: Cookie[];
21
+ origins: OriginStorage[];
22
+ }>;
23
+ debuggingEndpoint(): DebuggingEndpoint | undefined;
24
+ openCdpSession(page: Page): Promise<CDPSession>;
25
+ startTrace(): Promise<void>;
26
+ stopTrace(path: string): Promise<void>;
27
+ onClosed(listener: () => void): void;
28
+ close(): Promise<void>;
29
+ }
30
+ export declare class PatchrightEngine implements BrowserEngine {
31
+ #private;
32
+ launch(chromeProfileDir: string, mode: ProfileMode): Promise<void>;
33
+ openBackgroundPage(browserContextId?: string): Promise<Page>;
34
+ openForegroundPage(browserContextId?: string): Promise<Page>;
35
+ cookies(urls: string[] | undefined, browserContextId?: string): Promise<Cookie[]>;
36
+ addCookies(cookies: Cookie[], browserContextId?: string): Promise<void>;
37
+ createIsolatedContext(): Promise<string>;
38
+ disposeIsolatedContext(browserContextId: string): Promise<void>;
39
+ groupTabs(pages: Page[], title: string, color: TabGroupColor): Promise<void>;
40
+ describeTabGroups(pages: Page[]): Promise<TabGroupSummary[]>;
41
+ readProfileCopy(copyUserDataDir: string, origins: string[]): Promise<{
42
+ cookies: Cookie[];
43
+ origins: OriginStorage[];
44
+ }>;
45
+ debuggingEndpoint(): DebuggingEndpoint | undefined;
46
+ openCdpSession(page: Page): Promise<CDPSession>;
47
+ startTrace(): Promise<void>;
48
+ stopTrace(path: string): Promise<void>;
49
+ onClosed(listener: () => void): void;
50
+ close(): Promise<void>;
51
+ }