effing-use 0.1.1 → 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.
@@ -0,0 +1,186 @@
1
+ import type { Page } from "playwright";
2
+ import { getConsoleLogs, getNetworkLogs } from "./session.js";
3
+
4
+ export type Effect = {
5
+ urlChanged: boolean;
6
+ urlBefore?: string;
7
+ urlAfter?: string;
8
+ domChanged: string[];
9
+ consoleErrors: string[];
10
+ dialog?: string;
11
+ networkFailures?: string[];
12
+ mustObserve: boolean;
13
+ };
14
+
15
+ const EFFECT_MAX_CHARS_DEFAULT = 800;
16
+
17
+ function capEffect(effect: Effect, maxChars: number): Effect {
18
+ // Hard cap 800 chars total — truncate domChanged/consoleErrors/networkFailures
19
+ let total = JSON.stringify(effect).length;
20
+ if (total <= maxChars) return effect;
21
+ // Trim arrays until under cap
22
+ while (total > maxChars && effect.domChanged.length > 0) {
23
+ effect.domChanged.pop();
24
+ total = JSON.stringify(effect).length;
25
+ }
26
+ while (total > maxChars && effect.consoleErrors.length > 0) {
27
+ effect.consoleErrors.pop();
28
+ total = JSON.stringify(effect).length;
29
+ }
30
+ while (total > maxChars && (effect.networkFailures?.length ?? 0) > 0) {
31
+ effect.networkFailures!.pop();
32
+ total = JSON.stringify(effect).length;
33
+ }
34
+ return effect;
35
+ }
36
+
37
+ export async function collectEffect(
38
+ page: Page,
39
+ sessionId: string,
40
+ urlBefore: string,
41
+ domBeforeHash: string | null,
42
+ consoleBeforeLen: number,
43
+ networkBeforeLen: number,
44
+ maxChars = EFFECT_MAX_CHARS_DEFAULT,
45
+ ): Promise<Effect> {
46
+ const urlAfter = page.url();
47
+ const urlChanged = urlBefore !== urlAfter;
48
+
49
+ // domChanged: try to get diff via delta module if available, else simple heuristic
50
+ let domChanged: string[] = [];
51
+ try {
52
+ const currentDom = await page.evaluate(
53
+ () => document.body?.innerText?.slice(0, 200) ?? "",
54
+ );
55
+ if (domBeforeHash !== null && currentDom !== domBeforeHash) {
56
+ domChanged = [
57
+ currentDom.slice(0, 120).replace(/\s+/g, " ").trim(),
58
+ ].filter(Boolean);
59
+ }
60
+ } catch {
61
+ // ignore
62
+ }
63
+
64
+ const consoleLogs = getConsoleLogs(sessionId);
65
+ const newConsole = consoleLogs.slice(consoleBeforeLen);
66
+ const consoleErrors = newConsole
67
+ .filter((c) => c.type === "error")
68
+ .slice(-3)
69
+ .map((c) => c.text.slice(0, 200));
70
+
71
+ const networkLogs = getNetworkLogs(sessionId);
72
+ const newNetwork = networkLogs.slice(networkBeforeLen);
73
+ const networkFailures = newNetwork
74
+ .filter((r) => r.status >= 400)
75
+ .slice(-3)
76
+ .map((r) => `${r.method} ${r.url.slice(0, 80)} → ${r.status}`);
77
+
78
+ // dialog detection: check if page has dialog handler armed? For now undefined
79
+ const effect: Effect = {
80
+ urlChanged,
81
+ ...(urlChanged ? { urlBefore, urlAfter } : {}),
82
+ domChanged: domChanged.slice(0, 5),
83
+ consoleErrors: consoleErrors.slice(0, 3),
84
+ ...(networkFailures.length ? { networkFailures } : {}),
85
+ mustObserve: false,
86
+ };
87
+
88
+ return capEffect(effect, maxChars);
89
+ }
90
+
91
+ const EXPECT_MAX_PATTERN = 200;
92
+
93
+ export function parseExpect(
94
+ expect: string,
95
+ ): { kind: string; pattern: string } | null {
96
+ const s = expect.trim();
97
+ let parsed: { kind: string; pattern: string } | null = null;
98
+ if (s.startsWith("url~")) parsed = { kind: "url", pattern: s.slice(4) };
99
+ else if (s.startsWith("text~")) parsed = { kind: "text", pattern: s.slice(5) };
100
+ else if (s.startsWith("visible=")) parsed = { kind: "visible", pattern: s.slice(8) };
101
+ else if (s.startsWith("gone=")) parsed = { kind: "gone", pattern: s.slice(5) };
102
+ else return null;
103
+ if (parsed.pattern.length > EXPECT_MAX_PATTERN) return null;
104
+ // Validate regex patterns early for url~/text~
105
+ if (parsed.kind === "url" || parsed.kind === "text") {
106
+ try {
107
+ new RegExp(parsed.pattern);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ return parsed;
113
+ }
114
+
115
+ export async function evaluateExpect(
116
+ page: Page,
117
+ expect: string,
118
+ ): Promise<{ ok: boolean; message?: string }> {
119
+ const parsed = parseExpect(expect);
120
+ if (!parsed) {
121
+ return {
122
+ ok: false,
123
+ message: `Bad expect syntax "${expect}". Use url~<regex> | text~<regex> | visible=<css> | gone=<css>`,
124
+ };
125
+ }
126
+ try {
127
+ switch (parsed.kind) {
128
+ case "url": {
129
+ const re = new RegExp(parsed.pattern);
130
+ const url = page.url();
131
+ if (re.test(url)) return { ok: true };
132
+ return {
133
+ ok: false,
134
+ message: `url "${url}" does not match /${parsed.pattern}/`,
135
+ };
136
+ }
137
+ case "text": {
138
+ const text = await page.innerText("body").catch(() => "");
139
+ const slice = text.slice(0, 50000);
140
+ const re = new RegExp(parsed.pattern);
141
+ if (re.test(slice)) return { ok: true };
142
+ return {
143
+ ok: false,
144
+ message: `page text does not match /${parsed.pattern}/`,
145
+ };
146
+ }
147
+ case "visible": {
148
+ const loc = page.locator(parsed.pattern);
149
+ const count = await loc.count().catch(() => 0);
150
+ if (count === 0)
151
+ return {
152
+ ok: false,
153
+ message: `selector "${parsed.pattern}" not found`,
154
+ };
155
+ const visible = await loc
156
+ .first()
157
+ .isVisible()
158
+ .catch(() => false);
159
+ if (visible) return { ok: true };
160
+ return {
161
+ ok: false,
162
+ message: `selector "${parsed.pattern}" not visible`,
163
+ };
164
+ }
165
+ case "gone": {
166
+ const loc = page.locator(parsed.pattern);
167
+ const count = await loc.count().catch(() => 0);
168
+ if (count === 0) return { ok: true };
169
+ const visible = await loc
170
+ .first()
171
+ .isVisible()
172
+ .catch(() => false);
173
+ if (!visible) return { ok: true };
174
+ return {
175
+ ok: false,
176
+ message: `selector "${parsed.pattern}" still visible`,
177
+ };
178
+ }
179
+ default:
180
+ return { ok: false, message: `Unknown expect kind "${parsed.kind}"` };
181
+ }
182
+ } catch (e) {
183
+ const msg = e instanceof Error ? e.message : String(e);
184
+ return { ok: false, message: `expect evaluation failed: ${msg}` };
185
+ }
186
+ }
@@ -0,0 +1,95 @@
1
+ export type Fingerprint = {
2
+ role: string;
3
+ accessibleName: string;
4
+ textHash: string;
5
+ box: { x: number; y: number; w: number; h: number };
6
+ pathHash: string;
7
+ };
8
+
9
+ function hashText(s: string): string {
10
+ return s.trim().replace(/\s+/g, " ").slice(0, 80);
11
+ }
12
+
13
+ function boxKey(box: { x: number; y: number; w: number; h: number }): string {
14
+ return `${Math.round(box.x)}:${Math.round(box.y)}:${Math.round(box.w)}:${Math.round(box.h)}`;
15
+ }
16
+
17
+ // Per-session registry: sessionId -> Map<ref, Fingerprint>
18
+ const registry = new Map<string, Map<string, Fingerprint>>();
19
+
20
+ export function registerFingerprints(
21
+ sessionId: string,
22
+ entries: Array<{ ref: string; fp: Fingerprint }>,
23
+ ): void {
24
+ let m = registry.get(sessionId);
25
+ if (!m) {
26
+ m = new Map();
27
+ registry.set(sessionId, m);
28
+ }
29
+ // Never recycle within session — but allow overwrite on fresh snapshot
30
+ for (const { ref, fp } of entries) m.set(ref, fp);
31
+ }
32
+
33
+ export function getFingerprint(
34
+ sessionId: string,
35
+ ref: string,
36
+ ): Fingerprint | undefined {
37
+ return registry.get(sessionId)?.get(ref);
38
+ }
39
+
40
+ export function clearRegistry(sessionId: string): void {
41
+ registry.delete(sessionId);
42
+ }
43
+
44
+ export function fingerprintEquals(a: Fingerprint, b: Fingerprint): boolean {
45
+ return (
46
+ a.role === b.role &&
47
+ a.accessibleName === b.accessibleName &&
48
+ a.textHash === b.textHash &&
49
+ boxKey(a.box) === boxKey(b.box) &&
50
+ a.pathHash === b.pathHash
51
+ );
52
+ }
53
+
54
+ // Loose match for rebind: role + name + textHash (ignore box/path)
55
+ export function looseMatch(a: Fingerprint, b: Fingerprint): boolean {
56
+ return (
57
+ a.role === b.role &&
58
+ a.accessibleName === b.accessibleName &&
59
+ a.textHash === b.textHash
60
+ );
61
+ }
62
+
63
+ export function fuzzyMatch(a: Fingerprint, b: Fingerprint): boolean {
64
+ // fuzzy: role must match, and either name or textHash matches (case-insensitive contains)
65
+ if (a.role !== b.role) return false;
66
+ const an = a.accessibleName.toLowerCase();
67
+ const bn = b.accessibleName.toLowerCase();
68
+ const at = a.textHash.toLowerCase();
69
+ const bt = b.textHash.toLowerCase();
70
+ const nameMatch = an && bn && (an.includes(bn) || bn.includes(an));
71
+ const textMatch = at && bt && (at.includes(bt) || bt.includes(at));
72
+ return Boolean(nameMatch || textMatch);
73
+ }
74
+
75
+ // Build fingerprint from evaluated element data
76
+ export function buildFingerprint(data: {
77
+ role: string;
78
+ accessibleName: string;
79
+ text: string;
80
+ box: { x: number; y: number; w: number; h: number };
81
+ pathHash: string;
82
+ }): Fingerprint {
83
+ return {
84
+ role: data.role || "generic",
85
+ accessibleName: (data.accessibleName || "").trim().slice(0, 80),
86
+ textHash: hashText(data.text || ""),
87
+ box: {
88
+ x: Math.round(data.box.x),
89
+ y: Math.round(data.box.y),
90
+ w: Math.round(data.box.w),
91
+ h: Math.round(data.box.h),
92
+ },
93
+ pathHash: data.pathHash || "",
94
+ };
95
+ }
@@ -0,0 +1,102 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Config } from "../config.js";
4
+ import type { RecordStep } from "./record.js";
5
+
6
+ const IRREVERSIBLE = /submit|send|buy|pay|delete|remove|publish|post|confirm/i;
7
+
8
+ export function isIrreversible(step: RecordStep): boolean {
9
+ const hay = [step.op, step.target ?? "", step.value ?? ""].join(" ");
10
+ return IRREVERSIBLE.test(hay);
11
+ }
12
+
13
+ function selectorFor(step: RecordStep): string {
14
+ if (step.resolvedSelector) return step.resolvedSelector;
15
+ if (step.target) return step.target;
16
+ return "";
17
+ }
18
+
19
+ function sanitizeMacroName(s: string): string {
20
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "macro";
21
+ }
22
+
23
+ export async function compileMacro(
24
+ config: Config,
25
+ name: string,
26
+ steps: RecordStep[],
27
+ ): Promise<{ tsPath: string; mdPath: string; warnings: string[] }> {
28
+ const safe = sanitizeMacroName(name);
29
+ const dir = join(config.outputDir, "macros");
30
+ await mkdir(dir, { recursive: true });
31
+
32
+ const warnings: string[] = [];
33
+ const irreversibleSteps = steps.filter(isIrreversible);
34
+ if (irreversibleSteps.length > 0) {
35
+ warnings.push(
36
+ `${irreversibleSteps.length} step(s) require approval: ${irreversibleSteps.map((s) => s.seq).join(", ")}`,
37
+ );
38
+ }
39
+
40
+ // TS macro
41
+ const tsLines: string[] = [
42
+ `// macro: ${name} — generated by effing-use compile`,
43
+ `// steps: ${steps.length}, requiresApproval: ${irreversibleSteps.length > 0}`,
44
+ `import type { Page } from "playwright";`,
45
+ ``,
46
+ `export async function run(page: Page) {`,
47
+ ];
48
+ for (const s of steps) {
49
+ const sel = selectorFor(s);
50
+ const val = (s.value ?? "").slice(0, 500);
51
+ const flag = isIrreversible(s) ? " // requiresApproval" : "";
52
+ switch (s.op) {
53
+ case "click":
54
+ tsLines.push(` await page.locator(${JSON.stringify(sel)}).click();${flag}`);
55
+ break;
56
+ case "fill":
57
+ tsLines.push(` await page.locator(${JSON.stringify(sel)}).fill(${JSON.stringify(val)});${flag}`);
58
+ break;
59
+ case "press":
60
+ tsLines.push(
61
+ ` await page.keyboard.press(${JSON.stringify(val || s.target || "Enter")});${flag}`,
62
+ );
63
+ break;
64
+ case "goto":
65
+ case "open":
66
+ tsLines.push(` await page.goto(${JSON.stringify(val || sel)});${flag}`);
67
+ break;
68
+ default:
69
+ tsLines.push(` // ${JSON.stringify(s.op)} ${JSON.stringify(sel)} ${JSON.stringify(val)}${flag}`);
70
+ break;
71
+ }
72
+ }
73
+ tsLines.push(`}`);
74
+ tsLines.push(``);
75
+
76
+ const tsPath = join(dir, `${safe}.ts`);
77
+ await writeFile(tsPath, tsLines.join("\n"), "utf-8");
78
+
79
+ // SKILL.md
80
+ const mdLines: string[] = [
81
+ `# Skill: ${safe}`,
82
+ ``,
83
+ `When to use: replay this ${steps.length}-step flow via \`browser_act action=replay value=${safe}\`.`,
84
+ ``,
85
+ `## Steps`,
86
+ ...steps.map((s) =>
87
+ `${s.seq + 1}. ${s.op} ${s.target ?? ""} ${s.value ?? ""}${isIrreversible(s) ? " (requires approval)" : ""}`.trim(),
88
+ ),
89
+ ``,
90
+ `## Verification`,
91
+ irreversibleSteps.length > 0
92
+ ? `This macro contains irreversible steps — replay will pause with E_APPROVAL_REQUIRED until approve:true.`
93
+ : `No approval-gated steps.`,
94
+ ``,
95
+ `## Inputs`,
96
+ `None in v1 — values are baked in. Parameterize in a future version if needed.`,
97
+ ];
98
+ const mdPath = join(dir, `${safe}.md`);
99
+ await writeFile(mdPath, mdLines.join("\n"), "utf-8");
100
+
101
+ return { tsPath, mdPath, warnings };
102
+ }
@@ -0,0 +1,117 @@
1
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Config } from "../config.js";
4
+ import type { Fingerprint } from "./identity.js";
5
+
6
+ export type RecordStep = {
7
+ seq: number;
8
+ op: string;
9
+ target?: string;
10
+ value?: string;
11
+ targetFingerprint?: Fingerprint;
12
+ resolvedSelector?: string;
13
+ expect?: string;
14
+ valueRedacted?: boolean;
15
+ };
16
+
17
+ type Recording = {
18
+ name: string;
19
+ createdAt: string;
20
+ steps: RecordStep[];
21
+ };
22
+
23
+ const activeRecordings = new Map<
24
+ string,
25
+ { name: string; steps: RecordStep[] }
26
+ >();
27
+
28
+ const SECRET_HINT = /password|otp|secret|token|pin|ssn/i;
29
+
30
+ function isSecretField(
31
+ fp?: Fingerprint,
32
+ target?: string,
33
+ value?: string,
34
+ ): boolean {
35
+ const hay = [fp?.accessibleName ?? "", fp?.role ?? "", target ?? ""].join(
36
+ " ",
37
+ );
38
+ if (SECRET_HINT.test(hay)) return true;
39
+ // also check input type=password via target string
40
+ if (/type\s*=\s*password/i.test(target ?? "")) return true;
41
+ return false;
42
+ }
43
+
44
+ export function isRecording(sessionId: string): boolean {
45
+ return activeRecordings.has(sessionId);
46
+ }
47
+
48
+ export function startRecording(sessionId: string, name: string): void {
49
+ activeRecordings.set(sessionId, { name, steps: [] });
50
+ }
51
+
52
+ export function stopRecording(
53
+ sessionId: string,
54
+ ): { name: string; steps: RecordStep[] } | null {
55
+ const r = activeRecordings.get(sessionId);
56
+ if (!r) return null;
57
+ activeRecordings.delete(sessionId);
58
+ return r;
59
+ }
60
+
61
+ export function captureStep(
62
+ sessionId: string,
63
+ step: Omit<RecordStep, "seq">,
64
+ config: Config,
65
+ ): void {
66
+ const rec = activeRecordings.get(sessionId);
67
+ if (!rec) return;
68
+ const seq = rec.steps.length;
69
+ let value = step.value;
70
+ let valueRedacted = false;
71
+ if (
72
+ config.recordRedact &&
73
+ value &&
74
+ isSecretField(step.targetFingerprint, step.target, value)
75
+ ) {
76
+ value = "«redacted»";
77
+ valueRedacted = true;
78
+ }
79
+ rec.steps.push({
80
+ seq,
81
+ ...step,
82
+ value,
83
+ ...(valueRedacted ? { valueRedacted: true } : {}),
84
+ });
85
+ }
86
+
87
+ function sanitizeName(s: string): string {
88
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "recording";
89
+ }
90
+
91
+ export async function saveRecording(
92
+ config: Config,
93
+ name: string,
94
+ steps: RecordStep[],
95
+ ): Promise<string> {
96
+ const safe = sanitizeName(name);
97
+ const dir = join(config.outputDir, "recordings");
98
+ await mkdir(dir, { recursive: true });
99
+ const path = join(dir, `${safe}.json`);
100
+ const rec: Recording = { name: safe, createdAt: new Date().toISOString(), steps };
101
+ await writeFile(path, JSON.stringify(rec, null, 2), "utf-8");
102
+ return path;
103
+ }
104
+
105
+ export async function loadRecording(
106
+ config: Config,
107
+ name: string,
108
+ ): Promise<Recording> {
109
+ const safe = sanitizeName(name);
110
+ const path = join(config.outputDir, "recordings", `${safe}.json`);
111
+ const raw = await readFile(path, "utf-8");
112
+ return JSON.parse(raw) as Recording;
113
+ }
114
+
115
+ export function getActiveName(sessionId: string): string | null {
116
+ return activeRecordings.get(sessionId)?.name ?? null;
117
+ }
@@ -1,4 +1,10 @@
1
1
  import type { Page, Locator } from "playwright";
