@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 +71 -0
- package/package.json +1 -1
- package/templates/heal.ts.tmpl +141 -111
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.
|
|
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"
|
package/templates/heal.ts.tmpl
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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
|
|
178
|
-
*
|
|
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
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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: "
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
451
|
+
return original;
|
|
422
452
|
}
|