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
@@ -0,0 +1,1076 @@
1
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { inspectChallenges, waitForPersonToSolve } from "./challenges.js";
5
+ import { copySiteLoginStorage, describeChromeProfile, hostBelongsToSite, listChromeProfiles, resolveChromeProfile, siteFromInput, } from "./chrome-profiles.js";
6
+ import { parseWatchEventKinds, urlOfEvent, watchEventFields, watchEventLine, } from "./events.js";
7
+ import { consoleLine, isAtLeast, parseConsoleLevel } from "./diagnostics.js";
8
+ import { extractRowsInPage, parseExtractSchema } from "./extract.js";
9
+ import { isNamePattern, nameGlobMatches, urlGlobMatches } from "./glob.js";
10
+ import { buildHar } from "./har.js";
11
+ import { bodyExtension, isTextual } from "./network.js";
12
+ import { writeOriginStorageInPage } from "./origin-storage.js";
13
+ import { sessionFolderName } from "./paths.js";
14
+ import { CommandError, } from "./protocol.js";
15
+ import { parseProtocolSchema, protocolHelp } from "./protocol-help.js";
16
+ import { locatorForRef, requestUrlGlob } from "./history.js";
17
+ import { parseRef, refsIn } from "./refs.js";
18
+ import { z } from "zod";
19
+ import { parseJsonInput } from "./validate.js";
20
+ import { clickPoint, describeTarget, elementLocator, parseTarget, typeLikeAPerson } from "./targets.js";
21
+ import { describeWaitCondition, parseWaitCondition, waitForCondition } from "./wait.js";
22
+ // Output past this size goes to a file in the session folder, and stdout carries the path.
23
+ const inlineLimitBytes = 2048;
24
+ const waitStates = ["load", "domcontentloaded", "networkidle"];
25
+ export async function runCommand(ctx, call) {
26
+ const { session, args, timeoutMs } = call;
27
+ switch (call.command) {
28
+ case "open": {
29
+ const browserContextId = await browserContextForOpen(ctx, session, args.isolated === true);
30
+ const page = await ctx.engine.openBackgroundPage(browserContextId);
31
+ const tab = ctx.registry.adoptPage(session, page, true);
32
+ await ctx.consoleCaptureReady(tab);
33
+ const url = optionalString(args, "url");
34
+ if (url !== undefined) {
35
+ try {
36
+ await navigate(page, url, waitStateArg(args), timeoutMs);
37
+ }
38
+ catch (err) {
39
+ // A failed open leaves no blank tab behind, so retries do not pile up orphans.
40
+ await page.close().catch(() => { });
41
+ throw err;
42
+ }
43
+ }
44
+ return describeTab(tab, `opened ${tab.id}`);
45
+ }
46
+ case "tabs": {
47
+ const tabs = args.all === true ? ctx.registry.allTabs() : ctx.registry.tabsOf(session);
48
+ const lines = tabs.length === 0
49
+ ? ["no tabs"]
50
+ : tabs.map((tab) => `${tab.isCurrent ? "*" : " "} ${tab.id}${args.all === true ? ` [${tab.session}]` : ""} ${tab.url}`);
51
+ return { lines, fields: { tabs } };
52
+ }
53
+ case "switch": {
54
+ const tab = ctx.registry.switchTo(session, requiredString(args, "tab"));
55
+ return describeTab(tab, `switched to ${tab.id}`);
56
+ }
57
+ case "close": {
58
+ const tabId = optionalString(args, "tab");
59
+ const tab = tabId === undefined ? ctx.registry.currentTab(session) : ctx.registry.ownedTab(session, tabId);
60
+ await tab.page.close();
61
+ return { lines: [`closed ${tab.id}`], fields: { tab: tab.id } };
62
+ }
63
+ case "goto": {
64
+ const tab = ctx.registry.currentTab(session);
65
+ await navigate(tab.page, requiredString(args, "url"), waitStateArg(args), timeoutMs);
66
+ return describeTab(tab, `navigated ${tab.id}`);
67
+ }
68
+ case "snapshot": {
69
+ const tab = ctx.registry.currentTab(session);
70
+ // The page-level snapshot descends into iframes, cross-site ones included, and prefixes their refs.
71
+ const snapshot = await guardTab(tab, () => tab.page.ariaSnapshot({ mode: "ai", timeout: timeoutMs }));
72
+ tab.generations.recordSnapshot(snapshot);
73
+ const refCount = refsIn(snapshot).size;
74
+ const title = await tab.page.title();
75
+ const page = { url: tab.page.url(), title, refCount };
76
+ const pageLines = [`url: ${page.url}`, `title: ${title}`, `refs: ${refCount}`];
77
+ if (args.inline === true)
78
+ return { lines: [...pageLines, snapshot], fields: { ...page, snapshot } };
79
+ const path = await outputPath(ctx, session, args, `snapshot-${tab.id}`, "yml");
80
+ await writeFile(path, snapshot);
81
+ return { lines: [`snapshot: ${path}`, ...pageLines], fields: { path, ...page } };
82
+ }
83
+ case "click": {
84
+ const tab = ctx.registry.currentTab(session);
85
+ const target = parseTarget(args, { allowsPoint: true });
86
+ if (target.kind === "point") {
87
+ await guardTab(tab, () => clickPoint(tab.page, target.x, target.y));
88
+ }
89
+ else {
90
+ await guardTab(tab, () => targetLocator(tab, target).click({ timeout: timeoutMs }));
91
+ }
92
+ return { ...(await describeTab(tab, `clicked ${describeTarget(target)}`)), replay: refReplay(tab, target) };
93
+ }
94
+ case "fill": {
95
+ const tab = ctx.registry.currentTab(session);
96
+ const target = parseTarget(args, { allowsPoint: false });
97
+ if (target.kind === "point")
98
+ throw new CommandError("bad_args", "fill needs a ref or a locator");
99
+ const locator = targetLocator(tab, target);
100
+ await guardTab(tab, () => locator.fill(requiredString(args, "fillText"), { timeout: timeoutMs }));
101
+ const isSecretText = await locator
102
+ .evaluate((element) => element instanceof HTMLInputElement && element.type === "password", undefined, { timeout: timeoutMs }, true)
103
+ .catch(() => false);
104
+ return {
105
+ ...(await describeTab(tab, `filled ${describeTarget(target)}`)),
106
+ replay: { ...refReplay(tab, target), isSecretText },
107
+ };
108
+ }
109
+ case "type": {
110
+ const tab = ctx.registry.currentTab(session);
111
+ const text = requiredString(args, "text");
112
+ const isSecretText = await tab.page
113
+ .evaluate(() => document.activeElement instanceof HTMLInputElement && document.activeElement.type === "password", undefined, undefined, true)
114
+ .catch(() => false);
115
+ await guardTab(tab, () => typeLikeAPerson(tab.page, text));
116
+ return { ...(await describeTab(tab, `typed ${text.length} characters`)), replay: { isSecretText } };
117
+ }
118
+ case "challenge": {
119
+ const tab = ctx.registry.currentTab(session);
120
+ const report = await guardTab(tab, () => args.handoff === true ? waitForPersonToSolve(tab.page, timeoutMs) : inspectChallenges(tab.page));
121
+ const widgetLines = report.widgets.map((widget) => `${widget.vendor} ${widget.box === undefined ? "" : `at ${Math.round(widget.box.x)},${Math.round(widget.box.y)} ${Math.round(widget.box.width)}x${Math.round(widget.box.height)} `}${widget.url}`);
122
+ return {
123
+ lines: [`challenge: ${report.state}`, ...widgetLines],
124
+ fields: { tab: tab.id, state: report.state, widgets: report.widgets },
125
+ };
126
+ }
127
+ case "press": {
128
+ const tab = ctx.registry.currentTab(session);
129
+ await guardTab(tab, () => tab.page.keyboard.press(requiredString(args, "key")));
130
+ return describeTab(tab, `pressed ${args.key}`);
131
+ }
132
+ case "screenshot": {
133
+ const tab = ctx.registry.currentTab(session);
134
+ const element = optionalElement(tab, args);
135
+ const image = await guardTab(tab, () => element === undefined
136
+ ? tab.page.screenshot({ fullPage: args.full === true, timeout: timeoutMs })
137
+ : element.screenshot({ timeout: timeoutMs }));
138
+ const path = await outputPath(ctx, session, args, `screenshot-${tab.id}`, "png");
139
+ await writeFile(path, image);
140
+ return {
141
+ lines: [`screenshot: ${path}`, `url: ${tab.page.url()}`],
142
+ fields: { path, url: tab.page.url() },
143
+ replay: optionalRefReplay(tab, args),
144
+ };
145
+ }
146
+ case "text": {
147
+ const tab = ctx.registry.currentTab(session);
148
+ const locator = optionalElement(tab, args) ?? tab.page.locator("body");
149
+ const text = await guardTab(tab, () => locator.innerText({ timeout: timeoutMs }));
150
+ return {
151
+ ...(await deliver(ctx, session, args, {
152
+ field: "text",
153
+ value: text,
154
+ content: text,
155
+ prefix: `text-${tab.id}`,
156
+ extension: "txt",
157
+ })),
158
+ replay: optionalRefReplay(tab, args),
159
+ };
160
+ }
161
+ case "eval": {
162
+ const tab = ctx.registry.currentTab(session);
163
+ const expression = requiredString(args, "js");
164
+ const isMainWorld = args.mainWorld === true;
165
+ // Patchright's 4th evaluate argument picks the world; the isolated world hides page globals but
166
+ // leaves no trace in the page's own JS realm.
167
+ // oxlint-disable-next-line typescript/unbound-method -- called with tab.page as this on the next line
168
+ const evaluate = tab.page.evaluate;
169
+ const value = await guardTab(tab, () => evaluate.call(tab.page, expression, undefined, undefined, !isMainWorld));
170
+ // JSON has no undefined, so an expression without a value comes back as null.
171
+ const json = JSON.stringify(value ?? null, null, 2);
172
+ return deliver(ctx, session, args, {
173
+ field: "value",
174
+ value: value ?? null,
175
+ content: json,
176
+ prefix: `eval-${tab.id}`,
177
+ extension: "json",
178
+ });
179
+ }
180
+ case "extract": {
181
+ const tab = ctx.registry.currentTab(session);
182
+ const schema = parseExtractSchema(await schemaText(requiredString(args, "schema")));
183
+ const root = optionalElement(tab, args) ?? tab.page.locator(":root");
184
+ const rows = await guardTab(tab, () => root.evaluate(extractRowsInPage, schema, undefined, true));
185
+ const written = await deliver(ctx, session, args, {
186
+ field: "rows",
187
+ value: rows,
188
+ content: JSON.stringify(rows, null, 2),
189
+ prefix: `extract-${tab.id}`,
190
+ extension: "json",
191
+ });
192
+ return {
193
+ lines: [`rows: ${rows.length}`, ...written.lines],
194
+ fields: { count: rows.length, ...written.fields },
195
+ replay: optionalRefReplay(tab, args),
196
+ };
197
+ }
198
+ case "wait": {
199
+ const tab = ctx.registry.currentTab(session);
200
+ const condition = parseWaitCondition(args);
201
+ const startedAtMs = Date.now();
202
+ try {
203
+ await guardTab(tab, () => waitForCondition(tab.page, condition, timeoutMs));
204
+ }
205
+ catch (err) {
206
+ if (!(err instanceof CommandError) || err.code !== "timeout")
207
+ throw err;
208
+ const title = await tab.page.title().catch(() => "");
209
+ throw new CommandError("timeout", `no ${describeWaitCondition(condition)} within ${timeoutMs} ms`, `the tab is at ${tab.page.url()}, title "${title}"; raise --timeout-ms or check the page with snapshot`);
210
+ }
211
+ const waitedMs = Date.now() - startedAtMs;
212
+ const described = await describeTab(tab, `${describeWaitCondition(condition)} after ${waitedMs} ms`);
213
+ return { lines: described.lines, fields: { ...described.fields, waitedMs } };
214
+ }
215
+ case "watch": {
216
+ const kinds = new Set(parseWatchEventKinds(optionalString(args, "events"), ctx.mode));
217
+ const urlGlob = optionalString(args, "url");
218
+ const limit = args.count === undefined ? undefined : Number(args.count);
219
+ if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0))
220
+ throw new CommandError("bad_args", `--count must be a positive integer, got ${String(args.count)}`);
221
+ let count = 0;
222
+ let finish = () => { };
223
+ const finished = new Promise((resolve) => {
224
+ finish = resolve;
225
+ });
226
+ const deliverEvent = (event) => {
227
+ if (!kinds.has(event.kind) || (limit !== undefined && count >= limit))
228
+ return;
229
+ if (urlGlob !== undefined && !urlGlobMatches(urlGlob, urlOfEvent(event) ?? ""))
230
+ return;
231
+ count++;
232
+ call.emit({ line: watchEventLine(event), fields: watchEventFields(event) });
233
+ if (limit !== undefined && count >= limit)
234
+ finish("count");
235
+ };
236
+ const unsubscribe = ctx.events.subscribe(session, deliverEvent);
237
+ const stopConsole = kinds.has("console")
238
+ ? ctx.diagnostics.follow(session, (message) => deliverEvent({ kind: "console", tabId: message.tabId, message, atMs: message.atMs }))
239
+ : () => { };
240
+ const timer = setTimeout(() => finish("timeout"), timeoutMs);
241
+ call.disconnected.addEventListener("abort", () => finish("disconnected"), { once: true });
242
+ const reason = await finished;
243
+ clearTimeout(timer);
244
+ unsubscribe();
245
+ stopConsole();
246
+ return {
247
+ lines: [`watched ${count} events, stopped on ${reason}`],
248
+ fields: { count, reason, events: [...kinds] },
249
+ };
250
+ }
251
+ case "network-list": {
252
+ const entries = ctx.network.list(session, {
253
+ urlGlob: optionalString(args, "url"),
254
+ types: optionalString(args, "type"),
255
+ status: optionalString(args, "status"),
256
+ });
257
+ const dropped = ctx.network.droppedCount(session);
258
+ const header = [`requests: ${entries.length}`, ...(dropped > 0 ? [`dropped from buffer: ${dropped}`] : [])];
259
+ const written = await deliver(ctx, session, args, {
260
+ field: "requests",
261
+ value: entries.map(networkSummary),
262
+ content: entries.map(networkLine).join("\n"),
263
+ prefix: "network",
264
+ extension: "txt",
265
+ });
266
+ return {
267
+ lines: entries.length === 0 ? header : [...header, ...written.lines],
268
+ fields: { count: entries.length, dropped, ...written.fields },
269
+ };
270
+ }
271
+ case "network-get": {
272
+ const entry = networkEntryArg(ctx, session, args);
273
+ const replay = optionalString(args, "id") === undefined ? undefined : { requestUrlGlob: requestUrlGlob(entry.url) };
274
+ const detail = {
275
+ ...networkSummary(entry),
276
+ requestHeaders: entry.requestHeaders,
277
+ postData: entry.postData,
278
+ statusText: entry.statusText,
279
+ responseHeaders: entry.responseHeaders,
280
+ timing: entry.timing,
281
+ failure: entry.failure,
282
+ };
283
+ const lines = [
284
+ networkLine(entry),
285
+ ...Object.entries(entry.requestHeaders).map(([name, value]) => `> ${name}: ${value}`),
286
+ ...(entry.postData === undefined
287
+ ? []
288
+ : [
289
+ `> body: ${entry.postData.length > 500 ? `${entry.postData.slice(0, 500)}... (${Buffer.byteLength(entry.postData)} bytes, full text in --json)` : entry.postData}`,
290
+ ]),
291
+ ...Object.entries(entry.responseHeaders).map(([name, value]) => `< ${name}: ${value}`),
292
+ ];
293
+ if (args.body !== true)
294
+ return { lines, fields: detail, replay };
295
+ const body = await ctx.network.body(entry);
296
+ const extension = bodyExtension(entry.responseHeaders);
297
+ if (isTextual(entry.responseHeaders)) {
298
+ const text = body.toString("utf8");
299
+ const written = await deliver(ctx, session, args, {
300
+ field: "body",
301
+ value: text,
302
+ content: text,
303
+ prefix: `body-${entry.id}`,
304
+ extension,
305
+ });
306
+ return { lines: [...lines, ...written.lines], fields: { ...detail, ...written.fields }, replay };
307
+ }
308
+ const path = await outputPath(ctx, session, args, `body-${entry.id}`, extension);
309
+ await writeFile(path, body);
310
+ return {
311
+ lines: [...lines, `body: ${path}`, `bytes: ${body.byteLength}`],
312
+ fields: { ...detail, path, bytes: body.byteLength },
313
+ replay,
314
+ };
315
+ }
316
+ case "network-har-start": {
317
+ ctx.network.startHar(session);
318
+ return { lines: ["recording HAR for this session"], fields: { session } };
319
+ }
320
+ case "network-har-stop": {
321
+ const { entries, droppedCount } = ctx.network.stopHar(session);
322
+ const bodies = new Map();
323
+ // Text bodies go into the HAR; binary bodies would bloat it past what an agent can read.
324
+ await Promise.all(entries
325
+ .filter((entry) => entry.state === "finished" && isTextual(entry.responseHeaders))
326
+ .map(async (entry) => {
327
+ const body = await ctx.network.body(entry).catch(() => undefined);
328
+ if (body !== undefined)
329
+ bodies.set(entry.id, { text: body.toString("utf8"), encoding: undefined });
330
+ }));
331
+ const har = buildHar(entries, bodies, ctx.version);
332
+ const path = await outputPath(ctx, session, args, "network", "har");
333
+ await writeFile(path, JSON.stringify(har, null, 2));
334
+ return {
335
+ lines: [
336
+ `har: ${path}`,
337
+ `entries: ${har.log.entries.length}`,
338
+ `bodies: ${bodies.size}`,
339
+ ...(droppedCount > 0 ? [`dropped past 10000: ${droppedCount}`] : []),
340
+ ],
341
+ fields: { path, entries: har.log.entries.length, bodies: bodies.size, dropped: droppedCount },
342
+ };
343
+ }
344
+ case "route-block": {
345
+ const rule = await ctx.routes.block(session, requiredString(args, "glob"));
346
+ return { lines: [`blocking ${rule.glob}`], fields: { rules: ctx.routes.rulesOf(session).map(ruleSummary) } };
347
+ }
348
+ case "route-mock": {
349
+ const rule = await ctx.routes.mock(session, requiredString(args, "glob"), requiredString(args, "file"));
350
+ return { lines: [`mocking ${rule.glob}`], fields: { rules: ctx.routes.rulesOf(session).map(ruleSummary) } };
351
+ }
352
+ case "route-list": {
353
+ const rules = ctx.routes.rulesOf(session).map(ruleSummary);
354
+ return {
355
+ lines: rules.length === 0
356
+ ? ["no routes"]
357
+ : rules.map((rule) => `${rule.kind} ${rule.glob}${rule.file === undefined ? "" : ` ${rule.file}`}`),
358
+ fields: { rules },
359
+ };
360
+ }
361
+ case "route-clear": {
362
+ const cleared = await ctx.routes.clear(session);
363
+ return { lines: [`cleared ${cleared} routes`], fields: { cleared } };
364
+ }
365
+ case "login": {
366
+ const url = requiredString(args, "url");
367
+ const until = optionalString(args, "until");
368
+ const browserContextId = ctx.registry.browserContextOf(session);
369
+ const page = await ctx.engine.openForegroundPage(browserContextId);
370
+ const tab = ctx.registry.adoptPage(session, page, true);
371
+ await ctx.consoleCaptureReady(tab);
372
+ try {
373
+ await navigate(page, url, "domcontentloaded", timeoutMs);
374
+ }
375
+ catch (err) {
376
+ await page.close().catch(() => { });
377
+ throw err;
378
+ }
379
+ const closed = new Promise((resolve) => page.once("close", () => resolve("closed")));
380
+ const reached = until === undefined
381
+ ? new Promise(() => { })
382
+ : page
383
+ .waitForURL((current) => urlGlobMatches(until, current.href), { timeout: 0, waitUntil: "commit" })
384
+ .then(() => "reached");
385
+ let timer;
386
+ const expired = new Promise((resolve) => {
387
+ timer = setTimeout(() => resolve("expired"), timeoutMs);
388
+ });
389
+ const outcome = await Promise.race([closed, reached.catch(() => "closed"), expired]);
390
+ clearTimeout(timer);
391
+ if (outcome === "expired") {
392
+ throw new CommandError("timeout", `login did not finish within ${timeoutMs} ms`, until === undefined
393
+ ? "close the tab when signed in, or pass --until <url-glob>"
394
+ : `the tab never reached ${until}`);
395
+ }
396
+ const cookieCount = (await ctx.engine.cookies([url], browserContextId)).length;
397
+ const lines = outcome === "reached"
398
+ ? [`signed in, ${tab.id} reached ${page.url()}`, `cookies for ${new URL(url).origin}: ${cookieCount}`]
399
+ : [`login tab closed`, `cookies for ${new URL(url).origin}: ${cookieCount}`];
400
+ return { lines, fields: { tab: tab.id, outcome, url: tab.isClosed ? undefined : page.url(), cookieCount } };
401
+ }
402
+ case "cookies": {
403
+ const domain = optionalString(args, "domain")?.replace(/^\./, "");
404
+ const cookies = (await ctx.engine.cookies(undefined, ctx.registry.browserContextOf(session))).filter((cookie) => domain === undefined || cookieMatchesDomain(cookie, domain));
405
+ const lines = cookies.map((cookie) => `${cookie.domain} ${cookie.path} ${cookie.name}${cookie.expires > 0 ? ` expires ${new Date(cookie.expires * 1000).toISOString()}` : " session"}`);
406
+ const written = await deliver(ctx, session, args, {
407
+ field: "cookies",
408
+ value: cookies,
409
+ content: JSON.stringify(cookies, null, 2),
410
+ prefix: "cookies",
411
+ extension: "json",
412
+ });
413
+ // Plain output lists cookies without their values; the values are in --json and the file.
414
+ const { path } = written.fields;
415
+ const shown = typeof path === "string" ? [`json: ${path}`] : lines;
416
+ return { lines: [`cookies: ${cookies.length}`, ...shown], fields: { count: cookies.length, ...written.fields } };
417
+ }
418
+ case "state-save": {
419
+ const file = requiredString(args, "file");
420
+ const origins = ctx.registry.originsOf(session);
421
+ if (origins.length === 0)
422
+ throw new CommandError("bad_args", "this session has not loaded any web page yet", "open the sites first; state save keeps the origins this session visited");
423
+ const cookies = await ctx.engine.cookies(origins, ctx.registry.browserContextOf(session));
424
+ const storageByOrigin = new Map();
425
+ for (const tab of ctx.registry.openTabsOf(session)) {
426
+ const origin = originOfUrl(tab.page.url());
427
+ if (origin === undefined || storageByOrigin.has(origin))
428
+ continue;
429
+ const items = await tab.page
430
+ .evaluate(() => Object.entries(localStorage).map(([name, value]) => ({ name, value })), undefined, undefined, true)
431
+ .catch(() => undefined);
432
+ if (items !== undefined)
433
+ storageByOrigin.set(origin, items);
434
+ }
435
+ const state = {
436
+ cookies,
437
+ origins: [...storageByOrigin].map(([origin, localStorage]) => ({ origin, localStorage })),
438
+ };
439
+ await ctx.copyGuard.recordUnasked({
440
+ kind: "state-save",
441
+ session,
442
+ source: profileCopyTarget(ctx, session),
443
+ target: `file ${file}`,
444
+ site: undefined,
445
+ cookies: cookies.length,
446
+ origins: [...storageByOrigin.keys()],
447
+ });
448
+ await writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
449
+ const missed = origins.filter((origin) => !storageByOrigin.has(origin));
450
+ return {
451
+ lines: [
452
+ `state: ${file}`,
453
+ `cookies: ${cookies.length}`,
454
+ `localStorage origins: ${storageByOrigin.size}`,
455
+ ...(missed.length > 0 ? [`no open tab, localStorage skipped: ${missed.join(" ")}`] : []),
456
+ ],
457
+ fields: { path: file, cookies: cookies.length, origins: [...storageByOrigin.keys()], skippedOrigins: missed },
458
+ };
459
+ }
460
+ case "state-load": {
461
+ const file = requiredString(args, "file");
462
+ const state = parseStorageState(await readFile(file, "utf8").catch((err) => {
463
+ throw new CommandError("bad_args", `cannot read state file ${file}: ${err instanceof Error ? err.message : String(err)}`);
464
+ }));
465
+ await ctx.copyGuard.requireApproval({
466
+ kind: "state-load",
467
+ session,
468
+ source: `file ${file}`,
469
+ target: profileCopyTarget(ctx, session),
470
+ site: undefined,
471
+ cookies: state.cookies.length,
472
+ origins: state.origins.map((entry) => entry.origin),
473
+ });
474
+ const browserContextId = ctx.registry.browserContextOf(session);
475
+ await ctx.engine.addCookies(state.cookies, browserContextId);
476
+ for (const { origin, localStorage } of state.origins)
477
+ await writeOriginStorage(ctx, { origin, localStorage, indexedDB: [] }, timeoutMs, browserContextId);
478
+ return {
479
+ lines: [
480
+ `loaded ${file}`,
481
+ `cookies: ${state.cookies.length}`,
482
+ `localStorage origins: ${state.origins.length}`,
483
+ browserContextId === undefined
484
+ ? "the profile is shared, so every session now sees this state"
485
+ : "loaded into this isolated session only",
486
+ ],
487
+ fields: { path: file, cookies: state.cookies.length, origins: state.origins.map((entry) => entry.origin) },
488
+ };
489
+ }
490
+ case "state-import": {
491
+ const site = siteFromInput(requiredString(args, "site"));
492
+ const userDataDir = requiredString(args, "chromeUserDataDir");
493
+ const { profiles, lastUsedFolder } = await listChromeProfiles(userDataDir);
494
+ const chromeProfile = resolveChromeProfile(profiles, optionalString(args, "from"), lastUsedFolder);
495
+ const copyDir = await mkdtemp(join(tmpdir(), "patchrome-login-copy-"));
496
+ let read;
497
+ try {
498
+ const origins = await copySiteLoginStorage(join(userDataDir, chromeProfile.folder), site, copyDir);
499
+ read = await ctx.engine.readProfileCopy(copyDir, origins);
500
+ }
501
+ finally {
502
+ // The copy holds decryptable cookies for every site in the profile.
503
+ await rm(copyDir, { recursive: true, force: true });
504
+ }
505
+ const cookies = read.cookies.filter((cookie) => hostBelongsToSite(cookie.domain, site));
506
+ const stored = read.origins.filter((origin) => origin.localStorage.length > 0 || origin.indexedDB.length > 0);
507
+ const source = describeChromeProfile(chromeProfile);
508
+ if (cookies.length === 0 && stored.length === 0) {
509
+ throw new CommandError("bad_args", `Chrome profile ${source} has no cookies or storage for ${site}`, "sign in to the site in that Chrome profile first, or pick another with --from");
510
+ }
511
+ await ctx.copyGuard.requireApproval({
512
+ kind: "state-import",
513
+ session,
514
+ source: `Chrome profile ${source}`,
515
+ target: profileCopyTarget(ctx, session),
516
+ site,
517
+ cookies: cookies.length,
518
+ origins: stored.map((origin) => origin.origin),
519
+ });
520
+ const browserContextId = ctx.registry.browserContextOf(session);
521
+ await ctx.engine.addCookies(cookies, browserContextId);
522
+ for (const origin of stored)
523
+ await writeOriginStorage(ctx, origin, timeoutMs, browserContextId);
524
+ return {
525
+ lines: [
526
+ `imported ${site} from Chrome profile ${source}`,
527
+ `cookies: ${cookies.length}`,
528
+ ...stored.map((origin) => `${origin.origin}: ${origin.localStorage.length} localStorage items, IndexedDB ${origin.indexedDB.length === 0 ? "none" : origin.indexedDB.map((db) => db.name).join(" ")}`),
529
+ browserContextId === undefined
530
+ ? "the profile is shared, so every session now sees this login"
531
+ : "imported into this isolated session only",
532
+ ],
533
+ fields: {
534
+ site,
535
+ chromeProfile,
536
+ cookies: cookies.length,
537
+ origins: stored.map((origin) => ({
538
+ origin: origin.origin,
539
+ localStorageItems: origin.localStorage.length,
540
+ indexedDB: origin.indexedDB.map((db) => db.name),
541
+ })),
542
+ },
543
+ };
544
+ }
545
+ case "console": {
546
+ requireDebugProfile(ctx, "console");
547
+ const level = parseConsoleLevel(optionalString(args, "level") ?? "debug");
548
+ if (args.follow !== true) {
549
+ const messages = ctx.diagnostics.messages(session, level);
550
+ const written = await deliver(ctx, session, args, {
551
+ field: "messages",
552
+ value: messages,
553
+ content: messages.map(consoleLine).join("\n"),
554
+ prefix: "console",
555
+ extension: "txt",
556
+ });
557
+ return {
558
+ lines: [`messages: ${messages.length}`, ...(messages.length === 0 ? [] : written.lines)],
559
+ fields: { count: messages.length, ...written.fields },
560
+ };
561
+ }
562
+ let count = 0;
563
+ const stopFollowing = ctx.diagnostics.follow(session, (message) => {
564
+ if (!isAtLeast(message.level, level))
565
+ return;
566
+ count++;
567
+ call.emit({ line: consoleLine(message), fields: { message } });
568
+ });
569
+ const reason = await new Promise((resolve) => {
570
+ const timer = setTimeout(() => resolve("timeout"), timeoutMs);
571
+ call.disconnected.addEventListener("abort", () => {
572
+ clearTimeout(timer);
573
+ resolve("disconnected");
574
+ }, { once: true });
575
+ });
576
+ stopFollowing();
577
+ return { lines: [`followed for ${timeoutMs} ms, ${count} messages`], fields: { count, reason } };
578
+ }
579
+ case "errors": {
580
+ requireDebugProfile(ctx, "errors");
581
+ const errors = ctx.diagnostics.errors(session);
582
+ const text = errors
583
+ .map((error) => `${error.id} ${error.tabId} ${error.message}${error.url === undefined ? "" : ` (${error.url}:${error.line})`}${error.stack === undefined ? "" : `\n${error.stack}`}`)
584
+ .join("\n");
585
+ const written = await deliver(ctx, session, args, {
586
+ field: "errors",
587
+ value: errors,
588
+ content: text,
589
+ prefix: "errors",
590
+ extension: "txt",
591
+ });
592
+ return {
593
+ lines: [`errors: ${errors.length}`, ...(errors.length === 0 ? [] : written.lines)],
594
+ fields: { count: errors.length, ...written.fields },
595
+ };
596
+ }
597
+ case "trace-start": {
598
+ requireDebugProfile(ctx, "trace start");
599
+ if (ctx.trace.owner !== undefined) {
600
+ throw new CommandError("bad_args", ctx.trace.owner === session
601
+ ? "this session is already tracing"
602
+ : `session ${ctx.trace.owner} is already tracing this browser`, ctx.trace.owner === session
603
+ ? "run `patchrome trace stop`"
604
+ : "a trace covers every tab in the profile, so only one runs at a time");
605
+ }
606
+ await ctx.engine.startTrace();
607
+ ctx.trace.owner = session;
608
+ return { lines: ["tracing, covers every tab in this profile"], fields: { session } };
609
+ }
610
+ case "trace-stop": {
611
+ requireDebugProfile(ctx, "trace stop");
612
+ if (ctx.trace.owner !== session) {
613
+ throw new CommandError("bad_args", ctx.trace.owner === undefined ? "no trace is running" : `session ${ctx.trace.owner} owns the running trace`, "run `patchrome trace start` first");
614
+ }
615
+ const path = await outputPath(ctx, session, args, "trace", "zip");
616
+ await ctx.engine.stopTrace(path);
617
+ ctx.trace.owner = undefined;
618
+ return { lines: [`trace: ${path}`, "open with `npx playwright show-trace <file>`"], fields: { path } };
619
+ }
620
+ case "cdp": {
621
+ requireDebugProfile(ctx, "cdp");
622
+ const tab = ctx.registry.currentTab(session);
623
+ const method = requiredString(args, "method");
624
+ if (!/^[A-Z][A-Za-z]*\.[a-z][A-Za-z]*$/.test(method))
625
+ throw new CommandError("bad_args", `${method} is not a CDP method`, "use Domain.method, for example Performance.getMetrics");
626
+ const params = parseCdpParams(optionalString(args, "params"));
627
+ const cdp = await guardTab(tab, () => ctx.engine.openCdpSession(tab.page));
628
+ try {
629
+ const reply = await guardTab(tab, () => cdp.send(method, params));
630
+ return await deliver(ctx, session, args, {
631
+ field: "result",
632
+ value: reply,
633
+ content: JSON.stringify(reply, null, 2),
634
+ prefix: `cdp-${tab.id}`,
635
+ extension: "json",
636
+ });
637
+ }
638
+ catch (err) {
639
+ if (err instanceof CommandError && err.code === "bad_args")
640
+ throw new CommandError("bad_args", `${method} failed: ${err.message}`);
641
+ throw err;
642
+ }
643
+ finally {
644
+ await cdp.detach().catch(() => { });
645
+ }
646
+ }
647
+ case "cdp-help": {
648
+ requireDebugProfile(ctx, "cdp help");
649
+ const endpoint = ctx.engine.debuggingEndpoint();
650
+ if (endpoint === undefined)
651
+ throw new CommandError("bad_args", "the debug browser exposed no debugging endpoint", "see `patchrome daemon logs`");
652
+ const response = await fetch(`${endpoint.httpUrl}/json/protocol`);
653
+ const schema = parseProtocolSchema(await response.text());
654
+ const topic = optionalString(args, "topic");
655
+ const help = protocolHelp(schema, topic).join("\n");
656
+ return deliver(ctx, session, args, {
657
+ field: "help",
658
+ value: help,
659
+ content: help,
660
+ prefix: "cdp-help",
661
+ extension: "txt",
662
+ });
663
+ }
664
+ case "devtools-url": {
665
+ requireDebugProfile(ctx, "devtools-url");
666
+ const endpoint = ctx.engine.debuggingEndpoint();
667
+ if (endpoint === undefined)
668
+ throw new CommandError("bad_args", "the debug browser exposed no debugging endpoint", "see `patchrome daemon logs`");
669
+ return {
670
+ lines: [
671
+ endpoint.httpUrl,
672
+ `browser: ${endpoint.browserWsUrl}`,
673
+ `attach: chrome-devtools start --browserUrl ${endpoint.httpUrl}`,
674
+ ],
675
+ fields: { ...endpoint },
676
+ };
677
+ }
678
+ case "session": {
679
+ const tabs = ctx.registry.tabsOf(session);
680
+ const label = ctx.registry.labelOf(session);
681
+ await ctx.regroupTabs(session);
682
+ const openPages = ctx.registry.openTabsOf(session).map((tab) => tab.page);
683
+ const tabGroups = openPages.length === 0 || ctx.registry.browserContextOf(session) !== undefined
684
+ ? []
685
+ : await ctx.engine.describeTabGroups(openPages).catch(() => []);
686
+ return {
687
+ lines: [
688
+ `session: ${session}`,
689
+ ...(label === undefined ? [] : [`label: ${label}`]),
690
+ ...tabGroups.map((group) => `tab group: ${group.title} (${group.color}, ${group.tabCount} tabs)`),
691
+ ...tabs.map((tab) => `${tab.isCurrent ? "*" : " "} ${tab.id} ${tab.url}`),
692
+ ],
693
+ fields: { session, label, tabGroups, tabs },
694
+ };
695
+ }
696
+ case "sessions": {
697
+ const pattern = optionalString(args, "pattern");
698
+ const matches = (name) => pattern === undefined || nameGlobMatches(pattern, name);
699
+ const live = ctx.registry
700
+ .sessionNames()
701
+ .filter(matches)
702
+ .map((name) => ({
703
+ session: name,
704
+ label: ctx.registry.labelOf(name),
705
+ tabCount: ctx.registry.openTabsOf(name).length,
706
+ isIsolated: ctx.registry.browserContextOf(name) !== undefined,
707
+ isAwaitingRestore: false,
708
+ }));
709
+ const saved = ctx
710
+ .savedSessionNames()
711
+ .filter(matches)
712
+ .map((name) => ({ session: name, label: undefined, tabCount: 0, isIsolated: false, isAwaitingRestore: true }));
713
+ const all = [...live, ...saved].toSorted((a, b) => a.session.localeCompare(b.session));
714
+ if (pattern !== undefined && all.length === 0)
715
+ throw new CommandError("bad_args", `no session matches ${pattern}`, "run `patchrome sessions` to list them");
716
+ return {
717
+ lines: all.length === 0
718
+ ? ["no sessions"]
719
+ : all.map((entry) => `${entry.session} ${entry.isAwaitingRestore ? "saved, reopens on its next command" : `${entry.tabCount} tabs${entry.isIsolated ? " isolated" : ""}`}${entry.label === undefined ? "" : ` label: ${entry.label}`}`),
720
+ fields: { sessions: all },
721
+ };
722
+ }
723
+ case "session-label": {
724
+ const label = requiredString(args, "label").trim();
725
+ if (label === "")
726
+ throw new CommandError("bad_args", "session label needs non-blank text");
727
+ ctx.registry.setLabel(session, label);
728
+ await ctx.regroupTabs(session);
729
+ return { lines: [`labelled ${session}: ${label}`], fields: { session, label } };
730
+ }
731
+ case "session-close": {
732
+ const pattern = optionalString(args, "pattern");
733
+ if (pattern === undefined) {
734
+ const closedTabs = await closeSession(ctx, session);
735
+ return { lines: [`closed session ${session}, ${closedTabs} tabs`], fields: { session, closedTabs } };
736
+ }
737
+ const live = ctx.registry.sessionNames().filter((name) => nameGlobMatches(pattern, name));
738
+ const saved = ctx.savedSessionNames().filter((name) => nameGlobMatches(pattern, name));
739
+ if (live.length === 0 && saved.length === 0) {
740
+ const known = [...ctx.registry.sessionNames(), ...ctx.savedSessionNames()];
741
+ throw new CommandError("bad_args", `no session ${isNamePattern(pattern) ? "matches" : "named"} ${pattern}`, known.length === 0 ? "no sessions are open" : `sessions: ${known.toSorted().join(" ")}`);
742
+ }
743
+ const closed = [];
744
+ for (const name of live)
745
+ closed.push({ session: name, closedTabs: await closeSession(ctx, name) });
746
+ for (const name of saved) {
747
+ ctx.forgetSavedSession(name);
748
+ closed.push({ session: name, closedTabs: 0 });
749
+ }
750
+ closed.sort((a, b) => a.session.localeCompare(b.session));
751
+ return {
752
+ lines: closed.map((entry) => `closed session ${entry.session}, ${entry.closedTabs} tabs`),
753
+ fields: { sessions: closed },
754
+ };
755
+ }
756
+ case "daemon-status": {
757
+ const tabCount = ctx.registry.allTabs().length;
758
+ const sessions = ctx.registry.sessionNames();
759
+ return {
760
+ lines: [
761
+ `profile: ${ctx.profile}`,
762
+ `mode: ${ctx.mode}`,
763
+ ...(ctx.engine.debuggingEndpoint() === undefined
764
+ ? []
765
+ : [`devtools: ${ctx.engine.debuggingEndpoint()?.httpUrl}`]),
766
+ `build: ${ctx.buildId}`,
767
+ `pid: ${process.pid}`,
768
+ `uptime: ${Math.round((Date.now() - ctx.startedAtMs) / 1000)} s`,
769
+ `sessions: ${sessions.length}`,
770
+ `tabs: ${tabCount}`,
771
+ ],
772
+ fields: {
773
+ profile: ctx.profile,
774
+ mode: ctx.mode,
775
+ devtoolsUrl: ctx.engine.debuggingEndpoint()?.httpUrl,
776
+ buildId: ctx.buildId,
777
+ pid: process.pid,
778
+ startedAtMs: ctx.startedAtMs,
779
+ sessions,
780
+ tabCount,
781
+ },
782
+ };
783
+ }
784
+ case "daemon-stop": {
785
+ ctx.requestShutdown();
786
+ return { lines: ["daemon stopping"], fields: { pid: process.pid } };
787
+ }
788
+ }
789
+ }
790
+ // Closes every tab of the session and drops what the daemon kept for it. Returns how many tabs closed.
791
+ async function closeSession(ctx, session) {
792
+ const tabs = ctx.registry.openTabsOf(session);
793
+ const browserContextId = ctx.registry.browserContextOf(session);
794
+ await Promise.all(tabs.map((tab) => tab.page.close().catch(() => { })));
795
+ if (browserContextId !== undefined)
796
+ await ctx.engine.disposeIsolatedContext(browserContextId).catch(() => { });
797
+ ctx.registry.forget(session);
798
+ ctx.network.forget(session);
799
+ ctx.diagnostics.forget(session);
800
+ await ctx.routes.forget(session);
801
+ if (ctx.trace.owner === session) {
802
+ ctx.trace.owner = undefined;
803
+ await ctx.engine
804
+ .stopTrace(join(ctx.sessionsDir, sessionFolderName(session), "abandoned-trace.zip"))
805
+ .catch(() => { });
806
+ }
807
+ return tabs.length;
808
+ }
809
+ // A session picks shared or isolated with its first tab and keeps it: moving tabs between cookie jars
810
+ // would leak one site's login into the other.
811
+ async function browserContextForOpen(ctx, session, isIsolatedRequest) {
812
+ const existing = ctx.registry.browserContextOf(session);
813
+ if (!isIsolatedRequest || existing !== undefined)
814
+ return existing;
815
+ if (ctx.registry.openTabsOf(session).length > 0) {
816
+ throw new CommandError("bad_args", `session ${session} already browses the shared profile`, "run `patchrome session close`, then `open --isolated`");
817
+ }
818
+ const browserContextId = await ctx.engine.createIsolatedContext();
819
+ ctx.registry.isolate(session, browserContextId);
820
+ return browserContextId;
821
+ }
822
+ // Chrome closes with the daemon, so a restart reopens each saved tab at its last URL under the same id.
823
+ // An isolated session gets a fresh in-memory context: its cookies did not survive. A tab whose page fails
824
+ // to load is left out, and a command on it gives tab_gone.
825
+ // A restore that fails part way closes what it opened, so the saved session stays whole for the next attempt
826
+ // instead of being replaced by its first few tabs.
827
+ export async function restoreSession(ctx, saved, timeoutMs) {
828
+ try {
829
+ return await reopenSavedTabs(ctx, saved, timeoutMs);
830
+ }
831
+ catch (err) {
832
+ await closeSession(ctx, saved.name);
833
+ throw err;
834
+ }
835
+ }
836
+ async function reopenSavedTabs(ctx, saved, timeoutMs) {
837
+ const restored = [];
838
+ const dropped = [];
839
+ ctx.registry.setLabel(saved.name, saved.label);
840
+ if (saved.isIsolated)
841
+ ctx.registry.isolate(saved.name, await ctx.engine.createIsolatedContext());
842
+ const browserContextId = ctx.registry.browserContextOf(saved.name);
843
+ for (const { id, url } of saved.tabs) {
844
+ const page = await ctx.engine.openBackgroundPage(browserContextId);
845
+ const tab = ctx.registry.adoptPage(saved.name, page, false, id);
846
+ await ctx.consoleCaptureReady(tab);
847
+ if (url.startsWith("about:blank")) {
848
+ restored.push(id);
849
+ continue;
850
+ }
851
+ try {
852
+ await navigate(page, url, "domcontentloaded", timeoutMs);
853
+ restored.push(id);
854
+ }
855
+ catch {
856
+ await page.close().catch(() => { });
857
+ dropped.push(id);
858
+ }
859
+ }
860
+ if (saved.currentTabId !== undefined && restored.includes(saved.currentTabId))
861
+ ctx.registry.switchTo(saved.name, saved.currentTabId);
862
+ return { restored, dropped };
863
+ }
864
+ function requireDebugProfile(ctx, command) {
865
+ switch (ctx.mode) {
866
+ case "debug":
867
+ return;
868
+ case "stealth":
869
+ throw new CommandError("unsupported_in_stealth", `${command} needs a debug profile; profile ${ctx.profile} is stealth`, `run it with --profile debug; stealth profiles keep Runtime, Tracing and the debugging port off so sites cannot detect them`);
870
+ }
871
+ }
872
+ function parseCdpParams(raw) {
873
+ if (raw === undefined)
874
+ return {};
875
+ return parseJsonInput(z.record(z.string(), z.unknown()), raw, "CDP params", `pass an object, for example '{"expression": "1 + 1"}'`);
876
+ }
877
+ async function schemaText(schemaArg) {
878
+ if (schemaArg.trimStart().startsWith("{"))
879
+ return schemaArg;
880
+ try {
881
+ return await readFile(schemaArg, "utf8");
882
+ }
883
+ catch (err) {
884
+ throw new CommandError("bad_args", `cannot read schema file ${schemaArg}: ${err instanceof Error ? err.message : String(err)}`, "pass a JSON file path or inline JSON starting with {");
885
+ }
886
+ }
887
+ function networkSummary(entry) {
888
+ return {
889
+ id: entry.id,
890
+ tab: entry.tabId,
891
+ method: entry.method,
892
+ url: entry.url,
893
+ type: entry.resourceType,
894
+ state: entry.state,
895
+ status: entry.status,
896
+ durationMs: entry.durationMs,
897
+ };
898
+ }
899
+ function networkLine(entry) {
900
+ const status = entry.state === "failed" ? `failed(${entry.failure})` : String(entry.status ?? "pending");
901
+ const duration = entry.durationMs === undefined ? "" : ` ${entry.durationMs}ms`;
902
+ return `${entry.id} ${entry.tabId} ${status} ${entry.resourceType} ${entry.method} ${entry.url}${duration}`;
903
+ }
904
+ function ruleSummary(rule) {
905
+ return rule.kind === "mock"
906
+ ? { kind: rule.kind, glob: rule.glob, file: rule.file }
907
+ : { kind: rule.kind, glob: rule.glob, file: undefined };
908
+ }
909
+ function cookieMatchesDomain(cookie, domain) {
910
+ const cookieDomain = cookie.domain.replace(/^\./, "");
911
+ return cookieDomain === domain || cookieDomain.endsWith(`.${domain}`);
912
+ }
913
+ function originOfUrl(url) {
914
+ try {
915
+ const parsed = new URL(url);
916
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : undefined;
917
+ }
918
+ catch {
919
+ return undefined;
920
+ }
921
+ }
922
+ const storageStateSchema = z.object({
923
+ cookies: z.array(z.looseObject({ name: z.string(), value: z.string(), domain: z.string(), path: z.string() })),
924
+ origins: z.array(z.object({
925
+ origin: z.string().refine((origin) => originOfUrl(origin) === origin, "must be an http(s) origin"),
926
+ localStorage: z.array(z.object({ name: z.string(), value: z.string() })),
927
+ })),
928
+ });
929
+ function profileCopyTarget(ctx, session) {
930
+ return ctx.registry.browserContextOf(session) === undefined
931
+ ? `patchrome profile ${ctx.profile} (shared by every session)`
932
+ : `isolated session ${session} of patchrome profile ${ctx.profile}`;
933
+ }
934
+ // Accepts the file `state save` writes, which is also Playwright's storageState format.
935
+ export function parseStorageState(raw) {
936
+ const state = parseJsonInput(storageStateSchema, raw, "state file", "use a file written by `patchrome state save` or Playwright's storageState()");
937
+ return { cookies: state.cookies, origins: state.origins };
938
+ }
939
+ // Writes storage without contacting the site: a background tab loads the origin from a route that answers
940
+ // with an empty page, writes, and closes. The tab is never adopted by a session.
941
+ async function writeOriginStorage(ctx, storage, timeoutMs, browserContextId) {
942
+ if (storage.localStorage.length === 0 && storage.indexedDB.length === 0)
943
+ return;
944
+ const page = await ctx.engine.openBackgroundPage(browserContextId);
945
+ try {
946
+ await page.route("**/*", (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><title>patchrome state</title>" }));
947
+ await page.goto(`${storage.origin}/`, { waitUntil: "commit", timeout: timeoutMs });
948
+ await page.evaluate(writeOriginStorageInPage, { localStorage: storage.localStorage, indexedDB: storage.indexedDB }, undefined, true);
949
+ }
950
+ finally {
951
+ await page.close().catch(() => { });
952
+ }
953
+ }
954
+ // A ref, or a locator from --role, --text, --label or --selector, for commands that act on one element.
955
+ function targetLocator(tab, target) {
956
+ return target.kind === "ref" ? refLocator(tab, target.ref) : elementLocator(tab.page, target);
957
+ }
958
+ // A ref names an element on one page only; history replays it as the locator its snapshot line gives.
959
+ function refReplay(tab, target) {
960
+ if (target.kind !== "ref")
961
+ return undefined;
962
+ const ref = parseRef(target.ref);
963
+ return {
964
+ refLocator: locatorForRef(tab.generations.latestSnapshot, ref) ?? {
965
+ flags: ["--ref", ref],
966
+ notes: [`@${ref} was not found in the snapshot; replace it with a locator`],
967
+ },
968
+ };
969
+ }
970
+ function optionalRefReplay(tab, args) {
971
+ const ref = optionalString(args, "ref");
972
+ return ref === undefined ? undefined : refReplay(tab, { kind: "ref", ref });
973
+ }
974
+ // text, screenshot and extract read the whole page unless given an element.
975
+ function optionalElement(tab, args) {
976
+ const isGiven = ["ref", "selector", "role", "text", "label"].some((name) => typeof args[name] === "string");
977
+ if (!isGiven)
978
+ return undefined;
979
+ const target = parseTarget(args, { allowsPoint: false });
980
+ return target.kind === "point" ? undefined : targetLocator(tab, target);
981
+ }
982
+ // `network get --url <glob>` takes the newest matching request that finished, so a script need not know ids.
983
+ function networkEntryArg(ctx, session, args) {
984
+ const id = optionalString(args, "id");
985
+ const urlGlob = optionalString(args, "url");
986
+ if ((id === undefined) === (urlGlob === undefined))
987
+ throw new CommandError("bad_args", "network get takes a request id or --url <glob>", "network get n17, or network get --url '*/api/items*'");
988
+ if (id !== undefined)
989
+ return ctx.network.entry(session, id);
990
+ const matches = ctx.network.list(session, { urlGlob, types: undefined, status: undefined });
991
+ const newest = matches.findLast((entry) => entry.state === "finished") ?? matches.at(-1);
992
+ if (newest === undefined)
993
+ throw new CommandError("bad_args", `no request in this session matches ${urlGlob}`, "globs match the whole URL; run `patchrome network list` to see what loaded");
994
+ return newest;
995
+ }
996
+ function refLocator(tab, refInput) {
997
+ const ref = parseRef(refInput);
998
+ tab.generations.assertRefCurrent(ref);
999
+ return tab.page.locator(`aria-ref=${ref}`);
1000
+ }
1001
+ async function navigate(page, url, waitUntil, timeoutMs) {
1002
+ try {
1003
+ await page.goto(url, { waitUntil, timeout: timeoutMs });
1004
+ }
1005
+ catch (err) {
1006
+ throw translateError(err, "navigation_failed");
1007
+ }
1008
+ }
1009
+ // Playwright errors become the closed error set; anything unrecognised surfaces as the fallback code.
1010
+ async function guardTab(tab, action) {
1011
+ try {
1012
+ return await action();
1013
+ }
1014
+ catch (err) {
1015
+ if (tab.isClosed)
1016
+ throw new CommandError("tab_gone", `tab ${tab.id} closed during the command`, "run `patchrome open <url>`");
1017
+ throw translateError(err, "bad_args");
1018
+ }
1019
+ }
1020
+ function translateError(err, fallback) {
1021
+ if (err instanceof CommandError)
1022
+ return err;
1023
+ const message = err instanceof Error ? (err.message.split("\n")[0] ?? err.message) : String(err);
1024
+ if (err instanceof Error && err.name === "TimeoutError")
1025
+ return new CommandError("timeout", message, "raise --timeout-ms or wait for a condition first");
1026
+ if (/Target page, context or browser has been closed|Target closed/.test(message))
1027
+ return new CommandError("tab_gone", message, "run `patchrome open <url>`");
1028
+ return new CommandError(fallback, message);
1029
+ }
1030
+ async function describeTab(tab, action) {
1031
+ const url = tab.page.url();
1032
+ const title = await tab.page.title().catch(() => "");
1033
+ return { lines: [action, `url: ${url}`, `title: ${title}`], fields: { tab: tab.id, url, title } };
1034
+ }
1035
+ // Where a value goes: `--out <file>` always writes that file, `--inline` always prints it, and without either
1036
+ // a value over 2 KB goes to a session file. A script passes one of the two, so the output shape never
1037
+ // depends on the page.
1038
+ async function deliver(ctx, session, args, payload) {
1039
+ const bytes = Buffer.byteLength(payload.content);
1040
+ if (optionalString(args, "out") === undefined && (args.inline === true || bytes <= inlineLimitBytes)) {
1041
+ return { lines: [payload.content], fields: { [payload.field]: payload.value } };
1042
+ }
1043
+ const path = await outputPath(ctx, session, args, payload.prefix, payload.extension);
1044
+ await writeFile(path, payload.content);
1045
+ return { lines: [`${payload.field}: ${path}`, `bytes: ${bytes}`], fields: { path, bytes } };
1046
+ }
1047
+ async function outputPath(ctx, session, args, prefix, extension) {
1048
+ const out = optionalString(args, "out");
1049
+ if (out === undefined)
1050
+ return sessionFilePath(ctx, session, prefix, extension);
1051
+ await mkdir(dirname(out), { recursive: true });
1052
+ return out;
1053
+ }
1054
+ async function sessionFilePath(ctx, session, prefix, extension) {
1055
+ const dir = join(ctx.sessionsDir, sessionFolderName(session));
1056
+ await mkdir(dir, { recursive: true });
1057
+ const highest = Math.max(0, ...(await readdir(dir)).map((name) => Number(name.match(new RegExp(`^${prefix}-(\\d+)\\.`))?.[1] ?? 0)));
1058
+ return join(dir, `${prefix}-${highest + 1}.${extension}`);
1059
+ }
1060
+ function requiredString(args, name) {
1061
+ const value = args[name];
1062
+ if (typeof value !== "string" || value === "")
1063
+ throw new CommandError("bad_args", `missing ${name}`);
1064
+ return value;
1065
+ }
1066
+ function optionalString(args, name) {
1067
+ const value = args[name];
1068
+ return typeof value === "string" ? value : undefined;
1069
+ }
1070
+ function waitStateArg(args) {
1071
+ const value = args.wait ?? "load";
1072
+ if (!waitStates.includes(value)) {
1073
+ throw new CommandError("bad_args", `--wait must be one of ${waitStates.join(", ")}, got ${String(value)}`);
1074
+ }
1075
+ return value;
1076
+ }