@vijaypjavvadi/bdd2pw 2.0.2 → 2.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.
@@ -1,64 +1,61 @@
1
1
  /**
2
- * Self-healing locator helper — v1.1 scaffold.
2
+ * Self-healing locator helper — v1.2.1 scaffold (bdd2pw 2.2.1+).
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)
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.
14
- * - 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.
8
+ * v1.2 → v1.2.1: action-time healing is now applied via INSTANCE
9
+ * MUTATION instead of a Proxy wrapper. v1.2's Proxy broke Playwright
10
+ * `expect(loc).toBeVisible()` because the matcher does a private-field
11
+ * probe (`#frame`) that doesn't survive a generic Proxy — every spec
12
+ * that called expect() on a POM field died synchronously with
13
+ * `Error: toBeVisible can be only used with Locator object`. v1.2.1
14
+ * preserves Locator identity so expect() works normally while keeping
15
+ * the action-time heal-and-retry behaviour intact.
18
16
  *
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.
17
+ * What this provides today (v1.2.1)
18
+ * ----------------------------------
19
+ * - `healOrThrow(page, { preferred, context })` — registers the locator
20
+ * to `artefacts/heal-events.jsonl` AND mutates it in place so every
21
+ * healable action method (.click, .fill, .check,
22
+ * .selectOption, .hover, .focus, .textContent, .innerText,
23
+ * .getAttribute, .isVisible, .waitFor, .press, .type, .setChecked,
24
+ * .uncheck, .clear, .scrollIntoViewIfNeeded). On the FIRST action
25
+ * failure for a given locator, the proxy:
26
+ * 1. Logs a `heal_attempt` event with the original error.
27
+ * 2. Asks the self-healing service (when SELF_HEALING_URL is set)
28
+ * for a candidate replacement selector via POST /api/v1/heal.
29
+ * 3. Re-runs the original action against `page.locator(<candidate>)`.
30
+ * 4. On success, logs a `healed` event; subsequent actions on the
31
+ * SAME locator instance use the candidate transparently.
32
+ * 5. On failure (no service, no candidate, or candidate also fails),
33
+ * logs `heal_unavailable` and re-throws the original error so
34
+ * the test still fails honestly.
35
+ * - Optional fire-and-forget heartbeat to `${SELF_HEALING_URL}/api/v1/register`
36
+ * so the service knows which locators a test references. Best-effort
37
+ * POST — failures are silently swallowed.
31
38
  *
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.
39
+ * Action-time healing intentionally fires only on ACTION failures (click,
40
+ * fill, etc.), not on locator construction — `page.getByLabel("Username")`
41
+ * is just an object factory and never throws. The proxy adds zero overhead
42
+ * on the happy path: every method call passes through unmodified, the
43
+ * try/catch only kicks in when the underlying Playwright call rejects.
37
44
  *
38
45
  * Environment variables
39
46
  * ---------------------
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.
47
+ * SELF_HEALING_URL Base URL of the self-healing service.
48
+ * HEAL_EVENTS_PATH Override the JSONL output path (default
49
+ * `artefacts/heal-events.jsonl`).
50
+ * HEAL_DISABLE "1"/"true" disables JSONL writes + heartbeats +
51
+ * heal-service calls. Useful in CI.
55
52
  */
56
53
 
57
54
  import * as fs from "fs";
58
55
  import * as path from "path";
59
56
  import * as http from "http";
60
57
  import * as https from "https";
61
- import type { Locator, Page } from "@playwright/test";
58
+ import { test, type Locator, type Page } from "@playwright/test";
62
59
 