2
+ import {
3
+ getFingerprint,
4
+ looseMatch,
5
+ fuzzyMatch,
6
+ type Fingerprint,
7
+ } from "./identity.js";
2
8
 
3
9
  export class EngineError extends Error {
4
10
  code: string;
@@ -30,17 +36,47 @@ export function resolveRef(page: Page, ref: string): Locator {
30
36
  export async function resolveLocator(
31
37
  page: Page,
32
38
  target: string,
39
+ sessionId?: string,
33
40
  ): Promise<Locator> {
34
41
  const t = target.trim();
35
- // 0. eNN ref
42
+ // 0. eNN ref — with identity validation when sessionId is known
36
43
  if (/^e\d+$/i.test(t)) {
37
44
  const loc = resolveRef(page, t);
38
- if ((await loc.count()) > 0) return loc;
39
- throw new EngineError(
40
- "E_NOT_FOUND",
41
- `No element matches "${t}".`,
42
- "Call browser_observe kind=snapshot first to refresh refs, then use a fresh e-ref.",
43
- );
45
+ if ((await loc.count()) === 0) {
46
+ throw new EngineError(
47
+ "E_NOT_FOUND",
48
+ `No element matches "${t}".`,
49
+ "Call browser_observe kind=snapshot first to refresh refs, then use a fresh e-ref.",
50
+ );
51
+ }
52
+ if (sessionId) {
53
+ const expected = getFingerprint(sessionId, t);
54
+ if (expected) {
55
+ const actual = await fingerprintAt(page, loc).catch(() => null);
56
+ if (actual) {
57
+ const exact =
58
+ expected.role === actual.role &&
59
+ expected.accessibleName === actual.accessibleName &&
60
+ expected.textHash === actual.textHash;
61
+ if (!exact) {
62
+ // Try rebind: search tree for loose/fuzzy match
63
+ const rebound = await findRebound(page, expected);
64
+ if (rebound) {
65
+ // Return rebound locator; caller can check rebound flag via session
66
+ (page as unknown as Record<string, unknown>).__rebound = true;
67
+ return rebound;
68
+ }
69
+ throw new EngineError(
70
+ "E_STALE",
71
+ `Ref ${t} is stale — element changed.`,
72
+ "Call browser_observe kind=snapshot to refresh refs.",
73
+ { expected, actual },
74
+ );
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return loc;
44
80
  }
45
81
  // 1. role= selector passes through
46
82
  if (t.startsWith("role=")) return page.locator(t);
@@ -73,3 +109,118 @@ export async function resolveLocator(
73
109
  "Call browser_observe kind=snapshot first, then use an e-ref, role= selector, or CSS selector.",
74
110
  );
75
111
  }
112
+
113
+ async function fingerprintAt(page: Page, loc: Locator): Promise<Fingerprint> {
114
+ const box = await loc.boundingBox().catch(() => null);
115
+ const data = await loc
116
+ .evaluate((el) => {
117
+ const text = (el.textContent ?? "").trim().slice(0, 80);
118
+ const aria = el.getAttribute("aria-label") ?? "";
119
+ const role = el.getAttribute("role") ?? el.tagName.toLowerCase();
120
+ // simple path hash: tag chain
121
+ let cur: Element | null = el;
122
+ const parts: string[] = [];
123
+ while (cur && parts.length < 6) {
124
+ parts.push(cur.tagName.toLowerCase());
125
+ cur = cur.parentElement;
126
+ }
127
+ return { role, accessibleName: aria, text, pathHash: parts.join(">") };
128
+ })
129
+ .catch(() => ({
130
+ role: "generic",
131
+ accessibleName: "",
132
+ text: "",
133
+ pathHash: "",
134
+ }));
135
+ return {
136
+ role: data.role || "generic",
137
+ accessibleName: (data.accessibleName || "").trim().slice(0, 80),
138
+ textHash: (data.text || "").trim().replace(/\s+/g, " ").slice(0, 80),
139
+ box: {
140
+ x: Math.round(box?.x ?? 0),
141
+ y: Math.round(box?.y ?? 0),
142
+ w: Math.round(box?.width ?? 0),
143
+ h: Math.round(box?.height ?? 0),
144
+ },
145
+ pathHash: data.pathHash || "",
146
+ };
147
+ }
148
+
149
+ async function findRebound(
150
+ page: Page,
151
+ expected: Fingerprint,
152
+ ): Promise<Locator | null> {
153
+ // Scan interactive elements for loose/fuzzy match
154
+ const els = await page
155
+ .evaluate(() => {
156
+ const nodes = [
157
+ ...document.querySelectorAll(
158
+ "button, a, input, select, textarea, [role=button], [tabindex]",
159
+ ),
160
+ ];
161
+ return nodes.slice(0, 200).map((el, i) => {
162
+ const text = (el.textContent ?? "")
163
+ .trim()
164
+ .replace(/\s+/g, " ")
165
+ .slice(0, 80);
166
+ const aria = el.getAttribute("aria-label") ?? "";
167
+ const role = el.getAttribute("role") ?? el.tagName.toLowerCase();
168
+ let cur: Element | null = el;
169
+ const parts: string[] = [];
170
+ while (cur && parts.length < 6) {
171
+ parts.push(cur.tagName.toLowerCase());
172
+ cur = cur.parentElement;
173
+ }
174
+ return {
175
+ i,
176
+ role,
177
+ accessibleName: aria,
178
+ textHash: text,
179
+ pathHash: parts.join(">"),
180
+ };
181
+ });
182
+ })
183
+ .catch(
184
+ () =>
185
+ [] as Array<{
186
+ i: number;
187
+ role: string;
188
+ accessibleName: string;
189
+ textHash: string;
190
+ pathHash: string;
191
+ }>,
192
+ );
193
+ for (const e of els) {
194
+ const cand: Fingerprint = {
195
+ role: e.role,
196
+ accessibleName: e.accessibleName,
197
+ textHash: e.textHash,
198
+ box: { x: 0, y: 0, w: 0, h: 0 },
199
+ pathHash: e.pathHash,
200
+ };
201
+ if (looseMatch(expected, cand)) {
202
+ return page
203
+ .locator(
204
+ "button, a, input, select, textarea, [role=button], [tabindex]",
205
+ )
206
+ .nth(e.i);
207
+ }
208
+ }
209
+ for (const e of els) {
210
+ const cand: Fingerprint = {
211
+ role: e.role,
212
+ accessibleName: e.accessibleName,
213
+ textHash: e.textHash,
214
+ box: { x: 0, y: 0, w: 0, h: 0 },
215
+ pathHash: e.pathHash,
216
+ };
217
+ if (fuzzyMatch(expected, cand)) {
218
+ return page
219
+ .locator(
220
+ "button, a, input, select, textarea, [role=button], [tabindex]",
221
+ )
222
+ .nth(e.i);
223
+ }
224
+ }
225
+ return null;
226
+ }