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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const shellCommands = new Set([
|
|
3
|
+
"sh",
|
|
4
|
+
"bash",
|
|
5
|
+
"zsh",
|
|
6
|
+
"fish",
|
|
7
|
+
"dash",
|
|
8
|
+
"ksh",
|
|
9
|
+
"nu",
|
|
10
|
+
"env",
|
|
11
|
+
"node",
|
|
12
|
+
"npx",
|
|
13
|
+
"npm",
|
|
14
|
+
"pnpm",
|
|
15
|
+
"bun",
|
|
16
|
+
]);
|
|
17
|
+
// Claude Code and Codex run each tool call in a fresh shell, so the direct parent pid changes per command.
|
|
18
|
+
// The first ancestor that is not a shell or a Node launcher is the agent or terminal, and outlives the call.
|
|
19
|
+
export function resolveSessionName(env, startPid, lookup) {
|
|
20
|
+
if (env.PATCHROME_SESSION)
|
|
21
|
+
return env.PATCHROME_SESSION;
|
|
22
|
+
if (env.CLAUDE_CODE_SESSION_ID)
|
|
23
|
+
return `claude-${env.CLAUDE_CODE_SESSION_ID}`;
|
|
24
|
+
let pid = startPid;
|
|
25
|
+
for (let depth = 0; depth < 32 && pid > 1; depth++) {
|
|
26
|
+
const info = lookup(pid);
|
|
27
|
+
if (!info)
|
|
28
|
+
break;
|
|
29
|
+
const commandName = info.command.split("/").pop()?.replace(/^-/, "") ?? "";
|
|
30
|
+
const hasTty = info.tty !== "" && info.tty !== "??" && info.tty !== "?";
|
|
31
|
+
if (hasTty)
|
|
32
|
+
return `tty-${info.tty.replace(/^\/dev\//, "")}`;
|
|
33
|
+
if (!shellCommands.has(commandName))
|
|
34
|
+
return `pid-${info.pid}`;
|
|
35
|
+
pid = info.ppid;
|
|
36
|
+
}
|
|
37
|
+
return `pid-${startPid}`;
|
|
38
|
+
}
|
|
39
|
+
export function lookupProcess(pid) {
|
|
40
|
+
try {
|
|
41
|
+
const line = execFileSync("ps", ["-o", "pid=,ppid=,tty=,comm=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
42
|
+
const match = line.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
43
|
+
if (!match)
|
|
44
|
+
return undefined;
|
|
45
|
+
return { pid: Number(match[1]), ppid: Number(match[2]), tty: match[3] ?? "", command: match[4] ?? "" };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SavedSession } from "./sessions.ts";
|
|
2
|
+
export declare const sessionRetentionMs: number;
|
|
3
|
+
export declare function loadSavedSessions(path: string, nowMs: number): Promise<SavedSession[]>;
|
|
4
|
+
export declare function saveSessions(path: string, sessions: SavedSession[], nowMs: number): Promise<void>;
|
|
5
|
+
export declare function pruneSessionFolders(sessionsDir: string, nowMs: number): Promise<string[]>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
// Session folders and saved sessions untouched this long are dropped when the daemon starts.
|
|
5
|
+
export const sessionRetentionMs = 7 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const savedSessionSchema = z.object({
|
|
7
|
+
name: z.string(),
|
|
8
|
+
isIsolated: z.boolean(),
|
|
9
|
+
label: z.string().optional(),
|
|
10
|
+
currentTabId: z.string().optional(),
|
|
11
|
+
tabs: z.array(z.object({ id: z.string().regex(/^t\d+$/), url: z.string() })),
|
|
12
|
+
});
|
|
13
|
+
// One bad entry drops that session, not the whole file.
|
|
14
|
+
const sessionsFileSchema = z.object({
|
|
15
|
+
savedAtMs: z.number(),
|
|
16
|
+
sessions: z.array(z.unknown()),
|
|
17
|
+
});
|
|
18
|
+
// A missing, stale or unreadable file restores nothing: restore is best-effort and never blocks a start.
|
|
19
|
+
export async function loadSavedSessions(path, nowMs) {
|
|
20
|
+
const raw = await readFile(path, "utf8").catch(() => undefined);
|
|
21
|
+
if (raw === undefined)
|
|
22
|
+
return [];
|
|
23
|
+
try {
|
|
24
|
+
const parsed = sessionsFileSchema.safeParse(JSON.parse(raw));
|
|
25
|
+
if (!parsed.success || nowMs - parsed.data.savedAtMs > sessionRetentionMs)
|
|
26
|
+
return [];
|
|
27
|
+
return parsed.data.sessions.flatMap((entry) => {
|
|
28
|
+
const session = savedSessionSchema.safeParse(entry);
|
|
29
|
+
if (!session.success)
|
|
30
|
+
return [];
|
|
31
|
+
const { label, currentTabId } = session.data;
|
|
32
|
+
return [{ ...session.data, label, currentTabId }];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Writes through a temp file and a rename, so a daemon killed mid-write leaves the previous file intact.
|
|
40
|
+
export async function saveSessions(path, sessions, nowMs) {
|
|
41
|
+
const body = { savedAtMs: nowMs, sessions };
|
|
42
|
+
const partialPath = `${path}.partial`;
|
|
43
|
+
await writeFile(partialPath, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
44
|
+
await rename(partialPath, path);
|
|
45
|
+
}
|
|
46
|
+
export async function pruneSessionFolders(sessionsDir, nowMs) {
|
|
47
|
+
const names = await readdir(sessionsDir).catch(() => []);
|
|
48
|
+
const pruned = [];
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
const folder = join(sessionsDir, name);
|
|
51
|
+
const info = await stat(folder).catch(() => undefined);
|
|
52
|
+
if (!info?.isDirectory() || nowMs - info.mtimeMs <= sessionRetentionMs)
|
|
53
|
+
continue;
|
|
54
|
+
await rm(folder, { recursive: true, force: true });
|
|
55
|
+
pruned.push(name);
|
|
56
|
+
}
|
|
57
|
+
return pruned;
|
|
58
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Page } from "patchright";
|
|
2
|
+
import { SnapshotGenerations } from "./refs.ts";
|
|
3
|
+
export interface Tab {
|
|
4
|
+
id: string;
|
|
5
|
+
session: string;
|
|
6
|
+
page: Page;
|
|
7
|
+
generations: SnapshotGenerations;
|
|
8
|
+
isClosed: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface SavedSession {
|
|
11
|
+
name: string;
|
|
12
|
+
isIsolated: boolean;
|
|
13
|
+
label: string | undefined;
|
|
14
|
+
currentTabId: string | undefined;
|
|
15
|
+
tabs: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
url: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
export interface TabSummary {
|
|
21
|
+
id: string;
|
|
22
|
+
session: string;
|
|
23
|
+
url: string;
|
|
24
|
+
isCurrent: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type TrackedPage = Pick<Page, "on" | "url" | "mainFrame">;
|
|
27
|
+
export declare class SessionRegistry {
|
|
28
|
+
#private;
|
|
29
|
+
constructor(onTabAdopted?: (tab: Tab) => void, onChanged?: () => void);
|
|
30
|
+
adoptPage(sessionName: string, page: TrackedPage, makeCurrent: boolean, restoredTabId?: string): Tab;
|
|
31
|
+
currentTab(sessionName: string): Tab;
|
|
32
|
+
ownedTab(sessionName: string, tabId: string): Tab;
|
|
33
|
+
switchTo(sessionName: string, tabId: string): Tab;
|
|
34
|
+
tabsOf(sessionName: string): TabSummary[];
|
|
35
|
+
allTabs(): TabSummary[];
|
|
36
|
+
openTabsOf(sessionName: string): Tab[];
|
|
37
|
+
forget(sessionName: string): void;
|
|
38
|
+
reserveTabIds(tabIds: string[]): void;
|
|
39
|
+
hasSession(sessionName: string): boolean;
|
|
40
|
+
browserContextOf(sessionName: string): string | undefined;
|
|
41
|
+
isolate(sessionName: string, browserContextId: string): void;
|
|
42
|
+
labelOf(sessionName: string): string | undefined;
|
|
43
|
+
setLabel(sessionName: string, label: string | undefined): void;
|
|
44
|
+
savedSessions(): SavedSession[];
|
|
45
|
+
originsOf(sessionName: string): string[];
|
|
46
|
+
sessionNames(): string[];
|
|
47
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { CommandError } from "./protocol.js";
|
|
2
|
+
import { SnapshotGenerations } from "./refs.js";
|
|
3
|
+
export class SessionRegistry {
|
|
4
|
+
#sessions = new Map();
|
|
5
|
+
#tabs = new Map();
|
|
6
|
+
#nextTabNumber = 1;
|
|
7
|
+
#onTabAdopted;
|
|
8
|
+
#onChanged;
|
|
9
|
+
// The daemon hangs network recording and routes off onTabAdopted, so popups get them too, and saves
|
|
10
|
+
// sessions.json on onChanged.
|
|
11
|
+
constructor(onTabAdopted = () => { }, onChanged = () => { }) {
|
|
12
|
+
this.#onTabAdopted = onTabAdopted;
|
|
13
|
+
this.#onChanged = onChanged;
|
|
14
|
+
}
|
|
15
|
+
// A restored tab keeps the id it had before the daemon restarted, so an agent's `switch t3` still works.
|
|
16
|
+
adoptPage(sessionName, page, makeCurrent, restoredTabId) {
|
|
17
|
+
const session = this.#sessionFor(sessionName);
|
|
18
|
+
const restoredNumber = restoredTabId === undefined ? undefined : Number(restoredTabId.slice(1));
|
|
19
|
+
if (restoredNumber !== undefined)
|
|
20
|
+
this.#nextTabNumber = Math.max(this.#nextTabNumber, restoredNumber + 1);
|
|
21
|
+
const tab = {
|
|
22
|
+
id: restoredTabId ?? `t${this.#nextTabNumber++}`,
|
|
23
|
+
session: sessionName,
|
|
24
|
+
page: page,
|
|
25
|
+
generations: new SnapshotGenerations(),
|
|
26
|
+
isClosed: false,
|
|
27
|
+
};
|
|
28
|
+
this.#tabs.set(tab.id, tab);
|
|
29
|
+
session.tabIds.add(tab.id);
|
|
30
|
+
if (makeCurrent)
|
|
31
|
+
session.currentTabId = tab.id;
|
|
32
|
+
page.on("close", () => {
|
|
33
|
+
tab.isClosed = true;
|
|
34
|
+
session.tabIds.delete(tab.id);
|
|
35
|
+
this.#tabs.delete(tab.id);
|
|
36
|
+
this.#onChanged();
|
|
37
|
+
});
|
|
38
|
+
page.on("framenavigated", (frame) => {
|
|
39
|
+
if (frame !== page.mainFrame())
|
|
40
|
+
return;
|
|
41
|
+
tab.generations.recordNavigation();
|
|
42
|
+
const origin = originOf(page.url());
|
|
43
|
+
if (origin !== undefined)
|
|
44
|
+
session.origins.add(origin);
|
|
45
|
+
this.#onChanged();
|
|
46
|
+
});
|
|
47
|
+
// A popup belongs to the session whose page opened it, and does not become current by itself.
|
|
48
|
+
page.on("popup", (popup) => {
|
|
49
|
+
this.adoptPage(sessionName, popup, false);
|
|
50
|
+
});
|
|
51
|
+
this.#onTabAdopted(tab);
|
|
52
|
+
this.#onChanged();
|
|
53
|
+
return tab;
|
|
54
|
+
}
|
|
55
|
+
currentTab(sessionName) {
|
|
56
|
+
const session = this.#sessions.get(sessionName);
|
|
57
|
+
const tabId = session?.currentTabId;
|
|
58
|
+
if (!session || tabId === undefined) {
|
|
59
|
+
throw new CommandError("tab_gone", `session ${sessionName} has no current tab`, "run `patchrome open <url>`");
|
|
60
|
+
}
|
|
61
|
+
const tab = this.#tabs.get(tabId);
|
|
62
|
+
if (!tab || tab.isClosed) {
|
|
63
|
+
throw new CommandError("tab_gone", `tab ${tabId} was closed`, "run `patchrome open <url>` for a new tab");
|
|
64
|
+
}
|
|
65
|
+
return tab;
|
|
66
|
+
}
|
|
67
|
+
ownedTab(sessionName, tabId) {
|
|
68
|
+
const tab = this.#tabs.get(tabId);
|
|
69
|
+
if (!tab || tab.isClosed) {
|
|
70
|
+
throw new CommandError("tab_gone", `no open tab ${tabId}`, "run `patchrome tabs` to list this session's tabs");
|
|
71
|
+
}
|
|
72
|
+
if (tab.session !== sessionName) {
|
|
73
|
+
throw new CommandError("bad_args", `tab ${tabId} belongs to another session`, "a session only acts on tabs it opened");
|
|
74
|
+
}
|
|
75
|
+
return tab;
|
|
76
|
+
}
|
|
77
|
+
switchTo(sessionName, tabId) {
|
|
78
|
+
const tab = this.ownedTab(sessionName, tabId);
|
|
79
|
+
this.#sessionFor(sessionName).currentTabId = tab.id;
|
|
80
|
+
this.#onChanged();
|
|
81
|
+
return tab;
|
|
82
|
+
}
|
|
83
|
+
tabsOf(sessionName) {
|
|
84
|
+
const session = this.#sessions.get(sessionName);
|
|
85
|
+
if (!session)
|
|
86
|
+
return [];
|
|
87
|
+
return [...session.tabIds].map((id) => this.#summarize(id, session.currentTabId));
|
|
88
|
+
}
|
|
89
|
+
allTabs() {
|
|
90
|
+
return [...this.#sessions.values()].flatMap((session) => [...session.tabIds].map((id) => this.#summarize(id, session.currentTabId)));
|
|
91
|
+
}
|
|
92
|
+
openTabsOf(sessionName) {
|
|
93
|
+
const session = this.#sessions.get(sessionName);
|
|
94
|
+
if (!session)
|
|
95
|
+
return [];
|
|
96
|
+
return [...session.tabIds]
|
|
97
|
+
.map((id) => this.#tabs.get(id))
|
|
98
|
+
.filter((tab) => tab !== undefined && !tab.isClosed);
|
|
99
|
+
}
|
|
100
|
+
forget(sessionName) {
|
|
101
|
+
for (const tabId of this.#sessions.get(sessionName)?.tabIds ?? [])
|
|
102
|
+
this.#tabs.delete(tabId);
|
|
103
|
+
this.#sessions.delete(sessionName);
|
|
104
|
+
this.#onChanged();
|
|
105
|
+
}
|
|
106
|
+
// Tab ids saved before a restart stay reserved, so a new tab never takes the id of one still to be restored.
|
|
107
|
+
reserveTabIds(tabIds) {
|
|
108
|
+
for (const tabId of tabIds)
|
|
109
|
+
this.#nextTabNumber = Math.max(this.#nextTabNumber, Number(tabId.slice(1)) + 1);
|
|
110
|
+
}
|
|
111
|
+
hasSession(sessionName) {
|
|
112
|
+
return this.#sessions.has(sessionName);
|
|
113
|
+
}
|
|
114
|
+
browserContextOf(sessionName) {
|
|
115
|
+
return this.#sessions.get(sessionName)?.browserContextId;
|
|
116
|
+
}
|
|
117
|
+
isolate(sessionName, browserContextId) {
|
|
118
|
+
this.#sessionFor(sessionName).browserContextId = browserContextId;
|
|
119
|
+
}
|
|
120
|
+
labelOf(sessionName) {
|
|
121
|
+
return this.#sessions.get(sessionName)?.label;
|
|
122
|
+
}
|
|
123
|
+
setLabel(sessionName, label) {
|
|
124
|
+
this.#sessionFor(sessionName).label = label;
|
|
125
|
+
this.#onChanged();
|
|
126
|
+
}
|
|
127
|
+
savedSessions() {
|
|
128
|
+
return [...this.#sessions.values()].map((session) => ({
|
|
129
|
+
name: session.name,
|
|
130
|
+
isIsolated: session.browserContextId !== undefined,
|
|
131
|
+
label: session.label,
|
|
132
|
+
currentTabId: session.currentTabId,
|
|
133
|
+
tabs: [...session.tabIds].map((id) => ({ id, url: this.#tabs.get(id)?.page.url() ?? "" })),
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
// Web origins the session's tabs have loaded, which is what `state save` keeps.
|
|
137
|
+
originsOf(sessionName) {
|
|
138
|
+
return [...(this.#sessions.get(sessionName)?.origins ?? [])];
|
|
139
|
+
}
|
|
140
|
+
sessionNames() {
|
|
141
|
+
return [...this.#sessions.keys()];
|
|
142
|
+
}
|
|
143
|
+
#sessionFor(name) {
|
|
144
|
+
let session = this.#sessions.get(name);
|
|
145
|
+
if (!session) {
|
|
146
|
+
session = {
|
|
147
|
+
name,
|
|
148
|
+
tabIds: new Set(),
|
|
149
|
+
currentTabId: undefined,
|
|
150
|
+
origins: new Set(),
|
|
151
|
+
browserContextId: undefined,
|
|
152
|
+
label: undefined,
|
|
153
|
+
};
|
|
154
|
+
this.#sessions.set(name, session);
|
|
155
|
+
}
|
|
156
|
+
return session;
|
|
157
|
+
}
|
|
158
|
+
#summarize(tabId, currentTabId) {
|
|
159
|
+
const tab = this.#tabs.get(tabId);
|
|
160
|
+
return { id: tabId, session: tab?.session ?? "", url: tab?.page.url() ?? "", isCurrent: tabId === currentTabId };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function originOf(url) {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = new URL(url);
|
|
166
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : undefined;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const tabGroupColors: readonly ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"];
|
|
2
|
+
export type TabGroupColor = (typeof tabGroupColors)[number];
|
|
3
|
+
export interface TabGroupSummary {
|
|
4
|
+
title: string;
|
|
5
|
+
color: TabGroupColor;
|
|
6
|
+
tabCount: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function tabGroupTitleFor(sessionName: string, label: string | undefined): string;
|
|
9
|
+
export declare function tabGroupColorFor(sessionName: string): TabGroupColor;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Chrome's fixed tab group palette.
|
|
2
|
+
export const tabGroupColors = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"];
|
|
3
|
+
// The session name leads, so a glance at the tab strip says which agent owns the tabs.
|
|
4
|
+
export function tabGroupTitleFor(sessionName, label) {
|
|
5
|
+
return label === undefined || label === "" ? sessionName : `${sessionName}: ${label}`;
|
|
6
|
+
}
|
|
7
|
+
// A session keeps its colour across restarts and relabels, because the colour comes from the name alone.
|
|
8
|
+
export function tabGroupColorFor(sessionName) {
|
|
9
|
+
let hash = 0;
|
|
10
|
+
for (const char of sessionName)
|
|
11
|
+
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
12
|
+
return tabGroupColors[hash % tabGroupColors.length] ?? "grey";
|
|
13
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Locator, Page } from "patchright";
|
|
2
|
+
import { type CommandArgs } from "./protocol.ts";
|
|
3
|
+
export declare const ariaRoles: readonly ["alert", "alertdialog", "application", "article", "banner", "blockquote", "button", "caption", "cell", "checkbox", "code", "columnheader", "combobox", "complementary", "contentinfo", "definition", "deletion", "dialog", "directory", "document", "emphasis", "feed", "figure", "form", "generic", "grid", "gridcell", "group", "heading", "img", "insertion", "link", "list", "listbox", "listitem", "log", "main", "marquee", "math", "meter", "menu", "menubar", "menuitem", "menuitemcheckbox", "menuitemradio", "navigation", "none", "note", "option", "paragraph", "presentation", "progressbar", "radio", "radiogroup", "region", "row", "rowgroup", "rowheader", "scrollbar", "search", "searchbox", "separator", "slider", "spinbutton", "status", "strong", "subscript", "superscript", "switch", "tab", "table", "tablist", "tabpanel", "term", "textbox", "time", "timer", "toolbar", "tooltip", "tree", "treegrid", "treeitem"];
|
|
4
|
+
export type AriaRole = (typeof ariaRoles)[number];
|
|
5
|
+
export declare function isAriaRole(value: string): value is AriaRole;
|
|
6
|
+
export interface LocatorScope {
|
|
7
|
+
frame: string | undefined;
|
|
8
|
+
nth: number | undefined;
|
|
9
|
+
}
|
|
10
|
+
export type ElementLocator = ({
|
|
11
|
+
kind: "selector";
|
|
12
|
+
selector: string;
|
|
13
|
+
} & LocatorScope) | ({
|
|
14
|
+
kind: "role";
|
|
15
|
+
role: AriaRole;
|
|
16
|
+
name: string | undefined;
|
|
17
|
+
isExact: boolean;
|
|
18
|
+
} & LocatorScope) | ({
|
|
19
|
+
kind: "text";
|
|
20
|
+
text: string;
|
|
21
|
+
isExact: boolean;
|
|
22
|
+
} & LocatorScope) | ({
|
|
23
|
+
kind: "label";
|
|
24
|
+
label: string;
|
|
25
|
+
isExact: boolean;
|
|
26
|
+
} & LocatorScope);
|
|
27
|
+
export type Target = {
|
|
28
|
+
kind: "ref";
|
|
29
|
+
ref: string;
|
|
30
|
+
} | ElementLocator | {
|
|
31
|
+
kind: "point";
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
};
|
|
35
|
+
export declare function parseTarget(args: CommandArgs, { allowsPoint }: {
|
|
36
|
+
allowsPoint: boolean;
|
|
37
|
+
}): Target;
|
|
38
|
+
export declare function parseElementLocator(args: CommandArgs, hint: string): ElementLocator | undefined;
|
|
39
|
+
export declare function elementLocator(page: Page, target: ElementLocator): Locator;
|
|
40
|
+
export declare function describeTarget(target: Target): string;
|
|
41
|
+
export declare function describeElementLocator(target: ElementLocator): string;
|
|
42
|
+
export declare function clickPoint(page: Page, x: number, y: number): Promise<void>;
|
|
43
|
+
export declare function typeLikeAPerson(page: Page, text: string): Promise<void>;
|
package/dist/targets.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { CommandError } from "./protocol.js";
|
|
2
|
+
// Roles Playwright's getByRole accepts, which are also the roles a snapshot line starts with.
|
|
3
|
+
export const ariaRoles = [
|
|
4
|
+
"alert",
|
|
5
|
+
"alertdialog",
|
|
6
|
+
"application",
|
|
7
|
+
"article",
|
|
8
|
+
"banner",
|
|
9
|
+
"blockquote",
|
|
10
|
+
"button",
|
|
11
|
+
"caption",
|
|
12
|
+
"cell",
|
|
13
|
+
"checkbox",
|
|
14
|
+
"code",
|
|
15
|
+
"columnheader",
|
|
16
|
+
"combobox",
|
|
17
|
+
"complementary",
|
|
18
|
+
"contentinfo",
|
|
19
|
+
"definition",
|
|
20
|
+
"deletion",
|
|
21
|
+
"dialog",
|
|
22
|
+
"directory",
|
|
23
|
+
"document",
|
|
24
|
+
"emphasis",
|
|
25
|
+
"feed",
|
|
26
|
+
"figure",
|
|
27
|
+
"form",
|
|
28
|
+
"generic",
|
|
29
|
+
"grid",
|
|
30
|
+
"gridcell",
|
|
31
|
+
"group",
|
|
32
|
+
"heading",
|
|
33
|
+
"img",
|
|
34
|
+
"insertion",
|
|
35
|
+
"link",
|
|
36
|
+
"list",
|
|
37
|
+
"listbox",
|
|
38
|
+
"listitem",
|
|
39
|
+
"log",
|
|
40
|
+
"main",
|
|
41
|
+
"marquee",
|
|
42
|
+
"math",
|
|
43
|
+
"meter",
|
|
44
|
+
"menu",
|
|
45
|
+
"menubar",
|
|
46
|
+
"menuitem",
|
|
47
|
+
"menuitemcheckbox",
|
|
48
|
+
"menuitemradio",
|
|
49
|
+
"navigation",
|
|
50
|
+
"none",
|
|
51
|
+
"note",
|
|
52
|
+
"option",
|
|
53
|
+
"paragraph",
|
|
54
|
+
"presentation",
|
|
55
|
+
"progressbar",
|
|
56
|
+
"radio",
|
|
57
|
+
"radiogroup",
|
|
58
|
+
"region",
|
|
59
|
+
"row",
|
|
60
|
+
"rowgroup",
|
|
61
|
+
"rowheader",
|
|
62
|
+
"scrollbar",
|
|
63
|
+
"search",
|
|
64
|
+
"searchbox",
|
|
65
|
+
"separator",
|
|
66
|
+
"slider",
|
|
67
|
+
"spinbutton",
|
|
68
|
+
"status",
|
|
69
|
+
"strong",
|
|
70
|
+
"subscript",
|
|
71
|
+
"superscript",
|
|
72
|
+
"switch",
|
|
73
|
+
"tab",
|
|
74
|
+
"table",
|
|
75
|
+
"tablist",
|
|
76
|
+
"tabpanel",
|
|
77
|
+
"term",
|
|
78
|
+
"textbox",
|
|
79
|
+
"time",
|
|
80
|
+
"timer",
|
|
81
|
+
"toolbar",
|
|
82
|
+
"tooltip",
|
|
83
|
+
"tree",
|
|
84
|
+
"treegrid",
|
|
85
|
+
"treeitem",
|
|
86
|
+
];
|
|
87
|
+
export function isAriaRole(value) {
|
|
88
|
+
return ariaRoles.includes(value);
|
|
89
|
+
}
|
|
90
|
+
const targetHint = "pass a ref from the latest snapshot, --role <role> [--name <name>], --text <text>, --label <text>, --selector <css>, or --at <x>,<y>";
|
|
91
|
+
const locatorKinds = ["selector", "role", "text", "label"];
|
|
92
|
+
export function parseTarget(args, { allowsPoint }) {
|
|
93
|
+
const ref = typeof args.ref === "string" ? args.ref : undefined;
|
|
94
|
+
const at = typeof args.at === "string" ? args.at : undefined;
|
|
95
|
+
const locatorNames = locatorKinds.filter((name) => typeof args[name] === "string");
|
|
96
|
+
const given = [ref, at].filter((value) => value !== undefined).length + locatorNames.length;
|
|
97
|
+
if (given !== 1)
|
|
98
|
+
throw new CommandError("bad_args", given === 0 ? "no target given" : "give one target, not several", targetHint);
|
|
99
|
+
if (ref !== undefined || at !== undefined) {
|
|
100
|
+
const stray = ["frame", "nth", "name"].find((name) => args[name] !== undefined) ??
|
|
101
|
+
(args.exact === true ? "exact" : undefined);
|
|
102
|
+
if (stray !== undefined)
|
|
103
|
+
throw new CommandError("bad_args", `--${stray} goes with --role, --text, --label or --selector`, targetHint);
|
|
104
|
+
}
|
|
105
|
+
if (ref !== undefined)
|
|
106
|
+
return { kind: "ref", ref };
|
|
107
|
+
if (at === undefined)
|
|
108
|
+
return parseElementLocator(args, targetHint) ?? unreachable();
|
|
109
|
+
if (!allowsPoint)
|
|
110
|
+
throw new CommandError("bad_args", "--at works with click only", "click the field with --at first, then run `patchrome type <text>`");
|
|
111
|
+
const match = at.match(/^(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)$/);
|
|
112
|
+
if (!match?.[1] || !match[2])
|
|
113
|
+
throw new CommandError("bad_args", `--at takes <x>,<y> in viewport pixels, got ${at}`, "for example --at 120,340");
|
|
114
|
+
return { kind: "point", x: Number(match[1]), y: Number(match[2]) };
|
|
115
|
+
}
|
|
116
|
+
// Reads one of --selector, --role, --text, --label with --name, --exact, --nth and --frame. Undefined when
|
|
117
|
+
// none is given; wait also takes --url, --title and --load, so absence is not an error here.
|
|
118
|
+
export function parseElementLocator(args, hint) {
|
|
119
|
+
const given = locatorKinds.filter((name) => typeof args[name] === "string");
|
|
120
|
+
const [kind] = given;
|
|
121
|
+
const name = typeof args.name === "string" ? args.name : undefined;
|
|
122
|
+
const isExact = args.exact === true;
|
|
123
|
+
const frame = typeof args.frame === "string" ? args.frame : undefined;
|
|
124
|
+
if (kind === undefined) {
|
|
125
|
+
const stray = frame !== undefined
|
|
126
|
+
? "frame"
|
|
127
|
+
: name !== undefined
|
|
128
|
+
? "name"
|
|
129
|
+
: args.nth !== undefined
|
|
130
|
+
? "nth"
|
|
131
|
+
: isExact
|
|
132
|
+
? "exact"
|
|
133
|
+
: undefined;
|
|
134
|
+
if (stray !== undefined)
|
|
135
|
+
throw new CommandError("bad_args", `--${stray} goes with --role, --text, --label or --selector`, hint);
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
if (given.length > 1)
|
|
139
|
+
throw new CommandError("bad_args", `give one of ${given.map((option) => `--${option}`).join(" ")}, not several`, hint);
|
|
140
|
+
const value = String(args[kind]);
|
|
141
|
+
if (value === "" || frame === "")
|
|
142
|
+
throw new CommandError("bad_args", `--${kind}${frame === "" ? " and --frame" : ""} need a non-empty value`, hint);
|
|
143
|
+
if (name !== undefined && kind !== "role")
|
|
144
|
+
throw new CommandError("bad_args", "--name goes with --role", "for example --role button --name 'Sign in'");
|
|
145
|
+
if (isExact && (kind === "selector" || (kind === "role" && name === undefined)))
|
|
146
|
+
throw new CommandError("bad_args", "--exact goes with --name, --text or --label", hint);
|
|
147
|
+
const scope = { frame, nth: parseNth(args.nth) };
|
|
148
|
+
switch (kind) {
|
|
149
|
+
case "selector":
|
|
150
|
+
return { kind, selector: value, ...scope };
|
|
151
|
+
case "role":
|
|
152
|
+
if (!isAriaRole(value))
|
|
153
|
+
throw new CommandError("bad_args", `--role ${value} is not an ARIA role`, `roles: ${ariaRoles.join(" ")}`);
|
|
154
|
+
return { kind, role: value, name, isExact, ...scope };
|
|
155
|
+
case "text":
|
|
156
|
+
return { kind, text: value, isExact, ...scope };
|
|
157
|
+
case "label":
|
|
158
|
+
return { kind, label: value, isExact, ...scope };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function parseNth(raw) {
|
|
162
|
+
if (raw === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
const nth = Number(raw);
|
|
165
|
+
if (!Number.isInteger(nth) || nth < 0)
|
|
166
|
+
throw new CommandError("bad_args", `--nth must be a whole number from 0, got ${String(raw)}`);
|
|
167
|
+
return nth;
|
|
168
|
+
}
|
|
169
|
+
function unreachable() {
|
|
170
|
+
throw new CommandError("bad_args", "no target given", targetHint);
|
|
171
|
+
}
|
|
172
|
+
export function elementLocator(page, target) {
|
|
173
|
+
const root = target.frame === undefined ? page : page.frameLocator(target.frame);
|
|
174
|
+
const matches = (() => {
|
|
175
|
+
switch (target.kind) {
|
|
176
|
+
case "selector":
|
|
177
|
+
return root.locator(target.selector);
|
|
178
|
+
case "role":
|
|
179
|
+
return root.getByRole(target.role, target.name === undefined ? {} : { name: target.name, exact: target.isExact });
|
|
180
|
+
case "text":
|
|
181
|
+
return root.getByText(target.text, { exact: target.isExact });
|
|
182
|
+
case "label":
|
|
183
|
+
return root.getByLabel(target.label, { exact: target.isExact });
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
return target.nth === undefined ? matches.first() : matches.nth(target.nth);
|
|
187
|
+
}
|
|
188
|
+
export function describeTarget(target) {
|
|
189
|
+
switch (target.kind) {
|
|
190
|
+
case "ref":
|
|
191
|
+
return target.ref;
|
|
192
|
+
case "point":
|
|
193
|
+
return `${target.x},${target.y}`;
|
|
194
|
+
case "selector":
|
|
195
|
+
case "role":
|
|
196
|
+
case "text":
|
|
197
|
+
case "label":
|
|
198
|
+
return describeElementLocator(target);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function describeElementLocator(target) {
|
|
202
|
+
const what = (() => {
|
|
203
|
+
switch (target.kind) {
|
|
204
|
+
case "selector":
|
|
205
|
+
return target.selector;
|
|
206
|
+
case "role":
|
|
207
|
+
return target.name === undefined ? target.role : `${target.role} "${target.name}"`;
|
|
208
|
+
case "text":
|
|
209
|
+
return `text "${target.text}"`;
|
|
210
|
+
case "label":
|
|
211
|
+
return `label "${target.label}"`;
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
return `${what}${target.nth === undefined ? "" : ` #${target.nth}`}${target.frame === undefined ? "" : ` in ${target.frame}`}`;
|
|
215
|
+
}
|
|
216
|
+
// A person's pointer travels to the spot, and their keys land tens of milliseconds apart.
|
|
217
|
+
export async function clickPoint(page, x, y) {
|
|
218
|
+
await page.mouse.move(x, y, { steps: 12 });
|
|
219
|
+
await page.mouse.click(x, y, { delay: jitterMs(40, 110) });
|
|
220
|
+
}
|
|
221
|
+
export async function typeLikeAPerson(page, text) {
|
|
222
|
+
for (const character of text) {
|
|
223
|
+
await page.keyboard.type(character);
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, jitterMs(45, 140)));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function jitterMs(minMs, maxMs) {
|
|
228
|
+
return Math.round(minMs + Math.random() * (maxMs - minMs));
|
|
229
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare function parseJsonInput<T extends z.ZodType>(schema: T, raw: string, what: string, hint?: string): z.infer<T>;
|
|
3
|
+
export declare function parseInput<T extends z.ZodType>(schema: T, value: unknown, what: string, hint?: string): z.infer<T>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CommandError } from "./protocol.js";
|
|
3
|
+
// Parses JSON input against a schema. The first problem becomes a bad_args error that names the input and
|
|
4
|
+
// the path inside it, such as `extract schema: fields.a has unknown keys selecter`.
|
|
5
|
+
export function parseJsonInput(schema, raw, what, hint) {
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
throw new CommandError("bad_args", `${what} is not JSON: ${err instanceof Error ? err.message : String(err)}`, hint);
|
|
12
|
+
}
|
|
13
|
+
return parseInput(schema, parsed, what, hint);
|
|
14
|
+
}
|
|
15
|
+
export function parseInput(schema, value, what, hint) {
|
|
16
|
+
const result = schema.safeParse(value);
|
|
17
|
+
if (result.success)
|
|
18
|
+
return result.data;
|
|
19
|
+
throw new CommandError("bad_args", `${what}: ${describeIssue(result.error.issues[0])}`, hint);
|
|
20
|
+
}
|
|
21
|
+
function describeIssue(issue) {
|
|
22
|
+
if (issue === undefined)
|
|
23
|
+
return "is invalid";
|
|
24
|
+
const where = issue.path.length === 0 ? "" : `${issue.path.join(".")} `;
|
|
25
|
+
switch (issue.code) {
|
|
26
|
+
case "unrecognized_keys":
|
|
27
|
+
return `${where}has unknown keys ${issue.keys.join(", ")}`;
|
|
28
|
+
default:
|
|
29
|
+
return `${where}${issue.message}`;
|
|
30
|
+
}
|
|
31
|
+
}
|