63
60
  export interface HealContext {
64
61
  /** PascalCase Page Object class name, e.g. "LoginPage". */
@@ -79,6 +76,27 @@ interface HealEvent {
79
76
  event: "register";
80
77
  page: string;
81
78
  name: string;
79
+ /** Scenario / test title at the time the locator was registered. */
80
+ scenario_name: string | null;
81
+ pid: number;
82
+ cwd: string;
83
+ }
84
+
85
+ interface HealAttemptEvent {
86
+ ts: string;
87
+ event: "heal_attempt" | "healed" | "heal_unavailable";
88
+ page: string;
89
+ name: string;
90
+ scenario_name: string | null;
91
+ method: string;
92
+ /** Original locator selector (best-effort serialisation). */
93
+ original: string | null;
94
+ /** Replacement selector when known. */
95
+ healed: string | null;
96
+ /** Confidence in the replacement, 0–1, when the service provided one. */
97
+ confidence: number | null;
98
+ /** Truncated error message that triggered the heal attempt. */
99
+ error: string | null;
82
100
  pid: number;
83
101
  cwd: string;
84
102
  }
@@ -103,7 +121,7 @@ function ensureArtefactsDir(): string {
103
121
  return fullPath;
104
122
  }
105
123
 
106
- function appendEvent(event: HealEvent): void {
124
+ function appendEvent(event: HealEvent | HealAttemptEvent): void {
107
125
  if (DISABLED) return;
108
126
  const fullPath = ensureArtefactsDir();
109
127
  try {
@@ -113,6 +131,20 @@ function appendEvent(event: HealEvent): void {
113
131
  }
114
132
  }
115
133
 
134
+ /**
135
+ * Best-effort scenario-name capture. test.info() throws when called
136
+ * outside a running test (`Test info is not available outside the test
137
+ * lifecycle`), so we swallow and return null.
138
+ */
139
+ function currentScenarioName(): string | null {
140
+ try {
141
+ const info = test.info();
142
+ return info?.title ?? null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
116
148
  function heartbeat(context: HealContext): void {
117
149
  if (DISABLED || !SERVICE_URL) return;
118
150
  let url: URL;
@@ -151,28 +183,270 @@ function heartbeat(context: HealContext): void {
151
183
  }
152
184
 
153
185
  /**
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.
186
+ * Register a locator with the self-healing pipeline and mutate it in
187
+ * place so action methods perform action-time healing on failure
188
+ * (v1.2.1). Returns the SAME Locator instance — identity is preserved,
189
+ * so `expect(returned).toBeVisible()` works normally.
166
190
  */
167
- export function healOrThrow(_page: Page, opts: HealOrThrowOptions): Locator {
191
+ export function healOrThrow(page: Page, opts: HealOrThrowOptions): Locator {
168
192
  appendEvent({
169
193
  ts: new Date().toISOString(),
170
194
  event: "register",
171
195
  page: opts.context.page,
172
196
  name: opts.context.name,
197
+ scenario_name: currentScenarioName(),
173
198
  pid: process.pid,
174
199
  cwd: process.cwd(),
175
200
  });
176
201
  heartbeat(opts.context);
177
- return opts.preferred;
202
+ return wrapLocatorForHealing(page, opts.preferred, opts.context);
203
+ }
204
+
205
+ /* ─── v1.2.1 action-time healing (instance mutation, no Proxy) ────── */
206
+
207
+ /** Healable Locator action methods — i.e. ones that throw on element-not-
208
+ * found / timeout. Read methods like .count() / .all() return 0 / [] and
209
+ * don't need wrapping. */
210
+ const HEALABLE_METHODS = new Set<string>([
211
+ "click", "dblclick", "fill", "type", "press", "check", "uncheck",
212
+ "setChecked", "selectOption", "hover", "focus", "blur", "tap",
213
+ "clear", "scrollIntoViewIfNeeded", "waitFor",
214
+ "textContent", "innerText", "innerHTML", "getAttribute", "inputValue",
215
+ "isVisible", "isHidden", "isEnabled", "isDisabled", "isChecked",
216
+ "isEditable",
217
+ ]);
218
+
219
+ /** Pull a human-readable selector string out of a Locator. Playwright
220
+ * doesn't expose this officially; read its private `_selector` field
221
+ * when present and fall back to `String(locator)`. */
222
+ function locatorSelector(loc: Locator): string | null {
223
+ try {
224
+ const internal = (loc as unknown as { _selector?: string })._selector;
225
+ if (typeof internal === "string" && internal.length > 0) return internal;
226
+ const str = String(loc);
227
+ return str === "[object Object]" ? null : str;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ interface HealCandidate {
234
+ selector: string;
235
+ confidence: number | null;
236
+ }
237
+
238
+ /** POST /api/v1/heal — returns a candidate replacement selector or null. */
239
+ async function fetchHealCandidate(
240
+ context: HealContext,
241
+ originalSelector: string | null,
242
+ errorMessage: string,
243
+ ): Promise<HealCandidate | null> {
244
+ if (DISABLED || !SERVICE_URL) return null;
245
+ let url: URL;
246
+ try {
247
+ url = new URL("/api/v1/heal", SERVICE_URL);
248
+ } catch {
249
+ return null;
250
+ }
251
+ const payload = JSON.stringify({
252
+ page: context.page,
253
+ name: context.name,
254
+ original: originalSelector,
255
+ error: errorMessage.slice(0, 500),
256
+ scenario_name: currentScenarioName(),
257
+ });
258
+ const client = url.protocol === "https:" ? https : http;
259
+ return new Promise<HealCandidate | null>((resolve) => {
260
+ let resolved = false;
261
+ const settle = (v: HealCandidate | null) => {
262
+ if (!resolved) { resolved = true; resolve(v); }
263
+ };
264
+ try {
265
+ const req = client.request(
266
+ url,
267
+ {
268
+ method: "POST",
269
+ headers: {
270
+ "Content-Type": "application/json",
271
+ "Content-Length": Buffer.byteLength(payload),
272
+ },
273
+ timeout: 5000,
274
+ },
275
+ (res) => {
276
+ const chunks: Buffer[] = [];
277
+ res.on("data", (c) => chunks.push(c));
278
+ res.on("end", () => {
279
+ try {
280
+ const body = Buffer.concat(chunks).toString("utf8");
281
+ const parsed = JSON.parse(body) as { selector?: string; confidence?: number };
282
+ if (parsed && typeof parsed.selector === "string") {
283
+ settle({ selector: parsed.selector, confidence: typeof parsed.confidence === "number" ? parsed.confidence : null });
284
+ } else {
285
+ settle(null);
286
+ }
287
+ } catch {
288
+ settle(null);
289
+ }
290
+ });
291
+ res.on("error", () => settle(null));
292
+ },
293
+ );
294
+ req.on("error", () => settle(null));
295
+ req.on("timeout", () => { req.destroy(); settle(null); });
296
+ req.write(payload);
297
+ req.end();
298
+ } catch {
299
+ settle(null);
300
+ }
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Add action-time healing to a Locator by MUTATING IT IN PLACE.
306
+ *
307
+ * v1.2 used a `new Proxy(...)` wrapper here, which broke any spec that
308
+ * called `await expect(loc).toBeVisible()` — Playwright's matchers do
309
+ * a private-field probe (`#frame` and friends) that doesn't survive a
310
+ * generic Proxy. Result: 100% test fail rate on any spec that asserts
311
+ * against a POM field, with the unhelpful error
312
+ * `Error: toBeVisible can be only used with Locator object`.
313
+ *
314
+ * v1.2.1 replaces the Proxy with direct method override:
315
+ * - For every healable method (click, fill, …), capture the original
316
+ * bound function via closure BEFORE mutation.
317
+ * - Replace the property on the Locator INSTANCE with a wrapper that
318
+ * try/catches and heals on failure.
319
+ * - Return the original Locator unchanged in identity. `instanceof
320
+ * Locator` still holds; private-field probes still work; `expect()`
321
+ * accepts it normally; non-healable methods (.locator(), .first(),
322
+ * etc.) keep their prototype's implementation untouched.
323
+ *
324
+ * After a successful heal, `healedLocator` is set in the closure and
325
+ * subsequent calls (on any healable method on this same instance) go
326
+ * through it transparently. Cross-method healing of one cached
327
+ * candidate, exactly as v1.2 promised — but without sacrificing
328
+ * matcher compatibility.
329
+ */
330
+ function wrapLocatorForHealing(page: Page, original: Locator, context: HealContext): Locator {
331
+ let healedLocator: Locator | null = null;
332
+
333
+ for (const method of HEALABLE_METHODS) {
334
+ // Capture the original method BEFORE mutation — otherwise we'd
335
+ // recurse infinitely when our wrapper tries to call the "original".
336
+ const origFn = (original as unknown as Record<string, unknown>)[method];
337
+ if (typeof origFn !== "function") continue;
338
+
339
+ (original as unknown as Record<string, unknown>)[method] = async function (
340
+ ...args: unknown[]
341
+ ) {
342
+ // Once healed, route every healable call through the candidate.
343
+ if (healedLocator) {
344
+ const fn = (healedLocator as unknown as Record<string, Function>)[method];
345
+ return await fn.apply(healedLocator, args);
346
+ }
347
+
348
+ try {
349
+ return await (origFn as (...a: unknown[]) => Promise<unknown>).apply(
350
+ original,
351
+ args,
352
+ );
353
+ } catch (err) {
354
+ const errMsg = err instanceof Error ? err.message : String(err);
355
+ const original_selector = locatorSelector(original);
356
+ appendEvent({
357
+ ts: new Date().toISOString(),
358
+ event: "heal_attempt",
359
+ page: context.page,
360
+ name: context.name,
361
+ scenario_name: currentScenarioName(),
362
+ method,
363
+ original: original_selector,
364
+ healed: null,
365
+ confidence: null,
366
+ error: errMsg.slice(0, 500),
367
+ pid: process.pid,
368
+ cwd: process.cwd(),
369
+ });
370
+
371
+ const candidate = await fetchHealCandidate(context, original_selector, errMsg);
372
+ if (!candidate) {
373
+ appendEvent({
374
+ ts: new Date().toISOString(),
375
+ event: "heal_unavailable",
376
+ page: context.page,
377
+ name: context.name,
378
+ scenario_name: currentScenarioName(),
379
+ method,
380
+ original: original_selector,
381
+ healed: null,
382
+ confidence: null,
383
+ error: errMsg.slice(0, 500),
384
+ pid: process.pid,
385
+ cwd: process.cwd(),
386
+ });
387
+ throw err;
388
+ }
389
+
390
+ let candidateLocator: Locator;
391
+ try {
392
+ candidateLocator = page.locator(candidate.selector);
393
+ } catch {
394
+ appendEvent({
395
+ ts: new Date().toISOString(),
396
+ event: "heal_unavailable",
397
+ page: context.page,
398
+ name: context.name,
399
+ scenario_name: currentScenarioName(),
400
+ method,
401
+ original: original_selector,
402
+ healed: candidate.selector,
403
+ confidence: candidate.confidence,
404
+ error: "candidate selector failed to construct",
405
+ pid: process.pid,
406
+ cwd: process.cwd(),
407
+ });
408
+ throw err;
409
+ }
410
+
411
+ try {
412
+ const retryFn = (candidateLocator as unknown as Record<string, Function>)[method];
413
+ const retryResult = await retryFn.apply(candidateLocator, args);
414
+ healedLocator = candidateLocator;
415
+ appendEvent({
416
+ ts: new Date().toISOString(),
417
+ event: "healed",
418
+ page: context.page,
419
+ name: context.name,
420
+ scenario_name: currentScenarioName(),
421
+ method,
422
+ original: original_selector,
423
+ healed: candidate.selector,
424
+ confidence: candidate.confidence,
425
+ error: errMsg.slice(0, 500),
426
+ pid: process.pid,
427
+ cwd: process.cwd(),
428
+ });
429
+ return retryResult;
430
+ } catch (retryErr) {
431
+ appendEvent({
432
+ ts: new Date().toISOString(),
433
+ event: "heal_unavailable",
434
+ page: context.page,
435
+ name: context.name,
436
+ scenario_name: currentScenarioName(),
437
+ method,
438
+ original: original_selector,
439
+ healed: candidate.selector,
440
+ confidence: candidate.confidence,
441
+ error: retryErr instanceof Error ? retryErr.message.slice(0, 500) : String(retryErr).slice(0, 500),
442
+ pid: process.pid,
443
+ cwd: process.cwd(),
444
+ });
445
+ throw err;
446
+ }
447
+ }
448
+ };
449
+ }
450
+
451
+ return original;
178
452
  }