playwright-locator-healer 0.1.3
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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/USAGE.md +217 -0
- package/dist/healing/audit-log.d.ts +9 -0
- package/dist/healing/audit-log.d.ts.map +1 -0
- package/dist/healing/audit-log.js +22 -0
- package/dist/healing/audit-log.js.map +1 -0
- package/dist/healing/auto-fixture.d.ts +14 -0
- package/dist/healing/auto-fixture.d.ts.map +1 -0
- package/dist/healing/auto-fixture.js +430 -0
- package/dist/healing/auto-fixture.js.map +1 -0
- package/dist/healing/cache.d.ts +11 -0
- package/dist/healing/cache.d.ts.map +1 -0
- package/dist/healing/cache.js +128 -0
- package/dist/healing/cache.js.map +1 -0
- package/dist/healing/circuit-breaker.d.ts +11 -0
- package/dist/healing/circuit-breaker.d.ts.map +1 -0
- package/dist/healing/circuit-breaker.js +34 -0
- package/dist/healing/circuit-breaker.js.map +1 -0
- package/dist/healing/cost-tracker.d.ts +30 -0
- package/dist/healing/cost-tracker.d.ts.map +1 -0
- package/dist/healing/cost-tracker.js +101 -0
- package/dist/healing/cost-tracker.js.map +1 -0
- package/dist/healing/dom-extractor.d.ts +9 -0
- package/dist/healing/dom-extractor.d.ts.map +1 -0
- package/dist/healing/dom-extractor.js +268 -0
- package/dist/healing/dom-extractor.js.map +1 -0
- package/dist/healing/errors.d.ts +33 -0
- package/dist/healing/errors.d.ts.map +1 -0
- package/dist/healing/errors.js +40 -0
- package/dist/healing/errors.js.map +1 -0
- package/dist/healing/heal/caller-context.d.ts +8 -0
- package/dist/healing/heal/caller-context.d.ts.map +1 -0
- package/dist/healing/heal/caller-context.js +46 -0
- package/dist/healing/heal/caller-context.js.map +1 -0
- package/dist/healing/heal/orchestrator-helpers.d.ts +84 -0
- package/dist/healing/heal/orchestrator-helpers.d.ts.map +1 -0
- package/dist/healing/heal/orchestrator-helpers.js +117 -0
- package/dist/healing/heal/orchestrator-helpers.js.map +1 -0
- package/dist/healing/heal/orchestrator.d.ts +47 -0
- package/dist/healing/heal/orchestrator.d.ts.map +1 -0
- package/dist/healing/heal/orchestrator.js +644 -0
- package/dist/healing/heal/orchestrator.js.map +1 -0
- package/dist/healing/heal/playwright-code.d.ts +26 -0
- package/dist/healing/heal/playwright-code.d.ts.map +1 -0
- package/dist/healing/heal/playwright-code.js +134 -0
- package/dist/healing/heal/playwright-code.js.map +1 -0
- package/dist/healing/heal/preflight.d.ts +8 -0
- package/dist/healing/heal/preflight.d.ts.map +1 -0
- package/dist/healing/heal/preflight.js +77 -0
- package/dist/healing/heal/preflight.js.map +1 -0
- package/dist/healing/heal/prompt.d.ts +9 -0
- package/dist/healing/heal/prompt.d.ts.map +1 -0
- package/dist/healing/heal/prompt.js +22 -0
- package/dist/healing/heal/prompt.js.map +1 -0
- package/dist/healing/heal/report.d.ts +11 -0
- package/dist/healing/heal/report.d.ts.map +1 -0
- package/dist/healing/heal/report.js +28 -0
- package/dist/healing/heal/report.js.map +1 -0
- package/dist/healing/heal/source-trace.d.ts +17 -0
- package/dist/healing/heal/source-trace.d.ts.map +1 -0
- package/dist/healing/heal/source-trace.js +59 -0
- package/dist/healing/heal/source-trace.js.map +1 -0
- package/dist/healing/index.d.ts +6 -0
- package/dist/healing/index.d.ts.map +1 -0
- package/dist/healing/index.js +3 -0
- package/dist/healing/index.js.map +1 -0
- package/dist/healing/llm-client.d.ts +58 -0
- package/dist/healing/llm-client.d.ts.map +1 -0
- package/dist/healing/llm-client.js +258 -0
- package/dist/healing/llm-client.js.map +1 -0
- package/dist/healing/logger.d.ts +18 -0
- package/dist/healing/logger.d.ts.map +1 -0
- package/dist/healing/logger.js +38 -0
- package/dist/healing/logger.js.map +1 -0
- package/dist/healing/models.d.ts +26 -0
- package/dist/healing/models.d.ts.map +1 -0
- package/dist/healing/models.js +109 -0
- package/dist/healing/models.js.map +1 -0
- package/dist/healing/overlay/bridge.d.ts +46 -0
- package/dist/healing/overlay/bridge.d.ts.map +1 -0
- package/dist/healing/overlay/bridge.js +2 -0
- package/dist/healing/overlay/bridge.js.map +1 -0
- package/dist/healing/overlay/dock.css.d.ts +2 -0
- package/dist/healing/overlay/dock.css.d.ts.map +1 -0
- package/dist/healing/overlay/dock.css.js +448 -0
- package/dist/healing/overlay/dock.css.js.map +1 -0
- package/dist/healing/overlay/dock.html.d.ts +46 -0
- package/dist/healing/overlay/dock.html.d.ts.map +1 -0
- package/dist/healing/overlay/dock.html.js +248 -0
- package/dist/healing/overlay/dock.html.js.map +1 -0
- package/dist/healing/overlay/drag.d.ts +17 -0
- package/dist/healing/overlay/drag.d.ts.map +1 -0
- package/dist/healing/overlay/drag.js +68 -0
- package/dist/healing/overlay/drag.js.map +1 -0
- package/dist/healing/overlay/inject.d.ts +41 -0
- package/dist/healing/overlay/inject.d.ts.map +1 -0
- package/dist/healing/overlay/inject.js +277 -0
- package/dist/healing/overlay/inject.js.map +1 -0
- package/dist/healing/overlay/keybinds.d.ts +33 -0
- package/dist/healing/overlay/keybinds.d.ts.map +1 -0
- package/dist/healing/overlay/keybinds.js +105 -0
- package/dist/healing/overlay/keybinds.js.map +1 -0
- package/dist/healing/prompts/heal-v21.d.ts +8 -0
- package/dist/healing/prompts/heal-v21.d.ts.map +1 -0
- package/dist/healing/prompts/heal-v21.js +204 -0
- package/dist/healing/prompts/heal-v21.js.map +1 -0
- package/dist/healing/retry-policy.d.ts +37 -0
- package/dist/healing/retry-policy.d.ts.map +1 -0
- package/dist/healing/retry-policy.js +46 -0
- package/dist/healing/retry-policy.js.map +1 -0
- package/dist/healing/session-cost.d.ts +44 -0
- package/dist/healing/session-cost.d.ts.map +1 -0
- package/dist/healing/session-cost.js +95 -0
- package/dist/healing/session-cost.js.map +1 -0
- package/dist/healing/test-fixture.d.ts +9 -0
- package/dist/healing/test-fixture.d.ts.map +1 -0
- package/dist/healing/test-fixture.js +37 -0
- package/dist/healing/test-fixture.js.map +1 -0
- package/dist/healing/types.d.ts +399 -0
- package/dist/healing/types.d.ts.map +1 -0
- package/dist/healing/types.js +162 -0
- package/dist/healing/types.js.map +1 -0
- package/dist/healing/validator.d.ts +64 -0
- package/dist/healing/validator.d.ts.map +1 -0
- package/dist/healing/validator.js +286 -0
- package/dist/healing/validator.js.map +1 -0
- package/dist/scripts/heal-report.d.ts +23 -0
- package/dist/scripts/heal-report.d.ts.map +1 -0
- package/dist/scripts/heal-report.js +106 -0
- package/dist/scripts/heal-report.js.map +1 -0
- package/dist/scripts/init.d.ts +3 -0
- package/dist/scripts/init.d.ts.map +1 -0
- package/dist/scripts/init.js +132 -0
- package/dist/scripts/init.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseSourceTrace, readSnippet } from './source-trace.js';
|
|
3
|
+
import { appendReport, appendAuditV2 } from './report.js';
|
|
4
|
+
import { cacheV2Get, cacheV2Set } from '../cache.js';
|
|
5
|
+
import { buildLocator, validateProposedLocator, verifyActionable, relaxRoleNameMatch, captureFingerprint, fingerprintMatches } from '../validator.js';
|
|
6
|
+
import { injectOverlay } from '../overlay/inject.js';
|
|
7
|
+
import { HealCancelledError, HealExhaustedError, HealLLMError, HealingError } from '../errors.js';
|
|
8
|
+
import { requestHealedLocator } from '../llm-client.js';
|
|
9
|
+
import { tryPreflight } from './preflight.js';
|
|
10
|
+
import { extractScopedCandidates } from '../dom-extractor.js';
|
|
11
|
+
import { isBudgetExceeded, getSessionCost, getCallCount, getDailyTotals, MODEL_NAME } from '../cost-tracker.js';
|
|
12
|
+
import { warn as logWarn } from '../logger.js';
|
|
13
|
+
import { toPlaywrightCode } from './playwright-code.js';
|
|
14
|
+
import { safeCount, validateJs, tierOfType, extractStructured, tryForegroundUpgrade, } from './orchestrator-helpers.js';
|
|
15
|
+
/** Maximum number of progressive LLM rounds attempted before giving up. */
|
|
16
|
+
const MAX_ROUNDS = 4;
|
|
17
|
+
function statusFields(extra) {
|
|
18
|
+
const daily = getDailyTotals();
|
|
19
|
+
return {
|
|
20
|
+
sessionCostUsd: getSessionCost(),
|
|
21
|
+
llmCallCount: getCallCount(),
|
|
22
|
+
todayCostUsd: daily.cost,
|
|
23
|
+
todayCallCount: daily.calls,
|
|
24
|
+
modelName: MODEL_NAME,
|
|
25
|
+
statusLabel: extra?.statusLabel,
|
|
26
|
+
llmStartedAt: extra?.llmStartedAt,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Top-level heal entry point. Five-stage flow:
|
|
31
|
+
* 0. live re-check — maybe the page changed and the original works now
|
|
32
|
+
* 1. cache lookup — return a previously-healed selector if it still resolves
|
|
33
|
+
* 2. overlay + pick — developer clicks the right element
|
|
34
|
+
* 3. preflight — try cheap deterministic locators (testid, role+name, …)
|
|
35
|
+
* 4. progressive LLM rounds — 4 rounds with increasing tier permissions
|
|
36
|
+
*
|
|
37
|
+
* Returns the persisted `HealEntryV2` so the caller (auto-fixture or
|
|
38
|
+
* manual code) can build a Playwright `Locator` from the result and retry.
|
|
39
|
+
*
|
|
40
|
+
* @param input - See `HealActionInput`.
|
|
41
|
+
* @returns The healed entry (selector + selectorType + tier + trace).
|
|
42
|
+
* @throws HealCancelledError, HealExhaustedError, HealLLMError on the
|
|
43
|
+
* respective failure modes; HealingError(circuit-open) when the LLM
|
|
44
|
+
* circuit breaker is open.
|
|
45
|
+
*/
|
|
46
|
+
export async function healAction(input) {
|
|
47
|
+
const cacheDir = input.cacheDir ?? (process.env.HEAL_CACHE_DIR ?? join(process.cwd(), '.healed-locators'));
|
|
48
|
+
const { page, originalSelector, action, description, testFile, callerStack } = input;
|
|
49
|
+
// P2-2: prefer the caller-captured stack (taken synchronously at the
|
|
50
|
+
// proxy entry) over the local stack, which has already crossed multiple
|
|
51
|
+
// async boundaries and shows <anonymous> Proxy frames.
|
|
52
|
+
const trace = parseSourceTrace(callerStack ?? new Error().stack);
|
|
53
|
+
const ctx = { originalSelector, action, callSite: trace.callSite };
|
|
54
|
+
// Stage 0 — live re-check
|
|
55
|
+
if ((await safeCount(page, originalSelector)) === 1) {
|
|
56
|
+
return synthEntry(originalSelector, 'css', originalSelector, description ?? '', action, trace, 1);
|
|
57
|
+
}
|
|
58
|
+
// Stage 1 — cache
|
|
59
|
+
let cached = null;
|
|
60
|
+
try {
|
|
61
|
+
cached = await cacheV2Get(cacheDir, testFile, originalSelector, action);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e instanceof HealingError && e.code === 'invalid-cache') {
|
|
65
|
+
process.stderr.write(`[HEAL] ${e.message} — treating as cache miss\n`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (cached) {
|
|
72
|
+
const loc = buildLocator(page, {
|
|
73
|
+
selectorType: cached.selectorType, selector: cached.healedSelector, rationale: '',
|
|
74
|
+
tier: cached.tier ?? 6,
|
|
75
|
+
});
|
|
76
|
+
// intentionally swallowed: count() throws on invalid selector → treat as 0 matches and fall through
|
|
77
|
+
if ((await loc.count().catch(() => 0)) === 1) {
|
|
78
|
+
// P0-1b: drift gate parity with auto-fixture. When the cached entry
|
|
79
|
+
// carries a fingerprint, recompute on the live resolved element. If
|
|
80
|
+
// tag/role/attrs/text have drifted past tolerance, treat the entry
|
|
81
|
+
// as stale and re-heal rather than trust a `count===1` collision
|
|
82
|
+
// (e.g. cache poisoned by manual edit pointing at `<body>`).
|
|
83
|
+
if (cached.fingerprint) {
|
|
84
|
+
// intentionally swallowed: fingerprint extraction failure → assume drift, re-heal
|
|
85
|
+
const live = await loc.elementHandle().then((h) => h ? captureFingerprint(h) : null).catch(() => null);
|
|
86
|
+
if (!live || !fingerprintMatches(cached.fingerprint, live)) {
|
|
87
|
+
await appendAuditV2(cacheDir, { event: 'cache-stale', ...ctx });
|
|
88
|
+
// fall through to overlay heal
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await appendAuditV2(cacheDir, { event: 'cache-hit', ...ctx, healedSelector: cached.healedSelector, tier: cached.tier ?? 6 });
|
|
92
|
+
if ((cached.tier ?? 4) >= 5) {
|
|
93
|
+
const upgraded = await tryForegroundUpgrade();
|
|
94
|
+
if (upgraded)
|
|
95
|
+
return upgraded;
|
|
96
|
+
}
|
|
97
|
+
return cached;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await appendAuditV2(cacheDir, { event: 'cache-hit', ...ctx, healedSelector: cached.healedSelector, tier: cached.tier ?? 6 });
|
|
102
|
+
if ((cached.tier ?? 4) >= 5) {
|
|
103
|
+
const upgraded = await tryForegroundUpgrade();
|
|
104
|
+
if (upgraded)
|
|
105
|
+
return upgraded;
|
|
106
|
+
}
|
|
107
|
+
return cached;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
await appendAuditV2(cacheDir, { event: 'cache-stale', ...ctx });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Stage 2 + 3 + 4 — overlay + pick + preflight + LLM rounds
|
|
115
|
+
return openOverlayAndHeal(input, trace, ctx, []);
|
|
116
|
+
}
|
|
117
|
+
async function openOverlayAndHeal(input, trace, ctx, seedAttempts) {
|
|
118
|
+
const { page, originalSelector, action, description, testFile } = input;
|
|
119
|
+
const cacheDir = input.cacheDir ?? (process.env.HEAL_CACHE_DIR ?? join(process.cwd(), '.healed-locators'));
|
|
120
|
+
const snippet = readSnippet(trace.callSite);
|
|
121
|
+
const overlay = await injectOverlay(page);
|
|
122
|
+
try {
|
|
123
|
+
await overlay.setState({
|
|
124
|
+
stage: 'pick', originalSelector, action, description,
|
|
125
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
126
|
+
sourceSnippet: snippet, ...statusFields({ statusLabel: 'PICK' }),
|
|
127
|
+
});
|
|
128
|
+
await overlay.enablePickMode();
|
|
129
|
+
let pickedHandle = null;
|
|
130
|
+
let pickedXpath = '';
|
|
131
|
+
let pickedHtml = '';
|
|
132
|
+
let intentText = description ?? '';
|
|
133
|
+
let intentSubmitted = false;
|
|
134
|
+
let forceSkipPreflight = false;
|
|
135
|
+
// Loop until BOTH a pick happened AND user explicitly submitted intent.
|
|
136
|
+
// Auto-derived description pre-fills the textarea but never auto-fires.
|
|
137
|
+
while (!pickedHandle || !intentSubmitted) {
|
|
138
|
+
const msg = await overlay.waitForMessage();
|
|
139
|
+
if (msg.type === 'cancel') {
|
|
140
|
+
await appendAuditV2(cacheDir, { event: 'cancel', ...ctx });
|
|
141
|
+
throw new HealCancelledError('user cancelled heal', ctx);
|
|
142
|
+
}
|
|
143
|
+
if (msg.type === 'pick-error') {
|
|
144
|
+
await appendAuditV2(cacheDir, { event: 'pick-error', ...ctx, reason: msg.reason });
|
|
145
|
+
throw new HealingError(`[heal] ${msg.reason}: cannot reach element inside closed shadow DOM at ${msg.xpath}. ` +
|
|
146
|
+
`Closed shadow roots are not supported.`, 'shadow-unsupported');
|
|
147
|
+
}
|
|
148
|
+
if (msg.type === 'pick') {
|
|
149
|
+
pickedXpath = msg.xpath;
|
|
150
|
+
pickedHtml = msg.html;
|
|
151
|
+
pickedHandle = await page.locator(`xpath=${pickedXpath}`).elementHandle();
|
|
152
|
+
await overlay.disablePickMode();
|
|
153
|
+
// Compute pick-preview info and show the PICK-PREVIEW stage.
|
|
154
|
+
const pickPreview = pickedHandle
|
|
155
|
+
? await computePickPreview(pickedHandle)
|
|
156
|
+
: undefined;
|
|
157
|
+
await overlay.setState({
|
|
158
|
+
stage: 'pick-preview', originalSelector, action, description,
|
|
159
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
160
|
+
sourceSnippet: snippet, pickPreview,
|
|
161
|
+
...statusFields({ statusLabel: 'PICK · PREVIEW' }),
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (msg.type === 'pick-preview-repick') {
|
|
166
|
+
// Return to pick stage and re-enable pick mode.
|
|
167
|
+
pickedHandle = null;
|
|
168
|
+
pickedXpath = '';
|
|
169
|
+
pickedHtml = '';
|
|
170
|
+
await overlay.setState({
|
|
171
|
+
stage: 'pick', originalSelector, action, description,
|
|
172
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame, sourceSnippet: snippet,
|
|
173
|
+
...statusFields({ statusLabel: 'PICK' }),
|
|
174
|
+
});
|
|
175
|
+
await overlay.enablePickMode();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (msg.type === 'pick-preview-continue') {
|
|
179
|
+
// Advance to intent stage with the picked info preserved.
|
|
180
|
+
if (!pickedHandle)
|
|
181
|
+
continue; // no pick yet — ignore stale message
|
|
182
|
+
await overlay.setState({
|
|
183
|
+
stage: 'intent', originalSelector, action, description,
|
|
184
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
185
|
+
pickedHtml, intentText, sourceSnippet: snippet,
|
|
186
|
+
...statusFields({ statusLabel: 'INTENT' }),
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (msg.type === 'force-llm') {
|
|
191
|
+
// Require a pick first — ignore otherwise.
|
|
192
|
+
if (!pickedHandle)
|
|
193
|
+
continue;
|
|
194
|
+
forceSkipPreflight = true;
|
|
195
|
+
await appendAuditV2(cacheDir, { event: 'force-llm', ...ctx });
|
|
196
|
+
// Re-render intent stage so user types context, then presses propose.
|
|
197
|
+
await overlay.setState({
|
|
198
|
+
stage: 'intent', originalSelector, action, description,
|
|
199
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
200
|
+
pickedHtml, intentText, sourceSnippet: snippet,
|
|
201
|
+
...statusFields({ statusLabel: 'INTENT · FORCE LLM' }),
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (msg.type === 'intent') {
|
|
206
|
+
intentText = msg.text || description || '';
|
|
207
|
+
intentSubmitted = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (msg.type === 'reject') {
|
|
211
|
+
pickedHandle = null;
|
|
212
|
+
pickedXpath = '';
|
|
213
|
+
pickedHtml = '';
|
|
214
|
+
forceSkipPreflight = false;
|
|
215
|
+
await overlay.setState({
|
|
216
|
+
stage: 'pick', originalSelector, action, description,
|
|
217
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame, sourceSnippet: snippet,
|
|
218
|
+
...statusFields({ statusLabel: 'PICK' }),
|
|
219
|
+
});
|
|
220
|
+
await overlay.enablePickMode();
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Stage 3 — preflight (skipped if user pressed force-LLM)
|
|
225
|
+
const pre = !forceSkipPreflight
|
|
226
|
+
? await (async () => {
|
|
227
|
+
await overlay.setState({
|
|
228
|
+
stage: 'intent', originalSelector, action, description,
|
|
229
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
230
|
+
pickedHtml, intentText, sourceSnippet: snippet, loading: true,
|
|
231
|
+
loadingLabel: 'PREFLIGHT',
|
|
232
|
+
...statusFields({ statusLabel: 'PREFLIGHT' }),
|
|
233
|
+
});
|
|
234
|
+
return tryPreflight(page, pickedHandle);
|
|
235
|
+
})()
|
|
236
|
+
: null;
|
|
237
|
+
if (pre) {
|
|
238
|
+
// P0-4: preflight semantic guard. Preflight derives a selector from
|
|
239
|
+
// the picked element's own attrs — it cannot detect a wrong-element
|
|
240
|
+
// pick (operator picks the username input but intent says "login
|
|
241
|
+
// button"). Refuse to cache obvious mismatches between the picked
|
|
242
|
+
// element's tag/role and the action verb / intent keywords.
|
|
243
|
+
const preflightMismatch = await detectPreflightMismatch(pickedHandle, action, intentText);
|
|
244
|
+
if (preflightMismatch) {
|
|
245
|
+
await appendAuditV2(cacheDir, {
|
|
246
|
+
event: 'preflight-rejected', ...ctx,
|
|
247
|
+
healedSelector: pre.selector, reason: preflightMismatch,
|
|
248
|
+
});
|
|
249
|
+
// fall through to LLM round — operator may have picked wrong, give them another chance
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
// P0-1a: capture fingerprint of the picked element so future cache
|
|
253
|
+
// hits can detect drift (DOM rolled forward, page rewritten, etc).
|
|
254
|
+
const fingerprint = await captureFingerprint(pickedHandle).catch(() => undefined);
|
|
255
|
+
const entry = synthEntry(originalSelector, pre.selectorType, pre.selector, intentText, action, trace, 1, fingerprint);
|
|
256
|
+
await cacheV2Set(cacheDir, testFile, entry);
|
|
257
|
+
await appendReport(cacheDir, entry);
|
|
258
|
+
await appendAuditV2(cacheDir, { event: 'preflight-hit', ...ctx, healedSelector: pre.selector, tier: 1 });
|
|
259
|
+
return entry;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Stage 4 — progressive rounds
|
|
263
|
+
const attempts = [...seedAttempts];
|
|
264
|
+
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
|
265
|
+
if (isBudgetExceeded()) {
|
|
266
|
+
await appendAuditV2(cacheDir, { event: 'budget-exceeded', ...ctx, round });
|
|
267
|
+
throw new HealExhaustedError('budget exceeded', { ...ctx, attempts: round });
|
|
268
|
+
}
|
|
269
|
+
const candidates = await extractScopedCandidates(page, pickedHandle, round);
|
|
270
|
+
const { ancestorsHtml, structured } = await pickedHandle.evaluate(extractStructured);
|
|
271
|
+
// Wire abort: any cancel-llm message from overlay aborts the in-flight call.
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
let abortKind = null;
|
|
274
|
+
overlay.setAbortHandler((m) => {
|
|
275
|
+
if (m.type === 'cancel-llm') {
|
|
276
|
+
abortKind = 'cancel-llm';
|
|
277
|
+
controller.abort();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (m.type === 'cancel') {
|
|
281
|
+
abortKind = 'cancel';
|
|
282
|
+
controller.abort();
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
});
|
|
287
|
+
overlay.setCancelInFlight(() => controller.abort());
|
|
288
|
+
const llmStartedAt = Date.now();
|
|
289
|
+
await overlay.setState({
|
|
290
|
+
stage: 'intent', originalSelector, action, description,
|
|
291
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
292
|
+
pickedHtml, intentText, loading: true, loadingLabel: `LLM R${round}`,
|
|
293
|
+
sourceSnippet: snippet, round,
|
|
294
|
+
...statusFields({ statusLabel: `LLM R${round}`, llmStartedAt }),
|
|
295
|
+
});
|
|
296
|
+
let suggestions;
|
|
297
|
+
try {
|
|
298
|
+
suggestions = await requestHealedLocator({
|
|
299
|
+
picked: structured, ancestorsHtml, intent: intentText, action,
|
|
300
|
+
originalSelector, pageUrl: page.url(),
|
|
301
|
+
candidates, previousAttempts: attempts, round: round,
|
|
302
|
+
signal: controller.signal,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
overlay.setCancelInFlight(null);
|
|
307
|
+
overlay.setAbortHandler(null);
|
|
308
|
+
const aborted = abortKind !== null || (e instanceof HealingError && e.code === 'aborted');
|
|
309
|
+
if (aborted) {
|
|
310
|
+
await appendAuditV2(cacheDir, { event: 'llm-aborted', ...ctx, round });
|
|
311
|
+
if (abortKind === 'cancel') {
|
|
312
|
+
await appendAuditV2(cacheDir, { event: 'cancel', ...ctx });
|
|
313
|
+
throw new HealCancelledError('user cancelled heal', ctx);
|
|
314
|
+
}
|
|
315
|
+
await overlay.setState({
|
|
316
|
+
stage: 'intent', originalSelector, action, description,
|
|
317
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
318
|
+
pickedHtml, intentText, sourceSnippet: snippet,
|
|
319
|
+
...statusFields({ statusLabel: 'INTENT (LLM cancelled · edit & retry)' }),
|
|
320
|
+
});
|
|
321
|
+
while (true) {
|
|
322
|
+
const m = await overlay.waitForMessage();
|
|
323
|
+
if (m.type === 'cancel') {
|
|
324
|
+
await appendAuditV2(cacheDir, { event: 'cancel', ...ctx });
|
|
325
|
+
throw new HealCancelledError('user cancelled heal', ctx);
|
|
326
|
+
}
|
|
327
|
+
if (m.type === 'intent') {
|
|
328
|
+
intentText = m.text || intentText;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
round--;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
await appendAuditV2(cacheDir, { event: 'llm-error', ...ctx, error: String(e), round });
|
|
336
|
+
throw new HealLLMError('llm call failed', ctx, e);
|
|
337
|
+
}
|
|
338
|
+
overlay.setCancelInFlight(null);
|
|
339
|
+
overlay.setAbortHandler(null);
|
|
340
|
+
await appendAuditV2(cacheDir, {
|
|
341
|
+
event: 'llm-suggestions', ...ctx, round,
|
|
342
|
+
suggestions: suggestions.map(s => ({
|
|
343
|
+
selectorType: s.selectorType, selector: s.selector, tier: s.tier ?? 6,
|
|
344
|
+
})),
|
|
345
|
+
});
|
|
346
|
+
const result = await tryValidateAndConfirm(page, overlay, suggestions, ctx, trace, cacheDir, testFile, intentText, action, attempts, round, pickedHtml, pickedHandle);
|
|
347
|
+
if (result.kind === 'accepted')
|
|
348
|
+
return result.entry;
|
|
349
|
+
if (result.kind === 'cancel')
|
|
350
|
+
throw new HealCancelledError('user cancelled at confirm', ctx);
|
|
351
|
+
if (result.kind === 'edit-intent') {
|
|
352
|
+
// User rejected a suggestion → return to intent stage, preserve text,
|
|
353
|
+
// wait for them to revise and explicitly send again. LLM does NOT
|
|
354
|
+
// auto-fire — user must press ↵ propose.
|
|
355
|
+
await overlay.setState({
|
|
356
|
+
stage: 'intent',
|
|
357
|
+
originalSelector: ctx.originalSelector, action,
|
|
358
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
359
|
+
pickedHtml, intentText,
|
|
360
|
+
...statusFields({ statusLabel: 'INTENT — revise & send' }),
|
|
361
|
+
});
|
|
362
|
+
while (true) {
|
|
363
|
+
const m = await overlay.waitForMessage();
|
|
364
|
+
if (m.type === 'cancel') {
|
|
365
|
+
await appendAuditV2(cacheDir, { event: 'cancel', ...ctx });
|
|
366
|
+
throw new HealCancelledError('user cancelled at edit-intent', ctx);
|
|
367
|
+
}
|
|
368
|
+
if (m.type === 'intent') {
|
|
369
|
+
intentText = m.text || intentText;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Loop will re-enter the round with same number — fresh LLM call w/ new intent.
|
|
374
|
+
round--;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// result.kind === 'retry' — all suggestions invalid → ask broaden.
|
|
378
|
+
if (round < MAX_ROUNDS) {
|
|
379
|
+
const ok = await askBroaden(overlay, round);
|
|
380
|
+
if (!ok)
|
|
381
|
+
throw new HealCancelledError('user declined broaden', ctx);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await appendAuditV2(cacheDir, { event: 'exhausted', ...ctx, attempts: attempts.length });
|
|
385
|
+
throw new HealExhaustedError(`exhausted ${MAX_ROUNDS} rounds`, { ...ctx, attempts: MAX_ROUNDS });
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await overlay.destroy().catch((err) => {
|
|
389
|
+
logWarn('best-effort overlay teardown failed:', err.message);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function askBroaden(overlay, round) {
|
|
394
|
+
await overlay.setState({
|
|
395
|
+
stage: 'broaden', round,
|
|
396
|
+
message: `Round ${round} found no unique match. Broaden scope and retry?`,
|
|
397
|
+
...statusFields({ statusLabel: 'BROADEN?' }),
|
|
398
|
+
});
|
|
399
|
+
const msg = await overlay.waitForMessage();
|
|
400
|
+
return msg.type === 'broaden';
|
|
401
|
+
}
|
|
402
|
+
async function tryValidateAndConfirm(page, overlay, suggestions, ctx, trace, cacheDir, testFile, intentText, action, attempts, round, pickedHtml, pickedHandle) {
|
|
403
|
+
for (const sug of suggestions) {
|
|
404
|
+
const tier = sug.tier ?? tierOfType(sug.selectorType);
|
|
405
|
+
// js tier — validate via evaluate, not locator
|
|
406
|
+
if (sug.selectorType === 'js') {
|
|
407
|
+
const ok = await validateJs(page, sug.selector);
|
|
408
|
+
if (!ok) {
|
|
409
|
+
attempts.push({ selectorType: sug.selectorType, selector: sug.selector, tier, reason: 'js-eval-error' });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const decision = await confirmWith(overlay, sug, ctx, trace, intentText, pickedHtml);
|
|
413
|
+
if (decision === 'accept') {
|
|
414
|
+
await appendAuditV2(cacheDir, { event: 'js-accepted', ...ctx, round, selector: sug.selector });
|
|
415
|
+
// audit-only, NOT cached
|
|
416
|
+
return { kind: 'accepted', entry: synthEntry(ctx.originalSelector, 'css', sug.selector, intentText, action, trace, 9) };
|
|
417
|
+
}
|
|
418
|
+
if (decision === 'cancel')
|
|
419
|
+
return { kind: 'cancel' };
|
|
420
|
+
attempts.push({ selectorType: sug.selectorType, selector: sug.selector, tier, reason: 'js-eval-error' });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
let activeSug = sug;
|
|
424
|
+
let loc = buildLocator(page, activeSug);
|
|
425
|
+
let v = await validateProposedLocator(loc, pickedHandle);
|
|
426
|
+
// P0-5: when an exact role+name match returns count===0, retry with a
|
|
427
|
+
// case-insensitive regex variant. Accessible names commonly carry
|
|
428
|
+
// whitespace from adjacent icons (` Login` vs `Login`); the relaxed
|
|
429
|
+
// form heals those without bothering the operator with another round.
|
|
430
|
+
if (!v.ok && v.count === 0) {
|
|
431
|
+
const relaxed = relaxRoleNameMatch(activeSug);
|
|
432
|
+
if (relaxed) {
|
|
433
|
+
const relaxedLoc = buildLocator(page, relaxed);
|
|
434
|
+
const v2 = await validateProposedLocator(relaxedLoc, pickedHandle);
|
|
435
|
+
if (v2.ok) {
|
|
436
|
+
activeSug = relaxed;
|
|
437
|
+
loc = relaxedLoc;
|
|
438
|
+
v = v2;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (!v.ok) {
|
|
443
|
+
const reason = v.reason === 'strict-mode-violation' ? 'strict-mode-violation'
|
|
444
|
+
: v.count > 1 ? 'count-many'
|
|
445
|
+
: v.count === 0 ? 'count-zero'
|
|
446
|
+
: 'invalid-selector';
|
|
447
|
+
attempts.push({ selectorType: activeSug.selectorType, selector: activeSug.selector, tier, reason, matchedCount: v.count });
|
|
448
|
+
await appendAuditV2(cacheDir, {
|
|
449
|
+
event: 'validate', ...ctx, round,
|
|
450
|
+
selectorType: activeSug.selectorType, selector: activeSug.selector, ok: false, reason: v.reason ?? '',
|
|
451
|
+
});
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// Pre-cache actionability gate: count===1+visible isn't sufficient — the
|
|
455
|
+
// element must actually accept the requested action (not overlaid,
|
|
456
|
+
// disabled, or otherwise unactionable). Without this, cached selectors
|
|
457
|
+
// poison subsequent runs.
|
|
458
|
+
const act = await verifyActionable(loc, action);
|
|
459
|
+
if (!act.ok) {
|
|
460
|
+
attempts.push({ selectorType: activeSug.selectorType, selector: activeSug.selector, tier, reason: 'count-zero', matchedCount: v.count });
|
|
461
|
+
await appendAuditV2(cacheDir, {
|
|
462
|
+
event: 'validate', ...ctx, round,
|
|
463
|
+
selectorType: activeSug.selectorType, selector: activeSug.selector, ok: false, reason: `actionability: ${act.reason ?? 'unknown'}`,
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const decision = await confirmWith(overlay, activeSug, ctx, trace, intentText, pickedHtml);
|
|
468
|
+
if (decision === 'accept') {
|
|
469
|
+
const fingerprint = pickedHandle ? await captureFingerprint(pickedHandle).catch(() => undefined) : undefined;
|
|
470
|
+
const entry = {
|
|
471
|
+
originalSelector: ctx.originalSelector, action,
|
|
472
|
+
healedSelector: activeSug.selector,
|
|
473
|
+
selectorType: activeSug.selectorType,
|
|
474
|
+
tier,
|
|
475
|
+
intent: intentText,
|
|
476
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
477
|
+
healedAt: new Date().toISOString(),
|
|
478
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
479
|
+
};
|
|
480
|
+
await cacheV2Set(cacheDir, testFile, entry);
|
|
481
|
+
await appendReport(cacheDir, entry);
|
|
482
|
+
await appendAuditV2(cacheDir, { event: 'accept', ...ctx, round, tier, healedSelector: activeSug.selector });
|
|
483
|
+
return { kind: 'accepted', entry };
|
|
484
|
+
}
|
|
485
|
+
if (decision === 'cancel')
|
|
486
|
+
return { kind: 'cancel' };
|
|
487
|
+
// rejected → user wants to revise intent and re-fire LLM.
|
|
488
|
+
// Drop remaining suggestions in this batch and bubble back up.
|
|
489
|
+
attempts.push({ selectorType: activeSug.selectorType, selector: activeSug.selector, tier, reason: 'count-zero', matchedCount: 1 });
|
|
490
|
+
return { kind: 'edit-intent' };
|
|
491
|
+
}
|
|
492
|
+
return { kind: 'retry' };
|
|
493
|
+
}
|
|
494
|
+
async function confirmWith(overlay, sug, ctx, trace, intentText, pickedHtml) {
|
|
495
|
+
await overlay.setState({
|
|
496
|
+
stage: 'confirm', originalSelector: ctx.originalSelector, action: ctx.action,
|
|
497
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
498
|
+
intentText,
|
|
499
|
+
pickedHtml: pickedHtml ?? '',
|
|
500
|
+
proposedSelector: `${sug.selectorType}=${sug.selector}`,
|
|
501
|
+
proposedCode: toPlaywrightCode(sug.selectorType, sug.selector, ctx.action),
|
|
502
|
+
matchCount: 1,
|
|
503
|
+
...statusFields({ statusLabel: 'CONFIRM' }),
|
|
504
|
+
});
|
|
505
|
+
const msg = await overlay.waitForMessage();
|
|
506
|
+
if (msg.type === 'accept')
|
|
507
|
+
return 'accept';
|
|
508
|
+
if (msg.type === 'cancel')
|
|
509
|
+
return 'cancel';
|
|
510
|
+
return 'reject';
|
|
511
|
+
}
|
|
512
|
+
// Helpers (safeCount, validateJs, tierOfType, extractStructured,
|
|
513
|
+
// tryForegroundUpgrade) moved to ./orchestrator-helpers.ts.
|
|
514
|
+
// toPlaywrightCode lives in ./playwright-code.ts; re-exported for the
|
|
515
|
+
// stable public import path:
|
|
516
|
+
export { toPlaywrightCode } from './playwright-code.js';
|
|
517
|
+
/**
|
|
518
|
+
* Compute a `PickPreviewInfo` from an `ElementHandle`. Runs entirely
|
|
519
|
+
* client-side via `evaluate` — no LLM involved.
|
|
520
|
+
*
|
|
521
|
+
* Stable-handle priority: `#id` → `[data-testid]` → `[data-test]` →
|
|
522
|
+
* `[data-cy]` → `[data-qa]` → `[aria-label]` → `role+name`.
|
|
523
|
+
*/
|
|
524
|
+
async function computePickPreview(handle) {
|
|
525
|
+
return handle.evaluate((el) => {
|
|
526
|
+
const tag = el.tagName.toLowerCase();
|
|
527
|
+
// Role: explicit role attr first, then implicit from tag.
|
|
528
|
+
const explicitRole = el.getAttribute('role') ?? '';
|
|
529
|
+
const implicitRoleMap = {
|
|
530
|
+
button: 'button', a: 'link', input: 'textbox', textarea: 'textbox',
|
|
531
|
+
select: 'combobox', img: 'img', nav: 'navigation', main: 'main',
|
|
532
|
+
header: 'banner', footer: 'contentinfo', form: 'form', table: 'table',
|
|
533
|
+
ul: 'list', ol: 'list', li: 'listitem', h1: 'heading', h2: 'heading',
|
|
534
|
+
h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
|
|
535
|
+
};
|
|
536
|
+
const role = explicitRole || implicitRoleMap[tag] || tag;
|
|
537
|
+
// Accessible name: aria-label → aria-labelledby text → visible text trimmed to 80.
|
|
538
|
+
const ariaLabel = (el.getAttribute('aria-label') ?? '').trim();
|
|
539
|
+
let accessibleName = ariaLabel;
|
|
540
|
+
if (!accessibleName) {
|
|
541
|
+
const labelledById = el.getAttribute('aria-labelledby');
|
|
542
|
+
if (labelledById) {
|
|
543
|
+
const ref = document.getElementById(labelledById);
|
|
544
|
+
accessibleName = (ref?.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!accessibleName) {
|
|
548
|
+
accessibleName = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
549
|
+
}
|
|
550
|
+
accessibleName = accessibleName || '(no accessible name)';
|
|
551
|
+
// Text: first 60 chars textContent single-line.
|
|
552
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
553
|
+
// Stable handle: prefer most-stable attribute.
|
|
554
|
+
let stableHandle = '';
|
|
555
|
+
const id = el.id ? el.id.trim() : '';
|
|
556
|
+
if (id) {
|
|
557
|
+
stableHandle = `#${id}`;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const testid = el.getAttribute('data-testid') ?? el.getAttribute('data-test') ??
|
|
561
|
+
el.getAttribute('data-cy') ?? el.getAttribute('data-qa') ?? '';
|
|
562
|
+
if (testid) {
|
|
563
|
+
const attr = el.getAttribute('data-testid') !== null ? 'data-testid'
|
|
564
|
+
: el.getAttribute('data-test') !== null ? 'data-test'
|
|
565
|
+
: el.getAttribute('data-cy') !== null ? 'data-cy'
|
|
566
|
+
: 'data-qa';
|
|
567
|
+
stableHandle = `[${attr}="${testid}"]`;
|
|
568
|
+
}
|
|
569
|
+
else if (ariaLabel) {
|
|
570
|
+
stableHandle = `[aria-label="${ariaLabel}"]`;
|
|
571
|
+
}
|
|
572
|
+
else if (role && accessibleName && accessibleName !== '(no accessible name)') {
|
|
573
|
+
const nameSlice = accessibleName.slice(0, 40);
|
|
574
|
+
stableHandle = `${role}[name="${nameSlice}"]`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return { tag, role, accessibleName, text, stableHandle };
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Construct a `HealEntryV2` record. Used both for cache writes and for
|
|
582
|
+
* the live re-check / preflight paths that never round-trip the LLM.
|
|
583
|
+
*
|
|
584
|
+
* @param original - Original (broken) selector string.
|
|
585
|
+
* @param type - Healed selector kind (`css`, `role`, `testid`, ...).
|
|
586
|
+
* @param healed - Healed selector body (no `kind=` prefix).
|
|
587
|
+
* @param intent - Natural-language intent string captured from the user.
|
|
588
|
+
* @param action - Playwright action verb (`click`, `fill`, ...).
|
|
589
|
+
* @param trace - Parsed source-trace `{ callSite, pomFrame }`.
|
|
590
|
+
* @param tier - Selector tier 1-7 (default 6 = xpath fallback).
|
|
591
|
+
* @returns A fully populated `HealEntryV2` with `healedAt` set to now.
|
|
592
|
+
*/
|
|
593
|
+
/**
|
|
594
|
+
* Cheap deterministic guard against the "operator picked the wrong element"
|
|
595
|
+
* failure mode. Inspects tag + intent text + action verb. Returns a reason
|
|
596
|
+
* string if the picked element is incompatible with the action, else null.
|
|
597
|
+
*
|
|
598
|
+
* Examples caught:
|
|
599
|
+
* - intent="login button", action="click", picked=`<input type="text">` → "input-for-button-intent"
|
|
600
|
+
* - action="fill", picked=`<button>` → "button-for-fill"
|
|
601
|
+
*
|
|
602
|
+
* Not a perfect semantic check; just blocks the most common adversarial picks
|
|
603
|
+
* the QA suite found. The overlay PICK-PREVIEW pane (Batch D) is the primary
|
|
604
|
+
* defense — this is belt-and-suspenders.
|
|
605
|
+
*/
|
|
606
|
+
async function detectPreflightMismatch(handle, action, intentText) {
|
|
607
|
+
try {
|
|
608
|
+
const tag = await handle.evaluate((el) => el.tagName.toLowerCase());
|
|
609
|
+
const typeAttr = await handle.evaluate((el) => el.getAttribute('type') || '');
|
|
610
|
+
const intent = intentText.toLowerCase();
|
|
611
|
+
const BUTTON_HINTS = ['button', 'submit', 'click', 'login', 'signin', 'sign in', 'go ', 'continue', 'confirm'];
|
|
612
|
+
const FILL_HINTS = ['fill', 'enter', 'type', 'input', 'textbox', 'password', 'email', 'username'];
|
|
613
|
+
const looksLikeButtonIntent = BUTTON_HINTS.some((kw) => intent.includes(kw));
|
|
614
|
+
const looksLikeFillIntent = FILL_HINTS.some((kw) => intent.includes(kw));
|
|
615
|
+
const isClickAction = action === 'click' || action === 'tap' || action === 'dblclick';
|
|
616
|
+
const isFillAction = action === 'fill' || action === 'type' || action === 'pressSequentially' || action === 'clear';
|
|
617
|
+
const isTextInput = tag === 'textarea' || (tag === 'input' && !['button', 'submit', 'reset', 'image', 'checkbox', 'radio'].includes(typeAttr.toLowerCase()));
|
|
618
|
+
const isButton = tag === 'button' || (tag === 'input' && ['button', 'submit', 'reset', 'image'].includes(typeAttr.toLowerCase()));
|
|
619
|
+
if (isClickAction && isTextInput && looksLikeButtonIntent) {
|
|
620
|
+
return `input-for-button-intent: picked <${tag}${typeAttr ? ` type="${typeAttr}"` : ''}> but action=click + intent mentions button/submit`;
|
|
621
|
+
}
|
|
622
|
+
if (isFillAction && isButton) {
|
|
623
|
+
return `button-for-fill: picked <${tag}> but action=${action} expects a fillable field`;
|
|
624
|
+
}
|
|
625
|
+
if (isClickAction && looksLikeFillIntent && isTextInput) {
|
|
626
|
+
return `input-for-fill-intent-but-click-action: intent looks like fill but action is click`;
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// intentionally swallowed: pick handle could be detached → can't guard, let it through
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
export function synthEntry(original, type, healed, intent, action, trace, tier = 6, fingerprint) {
|
|
636
|
+
return {
|
|
637
|
+
originalSelector: original, action, healedSelector: healed,
|
|
638
|
+
selectorType: type, tier, intent,
|
|
639
|
+
callSite: trace.callSite, pomFrame: trace.pomFrame,
|
|
640
|
+
healedAt: new Date().toISOString(),
|
|
641
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
//# sourceMappingURL=orchestrator.js.map
|