effing-use 0.1.1 → 0.2.1
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 +165 -26
- package/package.json +4 -3
- package/skills/effing-use/SKILL.md +38 -11
- package/src/browser/delta.ts +173 -0
- package/src/browser/engine.ts +581 -58
- package/src/browser/evidence.ts +208 -0
- package/src/browser/identity.ts +237 -0
- package/src/browser/macro.ts +118 -0
- package/src/browser/record.ts +149 -0
- package/src/browser/refs.ts +192 -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 +10 -7
- package/src/tools/extract.ts +1 -0
- package/src/tools/observe.ts +5 -1
|
@@ -0,0 +1,208 @@
|
|
|
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: line-level diff of body.innerText (50k cap). v0.2 only
|
|
50
|
+
// compared the first 200 chars, so any below-fold change looked like a no-op
|
|
51
|
+
// (and fed the failure contract wrong "no DOM change" signals).
|
|
52
|
+
let domChanged: string[] = [];
|
|
53
|
+
try {
|
|
54
|
+
const currentDom = await page.evaluate(
|
|
55
|
+
() => document.body?.innerText?.slice(0, 50000) ?? "",
|
|
56
|
+
);
|
|
57
|
+
if (domBeforeHash !== null && currentDom !== domBeforeHash) {
|
|
58
|
+
const before = new Set(
|
|
59
|
+
domBeforeHash
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((l) => l.trim())
|
|
62
|
+
.filter(Boolean),
|
|
63
|
+
);
|
|
64
|
+
const after = currentDom
|
|
65
|
+
.split("\n")
|
|
66
|
+
.map((l) => l.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
const afterSet = new Set(after);
|
|
69
|
+
const changed = after
|
|
70
|
+
.filter((l) => !before.has(l))
|
|
71
|
+
.slice(0, 5)
|
|
72
|
+
.map((l) => l.slice(0, 140));
|
|
73
|
+
const removed = [...before]
|
|
74
|
+
.filter((l) => !afterSet.has(l))
|
|
75
|
+
.slice(0, 3)
|
|
76
|
+
.map((l) => `- ${l.slice(0, 138)}`);
|
|
77
|
+
domChanged = [...changed, ...removed].slice(0, 5);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const consoleLogs = getConsoleLogs(sessionId);
|
|
84
|
+
const newConsole = consoleLogs.slice(consoleBeforeLen);
|
|
85
|
+
const consoleErrors = newConsole
|
|
86
|
+
.filter((c) => c.type === "error")
|
|
87
|
+
.slice(-3)
|
|
88
|
+
.map((c) => c.text.slice(0, 200));
|
|
89
|
+
|
|
90
|
+
const networkLogs = getNetworkLogs(sessionId);
|
|
91
|
+
const newNetwork = networkLogs.slice(networkBeforeLen);
|
|
92
|
+
const networkFailures = newNetwork
|
|
93
|
+
.filter((r) => r.status >= 400)
|
|
94
|
+
.slice(-3)
|
|
95
|
+
.map((r) => `${r.method} ${r.url.slice(0, 80)} → ${r.status}`);
|
|
96
|
+
|
|
97
|
+
// dialog detection: check if page has dialog handler armed? For now undefined
|
|
98
|
+
const effect: Effect = {
|
|
99
|
+
urlChanged,
|
|
100
|
+
...(urlChanged ? { urlBefore, urlAfter } : {}),
|
|
101
|
+
domChanged: domChanged.slice(0, 5),
|
|
102
|
+
consoleErrors: consoleErrors.slice(0, 3),
|
|
103
|
+
...(networkFailures.length ? { networkFailures } : {}),
|
|
104
|
+
mustObserve: false,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return capEffect(effect, maxChars);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const EXPECT_MAX_PATTERN = 200;
|
|
111
|
+
|
|
112
|
+
export function parseExpect(
|
|
113
|
+
expect: string,
|
|
114
|
+
): { kind: string; pattern: string } | null {
|
|
115
|
+
const s = expect.trim();
|
|
116
|
+
let parsed: { kind: string; pattern: string } | null = null;
|
|
117
|
+
if (s.startsWith("url~")) parsed = { kind: "url", pattern: s.slice(4) };
|
|
118
|
+
else if (s.startsWith("text~"))
|
|
119
|
+
parsed = { kind: "text", pattern: s.slice(5) };
|
|
120
|
+
else if (s.startsWith("visible="))
|
|
121
|
+
parsed = { kind: "visible", pattern: s.slice(8) };
|
|
122
|
+
else if (s.startsWith("gone="))
|
|
123
|
+
parsed = { kind: "gone", pattern: s.slice(5) };
|
|
124
|
+
else return null;
|
|
125
|
+
if (parsed.pattern.length > EXPECT_MAX_PATTERN) return null;
|
|
126
|
+
// Validate regex patterns early for url~/text~
|
|
127
|
+
if (parsed.kind === "url" || parsed.kind === "text") {
|
|
128
|
+
try {
|
|
129
|
+
new RegExp(parsed.pattern);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function evaluateExpect(
|
|
138
|
+
page: Page,
|
|
139
|
+
expect: string,
|
|
140
|
+
): Promise<{ ok: boolean; message?: string }> {
|
|
141
|
+
const parsed = parseExpect(expect);
|
|
142
|
+
if (!parsed) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
message: `Bad expect syntax "${expect}". Use url~<regex> | text~<regex> | visible=<css> | gone=<css>`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
switch (parsed.kind) {
|
|
150
|
+
case "url": {
|
|
151
|
+
const re = new RegExp(parsed.pattern);
|
|
152
|
+
const url = page.url();
|
|
153
|
+
if (re.test(url)) return { ok: true };
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
message: `url "${url}" does not match /${parsed.pattern}/`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
case "text": {
|
|
160
|
+
const text = await page.innerText("body").catch(() => "");
|
|
161
|
+
const slice = text.slice(0, 50000);
|
|
162
|
+
const re = new RegExp(parsed.pattern);
|
|
163
|
+
if (re.test(slice)) return { ok: true };
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
message: `page text does not match /${parsed.pattern}/`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
case "visible": {
|
|
170
|
+
const loc = page.locator(parsed.pattern);
|
|
171
|
+
const count = await loc.count().catch(() => 0);
|
|
172
|
+
if (count === 0)
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
message: `selector "${parsed.pattern}" not found`,
|
|
176
|
+
};
|
|
177
|
+
const visible = await loc
|
|
178
|
+
.first()
|
|
179
|
+
.isVisible()
|
|
180
|
+
.catch(() => false);
|
|
181
|
+
if (visible) return { ok: true };
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
message: `selector "${parsed.pattern}" not visible`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
case "gone": {
|
|
188
|
+
const loc = page.locator(parsed.pattern);
|
|
189
|
+
const count = await loc.count().catch(() => 0);
|
|
190
|
+
if (count === 0) return { ok: true };
|
|
191
|
+
const visible = await loc
|
|
192
|
+
.first()
|
|
193
|
+
.isVisible()
|
|
194
|
+
.catch(() => false);
|
|
195
|
+
if (!visible) return { ok: true };
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
message: `selector "${parsed.pattern}" still visible`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
default:
|
|
202
|
+
return { ok: false, message: `Unknown expect kind "${parsed.kind}"` };
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
206
|
+
return { ok: false, message: `expect evaluation failed: ${msg}` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
|
|
3
|
+
export type Fingerprint = {
|
|
4
|
+
role: string;
|
|
5
|
+
accessibleName: string;
|
|
6
|
+
textHash: string;
|
|
7
|
+
box: { x: number; y: number; w: number; h: number };
|
|
8
|
+
pathHash: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Raw per-element data as extracted inside the page (before normalization). */
|
|
12
|
+
export type RawFp = {
|
|
13
|
+
role: string;
|
|
14
|
+
accessibleName: string;
|
|
15
|
+
text: string;
|
|
16
|
+
pathHash: string;
|
|
17
|
+
box: { x: number; y: number; w: number; h: number };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Browser-side fingerprint extractor — SINGLE SOURCE OF TRUTH shared by
|
|
22
|
+
* snapshot registration, stale-ref validation and rebind search. Serialized
|
|
23
|
+
* into the page as a string so it never closes over module scope.
|
|
24
|
+
*
|
|
25
|
+
* v0.2 bug fixed here: fingerprints used tag-name roles ("input"), aria-label
|
|
26
|
+
* only, and own textContent (always "" for form controls), so every bare
|
|
27
|
+
* checkbox shared one identity — rebind then clicked the wrong element
|
|
28
|
+
* (TodoMVC: stale item toggle rebound to toggle-all). Now:
|
|
29
|
+
* - role: implicit ARIA role (input[type=checkbox] → "checkbox")
|
|
30
|
+
* - accessibleName: aria-label → <label> → alt → title → placeholder → text
|
|
31
|
+
* - textHash: own text, else contextual text (nearest li/label/td/row), else
|
|
32
|
+
* placeholder — so item toggles carry their todo text
|
|
33
|
+
*/
|
|
34
|
+
export const RAW_FP_SCRIPT = `(() => {
|
|
35
|
+
const SEL = "button, a, input, select, textarea, [role=button], [tabindex]";
|
|
36
|
+
const norm = (s) => (s || "").trim().replace(/\\s+/g, " ").slice(0, 80);
|
|
37
|
+
const implicitRole = (el) => {
|
|
38
|
+
const r = el.getAttribute("role");
|
|
39
|
+
if (r) return r;
|
|
40
|
+
const tag = el.tagName.toLowerCase();
|
|
41
|
+
if (tag === "a") return el.hasAttribute("href") ? "link" : "generic";
|
|
42
|
+
if (tag === "button") return "button";
|
|
43
|
+
if (tag === "textarea") return "textbox";
|
|
44
|
+
if (tag === "select") return "combobox";
|
|
45
|
+
if (tag === "summary") return "button";
|
|
46
|
+
if (tag === "input") {
|
|
47
|
+
const t = (el.getAttribute("type") || "text").toLowerCase();
|
|
48
|
+
if (t === "checkbox") return "checkbox";
|
|
49
|
+
if (t === "radio") return "radio";
|
|
50
|
+
if (t === "submit" || t === "button" || t === "reset" || t === "image") return "button";
|
|
51
|
+
if (t === "range") return "slider";
|
|
52
|
+
if (t === "hidden") return "hidden";
|
|
53
|
+
return "textbox";
|
|
54
|
+
}
|
|
55
|
+
return tag;
|
|
56
|
+
};
|
|
57
|
+
const accName = (el) => {
|
|
58
|
+
const aria = el.getAttribute("aria-label");
|
|
59
|
+
if (aria && aria.trim()) return norm(aria);
|
|
60
|
+
if (el.labels && el.labels.length > 0) {
|
|
61
|
+
const t = norm(el.labels[0].textContent);
|
|
62
|
+
if (t) return t;
|
|
63
|
+
}
|
|
64
|
+
const alt = el.getAttribute("alt");
|
|
65
|
+
if (alt && alt.trim()) return norm(alt);
|
|
66
|
+
const title = el.getAttribute("title");
|
|
67
|
+
if (title && title.trim()) return norm(title);
|
|
68
|
+
const ph = el.getAttribute("placeholder");
|
|
69
|
+
if (ph && ph.trim()) return norm(ph);
|
|
70
|
+
return norm(el.textContent);
|
|
71
|
+
};
|
|
72
|
+
const textOf = (el) => {
|
|
73
|
+
const own = norm(el.textContent);
|
|
74
|
+
if (own) return own;
|
|
75
|
+
const ctx = el.closest && el.closest("li, label, td, [role=listitem], [role=row]");
|
|
76
|
+
if (ctx) {
|
|
77
|
+
const t = norm(ctx.textContent);
|
|
78
|
+
if (t) return t;
|
|
79
|
+
}
|
|
80
|
+
return norm(el.getAttribute("placeholder"));
|
|
81
|
+
};
|
|
82
|
+
const pathOf = (el) => {
|
|
83
|
+
const parts = [];
|
|
84
|
+
let cur = el;
|
|
85
|
+
while (cur && parts.length < 6) {
|
|
86
|
+
parts.push(cur.tagName.toLowerCase());
|
|
87
|
+
cur = cur.parentElement;
|
|
88
|
+
}
|
|
89
|
+
return parts.join(">");
|
|
90
|
+
};
|
|
91
|
+
const els = Array.prototype.slice.call(document.querySelectorAll(SEL), 0, 200);
|
|
92
|
+
return els.map((el) => {
|
|
93
|
+
const rect = el.getBoundingClientRect();
|
|
94
|
+
return {
|
|
95
|
+
role: implicitRole(el),
|
|
96
|
+
accessibleName: accName(el),
|
|
97
|
+
text: textOf(el),
|
|
98
|
+
pathHash: pathOf(el),
|
|
99
|
+
box: {
|
|
100
|
+
x: Math.round(rect.x),
|
|
101
|
+
y: Math.round(rect.y),
|
|
102
|
+
w: Math.round(rect.width),
|
|
103
|
+
h: Math.round(rect.height)
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
})()`;
|
|
108
|
+
|
|
109
|
+
/** Extract fingerprints for every snapshot-visible element, as e-refs. */
|
|
110
|
+
export async function extractFingerprints(
|
|
111
|
+
page: Page,
|
|
112
|
+
): Promise<Array<{ ref: string; fp: Fingerprint }>> {
|
|
113
|
+
const raw = (await page
|
|
114
|
+
.evaluate(RAW_FP_SCRIPT)
|
|
115
|
+
.catch(() => [] as RawFp[])) as RawFp[];
|
|
116
|
+
return raw.map((r, i) => ({ ref: `e${i}`, fp: buildFingerprint(r) }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hashText(s: string): string {
|
|
120
|
+
return s.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function boxKey(box: { x: number; y: number; w: number; h: number }): string {
|
|
124
|
+
return `${Math.round(box.x)}:${Math.round(box.y)}:${Math.round(box.w)}:${Math.round(box.h)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Per-session registry: sessionId -> Map<ref, Fingerprint>
|
|
128
|
+
const registry = new Map<string, Map<string, Fingerprint>>();
|
|
129
|
+
|
|
130
|
+
export function registerFingerprints(
|
|
131
|
+
sessionId: string,
|
|
132
|
+
entries: Array<{ ref: string; fp: Fingerprint }>,
|
|
133
|
+
): void {
|
|
134
|
+
let m = registry.get(sessionId);
|
|
135
|
+
if (!m) {
|
|
136
|
+
m = new Map();
|
|
137
|
+
registry.set(sessionId, m);
|
|
138
|
+
}
|
|
139
|
+
// Never recycle within session — but allow overwrite on fresh snapshot
|
|
140
|
+
for (const { ref, fp } of entries) m.set(ref, fp);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getFingerprint(
|
|
144
|
+
sessionId: string,
|
|
145
|
+
ref: string,
|
|
146
|
+
): Fingerprint | undefined {
|
|
147
|
+
return registry.get(sessionId)?.get(ref);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function clearRegistry(sessionId: string): void {
|
|
151
|
+
registry.delete(sessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type RebindCandidate = { index: number; fp: Fingerprint };
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Conservative rebind ladder (P0 fix): pick an element only when the match is
|
|
158
|
+
* UNAMBIGUOUS — never guess among equals.
|
|
159
|
+
*
|
|
160
|
+
* 1. loose match (role + accessibleName + textHash) — unique → take it
|
|
161
|
+
* 2. multiple loose → narrow by identical pathHash; still >1 → give up
|
|
162
|
+
* 3. no loose → fuzzy (role + name/text contains) under the same rules
|
|
163
|
+
*
|
|
164
|
+
* Returns the chosen candidate index, or null when zero or more than one
|
|
165
|
+
* candidate survives — callers turn null into E_STALE (fail loud) instead of
|
|
166
|
+
* clicking the wrong element.
|
|
167
|
+
*/
|
|
168
|
+
export function chooseRebindIndex(
|
|
169
|
+
expected: Fingerprint,
|
|
170
|
+
candidates: RebindCandidate[],
|
|
171
|
+
): number | null {
|
|
172
|
+
const narrow = (pool: RebindCandidate[]): number | null => {
|
|
173
|
+
if (pool.length === 1) return pool[0].index;
|
|
174
|
+
if (pool.length > 1 && expected.pathHash) {
|
|
175
|
+
const byPath = pool.filter((c) => c.fp.pathHash === expected.pathHash);
|
|
176
|
+
if (byPath.length === 1) return byPath[0].index;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
const loose = candidates.filter((c) => looseMatch(expected, c.fp));
|
|
181
|
+
if (loose.length > 0) return narrow(loose);
|
|
182
|
+
const fuzzy = candidates.filter((c) => fuzzyMatch(expected, c.fp));
|
|
183
|
+
return narrow(fuzzy);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function fingerprintEquals(a: Fingerprint, b: Fingerprint): boolean {
|
|
187
|
+
return (
|
|
188
|
+
a.role === b.role &&
|
|
189
|
+
a.accessibleName === b.accessibleName &&
|
|
190
|
+
a.textHash === b.textHash &&
|
|
191
|
+
boxKey(a.box) === boxKey(b.box) &&
|
|
192
|
+
a.pathHash === b.pathHash
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Loose match for rebind: role + name + textHash (ignore box/path)
|
|
197
|
+
export function looseMatch(a: Fingerprint, b: Fingerprint): boolean {
|
|
198
|
+
return (
|
|
199
|
+
a.role === b.role &&
|
|
200
|
+
a.accessibleName === b.accessibleName &&
|
|
201
|
+
a.textHash === b.textHash
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function fuzzyMatch(a: Fingerprint, b: Fingerprint): boolean {
|
|
206
|
+
// fuzzy: role must match, and either name or textHash matches (case-insensitive contains)
|
|
207
|
+
if (a.role !== b.role) return false;
|
|
208
|
+
const an = a.accessibleName.toLowerCase();
|
|
209
|
+
const bn = b.accessibleName.toLowerCase();
|
|
210
|
+
const at = a.textHash.toLowerCase();
|
|
211
|
+
const bt = b.textHash.toLowerCase();
|
|
212
|
+
const nameMatch = an && bn && (an.includes(bn) || bn.includes(an));
|
|
213
|
+
const textMatch = at && bt && (at.includes(bt) || bt.includes(at));
|
|
214
|
+
return Boolean(nameMatch || textMatch);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Build fingerprint from evaluated element data
|
|
218
|
+
export function buildFingerprint(data: {
|
|
219
|
+
role: string;
|
|
220
|
+
accessibleName: string;
|
|
221
|
+
text: string;
|
|
222
|
+
box: { x: number; y: number; w: number; h: number };
|
|
223
|
+
pathHash: string;
|
|
224
|
+
}): Fingerprint {
|
|
225
|
+
return {
|
|
226
|
+
role: data.role || "generic",
|
|
227
|
+
accessibleName: (data.accessibleName || "").trim().slice(0, 80),
|
|
228
|
+
textHash: hashText(data.text || ""),
|
|
229
|
+
box: {
|
|
230
|
+
x: Math.round(data.box.x),
|
|
231
|
+
y: Math.round(data.box.y),
|
|
232
|
+
w: Math.round(data.box.w),
|
|
233
|
+
h: Math.round(data.box.h),
|
|
234
|
+
},
|
|
235
|
+
pathHash: data.pathHash || "",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
// Steps are human-facing here — 1-indexed, matching the .md step list
|
|
36
|
+
warnings.push(
|
|
37
|
+
`${irreversibleSteps.length} step(s) require approval: steps ${irreversibleSteps
|
|
38
|
+
.map((s) => s.seq + 1)
|
|
39
|
+
.join(", ")}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// TS macro
|
|
44
|
+
const tsLines: string[] = [
|
|
45
|
+
`// macro: ${name} — generated by effing-use compile`,
|
|
46
|
+
`// steps: ${steps.length}, requiresApproval: ${irreversibleSteps.length > 0}`,
|
|
47
|
+
`import type { Page } from "playwright";`,
|
|
48
|
+
``,
|
|
49
|
+
`export async function run(page: Page) {`,
|
|
50
|
+
];
|
|
51
|
+
for (const s of steps) {
|
|
52
|
+
const sel = selectorFor(s);
|
|
53
|
+
const val = (s.value ?? "").slice(0, 500);
|
|
54
|
+
const flag = isIrreversible(s) ? " // requiresApproval" : "";
|
|
55
|
+
// Recordings made before stable-selector resolution store session-scoped
|
|
56
|
+
// e-refs — flag them so nobody runs the .ts standalone expecting it to work.
|
|
57
|
+
const staleNote = /^e\d+$/.test(sel)
|
|
58
|
+
? " /* session-scoped e-ref — not a standalone selector */"
|
|
59
|
+
: "";
|
|
60
|
+
switch (s.op) {
|
|
61
|
+
case "click":
|
|
62
|
+
tsLines.push(
|
|
63
|
+
` await page.locator(${JSON.stringify(sel)}).click();${flag}${staleNote}`,
|
|
64
|
+
);
|
|
65
|
+
break;
|
|
66
|
+
case "fill":
|
|
67
|
+
tsLines.push(
|
|
68
|
+
` await page.locator(${JSON.stringify(sel)}).fill(${JSON.stringify(val)});${flag}${staleNote}`,
|
|
69
|
+
);
|
|
70
|
+
break;
|
|
71
|
+
case "press":
|
|
72
|
+
tsLines.push(
|
|
73
|
+
` await page.keyboard.press(${JSON.stringify(val || s.target || "Enter")});${flag}`,
|
|
74
|
+
);
|
|
75
|
+
break;
|
|
76
|
+
case "goto":
|
|
77
|
+
case "open":
|
|
78
|
+
tsLines.push(
|
|
79
|
+
` await page.goto(${JSON.stringify(val || sel)});${flag}`,
|
|
80
|
+
);
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
tsLines.push(
|
|
84
|
+
` // ${JSON.stringify(s.op)} ${JSON.stringify(sel)} ${JSON.stringify(val)}${flag}`,
|
|
85
|
+
);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
tsLines.push(`}`);
|
|
90
|
+
tsLines.push(``);
|
|
91
|
+
|
|
92
|
+
const tsPath = join(dir, `${safe}.ts`);
|
|
93
|
+
await writeFile(tsPath, tsLines.join("\n"), "utf-8");
|
|
94
|
+
|
|
95
|
+
// SKILL.md
|
|
96
|
+
const mdLines: string[] = [
|
|
97
|
+
`# Skill: ${safe}`,
|
|
98
|
+
``,
|
|
99
|
+
`When to use: replay this ${steps.length}-step flow via \`browser_act action=replay value=${safe}\`.`,
|
|
100
|
+
``,
|
|
101
|
+
`## Steps`,
|
|
102
|
+
...steps.map((s) =>
|
|
103
|
+
`${s.seq + 1}. ${s.op} ${s.target ?? ""} ${s.value ?? ""}${isIrreversible(s) ? " (requires approval)" : ""}`.trim(),
|
|
104
|
+
),
|
|
105
|
+
``,
|
|
106
|
+
`## Verification`,
|
|
107
|
+
irreversibleSteps.length > 0
|
|
108
|
+
? `This macro contains irreversible steps — replay will pause with E_APPROVAL_REQUIRED until approve:true.`
|
|
109
|
+
: `No approval-gated steps.`,
|
|
110
|
+
``,
|
|
111
|
+
`## Inputs`,
|
|
112
|
+
`None in v1 — values are baked in. Parameterize in a future version if needed.`,
|
|
113
|
+
];
|
|
114
|
+
const mdPath = join(dir, `${safe}.md`);
|
|
115
|
+
await writeFile(mdPath, mdLines.join("\n"), "utf-8");
|
|
116
|
+
|
|
117
|
+
return { tsPath, mdPath, warnings };
|
|
118
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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 = {
|
|
101
|
+
name: safe,
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
steps,
|
|
104
|
+
};
|
|
105
|
+
await writeFile(path, JSON.stringify(rec, null, 2), "utf-8");
|
|
106
|
+
return path;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function loadRecording(
|
|
110
|
+
config: Config,
|
|
111
|
+
name: string,
|
|
112
|
+
): Promise<Recording> {
|
|
113
|
+
const safe = sanitizeName(name);
|
|
114
|
+
const path = join(config.outputDir, "recordings", `${safe}.json`);
|
|
115
|
+
const raw = await readFile(path, "utf-8");
|
|
116
|
+
return JSON.parse(raw) as Recording;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getActiveName(sessionId: string): string | null {
|
|
120
|
+
return activeRecordings.get(sessionId)?.name ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- replay cursor
|
|
124
|
+
// Plan §6.3: pause AT an approval-gated step and resume with approve:true —
|
|
125
|
+
// the cursor keeps the benign prefix from running twice (non-idempotent steps).
|
|
126
|
+
const replayCursors = new Map<string, number>();
|
|
127
|
+
|
|
128
|
+
function cursorKey(sessionId: string, name: string): string {
|
|
129
|
+
return `${sessionId}:${name}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getReplayCursor(
|
|
133
|
+
sessionId: string,
|
|
134
|
+
name: string,
|
|
135
|
+
): number | undefined {
|
|
136
|
+
return replayCursors.get(cursorKey(sessionId, name));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function setReplayCursor(
|
|
140
|
+
sessionId: string,
|
|
141
|
+
name: string,
|
|
142
|
+
step: number,
|
|
143
|
+
): void {
|
|
144
|
+
replayCursors.set(cursorKey(sessionId, name), step);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clearReplayCursor(sessionId: string, name: string): void {
|
|
148
|
+
replayCursors.delete(cursorKey(sessionId, name));
|
|
149
|
+
}
|