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.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/patchrome.js +10 -0
- package/dist/build-id.d.ts +2 -0
- package/dist/build-id.js +21 -0
- package/dist/challenges.d.ts +22 -0
- package/dist/challenges.js +97 -0
- package/dist/chrome-profiles.d.ts +17 -0
- package/dist/chrome-profiles.js +141 -0
- package/dist/cli-options.d.ts +131 -0
- package/dist/cli-options.js +43 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +572 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +210 -0
- package/dist/commands.d.ts +58 -0
- package/dist/commands.js +1076 -0
- package/dist/completions.d.ts +1 -0
- package/dist/completions.js +114 -0
- package/dist/copy-guard.d.ts +75 -0
- package/dist/copy-guard.js +167 -0
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +313 -0
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +117 -0
- package/dist/engine.d.ts +51 -0
- package/dist/engine.js +257 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.js +106 -0
- package/dist/extract.d.ts +27 -0
- package/dist/extract.js +62 -0
- package/dist/focus.d.ts +1 -0
- package/dist/focus.js +44 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.js +63 -0
- package/dist/har.d.ts +105 -0
- package/dist/har.js +88 -0
- package/dist/history.d.ts +35 -0
- package/dist/history.js +277 -0
- package/dist/host-platform.d.ts +5 -0
- package/dist/host-platform.js +19 -0
- package/dist/host-prompts-macos.d.ts +2 -0
- package/dist/host-prompts-macos.js +102 -0
- package/dist/host-prompts-wsl.d.ts +6 -0
- package/dist/host-prompts-wsl.js +64 -0
- package/dist/host-prompts.d.ts +3 -0
- package/dist/host-prompts.js +25 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +47 -0
- package/dist/network.d.ts +54 -0
- package/dist/network.js +204 -0
- package/dist/origin-storage.d.ts +31 -0
- package/dist/origin-storage.js +82 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +52 -0
- package/dist/pipe.d.ts +9 -0
- package/dist/pipe.js +73 -0
- package/dist/profile-mode.d.ts +10 -0
- package/dist/profile-mode.js +42 -0
- package/dist/protocol-help.d.ts +34 -0
- package/dist/protocol-help.js +66 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +89 -0
- package/dist/refs.d.ts +9 -0
- package/dist/refs.js +46 -0
- package/dist/routes.d.ts +20 -0
- package/dist/routes.js +106 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +81 -0
- package/dist/session-name.d.ts +9 -0
- package/dist/session-name.js +50 -0
- package/dist/session-store.d.ts +5 -0
- package/dist/session-store.js +58 -0
- package/dist/sessions.d.ts +47 -0
- package/dist/sessions.js +171 -0
- package/dist/tab-groups.d.ts +9 -0
- package/dist/tab-groups.js +13 -0
- package/dist/targets.d.ts +43 -0
- package/dist/targets.js +229 -0
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +31 -0
- package/dist/wait.d.ts +24 -0
- package/dist/wait.js +88 -0
- package/examples/go/go.mod +3 -0
- package/examples/go/main.go +104 -0
- package/examples/hn-front-page.sh +18 -0
- package/examples/hn-front-page.ts +24 -0
- package/examples/hn_front_page.py +56 -0
- package/extension/tab-groups/manifest.json +8 -0
- package/extension/tab-groups/service-worker.js +41 -0
- package/package.json +60 -0
- package/skills/patchrome/SKILL.md +74 -0
- package/skills/patchrome/references/commands.md +130 -0
- package/skills/patchrome/references/debugging.md +20 -0
- package/skills/patchrome/references/hard-pages.md +49 -0
- package/skills/patchrome/references/logins.md +46 -0
- package/skills/patchrome/references/scraping.md +51 -0
- package/skills/patchrome/references/scripting.md +79 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { chromium } from "patchright";
|
|
7
|
+
import { keepFocusDuring } from "./focus.js";
|
|
8
|
+
import { readOriginStorageInPage } from "./origin-storage.js";
|
|
9
|
+
const pageOpenTimeoutMs = 15_000;
|
|
10
|
+
// Chrome can raise its window shortly after launch resolves.
|
|
11
|
+
const focusGraceMs = 3_000;
|
|
12
|
+
const devToolsPortWaitMs = 10_000;
|
|
13
|
+
const extensionWorkerWaitMs = 10_000;
|
|
14
|
+
// src/ and dist/ both sit one level below the package root, next to extension/.
|
|
15
|
+
const tabGroupsExtensionDir = fileURLToPath(new URL("../extension/tab-groups", import.meta.url));
|
|
16
|
+
export class PatchrightEngine {
|
|
17
|
+
#context;
|
|
18
|
+
#browserCdp;
|
|
19
|
+
#closedListeners = [];
|
|
20
|
+
#endpoint;
|
|
21
|
+
#tabGroupsExtensionId;
|
|
22
|
+
#targetIdByPage = new WeakMap();
|
|
23
|
+
async launch(chromeProfileDir, mode) {
|
|
24
|
+
// A port file left by an earlier Chrome would point at a dead port.
|
|
25
|
+
await rm(join(chromeProfileDir, "DevToolsActivePort"), { force: true });
|
|
26
|
+
const context = await keepFocusDuring(chromium.launchPersistentContext(chromeProfileDir, {
|
|
27
|
+
channel: "chrome",
|
|
28
|
+
headless: false,
|
|
29
|
+
viewport: null,
|
|
30
|
+
// Without this Playwright passes --no-sandbox, which weakens Chrome and shows a warning bar.
|
|
31
|
+
chromiumSandbox: true,
|
|
32
|
+
args: launchArgsFor(mode),
|
|
33
|
+
}), focusGraceMs);
|
|
34
|
+
context.on("close", () => {
|
|
35
|
+
for (const listener of this.#closedListeners)
|
|
36
|
+
listener();
|
|
37
|
+
});
|
|
38
|
+
const browser = context.browser();
|
|
39
|
+
if (!browser)
|
|
40
|
+
throw new Error("persistent context exposed no browser for a browser-level CDP session");
|
|
41
|
+
this.#browserCdp = await browser.newBrowserCDPSession();
|
|
42
|
+
this.#context = context;
|
|
43
|
+
const { id } = await this.#browserCdp.send("Extensions.loadUnpacked", { path: tabGroupsExtensionDir });
|
|
44
|
+
this.#tabGroupsExtensionId = id;
|
|
45
|
+
switch (mode) {
|
|
46
|
+
case "stealth":
|
|
47
|
+
break;
|
|
48
|
+
case "debug":
|
|
49
|
+
this.#endpoint = await readDevToolsEndpoint(chromeProfileDir);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async openBackgroundPage(browserContextId) {
|
|
54
|
+
return this.#openTarget(true, browserContextId);
|
|
55
|
+
}
|
|
56
|
+
async openForegroundPage(browserContextId) {
|
|
57
|
+
if (browserContextId === undefined)
|
|
58
|
+
return this.#requireContext().newPage();
|
|
59
|
+
return this.#openTarget(false, browserContextId);
|
|
60
|
+
}
|
|
61
|
+
// Playwright does not know isolated contexts and files their pages under the persistent one, so
|
|
62
|
+
// context.cookies() would read the wrong jar. CDP Storage takes the context id.
|
|
63
|
+
async cookies(urls, browserContextId) {
|
|
64
|
+
if (browserContextId === undefined)
|
|
65
|
+
return this.#requireContext().cookies(urls);
|
|
66
|
+
const { cookies } = await this.#requireBrowserCdp().send("Storage.getCookies", { browserContextId });
|
|
67
|
+
return cookies
|
|
68
|
+
.map(playwrightCookieOf)
|
|
69
|
+
.filter((cookie) => urls === undefined || urls.some((url) => cookieAppliesTo(cookie, url)));
|
|
70
|
+
}
|
|
71
|
+
async addCookies(cookies, browserContextId) {
|
|
72
|
+
if (browserContextId === undefined)
|
|
73
|
+
return this.#requireContext().addCookies(cookies);
|
|
74
|
+
await this.#requireBrowserCdp().send("Storage.setCookies", {
|
|
75
|
+
browserContextId,
|
|
76
|
+
cookies: cookies.map((cookie) => ({
|
|
77
|
+
name: cookie.name,
|
|
78
|
+
value: cookie.value,
|
|
79
|
+
domain: cookie.domain,
|
|
80
|
+
path: cookie.path,
|
|
81
|
+
secure: cookie.secure,
|
|
82
|
+
httpOnly: cookie.httpOnly,
|
|
83
|
+
sameSite: cookie.sameSite,
|
|
84
|
+
...(cookie.expires > 0 ? { expires: cookie.expires } : {}),
|
|
85
|
+
})),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async createIsolatedContext() {
|
|
89
|
+
const { browserContextId } = await this.#requireBrowserCdp().send("Target.createBrowserContext", {});
|
|
90
|
+
return browserContextId;
|
|
91
|
+
}
|
|
92
|
+
async disposeIsolatedContext(browserContextId) {
|
|
93
|
+
await this.#requireBrowserCdp().send("Target.disposeBrowserContext", { browserContextId });
|
|
94
|
+
}
|
|
95
|
+
async groupTabs(pages, title, color) {
|
|
96
|
+
const targetIds = await Promise.all(pages.map((page) => this.#targetIdOf(page)));
|
|
97
|
+
const worker = await this.#tabGroupsWorker();
|
|
98
|
+
await evaluateInWorker(worker, (request) => globalThis.patchromeGroupTabs(request), { targetIds, title, color });
|
|
99
|
+
}
|
|
100
|
+
async describeTabGroups(pages) {
|
|
101
|
+
const targetIds = await Promise.all(pages.map((page) => this.#targetIdOf(page)));
|
|
102
|
+
const worker = await this.#tabGroupsWorker();
|
|
103
|
+
return evaluateInWorker(worker, (request) => globalThis.patchromeDescribeTabGroups(request), { targetIds });
|
|
104
|
+
}
|
|
105
|
+
async readProfileCopy(copyUserDataDir, origins) {
|
|
106
|
+
const reader = await chromium.launchPersistentContext(copyUserDataDir, {
|
|
107
|
+
channel: "chrome",
|
|
108
|
+
headless: true,
|
|
109
|
+
// The everyday profile encrypts cookies with the OS keychain key. Under Playwright's mock keychain Chrome
|
|
110
|
+
// cannot decrypt them and deletes them from the copy.
|
|
111
|
+
ignoreDefaultArgs: ["--use-mock-keychain", "--password-store=basic"],
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await reader.route("**/*", (route) => route.fulfill({
|
|
115
|
+
status: 200,
|
|
116
|
+
contentType: "text/html",
|
|
117
|
+
body: "<!doctype html><title>patchrome import</title>",
|
|
118
|
+
}));
|
|
119
|
+
const cookies = await reader.cookies();
|
|
120
|
+
const page = await reader.newPage();
|
|
121
|
+
const storage = [];
|
|
122
|
+
for (const origin of origins) {
|
|
123
|
+
await page.goto(`${origin}/`, { waitUntil: "commit" });
|
|
124
|
+
storage.push({ origin, ...(await page.evaluate(readOriginStorageInPage, undefined, undefined, true)) });
|
|
125
|
+
}
|
|
126
|
+
return { cookies, origins: storage };
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await reader.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
debuggingEndpoint() {
|
|
133
|
+
return this.#endpoint;
|
|
134
|
+
}
|
|
135
|
+
async openCdpSession(page) {
|
|
136
|
+
return this.#requireContext().newCDPSession(page);
|
|
137
|
+
}
|
|
138
|
+
async startTrace() {
|
|
139
|
+
await this.#requireContext().tracing.start({ screenshots: true, snapshots: true });
|
|
140
|
+
}
|
|
141
|
+
async stopTrace(path) {
|
|
142
|
+
await this.#requireContext().tracing.stop({ path });
|
|
143
|
+
}
|
|
144
|
+
onClosed(listener) {
|
|
145
|
+
this.#closedListeners.push(listener);
|
|
146
|
+
}
|
|
147
|
+
async close() {
|
|
148
|
+
await this.#context?.close();
|
|
149
|
+
}
|
|
150
|
+
// context.newPage() activates Chrome on macOS; a background CDP target does not. The unique about:blank
|
|
151
|
+
// fragment ties the `page` event back to this call when several sessions open tabs at once.
|
|
152
|
+
async #openTarget(isBackground, browserContextId) {
|
|
153
|
+
const context = this.#requireContext();
|
|
154
|
+
const marker = `about:blank#patchrome-${randomUUID()}`;
|
|
155
|
+
const pagePromise = context.waitForEvent("page", {
|
|
156
|
+
predicate: (page) => page.url() === marker,
|
|
157
|
+
timeout: pageOpenTimeoutMs,
|
|
158
|
+
});
|
|
159
|
+
await this.#requireBrowserCdp().send("Target.createTarget", {
|
|
160
|
+
url: marker,
|
|
161
|
+
background: isBackground,
|
|
162
|
+
...(browserContextId === undefined ? {} : { browserContextId }),
|
|
163
|
+
});
|
|
164
|
+
return pagePromise;
|
|
165
|
+
}
|
|
166
|
+
// The extension API names tabs by its own ids; CDP target ids are the only id both sides share.
|
|
167
|
+
#targetIdOf(page) {
|
|
168
|
+
let targetId = this.#targetIdByPage.get(page);
|
|
169
|
+
if (targetId === undefined) {
|
|
170
|
+
targetId = this.openCdpSession(page).then(async (cdp) => {
|
|
171
|
+
try {
|
|
172
|
+
return (await cdp.send("Target.getTargetInfo")).targetInfo.targetId;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await cdp.detach().catch(() => { });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
this.#targetIdByPage.set(page, targetId);
|
|
179
|
+
}
|
|
180
|
+
return targetId;
|
|
181
|
+
}
|
|
182
|
+
async #tabGroupsWorker() {
|
|
183
|
+
const context = this.#requireContext();
|
|
184
|
+
const prefix = `chrome-extension://${this.#tabGroupsExtensionId}/`;
|
|
185
|
+
const isTabGroupsWorker = (worker) => worker.url().startsWith(prefix);
|
|
186
|
+
return (context.serviceWorkers().find(isTabGroupsWorker) ??
|
|
187
|
+
context.waitForEvent("serviceworker", { predicate: isTabGroupsWorker, timeout: extensionWorkerWaitMs }));
|
|
188
|
+
}
|
|
189
|
+
#requireContext() {
|
|
190
|
+
if (!this.#context)
|
|
191
|
+
throw new Error("engine not launched");
|
|
192
|
+
return this.#context;
|
|
193
|
+
}
|
|
194
|
+
#requireBrowserCdp() {
|
|
195
|
+
if (!this.#browserCdp)
|
|
196
|
+
throw new Error("engine not launched");
|
|
197
|
+
return this.#browserCdp;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Port 0 lets Chrome pick a free port, so two debug profiles never collide. Chrome binds it to 127.0.0.1.
|
|
201
|
+
// The extension flag lets Extensions.loadUnpacked load the tab groups extension over the launch pipe; it
|
|
202
|
+
// opens no port.
|
|
203
|
+
function launchArgsFor(mode) {
|
|
204
|
+
const extensionLoading = "--enable-unsafe-extension-debugging";
|
|
205
|
+
switch (mode) {
|
|
206
|
+
case "stealth":
|
|
207
|
+
return [extensionLoading];
|
|
208
|
+
case "debug":
|
|
209
|
+
return [extensionLoading, "--remote-debugging-port=0"];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Patchright evaluates in an isolated world by default, where the extension's globals do not exist.
|
|
213
|
+
function evaluateInWorker(worker, fn, arg) {
|
|
214
|
+
// oxlint-disable-next-line typescript/unbound-method -- called with worker as this on the next line
|
|
215
|
+
const evaluate = worker.evaluate;
|
|
216
|
+
return evaluate.call(worker, fn, arg, false);
|
|
217
|
+
}
|
|
218
|
+
// Chrome writes the chosen port and the browser target path to DevToolsActivePort once it listens.
|
|
219
|
+
async function readDevToolsEndpoint(chromeProfileDir) {
|
|
220
|
+
const deadlineMs = Date.now() + devToolsPortWaitMs;
|
|
221
|
+
while (Date.now() < deadlineMs) {
|
|
222
|
+
const content = await readFile(join(chromeProfileDir, "DevToolsActivePort"), "utf8").catch(() => "");
|
|
223
|
+
const [port, browserPath] = content.split("\n");
|
|
224
|
+
if (port && browserPath)
|
|
225
|
+
return { httpUrl: `http://127.0.0.1:${port}`, browserWsUrl: `ws://127.0.0.1:${port}${browserPath}` };
|
|
226
|
+
await sleep(100);
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`Chrome wrote no DevToolsActivePort in ${chromeProfileDir} within ${devToolsPortWaitMs} ms`);
|
|
229
|
+
}
|
|
230
|
+
function playwrightCookieOf(cookie) {
|
|
231
|
+
return {
|
|
232
|
+
name: cookie.name,
|
|
233
|
+
value: cookie.value,
|
|
234
|
+
domain: cookie.domain,
|
|
235
|
+
path: cookie.path,
|
|
236
|
+
expires: cookie.session ? -1 : cookie.expires,
|
|
237
|
+
httpOnly: cookie.httpOnly,
|
|
238
|
+
secure: cookie.secure,
|
|
239
|
+
sameSite: cookie.sameSite ?? "Lax",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// The same match context.cookies(urls) applies: host within the cookie domain, path prefix, https for secure.
|
|
243
|
+
function cookieAppliesTo(cookie, url) {
|
|
244
|
+
const parsed = new URL(url);
|
|
245
|
+
const domain = cookie.domain.replace(/^\./, "");
|
|
246
|
+
const isHostMatch = parsed.hostname === domain || (cookie.domain.startsWith(".") && parsed.hostname.endsWith(`.${domain}`));
|
|
247
|
+
const path = parsed.pathname || "/";
|
|
248
|
+
const isPathMatch = path === cookie.path ||
|
|
249
|
+
path.startsWith(cookie.path.endsWith("/") ? cookie.path : `${cookie.path}/`) ||
|
|
250
|
+
cookie.path === "/";
|
|
251
|
+
return (isHostMatch &&
|
|
252
|
+
isPathMatch &&
|
|
253
|
+
(!cookie.secure ||
|
|
254
|
+
parsed.protocol === "https:" ||
|
|
255
|
+
parsed.hostname === "127.0.0.1" ||
|
|
256
|
+
parsed.hostname === "localhost"));
|
|
257
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ConsoleMessage, type PageError } from "./diagnostics.ts";
|
|
2
|
+
import type { NetworkEntry } from "./network.ts";
|
|
3
|
+
import type { ProfileMode } from "./profile-mode.ts";
|
|
4
|
+
export declare const watchEventKinds: readonly ["navigation", "load", "response", "console", "error"];
|
|
5
|
+
export type WatchEventKind = (typeof watchEventKinds)[number];
|
|
6
|
+
export type WatchEvent = {
|
|
7
|
+
kind: "navigation";
|
|
8
|
+
tabId: string;
|
|
9
|
+
url: string;
|
|
10
|
+
atMs: number;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "load";
|
|
13
|
+
tabId: string;
|
|
14
|
+
url: string;
|
|
15
|
+
atMs: number;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "response";
|
|
18
|
+
tabId: string;
|
|
19
|
+
entry: NetworkEntry;
|
|
20
|
+
atMs: number;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "console";
|
|
23
|
+
tabId: string;
|
|
24
|
+
message: ConsoleMessage;
|
|
25
|
+
atMs: number;
|
|
26
|
+
} | {
|
|
27
|
+
kind: "error";
|
|
28
|
+
tabId: string;
|
|
29
|
+
pageError: PageError;
|
|
30
|
+
atMs: number;
|
|
31
|
+
};
|
|
32
|
+
export declare function eventKindsFor(mode: ProfileMode): readonly WatchEventKind[];
|
|
33
|
+
export declare function parseWatchEventKinds(raw: string | undefined, mode: ProfileMode): WatchEventKind[];
|
|
34
|
+
export declare function urlOfEvent(event: WatchEvent): string | undefined;
|
|
35
|
+
export declare function watchEventLine(event: WatchEvent): string;
|
|
36
|
+
export declare function watchEventFields(event: WatchEvent): Record<string, unknown>;
|
|
37
|
+
export declare class SessionEvents {
|
|
38
|
+
#private;
|
|
39
|
+
publish(session: string, event: WatchEvent): void;
|
|
40
|
+
subscribe(session: string, listener: (event: WatchEvent) => void): () => void;
|
|
41
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { consoleLine } from "./diagnostics.js";
|
|
2
|
+
import { CommandError } from "./protocol.js";
|
|
3
|
+
export const watchEventKinds = ["navigation", "load", "response", "console", "error"];
|
|
4
|
+
// Console and page errors come from the Runtime domain, which only a debug profile turns on.
|
|
5
|
+
export function eventKindsFor(mode) {
|
|
6
|
+
switch (mode) {
|
|
7
|
+
case "stealth":
|
|
8
|
+
return ["navigation", "load", "response"];
|
|
9
|
+
case "debug":
|
|
10
|
+
return watchEventKinds;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function parseWatchEventKinds(raw, mode) {
|
|
14
|
+
const available = eventKindsFor(mode);
|
|
15
|
+
if (raw === undefined)
|
|
16
|
+
return [...available];
|
|
17
|
+
const kinds = raw.split(",").map((part) => part.trim());
|
|
18
|
+
for (const kind of kinds) {
|
|
19
|
+
if (!watchEventKinds.includes(kind)) {
|
|
20
|
+
throw new CommandError("bad_args", `--events ${kind} is not an event`, `use any of ${watchEventKinds.join(", ")}`);
|
|
21
|
+
}
|
|
22
|
+
if (!available.includes(kind)) {
|
|
23
|
+
throw new CommandError("unsupported_in_stealth", `watch --events ${kind} needs a debug profile`, "stealth profiles keep Runtime off so sites cannot detect them; run with --profile debug");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return kinds;
|
|
27
|
+
}
|
|
28
|
+
// The URL an event is about, which `watch --url` filters on.
|
|
29
|
+
export function urlOfEvent(event) {
|
|
30
|
+
switch (event.kind) {
|
|
31
|
+
case "navigation":
|
|
32
|
+
case "load":
|
|
33
|
+
return event.url;
|
|
34
|
+
case "response":
|
|
35
|
+
return event.entry.url;
|
|
36
|
+
case "console":
|
|
37
|
+
return event.message.url;
|
|
38
|
+
case "error":
|
|
39
|
+
return event.pageError.url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function watchEventLine(event) {
|
|
43
|
+
switch (event.kind) {
|
|
44
|
+
case "navigation":
|
|
45
|
+
return `navigation ${event.tabId} ${event.url}`;
|
|
46
|
+
case "load":
|
|
47
|
+
return `load ${event.tabId} ${event.url}`;
|
|
48
|
+
case "response": {
|
|
49
|
+
const { entry } = event;
|
|
50
|
+
const status = entry.state === "failed" ? `failed(${entry.failure})` : String(entry.status ?? "pending");
|
|
51
|
+
return `response ${entry.id} ${entry.tabId} ${status} ${entry.resourceType} ${entry.method} ${entry.url}${entry.durationMs === undefined ? "" : ` ${entry.durationMs}ms`}`;
|
|
52
|
+
}
|
|
53
|
+
case "console":
|
|
54
|
+
return `console ${consoleLine(event.message)}`;
|
|
55
|
+
case "error":
|
|
56
|
+
return `error ${event.pageError.id} ${event.tabId} ${event.pageError.message}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function watchEventFields(event) {
|
|
60
|
+
switch (event.kind) {
|
|
61
|
+
case "navigation":
|
|
62
|
+
case "load":
|
|
63
|
+
return { kind: event.kind, tab: event.tabId, url: event.url, atMs: event.atMs };
|
|
64
|
+
case "response": {
|
|
65
|
+
const { entry } = event;
|
|
66
|
+
return {
|
|
67
|
+
kind: event.kind,
|
|
68
|
+
tab: event.tabId,
|
|
69
|
+
id: entry.id,
|
|
70
|
+
method: entry.method,
|
|
71
|
+
url: entry.url,
|
|
72
|
+
type: entry.resourceType,
|
|
73
|
+
state: entry.state,
|
|
74
|
+
status: entry.status,
|
|
75
|
+
durationMs: entry.durationMs,
|
|
76
|
+
failure: entry.failure,
|
|
77
|
+
atMs: event.atMs,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
case "console":
|
|
81
|
+
return { kind: event.kind, tab: event.tabId, ...event.message };
|
|
82
|
+
case "error":
|
|
83
|
+
return { kind: event.kind, tab: event.tabId, ...event.pageError };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Fans page events out to the `watch` streams of the session that owns the tab.
|
|
87
|
+
export class SessionEvents {
|
|
88
|
+
#listeners = new Map();
|
|
89
|
+
publish(session, event) {
|
|
90
|
+
for (const listener of this.#listeners.get(session) ?? [])
|
|
91
|
+
listener(event);
|
|
92
|
+
}
|
|
93
|
+
subscribe(session, listener) {
|
|
94
|
+
let listeners = this.#listeners.get(session);
|
|
95
|
+
if (!listeners) {
|
|
96
|
+
listeners = new Set();
|
|
97
|
+
this.#listeners.set(session, listeners);
|
|
98
|
+
}
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => {
|
|
101
|
+
listeners.delete(listener);
|
|
102
|
+
if (listeners.size === 0 && this.#listeners.get(session) === listeners)
|
|
103
|
+
this.#listeners.delete(session);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const extractSchemaSchema: z.ZodObject<{
|
|
3
|
+
rows: z.ZodOptional<z.ZodString>;
|
|
4
|
+
fields: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<{
|
|
5
|
+
selector: string;
|
|
6
|
+
attr: undefined;
|
|
7
|
+
all: boolean;
|
|
8
|
+
}, string>>, z.ZodPipe<z.ZodObject<{
|
|
9
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
10
|
+
attr: z.ZodOptional<z.ZodString>;
|
|
11
|
+
all: z.ZodOptional<z.ZodBoolean>;
|
|
12
|
+
}, z.core.$strict>, z.ZodTransform<{
|
|
13
|
+
selector: string | undefined;
|
|
14
|
+
attr: string | undefined;
|
|
15
|
+
all: boolean;
|
|
16
|
+
}, {
|
|
17
|
+
selector?: string | undefined;
|
|
18
|
+
attr?: string | undefined;
|
|
19
|
+
all?: boolean | undefined;
|
|
20
|
+
}>>]>>;
|
|
21
|
+
limit: z.ZodOptional<z.ZodInt>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
export type ExtractSchema = z.infer<typeof extractSchemaSchema>;
|
|
24
|
+
export type FieldSpec = ExtractSchema["fields"][string];
|
|
25
|
+
export declare function parseExtractSchema(raw: string): ExtractSchema;
|
|
26
|
+
export declare function extractRowsInPage(root: Element, schema: ExtractSchema): Array<Record<string, unknown>>;
|
|
27
|
+
export {};
|
package/dist/extract.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { parseJsonInput } from "./validate.js";
|
|
3
|
+
const fieldSpecSchema = z.union([
|
|
4
|
+
z.string().transform((selector) => ({ selector, attr: undefined, all: false })),
|
|
5
|
+
z
|
|
6
|
+
.strictObject({
|
|
7
|
+
selector: z.string().optional(),
|
|
8
|
+
attr: z.string().optional(),
|
|
9
|
+
all: z.boolean().optional(),
|
|
10
|
+
})
|
|
11
|
+
.transform(({ selector, attr, all }) => ({ selector, attr, all: all === true })),
|
|
12
|
+
], { error: "must be a selector string or an object with selector, attr, all" });
|
|
13
|
+
const extractSchemaSchema = z.strictObject({
|
|
14
|
+
rows: z.string().min(1, "must be a CSS selector string").optional(),
|
|
15
|
+
fields: z
|
|
16
|
+
.record(z.string(), fieldSpecSchema)
|
|
17
|
+
.refine((fields) => Object.keys(fields).length > 0, "needs at least one field"),
|
|
18
|
+
limit: z.int().positive().optional(),
|
|
19
|
+
});
|
|
20
|
+
const schemaHint = `{"rows": "li.item", "fields": {"title": "h2", "link": {"selector": "a", "attr": "href"}, "tags": {"selector": ".tag", "all": true}}}`;
|
|
21
|
+
// Validated in the daemon before it touches the page, so a typo names the field instead of returning nulls.
|
|
22
|
+
export function parseExtractSchema(raw) {
|
|
23
|
+
return parseJsonInput(extractSchemaSchema, raw, "extract schema", schemaHint);
|
|
24
|
+
}
|
|
25
|
+
// Runs inside the page, so it can only use what it is passed. A missing element gives null; href and src
|
|
26
|
+
// resolve against the document so rows carry absolute URLs.
|
|
27
|
+
export function extractRowsInPage(root, schema) {
|
|
28
|
+
const rowElements = schema.rows === undefined ? [root] : [...root.querySelectorAll(schema.rows)];
|
|
29
|
+
const limited = schema.limit === undefined ? rowElements : rowElements.slice(0, schema.limit);
|
|
30
|
+
const valueOf = (element, spec) => {
|
|
31
|
+
if (spec.attr === undefined) {
|
|
32
|
+
const text = element instanceof HTMLElement ? element.innerText : element.textContent;
|
|
33
|
+
return (text ?? "").replace(/\s+/g, " ").trim();
|
|
34
|
+
}
|
|
35
|
+
const attribute = element.getAttribute(spec.attr);
|
|
36
|
+
if (attribute === null)
|
|
37
|
+
return null;
|
|
38
|
+
if (spec.attr === "href" || spec.attr === "src") {
|
|
39
|
+
try {
|
|
40
|
+
return new URL(attribute, element.ownerDocument.baseURI).href;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return attribute;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return attribute;
|
|
47
|
+
};
|
|
48
|
+
return limited.map((row) => {
|
|
49
|
+
const record = {};
|
|
50
|
+
for (const [name, spec] of Object.entries(schema.fields)) {
|
|
51
|
+
if (spec.all) {
|
|
52
|
+
const matches = spec.selector === undefined ? [row] : [...row.querySelectorAll(spec.selector)];
|
|
53
|
+
record[name] = matches.map((element) => valueOf(element, spec)).filter((value) => value !== null);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const element = spec.selector === undefined ? row : row.querySelector(spec.selector);
|
|
57
|
+
record[name] = element === null ? null : valueOf(element, spec);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return record;
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/focus.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function keepFocusDuring<T>(launch: Promise<T>, graceMs: number): Promise<T>;
|
package/dist/focus.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
const chromeBundleId = "com.google.Chrome";
|
|
5
|
+
const pollIntervalMs = 50;
|
|
6
|
+
async function frontmostBundleId() {
|
|
7
|
+
try {
|
|
8
|
+
const { stdout } = await run("/bin/zsh", ["-c", "lsappinfo info -only bundleid $(lsappinfo front)"]);
|
|
9
|
+
return stdout.match(/="([^"]+)"/)?.[1];
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// macOS activates Chrome when its first window appears, so keystrokes meant for the user's editor land in
|
|
16
|
+
// the address bar. Chrome has no flag to launch in the background, and --no-startup-window stalls Playwright,
|
|
17
|
+
// so this watches the launch and reactivates whichever app was in front. Chrome can activate more than once
|
|
18
|
+
// while its first window settles, so the watch keeps going until the grace period ends. `open -b` needs no Apple Events
|
|
19
|
+
// permission, unlike osascript.
|
|
20
|
+
export async function keepFocusDuring(launch, graceMs) {
|
|
21
|
+
if (process.platform !== "darwin")
|
|
22
|
+
return launch;
|
|
23
|
+
const previous = await frontmostBundleId();
|
|
24
|
+
if (previous === undefined || previous === chromeBundleId)
|
|
25
|
+
return launch;
|
|
26
|
+
let isWatching = true;
|
|
27
|
+
void (async () => {
|
|
28
|
+
// oxlint-disable-next-line eslint/no-unmodified-loop-condition -- the grace timer in the finally below clears it
|
|
29
|
+
while (isWatching) {
|
|
30
|
+
if ((await frontmostBundleId()) === chromeBundleId) {
|
|
31
|
+
await run("open", ["-b", previous]).catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
try {
|
|
37
|
+
return await launch;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
isWatching = false;
|
|
42
|
+
}, graceMs);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/glob.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function urlGlobMatches(glob: string, url: string): boolean;
|
|
2
|
+
export declare function isNamePattern(input: string): boolean;
|
|
3
|
+
export declare function nameGlobMatches(pattern: string, name: string): boolean;
|
|
4
|
+
export declare function parseStatusFilter(raw: string): (status: number | undefined) => boolean;
|
package/dist/glob.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CommandError } from "./protocol.js";
|
|
2
|
+
// One glob dialect for `network list --url` and `route`: `*` matches any run of characters, `?` one
|
|
3
|
+
// character, and the glob must match the whole URL. Playwright's own globs treat `/` specially, which
|
|
4
|
+
// surprises agents writing `*api*`.
|
|
5
|
+
export function urlGlobMatches(glob, url) {
|
|
6
|
+
return globToRegExp(glob).test(url);
|
|
7
|
+
}
|
|
8
|
+
const compiled = new Map();
|
|
9
|
+
function globToRegExp(glob) {
|
|
10
|
+
let pattern = compiled.get(glob);
|
|
11
|
+
if (!pattern) {
|
|
12
|
+
// oxlint-disable-next-line typescript/no-misused-spread -- code points are the unit a glob's ? matches
|
|
13
|
+
const source = [...glob]
|
|
14
|
+
.map((char) => (char === "*" ? ".*" : char === "?" ? "." : char.replace(/[.+^${}()|[\]\\]/g, "\\$&")))
|
|
15
|
+
.join("");
|
|
16
|
+
pattern = new RegExp(`^${source}$`, "s");
|
|
17
|
+
compiled.set(glob, pattern);
|
|
18
|
+
}
|
|
19
|
+
return pattern;
|
|
20
|
+
}
|
|
21
|
+
// Session names take shell-style patterns: `*`, `?` and `[abc]`. A name without those characters is
|
|
22
|
+
// literal, so `session close work-1` never closes `work-10`.
|
|
23
|
+
export function isNamePattern(input) {
|
|
24
|
+
return /[*?[]/.test(input);
|
|
25
|
+
}
|
|
26
|
+
export function nameGlobMatches(pattern, name) {
|
|
27
|
+
if (!isNamePattern(pattern))
|
|
28
|
+
return pattern === name;
|
|
29
|
+
let source = "";
|
|
30
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
31
|
+
const char = pattern[index] ?? "";
|
|
32
|
+
const classEnd = char === "[" ? pattern.indexOf("]", index + 2) : -1;
|
|
33
|
+
if (char === "*")
|
|
34
|
+
source += ".*";
|
|
35
|
+
else if (char === "?")
|
|
36
|
+
source += ".";
|
|
37
|
+
else if (classEnd !== -1) {
|
|
38
|
+
const members = pattern.slice(index + 1, classEnd);
|
|
39
|
+
source += members.startsWith("!") ? `[^${escapeClass(members.slice(1))}]` : `[${escapeClass(members)}]`;
|
|
40
|
+
index = classEnd;
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
44
|
+
}
|
|
45
|
+
return new RegExp(`^${source}$`, "s").test(name);
|
|
46
|
+
}
|
|
47
|
+
function escapeClass(members) {
|
|
48
|
+
return members.replace(/[\\\]^]/g, "\\$&");
|
|
49
|
+
}
|
|
50
|
+
// `--status 404`, `--status 4xx`, `--status 2xx,304`.
|
|
51
|
+
export function parseStatusFilter(raw) {
|
|
52
|
+
const parts = raw.split(",").map((part) => part.trim().toLowerCase());
|
|
53
|
+
const checks = parts.map((part) => {
|
|
54
|
+
if (/^[1-5]xx$/.test(part)) {
|
|
55
|
+
const hundred = Number(part[0]) * 100;
|
|
56
|
+
return (status) => status >= hundred && status < hundred + 100;
|
|
57
|
+
}
|
|
58
|
+
if (/^[1-5]\d\d$/.test(part))
|
|
59
|
+
return (status) => status === Number(part);
|
|
60
|
+
throw new CommandError("bad_args", `--status takes codes like 404, 4xx or 2xx,304, got ${raw}`);
|
|
61
|
+
});
|
|
62
|
+
return (status) => status !== undefined && checks.some((check) => check(status));
|
|
63
|
+
}
|