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.
- package/README.md +45 -18
- package/package.json +4 -3
- package/skills/effing-use/SKILL.md +34 -10
- package/src/browser/delta.ts +136 -0
- package/src/browser/engine.ts +541 -55
- package/src/browser/evidence.ts +186 -0
- package/src/browser/identity.ts +95 -0
- package/src/browser/macro.ts +102 -0
- package/src/browser/record.ts +117 -0
- package/src/browser/refs.ts +158 -7
- package/src/browser/session.ts +16 -0
- package/src/browser/state.ts +100 -0
- package/src/cli.ts +173 -0
- package/src/config.ts +10 -0
- package/src/http.ts +1 -0
- package/src/tools/act.ts +9 -2
- package/src/tools/extract.ts +1 -0
- package/src/tools/observe.ts +5 -1
|
@@ -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
|
+
}
|
package/src/browser/refs.ts
CHANGED
|
@@ -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())
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|