@vijaypjavvadi/bdd2pw 2.0.2 → 2.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/CHANGELOG.md +136 -0
- package/dist/cli.js +13 -0
- package/dist/cli.js.map +1 -1
- package/dist/emitters/facade.js +35 -13
- package/dist/emitters/facade.js.map +1 -1
- package/dist/index.js +25 -2
- package/dist/index.js.map +1 -1
- package/dist/llm/anthropicClient.d.ts +2 -0
- package/dist/llm/anthropicClient.js +28 -2
- package/dist/llm/anthropicClient.js.map +1 -1
- package/dist/llm/governanceClient.d.ts +2 -1
- package/dist/llm/governanceClient.js +13 -2
- package/dist/llm/governanceClient.js.map +1 -1
- package/dist/llm/llmStepMatcher.d.ts +11 -0
- package/dist/llm/llmStepMatcher.js +26 -1
- package/dist/llm/llmStepMatcher.js.map +1 -1
- package/dist/llm/types.d.ts +33 -1
- package/dist/transformers/stepMatcher.js +15 -2
- package/dist/transformers/stepMatcher.js.map +1 -1
- package/dist/transformers/stepNormalizer.d.ts +36 -0
- package/dist/transformers/stepNormalizer.js +153 -0
- package/dist/transformers/stepNormalizer.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
- package/templates/heal.ts.tmpl +302 -58
package/templates/heal.ts.tmpl
CHANGED
|
@@ -1,64 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Self-healing locator helper — v1.
|
|
2
|
+
* Self-healing locator helper — v1.2 scaffold.
|
|
3
3
|
*
|
|
4
4
|
* GENERATED FILE. Do not edit directly; this file is rewritten on every
|
|
5
5
|
* `bdd2pw scaffold --self-healing` invocation. To customise, fork it under
|
|
6
6
|
* a different filename and update the `tsconfig.json` `paths` mapping.
|
|
7
7
|
*
|
|
8
|
-
* What this provides today (v1.
|
|
8
|
+
* What this provides today (v1.2)
|
|
9
9
|
* --------------------------------
|
|
10
|
-
* - `healOrThrow(page, { preferred, context })` —
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* - `healOrThrow(page, { preferred, context })` — registers the locator
|
|
11
|
+
* to `artefacts/heal-events.jsonl` AND wraps it in a `Proxy` that
|
|
12
|
+
* intercepts every healable action method (.click, .fill, .check,
|
|
13
|
+
* .selectOption, .hover, .focus, .textContent, .innerText,
|
|
14
|
+
* .getAttribute, .isVisible, .waitFor, .press, .type, .setChecked,
|
|
15
|
+
* .uncheck, .clear, .scrollIntoViewIfNeeded). On the FIRST action
|
|
16
|
+
* failure for a given locator, the proxy:
|
|
17
|
+
* 1. Logs a `heal_attempt` event with the original error.
|
|
18
|
+
* 2. Asks the self-healing service (when SELF_HEALING_URL is set)
|
|
19
|
+
* for a candidate replacement selector via POST /api/v1/heal.
|
|
20
|
+
* 3. Re-runs the original action against `page.locator(<candidate>)`.
|
|
21
|
+
* 4. On success, logs a `healed` event; subsequent actions on the
|
|
22
|
+
* SAME locator instance use the candidate transparently.
|
|
23
|
+
* 5. On failure (no service, no candidate, or candidate also fails),
|
|
24
|
+
* logs `heal_unavailable` and re-throws the original error so
|
|
25
|
+
* the test still fails honestly.
|
|
14
26
|
* - Optional fire-and-forget heartbeat to `${SELF_HEALING_URL}/api/v1/register`
|
|
15
|
-
* so the service knows which locators a test references.
|
|
16
|
-
*
|
|
17
|
-
* service doesn't break tests.
|
|
27
|
+
* so the service knows which locators a test references. Best-effort
|
|
28
|
+
* POST — failures are silently swallowed.
|
|
18
29
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* Locator-wrapping proxy that intercepts every action method (.click,
|
|
25
|
-
* .fill, .check, .selectOption, .hover, .focus, .textContent,
|
|
26
|
-
* .innerText, .getAttribute, .isVisible, .waitForSelector, ...). The
|
|
27
|
-
* existing self-healing SDK does this for *string-selector*-based test
|
|
28
|
-
* code (`page.fill('#username', ...)`); pw-emit's POMs use Locator
|
|
29
|
-
* objects (`this.usernameInput.fill(...)`), so the v1.2 wrapper has a
|
|
30
|
-
* different shape.
|
|
31
|
-
*
|
|
32
|
-
* Until v1.2 ships, this file is a registration scaffold: the offline
|
|
33
|
-
* self-heal pipeline (`E:\EB1A_Research\self_healing_stage_services`)
|
|
34
|
-
* sees what locators each test references via the JSONL feed, but
|
|
35
|
-
* recovery still has to happen through that pipeline's batch processing
|
|
36
|
-
* rather than at action time.
|
|
30
|
+
* Action-time healing intentionally fires only on ACTION failures (click,
|
|
31
|
+
* fill, etc.), not on locator construction — `page.getByLabel("Username")`
|
|
32
|
+
* is just an object factory and never throws. The proxy adds zero overhead
|
|
33
|
+
* on the happy path: every method call passes through unmodified, the
|
|
34
|
+
* try/catch only kicks in when the underlying Playwright call rejects.
|
|
37
35
|
*
|
|
38
36
|
* Environment variables
|
|
39
37
|
* ---------------------
|
|
40
|
-
* SELF_HEALING_URL Base URL of the self-healing service
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* JSONL.
|
|
46
|
-
*
|
|
47
|
-
* HEAL_EVENTS_PATH Override the JSONL output path. Defaults to
|
|
48
|
-
* `artefacts/heal-events.jsonl` relative to the
|
|
49
|
-
* process cwd (typically the repo root when running
|
|
50
|
-
* `npx playwright test`).
|
|
51
|
-
*
|
|
52
|
-
* HEAL_DISABLE Set to "1" or "true" to disable both JSONL writes
|
|
53
|
-
* and heartbeats. Useful in CI when you don't want
|
|
54
|
-
* the artefact noise.
|
|
38
|
+
* SELF_HEALING_URL Base URL of the self-healing service.
|
|
39
|
+
* HEAL_EVENTS_PATH Override the JSONL output path (default
|
|
40
|
+
* `artefacts/heal-events.jsonl`).
|
|
41
|
+
* HEAL_DISABLE "1"/"true" disables JSONL writes + heartbeats +
|
|
42
|
+
* heal-service calls. Useful in CI.
|
|
55
43
|
*/
|
|
56
44
|
|
|
57
45
|
import * as fs from "fs";
|
|
58
46
|
import * as path from "path";
|
|
59
47
|
import * as http from "http";
|
|
60
48
|
import * as https from "https";
|
|
61
|
-
import type
|
|
49
|
+
import { test, type Locator, type Page } from "@playwright/test";
|
|
62
50
|
|
|
63
51
|
export interface HealContext {
|
|
64
52
|
/** PascalCase Page Object class name, e.g. "LoginPage". */
|
|
@@ -79,6 +67,27 @@ interface HealEvent {
|
|
|
79
67
|
event: "register";
|
|
80
68
|
page: string;
|
|
81
69
|
name: string;
|
|
70
|
+
/** Scenario / test title at the time the locator was registered. */
|
|
71
|
+
scenario_name: string | null;
|
|
72
|
+
pid: number;
|
|
73
|
+
cwd: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface HealAttemptEvent {
|
|
77
|
+
ts: string;
|
|
78
|
+
event: "heal_attempt" | "healed" | "heal_unavailable";
|
|
79
|
+
page: string;
|
|
80
|
+
name: string;
|
|
81
|
+
scenario_name: string | null;
|
|
82
|
+
method: string;
|
|
83
|
+
/** Original locator selector (best-effort serialisation). */
|
|
84
|
+
original: string | null;
|
|
85
|
+
/** Replacement selector when known. */
|
|
86
|
+
healed: string | null;
|
|
87
|
+
/** Confidence in the replacement, 0–1, when the service provided one. */
|
|
88
|
+
confidence: number | null;
|
|
89
|
+
/** Truncated error message that triggered the heal attempt. */
|
|
90
|
+
error: string | null;
|
|
82
91
|
pid: number;
|
|
83
92
|
cwd: string;
|
|
84
93
|
}
|
|
@@ -103,7 +112,7 @@ function ensureArtefactsDir(): string {
|
|
|
103
112
|
return fullPath;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
function appendEvent(event: HealEvent): void {
|
|
115
|
+
function appendEvent(event: HealEvent | HealAttemptEvent): void {
|
|
107
116
|
if (DISABLED) return;
|
|
108
117
|
const fullPath = ensureArtefactsDir();
|
|
109
118
|
try {
|
|
@@ -113,6 +122,20 @@ function appendEvent(event: HealEvent): void {
|
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Best-effort scenario-name capture. test.info() throws when called
|
|
127
|
+
* outside a running test (`Test info is not available outside the test
|
|
128
|
+
* lifecycle`), so we swallow and return null.
|
|
129
|
+
*/
|
|
130
|
+
function currentScenarioName(): string | null {
|
|
131
|
+
try {
|
|
132
|
+
const info = test.info();
|
|
133
|
+
return info?.title ?? null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
function heartbeat(context: HealContext): void {
|
|
117
140
|
if (DISABLED || !SERVICE_URL) return;
|
|
118
141
|
let url: URL;
|
|
@@ -151,28 +174,249 @@ function heartbeat(context: HealContext): void {
|
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
/**
|
|
154
|
-
* Register a locator with the self-healing pipeline and return
|
|
155
|
-
*
|
|
156
|
-
* The emitted POM constructor calls this for every field:
|
|
157
|
-
*
|
|
158
|
-
* this.usernameInput = healOrThrow(page, {
|
|
159
|
-
* preferred: page.getByLabel("Username"),
|
|
160
|
-
* context: { page: "LoginPage", name: "usernameInput" },
|
|
161
|
-
* });
|
|
162
|
-
*
|
|
163
|
-
* v1.1 returns `preferred` as-is — no wrapping, no proxy, no action-time
|
|
164
|
-
* healing. The locator is registered to JSONL + heartbeat so the offline
|
|
165
|
-
* self-heal pipeline sees it. v1.2 will wrap action methods.
|
|
177
|
+
* Register a locator with the self-healing pipeline and return a Proxy
|
|
178
|
+
* that performs action-time healing on failure (v1.2).
|
|
166
179
|
*/
|
|
167
|
-
export function healOrThrow(
|
|
180
|
+
export function healOrThrow(page: Page, opts: HealOrThrowOptions): Locator {
|
|
168
181
|
appendEvent({
|
|
169
182
|
ts: new Date().toISOString(),
|
|
170
183
|
event: "register",
|
|
171
184
|
page: opts.context.page,
|
|
172
185
|
name: opts.context.name,
|
|
186
|
+
scenario_name: currentScenarioName(),
|
|
173
187
|
pid: process.pid,
|
|
174
188
|
cwd: process.cwd(),
|
|
175
189
|
});
|
|
176
190
|
heartbeat(opts.context);
|
|
177
|
-
return opts.preferred;
|
|
191
|
+
return wrapLocatorForHealing(page, opts.preferred, opts.context);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ─── v1.2 action-time healing ────────────────────────────────────── */
|
|
195
|
+
|
|
196
|
+
/** Healable Locator action methods — i.e. ones that throw on element-not-
|
|
197
|
+
* found / timeout. Read methods like .count() / .all() return 0 / [] and
|
|
198
|
+
* don't need wrapping. */
|
|
199
|
+
const HEALABLE_METHODS = new Set<string>([
|
|
200
|
+
"click", "dblclick", "fill", "type", "press", "check", "uncheck",
|
|
201
|
+
"setChecked", "selectOption", "hover", "focus", "blur", "tap",
|
|
202
|
+
"clear", "scrollIntoViewIfNeeded", "waitFor",
|
|
203
|
+
"textContent", "innerText", "innerHTML", "getAttribute", "inputValue",
|
|
204
|
+
"isVisible", "isHidden", "isEnabled", "isDisabled", "isChecked",
|
|
205
|
+
"isEditable",
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
/** Pull a human-readable selector string out of a Locator. Playwright
|
|
209
|
+
* doesn't expose this officially; read its private `_selector` field
|
|
210
|
+
* when present and fall back to `String(locator)`. */
|
|
211
|
+
function locatorSelector(loc: Locator): string | null {
|
|
212
|
+
try {
|
|
213
|
+
const internal = (loc as unknown as { _selector?: string })._selector;
|
|
214
|
+
if (typeof internal === "string" && internal.length > 0) return internal;
|
|
215
|
+
const str = String(loc);
|
|
216
|
+
return str === "[object Object]" ? null : str;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface HealCandidate {
|
|
223
|
+
selector: string;
|
|
224
|
+
confidence: number | null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** POST /api/v1/heal — returns a candidate replacement selector or null. */
|
|
228
|
+
async function fetchHealCandidate(
|
|
229
|
+
context: HealContext,
|
|
230
|
+
originalSelector: string | null,
|
|
231
|
+
errorMessage: string,
|
|
232
|
+
): Promise<HealCandidate | null> {
|
|
233
|
+
if (DISABLED || !SERVICE_URL) return null;
|
|
234
|
+
let url: URL;
|
|
235
|
+
try {
|
|
236
|
+
url = new URL("/api/v1/heal", SERVICE_URL);
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const payload = JSON.stringify({
|
|
241
|
+
page: context.page,
|
|
242
|
+
name: context.name,
|
|
243
|
+
original: originalSelector,
|
|
244
|
+
error: errorMessage.slice(0, 500),
|
|
245
|
+
scenario_name: currentScenarioName(),
|
|
246
|
+
});
|
|
247
|
+
const client = url.protocol === "https:" ? https : http;
|
|
248
|
+
return new Promise<HealCandidate | null>((resolve) => {
|
|
249
|
+
let resolved = false;
|
|
250
|
+
const settle = (v: HealCandidate | null) => {
|
|
251
|
+
if (!resolved) { resolved = true; resolve(v); }
|
|
252
|
+
};
|
|
253
|
+
try {
|
|
254
|
+
const req = client.request(
|
|
255
|
+
url,
|
|
256
|
+
{
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
261
|
+
},
|
|
262
|
+
timeout: 5000,
|
|
263
|
+
},
|
|
264
|
+
(res) => {
|
|
265
|
+
const chunks: Buffer[] = [];
|
|
266
|
+
res.on("data", (c) => chunks.push(c));
|
|
267
|
+
res.on("end", () => {
|
|
268
|
+
try {
|
|
269
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
270
|
+
const parsed = JSON.parse(body) as { selector?: string; confidence?: number };
|
|
271
|
+
if (parsed && typeof parsed.selector === "string") {
|
|
272
|
+
settle({ selector: parsed.selector, confidence: typeof parsed.confidence === "number" ? parsed.confidence : null });
|
|
273
|
+
} else {
|
|
274
|
+
settle(null);
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
settle(null);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
res.on("error", () => settle(null));
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
req.on("error", () => settle(null));
|
|
284
|
+
req.on("timeout", () => { req.destroy(); settle(null); });
|
|
285
|
+
req.write(payload);
|
|
286
|
+
req.end();
|
|
287
|
+
} catch {
|
|
288
|
+
settle(null);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Wrap a Locator in a Proxy that retries failed actions against a
|
|
295
|
+
* service-suggested candidate selector. Once a candidate is verified to
|
|
296
|
+
* work for any method on this Locator, subsequent calls use the
|
|
297
|
+
* candidate transparently.
|
|
298
|
+
*/
|
|
299
|
+
function wrapLocatorForHealing(page: Page, original: Locator, context: HealContext): Locator {
|
|
300
|
+
let healedLocator: Locator | null = null;
|
|
301
|
+
|
|
302
|
+
const proxy = new Proxy(original, {
|
|
303
|
+
get(target, prop, _receiver) {
|
|
304
|
+
const live: Locator = healedLocator ?? target;
|
|
305
|
+
|
|
306
|
+
if (typeof prop !== "string") {
|
|
307
|
+
const v = Reflect.get(live, prop, live);
|
|
308
|
+
return typeof v === "function" ? (v as Function).bind(live) : v;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const value = Reflect.get(live, prop, live);
|
|
312
|
+
if (typeof value !== "function") return value;
|
|
313
|
+
if (!HEALABLE_METHODS.has(prop)) return (value as Function).bind(live);
|
|
314
|
+
|
|
315
|
+
// Healable action: wrap so we can intercept rejections.
|
|
316
|
+
const method = prop;
|
|
317
|
+
return async (...args: unknown[]) => {
|
|
318
|
+
try {
|
|
319
|
+
return await (value as (...a: unknown[]) => Promise<unknown>).apply(live, args);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
// Already healed once and STILL failing → real defect; rethrow.
|
|
322
|
+
if (healedLocator) throw err;
|
|
323
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
324
|
+
const original_selector = locatorSelector(target);
|
|
325
|
+
appendEvent({
|
|
326
|
+
ts: new Date().toISOString(),
|
|
327
|
+
event: "heal_attempt",
|
|
328
|
+
page: context.page,
|
|
329
|
+
name: context.name,
|
|
330
|
+
scenario_name: currentScenarioName(),
|
|
331
|
+
method,
|
|
332
|
+
original: original_selector,
|
|
333
|
+
healed: null,
|
|
334
|
+
confidence: null,
|
|
335
|
+
error: errMsg.slice(0, 500),
|
|
336
|
+
pid: process.pid,
|
|
337
|
+
cwd: process.cwd(),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const candidate = await fetchHealCandidate(context, original_selector, errMsg);
|
|
341
|
+
if (!candidate) {
|
|
342
|
+
appendEvent({
|
|
343
|
+
ts: new Date().toISOString(),
|
|
344
|
+
event: "heal_unavailable",
|
|
345
|
+
page: context.page,
|
|
346
|
+
name: context.name,
|
|
347
|
+
scenario_name: currentScenarioName(),
|
|
348
|
+
method,
|
|
349
|
+
original: original_selector,
|
|
350
|
+
healed: null,
|
|
351
|
+
confidence: null,
|
|
352
|
+
error: errMsg.slice(0, 500),
|
|
353
|
+
pid: process.pid,
|
|
354
|
+
cwd: process.cwd(),
|
|
355
|
+
});
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let candidateLocator: Locator;
|
|
360
|
+
try {
|
|
361
|
+
candidateLocator = page.locator(candidate.selector);
|
|
362
|
+
} catch {
|
|
363
|
+
appendEvent({
|
|
364
|
+
ts: new Date().toISOString(),
|
|
365
|
+
event: "heal_unavailable",
|
|
366
|
+
page: context.page,
|
|
367
|
+
name: context.name,
|
|
368
|
+
scenario_name: currentScenarioName(),
|
|
369
|
+
method,
|
|
370
|
+
original: original_selector,
|
|
371
|
+
healed: candidate.selector,
|
|
372
|
+
confidence: candidate.confidence,
|
|
373
|
+
error: "candidate selector failed to construct",
|
|
374
|
+
pid: process.pid,
|
|
375
|
+
cwd: process.cwd(),
|
|
376
|
+
});
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const retryFn = (candidateLocator as unknown as Record<string, Function>)[method];
|
|
382
|
+
const retryResult = await retryFn.apply(candidateLocator, args);
|
|
383
|
+
healedLocator = candidateLocator;
|
|
384
|
+
appendEvent({
|
|
385
|
+
ts: new Date().toISOString(),
|
|
386
|
+
event: "healed",
|
|
387
|
+
page: context.page,
|
|
388
|
+
name: context.name,
|
|
389
|
+
scenario_name: currentScenarioName(),
|
|
390
|
+
method,
|
|
391
|
+
original: original_selector,
|
|
392
|
+
healed: candidate.selector,
|
|
393
|
+
confidence: candidate.confidence,
|
|
394
|
+
error: errMsg.slice(0, 500),
|
|
395
|
+
pid: process.pid,
|
|
396
|
+
cwd: process.cwd(),
|
|
397
|
+
});
|
|
398
|
+
return retryResult;
|
|
399
|
+
} catch (retryErr) {
|
|
400
|
+
appendEvent({
|
|
401
|
+
ts: new Date().toISOString(),
|
|
402
|
+
event: "heal_unavailable",
|
|
403
|
+
page: context.page,
|
|
404
|
+
name: context.name,
|
|
405
|
+
scenario_name: currentScenarioName(),
|
|
406
|
+
method,
|
|
407
|
+
original: original_selector,
|
|
408
|
+
healed: candidate.selector,
|
|
409
|
+
confidence: candidate.confidence,
|
|
410
|
+
error: retryErr instanceof Error ? retryErr.message.slice(0, 500) : String(retryErr).slice(0, 500),
|
|
411
|
+
pid: process.pid,
|
|
412
|
+
cwd: process.cwd(),
|
|
413
|
+
});
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return proxy;
|
|
178
422
|
}
|