@qa-gentic/stlc-agents 1.0.27 → 1.0.29

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.
Files changed (47) hide show
  1. package/ARCHITECTURE-ADO.md +350 -0
  2. package/ARCHITECTURE-JIRA.md +203 -0
  3. package/QUICKSTART-ADO.md +400 -0
  4. package/QUICKSTART-JIRA.md +334 -0
  5. package/README.md +49 -0
  6. package/bin/postinstall.js +14 -4
  7. package/package.json +19 -7
  8. package/skills/migrate-framework/SKILL.md +207 -0
  9. package/src/stlc_agents/agent_migration/__init__.py +0 -0
  10. package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
  11. package/src/stlc_agents/agent_migration/cli.py +217 -0
  12. package/src/stlc_agents/agent_migration/detector.py +81 -0
  13. package/src/stlc_agents/agent_migration/mapper.py +439 -0
  14. package/src/stlc_agents/agent_migration/reporter.py +86 -0
  15. package/src/stlc_agents/agent_migration/server.py +267 -0
  16. package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
  17. package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
  18. package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
  19. package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
  20. package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
  21. package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
  22. package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
  23. package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
  24. package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
  25. package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
  26. package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
  27. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
  29. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  30. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
  31. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
  32. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
  33. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  34. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
  35. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
  36. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  37. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
  38. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  39. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
  40. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
  41. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
  42. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  43. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
  44. package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  45. package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
  46. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
  47. package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
