@qa-gentic/stlc-agents 1.0.27 → 1.0.28
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/ARCHITECTURE-ADO.md +350 -0
- package/ARCHITECTURE-JIRA.md +203 -0
- package/QUICKSTART-ADO.md +400 -0
- package/QUICKSTART-JIRA.md +334 -0
- package/README.md +49 -0
- package/package.json +18 -6
- package/skills/migrate-framework/SKILL.md +207 -0
- package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
- package/src/stlc_agents/agent_migration/cli.py +217 -0
- package/src/stlc_agents/agent_migration/detector.py +81 -0
- package/src/stlc_agents/agent_migration/mapper.py +439 -0
- package/src/stlc_agents/agent_migration/reporter.py +86 -0
- package/src/stlc_agents/agent_migration/server.py +267 -0
- package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
- package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
- package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
- package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
- package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
- package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
- package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
- package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
- 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 →
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
3145
|
+
* Chain: cached-healed → primary → attribute → type-hint → role → label → text → AI Vision
|
|
3145
3146
|
*
|
|
3146
|
-
*
|
|
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
|
-
|
|
3200
|
-
|
|
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.
|
|
3292
|
+
const l = this.page.locator(attrSel).first();
|
|
3203
3293
|
await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
|
|
3204
|
-
this.
|
|
3205
|
-
this.
|
|
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
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3265
|
-
|
|
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;
|
|
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
|
|
3322
|
-
this.store.forEach((v, k) => {{
|
|
3323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3506
|
-
*
|
|
3507
|
-
*
|
|
3508
|
-
*
|
|
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
|
-
*
|
|
3992
|
+
* Dashboard endpoints (read/write):
|
|
3511
3993
|
* GET / → HTML dashboard (auto-refreshes every 10s)
|
|
3512
|
-
* GET /api/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3519
|
-
*
|
|
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
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
this.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
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
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
|
|
3615
|
-
e.
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
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
|
|
3629
|
-
|
|
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
|
|
4447
|
+
const actionsCell = (key: string, kind: string, visualLink: boolean) => readOnly
|
|
4448
|
+
? `<small style="color:#64748b">read-only</small>${{visualLink ? ` · <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
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
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 ·
|
|
3674
|
-
<a href="/api/
|
|
3675
|
-
|
|
4495
|
+
<a href="/api/healed" style="color:#818cf8">Healed JSON</a> ·
|
|
4496
|
+
<a href="/api/pending" style="color:#818cf8">Pending JSON</a> ·
|
|
4497
|
+
<a href="/api/analytics" style="color:#818cf8">Analytics</a> ·
|
|
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
|
-
${{
|
|
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();
|