@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.
@@ -1,64 +1,52 @@
1
1
  /**
2
- * Self-healing locator helper — v1.1 scaffold.
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.1)
8
+ * What this provides today (v1.2)
9
9
  * --------------------------------
10
- * - `healOrThrow(page, { preferred, context })` — a passthrough that
11
- * returns the `preferred` Playwright Locator unchanged, AND records
12
- * the locator-creation event to `artefacts/heal-events.jsonl` for the
13
- * offline self-heal pipeline to consume.
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. The heartbeat is
16
- * a best-effort POST — failures are silently swallowed so a missing
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
- * What this does NOT do yet (deferred to v1.2)
20
- * --------------------------------------------
21
- * - **Action-time healing.** When `await locator.click()` fails because
22
- * the locator changed, this helper will not catch the failure, call
23
- * `/api/v1/heal`, and retry with a suggested locator. That requires a
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 (e.g.
41
- * http://localhost:8003). When set, every locator
42
- * creation triggers a fire-and-forget POST to
43
- * `${SELF_HEALING_URL}/api/v1/register`. When unset,
44
- * no network calls are made; events still land in
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 { Locator, Page } from "@playwright/test";
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 it unchanged.
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(_page: Page, opts: HealOrThrowOptions): Locator {
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
  }