@vijaypjavvadi/bdd2pw 3.8.0 → 3.9.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 +81 -0
- package/README.md +396 -332
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/index.d.ts +1 -0
- package/dist/llm/index.js +4 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/prompt.js +66 -66
- package/dist/llm/telemetry.d.ts +150 -0
- package/dist/llm/telemetry.js +230 -0
- package/dist/llm/telemetry.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
- package/templates/heal.ts.tmpl +452 -452
package/templates/heal.ts.tmpl
CHANGED
|
@@ -1,452 +1,452 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Self-healing locator helper — v1.2.1 scaffold (bdd2pw 2.2.1+).
|
|
3
|
-
*
|
|
4
|
-
* GENERATED FILE. Do not edit directly; this file is rewritten on every
|
|
5
|
-
* `bdd2pw scaffold --self-healing` invocation. To customise, fork it under
|
|
6
|
-
* a different filename and update the `tsconfig.json` `paths` mapping.
|
|
7
|
-
*
|
|
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
|
-
* ----------------------------------
|
|
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.
|
|
38
|
-
*
|
|
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.
|
|
44
|
-
*
|
|
45
|
-
* Environment variables
|
|
46
|
-
* ---------------------
|
|
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.
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
import * as fs from "fs";
|
|
55
|
-
import * as path from "path";
|
|
56
|
-
import * as http from "http";
|
|
57
|
-
import * as https from "https";
|
|
58
|
-
import { test, type Locator, type Page } from "@playwright/test";
|
|
59
|
-
|
|
60
|
-
export interface HealContext {
|
|
61
|
-
/** PascalCase Page Object class name, e.g. "LoginPage". */
|
|
62
|
-
page: string;
|
|
63
|
-
/** camelCase field name, e.g. "usernameInput". */
|
|
64
|
-
name: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface HealOrThrowOptions {
|
|
68
|
-
/** The locator the emitter chose for this field. */
|
|
69
|
-
preferred: Locator;
|
|
70
|
-
/** Static metadata that uniquely identifies this locator across runs. */
|
|
71
|
-
context: HealContext;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
interface HealEvent {
|
|
75
|
-
ts: string;
|
|
76
|
-
event: "register";
|
|
77
|
-
page: string;
|
|
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;
|
|
100
|
-
pid: number;
|
|
101
|
-
cwd: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const DISABLED = process.env.HEAL_DISABLE === "1" || process.env.HEAL_DISABLE === "true";
|
|
105
|
-
const SERVICE_URL = process.env.SELF_HEALING_URL ?? "";
|
|
106
|
-
const EVENTS_PATH = process.env.HEAL_EVENTS_PATH ?? "artefacts/heal-events.jsonl";
|
|
107
|
-
|
|
108
|
-
let writerInitialised = false;
|
|
109
|
-
|
|
110
|
-
function ensureArtefactsDir(): string {
|
|
111
|
-
const fullPath = path.resolve(process.cwd(), EVENTS_PATH);
|
|
112
|
-
const dir = path.dirname(fullPath);
|
|
113
|
-
if (!writerInitialised) {
|
|
114
|
-
try {
|
|
115
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
-
writerInitialised = true;
|
|
117
|
-
} catch {
|
|
118
|
-
/* best-effort */
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return fullPath;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function appendEvent(event: HealEvent | HealAttemptEvent): void {
|
|
125
|
-
if (DISABLED) return;
|
|
126
|
-
const fullPath = ensureArtefactsDir();
|
|
127
|
-
try {
|
|
128
|
-
fs.appendFileSync(fullPath, JSON.stringify(event) + "\n", "utf8");
|
|
129
|
-
} catch {
|
|
130
|
-
/* best-effort — never break tests for logging */
|
|
131
|
-
}
|
|
132
|
-
}
|
|
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
|
-
|
|
148
|
-
function heartbeat(context: HealContext): void {
|
|
149
|
-
if (DISABLED || !SERVICE_URL) return;
|
|
150
|
-
let url: URL;
|
|
151
|
-
try {
|
|
152
|
-
url = new URL("/api/v1/register", SERVICE_URL);
|
|
153
|
-
} catch {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
const payload = JSON.stringify({
|
|
157
|
-
page: context.page,
|
|
158
|
-
name: context.name,
|
|
159
|
-
pid: process.pid,
|
|
160
|
-
ts: new Date().toISOString(),
|
|
161
|
-
});
|
|
162
|
-
const client = url.protocol === "https:" ? https : http;
|
|
163
|
-
try {
|
|
164
|
-
const req = client.request(
|
|
165
|
-
url,
|
|
166
|
-
{
|
|
167
|
-
method: "POST",
|
|
168
|
-
headers: {
|
|
169
|
-
"Content-Type": "application/json",
|
|
170
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
171
|
-
},
|
|
172
|
-
timeout: 1000,
|
|
173
|
-
},
|
|
174
|
-
(res) => res.resume(),
|
|
175
|
-
);
|
|
176
|
-
req.on("error", () => {});
|
|
177
|
-
req.on("timeout", () => req.destroy());
|
|
178
|
-
req.write(payload);
|
|
179
|
-
req.end();
|
|
180
|
-
} catch {
|
|
181
|
-
/* best-effort */
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
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.
|
|
190
|
-
*/
|
|
191
|
-
export function healOrThrow(page: Page, opts: HealOrThrowOptions): Locator {
|
|
192
|
-
appendEvent({
|
|
193
|
-
ts: new Date().toISOString(),
|
|
194
|
-
event: "register",
|
|
195
|
-
page: opts.context.page,
|
|
196
|
-
name: opts.context.name,
|
|
197
|
-
scenario_name: currentScenarioName(),
|
|
198
|
-
pid: process.pid,
|
|
199
|
-
cwd: process.cwd(),
|
|
200
|
-
});
|
|
201
|
-
heartbeat(opts.context);
|
|
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;
|
|
452
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing locator helper — v1.2.1 scaffold (bdd2pw 2.2.1+).
|
|
3
|
+
*
|
|
4
|
+
* GENERATED FILE. Do not edit directly; this file is rewritten on every
|
|
5
|
+
* `bdd2pw scaffold --self-healing` invocation. To customise, fork it under
|
|
6
|
+
* a different filename and update the `tsconfig.json` `paths` mapping.
|
|
7
|
+
*
|
|
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
|
+
* ----------------------------------
|
|
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.
|
|
38
|
+
*
|
|
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.
|
|
44
|
+
*
|
|
45
|
+
* Environment variables
|
|
46
|
+
* ---------------------
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import * as fs from "fs";
|
|
55
|
+
import * as path from "path";
|
|
56
|
+
import * as http from "http";
|
|
57
|
+
import * as https from "https";
|
|
58
|
+
import { test, type Locator, type Page } from "@playwright/test";
|
|
59
|
+
|
|
60
|
+
export interface HealContext {
|
|
61
|
+
/** PascalCase Page Object class name, e.g. "LoginPage". */
|
|
62
|
+
page: string;
|
|
63
|
+
/** camelCase field name, e.g. "usernameInput". */
|
|
64
|
+
name: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface HealOrThrowOptions {
|
|
68
|
+
/** The locator the emitter chose for this field. */
|
|
69
|
+
preferred: Locator;
|
|
70
|
+
/** Static metadata that uniquely identifies this locator across runs. */
|
|
71
|
+
context: HealContext;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface HealEvent {
|
|
75
|
+
ts: string;
|
|
76
|
+
event: "register";
|
|
77
|
+
page: string;
|
|
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;
|
|
100
|
+
pid: number;
|
|
101
|
+
cwd: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DISABLED = process.env.HEAL_DISABLE === "1" || process.env.HEAL_DISABLE === "true";
|
|
105
|
+
const SERVICE_URL = process.env.SELF_HEALING_URL ?? "";
|
|
106
|
+
const EVENTS_PATH = process.env.HEAL_EVENTS_PATH ?? "artefacts/heal-events.jsonl";
|
|
107
|
+
|
|
108
|
+
let writerInitialised = false;
|
|
109
|
+
|
|
110
|
+
function ensureArtefactsDir(): string {
|
|
111
|
+
const fullPath = path.resolve(process.cwd(), EVENTS_PATH);
|
|
112
|
+
const dir = path.dirname(fullPath);
|
|
113
|
+
if (!writerInitialised) {
|
|
114
|
+
try {
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
writerInitialised = true;
|
|
117
|
+
} catch {
|
|
118
|
+
/* best-effort */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return fullPath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function appendEvent(event: HealEvent | HealAttemptEvent): void {
|
|
125
|
+
if (DISABLED) return;
|
|
126
|
+
const fullPath = ensureArtefactsDir();
|
|
127
|
+
try {
|
|
128
|
+
fs.appendFileSync(fullPath, JSON.stringify(event) + "\n", "utf8");
|
|
129
|
+
} catch {
|
|
130
|
+
/* best-effort — never break tests for logging */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
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
|
+
|
|
148
|
+
function heartbeat(context: HealContext): void {
|
|
149
|
+
if (DISABLED || !SERVICE_URL) return;
|
|
150
|
+
let url: URL;
|
|
151
|
+
try {
|
|
152
|
+
url = new URL("/api/v1/register", SERVICE_URL);
|
|
153
|
+
} catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const payload = JSON.stringify({
|
|
157
|
+
page: context.page,
|
|
158
|
+
name: context.name,
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
ts: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
const client = url.protocol === "https:" ? https : http;
|
|
163
|
+
try {
|
|
164
|
+
const req = client.request(
|
|
165
|
+
url,
|
|
166
|
+
{
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"Content-Type": "application/json",
|
|
170
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
171
|
+
},
|
|
172
|
+
timeout: 1000,
|
|
173
|
+
},
|
|
174
|
+
(res) => res.resume(),
|
|
175
|
+
);
|
|
176
|
+
req.on("error", () => {});
|
|
177
|
+
req.on("timeout", () => req.destroy());
|
|
178
|
+
req.write(payload);
|
|
179
|
+
req.end();
|
|
180
|
+
} catch {
|
|
181
|
+
/* best-effort */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
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.
|
|
190
|
+
*/
|
|
191
|
+
export function healOrThrow(page: Page, opts: HealOrThrowOptions): Locator {
|
|
192
|
+
appendEvent({
|
|
193
|
+
ts: new Date().toISOString(),
|
|
194
|
+
event: "register",
|
|
195
|
+
page: opts.context.page,
|
|
196
|
+
name: opts.context.name,
|
|
197
|
+
scenario_name: currentScenarioName(),
|
|
198
|
+
pid: process.pid,
|
|
199
|
+
cwd: process.cwd(),
|
|
200
|
+
});
|
|
201
|
+
heartbeat(opts.context);
|
|
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;
|
|
452
|
+
}
|