@@ -2267,7 +2267,7 @@ def _gen_page_object(page_class, kebab, camel, gherkin, healing_strategy,
2267
2267
  f' * {page_class}Page — Three-Layer Self-Healing Page Object\n'
2268
2268
  f' *\n'
2269
2269
  f' * Layer 1 — Locator Healing ({healing_strategy}):\n'
2270
- f' * primary selector → role-based → label-based → text-based → playwright-cli + Claude AI Vision\n'
2270
+ f' * primary selector → role-based → label-based → text-based → ariaSnapshot + Claude AI Vision\n'
2271
2271
  f' * → DevToolsHealer AX tree (CDPSession) → DevToolsHealer bounding box (CDPSession)\n'
2272
2272
  f' * Healed selectors persisted in LocatorRepository — zero overhead on repeat runs.\n'
2273
2273
  f' *\n'
@@ -2954,6 +2954,7 @@ def _scaffold_locator_repository(
2954
2954
  if enable_visual_regression:
2955
2955
  files[f"{output_dir}/VisualIntentChecker.ts"] = _gen_visual_checker_cls(repository_path, dashboard_port)
2956
2956
  files[f"{output_dir}/HealingDashboard.ts"] = _gen_healing_dashboard_cls(repository_path, dashboard_port)
2957
+ files[f"{output_dir}/dashboard-server.ts"] = _gen_dashboard_server_cls()
2957
2958
 
2958
2959
  strategies = [
2959
2960
  "1. Healed selector from LocatorRepository (zero overhead on repeat runs)",
@@ -2964,7 +2965,7 @@ def _scaffold_locator_repository(
2964
2965
  ]
2965
2966
  if enable_ai_vision:
2966
2967
  strategies.append(
2967
- "6. playwright-cli snapshot → AI Vision (provider: anthropic | copilot | claude-code) "
2968
+ "6. ariaSnapshot → AI Vision (provider: anthropic | copilot | claude-code) "
2968
2969
  "→ healed selector persisted [skip with ENABLE_AI_HEALING=false]"
2969
2970
  )
2970
2971
  if enable_timing_healing:
@@ -2998,7 +2999,7 @@ def _scaffold_locator_repository(
2998
2999
 
2999
3000
 
3000
3001
  def _gen_locator_healer_cls(enable_ai_vision):
3001
- ai_import = 'import { execSync } from "child_process";' if enable_ai_vision else ""
3002
+ ai_import = ""
3002
3003
 
3003
3004
  # ------------------------------------------------------------------
3004
3005
  # AI Vision method — emitted only when enable_ai_vision=True.
@@ -3094,7 +3095,7 @@ def _gen_locator_healer_cls(enable_ai_vision):
3094
3095
  const provider = (process.env.AI_HEALING_PROVIDER ?? "anthropic").toLowerCase();
3095
3096
  this.logger.warn(`⚕ AI Vision [${provider}]: key="${key}" intent="${intent}"`);
3096
3097
  try {
3097
- const snapshot = execSync("playwright-cli snapshot", { encoding: "utf8", timeout: 10_000 });
3098
+ const snapshot = await this.page.locator("body").ariaSnapshot();
3098
3099
  const { url, authHeader, bodyMapper } = this._aiVisionEndpoint();
3099
3100
  const prompt = `DOM snapshot:\\n${snapshot.slice(0, 3500)}\\nFind: "${intent}". Return ONLY a CSS selector, no explanation.`;
3100
3101
 
@@ -3141,14 +3142,59 @@ import {{ LocatorRepository }} from "./LocatorRepository";
3141
3142
  /**
3142
3143
  * LocatorHealer — Multi-Strategy Locator Healing
3143
3144
  *
3144
- * Chain: cached-healed → primary → role → label → text → AI Vision (6)
3145
+ * Chain: cached-healed → primary → attribute → type-hint → role → label → text → AI Vision
3145
3146
  *
3146
- * All healed selectors persisted in LocatorRepository (instant on repeat runs).
3147
+ * Form fields (username, password, email, …) heal via the attribute pass —
3148
+ * the most reliable strategy for inputs that have no accessible name. The
3149
+ * AI Vision step is optional and only fires when every CSS / Playwright
3150
+ * heuristic has been exhausted; it requires an Anthropic API key.
3151
+ *
3152
+ * All healed selectors are persisted to LocatorRepository so repeat runs
3153
+ * skip the entire chain after the first successful heal.
3154
+ */
3155
+ /**
3156
+ * Identifiers for the fallback strategies the resolver runs after the
3157
+ * primary selector fails. Used by HEAL_STRATEGY_ORDER (CSV env var) to
3158
+ * reorder or disable specific strategies without forking the scaffold.
3159
+ *
3160
+ * Order in this list is the historical default chain.
3147
3161
  */
3162
+ const DEFAULT_HEAL_STRATEGY_ORDER = [
3163
+ "attribute",
3164
+ "type-hint",
3165
+ "role",
3166
+ "label",
3167
+ "text",
3168
+ ] as const;
3169
+ type HealStrategyName = typeof DEFAULT_HEAL_STRATEGY_ORDER[number];
3170
+
3171
+ function _resolveStrategyOrder(): HealStrategyName[] {{
3172
+ const raw = (process.env.HEAL_STRATEGY_ORDER ?? "").trim();
3173
+ if (!raw) return [...DEFAULT_HEAL_STRATEGY_ORDER];
3174
+ const known = new Set<string>(DEFAULT_HEAL_STRATEGY_ORDER);
3175
+ const picked: HealStrategyName[] = [];
3176
+ for (const part of raw.split(/[,\\s]+/).filter(Boolean)) {{
3177
+ const norm = part.toLowerCase();
3178
+ if (known.has(norm)) picked.push(norm as HealStrategyName);
3179
+ }}
3180
+ // Empty / all-invalid CSV → fall back to defaults to keep the chain useful.
3181
+ return picked.length ? picked : [...DEFAULT_HEAL_STRATEGY_ORDER];
3182
+ }}
3183
+
3148
3184
  export class LocatorHealer {{
3185
+ // Original scaffold defaults — preserved verbatim. LOCATOR_TIMEOUT, when
3186
+ // set, overrides the per-strategy attach probe (ATTACH_TO); TIMEOUT and
3187
+ // HEAL_TO retain their historical values so action timeouts don't shift
3188
+ // for existing tests that depend on them.
3149
3189
  private static readonly TIMEOUT = 8_000;
3150
3190
  private static readonly HEAL_TO = 15_000;
3151
- private static readonly ATTACH_TO = 3_000;
3191
+ private static readonly ATTACH_TO = Number(process.env.LOCATOR_TIMEOUT ?? 3_000);
3192
+ // Cap on the number of healing strategies the resolver will try BEFORE
3193
+ // falling through to AI Vision. 0 = unlimited (whole chain runs, original
3194
+ // behavior). Set LOCATOR_HEAL_ATTEMPTS=N to bound it.
3195
+ private static readonly MAX_ATTEMPTS = Number(process.env.LOCATOR_HEAL_ATTEMPTS ?? 0);
3196
+ // Ordered list of fallback strategies — read once from HEAL_STRATEGY_ORDER.
3197
+ private static readonly STRATEGY_ORDER = _resolveStrategyOrder();
3152
3198
 
3153
3199
  constructor(
3154
3200
  private readonly page: Page,
@@ -3196,30 +3242,187 @@ export class LocatorHealer {{
3196
3242
  this.logger.warn(`⚕ Primary failed: ${{key}}`);
3197
3243
  this.repo.incrementFailure(key);
3198
3244
  }}
3199
- const words = intent.split(" ").slice(0, 3).join("|");
3200
- for (const role of ["button", "link", "textbox", "combobox"] as const) {{
3245
+
3246
+ // Per-call attempt counter for LOCATOR_HEAL_ATTEMPTS. When set to a
3247
+ // positive number, the chain stops trying fallback strategies after N
3248
+ // attempts and falls through to AI Vision (or throws if AI is disabled).
3249
+ let attempts = 0;
3250
+ const capReached = () => LocatorHealer.MAX_ATTEMPTS > 0 && attempts >= LocatorHealer.MAX_ATTEMPTS;
3251
+
3252
+ // Strategy dispatcher — iteration order is set by HEAL_STRATEGY_ORDER
3253
+ // (parsed once into LocatorHealer.STRATEGY_ORDER). Each strategy gets a
3254
+ // consume() callback to bump the attempts counter so LOCATOR_HEAL_ATTEMPTS
3255
+ // bounds *probes* (not just strategies).
3256
+ for (const strategy of LocatorHealer.STRATEGY_ORDER) {{
3257
+ if (capReached()) break;
3258
+ const result = await this._runStrategy(strategy, key, intent, () => {{ attempts++; }});
3259
+ if (result) return result;
3260
+ }}
3261
+
3262
+ // Final stage: AI Vision (needs API key — see _aiVisionEndpoint).
3263
+ return this.healViaAiVision(key, intent);
3264
+ }}
3265
+
3266
+ /**
3267
+ * Run one named healing strategy. Returns the matched Locator or null.
3268
+ *
3269
+ * `consume` is invoked once per probe so LOCATOR_HEAL_ATTEMPTS bounds the
3270
+ * total work across the chain — even strategies that loop internally
3271
+ * (role, type-hint) count one slot per probe.
3272
+ */
3273
+ private async _runStrategy(
3274
+ strategy: HealStrategyName,
3275
+ key: string,
3276
+ intent: string,
3277
+ consume: () => void,
3278
+ ): Promise<Locator | null> {{
3279
+ if (strategy === "attribute") {{
3280
+ const keyword = intent.split(" ").find(w => w.length > 2) ?? intent;
3281
+ const attrSel = [
3282
+ `[name*="${{keyword}}" i]`,
3283
+ `[placeholder*="${{keyword}}" i]`,
3284
+ `[id*="${{keyword}}" i]`,
3285
+ `[aria-label*="${{keyword}}" i]`,
3286
+ `[data-testid*="${{keyword}}" i]`,
3287
+ `[data-test*="${{keyword}}" i]`,
3288
+ `[autocomplete*="${{keyword}}" i]`,
3289
+ ].join(", ");
3290
+ consume();
3201
3291
  try {{
3202
- const l = this.page.getByRole(role, {{ name: new RegExp(words, "i") }});
3292
+ const l = this.page.locator(attrSel).first();
3203
3293
  await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3204
- this.repo.updateHealed(key, `[role:${{role}}]`, intent, "role");
3205
- this.logger.warn(`⚕ Role-healed: ${{key}}`);
3294
+ const specific = await this._resolveSpecificSelector(l, attrSel);
3295
+ this.repo.updateHealed(key, specific, intent, "attr");
3296
+ this.logger.warn(`⚕ Attribute-healed: ${{key}} → ${{specific}}`);
3206
3297
  return l;
3207
3298
  }} catch {{}}
3299
+ return null;
3208
3300
  }}
3301
+
3302
+ if (strategy === "type-hint") {{
3303
+ const lower = intent.toLowerCase();
3304
+ const typeHints: Array<[RegExp, string]> = [
3305
+ [/\\bpassword\\b/, 'input[type="password"]'],
3306
+ [/\\bemail\\b/, 'input[type="email"], input[autocomplete="email"]'],
3307
+ [/\\bsearch\\b/, 'input[type="search"], [role="search"] input'],
3308
+ [/\\bphone\\b/, 'input[type="tel"], input[autocomplete*="tel"]'],
3309
+ [/\\bcheckbox\\b/, 'input[type="checkbox"]'],
3310
+ [/\\bradio\\b/, 'input[type="radio"]'],
3311
+ [/\\b(submit|login|sign\\s?in|save|continue|next|confirm)\\b/,
3312
+ 'button[type="submit"], input[type="submit"]'],
3313
+ ];
3314
+ for (const [re, sel] of typeHints) {{
3315
+ if (!re.test(lower)) continue;
3316
+ consume();
3317
+ try {{
3318
+ const l = this.page.locator(sel).first();
3319
+ await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3320
+ const specific = await this._resolveSpecificSelector(l, sel);
3321
+ this.repo.updateHealed(key, specific, intent, "type-hint");
3322
+ this.logger.warn(`⚕ Type-hint healed: ${{key}} → ${{specific}}`);
3323
+ return l;
3324
+ }} catch {{}}
3325
+ }}
3326
+ return null;
3327
+ }}
3328
+
3329
+ if (strategy === "role") {{
3330
+ const words = intent.split(" ").slice(0, 3).join("|");
3331
+ for (const role of ["button", "link", "textbox", "combobox", "checkbox", "radio"] as const) {{
3332
+ consume();
3333
+ try {{
3334
+ const l = this.page.getByRole(role, {{ name: new RegExp(words, "i") }});
3335
+ await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3336
+ const specific = await this._resolveSpecificSelector(l, `[role:${{role}}]`);
3337
+ this.repo.updateHealed(key, specific, intent, "role");
3338
+ this.logger.warn(`⚕ Role-healed: ${{key}} → ${{specific}}`);
3339
+ return l;
3340
+ }} catch {{}}
3341
+ }}
3342
+ return null;
3343
+ }}
3344
+
3345
+ if (strategy === "label") {{
3346
+ consume();
3347
+ try {{
3348
+ const l = this.page.getByLabel(intent, {{ exact: false }});
3349
+ await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3350
+ const specific = await this._resolveSpecificSelector(l, "[label-healed]");
3351
+ this.repo.updateHealed(key, specific, intent, "label");
3352
+ this.logger.warn(`⚕ Label-healed: ${{key}} → ${{specific}}`);
3353
+ return l;
3354
+ }} catch {{}}
3355
+ return null;
3356
+ }}
3357
+
3358
+ if (strategy === "text") {{
3359
+ consume();
3360
+ try {{
3361
+ const w = intent.split(" ").find(w => w.length > 3) ?? intent;
3362
+ const l = this.page.getByText(w, {{ exact: false }});
3363
+ await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3364
+ const specific = await this._resolveSpecificSelector(l, "[text-healed]");
3365
+ this.repo.updateHealed(key, specific, intent, "text");
3366
+ this.logger.warn(`⚕ Text-healed: ${{key}} → ${{specific}}`);
3367
+ return l;
3368
+ }} catch {{}}
3369
+ return null;
3370
+ }}
3371
+
3372
+ return null;
3373
+ }}
3374
+
3375
+ /**
3376
+ * Inspect the actually-matched element and return the most stable
3377
+ * SINGLE selector that uniquely identifies it on the live page.
3378
+ *
3379
+ * The healer's search selectors are deliberately broad (CSS unions, role
3380
+ * queries, etc.) so the chain has the best chance of finding *some*
3381
+ * matching element when the original primary selector breaks. But once
3382
+ * we've matched, we don't want to persist the union — we want the one
3383
+ * concrete locator that actually pinned the element. That's what the
3384
+ * qa-playwright-generator agent emits at generation time, and it's
3385
+ * what the operator wants to see in the heal store.
3386
+ *
3387
+ * Preference order (most stable first):
3388
+ * 1. `data-testid` / `data-test` — purpose-built test hooks
3389
+ * 2. `id` — unique by HTML spec
3390
+ * 3. `name` — typical for form fields
3391
+ * 4. `placeholder` — visible-only forms
3392
+ * 5. `aria-label` — accessible name
3393
+ * 6. `autocomplete` — semantic hint
3394
+ * 7. The fallback selector passed in — used when none of the above
3395
+ * are present (rare; means the element is genuinely anonymous).
3396
+ */
3397
+ private async _resolveSpecificSelector(loc: Locator, fallback: string): Promise<string> {{
3209
3398
  try {{
3210
- const l = this.page.getByLabel(intent, {{ exact: false }});
3211
- await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3212
- this.repo.updateHealed(key, "[label-healed]", intent, "label");
3213
- return l;
3214
- }} catch {{}}
3215
- try {{
3216
- const w = intent.split(" ").find(w => w.length > 3) ?? intent;
3217
- const l = this.page.getByText(w, {{ exact: false }});
3218
- await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
3219
- this.repo.updateHealed(key, "[text-healed]", intent, "text");
3220
- return l;
3399
+ const attrs = await loc.evaluate((el: Element) => {{
3400
+ const get = (name: string) => el.getAttribute(name) ?? null;
3401
+ return {{
3402
+ tag: el.tagName.toLowerCase(),
3403
+ id: (el as HTMLElement).id || null,
3404
+ name: get("name"),
3405
+ placeholder: get("placeholder"),
3406
+ ariaLabel: get("aria-label"),
3407
+ dataTestId: get("data-testid"),
3408
+ dataTest: get("data-test"),
3409
+ autocomplete: get("autocomplete"),
3410
+ type: get("type"),
3411
+ }};
3412
+ }});
3413
+ const esc = (v: string) => v.replace(/(["\\\\])/g, "\\\\$1");
3414
+ if (attrs.dataTestId) return `[data-testid="${{esc(attrs.dataTestId)}}"]`;
3415
+ if (attrs.dataTest) return `[data-test="${{esc(attrs.dataTest)}}"]`;
3416
+ if (attrs.id) return `#${{attrs.id.replace(/([^\\w-])/g, "\\\\$1")}}`;
3417
+ if (attrs.name) return `${{attrs.tag}}[name="${{esc(attrs.name)}}"]`;
3418
+ if (attrs.placeholder) return `${{attrs.tag}}[placeholder="${{esc(attrs.placeholder)}}"]`;
3419
+ if (attrs.ariaLabel) return `[aria-label="${{esc(attrs.ariaLabel)}}"]`;
3420
+ if (attrs.autocomplete) return `${{attrs.tag}}[autocomplete="${{esc(attrs.autocomplete)}}"]`;
3421
+ // Element has no identifying attribute — fall back to type+tag if it's a typed input.
3422
+ if (attrs.tag === "input" && attrs.type)
3423
+ return `input[type="${{esc(attrs.type)}}"]`;
3221
3424
  }} catch {{}}
3222
- return this.healViaAiVision(key, intent);
3425
+ return fallback;
3223
3426
  }}
3224
3427
  {ai_method}
3225
3428
  }}
@@ -3240,31 +3443,182 @@ export interface LocatorEntry {{
3240
3443
  lastBoundingBox?: BoundingBox;
3241
3444
  /** Pending AI suggestion awaiting HealingDashboard approval before commit */
3242
3445
  pendingSuggestion?: {{ selector:string; strategy:string; suggestedAt:string }};
3446
+ /** Set by HealingDashboard.approve — confirms a human signed off on the heal. */
3447
+ approvedAt?: string;
3448
+ /** Set when HealingDashboard.approve also wrote the healed selector back to the locator .ts file. */
3449
+ appliedToSourceAt?: string;
3450
+ }}
3451
+
3452
+ /** Current persisted-file schema version. Bumped when the on-disk shape changes. */
3453
+ export const HEAL_STORE_VERSION = 1;
3454
+
3455
+ /**
3456
+ * Resolve the heal-store path with env-var precedence:
3457
+ * 1. constructor argument (explicit override)
3458
+ * 2. HEAL_STORE_PATH (operator override)
3459
+ * 3. compile-time default ("{repository_path}")
3460
+ *
3461
+ * If `ENV` or `HELIX_ENV` is set, the chosen path is auto-suffixed with the
3462
+ * env slug — e.g. `./self-heals/healed-locators.json` →
3463
+ * `./self-heals/healed-locators.qa4.json` — so parallel runs against
3464
+ * different test environments don't trample each other's healed selectors.
3465
+ */
3466
+ function _resolveHealStorePath(override?: string): string {{
3467
+ const base = override ?? process.env.HEAL_STORE_PATH ?? "{repository_path}";
3468
+ const envTag = (process.env.ENV ?? process.env.HELIX_ENV ?? "").trim().toLowerCase();
3469
+ if (!envTag) return base;
3470
+ const ext = path.extname(base);
3471
+ if (ext) return base.slice(0, -ext.length) + "." + envTag + ext;
3472
+ return base + "." + envTag;
3243
3473
  }}
3244
3474
 
3245
3475
  /**
3246
3476
  * LocatorRepository — Shared state store for all healing layers.
3247
- * Persists to "{repository_path}".
3477
+ *
3478
+ * Persisted on-disk format (HEAL_STORE_VERSION=1):
3479
+ * {{ "_version": 1, "entries": {{ <key>: LocatorEntry, ... }} }}
3480
+ *
3481
+ * Legacy v0 files (flat {{ <key>: LocatorEntry, ... }}) are still readable.
3482
+ * On first write after load, the file is upgraded to the v1 envelope.
3483
+ *
3484
+ * History retention is controlled by ENABLE_LOCATOR_VERSIONING:
3485
+ * • unset / "true" → keep the full healingHistory[] of every heal (default)
3486
+ * • "false" → keep only the latest heal entry (lighter file)
3487
+ *
3488
+ * History length is capped by HEAL_HISTORY_CAP (default 50). Setting to 0
3489
+ * disables the cap; otherwise the array keeps the most recent N entries so
3490
+ * heavily-healed locators don't grow the file unboundedly.
3491
+ *
3492
+ * Writes are coalesced with a {{LOCATOR_REPO_DEBOUNCE_MS}}ms debounce — every
3493
+ * mutation schedules a flush rather than writing synchronously. A flush is
3494
+ * forced on process exit so no in-flight state is lost.
3495
+ *
3248
3496
  * queueSuggestion / approveSuggestion / rejectSuggestion power the HealingDashboard.
3249
3497
  */
3498
+ const LOCATOR_REPO_DEBOUNCE_MS = 500;
3499
+
3500
+ // Process-wide registry of every LocatorRepository instance, keyed by
3501
+ // resolved file path. A single set of exit hooks is registered on first
3502
+ // instantiation and flushes all known instances on shutdown. Required
3503
+ // because every page object instantiates its own LocatorRepository — if
3504
+ // each instance registered its own SIGINT/beforeExit listeners we'd hit
3505
+ // Node's MaxListenersExceededWarning at ~4 instances, and if each instance
3506
+ // only knew about its own state we'd lose data when the *last* instance to
3507
+ // write overwrote everyone else's heals.
3508
+ const _LIVE_REPOSITORIES: Map<string, LocatorRepository[]> = (() => {{
3509
+ const g = globalThis as any;
3510
+ if (!g.__locatorRepoRegistry) g.__locatorRepoRegistry = new Map();
3511
+ return g.__locatorRepoRegistry as Map<string, LocatorRepository[]>;
3512
+ }})();
3513
+
3514
+ let _exitHooksInstalled = false;
3515
+ function _installProcessExitHooks(): void {{
3516
+ if (_exitHooksInstalled) return;
3517
+ _exitHooksInstalled = true;
3518
+ const flushAll = () => {{
3519
+ _LIVE_REPOSITORIES.forEach(group => group.forEach(r => {{
3520
+ try {{ r.flush(); }} catch {{}}
3521
+ }}));
3522
+ }};
3523
+ process.once("beforeExit", flushAll);
3524
+ process.once("SIGINT", () => {{ flushAll(); process.exit(130); }});
3525
+ process.once("SIGTERM", () => {{ flushAll(); process.exit(143); }});
3526
+ }}
3527
+
3250
3528
  export class LocatorRepository {{
3251
3529
  private readonly store = new Map<string, LocatorEntry>();
3252
- constructor(private readonly filePath = "{repository_path}") {{ this.load(); }}
3530
+ // Versioning defaults to ON matches the scaffold's historical behavior
3531
+ // of always appending to healingHistory. Set ENABLE_LOCATOR_VERSIONING=false
3532
+ // to keep only the latest heal entry.
3533
+ private readonly versioningEnabled = process.env.ENABLE_LOCATOR_VERSIONING !== "false";
3534
+ private readonly historyCap = (() => {{
3535
+ const n = Number(process.env.HEAL_HISTORY_CAP ?? 50);
3536
+ return Number.isFinite(n) && n >= 0 ? n : 50;
3537
+ }})();
3538
+ private flushTimer: NodeJS.Timeout | null = null;
3539
+ readonly filePath: string;
3540
+
3541
+ constructor(filePath?: string) {{
3542
+ this.filePath = _resolveHealStorePath(filePath);
3543
+ this.load();
3544
+ const group = _LIVE_REPOSITORIES.get(this.filePath) ?? [];
3545
+ group.push(this);
3546
+ _LIVE_REPOSITORIES.set(this.filePath, group);
3547
+ _installProcessExitHooks();
3548
+ }}
3253
3549
 
3254
3550
  register(key: string, selector: string, intent: string, stability = 0): void {{
3255
- if (!this.store.has(key))
3551
+ const existing = this.store.get(key);
3552
+ if (!existing) {{
3256
3553
  this.store.set(key, {{ selector, intent, stability, healingHistory: [], successCount: 0, failureCount: 0 }});
3257
- else {{ this.store.get(key)!.intent = intent; this.store.get(key)!.stability = stability; }}
3554
+ return;
3555
+ }}
3556
+ // Always re-sync the baseline `selector` so the heal-store stays in
3557
+ // step with source-code edits (e.g. dashboard Confirm just wrote a
3558
+ // healed selector back into the locator .ts file — next register call
3559
+ // should reflect that). If the new baseline now equals the previously
3560
+ // healed selector, drop the heal state: the source has caught up and
3561
+ // the entry's just a baseline registration again.
3562
+ existing.intent = intent;
3563
+ existing.stability = stability;
3564
+ if (existing.healedSelector && existing.healedSelector === selector) {{
3565
+ delete existing.healedSelector;
3566
+ delete existing.lastHealed;
3567
+ delete existing.approvedAt;
3568
+ existing.healingHistory = [];
3569
+ }}
3570
+ existing.selector = selector;
3258
3571
  }}
3259
3572
 
3260
3573
  getHealed(key: string): string | undefined {{ return this.store.get(key)?.healedSelector; }}
3261
3574
 
3262
3575
  updateHealed(key: string, sel: string, intent: string, strategy = "unknown"): void {{
3263
3576
  const e = this.store.get(key); if (!e) return;
3264
- e.healingHistory.push({{ from: e.healedSelector ?? e.selector, to: sel,
3265
- timestamp: new Date().toISOString(), strategy }});
3577
+ const entry = {{ from: e.healedSelector ?? e.selector, to: sel,
3578
+ timestamp: new Date().toISOString(), strategy }};
3579
+ if (this.versioningEnabled) {{
3580
+ e.healingHistory.push(entry);
3581
+ // Trim oldest entries when history exceeds the cap so the JSON file
3582
+ // stays bounded even for locators that heal hundreds of times.
3583
+ if (this.historyCap > 0 && e.healingHistory.length > this.historyCap) {{
3584
+ e.healingHistory = e.healingHistory.slice(-this.historyCap);
3585
+ }}
3586
+ }} else {{
3587
+ // Keep only the most recent heal so the JSON file stays small. The
3588
+ // current selector is on `healedSelector` itself; the single-element
3589
+ // history exists just so consumers (dashboard, exportJson) still see
3590
+ // *something* in the array.
3591
+ e.healingHistory = [entry];
3592
+ }}
3266
3593
  e.healedSelector = sel; e.lastHealed = new Date().toISOString();
3267
- delete e.pendingSuggestion; this.persist();
3594
+ delete e.pendingSuggestion;
3595
+ this._appendHealLog(key, strategy, sel);
3596
+ // Force-flush on heal events so the operator can see the healed
3597
+ // selector in the JSON file immediately — debouncing is fine for
3598
+ // incrementSuccess / incrementFailure (which fire on every step) but
3599
+ // would hide the heal-store change behind a 500ms delay an interrupted
3600
+ // test run could miss.
3601
+ this.flush();
3602
+ }}
3603
+
3604
+ /**
3605
+ * Append a one-line record per heal to `./self-heals/heal.log`
3606
+ * (override path via HEAL_LOG_PATH). Format is tab-separated:
3607
+ *
3608
+ * <ISO timestamp>\\t<key>\\t<strategy>\\t<selector>
3609
+ *
3610
+ * Set HEAL_LOG_PATH=/dev/null (or HEAL_LOG_DISABLED=true) to skip.
3611
+ */
3612
+ private _appendHealLog(key: string, strategy: string, selector: string): void {{
3613
+ if (process.env.HEAL_LOG_DISABLED === "true") return;
3614
+ try {{
3615
+ const logPath = process.env.HEAL_LOG_PATH
3616
+ ?? path.join(path.dirname(this.filePath), "heal.log");
3617
+ if (logPath === "/dev/null") return;
3618
+ fs.mkdirSync(path.dirname(logPath), {{ recursive: true }});
3619
+ const line = `${{new Date().toISOString()}}\\t${{key}}\\t${{strategy}}\\t${{selector}}\\n`;
3620
+ fs.appendFileSync(logPath, line, "utf8");
3621
+ }} catch {{}}
3268
3622
  }}
3269
3623
 
3270
3624
  queueSuggestion(key: string, suggestedSelector: string, strategy: string): void {{
@@ -3317,27 +3671,119 @@ export class LocatorRepository {{
3317
3671
  }};
3318
3672
  }}
3319
3673
 
3674
+ /** Predicate: should this entry be written to disk? */
3675
+ private _isPersistable(v: LocatorEntry): boolean {{
3676
+ // Only entries with an actual heal recorded (healedSelector) or a
3677
+ // pending suggestion awaiting review qualify. Pure-registered baseline
3678
+ // entries (`register(...)` was called on construction but the locator
3679
+ // has never needed healing) stay in memory only — keeps the JSON file
3680
+ // focused on what humans actually need to look at.
3681
+ return Boolean(v.healedSelector) || Boolean(v.pendingSuggestion);
3682
+ }}
3683
+
3320
3684
  exportJson(): string {{
3321
- const o: Record<string, LocatorEntry> = {{}};
3322
- this.store.forEach((v, k) => {{ o[k] = v; }});
3323
- return JSON.stringify(o, null, 2);
3685
+ const entries: Record<string, LocatorEntry> = {{}};
3686
+ this.store.forEach((v, k) => {{
3687
+ if (this._isPersistable(v)) entries[k] = v;
3688
+ }});
3689
+ return JSON.stringify({{
3690
+ _version: HEAL_STORE_VERSION,
3691
+ _writtenAt: new Date().toISOString(),
3692
+ entries,
3693
+ }}, null, 2);
3324
3694
  }}
3325
3695
 
3696
+ /**
3697
+ * Schedule a debounced flush of the in-memory store to disk. Multiple
3698
+ * mutations within {{LOCATOR_REPO_DEBOUNCE_MS}}ms coalesce into one write —
3699
+ * a big speedup for parallel runs that previously triggered a synchronous
3700
+ * file write on every step.
3701
+ */
3326
3702
  private persist(): void {{
3703
+ if (this.flushTimer) return;
3704
+ this.flushTimer = setTimeout(() => {{
3705
+ this.flushTimer = null;
3706
+ this._writeNow();
3707
+ }}, LOCATOR_REPO_DEBOUNCE_MS);
3708
+ // Keep node alive long enough for the flush; cleared on _writeNow.
3709
+ this.flushTimer.unref?.();
3710
+ }}
3711
+
3712
+ /** Force an immediate synchronous write — used by exit hooks and tests. */
3713
+ flush(): void {{
3714
+ if (this.flushTimer) {{ clearTimeout(this.flushTimer); this.flushTimer = null; }}
3715
+ this._writeNow();
3716
+ }}
3717
+
3718
+ private _writeNow(): void {{
3327
3719
  try {{
3328
3720
  fs.mkdirSync(path.dirname(this.filePath), {{ recursive: true }});
3329
- fs.writeFileSync(this.filePath, this.exportJson(), "utf8");
3721
+ // Read-modify-write: merge in-memory state on top of whatever's on
3722
+ // disk so concurrent LocatorRepository instances (one per page object
3723
+ // class, plus per-worker copies in parallel cucumber runs) don't
3724
+ // trample each other's heals. Last writer still wins per-key, but
3725
+ // *keys this instance has never touched* are preserved.
3726
+ const merged = this._mergeWithDisk();
3727
+ fs.writeFileSync(this.filePath, merged, "utf8");
3330
3728
  }} catch {{}}
3331
3729
  }}
3332
3730
 
3333
- private load(): void {{
3731
+ /**
3732
+ * Serialize the on-disk state, overlay this instance's in-memory changes,
3733
+ * and return the resulting JSON string. Preserves entries the current
3734
+ * instance doesn't know about (held by sibling LocatorRepository instances
3735
+ * or written by a previous test run).
3736
+ */
3737
+ private _mergeWithDisk(): string {{
3738
+ let onDisk: Record<string, LocatorEntry> = {{}};
3334
3739
  try {{
3335
3740
  if (fs.existsSync(this.filePath)) {{
3336
3741
  const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
3337
- Object.entries(raw).forEach(([k, v]) => this.store.set(k, v as LocatorEntry));
3742
+ if (raw && typeof raw === "object" && "entries" in raw && raw.entries) {{
3743
+ onDisk = raw.entries as Record<string, LocatorEntry>;
3744
+ }} else if (raw && typeof raw === "object") {{
3745
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {{
3746
+ if (!k.startsWith("_")) onDisk[k] = v as LocatorEntry;
3747
+ }}
3748
+ }}
3749
+ }}
3750
+ }} catch {{}}
3751
+ // Overlay this instance's state, then filter to only the entries worth
3752
+ // persisting (healed or pending). Pure-registered baseline entries drop
3753
+ // out of the file, while heals from sibling LocatorRepository instances
3754
+ // remain (read from disk above).
3755
+ this.store.forEach((v, k) => {{ onDisk[k] = v; }});
3756
+ const filtered: Record<string, LocatorEntry> = {{}};
3757
+ for (const [k, v] of Object.entries(onDisk)) {{
3758
+ if (this._isPersistable(v)) filtered[k] = v;
3759
+ }}
3760
+ return JSON.stringify({{
3761
+ _version: HEAL_STORE_VERSION,
3762
+ _writtenAt: new Date().toISOString(),
3763
+ entries: filtered,
3764
+ }}, null, 2);
3765
+ }}
3766
+
3767
+ private load(): void {{
3768
+ try {{
3769
+ if (!fs.existsSync(this.filePath)) return;
3770
+ const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
3771
+ // v1 envelope: {{ _version, entries: {{...}} }}
3772
+ if (raw && typeof raw === "object" && "entries" in raw && raw.entries) {{
3773
+ Object.entries(raw.entries as Record<string, unknown>).forEach(([k, v]) => {{
3774
+ this.store.set(k, v as LocatorEntry);
3775
+ }});
3776
+ return;
3338
3777
  }}
3778
+ // Legacy v0 (flat map). Read it; the next persist() upgrades the file.
3779
+ Object.entries(raw as Record<string, unknown>).forEach(([k, v]) => {{
3780
+ // Defensive: skip top-level metadata keys that happen to start with "_".
3781
+ if (k.startsWith("_")) return;
3782
+ this.store.set(k, v as LocatorEntry);
3783
+ }});
3339
3784
  }} catch {{}}
3340
3785
  }}
3786
+
3341
3787
  }}
3342
3788
  '''
3343
3789
 
@@ -3460,7 +3906,7 @@ export class VisualIntentChecker {{
3460
3906
  return;
3461
3907
  }}
3462
3908
  const screenshot = await loc.screenshot({{ timeout: 5_000 }});
3463
- fs.writeFileSync(actual, screenshot);
3909
+ fs.writeFileSync(actual, new Uint8Array(screenshot));
3464
3910
  if (!fs.existsSync(baseline)) {{
3465
3911
  fs.copyFileSync(actual, baseline);
3466
3912
  this.logger.info(`📸 VisualIntent: new baseline saved for "${{key}}"`);
@@ -3493,6 +3939,34 @@ export class VisualIntentChecker {{
3493
3939
  '''
3494
3940
 
3495
3941
 
3942
+ def _gen_dashboard_server_cls():
3943
+ """
3944
+ Standalone launcher for the HealingDashboard HTTP server.
3945
+
3946
+ Invoked by the `healix:dashboard` and `healix:review` npm scripts. Loads
3947
+ .env first so HEAL_STORE_PATH / HEALING_DASHBOARD_PORT / HEALIX_REVIEW_PORT
3948
+ are honored without re-running the whole test suite.
3949
+ """
3950
+ return '''import "dotenv/config";
3951
+ import { HealingDashboard } from "./HealingDashboard";
3952
+
3953
+ const dash = new HealingDashboard();
3954
+ dash.start();
3955
+
3956
+ function shutdown(code: number): void {
3957
+ try { dash.stop(); } catch {}
3958
+ process.exit(code);
3959
+ }
3960
+
3961
+ process.on("SIGINT", () => shutdown(130));
3962
+ process.on("SIGTERM", () => shutdown(143));
3963
+
3964
+ // Idle for ever — Node would otherwise exit because the HTTP server's only
3965
+ // effective work happens inside its event loop.
3966
+ setInterval(() => {}, 1 << 30);
3967
+ '''
3968
+
3969
+
3496
3970
  def _gen_healing_dashboard_cls(repository_path, dashboard_port):
3497
3971
  return f'''import * as fs from "fs";
3498
3972
  import * as path from "path";
@@ -3502,56 +3976,184 @@ import * as url from "url";
3502
3976
  /**
3503
3977
  * HealingDashboard — CI/CD Telemetry for AI-Suggested Changes
3504
3978
  *
3505
- * Zero-dependency HTTP server displaying all pending suggestions from:
3506
- * • LocatorHealer → AI Vision selector suggestions
3507
- * TimingHealer → auto-adjusted timeout suggestions
3508
- * VisualIntentChecker visual baseline update suggestions
3979
+ * Two-server design:
3980
+ *
3981
+ * 1. **Dashboard server** (HEALING_DASHBOARD_PORT, default {dashboard_port}) —
3982
+ * full read/write UI for engineers; serves the HTML dashboard and the
3983
+ * approve / reject endpoints that commit changes to the heal store.
3984
+ * Set HEALING_DASHBOARD_PORT=0 to disable.
3985
+ *
3986
+ * 2. **Review server** (HEALIX_REVIEW_PORT, optional) — read-only mirror
3987
+ * for QA leads / managers / external dashboards. Serves the same JSON
3988
+ * endpoints (`/api/healed`, `/api/pending`, `/api/analytics`,
3989
+ * `/api/heal-log`) but rejects any POST. Off by default — set
3990
+ * HEALIX_REVIEW_PORT=<port> to enable.
3509
3991
  *
3510
- * Endpoints:
3992
+ * Dashboard endpoints (read/write):
3511
3993
  * GET / → HTML dashboard (auto-refreshes every 10s)
3512
- * GET /api/pending → JSON pending suggestions
3994
+ * GET /api/healed → JSON of all healed locators (current state)
3995
+ * GET /api/pending → JSON pending AI suggestions
3513
3996
  * GET /api/analytics → JSON healing analytics
3514
- * POST /api/approve/:key Approve commit to {repository_path}
3997
+ * GET /api/heal-log Tail of the heal log (recent heals, plain text)
3998
+ * POST /api/approve/:key → Approve → commit to the heal store
3515
3999
  * POST /api/reject/:key → Reject → discard
3516
4000
  * GET /api/visual/:key → Serve actual PNG for visual diff review
3517
4001
  *
3518
- * Set HEALING_DASHBOARD_PORT=0 to disable the HTTP server in CI pipelines.
3519
- * Suggestions are still written to {repository_path} for async review.
4002
+ * Suggestions are written to the heal store regardless of dashboard state,
4003
+ * so async review still works even when both servers are disabled.
4004
+ */
4005
+ /**
4006
+ * Locate the `<bareKey>: {{ selector: "<oldSelector>", ... }}` entry inside a
4007
+ * `*.locators.ts` source string and rewrite the `selector` field to point at
4008
+ * `newSelector`. Idempotent — if the entry isn't present or the selector
4009
+ * doesn't match `oldSelector`, the content is returned unchanged.
4010
+ *
4011
+ * Quote handling: TypeScript locator files use double-quoted strings by
4012
+ * convention (this matches `locator_registrar.py`'s `_quote` preference),
4013
+ * but we also accept single-quoted and back-ticked forms for robustness.
4014
+ * The rewrite preserves the quote style.
4015
+ *
4016
+ * Lives at module scope (not inside the class) so the surrounding Python
4017
+ * f-string template doesn't have to deal with the regex backslashes that
4018
+ * a class method would require — they get tangled into invalid syntax
4019
+ * once the f-string evaluates.
4020
+ */
4021
+ function LocatorHealer_rewriteEntrySelector(
4022
+ content: string, bareKey: string, oldSelector: string, newSelector: string,
4023
+ ): string {{
4024
+ if (oldSelector === newSelector) return content;
4025
+ const REGEX_META = ".*+?^${{}}()|[]\\\\";
4026
+ const escForRegex = (s: string) => {{
4027
+ let out = "";
4028
+ for (const c of s) {{
4029
+ if (REGEX_META.indexOf(c) >= 0) out += "\\\\";
4030
+ out += c;
4031
+ }}
4032
+ return out;
4033
+ }};
4034
+ const escForStringBody = (s: string, quote: string) => {{
4035
+ let out = "";
4036
+ for (const c of s) {{
4037
+ if (c === "\\\\" || c === quote) out += "\\\\";
4038
+ out += c;
4039
+ }}
4040
+ return out;
4041
+ }};
4042
+ let result = content;
4043
+ for (const quote of ['"', "'", "`"]) {{
4044
+ // Allow the source `oldSelector` to use either a literal quote or an
4045
+ // escaped one. We don't try to be exhaustive — most real heal entries
4046
+ // contain neither.
4047
+ const oldEscaped = escForRegex(oldSelector);
4048
+ const pattern = new RegExp(
4049
+ "(\\\\b" + escForRegex(bareKey) + "\\\\s*:\\\\s*\\\\{{\\\\s*selector\\\\s*:\\\\s*" +
4050
+ quote + ")" + oldEscaped + "(" + quote + ")",
4051
+ "g",
4052
+ );
4053
+ result = result.replace(pattern, (_m, prefix: string, q: string) => {{
4054
+ return prefix + escForStringBody(newSelector, q) + q;
4055
+ }});
4056
+ }}
4057
+ return result;
4058
+ }}
4059
+
4060
+ /**
4061
+ * Resolve the heal-store path with the same env-suffix logic the
4062
+ * LocatorRepository uses, so the dashboard reads the file the test run
4063
+ * actually wrote (e.g. `healed-locators.qa4.json` when ENV=qa4).
4064
+ *
4065
+ * Inlined rather than imported from LocatorRepository.ts to keep the
4066
+ * dashboard server self-contained — it can boot without ever loading the
4067
+ * full repository class (which would also install exit hooks etc.).
4068
+ */
4069
+ function _resolveDashboardStorePath(override?: string): string {{
4070
+ const base = override ?? process.env.HEAL_STORE_PATH ?? "{repository_path}";
4071
+ const envTag = (process.env.ENV ?? process.env.HELIX_ENV ?? "").trim().toLowerCase();
4072
+ if (!envTag) return base;
4073
+ const ext = path.extname(base);
4074
+ if (ext) return base.slice(0, -ext.length) + "." + envTag + ext;
4075
+ return base + "." + envTag;
4076
+ }}
4077
+
4078
+ /**
4079
+ * List every heal-store sibling of `primary` — i.e. files in the same
4080
+ * directory whose basename starts with the same stem (e.g. given
4081
+ * `./self-heals/healed-locators.json`, return `healed-locators.json`,
4082
+ * `healed-locators.qa4.json`, `healed-locators.dev1.json`, ...). Lets the
4083
+ * dashboard show heals from all environments when the operator launches
4084
+ * `healix:dashboard` without an explicit ENV.
3520
4085
  */
4086
+ function _siblingStoreFiles(primary: string): string[] {{
4087
+ try {{
4088
+ const dir = path.dirname(primary);
4089
+ const base = path.basename(primary);
4090
+ const ext = path.extname(base);
4091
+ const stem = ext ? base.slice(0, -ext.length) : base;
4092
+ // Strip any existing `.<env>` suffix so siblings of `*.qa4.json`
4093
+ // resolve as well as siblings of `*.json`.
4094
+ const root = stem.split(".")[0];
4095
+ if (!fs.existsSync(dir)) return [];
4096
+ return fs.readdirSync(dir)
4097
+ .filter(name => name.startsWith(root + ".") || name === root + (ext || ".json"))
4098
+ .filter(name => name.endsWith(ext || ".json"))
4099
+ .map(name => path.join(dir, name));
4100
+ }} catch {{
4101
+ return [];
4102
+ }}
4103
+ }}
4104
+
3521
4105
  export class HealingDashboard {{
3522
4106
  private server: http.Server | null = null;
4107
+ private reviewServer: http.Server | null = null;
3523
4108
  private readonly port: number;
3524
-
3525
- constructor(
3526
- private readonly repoPath = "{repository_path}",
3527
- port = {dashboard_port},
3528
- ) {{
3529
- this.port = Number(process.env.HEALING_DASHBOARD_PORT ?? port);
4109
+ private readonly reviewPort: number;
4110
+ private readonly repoPath: string;
4111
+
4112
+ constructor(repoPathOverride?: string, port = {dashboard_port}) {{
4113
+ this.repoPath = _resolveDashboardStorePath(repoPathOverride);
4114
+ this.port = Number(process.env.HEALING_DASHBOARD_PORT ?? port);
4115
+ // Review server is opt-in (default 0 = disabled) so we don't grab a
4116
+ // random port without the operator asking for it.
4117
+ this.reviewPort = Number(process.env.HEALIX_REVIEW_PORT ?? 0);
3530
4118
  }}
3531
4119
 
3532
4120
  start(): void {{
3533
- if (this.port === 0) return;
3534
- this.server = http.createServer((req, res) => this._route(req, res));
3535
- this.server.listen(this.port, () => {{
3536
- console.log(`\\n🩺 HealingDashboard running at http://localhost:${{this.port}}`);
3537
- console.log(` Approve or reject AI-suggested changes before committing.\\n`);
3538
- }});
4121
+ if (this.port !== 0) {{
4122
+ this.server = http.createServer((req, res) => this._route(req, res, /* readOnly */ false));
4123
+ this.server.listen(this.port, () => {{
4124
+ console.log(`\\n🩺 HealingDashboard running at http://localhost:${{this.port}}`);
4125
+ console.log(` Approve or reject AI-suggested changes before committing.\\n`);
4126
+ }});
4127
+ }}
4128
+ if (this.reviewPort !== 0 && this.reviewPort !== this.port) {{
4129
+ this.reviewServer = http.createServer((req, res) => this._route(req, res, /* readOnly */ true));
4130
+ this.reviewServer.listen(this.reviewPort, () => {{
4131
+ console.log(`👁 HealingReview (read-only) at http://localhost:${{this.reviewPort}}\\n`);
4132
+ }});
4133
+ }}
3539
4134
  }}
3540
4135
 
3541
- stop(): void {{ this.server?.close(); }}
4136
+ stop(): void {{ this.server?.close(); this.reviewServer?.close(); }}
3542
4137
 
3543
- private _route(req: http.IncomingMessage, res: http.ServerResponse): void {{
4138
+ private _route(req: http.IncomingMessage, res: http.ServerResponse, readOnly: boolean): void {{
3544
4139
  const p = url.parse(req.url ?? "/").pathname ?? "/";
3545
4140
  const m = req.method ?? "GET";
3546
4141
  res.setHeader("Access-Control-Allow-Origin", "*");
3547
4142
  if (m === "OPTIONS") {{ res.writeHead(204); res.end(); return; }}
3548
4143
 
3549
- if (m === "GET" && p === "/") {{ res.writeHead(200, {{"Content-Type":"text/html"}}); res.end(this._html()); return; }}
4144
+ if (m === "GET" && p === "/") {{ res.writeHead(200, {{"Content-Type":"text/html"}}); res.end(this._html(readOnly)); return; }}
4145
+ if (m === "GET" && p === "/api/healed") {{ res.writeHead(200, {{"Content-Type":"application/json"}}); res.end(JSON.stringify(this._healed(), null, 2)); return; }}
3550
4146
  if (m === "GET" && p === "/api/pending") {{ res.writeHead(200, {{"Content-Type":"application/json"}}); res.end(JSON.stringify(this._pending(), null, 2)); return; }}
3551
4147
  if (m === "GET" && p === "/api/analytics") {{ res.writeHead(200, {{"Content-Type":"application/json"}}); res.end(JSON.stringify(this._analytics(), null, 2)); return; }}
4148
+ if (m === "GET" && p === "/api/heal-log") {{ res.writeHead(200, {{"Content-Type":"text/plain"}}); res.end(this._healLogTail()); return; }}
3552
4149
 
3553
4150
  const am = p.match(/^[/]api[/](approve|reject)[/](.+)$/);
3554
4151
  if (m === "POST" && am) {{
4152
+ if (readOnly) {{
4153
+ res.writeHead(403, {{"Content-Type":"application/json"}});
4154
+ res.end(JSON.stringify({{ error: "read-only review server" }}));
4155
+ return;
4156
+ }}
3555
4157
  const ok = am[1] === "approve"
3556
4158
  ? this._approve(decodeURIComponent(am[2]))
3557
4159
  : this._reject(decodeURIComponent(am[2]));
@@ -3571,12 +4173,127 @@ export class HealingDashboard {{
3571
4173
  res.writeHead(404); res.end("Not found");
3572
4174
  }}
3573
4175
 
4176
+ /** All healed locators (current state). Used by the dashboard UI and `/api/healed`. */
4177
+ private _healed(): Array<{{ key: string; selector: string; healedSelector: string; intent: string; lastHealed?: string; strategy?: string }}> {{
4178
+ return Object.entries(this._load())
4179
+ .filter(([, v]) => (v as any).healedSelector)
4180
+ .map(([k, v]) => {{
4181
+ const entry = v as any;
4182
+ const lastHistory = entry.healingHistory?.[entry.healingHistory.length - 1];
4183
+ return {{
4184
+ key: k,
4185
+ selector: entry.selector,
4186
+ healedSelector: entry.healedSelector,
4187
+ intent: entry.intent,
4188
+ lastHealed: entry.lastHealed,
4189
+ strategy: lastHistory?.strategy,
4190
+ }};
4191
+ }});
4192
+ }}
4193
+
4194
+ /** Tail of the heal log (./self-heals/heal.log by default). Plain text. */
4195
+ private _healLogTail(): string {{
4196
+ try {{
4197
+ const logPath = process.env.HEAL_LOG_PATH
4198
+ ?? path.join(path.dirname(this.repoPath), "heal.log");
4199
+ if (!fs.existsSync(logPath)) return "";
4200
+ const content = fs.readFileSync(logPath, "utf8");
4201
+ // Return only the last ~500 lines so the response stays small.
4202
+ const lines = content.split("\\n");
4203
+ return lines.slice(-500).join("\\n");
4204
+ }} catch {{
4205
+ return "";
4206
+ }}
4207
+ }}
4208
+
4209
+ // Per-key source-file map populated by `_load()`. Required so dashboard
4210
+ // approve/reject (`_save()`) writes the entry back to the SAME file it
4211
+ // came from when multiple heal stores (e.g. healed-locators.qa4.json and
4212
+ // healed-locators.dev1.json) coexist in the same directory.
4213
+ private _entrySource: Map<string, string> = new Map();
4214
+
4215
+ /** Read a single heal-store file, returning its `entries` map (or {{}}). */
4216
+ private _readStore(filePath: string): Record<string, any> {{
4217
+ try {{
4218
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
4219
+ if (raw && typeof raw === "object" && "entries" in raw && raw.entries) {{
4220
+ return raw.entries as Record<string, any>;
4221
+ }}
4222
+ // Legacy flat shape — strip top-level metadata keys (`_*`).
4223
+ const out: Record<string, any> = {{}};
4224
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {{
4225
+ if (!k.startsWith("_")) out[k] = v;
4226
+ }}
4227
+ return out;
4228
+ }} catch {{
4229
+ return {{}};
4230
+ }}
4231
+ }}
4232
+
4233
+ /**
4234
+ * Load heal entries — primary file first, then sibling env-suffixed
4235
+ * files in the same directory (so a single dashboard process shows heals
4236
+ * from `healed-locators.qa4.json`, `healed-locators.dev1.json`, etc.
4237
+ * without the operator having to set ENV).
4238
+ *
4239
+ * Each entry's source file is remembered in `_entrySource` so subsequent
4240
+ * approve/reject calls (`_save()`) write the updated entry back to the
4241
+ * exact file it came from.
4242
+ */
3574
4243
  private _load(): Record<string, any> {{
3575
- try {{ return JSON.parse(fs.readFileSync(this.repoPath, "utf8")); }} catch {{ return {{}}; }}
4244
+ this._entrySource.clear();
4245
+ const merged: Record<string, any> = {{}};
4246
+ const candidates = new Set<string>([this.repoPath, ..._siblingStoreFiles(this.repoPath)]);
4247
+ for (const file of candidates) {{
4248
+ if (!fs.existsSync(file)) continue;
4249
+ const entries = this._readStore(file);
4250
+ for (const [k, v] of Object.entries(entries)) {{
4251
+ merged[k] = v;
4252
+ // Last file wins on key conflict — should never happen since
4253
+ // env-suffixed files describe different environments.
4254
+ this._entrySource.set(k, file);
4255
+ }}
4256
+ }}
4257
+ return merged;
3576
4258
  }}
3577
- private _save(d: Record<string, any>): void {{
3578
- fs.mkdirSync(path.dirname(this.repoPath), {{ recursive: true }});
3579
- fs.writeFileSync(this.repoPath, JSON.stringify(d, null, 2), "utf8");
4259
+
4260
+ /**
4261
+ * Persist the post-approve/reject state.
4262
+ *
4263
+ * Entries get grouped by their original source file (tracked in
4264
+ * `_entrySource`) and each file is re-emitted in the v1 envelope shape
4265
+ * with the heal-only filter applied. Entries we have no source for
4266
+ * (shouldn't happen in practice — they came from `_load()`) fall back
4267
+ * to the configured `repoPath`.
4268
+ */
4269
+ private _save(entries: Record<string, any>): void {{
4270
+ const buckets: Map<string, Record<string, any>> = new Map();
4271
+ for (const [k, v] of Object.entries(entries)) {{
4272
+ const source = this._entrySource.get(k) ?? this.repoPath;
4273
+ let bucket = buckets.get(source);
4274
+ if (!bucket) {{ bucket = {{}}; buckets.set(source, bucket); }}
4275
+ bucket[k] = v;
4276
+ }}
4277
+ // Make sure every file that originally had entries gets a re-emit
4278
+ // (so deletions / reverts actually persist) — even if every entry was
4279
+ // rejected.
4280
+ for (const source of this._entrySource.values()) {{
4281
+ if (!buckets.has(source)) buckets.set(source, {{}});
4282
+ }}
4283
+ for (const [file, fileEntries] of buckets) {{
4284
+ fs.mkdirSync(path.dirname(file), {{ recursive: true }});
4285
+ const filtered: Record<string, any> = {{}};
4286
+ for (const [k, v] of Object.entries(fileEntries)) {{
4287
+ const vv = v as any;
4288
+ if (vv?.healedSelector || vv?.pendingSuggestion) filtered[k] = vv;
4289
+ }}
4290
+ const envelope = {{
4291
+ _version: 1,
4292
+ _writtenAt: new Date().toISOString(),
4293
+ entries: filtered,
4294
+ }};
4295
+ fs.writeFileSync(file, JSON.stringify(envelope, null, 2), "utf8");
4296
+ }}
3580
4297
  }}
3581
4298
 
3582
4299
  private _pending(): any[] {{
@@ -3609,33 +4326,130 @@ export class HealingDashboard {{
3609
4326
  }};
3610
4327
  }}
3611
4328
 
4329
+ /**
4330
+ * Approve a heal or a pending suggestion.
4331
+ *
4332
+ * Two flows converge here:
4333
+ * • Pending suggestion (AI Vision, etc.) — commits suggestion.selector to
4334
+ * healedSelector and clears the pending state.
4335
+ * • Already-healed entry (automatic strategy succeeded mid-test) — stamps
4336
+ * `approvedAt` on the most recent history record so future runs know
4337
+ * a human has signed off; the selector itself is unchanged.
4338
+ *
4339
+ * Either way, the healed selector is propagated back to the source
4340
+ * locator `.ts` file by `_writeBackToLocatorFile` so the next test run
4341
+ * starts with the corrected primary selector (no healing chain probe
4342
+ * needed until the page changes again).
4343
+ *
4344
+ * Returns false only when the key isn't in the store at all.
4345
+ */
3612
4346
  private _approve(key: string): boolean {{
3613
4347
  const d = this._load(); const e = d[key];
3614
- if (!e?.pendingSuggestion) return false;
3615
- e.healingHistory.push({{
3616
- from: e.healedSelector ?? e.selector, to: e.pendingSuggestion.selector,
3617
- timestamp: new Date().toISOString(), strategy: e.pendingSuggestion.strategy + ":dashboard-approved",
3618
- }});
3619
- e.healedSelector = e.pendingSuggestion.selector;
3620
- delete e.pendingSuggestion;
3621
- this._save(d);
3622
- console.log(`✅ HealingDashboard: approved "${{key}}"`);
3623
- return true;
4348
+ if (!e) return false;
4349
+ if (e.pendingSuggestion) {{
4350
+ e.healingHistory = e.healingHistory ?? [];
4351
+ e.healingHistory.push({{
4352
+ from: e.healedSelector ?? e.selector, to: e.pendingSuggestion.selector,
4353
+ timestamp: new Date().toISOString(), strategy: e.pendingSuggestion.strategy + ":dashboard-approved",
4354
+ }});
4355
+ const oldSelector = e.healedSelector ?? e.selector;
4356
+ e.healedSelector = e.pendingSuggestion.selector;
4357
+ delete e.pendingSuggestion;
4358
+ const wrote = this._writeBackToLocatorFile(key, oldSelector, e.healedSelector);
4359
+ if (wrote) e.appliedToSourceAt = new Date().toISOString();
4360
+ this._save(d);
4361
+ console.log(`✅ HealingDashboard: approved suggestion for "${{key}}"${{wrote ? " + applied to locator file" : ""}}`);
4362
+ return true;
4363
+ }}
4364
+ if (e.healedSelector) {{
4365
+ // Confirm an automatically-applied heal — record the approval AND
4366
+ // propagate the healed selector back to the locator .ts file so the
4367
+ // primary probe on next run uses the corrected value.
4368
+ e.approvedAt = new Date().toISOString();
4369
+ if (e.healingHistory?.length) {{
4370
+ e.healingHistory[e.healingHistory.length - 1].strategy += ":dashboard-approved";
4371
+ }}
4372
+ const wrote = this._writeBackToLocatorFile(key, e.selector, e.healedSelector);
4373
+ if (wrote) e.appliedToSourceAt = new Date().toISOString();
4374
+ this._save(d);
4375
+ console.log(`✅ HealingDashboard: confirmed heal for "${{key}}"${{wrote ? " + applied to locator file" : ""}}`);
4376
+ return true;
4377
+ }}
4378
+ return false;
3624
4379
  }}
3625
4380
 
4381
+ /**
4382
+ * Rewrite the `selector:` field for a single locator entry inside a
4383
+ * `*.locators.ts` file. Matches by `<key>: {{ selector: "<oldSelector>", ...`
4384
+ * so we only touch the exact entry the heal came from — bare-key callers
4385
+ * (e.g. heal key = `"usernameInput"`) still resolve because the locator
4386
+ * file uses bare keys inside the `xxxLocators = {{ ... }}` object literal.
4387
+ *
4388
+ * Handles all three TypeScript string forms — `"..."`, `'...'`, and
4389
+ * `` `...` `` — and preserves the quote style when writing back.
4390
+ *
4391
+ * Scans every `*.ts` file under `LOCATOR_FILES_DIR` (default
4392
+ * `./src/locators`). Returns true if any file was modified.
4393
+ */
4394
+ private _writeBackToLocatorFile(key: string, oldSelector: string, newSelector: string): boolean {{
4395
+ if (!oldSelector || !newSelector || oldSelector === newSelector) return false;
4396
+ const dir = process.env.LOCATOR_FILES_DIR ?? "./src/locators";
4397
+ if (!fs.existsSync(dir)) return false;
4398
+ const bareKey = key.includes(".") ? (key.split(".").pop() ?? key) : key;
4399
+ let updated = false;
4400
+ const visit = (d: string): void => {{
4401
+ for (const entry of fs.readdirSync(d, {{ withFileTypes: true }})) {{
4402
+ const p = path.join(d, entry.name);
4403
+ if (entry.isDirectory()) {{ visit(p); continue; }}
4404
+ if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
4405
+ let content = fs.readFileSync(p, "utf8");
4406
+ const next = LocatorHealer_rewriteEntrySelector(content, bareKey, oldSelector, newSelector);
4407
+ if (next !== content) {{
4408
+ fs.writeFileSync(p, next, "utf8");
4409
+ updated = true;
4410
+ console.log(` ✓ wrote new selector for "${{bareKey}}" → ${{p}}`);
4411
+ }}
4412
+ }}
4413
+ }};
4414
+ try {{ visit(dir); }} catch {{}}
4415
+ return updated;
4416
+ }}
4417
+
4418
+ /**
4419
+ * Reject a heal or pending suggestion. The entry is reverted to its
4420
+ * baseline (only `selector`/`intent`/`stability` remain) and *not*
4421
+ * persisted by the next write — the LocatorRepository filter keeps only
4422
+ * entries with healedSelector or pendingSuggestion. Effectively, reject
4423
+ * makes the bad heal disappear from the file.
4424
+ */
3626
4425
  private _reject(key: string): boolean {{
3627
4426
  const d = this._load(); const e = d[key];
3628
- if (!e?.pendingSuggestion) return false;
3629
- console.log(`❌ HealingDashboard: rejected "${{key}}"`);
4427
+ if (!e) return false;
4428
+ const had = Boolean(e.pendingSuggestion) || Boolean(e.healedSelector);
4429
+ if (!had) return false;
3630
4430
  delete e.pendingSuggestion;
4431
+ delete e.healedSelector;
4432
+ delete e.lastHealed;
4433
+ delete e.approvedAt;
4434
+ e.healingHistory = [];
3631
4435
  this._save(d);
4436
+ // Re-save filters: an entry with no heal state no longer qualifies,
4437
+ // so the file no longer mentions this key. The page-object's
4438
+ // constructor will re-register the baseline on the next process start.
4439
+ console.log(`❌ HealingDashboard: rejected heal for "${{key}}"`);
3632
4440
  return true;
3633
4441
  }}
3634
4442
 
3635
- private _html(): string {{
4443
+ private _html(readOnly: boolean = false): string {{
3636
4444
  const pending = this._pending();
4445
+ const healed = this._healed();
3637
4446
  const analytics = this._analytics();
3638
- const rows = pending.map(p => `
4447
+ const actionsCell = (key: string, kind: string, visualLink: boolean) => readOnly
4448
+ ? `<small style="color:#64748b">read-only</small>${{visualLink ? ` &middot; <a href="/api/visual/${{encodeURIComponent(key)}}" target="_blank" style="color:#818cf8">View diff</a>` : ""}}`
4449
+ : `<button onclick="act('approve','${{encodeURIComponent(key)}}')" style="background:#22c55e;color:#000;border:none;padding:4px 10px;border-radius:3px;cursor:pointer">${{kind === "pending" ? "Approve" : "Confirm"}}</button>
4450
+ <button onclick="act('reject','${{encodeURIComponent(key)}}')" style="background:#ef4444;color:#fff;border:none;padding:4px 10px;border-radius:3px;cursor:pointer;margin-left:4px">${{kind === "pending" ? "Reject" : "Revert"}}</button>
4451
+ ${{visualLink ? `<a href="/api/visual/${{encodeURIComponent(key)}}" target="_blank" style="color:#818cf8;margin-left:6px">View diff</a>` : ""}}`;
4452
+ const pendingRows = pending.map(p => `
3639
4453
  <tr>
3640
4454
  <td><code>${{p.key}}</code></td>
3641
4455
  <td>${{p.intent}}</td>
@@ -3643,11 +4457,17 @@ export class HealingDashboard {{
3643
4457
  <td><code style="color:#f59e0b">${{p.suggestion.selector}}</code></td>
3644
4458
  <td>${{p.suggestion.strategy}}</td>
3645
4459
  <td><small>${{new Date(p.suggestion.suggestedAt).toLocaleString()}}</small></td>
3646
- <td>
3647
- <button onclick="act('approve','${{enc(p.key)}}')" style="background:#22c55e;color:#000;border:none;padding:4px 10px;border-radius:3px;cursor:pointer">Approve</button>
3648
- <button onclick="act('reject','${{enc(p.key)}}')" style="background:#ef4444;color:#fff;border:none;padding:4px 10px;border-radius:3px;cursor:pointer;margin-left:4px">Reject</button>
3649
- ${{p.key.startsWith("visual:") ? `<a href="/api/visual/${{enc(p.key)}}" target="_blank" style="color:#818cf8;margin-left:6px">View diff</a>` : ""}}
3650
- </td>
4460
+ <td>${{actionsCell(p.key, "pending", p.key.startsWith("visual:"))}}</td>
4461
+ </tr>`).join("");
4462
+ const healedRows = healed.map(h => `
4463
+ <tr>
4464
+ <td><code>${{h.key}}</code></td>
4465
+ <td>${{h.intent}}</td>
4466
+ <td><code style="color:#888">${{h.selector}}</code></td>
4467
+ <td><code style="color:#22c55e">${{h.healedSelector}}</code></td>
4468
+ <td>${{h.strategy ?? ""}}</td>
4469
+ <td><small>${{h.lastHealed ? new Date(h.lastHealed).toLocaleString() : ""}}</small></td>
4470
+ <td>${{actionsCell(h.key, "healed", h.key.startsWith("visual:"))}}</td>
3651
4471
  </tr>`).join("");
3652
4472
  return `<!DOCTYPE html>
3653
4473
  <html lang="en">
@@ -3657,12 +4477,13 @@ export class HealingDashboard {{
3657
4477
  <style>
3658
4478
  body {{ font-family: monospace; background:#0f172a; color:#e2e8f0; padding:24px }}
3659
4479
  h1 {{ color:#38bdf8; margin-bottom:4px }}
4480
+ h2 {{ color:#94a3b8; margin:24px 0 8px; font-size:13px; letter-spacing:1px; text-transform:uppercase }}
3660
4481
  .sub {{ color:#64748b; font-size:12px; display:block; margin-bottom:20px }}
3661
4482
  .stats {{ display:flex; gap:12px; margin-bottom:20px }}
3662
4483
  .stat {{ background:#1e293b; border:1px solid #334155; border-radius:6px; padding:10px 16px }}
3663
4484
  .sv {{ font-size:26px; font-weight:700; color:#38bdf8 }}
3664
4485
  .sl {{ font-size:9px; color:#64748b; letter-spacing:1px }}
3665
- table{{ width:100%; border-collapse:collapse; background:#1e293b; border-radius:6px; overflow:hidden }}
4486
+ table{{ width:100%; border-collapse:collapse; background:#1e293b; border-radius:6px; overflow:hidden; margin-bottom:24px }}
3666
4487
  th {{ background:#0f172a; padding:9px 12px; font-size:9px; letter-spacing:1px; color:#64748b; text-align:left }}
3667
4488
  td {{ padding:9px 12px; border-bottom:1px solid #334155; font-size:11px }}
3668
4489
  </style>
@@ -3671,25 +4492,39 @@ export class HealingDashboard {{
3671
4492
  <h1>🩺 Healing Dashboard</h1>
3672
4493
  <span class="sub">
3673
4494
  Auto-refreshes every 10s &middot;
3674
- <a href="/api/analytics" style="color:#818cf8">Analytics JSON</a> &middot;
3675
- Approve or reject AI suggestions before they are committed to the repository
4495
+ <a href="/api/healed" style="color:#818cf8">Healed JSON</a> &middot;
4496
+ <a href="/api/pending" style="color:#818cf8">Pending JSON</a> &middot;
4497
+ <a href="/api/analytics" style="color:#818cf8">Analytics</a> &middot;
4498
+ <a href="/api/heal-log" style="color:#818cf8">Heal log</a>
3676
4499
  </span>
3677
4500
  <div class="stats">
3678
- <div class="stat"><div class="sv">${{analytics.pendingApprovals}}</div><div class="sl">PENDING</div></div>
3679
4501
  <div class="stat"><div class="sv">${{analytics.healedLocators}}</div><div class="sl">HEALED</div></div>
4502
+ <div class="stat"><div class="sv">${{analytics.pendingApprovals}}</div><div class="sl">PENDING</div></div>
3680
4503
  <div class="stat"><div class="sv">${{analytics.timingSuggestions}}</div><div class="sl">TIMING</div></div>
3681
4504
  <div class="stat"><div class="sv">${{analytics.visualSuggestions}}</div><div class="sl">VISUAL</div></div>
3682
4505
  </div>
4506
+
4507
+ <h2>Healed locators</h2>
4508
+ <table>
4509
+ <thead>
4510
+ <tr><th>Key</th><th>Intent</th><th>Original</th><th>Healed</th><th>Strategy</th><th>Last healed</th><th>Action</th></tr>
4511
+ </thead>
4512
+ <tbody>
4513
+ ${{healedRows || '<tr><td colspan="7" style="padding:24px;text-align:center;color:#64748b">✓ No healed locators yet</td></tr>'}}
4514
+ </tbody>
4515
+ </table>
4516
+
4517
+ <h2>Pending AI suggestions</h2>
3683
4518
  <table>
3684
4519
  <thead>
3685
4520
  <tr><th>Key</th><th>Intent</th><th>Current</th><th>Suggested</th><th>Strategy</th><th>Time</th><th>Action</th></tr>
3686
4521
  </thead>
3687
4522
  <tbody>
3688
- ${{rows || '<tr><td colspan="7" style="padding:24px;text-align:center;color:#64748b">✓ No pending suggestions — all AI changes approved or no healing events yet</td></tr>'}}
4523
+ ${{pendingRows || '<tr><td colspan="7" style="padding:24px;text-align:center;color:#64748b">✓ No pending suggestions</td></tr>'}}
3689
4524
  </tbody>
3690
4525
  </table>
4526
+
3691
4527
  <script>
3692
- function enc(s) {{ return encodeURIComponent(s); }}
3693
4528
  async function act(action, key) {{
3694
4529
  await fetch("/api/" + action + "/" + key, {{ method: "POST" }});
3695
4530
  location.reload();