gsd-pi 2.7.1 → 2.8.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/README.md +12 -5
- package/dist/loader.js +0 -0
- package/dist/modes/interactive/theme/dark.json +85 -0
- package/dist/modes/interactive/theme/light.json +84 -0
- package/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/dist/modes/interactive/theme/theme.js +949 -0
- package/dist/modes/interactive/theme/theme.js.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/node_modules/cliui/CHANGELOG.md +121 -0
- package/node_modules/color-convert/CHANGELOG.md +54 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/node_modules/mz/HISTORY.md +66 -0
- package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
- package/node_modules/source-map/CHANGELOG.md +301 -0
- package/node_modules/thenify/History.md +11 -0
- package/node_modules/thenify-all/History.md +11 -0
- package/node_modules/y18n/CHANGELOG.md +100 -0
- package/node_modules/yargs/CHANGELOG.md +88 -0
- package/node_modules/yargs-parser/CHANGELOG.md +263 -0
- package/package.json +5 -2
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/src/resources/extensions/browser-tools/capture.ts +165 -0
- package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
- package/src/resources/extensions/browser-tools/index.ts +47 -4985
- package/src/resources/extensions/browser-tools/lifecycle.ts +265 -0
- package/src/resources/extensions/browser-tools/package.json +5 -1
- package/src/resources/extensions/browser-tools/refs.ts +264 -0
- package/src/resources/extensions/browser-tools/settle.ts +197 -0
- package/src/resources/extensions/browser-tools/state.ts +408 -0
- package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
- package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
- package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
- package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
- package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
- package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
- package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
- package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
- package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
- package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
- package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
- package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
- package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
- package/src/resources/extensions/browser-tools/utils.ts +660 -0
- package/src/resources/extensions/gsd/git-service.ts +3 -0
- package/src/resources/extensions/shared/interview-ui.ts +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-tools — browser lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* Manages the shared Browser + BrowserContext + Page singleton.
|
|
5
|
+
* Injects EVALUATE_HELPERS_SOURCE via context.addInitScript() so that
|
|
6
|
+
* page.evaluate() callbacks can reference window.__pi.* utilities.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Browser, BrowserContext, Frame, Page } from "playwright";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
registryAddPage,
|
|
13
|
+
registryGetActive,
|
|
14
|
+
registryRemovePage,
|
|
15
|
+
registrySetActive,
|
|
16
|
+
} from "./core.js";
|
|
17
|
+
import {
|
|
18
|
+
getBrowser,
|
|
19
|
+
setBrowser,
|
|
20
|
+
getContext,
|
|
21
|
+
setContext,
|
|
22
|
+
pageRegistry,
|
|
23
|
+
getActiveFrame,
|
|
24
|
+
setActiveFrame,
|
|
25
|
+
logPusher,
|
|
26
|
+
getConsoleLogs,
|
|
27
|
+
getNetworkLogs,
|
|
28
|
+
getDialogLogs,
|
|
29
|
+
getPendingCriticalRequestsByPage,
|
|
30
|
+
setHarState,
|
|
31
|
+
resetAllState,
|
|
32
|
+
HAR_FILENAME,
|
|
33
|
+
type ConsoleEntry,
|
|
34
|
+
type NetworkEntry,
|
|
35
|
+
} from "./state.js";
|
|
36
|
+
import {
|
|
37
|
+
isCriticalResourceType,
|
|
38
|
+
updatePendingCriticalRequests,
|
|
39
|
+
ensureSessionStartedAt,
|
|
40
|
+
ensureSessionArtifactDir,
|
|
41
|
+
} from "./utils.js";
|
|
42
|
+
import { EVALUATE_HELPERS_SOURCE } from "./evaluate-helpers.js";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Page event wiring
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Attach all event listeners to a page. Called on initial page and new tabs. */
|
|
49
|
+
export function attachPageListeners(p: Page, pageId: number): void {
|
|
50
|
+
const pendingMap = getPendingCriticalRequestsByPage();
|
|
51
|
+
pendingMap.set(p, 0);
|
|
52
|
+
|
|
53
|
+
const consoleLogs = getConsoleLogs();
|
|
54
|
+
const networkLogs = getNetworkLogs();
|
|
55
|
+
const dialogLogs = getDialogLogs();
|
|
56
|
+
|
|
57
|
+
// Console messages
|
|
58
|
+
p.on("console", (msg) => {
|
|
59
|
+
logPusher(consoleLogs, {
|
|
60
|
+
type: msg.type(),
|
|
61
|
+
text: msg.text(),
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
url: p.url(),
|
|
64
|
+
pageId,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Uncaught JS errors
|
|
69
|
+
p.on("pageerror", (err) => {
|
|
70
|
+
logPusher(consoleLogs, {
|
|
71
|
+
type: "pageerror",
|
|
72
|
+
text: err.message,
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
url: p.url(),
|
|
75
|
+
pageId,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Network requests — start/completed/failed
|
|
80
|
+
p.on("request", (request) => {
|
|
81
|
+
if (isCriticalResourceType(request.resourceType())) {
|
|
82
|
+
updatePendingCriticalRequests(p, 1);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
p.on("requestfinished", async (request) => {
|
|
87
|
+
if (isCriticalResourceType(request.resourceType())) {
|
|
88
|
+
updatePendingCriticalRequests(p, -1);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const response = await request.response();
|
|
92
|
+
const status = response?.status() ?? null;
|
|
93
|
+
const entry: NetworkEntry = {
|
|
94
|
+
method: request.method(),
|
|
95
|
+
url: request.url(),
|
|
96
|
+
status,
|
|
97
|
+
resourceType: request.resourceType(),
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
failed: false,
|
|
100
|
+
pageId,
|
|
101
|
+
};
|
|
102
|
+
if (response && status !== null && status >= 400) {
|
|
103
|
+
try {
|
|
104
|
+
const body = await response.text();
|
|
105
|
+
entry.responseBody = body.slice(0, 2000);
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
logPusher(networkLogs, entry);
|
|
109
|
+
} catch {}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
p.on("requestfailed", (request) => {
|
|
113
|
+
if (isCriticalResourceType(request.resourceType())) {
|
|
114
|
+
updatePendingCriticalRequests(p, -1);
|
|
115
|
+
}
|
|
116
|
+
logPusher(networkLogs, {
|
|
117
|
+
method: request.method(),
|
|
118
|
+
url: request.url(),
|
|
119
|
+
status: null,
|
|
120
|
+
resourceType: request.resourceType(),
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
failed: true,
|
|
123
|
+
failureText: request.failure()?.errorText ?? "Unknown failure",
|
|
124
|
+
pageId,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Auto-handle JS dialogs (alert, confirm, prompt, beforeunload)
|
|
129
|
+
p.on("dialog", async (dialog) => {
|
|
130
|
+
logPusher(dialogLogs, {
|
|
131
|
+
type: dialog.type(),
|
|
132
|
+
message: dialog.message(),
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
url: p.url(),
|
|
135
|
+
defaultValue: dialog.defaultValue() || undefined,
|
|
136
|
+
accepted: true,
|
|
137
|
+
pageId,
|
|
138
|
+
});
|
|
139
|
+
// Auto-accept all dialogs to prevent page freezes
|
|
140
|
+
await dialog.accept().catch(() => {});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Frame detach handler — clears activeFrame if the selected frame detaches
|
|
144
|
+
p.on("framedetached", (frame) => {
|
|
145
|
+
if (getActiveFrame() === frame) setActiveFrame(null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Page close handler — removes page from registry and handles active fallback
|
|
149
|
+
p.on("close", () => {
|
|
150
|
+
try {
|
|
151
|
+
registryRemovePage(pageRegistry, pageId);
|
|
152
|
+
} catch {
|
|
153
|
+
// Page already removed (e.g. during closeBrowser)
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Browser lifecycle
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export async function ensureBrowser(): Promise<{ browser: Browser; context: BrowserContext; page: Page }> {
|
|
163
|
+
const existingBrowser = getBrowser();
|
|
164
|
+
const existingContext = getContext();
|
|
165
|
+
if (existingBrowser && existingContext) {
|
|
166
|
+
return { browser: existingBrowser, context: existingContext, page: getActivePage() };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const startedAt = ensureSessionStartedAt();
|
|
170
|
+
const artifactDir = await ensureSessionArtifactDir();
|
|
171
|
+
const sessionHarPath = path.join(artifactDir, HAR_FILENAME);
|
|
172
|
+
setHarState({
|
|
173
|
+
enabled: true,
|
|
174
|
+
configuredAtContextCreation: true,
|
|
175
|
+
path: sessionHarPath,
|
|
176
|
+
exportCount: 0,
|
|
177
|
+
lastExportedPath: null,
|
|
178
|
+
lastExportedAt: null,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Lazy import so playwright is only loaded when actually needed
|
|
182
|
+
const { chromium } = await import("playwright");
|
|
183
|
+
|
|
184
|
+
const launchOptions: Record<string, unknown> = { headless: false };
|
|
185
|
+
const customPath = process.env.BROWSER_PATH;
|
|
186
|
+
if (customPath) launchOptions.executablePath = customPath;
|
|
187
|
+
const browser = await chromium.launch(launchOptions);
|
|
188
|
+
const context = await browser.newContext({
|
|
189
|
+
deviceScaleFactor: 2,
|
|
190
|
+
viewport: { width: 1280, height: 800 },
|
|
191
|
+
recordHar: {
|
|
192
|
+
path: sessionHarPath,
|
|
193
|
+
mode: "minimal",
|
|
194
|
+
content: "omit",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Inject shared browser-side utilities into every new page/frame
|
|
199
|
+
await context.addInitScript(EVALUATE_HELPERS_SOURCE);
|
|
200
|
+
|
|
201
|
+
setBrowser(browser);
|
|
202
|
+
setContext(context);
|
|
203
|
+
|
|
204
|
+
const initialPage = await context.newPage();
|
|
205
|
+
const pageEntry = registryAddPage(pageRegistry, {
|
|
206
|
+
page: initialPage,
|
|
207
|
+
title: await initialPage.title().catch(() => ""),
|
|
208
|
+
url: initialPage.url(),
|
|
209
|
+
opener: null,
|
|
210
|
+
});
|
|
211
|
+
registrySetActive(pageRegistry, pageEntry.id);
|
|
212
|
+
attachPageListeners(initialPage, pageEntry.id);
|
|
213
|
+
|
|
214
|
+
// Register new pages (popups, target="_blank", window.open) but do NOT auto-switch
|
|
215
|
+
context.on("page", (newPage) => {
|
|
216
|
+
// Determine opener page ID — find which registry page opened this one
|
|
217
|
+
const openerPage = newPage.opener();
|
|
218
|
+
let openerId: number | null = null;
|
|
219
|
+
if (openerPage) {
|
|
220
|
+
const openerEntry = pageRegistry.pages.find((e: any) => e.page === openerPage);
|
|
221
|
+
if (openerEntry) openerId = openerEntry.id;
|
|
222
|
+
}
|
|
223
|
+
const entry = registryAddPage(pageRegistry, {
|
|
224
|
+
page: newPage,
|
|
225
|
+
title: "",
|
|
226
|
+
url: newPage.url(),
|
|
227
|
+
opener: openerId,
|
|
228
|
+
});
|
|
229
|
+
attachPageListeners(newPage, entry.id);
|
|
230
|
+
// Update title once loaded
|
|
231
|
+
newPage.waitForLoadState("domcontentloaded", { timeout: 5000 })
|
|
232
|
+
.then(() => newPage.title())
|
|
233
|
+
.then((title) => { entry.title = title; })
|
|
234
|
+
.catch(() => {});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return { browser, context, page: getActivePage() };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Get the currently active page from the registry. */
|
|
241
|
+
export function getActivePage(): Page {
|
|
242
|
+
return registryGetActive(pageRegistry).page;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Get the active target — returns the selected frame if one is active, otherwise the active page. */
|
|
246
|
+
export function getActiveTarget(): Page | Frame {
|
|
247
|
+
return getActiveFrame() ?? getActivePage();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Safe accessor for error handling — returns the active page or null if unavailable. */
|
|
251
|
+
export function getActivePageOrNull(): Page | null {
|
|
252
|
+
try {
|
|
253
|
+
return getActivePage();
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function closeBrowser(): Promise<void> {
|
|
260
|
+
const browser = getBrowser();
|
|
261
|
+
if (browser) {
|
|
262
|
+
await browser.close().catch(() => {});
|
|
263
|
+
}
|
|
264
|
+
resetAllState();
|
|
265
|
+
}
|
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
"extensions": ["./index.ts"]
|
|
11
11
|
},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"playwright": ">=1.40.0"
|
|
13
|
+
"playwright": ">=1.40.0",
|
|
14
|
+
"sharp": ">=0.33.0"
|
|
14
15
|
},
|
|
15
16
|
"peerDependenciesMeta": {
|
|
16
17
|
"playwright": {
|
|
17
18
|
"optional": true
|
|
19
|
+
},
|
|
20
|
+
"sharp": {
|
|
21
|
+
"optional": true
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-tools — ref snapshot and resolution
|
|
3
|
+
*
|
|
4
|
+
* Builds deterministic element snapshots and resolves ref targets.
|
|
5
|
+
* Uses window.__pi.* utilities injected via addInitScript (from
|
|
6
|
+
* evaluate-helpers.ts) instead of redeclaring functions inline.
|
|
7
|
+
*
|
|
8
|
+
* Functions kept inline (not shared/duplicated):
|
|
9
|
+
* - matchesMode, computeNearestHeading, computeFormOwnership
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Frame, Page } from "playwright";
|
|
13
|
+
import type { RefNode } from "./state.js";
|
|
14
|
+
import { getSnapshotModeConfig } from "./core.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// buildRefSnapshot
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export async function buildRefSnapshot(
|
|
21
|
+
target: Page | Frame,
|
|
22
|
+
options: { selector?: string; interactiveOnly: boolean; limit: number; mode?: string },
|
|
23
|
+
): Promise<Array<Omit<RefNode, "ref">>> {
|
|
24
|
+
// Resolve mode config in Node context and serialize it as plain data for the evaluate callback
|
|
25
|
+
const modeConfig = options.mode ? getSnapshotModeConfig(options.mode) : null;
|
|
26
|
+
return await target.evaluate(({ selector, interactiveOnly, limit, modeConfig: mc }) => {
|
|
27
|
+
const root = selector ? document.querySelector(selector) : document.body;
|
|
28
|
+
if (!root) {
|
|
29
|
+
throw new Error(`Selector scope not found: ${selector}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Use injected window.__pi utilities
|
|
33
|
+
const pi = (window as any).__pi;
|
|
34
|
+
const simpleHash = pi.simpleHash;
|
|
35
|
+
const isVisible = pi.isVisible;
|
|
36
|
+
const isEnabled = pi.isEnabled;
|
|
37
|
+
const inferRole = pi.inferRole;
|
|
38
|
+
const accessibleName = pi.accessibleName;
|
|
39
|
+
const isInteractiveEl = pi.isInteractiveEl;
|
|
40
|
+
const cssPath = pi.cssPath;
|
|
41
|
+
const domPath = pi.domPath;
|
|
42
|
+
const selectorHints = pi.selectorHints;
|
|
43
|
+
|
|
44
|
+
// Mode-based element matching — used when a snapshot mode config is provided
|
|
45
|
+
const matchesMode = (el: Element, cfg: { tags: string[]; roles: string[]; selectors: string[]; ariaAttributes: string[] }): boolean => {
|
|
46
|
+
const tag = el.tagName.toLowerCase();
|
|
47
|
+
if (cfg.tags.length > 0 && cfg.tags.includes(tag)) return true;
|
|
48
|
+
const role = inferRole(el);
|
|
49
|
+
if (cfg.roles.length > 0 && cfg.roles.includes(role)) return true;
|
|
50
|
+
for (const sel of cfg.selectors) {
|
|
51
|
+
try { if (el.matches(sel)) return true; } catch { /* invalid selector, skip */ }
|
|
52
|
+
}
|
|
53
|
+
for (const attr of cfg.ariaAttributes) {
|
|
54
|
+
if (el.hasAttribute(attr)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let elements = Array.from(root.querySelectorAll("*"));
|
|
60
|
+
|
|
61
|
+
if (mc) {
|
|
62
|
+
// Mode takes precedence over interactiveOnly
|
|
63
|
+
if (mc.visibleOnly) {
|
|
64
|
+
// visible_only mode: include all elements that are visible
|
|
65
|
+
elements = elements.filter((el) => isVisible(el));
|
|
66
|
+
} else if (mc.useInteractiveFilter) {
|
|
67
|
+
// interactive mode: reuse existing isInteractiveEl
|
|
68
|
+
elements = elements.filter((el) => isInteractiveEl(el));
|
|
69
|
+
} else if (mc.containerExpand) {
|
|
70
|
+
// Container-expanding modes (dialog, errors): match containers, then include
|
|
71
|
+
// all interactive children of those containers, plus the containers themselves
|
|
72
|
+
const containers: Element[] = [];
|
|
73
|
+
const directMatches: Element[] = [];
|
|
74
|
+
for (const el of elements) {
|
|
75
|
+
if (matchesMode(el, mc)) {
|
|
76
|
+
// Check if this is a container element (has children)
|
|
77
|
+
const childEls = el.querySelectorAll("*");
|
|
78
|
+
if (childEls.length > 0) {
|
|
79
|
+
containers.push(el);
|
|
80
|
+
} else {
|
|
81
|
+
directMatches.push(el);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Collect container elements + all interactive children inside containers
|
|
86
|
+
const result = new Set<Element>(directMatches);
|
|
87
|
+
for (const container of containers) {
|
|
88
|
+
result.add(container);
|
|
89
|
+
const children = Array.from(container.querySelectorAll("*"));
|
|
90
|
+
for (const child of children) {
|
|
91
|
+
if (isInteractiveEl(child)) result.add(child);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
elements = Array.from(result);
|
|
95
|
+
} else {
|
|
96
|
+
// Standard mode filtering by tag/role/selector/ariaAttribute
|
|
97
|
+
elements = elements.filter((el) => matchesMode(el, mc));
|
|
98
|
+
}
|
|
99
|
+
} else if (!interactiveOnly) {
|
|
100
|
+
if (root instanceof Element) elements.unshift(root);
|
|
101
|
+
} else {
|
|
102
|
+
elements = elements.filter((el) => isInteractiveEl(el));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const seen = new Set<Element>();
|
|
106
|
+
const unique = elements.filter((el) => {
|
|
107
|
+
if (seen.has(el)) return false;
|
|
108
|
+
seen.add(el);
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Fingerprint helpers — computed for each element in the snapshot
|
|
113
|
+
const computeNearestHeading = (el: Element): string => {
|
|
114
|
+
const headingTags = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
115
|
+
// Walk up ancestors looking for heading or preceding-sibling heading
|
|
116
|
+
let current: Element | null = el;
|
|
117
|
+
while (current && current !== document.body) {
|
|
118
|
+
// Check preceding siblings of current
|
|
119
|
+
let sib: Element | null = current.previousElementSibling;
|
|
120
|
+
while (sib) {
|
|
121
|
+
if (headingTags.has(sib.tagName) || sib.getAttribute("role") === "heading") {
|
|
122
|
+
return (sib.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
|
|
123
|
+
}
|
|
124
|
+
sib = sib.previousElementSibling;
|
|
125
|
+
}
|
|
126
|
+
// Check if the parent itself is a heading (unlikely but possible)
|
|
127
|
+
const parent = current.parentElement;
|
|
128
|
+
if (parent && (headingTags.has(parent.tagName) || parent.getAttribute("role") === "heading")) {
|
|
129
|
+
return (parent.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
|
|
130
|
+
}
|
|
131
|
+
current = parent;
|
|
132
|
+
}
|
|
133
|
+
return "";
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const computeFormOwnership = (el: Element): string => {
|
|
137
|
+
// Check form attribute (explicit form association)
|
|
138
|
+
const formAttr = el.getAttribute("form");
|
|
139
|
+
if (formAttr) return formAttr;
|
|
140
|
+
// Walk up ancestors looking for <form>
|
|
141
|
+
let current: Element | null = el.parentElement;
|
|
142
|
+
while (current && current !== document.body) {
|
|
143
|
+
if (current.tagName === "FORM") {
|
|
144
|
+
return (current as HTMLFormElement).id || (current as HTMLFormElement).name || "form";
|
|
145
|
+
}
|
|
146
|
+
current = current.parentElement;
|
|
147
|
+
}
|
|
148
|
+
return "";
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return unique.slice(0, limit).map((el) => {
|
|
152
|
+
const tag = el.tagName.toLowerCase();
|
|
153
|
+
const role = inferRole(el);
|
|
154
|
+
const textContent = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 200);
|
|
155
|
+
const childTags = Array.from(el.children).map((c) => c.tagName.toLowerCase());
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
tag,
|
|
159
|
+
role,
|
|
160
|
+
name: accessibleName(el),
|
|
161
|
+
selectorHints: selectorHints(el),
|
|
162
|
+
isVisible: isVisible(el),
|
|
163
|
+
isEnabled: isEnabled(el),
|
|
164
|
+
xpathOrPath: cssPath(el),
|
|
165
|
+
href: el.getAttribute("href") || undefined,
|
|
166
|
+
type: el.getAttribute("type") || undefined,
|
|
167
|
+
path: domPath(el),
|
|
168
|
+
contentHash: simpleHash(textContent),
|
|
169
|
+
structuralSignature: simpleHash(`${tag}|${role}|${childTags.join(",")}`),
|
|
170
|
+
nearestHeading: computeNearestHeading(el),
|
|
171
|
+
formOwnership: computeFormOwnership(el),
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}, { ...options, modeConfig });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// resolveRefTarget
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
export async function resolveRefTarget(
|
|
182
|
+
target: Page | Frame,
|
|
183
|
+
node: RefNode,
|
|
184
|
+
): Promise<{ ok: true; selector: string } | { ok: false; reason: string }> {
|
|
185
|
+
return await target.evaluate((refNode) => {
|
|
186
|
+
// Use injected window.__pi utilities
|
|
187
|
+
const pi = (window as any).__pi;
|
|
188
|
+
const cssPath = pi.cssPath;
|
|
189
|
+
const simpleHash = pi.simpleHash;
|
|
190
|
+
|
|
191
|
+
const byPath = (): Element | null => {
|
|
192
|
+
let current: Element | null = document.documentElement;
|
|
193
|
+
for (const idx of refNode.path || []) {
|
|
194
|
+
if (!current || idx < 0 || idx >= current.children.length) return null;
|
|
195
|
+
current = current.children[idx] as Element;
|
|
196
|
+
}
|
|
197
|
+
return current;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const nodeName = (el: Element): string => {
|
|
201
|
+
return (
|
|
202
|
+
el.getAttribute("aria-label")?.trim() ||
|
|
203
|
+
(el as HTMLInputElement).value?.trim() ||
|
|
204
|
+
el.getAttribute("placeholder")?.trim() ||
|
|
205
|
+
(el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80)
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Tier 1: path-based resolution
|
|
210
|
+
const pathEl = byPath();
|
|
211
|
+
if (pathEl && pathEl.tagName.toLowerCase() === refNode.tag) {
|
|
212
|
+
return { ok: true as const, selector: cssPath(pathEl) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Tier 2: selector hints
|
|
216
|
+
for (const hint of refNode.selectorHints || []) {
|
|
217
|
+
try {
|
|
218
|
+
const el = document.querySelector(hint);
|
|
219
|
+
if (!el) continue;
|
|
220
|
+
if (el.tagName.toLowerCase() !== refNode.tag) continue;
|
|
221
|
+
return { ok: true as const, selector: cssPath(el) };
|
|
222
|
+
} catch {
|
|
223
|
+
// ignore malformed selector hint
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Tier 3: role + name match
|
|
228
|
+
const candidates = Array.from(document.querySelectorAll(refNode.tag));
|
|
229
|
+
const matchTarget = candidates.find((el) => {
|
|
230
|
+
const role = el.getAttribute("role") || "";
|
|
231
|
+
const name = nodeName(el);
|
|
232
|
+
const roleMatch = !refNode.role || role === refNode.role;
|
|
233
|
+
const nameMatch = !!refNode.name && name.toLowerCase() === refNode.name.toLowerCase();
|
|
234
|
+
return roleMatch && nameMatch;
|
|
235
|
+
});
|
|
236
|
+
if (matchTarget) {
|
|
237
|
+
return { ok: true as const, selector: cssPath(matchTarget) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Tier 4: structural signature + content hash fingerprint matching
|
|
241
|
+
if (refNode.contentHash && refNode.structuralSignature) {
|
|
242
|
+
const fpMatches: Element[] = [];
|
|
243
|
+
for (const candidate of candidates) {
|
|
244
|
+
const tag = candidate.tagName.toLowerCase();
|
|
245
|
+
const role = candidate.getAttribute("role") || "";
|
|
246
|
+
const textContent = (candidate.textContent || "").trim().replace(/\s+/g, " ").slice(0, 200);
|
|
247
|
+
const childTags = Array.from(candidate.children).map((c) => c.tagName.toLowerCase());
|
|
248
|
+
const candidateContentHash = simpleHash(textContent);
|
|
249
|
+
const candidateStructSig = simpleHash(`${tag}|${role}|${childTags.join(",")}`);
|
|
250
|
+
if (candidateContentHash === refNode.contentHash && candidateStructSig === refNode.structuralSignature) {
|
|
251
|
+
fpMatches.push(candidate);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (fpMatches.length === 1) {
|
|
255
|
+
return { ok: true as const, selector: cssPath(fpMatches[0]) };
|
|
256
|
+
}
|
|
257
|
+
if (fpMatches.length > 1) {
|
|
258
|
+
return { ok: false as const, reason: "multiple fingerprint matches — ambiguous" };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { ok: false as const, reason: "element not found in current DOM" };
|
|
263
|
+
}, node);
|
|
264
|
+
}
|