@vijaypjavvadi/bdd2pw 2.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  _Nothing yet._
11
11
 
12
+ ## [2.2.1] — 2026-05-09
13
+
14
+ ### Fixed — heal Proxy broke `expect(locator).toMatcher(...)` (HIGH, launch-blocker)
15
+
16
+ bdd2pw 2.2.0's action-time healing wrapped every POM-field Locator in a
17
+ `new Proxy(...)` for healable-method interception. Playwright's
18
+ `expect(loc).toBeVisible()` (and every other `LocatorMatchers`) does a
19
+ private-field probe (`#frame`, `Symbol.toStringTag`, etc.) that doesn't
20
+ survive a generic Proxy — so every spec that asserted against a POM
21
+ field died synchronously with:
22
+
23
+ ```
24
+ Error: toBeVisible can be only used with Locator object
25
+ ```
26
+
27
+ 100 % test fail rate on any spec calling `expect()` on a POM field.
28
+
29
+ **Fix:** replaced the Proxy with **direct instance mutation** in
30
+ `templates/heal.ts.tmpl` (heal helper bumped to v1.2.1):
31
+
32
+ ```ts
33
+ // Before (v1.2): wrap in Proxy → identity check fails
34
+ const proxy = new Proxy(original, { get(target, prop) {...} });
35
+ return proxy;
36
+
37
+ // After (v1.2.1): mutate own properties → identity preserved
38
+ for (const method of HEALABLE_METHODS) {
39
+ const origFn = (original as any)[method];
40
+ if (typeof origFn !== "function") continue;
41
+ (original as any)[method] = async function (...args) {
42
+ if (healedLocator) {
43
+ return await (healedLocator as any)[method].apply(healedLocator, args);
44
+ }
45
+ try { return await origFn.apply(original, args); }
46
+ catch (err) { /* heal-and-retry, identical to v1.2 */ }
47
+ };
48
+ }
49
+ return original;
50
+ ```
51
+
52
+ The healing logic (POST `/api/v1/heal`, retry with candidate, log
53
+ `heal_attempt` / `healed` / `heal_unavailable`) is byte-identical. Only
54
+ the wrapping mechanism changed.
55
+
56
+ #### What this preserves vs v1.2
57
+
58
+ | Property | v1.2 (Proxy) | v1.2.1 (mutation) |
59
+ |---|---|---|
60
+ | Action-time healing on `.click`/`.fill`/etc. | ✓ | ✓ |
61
+ | Once-cached candidate reused on subsequent calls | ✓ | ✓ |
62
+ | `await loc.click()` works | ✓ | ✓ |
63
+ | `await expect(loc).toBeVisible()` works | ❌ | ✓ |
64
+ | `loc instanceof Locator` | partially | ✓ |
65
+ | Private-field probes (`#frame`) | ❌ | ✓ |
66
+ | Non-healable methods (`.locator()`, `.first()`) untouched | ✓ | ✓ (prototype delegation) |
67
+
68
+ #### Files
69
+
70
+ - Modified: `templates/heal.ts.tmpl` (the `wrapLocatorForHealing`
71
+ function + doc comments at top + version bump v1.2 → v1.2.1).
72
+
73
+ #### Migration from 2.2.0
74
+
75
+ Pure bug fix — no API change. Just bump the dep pin to `^2.2.1`.
76
+
77
+ Cloud-jobs-template runs that were failing 100% on
78
+ `expect.toBeVisible can be only used with Locator object` will now go
79
+ green (or fail honestly on real test logic). Action-time healing
80
+ continues to work the same way for failed `.click()` / `.fill()` /
81
+ etc.
82
+
12
83
  ## [2.2.0] — 2026-05-08
13
84
 
14
85
  ### Fixed — LLM fallback hung 8+ minutes in cloud-job containers (HIGH, launch-blocker)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vijaypjavvadi/bdd2pw",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Scaffold runnable Playwright TypeScript tests from Gherkin .feature files. Detects existing Page Objects, scans live pages via Microsoft Playwright MCP, emits POMs + specs, ready for execution. CLI + HTTP service. Optional governed LLM fallback (Anthropic / OpenAI / Gemini).",
5
5
  "bin": {
6
6
  "bdd2pw": "dist/cli.js"
@@ -1,15 +1,24 @@
1
1
  /**
2
- * Self-healing locator helper — v1.2 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.2)
9
- * --------------------------------
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.
16
+ *
17
+ * What this provides today (v1.2.1)
18
+ * ----------------------------------
10
19
  * - `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,
20
+ * to `artefacts/heal-events.jsonl` AND mutates it in place so every
21
+ * healable action method (.click, .fill, .check,
13
22
  * .selectOption, .hover, .focus, .textContent, .innerText,
14
23
  * .getAttribute, .isVisible, .waitFor, .press, .type, .setChecked,
15
24
  * .uncheck, .clear, .scrollIntoViewIfNeeded). On the FIRST action
@@ -174,8 +183,10 @@ function heartbeat(context: HealContext): void {
174
183
  }
175
184
 
176
185
  /**
177
- * Register a locator with the self-healing pipeline and return a Proxy
178
- * that performs action-time healing on failure (v1.2).
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.
179
190
  */
180
191
  export function healOrThrow(page: Page, opts: HealOrThrowOptions): Locator {
181
192
  appendEvent({
@@ -191,7 +202,7 @@ export function healOrThrow(page: Page, opts: HealOrThrowOptions): Locator {
191
202
  return wrapLocatorForHealing(page, opts.preferred, opts.context);
192
203
  }
193
204
 
194
- /* ─── v1.2 action-time healing ────────────────────────────────────── */
205
+ /* ─── v1.2.1 action-time healing (instance mutation, no Proxy) ────── */
195
206
 
196
207
  /** Healable Locator action methods — i.e. ones that throw on element-not-
197
208
  * found / timeout. Read methods like .count() / .all() return 0 / [] and
@@ -291,40 +302,77 @@ async function fetchHealCandidate(
291
302
  }
292
303
 
293
304
  /**
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.
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.
298
329
  */
299
330
  function wrapLocatorForHealing(page: Page, original: Locator, context: HealContext): Locator {
300
331
  let healedLocator: Locator | null = null;
301
332
 
302
- const proxy = new Proxy(original, {
303
- get(target, prop, _receiver) {
304
- const live: Locator = healedLocator ?? target;
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;
305
338
 
306
- if (typeof prop !== "string") {
307
- const v = Reflect.get(live, prop, live);
308
- return typeof v === "function" ? (v as Function).bind(live) : v;
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);
309
346
  }
310
347
 
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);
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
+ });
314
370
 
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);
371
+ const candidate = await fetchHealCandidate(context, original_selector, errMsg);
372
+ if (!candidate) {
325
373
  appendEvent({
326
374
  ts: new Date().toISOString(),
327
- event: "heal_attempt",
375
+ event: "heal_unavailable",
328
376
  page: context.page,
329
377
  name: context.name,
330
378
  scenario_name: currentScenarioName(),
@@ -336,87 +384,69 @@ function wrapLocatorForHealing(page: Page, original: Locator, context: HealConte
336
384
  pid: process.pid,
337
385
  cwd: process.cwd(),
338
386
  });
387
+ throw err;
388
+ }
339
389
 
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
- }
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
+ }
379
410
 
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
- }
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;
416
446
  }
417
- };
418
- },
419
- });
447
+ }
448
+ };
449
+ }
420
450
 
421
- return proxy;
451
+ return original;
422
452
  }