@dikshanty94/webnav 0.2.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 (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
@@ -0,0 +1,163 @@
1
+ // MCP wrapper (Phase 5): serve webnav's verbs as Model Context Protocol tools
2
+ // over stdio, so MCP-native agents see them without shelling out. A THIN layer:
3
+ // tools are GENERATED from the cli-spec registry (one tool per verb — the spec
4
+ // stays the single source of truth) and every call executes the real CLI, so
5
+ // the CLI remains primary and the two surfaces cannot drift.
6
+ //
7
+ // Deliberate exception to the one-JSON-object-stdout rule: in `webnav mcp`
8
+ // server mode, stdout carries newline-delimited JSON-RPC 2.0 messages (the MCP
9
+ // stdio transport). Diagnostics still go to stderr.
10
+ import { execFile } from 'node:child_process';
11
+ import { createInterface } from 'node:readline';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { join } from 'node:path';
14
+ import { CONSUMER_COMMANDS, DEV_COMMANDS, VERSION } from '../cli-spec.js';
15
+ // Long-running server modes can't be request/response tools.
16
+ const EXCLUDED = new Set(['dashboard', 'mcp']);
17
+ const PROTOCOL_VERSION = '2025-06-18';
18
+ // `--to-cluster` → schema property `to_cluster` (and back, in argvFor).
19
+ const propName = (flag) => flag.replace(/^--/, '').replace(/-/g, '_');
20
+ const flagName = (prop) => '--' + prop.replace(/_/g, '-');
21
+ function toolFor(spec, dev) {
22
+ const properties = {};
23
+ const required = [];
24
+ for (const a of spec.args) {
25
+ properties[a.name] = { type: 'string', description: a.description };
26
+ if (a.required)
27
+ required.push(a.name);
28
+ }
29
+ for (const f of spec.flags) {
30
+ const p = propName(f.name);
31
+ if (f.name === '--input') {
32
+ // Repeatable slot=value pairs (walk's runtime-only inputs).
33
+ properties[p] = { type: 'array', items: { type: 'string' }, description: f.description };
34
+ }
35
+ else {
36
+ properties[p] = {
37
+ type: f.takesValue ? 'string' : 'boolean',
38
+ description: f.description + (f.default !== undefined ? ` (default: ${f.default})` : ''),
39
+ };
40
+ }
41
+ }
42
+ return {
43
+ name: 'webnav_' + (dev ? 'dev_' : '') + spec.name.replace(/-/g, '_'),
44
+ description: spec.summary + ' Example: ' + spec.example,
45
+ inputSchema: { type: 'object', properties, required },
46
+ spec,
47
+ dev,
48
+ };
49
+ }
50
+ function verbTools() {
51
+ return [
52
+ ...CONSUMER_COMMANDS.filter((c) => !EXCLUDED.has(c.name)).map((c) => toolFor(c, false)),
53
+ ...DEV_COMMANDS.filter((c) => !EXCLUDED.has(c.name)).map((c) => toolFor(c, true)),
54
+ ];
55
+ }
56
+ /** The MCP tool list (schema only — what tools/list returns). */
57
+ export function mcpTools() {
58
+ return verbTools().map(({ spec: _s, dev: _d, ...tool }) => tool);
59
+ }
60
+ /** Rebuild the CLI argv for a tool call: positionals in spec order, then flags. */
61
+ export function argvFor(toolName, args) {
62
+ const tool = verbTools().find((t) => t.name === toolName);
63
+ if (!tool)
64
+ throw new Error(`unknown tool: ${toolName}`);
65
+ const argv = tool.dev ? ['dev', tool.spec.name] : [tool.spec.name];
66
+ for (const a of tool.spec.args) {
67
+ const v = args[a.name];
68
+ if (v !== undefined && v !== null)
69
+ argv.push(String(v));
70
+ }
71
+ for (const f of tool.spec.flags) {
72
+ const v = args[propName(f.name)];
73
+ if (v === undefined || v === null)
74
+ continue;
75
+ if (f.name === '--input') {
76
+ for (const pair of Array.isArray(v) ? v : [v])
77
+ argv.push('--input', String(pair));
78
+ }
79
+ else if (!f.takesValue) {
80
+ if (v === true || v === 'true')
81
+ argv.push(f.name);
82
+ }
83
+ else {
84
+ argv.push(flagName(propName(f.name)), String(v));
85
+ }
86
+ }
87
+ return argv;
88
+ }
89
+ const ROOT = fileURLToPath(new URL('../..', import.meta.url));
90
+ export const cliExecutor = (argv) => new Promise((resolve) => {
91
+ const child = execFile(join(ROOT, 'node_modules', '.bin', 'tsx'), [join(ROOT, 'src', 'cli.ts'), ...argv], { maxBuffer: 64 * 1024 * 1024 }, (_err, stdout, stderr) => {
92
+ if (stderr)
93
+ process.stderr.write(stderr);
94
+ resolve({ stdout, code: child.exitCode ?? 0 });
95
+ });
96
+ });
97
+ /** Handle one JSON-RPC message; null = notification (no response). */
98
+ export async function handleMcpMessage(msg, exec = cliExecutor) {
99
+ const id = msg.id ?? null;
100
+ const reply = (result) => ({ jsonrpc: '2.0', id, result });
101
+ const fail = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
102
+ switch (msg.method) {
103
+ case 'initialize':
104
+ return reply({
105
+ protocolVersion: msg.params?.protocolVersion ?? PROTOCOL_VERSION,
106
+ capabilities: { tools: {} },
107
+ serverInfo: { name: 'webnav', version: VERSION },
108
+ });
109
+ case 'notifications/initialized':
110
+ return null;
111
+ case 'ping':
112
+ return reply({});
113
+ case 'tools/list':
114
+ return reply({ tools: mcpTools() });
115
+ case 'tools/call': {
116
+ const name = msg.params?.name;
117
+ const args = msg.params?.arguments ?? {};
118
+ let argv;
119
+ try {
120
+ argv = argvFor(name, args);
121
+ }
122
+ catch (e) {
123
+ return fail(-32602, e.message);
124
+ }
125
+ const { stdout, code } = await exec(argv);
126
+ // Exit 2 = error; exit 3 = ran-fine-but-empty (a valid result, not an error).
127
+ return reply({
128
+ content: [{ type: 'text', text: stdout.trim() || '{}' }],
129
+ isError: code === 2,
130
+ });
131
+ }
132
+ default:
133
+ return id === null ? null : fail(-32601, `method not found: ${msg.method}`);
134
+ }
135
+ }
136
+ /** stdio loop: one JSON-RPC message per line in, one per line out. */
137
+ export function startMcpServer(exec = cliExecutor) {
138
+ return new Promise((resolve) => {
139
+ const rl = createInterface({ input: process.stdin });
140
+ let pending = Promise.resolve();
141
+ rl.on('line', (line) => {
142
+ if (!line.trim())
143
+ return;
144
+ let msg;
145
+ try {
146
+ msg = JSON.parse(line);
147
+ }
148
+ catch {
149
+ process.stdout.write(JSON.stringify({
150
+ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' },
151
+ }) + '\n');
152
+ return;
153
+ }
154
+ // Serialize handling so responses keep request order.
155
+ pending = pending.then(async () => {
156
+ const res = await handleMcpMessage(msg, exec);
157
+ if (res)
158
+ process.stdout.write(JSON.stringify(res) + '\n');
159
+ });
160
+ });
161
+ rl.on('close', () => { void pending.then(resolve); });
162
+ });
163
+ }
@@ -0,0 +1,10 @@
1
+ export declare function webnavDir(): string;
2
+ /** The shared map DB path. Honors WEBNAV_DB (override / tests); otherwise the
3
+ * per-user ~/.webnav/webnav.db. Ensures ~/.webnav exists for a real file path
4
+ * (an in-memory ':memory:' override needs no directory). */
5
+ export declare function dbPath(): string;
6
+ /** Non-secret CLI config (the hosted-route API key + optional API base URL) at
7
+ * ~/.webnav/config.json. SEPARATE from credentials.json (site logins) and the
8
+ * map DB — this only holds the hosted-service key, never site credentials.
9
+ * Honors WEBNAV_CONFIG (tests). */
10
+ export declare function configPath(): string;
package/dist/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ // Single source of truth for where webnav keeps its STATE on a laptop.
5
+ //
6
+ // The map (SQLite) is a per-USER resource, not per-directory: every session and
7
+ // every agent on the machine should share ONE map regardless of which folder it
8
+ // runs webnav from. So the DB lives at ~/.webnav/webnav.db (next to the creds
9
+ // file at ~/.webnav/credentials.json), NOT as a CWD-relative 'webnav.db' — the
10
+ // latter silently gave each working directory its own map ("works in one
11
+ // terminal, empty in another"). WEBNAV_DB overrides (tests pass ':memory:').
12
+ export function webnavDir() {
13
+ const dir = join(homedir(), '.webnav');
14
+ if (!existsSync(dir))
15
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
16
+ return dir;
17
+ }
18
+ /** The shared map DB path. Honors WEBNAV_DB (override / tests); otherwise the
19
+ * per-user ~/.webnav/webnav.db. Ensures ~/.webnav exists for a real file path
20
+ * (an in-memory ':memory:' override needs no directory). */
21
+ export function dbPath() {
22
+ const override = process.env.WEBNAV_DB;
23
+ if (override)
24
+ return override;
25
+ return join(webnavDir(), 'webnav.db');
26
+ }
27
+ /** Non-secret CLI config (the hosted-route API key + optional API base URL) at
28
+ * ~/.webnav/config.json. SEPARATE from credentials.json (site logins) and the
29
+ * map DB — this only holds the hosted-service key, never site credentials.
30
+ * Honors WEBNAV_CONFIG (tests). */
31
+ export function configPath() {
32
+ return process.env.WEBNAV_CONFIG ?? join(webnavDir(), 'config.json');
33
+ }
@@ -0,0 +1,62 @@
1
+ export type RunFn = (args: string[]) => Promise<string>;
2
+ export type ReadFileFn = (path: string) => string;
3
+ export interface BrowserOpts {
4
+ headed?: boolean;
5
+ persistent?: boolean;
6
+ profile?: string;
7
+ browser?: string;
8
+ configPath?: string;
9
+ }
10
+ export declare function wireSessionName(name: string): string;
11
+ export declare class PlaywrightAdapter {
12
+ private session;
13
+ private run;
14
+ private readFile;
15
+ private opts;
16
+ callCount: number;
17
+ constructor(session: string, run?: RunFn, readFile?: ReadFileFn, opts?: BrowserOpts);
18
+ private exec;
19
+ /** The `open`-only launch flags from BrowserOpts (headed/persistent/profile/browser/config). */
20
+ private openFlags;
21
+ open(url: string): Promise<string>;
22
+ goto(url: string): Promise<string>;
23
+ click(ref: string): Promise<string>;
24
+ rightClick(ref: string): Promise<string>;
25
+ fill(ref: string, text: string): Promise<string>;
26
+ type(text: string): Promise<string>;
27
+ press(key: string): Promise<string>;
28
+ hover(ref: string): Promise<string>;
29
+ evalJs(func: string, ref?: string): Promise<string>;
30
+ network(): Promise<string>;
31
+ goBack(): Promise<string>;
32
+ reload(): Promise<string>;
33
+ waitFor(condition: string): Promise<string>;
34
+ close(): Promise<string>;
35
+ /** Fire an action on a ref (alias for click — the agent decides what to fire). */
36
+ act(ref: string): Promise<undefined>;
37
+ /** Current page URL. Extracts the value from playwright-cli's `### Result`
38
+ * wrapper (if present) and strips the surrounding quotes off the scalar. */
39
+ currentUrl(): Promise<string>;
40
+ /** Returns the snapshot YAML content (reads the file path printed by the CLI). */
41
+ snapshot(): Promise<string>;
42
+ /** Session video: start/stop recording of the driven window. stop() writes to the
43
+ * given filename (playwright-cli --filename) — best-effort, never throws. */
44
+ videoStart(): Promise<void>;
45
+ videoStop(filename: string): Promise<boolean>;
46
+ /** Screenshot the current page; returns the .png path playwright-cli printed, or
47
+ * null if none was found (callers treat shots as optional decoration). */
48
+ screenshot(): Promise<string | null>;
49
+ /**
50
+ * Snapshot, but RETRY until the page is `ready` (a JS-SPA renders after first paint, so an
51
+ * immediate snapshot catches an unfinished shell). Re-snapshots up
52
+ * to `tries` times, `gapMs` apart, returning as soon as `classifyReadiness === 'ready'`;
53
+ * returns the last snapshot if the budget is exhausted (so the caller still classifies it —
54
+ * a genuine interstitial/bot-wall is surfaced, never evaded). The one-shot verbs (read /
55
+ * eval-on-page / search visits) use THIS instead of a bare snapshot.
56
+ */
57
+ snapshotReady(tries?: number, gapMs?: number): Promise<string>;
58
+ }
59
+ /** A --profile value is either an absolute/relative PATH (has a slash) or a bare
60
+ * session NAME → ~/.webnav/profiles/<name> (the dir the dashboard's persistent
61
+ * recording writes; a hand-done login/2FA there carries into every walk). Pure. */
62
+ export declare function resolveProfile(value: string, profilesRoot: string): string;
@@ -0,0 +1,145 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { promisify } from 'node:util';
4
+ import { throttleOpen } from './throttle.js';
5
+ import { classifyReadiness } from '../router/readiness.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const defaultRun = async (args) => {
8
+ const { stdout } = await execFileAsync('playwright-cli', args, { maxBuffer: 10 * 1024 * 1024 });
9
+ return stdout;
10
+ };
11
+ // macOS caps a unix-socket path (sun_path) at 104 bytes. playwright-cli's socket is
12
+ // $TMPDIR/playwright-cli/<16-char-hash>/<session>.sock and the darwin TMPDIR prefix is
13
+ // ~82 chars — so a session NAME over ~16 chars can hit `listen EINVAL` (live crash:
14
+ // 'replay-report-builder', 21 chars). Deterministic cap: same input → same wire name,
15
+ // so reattach through this adapter keeps working; names ≤16 are untouched (every seed/
16
+ // walk/record name in use today).
17
+ const WIRE_MAX = 16;
18
+ // djb2 xor variant — cheap, deterministic, good-enough spread for a 6-hex-digit tag.
19
+ export function wireSessionName(name) {
20
+ if (name.length <= WIRE_MAX)
21
+ return name;
22
+ let h = 5381;
23
+ for (let i = 0; i < name.length; i++)
24
+ h = ((h * 33) ^ name.charCodeAt(i)) >>> 0;
25
+ const head = name.slice(0, 9).replace(/[^\w.-]/g, '_');
26
+ return head + '-' + h.toString(16).padStart(6, '0').slice(0, 6);
27
+ }
28
+ export class PlaywrightAdapter {
29
+ session;
30
+ run;
31
+ readFile;
32
+ opts;
33
+ callCount = 0;
34
+ constructor(session, run = defaultRun, readFile = (p) => readFileSync(p, 'utf8'), opts = { headed: true }) {
35
+ this.session = session;
36
+ this.run = run;
37
+ this.readFile = readFile;
38
+ this.opts = opts;
39
+ this.session = wireSessionName(session);
40
+ }
41
+ async exec(...args) {
42
+ this.callCount++;
43
+ return this.run([`-s=${this.session}`, ...args]);
44
+ }
45
+ /** The `open`-only launch flags from BrowserOpts (headed/persistent/profile/browser/config). */
46
+ openFlags() {
47
+ const f = [];
48
+ if (this.opts.headed)
49
+ f.push('--headed');
50
+ if (this.opts.persistent)
51
+ f.push('--persistent');
52
+ if (this.opts.profile)
53
+ f.push('--profile', this.opts.profile);
54
+ if (this.opts.browser)
55
+ f.push('--browser', this.opts.browser);
56
+ // --config launches the window MAXIMIZED (viewport follows the window). Headed only:
57
+ // headless has no window to maximize and wants a fixed viewport, so skip it there.
58
+ if (this.opts.configPath && this.opts.headed)
59
+ f.push('--config', this.opts.configPath);
60
+ return f;
61
+ }
62
+ // open/goto are the NEW-client / explicit-jump page loads — gate them with the per-host
63
+ // politeness throttle so a burst can't hammer one site. Intra-session clicks (below) are
64
+ // NOT throttled: a held session navigating its own pages isn't a new client.
65
+ async open(url) { await throttleOpen(url); return this.exec('open', url, ...this.openFlags()); }
66
+ async goto(url) { await throttleOpen(url); return this.exec('goto', url); }
67
+ click(ref) { return this.exec('click', ref); }
68
+ rightClick(ref) { return this.exec('click', ref, 'right'); }
69
+ fill(ref, text) { return this.exec('fill', ref, text); }
70
+ type(text) { return this.exec('type', text); }
71
+ press(key) { return this.exec('press', key); }
72
+ hover(ref) { return this.exec('hover', ref); }
73
+ evalJs(func, ref) { return this.exec('eval', func, ...(ref ? [ref] : [])); }
74
+ network() { return this.exec('network'); }
75
+ goBack() { return this.exec('go-back'); }
76
+ reload() { return this.exec('reload'); }
77
+ waitFor(condition) { return this.exec('wait-for', condition); }
78
+ close() { return this.exec('close'); }
79
+ /** Fire an action on a ref (alias for click — the agent decides what to fire). */
80
+ act(ref) { return this.click(ref).then(() => undefined); }
81
+ /** Current page URL. Extracts the value from playwright-cli's `### Result`
82
+ * wrapper (if present) and strips the surrounding quotes off the scalar. */
83
+ async currentUrl() {
84
+ const raw = await this.evalJs('() => location.href');
85
+ const m = raw.match(/###\s*Result\s*\n([\s\S]*?)(?:\n###|\s*$)/);
86
+ const body = (m ? m[1] : raw).trim();
87
+ return body.replace(/^"|"$/g, '');
88
+ }
89
+ /** Returns the snapshot YAML content (reads the file path printed by the CLI). */
90
+ async snapshot() {
91
+ const out = await this.exec('snapshot');
92
+ const m = out.match(/\(([^)]+\.yml)\)/);
93
+ if (!m)
94
+ throw new Error('snapshot: could not find YAML path in CLI output');
95
+ return this.readFile(m[1]);
96
+ }
97
+ /** Session video: start/stop recording of the driven window. stop() writes to the
98
+ * given filename (playwright-cli --filename) — best-effort, never throws. */
99
+ async videoStart() { await this.exec('video-start'); } // caller logs failure
100
+ async videoStop(filename) {
101
+ try {
102
+ await this.exec('video-stop', '--filename', filename);
103
+ return true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ /** Screenshot the current page; returns the .png path playwright-cli printed, or
110
+ * null if none was found (callers treat shots as optional decoration). */
111
+ async screenshot() {
112
+ try {
113
+ const out = await this.exec('screenshot');
114
+ const m = out.match(/\(([^)]+\.png)\)/) ?? out.match(/(\/\S+\.png)/);
115
+ return m ? m[1] : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Snapshot, but RETRY until the page is `ready` (a JS-SPA renders after first paint, so an
123
+ * immediate snapshot catches an unfinished shell). Re-snapshots up
124
+ * to `tries` times, `gapMs` apart, returning as soon as `classifyReadiness === 'ready'`;
125
+ * returns the last snapshot if the budget is exhausted (so the caller still classifies it —
126
+ * a genuine interstitial/bot-wall is surfaced, never evaded). The one-shot verbs (read /
127
+ * eval-on-page / search visits) use THIS instead of a bare snapshot.
128
+ */
129
+ async snapshotReady(tries = 6, gapMs = 800) {
130
+ let snap = await this.snapshot();
131
+ for (let i = 0; i < tries && classifyReadiness(snap) === 'loading'; i++) {
132
+ await new Promise((r) => setTimeout(r, gapMs));
133
+ snap = await this.snapshot();
134
+ }
135
+ return snap;
136
+ }
137
+ }
138
+ /** A --profile value is either an absolute/relative PATH (has a slash) or a bare
139
+ * session NAME → ~/.webnav/profiles/<name> (the dir the dashboard's persistent
140
+ * recording writes; a hand-done login/2FA there carries into every walk). Pure. */
141
+ export function resolveProfile(value, profilesRoot) {
142
+ if (value.includes('/'))
143
+ return value;
144
+ return profilesRoot.replace(/\/$/, '') + '/' + value.replace(/[^\w.-]/g, '_');
145
+ }
@@ -0,0 +1,33 @@
1
+ import type { SnapNode } from './snapshot.js';
2
+ export interface AXNode {
3
+ nodeId: string;
4
+ ignored?: boolean;
5
+ role?: {
6
+ value: string;
7
+ };
8
+ name?: {
9
+ value: string;
10
+ };
11
+ properties?: {
12
+ name: string;
13
+ value: {
14
+ value?: unknown;
15
+ };
16
+ }[];
17
+ childIds?: string[];
18
+ parentId?: string;
19
+ backendDOMNodeId?: number;
20
+ }
21
+ /** AX tree (flat, childIds-linked) → SnapNode[] in document order, depth = nesting.
22
+ * Every emitted node gets a synthetic ref (b1,b2,…) — playwright refs every node too,
23
+ * and cross-resolve compares fingerprints (role+name+near), never the ref strings. */
24
+ export declare function adaptAXTree(nodes: AXNode[]): SnapNode[];
25
+ /** AX tree → both SnapNode[] and a Map<bN ref, {nodeId, backendDOMNodeId}> for CDP driving.
26
+ * The nodes are identical to adaptAXTree; the refMap enables clicking via ref. */
27
+ export declare function adaptAXTreeWithRefs(nodes: AXNode[]): {
28
+ nodes: SnapNode[];
29
+ refMap: Map<string, {
30
+ nodeId: string;
31
+ backendDOMNodeId?: number;
32
+ }>;
33
+ };
@@ -0,0 +1,76 @@
1
+ // CDP roles playwright folds away (text merged into parent names, or structural/
2
+ // presentational wrappers) or that are the document root. Dropping these makes the
3
+ // two producers structurally comparable.
4
+ //
5
+ // `option` and `MenuListPopup` are dropped deliberately, not just for parity: a native
6
+ // <select>'s options are a VALUE DOMAIN (the choices a user could pick), and the map
7
+ // stores structure, never data values (CLAUDE.md "map stores structure not values").
8
+ // Surfacing them would leak instance data into the skeleton the same way an option
9
+ // list or instance heading would.
10
+ const DROP = new Set([
11
+ 'RootWebArea', 'StaticText', 'InlineTextBox', 'LineBreak', 'none', 'presentation',
12
+ 'option', 'MenuListPopup', 'LabelText', 'Legend',
13
+ ]);
14
+ // CDP role token → playwright/ARIA vocabulary. Chromium already returns ARIA-canonical
15
+ // tokens for checkbox/radio/switch/combobox/menu/menuitem/menuitemcheckbox/button —
16
+ // verified empirically against tests/fixtures/ax/rich.ax.json — so `image`→`img` is the
17
+ // only translation needed.
18
+ const ROLE_MAP = { image: 'img' };
19
+ function prop(n, name) {
20
+ return n.properties?.find((p) => p.name === name)?.value?.value;
21
+ }
22
+ /** Internal walk function shared by adaptAXTree and adaptAXTreeWithRefs.
23
+ * Traverses the AX tree and emits SnapNode[], optionally building a refMap.
24
+ */
25
+ function walkAXTree(nodes, collectRefs = false) {
26
+ const byId = new Map(nodes.map((n) => [n.nodeId, n]));
27
+ const root = nodes.find((n) => !n.parentId) ?? nodes[0];
28
+ const out = [];
29
+ const refMap = collectRefs ? new Map() : null;
30
+ let ref = 0;
31
+ const visit = (id, depth) => {
32
+ const n = byId.get(id);
33
+ if (!n)
34
+ return;
35
+ const rawRole = n.role?.value ?? '';
36
+ const kept = !n.ignored && rawRole && !DROP.has(rawRole);
37
+ let childDepth = depth;
38
+ if (kept) {
39
+ const role = ROLE_MAP[rawRole] ?? rawRole;
40
+ const nm = (n.name?.value ?? '').trim();
41
+ const url = prop(n, 'url');
42
+ const bRef = 'b' + (++ref);
43
+ out.push({
44
+ role,
45
+ name: nm || null,
46
+ ref: bRef,
47
+ url: typeof url === 'string' && url ? url : null,
48
+ raw: `${role}${nm ? ` "${nm}"` : ''} [ref=${bRef}]`,
49
+ depth,
50
+ });
51
+ if (refMap) {
52
+ refMap.set(bRef, { nodeId: n.nodeId, backendDOMNodeId: n.backendDOMNodeId });
53
+ }
54
+ childDepth = depth + 1;
55
+ }
56
+ for (const c of n.childIds ?? [])
57
+ visit(c, childDepth);
58
+ };
59
+ visit(root.nodeId, 0);
60
+ return { nodes: out, refMap };
61
+ }
62
+ /** AX tree (flat, childIds-linked) → SnapNode[] in document order, depth = nesting.
63
+ * Every emitted node gets a synthetic ref (b1,b2,…) — playwright refs every node too,
64
+ * and cross-resolve compares fingerprints (role+name+near), never the ref strings. */
65
+ export function adaptAXTree(nodes) {
66
+ return walkAXTree(nodes, false).nodes;
67
+ }
68
+ /** AX tree → both SnapNode[] and a Map<bN ref, {nodeId, backendDOMNodeId}> for CDP driving.
69
+ * The nodes are identical to adaptAXTree; the refMap enables clicking via ref. */
70
+ export function adaptAXTreeWithRefs(nodes) {
71
+ const result = walkAXTree(nodes, true);
72
+ return {
73
+ nodes: result.nodes,
74
+ refMap: result.refMap,
75
+ };
76
+ }
@@ -0,0 +1,2 @@
1
+ /** Dev helper: open a URL and save its snapshot YAML to `out` (spec §6 fixtures). */
2
+ export declare function capture(url: string, out: string): Promise<void>;
@@ -0,0 +1,10 @@
1
+ import { PlaywrightAdapter } from './adapter.js';
2
+ /** Dev helper: open a URL and save its snapshot YAML to `out` (spec §6 fixtures). */
3
+ export async function capture(url, out) {
4
+ const a = new PlaywrightAdapter(`capture-${Date.now()}`);
5
+ await a.open(url);
6
+ const yml = await a.snapshot();
7
+ const { writeFileSync } = await import('node:fs');
8
+ writeFileSync(out, yml);
9
+ await a.close();
10
+ }
@@ -0,0 +1,40 @@
1
+ import type { SnapNode } from './snapshot.js';
2
+ export interface ElementFingerprint {
3
+ role: string;
4
+ name: string | null;
5
+ testId?: string | null;
6
+ placeholder?: string | null;
7
+ near?: string | null;
8
+ }
9
+ /** Given a `near` text, which of `candidateIdxs` have it inside their clean anchor scope. */
10
+ export declare function resolveByNear(nodes: SnapNode[], candidateIdxs: number[], near: string): number[];
11
+ /** Stability score for a candidate `near` text (HIGHER = more durable). Prefers anchors
12
+ * that survive content edits/i18n: +3 id-like (3+ digit run), +0..1 length, -2 free-text
13
+ * prose (space + no digit). Pure, deterministic. */
14
+ export declare function nearStability(text: string): number;
15
+ /**
16
+ * Choose WHICH text to store as `near` for a target candidate (the matcher's twin —
17
+ * used at BOTH record-time and step-5 heal so they never diverge). Collect every
18
+ * text in the candidate's clean scope that UNIQUELY resolves to it, then pick the MOST
19
+ * STABLE (nearStability desc; doc order tiebreak). null = honest "can't make unique" flag
20
+ * (a truly content-identical sibling) → the caller escalates.
21
+ */
22
+ export declare function deriveNear(nodes: SnapNode[], candIdx: number, role: string, name: string | null): string | null;
23
+ /**
24
+ * Recover a durable ElementFingerprint for the element at `ref` in a live snapshot —
25
+ * role + accessible name + (when the element has same-(role,name) siblings) a `near`
26
+ * content anchor. Used at record-time (the clicked element) and at heal-time (the
27
+ * agent-chosen element) so both produce the same durable key. Returns null if the ref
28
+ * isn't in the snapshot.
29
+ */
30
+ export declare function recoverFingerprint(nodes: SnapNode[], ref: string): ElementFingerprint | null;
31
+ /**
32
+ * Resolve an ElementFingerprint to a live element ref, deterministically (zero-LLM).
33
+ * Strict order:
34
+ * 1. testId (when present) + role match → unique? return it. (Inert in v1: a11y snapshots
35
+ * carry no testid; testId never overrides the durable role+name key.)
36
+ * 2. candidates = role==fp.role AND name==fp.name. 1 → return; 0 → null (drift); >1 → step 3.
37
+ * 3. fp.near present → resolveByNear; exactly 1 → return; else null (escalate).
38
+ * Returns null on no/ambiguous match — the caller escalates to the agent (#5a).
39
+ */
40
+ export declare function resolveByFingerprint(fp: ElementFingerprint, nodes: SnapNode[]): string | null;