Source: selfHealing.js

/**
 * @module selfHealing
 * @description Self-Healing Utility for Playwright test execution.
 *
 * ### Features
 * - Multi-strategy element finding with retry logic
 * - Healing history: records which strategy index succeeded per element so
 *   future runs try the winning strategy first (adaptive self-healing)
 * - Comprehensive ARIA role coverage in assertion transforms
 * - Code transform engine that rewrites raw Playwright calls into self-healing helpers
 *
 * ### Exports
 * - {@link recordHealing} — Record a successful healing result.
 * - {@link recordHealingFailure} — Record a failed healing attempt.
 * - {@link getHealingHint} — Get the previously-successful strategy index.
 * - {@link getHealingHistoryForTest} — Serialise healing history for runtime injection.
 * - {@link getSelfHealingHelperCode} — Generate the runtime helper code string.
 * - {@link applyHealingTransforms} — Rewrite Playwright code to use self-healing helpers.
 * - {@link CORE_RULES} — Native Playwright rules for local models (~200 tokens).
 * - {@link SELF_HEALING_PROMPT_RULES} — Full self-healing helper rules for cloud models.
 * - {@link getPromptRules} — Tier-aware getter: returns CORE_RULES or full rules.
 */

// ─────────────────────────────────────────────────────────────────────────────
// Healing History — server-side store
// ─────────────────────────────────────────────────────────────────────────────
// Tracks which strategy index succeeded for a given action+label combination
// so future runs can prioritise the winning strategy.
//
// Key format: "<testId>::<action>::<label>"
// Value: { strategyIndex: number, succeededAt: string, failCount: number }

import * as healingRepo from "./database/repositories/healingRepo.js";
import { looksLikeCssSelector } from "./utils/selectorHeuristics.js";

/**
 * Record a successful healing result.
 *
 * @param {string} testId         - Test ID (e.g. `"TC-1"`).
 * @param {string} action         - Action type (`"click"`, `"fill"`, `"expect"`).
 * @param {string} label          - Element label/text used in the action.
 * @param {number} strategyIndex  - Index of the winning strategy in the waterfall.
 */
export function recordHealing(testId, action, label, strategyIndex) {
  if (!testId || !action || typeof label !== "string") return;
  const idx = Number.isInteger(strategyIndex) && strategyIndex >= 0 ? strategyIndex : -1;
  if (idx < 0) return;
  const key = `${testId}::${action}::${label}`;
  const existing = healingRepo.get(key);
  healingRepo.set(key, {
    strategyIndex: idx,
    strategyVersion: STRATEGY_VERSION,
    succeededAt: new Date().toISOString(),
    failCount: existing?.failCount || 0,
  });
}

/**
 * Record a failed healing attempt (all strategies exhausted).
 *
 * @param {string} testId  - Test ID.
 * @param {string} action  - Action type.
 * @param {string} label   - Element label/text.
 */
export function recordHealingFailure(testId, action, label) {
  if (!testId || !action || typeof label !== "string") return;
  const key = `${testId}::${action}::${label}`;
  const existing = healingRepo.get(key) || { strategyIndex: -1, succeededAt: null, failCount: 0 };
  existing.failCount++;
  healingRepo.set(key, existing);
}

/**
 * Get the previously-successful strategy index for an action+label, or -1.
 *
 * @param {string} testId  - Test ID.
 * @param {string} action  - Action type.
 * @param {string} label   - Element label/text.
 * @returns {number}         Strategy index (0-based), or `-1` if no history.
 */
export function getHealingHint(testId, action, label) {
  if (!testId || !action || typeof label !== "string") return -1;
  const key = `${testId}::${action}::${label}`;
  const entry = healingRepo.get(key);
  if ((entry?.failCount || 0) >= HEALING_HINT_MAX_FAILS) return -1;
  // Ignore hints from a different strategy version — the strategyIndex
  // may point to a different strategy after strategies were reordered.
  if (entry?.strategyVersion != null && entry.strategyVersion !== STRATEGY_VERSION) return -1;
  return entry?.strategyIndex ?? -1;
}

/**
 * Serialise healing history for a test so it can be injected into runtime code.
 *
 * @param {string} testId  - Test ID.
 * @returns {Object<string, number>} Map of `"action::label"` → winning strategy index.
 */
export function getHealingHistoryForTest(testId) {
  const entries = healingRepo.getByTestId(testId);
  const result = {};
  for (const [shortKey, val] of Object.entries(entries)) {
    const idx = val.strategyIndex;
    if ((val?.failCount || 0) >= HEALING_HINT_MAX_FAILS) continue;
    if (!Number.isInteger(idx) || idx < 0) continue;
    // Skip hints from a different strategy version
    if (val.strategyVersion != null && val.strategyVersion !== STRATEGY_VERSION) continue;
    result[shortKey] = idx;
  }
  return result;
}

// ─────────────────────────────────────────────────────────────────────────────
// Self-healing helpers (runtime injection)
// ─────────────────────────────────────────────────────────────────────────────
// Read self-healing runtime defaults from env (baked into generated code at call time)
const HEALING_ELEMENT_TIMEOUT = parseInt(process.env.HEALING_ELEMENT_TIMEOUT, 10) || 5000;
const HEALING_RETRY_COUNT     = parseInt(process.env.HEALING_RETRY_COUNT, 10) || 3;
const HEALING_RETRY_DELAY     = parseInt(process.env.HEALING_RETRY_DELAY, 10) || 400;
const HEALING_HINT_MAX_FAILS  = parseInt(process.env.HEALING_HINT_MAX_FAILS, 10) || 3;
const HEALING_VISIBLE_WAIT_CAP = parseInt(process.env.HEALING_VISIBLE_WAIT_CAP, 10) || 1200;

// Strategy version — bump this whenever the strategy waterfall order changes
// (e.g. adding/removing/reordering strategies in safeClick, safeFill, etc.).
// Healing hints recorded with a different version are ignored so stale
// strategyIndex values don't point to the wrong strategy after an upgrade.
export const STRATEGY_VERSION = 3;

/**
 * Generate the self-healing runtime helper code as a string for injection
 * into Playwright test execution context. Includes `findElement`, `safeClick`,
 * `safeFill`, `safeExpect`, and retry logic.
 *
 * @param {Object<string, number>} [healingHints] - Map of `"action::label"` → strategy index from previous runs.
 * @returns {string} JavaScript code string to be prepended to test execution.
 */
export function getSelfHealingHelperCode(healingHints) {
  // healingHints is an optional map of "<action>::<label>" → strategyIndex.
  // Guard: if the caller passes null, a number, or an array, coerce to {}
  // so the injected `const __healingHints = ...` is always a valid object literal.
  const safeHints = (healingHints && typeof healingHints === "object" && !Array.isArray(healingHints))
    ? healingHints
    : {};
  const hintsJSON = JSON.stringify(safeHints);
  return `
    const DEFAULT_TIMEOUT = ${HEALING_ELEMENT_TIMEOUT};
    const RETRY_COUNT = ${HEALING_RETRY_COUNT};
    const RETRY_DELAY = ${HEALING_RETRY_DELAY};
    const FIRST_VISIBLE_WAIT_CAP = ${HEALING_VISIBLE_WAIT_CAP};

    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    const looksLikeSelector = (value) => {
      if (!value || typeof value !== 'string') return false;
      const s = value.trim();
      return /^(#|\\.|\\[|\\/\\/)/.test(s)
        || /(?:[\\w\\])])\\s[>~+]\\s(?:[\\w#.\\[:])/.test(s)
        || /\\w\\[[^\\]]+\\]/.test(s)
        || /:(?:nth-child|nth-of-type|first-child|last-child|has|is|not)\\(/.test(s);
    };

    // ── Healing history from previous runs ──────────────────────────────────
    // Maps "action::label" → winning strategy index so we try it first.
    const __healingHints = ${hintsJSON};
    // Accumulates healing events during this run for the runner to persist.
    const __healingEvents = [];

    // pierce: selector prefix — used for elements discovered inside shadow roots.
    // Playwright's CSS engine supports ">>" to pierce shadow DOM, and its built-in
    // "pierce/" prefix resolves through shadow boundaries. We normalise to the
    // Playwright css:pierce engine syntax here.
    function buildPierceLocator(page, selector) {
      // Strip our internal "pierce:" prefix if present before building the locator.
      const rawSelector = selector.startsWith('pierce:') ? selector.slice(7) : selector;
      // Playwright supports piercing shadow DOM via the css engine with the
      // ":shadow" pseudo or via ">> css=" chains. The most broadly compatible
      // approach is page.locator('css=selector') with Playwright's built-in
      // pierce support for shadow-including descendant combinators.
      return page.locator(\`css=\${rawSelector}\`);
    }

    async function retry(fn, retries = RETRY_COUNT, delay = RETRY_DELAY) {
      let lastError;
      for (let i = 0; i < retries; i++) {
        try {
          return await fn();
        } catch (err) {
          lastError = err;
          await sleep(delay);
        }
      }
      throw lastError;
    }

    // History-aware findElement: if a previous run recorded a winning strategy
    // for this action+label, try it first before falling through to the full
    // waterfall. This avoids wasting time on strategies that previously failed.
    // Given a base locator (which may match multiple DOM elements), return
    // the first element that is actually visible.  Falls back to .first()
    // only when no visible element is found — so the caller gets a clear
    // "not visible" error instead of silently picking a hidden duplicate
    // (e.g. a button inside a collapsed mobile menu).
    async function firstVisible(baseLocator, timeout) {
      // Guard: if a strategy factory returned null/undefined instead of a
      // Locator, fail fast with a clear message rather than a cryptic
      // "Cannot read properties of undefined (reading 'count')" deep inside Playwright.
      if (!baseLocator) {
        throw new Error('Strategy returned a null/undefined locator');
      }
      // Fast path: check if any element is already visible without waiting.
      // This avoids the expensive waitFor timeout for strategies that clearly
      // don't match, reducing worst-case waterfall time significantly.
      const count = await baseLocator.count().catch(() => 0);
      if (count === 0) {
        // No elements at all — fail fast instead of waiting the full timeout.
        throw new Error('No elements matched this strategy');
      }
      for (let n = 0; n < count; n++) {
        const candidate = baseLocator.nth(n);
        const visible = await candidate.isVisible().catch(() => false);
        if (visible) return candidate;
      }
      // No element is visible yet — wait for the first one to appear.
      // This preserves the original timeout-based retry behaviour.
      const first = baseLocator.first();
      const waitTimeout = Math.min(timeout, FIRST_VISIBLE_WAIT_CAP);
      await first.waitFor({ state: 'visible', timeout: waitTimeout });
      return first;
    }

    async function findElement(page, strategies, options = {}) {
      const timeout = options.timeout || DEFAULT_TIMEOUT;
      const hintKey = options.healingKey || null;
      const hintIdx = hintKey ? (__healingHints[hintKey] ?? -1) : -1;
      let lastError;

      // Helper: invoke a strategy factory and feed the result to firstVisible.
      // The factory call (e.g. p => p.locator(badXPath)) can throw synchronously
      // if Playwright rejects the selector at construction time. Wrapping it here
      // ensures a synchronous throw is caught just like an async timeout, so the
      // waterfall continues to the next strategy instead of aborting entirely.
      async function tryStrategy(strategyFn, page, timeout) {
        const locator = strategyFn(page);  // may throw synchronously
        return await firstVisible(locator, timeout);
      }

      // If we have a hint from a previous run, try that strategy first
      if (hintIdx >= 0 && hintIdx < strategies.length) {
        try {
          const locator = await tryStrategy(strategies[hintIdx], page, timeout);
          if (hintKey) {
            __healingEvents.push({ key: hintKey, strategyIndex: hintIdx, healed: false });
          }
          return locator;
        } catch (err) {
          lastError = err;
        }
      }

      // Full waterfall — try every strategy in order
      for (let i = 0; i < strategies.length; i++) {
        if (i === hintIdx) continue; // already tried above
        try {
          const locator = await tryStrategy(strategies[i], page, timeout);
          if (hintKey) {
            // Record that we healed: a different strategy won than the hint (or no hint existed)
            __healingEvents.push({ key: hintKey, strategyIndex: i, healed: hintIdx !== i });
          }
          return locator;
        } catch (err) {
          lastError = err;
        }
      }

      // ── Scroll-retry: elements below the fold (e.g. footer links) ────────
      // Some elements exist in the DOM but are off-screen. isVisible() returns
      // false for them and waitFor({ state: 'visible' }) times out because
      // nobody scrolls to them. Before giving up, scroll to the bottom of the
      // page and retry the full waterfall once. This handles footer links,
      // lazy-loaded sections, and "load more" content on any website.
      try {
        await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
        await page.waitForTimeout(500); // let lazy content render
        for (let i = 0; i < strategies.length; i++) {
          try {
            const locator = await tryStrategy(strategies[i], page, timeout);
            if (hintKey) {
              __healingEvents.push({ key: hintKey, strategyIndex: i, healed: true });
            }
            return locator;
          } catch (err) {
            lastError = err;
          }
        }
      } catch { /* scroll failed — page may be closed */ }

      // All strategies failed (even after scroll-retry)
      if (hintKey) {
        __healingEvents.push({ key: hintKey, strategyIndex: -1, healed: false, failed: true });
      }

      // Extract a human-readable message from the last error.
      // Playwright can throw AggregateError (with .errors[]) or regular Error.
      // String-concatenating an Error object directly produces unhelpful output
      // like "[object Object]" or just "AggregateError".
      let errMsg = 'unknown error';
      if (lastError) {
        if (lastError.errors && lastError.errors.length) {
          // AggregateError — join the sub-error messages
          errMsg = lastError.errors.map(e => e?.message || String(e)).join('; ');
        } else {
          errMsg = lastError.message || String(lastError);
        }
      }

      throw new Error(
        'Element not found using any strategy. Last error: ' + errMsg
      );
    }

    async function ensureReady(locator) {
      // All steps are best-effort: if the element is momentarily detached
      // or hidden, we still want to attempt scroll + attach before giving up.
      // The caller's retry loop will re-attempt the full sequence if needed.
      try { await locator.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); } catch {}
      try { await locator.scrollIntoViewIfNeeded(); } catch {}
      try { await locator.waitFor({ state: 'attached' }); } catch {}
      // Brief DOM stability pause — gives SPAs time to finish re-rendering
      // after the element appears. Without this, actions can fire while the
      // DOM is still mutating (e.g. text changing from "Loading..." to real
      // content), causing stale-element or wrong-value assertions.
      try { await locator.page().waitForTimeout(100); } catch {}
    }

    async function safeClick(page, text) {
      // Guard: undefined/null text would silently pass looksLikeSelector (returns false),
      // then every strategy gets getByRole('button', { name: undefined }) — matching
      // random elements instead of failing fast.
      if (text == null || typeof text !== 'string' || !text.trim()) {
        throw new Error('safeClick: text argument is required (got ' + typeof text + ')');
      }
      // When the text is a CSS/XPath selector, only use page.locator() —
      // text-based strategies (getByRole, getByText, aria-label) will never
      // match a selector string and just waste time + produce confusing errors.
      const strategies = looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          p => p.getByRole('button', { name: text }),
          p => p.getByRole('link',   { name: text }),
          p => p.getByRole('menuitem', { name: text }),
          p => p.getByRole('tab',    { name: text }),
          p => p.getByRole('checkbox', { name: text }),
          p => p.getByRole('radio',    { name: text }),
          p => p.getByRole('switch',   { name: text }),
          p => p.getByRole('option',   { name: text }),
          p => p.getByText(text, { exact: true }),
          p => p.getByText(text),
          p => p.locator(\`[aria-label*="\${text}"]\`),
          p => p.locator(\`[title*="\${text}"]\`),
          // pierce: strategy — finds elements inside shadow DOM roots that the
          // standard locators above cannot reach (Angular, Lit, Stencil, LWC).
          // Only attempt when text looks like a CSS selector; human-readable
          // text like "Sign in" produces invalid css= locators.
          ...(looksLikeSelector(text) ? [p => buildPierceLocator(p, text)] : []),
        ];

      const healingKey = 'click::' + text;

      await retry(async () => {
        // Re-resolve on every attempt so a DOM re-render (common in SPAs)
        // doesn't leave us retrying with a stale/detached locator reference.
        const el = await findElement(page, strategies, { healingKey });
        await ensureReady(el);
        await el.click({ timeout: DEFAULT_TIMEOUT });
      });

      // After clicking, give the page a moment to settle — navigation links
      // and SPAs need time to load the new content before the next assertion.
      // Use domcontentloaded (not networkidle) because SPAs and e-commerce
      // sites fire continuous background requests and never reach networkidle,
      // causing a guaranteed timeout.
      if (typeof page?.waitForLoadState === 'function') {
        await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
      }
    }

    async function safeHover(page, text) {
      if (text == null || typeof text !== 'string' || !text.trim()) {
        throw new Error('safeHover: text argument is required (got ' + typeof text + ')');
      }
      const strategies = looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          p => p.getByRole('button', { name: text }),
          p => p.getByRole('link',   { name: text }),
          p => p.getByRole('menuitem', { name: text }),
          p => p.getByRole('tab',    { name: text }),
          p => p.getByRole('img',    { name: text }),
          p => p.getByText(text, { exact: true }),
          p => p.getByText(text),
          p => p.locator(\`[aria-label*="\${text}"]\`),
          p => p.locator(\`[title*="\${text}"]\`),
        ];

      const healingKey = 'hover::' + text;

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey });
        await ensureReady(el);
        await el.hover({ timeout: DEFAULT_TIMEOUT });
      });

      // Brief pause after hover to let menus/tooltips render
      await sleep(300);
    }

    async function safeDblClick(page, text) {
      if (text == null || typeof text !== 'string' || !text.trim()) {
        throw new Error('safeDblClick: text argument is required (got ' + typeof text + ')');
      }
      const strategies = looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          p => p.getByRole('button', { name: text }),
          p => p.getByRole('link',   { name: text }),
          p => p.getByRole('menuitem', { name: text }),
          p => p.getByRole('tab',    { name: text }),
          p => p.getByRole('checkbox', { name: text }),
          p => p.getByRole('radio',    { name: text }),
          p => p.getByRole('switch',   { name: text }),
          p => p.getByRole('option',   { name: text }),
          p => p.getByText(text, { exact: true }),
          p => p.getByText(text),
          p => p.locator(\`[aria-label*="\${text}"]\`),
          p => p.locator(\`[title*="\${text}"]\`),
        ];

      const healingKey = 'dblclick::' + text;

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey });
        await ensureReady(el);
        await el.dblclick({ timeout: DEFAULT_TIMEOUT });
      });

      await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
    }

    // Helper: restrict a locator to only fillable elements so we never
    // try to .fill() a link, div, or other non-editable element.
    // Playwright's getByLabel/getByPlaceholder can match <a aria-label="...">,
    // which causes "Element is not an <input>, <textarea> or <select>" errors.
    const FILLABLE_SELECTOR = 'input, textarea, select, [contenteditable], [role="textbox"], [role="searchbox"], [role="combobox"], [role="spinbutton"]';
    function onlyFillable(locator) {
      return locator.locator(FILLABLE_SELECTOR);
    }

    async function safeFill(page, labelOrPlaceholder, value) {
      // Guard: same rationale as safeClick — undefined label matches random elements.
      if (labelOrPlaceholder == null || typeof labelOrPlaceholder !== 'string' || !labelOrPlaceholder.trim()) {
        throw new Error('safeFill: labelOrPlaceholder argument is required (got ' + typeof labelOrPlaceholder + ')');
      }
      // Playwright's .fill() requires a string argument. AI-generated code
      // sometimes passes a number (e.g. safeFill(page, 'Age', 25)), which
      // causes a runtime TypeError. Coerce to string to be safe.
      const strValue = (value == null) ? '' : String(value);

      const strategies = looksLikeSelector(labelOrPlaceholder)
        ? [p => onlyFillable(p.locator(labelOrPlaceholder))]
        : [
          p => onlyFillable(p.getByLabel(labelOrPlaceholder)),
          p => p.getByPlaceholder(labelOrPlaceholder),
          p => p.getByRole('searchbox', { name: labelOrPlaceholder }),
          p => p.getByRole('combobox',  { name: labelOrPlaceholder }),
          p => p.getByRole('textbox',   { name: labelOrPlaceholder }),
          p => p.getByRole('spinbutton', { name: labelOrPlaceholder }),
          p => p.locator(\`input[aria-label*="\${labelOrPlaceholder}"]\`),
          p => p.locator(\`textarea[aria-label*="\${labelOrPlaceholder}"]\`),
          p => p.locator(\`input[title*="\${labelOrPlaceholder}"]\`),
          // pierce: strategy — reaches input elements inside shadow DOM roots.
          // Only attempt when text looks like a CSS selector.
          ...(looksLikeSelector(labelOrPlaceholder) ? [p => onlyFillable(buildPierceLocator(p, labelOrPlaceholder))] : []),
        ];

      const healingKey = 'fill::' + labelOrPlaceholder;

      await retry(async () => {
        // Re-resolve on every attempt so a DOM re-render (common in SPAs)
        // doesn't leave us retrying with a stale/detached locator reference.
        const el = await findElement(page, strategies, { healingKey });
        await ensureReady(el);
        await el.fill('');
        await el.fill(strValue);
      });
    }

    async function safeSelect(page, labelOrText, value) {
      if (labelOrText == null || typeof labelOrText !== 'string' || !labelOrText.trim()) {
        throw new Error('safeSelect: labelOrText argument is required (got ' + typeof labelOrText + ')');
      }
      // Playwright's selectOption() accepts string, { label?, value?, index? },
      // or arrays for multi-select. Only coerce primitives (number/boolean) to
      // string — preserve objects and arrays so callers can use forms like
      // { label: 'United States' } or { index: 1 }.
      let selectValue;
      if (value == null) {
        selectValue = '';
      } else if (typeof value === 'object') {
        selectValue = value; // array or { label/value/index } — pass through
      } else {
        selectValue = String(value);
      }
      const strategies = looksLikeSelector(labelOrText)
        ? [p => p.locator(labelOrText)]
        : [
          p => p.getByLabel(labelOrText),
          p => p.getByRole('combobox', { name: labelOrText }),
          p => p.getByRole('listbox', { name: labelOrText }),
          p => p.locator(\`select[aria-label*="\${labelOrText}"]\`),
        ];

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'select::' + labelOrText });
        await ensureReady(el);
        await el.selectOption(selectValue);
      });
    }

    function buildCheckboxStrategies(labelOrText) {
      if (looksLikeSelector(labelOrText)) {
        return [p => p.locator(labelOrText)];
      }
      return [
        p => p.getByRole('checkbox', { name: labelOrText }),
        p => p.getByLabel(labelOrText),
        p => p.locator(\`[aria-label*="\${labelOrText}"]\`),
        // List/row scoped fallbacks — common in TodoMVC, task trackers,
        // bug queues, settings lists. The checkbox sits inside the li/tr/
        // .item/.row and the readable label is a sibling of (not on) the
        // checkbox, so getByRole+name / getByLabel never match. Scope to
        // the container by hasText first, then pick the checkbox within.
        p => p.locator('li', { hasText: labelOrText }).getByRole('checkbox').first(),
        p => p.locator('tr', { hasText: labelOrText }).getByRole('checkbox').first(),
        p => p.locator('[role="listitem"]', { hasText: labelOrText }).getByRole('checkbox').first(),
        p => p.locator('[role="row"]', { hasText: labelOrText }).getByRole('checkbox').first(),
        p => p.locator('.item, .row, .todo, .task', { hasText: labelOrText }).getByRole('checkbox').first(),
        p => p.locator('li', { hasText: labelOrText }).locator('input[type="checkbox"]').first(),
        p => p.locator('tr', { hasText: labelOrText }).locator('input[type="checkbox"]').first(),
      ];
    }

    async function safeCheck(page, labelOrText) {
      if (labelOrText == null || typeof labelOrText !== 'string' || !labelOrText.trim()) {
        throw new Error('safeCheck: labelOrText argument is required (got ' + typeof labelOrText + ')');
      }
      const strategies = buildCheckboxStrategies(labelOrText);

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'check::' + labelOrText });
        await ensureReady(el);
        await el.check();
      });
    }

    async function safeUncheck(page, labelOrText) {
      if (labelOrText == null || typeof labelOrText !== 'string' || !labelOrText.trim()) {
        throw new Error('safeUncheck: labelOrText argument is required (got ' + typeof labelOrText + ')');
      }
      const strategies = buildCheckboxStrategies(labelOrText);

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'uncheck::' + labelOrText });
        await ensureReady(el);
        await el.uncheck();
      });
    }

    // ── safeDrag — drag and drop with self-healing source/target lookup ──────
    async function safeDrag(page, sourceText, targetText) {
      if (sourceText == null || typeof sourceText !== 'string' || !sourceText.trim()) {
        throw new Error('safeDrag: sourceText argument is required (got ' + typeof sourceText + ')');
      }
      if (targetText == null || typeof targetText !== 'string' || !targetText.trim()) {
        throw new Error('safeDrag: targetText argument is required (got ' + typeof targetText + ')');
      }
      const makeStrategies = (text) => looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          p => p.getByText(text, { exact: true }),
          p => p.getByText(text),
          p => p.getByRole('listitem', { name: text }),
          p => p.getByRole('treeitem', { name: text }),
          p => p.locator(\`[aria-label*="\${text}"]\`),
          p => p.locator(\`[title*="\${text}"]\`),
        ];

      await retry(async () => {
        const src = await findElement(page, makeStrategies(sourceText), { healingKey: 'drag-src::' + sourceText });
        const tgt = await findElement(page, makeStrategies(targetText), { healingKey: 'drag-tgt::' + targetText });
        await ensureReady(src);
        await src.dragTo(tgt);
      });
    }

    // ── safeUpload — file upload with self-healing element lookup ────────────
    async function safeUpload(page, labelOrSelector, files) {
      if (labelOrSelector == null || typeof labelOrSelector !== 'string' || !labelOrSelector.trim()) {
        throw new Error('safeUpload: labelOrSelector argument is required (got ' + typeof labelOrSelector + ')');
      }
      const strategies = looksLikeSelector(labelOrSelector)
        ? [p => p.locator(labelOrSelector)]
        : [
          p => p.getByTestId(labelOrSelector),
          p => p.getByLabel(labelOrSelector),
          p => p.getByRole('button', { name: labelOrSelector }),
          p => p.getByText(labelOrSelector, { exact: true }),
          p => p.locator(\`input[type="file"][aria-label*="\${labelOrSelector}"]\`),
          p => p.locator('input[type="file"]'),
        ];

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'upload::' + labelOrSelector });
        await el.setInputFiles(files);
      });
    }

    // ── safeFocus — focus with self-healing element lookup ───────────────────
    async function safeFocus(page, labelOrText) {
      if (labelOrText == null || typeof labelOrText !== 'string' || !labelOrText.trim()) {
        throw new Error('safeFocus: labelOrText argument is required (got ' + typeof labelOrText + ')');
      }
      const strategies = looksLikeSelector(labelOrText)
        ? [p => p.locator(labelOrText)]
        : [
          p => onlyFillable(p.getByLabel(labelOrText)),
          p => p.getByPlaceholder(labelOrText),
          p => p.getByRole('textbox', { name: labelOrText }),
          p => p.getByRole('button', { name: labelOrText }),
          p => p.getByText(labelOrText, { exact: true }),
          p => p.locator(\`[aria-label*="\${labelOrText}"]\`),
        ];

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'focus::' + labelOrText });
        await ensureReady(el);
        await el.focus();
      });
    }

    // ── safeTap — touch tap with self-healing (mobile viewports) ────────────
    async function safeTap(page, text) {
      if (text == null || typeof text !== 'string' || !text.trim()) {
        throw new Error('safeTap: text argument is required (got ' + typeof text + ')');
      }
      const strategies = looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          p => p.getByRole('button', { name: text }),
          p => p.getByRole('link',   { name: text }),
          p => p.getByText(text, { exact: true }),
          p => p.getByText(text),
          p => p.locator(\`[aria-label*="\${text}"]\`),
        ];

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'tap::' + text });
        await ensureReady(el);
        await el.tap();
      });
    }

    // ── safePress — keyboard press on a specific element ────────────────────
    async function safePress(page, labelOrSelector, key) {
      if (labelOrSelector == null || typeof labelOrSelector !== 'string' || !labelOrSelector.trim()) {
        throw new Error('safePress: labelOrSelector argument is required (got ' + typeof labelOrSelector + ')');
      }
      const strategies = looksLikeSelector(labelOrSelector)
        ? [p => p.locator(labelOrSelector)]
        : [
          p => onlyFillable(p.getByLabel(labelOrSelector)),
          p => p.getByPlaceholder(labelOrSelector),
          p => p.getByRole('textbox', { name: labelOrSelector }),
          p => p.getByRole('button', { name: labelOrSelector }),
          p => p.getByText(labelOrSelector, { exact: true }),
          p => p.locator(\`[aria-label*="\${labelOrSelector}"]\`),
        ];

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'press::' + labelOrSelector });
        await ensureReady(el);
        await el.press(key);
      });
    }

    // ── safeRightClick — context-menu click with self-healing ────────────────
    async function safeRightClick(page, text) {
      if (text == null || typeof text !== 'string' || !text.trim()) {
        throw new Error('safeRightClick: text argument is required (got ' + typeof text + ')');
      }
      const strategies = looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          p => p.getByRole('button', { name: text }),
          p => p.getByRole('link',   { name: text }),
          p => p.getByRole('treeitem', { name: text }),
          p => p.getByRole('listitem', { name: text }),
          p => p.getByText(text, { exact: true }),
          p => p.getByText(text),
          p => p.locator(\`[aria-label*="\${text}"]\`),
        ];

      await retry(async () => {
        const el = await findElement(page, strategies, { healingKey: 'rightclick::' + text });
        await ensureReady(el);
        await el.click({ button: 'right', timeout: DEFAULT_TIMEOUT });
      });
    }

    // ── safeSelectFrame — switch into an iframe with self-healing ────────────
    function safeSelectFrame(page, selectorOrName) {
      if (selectorOrName == null || typeof selectorOrName !== 'string' || !selectorOrName.trim()) {
        throw new Error('safeSelectFrame: selectorOrName argument is required');
      }
      // Returns a FrameLocator — callers chain further locator calls on it.
      // Try the raw selector first, then fall back to name/title attributes.
      if (looksLikeSelector(selectorOrName)) {
        return page.frameLocator(selectorOrName);
      }
      // Attempt by title, name, or aria-label
      return page.frameLocator(\`iframe[title*="\${selectorOrName}"], iframe[name*="\${selectorOrName}"], iframe[aria-label*="\${selectorOrName}"]\`);
    }

    // ── safeExpect — self-healing visibility assertions
    //
    // Covers ALL common ARIA roles so the AI's role guess doesn't break the test.
    async function safeExpect(page, expect, text, role) {
      // Guard: same rationale as safeClick — undefined text matches random elements.
      // If the AI passed a Playwright Locator object instead of a string (common
      // Ollama mistake), fall back to asserting visibility on it directly rather
      // than crashing with a confusing error.
      if (text != null && typeof text === 'object') {
        // Looks like a Locator — has waitFor and isVisible methods
        if (typeof text.waitFor === 'function') {
          await text.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }).catch(() => {});
          await expect(text).toBeVisible();
          return;
        }
      }
      if (text == null || typeof text !== 'string' || !text.trim()) {
        throw new Error('safeExpect: text argument is required (got ' + typeof text + ')');
      }
      // When the text is a CSS/XPath selector, only use page.locator() —
      // role/text/aria-label strategies will never match a raw selector string.
      const strategies = looksLikeSelector(text)
        ? [p => p.locator(text)]
        : [
          ...(role
            ? [
              p => p.getByRole(role, { name: text }),
              p => p.getByText(text, { exact: true }),
              p => p.getByText(text),
              p => p.getByLabel(text),
              p => p.locator(\`[aria-label*="\${text}"]\`),
            ]
            : [
              // Input / field visibility
              p => p.getByRole('searchbox', { name: text }),
              p => p.getByRole('combobox',  { name: text }),
              p => p.getByRole('textbox',   { name: text }),
              p => p.getByRole('spinbutton', { name: text }),
              p => p.getByLabel(text),
              p => p.getByPlaceholder(text),
              p => p.locator(\`input[aria-label*="\${text}"]\`),
              p => p.locator(\`input[title*="\${text}"]\`),
              // Clickable / structural element visibility
              p => p.getByRole('button',     { name: text }),
              p => p.getByRole('link',       { name: text }),
              p => p.getByRole('menuitem',   { name: text }),
              p => p.getByRole('tab',        { name: text }),
              p => p.getByRole('heading',    { name: text }),
              p => p.getByRole('img',        { name: text }),
              p => p.getByRole('navigation', { name: text }),
              p => p.getByRole('listitem',   { name: text }),
              p => p.getByRole('cell',       { name: text }),
              p => p.getByRole('row',        { name: text }),
              p => p.getByRole('dialog',     { name: text }),
              p => p.getByRole('alert',      { name: text }),
              p => p.getByRole('checkbox',   { name: text }),
              p => p.getByRole('radio',      { name: text }),
              p => p.getByRole('switch',     { name: text }),
              p => p.getByRole('slider',     { name: text }),
              p => p.getByRole('progressbar', { name: text }),
              p => p.getByRole('option',     { name: text }),
              p => p.getByText(text, { exact: true }),
              p => p.getByText(text),
              p => p.getByLabel(text),
              p => p.locator(\`[aria-label*="\${text}"]\`),
              // pierce: strategy — asserts visibility of elements inside shadow roots.
              // Only attempt when text looks like a CSS selector.
              ...(looksLikeSelector(text) ? [p => buildPierceLocator(p, text)] : []),
            ]),
        ];

      const el = await findElement(page, strategies, { healingKey: 'expect::' + text });
      // Wait for the element to stabilise before asserting — prevents flaky
      // failures during page transitions, SPA re-renders, and CSS animations.
      await el.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }).catch(() => {});
      await expect(el).toBeVisible();
    }
  `;
}

// ─────────────────────────────────────────────────────────────────────────────
// Safer Transform Engine
// ─────────────────────────────────────────────────────────────────────────────

// Escape special characters in captured text so injecting into generated code
// strings is safe. Handles:
//   - backslashes  (must be first to avoid double-escaping)
//   - single quotes (the generated code uses '...' strings)
//   - backticks     (the runtime helpers use `...` template literals for
//                    aria-label/title selectors — an unescaped backtick would
//                    prematurely close the template)
//   - ${            (inside template literals, ${...} triggers interpolation;
//                    text like "Price: ${total}" would execute as code)
function esc(s) {
  return s
    .replace(/\\/g, "\\\\")
    .replace(/'/g, "\\'")
    .replace(/`/g, "\\`")
    .replace(/\$\{/g, "\\${");
}

/**
 * Rewrite raw Playwright code to use self-healing helpers (`safeClick`, `safeFill`, `safeExpect`).
 * Only transforms human-readable text selectors — CSS/XPath selectors are left untouched.
 *
 * @param {string} code - Raw Playwright test code.
 * @returns {string} Transformed code with self-healing helper calls.
 */
export function applyHealingTransforms(code) {
  // Guard: passing undefined/null crashes with "TypeError: undefined is not iterable"
  // at the first .replace() call. Return empty string instead of throwing so
  // callers that chain transforms don't need individual null checks.
  if (!code || typeof code !== "string") return code || "";
  return code
    // ── Interaction transforms ──────────────────────────────────────────────
    // page.click / page.fill — only transform human-readable text, NOT CSS selectors.
    // e.g. page.click('Sign in') → safeClick, but page.click('#btn') stays as-is.
    .replace(
      /\bpage\.click\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeClick(page, '${esc(arg)}')`
    )
    .replace(
      /\bpage\.fill\(['"`]([^'"`]+)['"`],\s*([^)]+)\)/g,
      (match, arg, val) => looksLikeCssSelector(arg) ? match : `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByText\(['"`]([^'"`]+)['"`]\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // page.locator(...).click() — leave CSS-based locators alone
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.click\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeClick(page, '${esc(sel)}')`
    )
    // page.getByLabel(...).click() — label-based clicks on form elements
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // page.getByPlaceholder(...).click() — clicking into inputs by placeholder
    .replace(
      /page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // page.getByTestId(...).click() — very common AI pattern
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // page.getByAltText(...).click() — image clicks
    .replace(
      /page\.getByAltText\(['"`]([^'"`]+)['"`]\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // ── Hover transforms → safeHover ────────────────────────────────────────
    .replace(
      /\bpage\.hover\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeHover(page, '${esc(arg)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.hover\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeHover(page, '${esc(sel)}')`
    )
    .replace(
      /page\.getByText\(['"`]([^'"`]+)['"`]\)\.hover\(\)/g,
      (match, arg) => `safeHover(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.hover\(\)/g,
      (match, arg) => `safeHover(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.hover\(\)/g,
      (match, arg) => `safeHover(page, '${esc(arg)}')`
    )
    // ── Double-click transforms → safeDblClick ──────────────────────────────
    .replace(
      /\bpage\.dblclick\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeDblClick(page, '${esc(arg)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.dblclick\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeDblClick(page, '${esc(sel)}')`
    )
    .replace(
      /page\.getByText\(['"`]([^'"`]+)['"`]\)\.dblclick\(\)/g,
      (match, arg) => `safeDblClick(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.dblclick\(\)/g,
      (match, arg) => `safeDblClick(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.dblclick\(\)/g,
      (match, arg) => `safeDblClick(page, '${esc(arg)}')`
    )
    // ── Fill transforms ─────────────────────────────────────────────────────
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.fill\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\.fill\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.fill\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    // page.getByTestId(...).fill(val) — very common AI pattern
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.fill\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    // page.locator(...).fill(val) — e.g. page.locator('#email').fill('test@x.com')
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.fill\(([^)]+)\)/g,
      (match, sel, val) => looksLikeCssSelector(sel) ? match : `safeFill(page, '${esc(sel)}', ${val})`
    )
    .replace(
      /\bpage\.check\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeCheck(page, '${esc(arg)}')`
    )
    .replace(
      /\bpage\.uncheck\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeUncheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.check\(\)/g,
      (match, arg) => `safeCheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.check\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeCheck(page, '${esc(sel)}')`
    )
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.uncheck\(\)/g,
      (match, arg) => `safeUncheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.uncheck\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeUncheck(page, '${esc(sel)}')`
    )
    .replace(
      /\bpage\.selectOption\(['"`]([^'"`]+)['"`],\s*([^)]+)\)/g,
      (match, arg, val) => looksLikeCssSelector(arg) ? match : `safeSelect(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.selectOption\(([^)]+)\)/g,
      (match, arg, val) => `safeSelect(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.selectOption\(([^)]+)\)/g,
      (match, sel, val) => looksLikeCssSelector(sel) ? match : `safeSelect(page, '${esc(sel)}', ${val})`
    )
    // ── Type transforms → safeFill (page.type is deprecated but AI emits it) ─
    .replace(
      /\bpage\.type\(['"`]([^'"`]+)['"`],\s*([^)]+)\)/g,
      (match, arg, val) => looksLikeCssSelector(arg) ? match : `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.type\(([^)]+)\)/g,
      (match, sel, val) => looksLikeCssSelector(sel) ? match : `safeFill(page, '${esc(sel)}', ${val})`
    )
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.type\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\.type\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.type\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.type\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    // ── Missing check/uncheck/selectOption locator variants ──────────────────
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.check\(\)/g,
      (match, arg) => `safeCheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.uncheck\(\)/g,
      (match, arg) => `safeUncheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.selectOption\(([^)]+)\)/g,
      (match, arg, val) => `safeSelect(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.check\(\)/g,
      (match, arg) => `safeCheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.uncheck\(\)/g,
      (match, arg) => `safeUncheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByTestId\(['"`]([^'"`]+)['"`]\)\.selectOption\(([^)]+)\)/g,
      (match, arg, val) => `safeSelect(page, '${esc(arg)}', ${val})`
    )
    .replace(
      /page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\.check\(\)/g,
      (match, arg) => `safeCheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\.uncheck\(\)/g,
      (match, arg) => `safeUncheck(page, '${esc(arg)}')`
    )
    .replace(
      /page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\.selectOption\(([^)]+)\)/g,
      (match, arg, val) => `safeSelect(page, '${esc(arg)}', ${val})`
    )
    // ── Tap transforms → safeTap ────────────────────────────────────────────
    .replace(
      /\bpage\.tap\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeTap(page, '${esc(arg)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.tap\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeTap(page, '${esc(sel)}')`
    )
    // ── Focus transforms → safeFocus ────────────────────────────────────────
    .replace(
      /\bpage\.focus\(['"`]([^'"`]+)['"`]\)/g,
      (match, arg) => looksLikeCssSelector(arg) ? match : `safeFocus(page, '${esc(arg)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.focus\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeFocus(page, '${esc(sel)}')`
    )
    // ── DragTo transforms → safeDrag ────────────────────────────────────────
    .replace(
      /\bpage\.dragAndDrop\(['"`]([^'"`]+)['"`],\s*['"`]([^'"`]+)['"`]\)/g,
      (match, src, tgt) => `safeDrag(page, '${esc(src)}', '${esc(tgt)}')`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.dragTo\(page\.locator\(['"`]([^'"`]+)['"`]\)\)/g,
      (match, src, tgt) => looksLikeCssSelector(src) || looksLikeCssSelector(tgt) ? match : `safeDrag(page, '${esc(src)}', '${esc(tgt)}')`
    )
    .replace(
      /page\.getByText\(['"`]([^'"`]+)['"`](?:,\s*\{[^}]*\})?\)\.dragTo\(page\.getByText\(['"`]([^'"`]+)['"`](?:,\s*\{[^}]*\})?\)\)/g,
      (match, src, tgt) => `safeDrag(page, '${esc(src)}', '${esc(tgt)}')`
    )
    // ── File upload transforms → safeUpload ─────────────────────────────────
    .replace(
      /\bpage\.setInputFiles\(['"`]([^'"`]+)['"`],\s*([^)]+)\)/g,
      (match, target, files) => looksLikeCssSelector(target) ? match : `safeUpload(page, '${esc(target)}', ${files})`
    )
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.setInputFiles\(([^)]+)\)/g,
      (match, target, files) => looksLikeCssSelector(target) ? match : `safeUpload(page, '${esc(target)}', ${files})`
    )
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`]\)\.setInputFiles\(([^)]+)\)/g,
      (match, target, files) => `safeUpload(page, '${esc(target)}', ${files})`
    )
    // page.getByTestId(...).setInputFiles(...) — intentionally NOT transformed.
    // The test-id locator is already precise; rewriting to safeUpload would
    // route through generic input[type="file"] fallbacks and could pick the
    // wrong file input on pages with multiple uploaders.
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.setInputFiles\(([^)]+)\)/g,
      (match, target, files) => `safeUpload(page, '${esc(target)}', ${files})`
    )
    .replace(
      /frame\.locator\(['"`]([^'"`]+)['"`]\)\.setInputFiles\(([^)]+)\)/g,
      (match, target, files) => looksLikeCssSelector(target) ? match : `safeUpload(frame, '${esc(target)}', ${files})`
    )
    // ── Press transforms → safePress ────────────────────────────────────────
    .replace(
      /\bpage\.press\(['"`]([^'"`]+)['"`],\s*['"`]([^'"`]+)['"`]\)/g,
      (match, sel, key) => looksLikeCssSelector(sel) ? match : `safePress(page, '${esc(sel)}', '${esc(key)}')`
    )
    // ── Assertion transforms ────────────────────────────────────────────────
    // Rewrite ALL role-based visibility assertions into safeExpect.
    // Covers every common ARIA role — not just the original 5.
    //
    // Scoped roles (button, link, menuitem, tab) keep the role hint:
    //   expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible()
    //   → await safeExpect(page, expect, 'Sign in', 'button')
    //
    // Input-like roles drop the role (safeExpect tries all input roles):
    //   expect(page.getByRole('textbox', { name: 'Search' })).toBeVisible()
    //   → await safeExpect(page, expect, 'Search')
    //
    // Structural roles (heading, img, dialog, etc.) keep the role hint:
    //   expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
    //   → await safeExpect(page, expect, 'Dashboard', 'heading')
    //
    // Non-role assertions (toHaveURL, toContainText, etc.) are left alone.

    // Scoped roles — keep role hint
    .replace(
      /(?:await\s+)?expect\(page\.getByRole\(['"`](button|link|menuitem|tab|heading|img|navigation|listitem|cell|row|dialog|alert|checkbox|radio|switch|slider|progressbar|option)['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\)\.toBeVisible\(\)/g,
      (match, role, name) => `await safeExpect(page, expect, '${esc(name)}', '${esc(role)}')`
    )
    // Input-like roles — drop role (safeExpect waterfall covers all input types)
    .replace(
      /(?:await\s+)?expect\(page\.getByRole\(['"`](?:textbox|searchbox|combobox|spinbutton)['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\)\.toBeVisible\(\)/g,
      (match, name) => `await safeExpect(page, expect, '${esc(name)}')`
    )
    // Catch-all for any remaining getByRole(...).toBeVisible() with unknown roles
    .replace(
      /(?:await\s+)?expect\(page\.getByRole\(['"`]([^'"`]+)['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\)\.toBeVisible\(\)/g,
      (match, role, name) => `await safeExpect(page, expect, '${esc(name)}', '${esc(role)}')`
    )
    .replace(
      /(?:await\s+)?expect\(page\.getByLabel\(['"`]([^'"`]+)['"`]\)\)\.toBeVisible\(\)/g,
      (match, name) => `await safeExpect(page, expect, '${esc(name)}')`
    )
    .replace(
      /(?:await\s+)?expect\(page\.getByText\(['"`]([^'"`]+)['"`](?:,\s*\{[^}]*\})?\)\)\.toBeVisible\(\)/g,
      (match, name) => `await safeExpect(page, expect, '${esc(name)}')`
    )
    .replace(
      /(?:await\s+)?expect\(page\.getByPlaceholder\(['"`]([^'"`]+)['"`]\)\)\.toBeVisible\(\)/g,
      (match, name) => `await safeExpect(page, expect, '${esc(name)}')`
    )
    // expect(page.getByTestId(...)).toBeVisible() — very common AI pattern
    .replace(
      /(?:await\s+)?expect\(page\.getByTestId\(['"`]([^'"`]+)['"`]\)\)\.toBeVisible\(\)/g,
      (match, name) => `await safeExpect(page, expect, '${esc(name)}')`
    )
    // expect(page.getByAltText(...)).toBeVisible() — image visibility
    .replace(
      /(?:await\s+)?expect\(page\.getByAltText\(['"`]([^'"`]+)['"`]\)\)\.toBeVisible\(\)/g,
      (match, name) => `await safeExpect(page, expect, '${esc(name)}')`
    )
    // expect(page.locator(...)).toBeVisible() — leave CSS selectors alone
    .replace(
      /(?:await\s+)?expect\(page\.locator\(['"`]([^'"`]+)['"`]\)\)\.toBeVisible\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `await safeExpect(page, expect, '${esc(sel)}')`
    )
    // ── Additional transforms for patterns Ollama frequently produces ────────
    // NOTE: expect(page.getByRole('role')).toBeVisible() without { name } is
    // valid Playwright — left as-is (no transform needed).

    // page.getByLabel(...).fill(val) with options object — e.g. { exact: true }
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`],\s*\{[^}]*\}\)\.fill\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    // page.getByLabel(...).click() with options — e.g. { exact: true }
    .replace(
      /page\.getByLabel\(['"`]([^'"`]+)['"`],\s*\{[^}]*\}\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // page.getByText(...).click() with options — e.g. { exact: true }
    .replace(
      /page\.getByText\(['"`]([^'"`]+)['"`],\s*\{[^}]*\}\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // page.getByText(...).fill(val) — Ollama sometimes chains fill on getByText
    .replace(
      /page\.getByText\(['"`]([^'"`]+)['"`](?:,\s*\{[^}]*\})?\)\.fill\(([^)]+)\)/g,
      (match, arg, val) => `safeFill(page, '${esc(arg)}', ${val})`
    )
    // page.locator(...).first().click() — Ollama often adds .first()
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.first\(\)\.click\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeClick(page, '${esc(sel)}')`
    )
    // page.locator(...).nth(N).click() — Ollama sometimes adds .nth()
    .replace(
      /page\.locator\(['"`]([^'"`]+)['"`]\)\.nth\(\d+\)\.click\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `safeClick(page, '${esc(sel)}')`
    )
    // page.getByRole(...).first().click() — chain with .first()
    .replace(
      /page\.getByRole\(['"`][^'"`]+['"`],\s*\{\s*name:\s*['"`]([^'"`]+)['"`]\s*\}\)\.first\(\)\.click\(\)/g,
      (match, arg) => `safeClick(page, '${esc(arg)}')`
    )
    // NOTE: expect(page.getByText('...', { exact: true })).toBeVisible() is already
    // handled by the getByText assertion transform above (line 1123) which uses
    // (?:,\s*\{[^}]*\})? to optionally match the options object.

    // expect(page.locator(...).first()).toBeVisible() — locator with .first()
    .replace(
      /(?:await\s+)?expect\(page\.locator\(['"`]([^'"`]+)['"`]\)\.first\(\)\)\.toBeVisible\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `await safeExpect(page, expect, '${esc(sel)}')`
    )
    // expect(page.locator(...).nth(N)).toBeVisible() — locator with .nth()
    .replace(
      /(?:await\s+)?expect\(page\.locator\(['"`]([^'"`]+)['"`]\)\.nth\(\d+\)\)\.toBeVisible\(\)/g,
      (match, sel) => looksLikeCssSelector(sel) ? match : `await safeExpect(page, expect, '${esc(sel)}')`
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// Prompt Rules — tiered for cloud vs local models (MNT-009)
// ─────────────────────────────────────────────────────────────────────────────
// CORE_RULES   (~200 tokens) — native Playwright instructions for local models.
//              Local models write standard Playwright code; the transform engine
//              (applyHealingTransforms) rewrites it to self-healing helpers at runtime.
// SELF_HEALING_PROMPT_RULES — full exhaustive self-healing helper rules for cloud models.
// ─────────────────────────────────────────────────────────────────────────────

export const CORE_RULES = `
Write standard Playwright code. The runtime automatically adds self-healing.

INTERACTIONS — use standard Playwright methods:
  await page.getByRole('button', { name: 'Submit' }).click()
  await page.getByLabel('Email').fill('user@test.com')
  await page.getByRole('combobox', { name: 'Country' }).selectOption('US')
  await page.getByRole('checkbox', { name: 'Agree' }).check()
  await page.getByText('Sign in').click()
  await page.keyboard.press('Enter')

ASSERTIONS — inline semantic locators inside expect():
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
  await expect(page.getByText('Welcome')).toBeVisible()
  await expect(page.locator('.results')).toHaveCount(3)
  await expect(page.getByText('search term')).toBeVisible()
  await expect(page).toHaveURL(/hostname/i)  — hostname-only regex, never exact URL

RULES:
  ✓ Prefer getByRole, getByLabel, getByText, getByPlaceholder over CSS
  ✓ Inline locators inside expect() — never assign to a variable
  ✓ Use { waitUntil: 'domcontentloaded' } after page.goto
  ✗ NEVER use page.click('sel') or page.fill('sel', val) — use locator chains
  ✗ NEVER use expect(page.locator('css')).toBeVisible()/.toContainText() —
       use getByText/getByRole for visibility/text
  ✗ NEVER hard-code dynamic values (dates, IDs, counts) — use regex patterns`.trim();

// Full self-healing helper rules for cloud models.
export const SELF_HEALING_PROMPT_RULES = `
STRICT RULE: Use ONLY self-healing helpers for ALL interactions AND visibility assertions.

INTERACTIONS — use these exclusively:
  ✓ await safeClick(page, text)            — for any click
  ✓ await safeDblClick(page, text)         — for any double-click
  ✓ await safeHover(page, text)            — for any hover (menus, tooltips)
  ✓ await safeFill(page, label, value)     — for any input fill (also replaces page.type)
  ✓ await safeSelect(page, label, value)   — for any select/dropdown
  ✓ await safeCheck(page, label)           — for checkbox/radio checked state
  ✓ await safeUncheck(page, label)         — for checkbox/radio unchecked state
  ✓ await safeDrag(page, sourceText, targetText) — for drag-and-drop
  ✓ await safeUpload(page, label, filePaths)     — for file upload (accepts string or string[])
  ✓ await safeFocus(page, label)           — for focusing an element
  ✓ await safeTap(page, text)              — for touch tap (mobile viewports)
  ✓ await safePress(page, label, key)      — for pressing a key on a focused element
  ✓ await safeRightClick(page, text)       — for context-menu / right-click

IFRAME / FRAME — use safeSelectFrame to get a FrameLocator, then chain helpers:
  ✓ const frame = safeSelectFrame(page, 'Payment')  — returns a FrameLocator
    Then use frame.locator(...) or frame.getByRole(...) inside the iframe.

DIALOGS (window.alert / confirm / prompt):
  Dialogs are auto-accepted by the runtime. If you need to dismiss instead:
  ✓ page.on('dialog', d => d.dismiss())    — register BEFORE the action that triggers it

NEW TABS / POPUPS:
  ✓ const [newPage] = await Promise.all([
      context.waitForEvent('page'),
      safeClick(page, 'Open in new tab'),
    ]);
    await newPage.waitForLoadState('domcontentloaded');
    // ... interact with newPage ...
    await newPage.close();

DOWNLOADS:
  ✓ const [download] = await Promise.all([
      page.waitForEvent('download'),
      safeClick(page, 'Download PDF'),
    ]);
    const path = await download.path();

KEYBOARD (global, not element-scoped):
  ✓ await page.keyboard.press('Enter')     — for global key presses (Escape, Tab, etc.)
  ✓ await page.keyboard.type('search term') — for typing without a specific element

MOUSE (coordinate-based — use only when no element label exists):
  ✓ await page.mouse.click(x, y)
  ✓ await page.mouse.move(x, y)
  ✓ await page.mouse.wheel(0, 500)         — for scrolling

VISIBILITY ASSERTIONS — use safeExpect (NOT raw page.locator):
  ✓ await safeExpect(page, expect, text)           — assert any element is visible
  ✓ await safeExpect(page, expect, text, 'button') — scoped to a role
  ✗ await expect(page.locator('.class')).toBeVisible()  — rejected by the validator
  ✗ await expect(page.locator('#id')).toContainText('X') — rejected; use safeExpect(page, expect, 'X')
  ✗ await expect(page.locator('.x')).toHaveText('X')     — rejected; use safeExpect(page, expect, 'X')

COUNT / VALUE / STATE / ATTRIBUTE ASSERTIONS — page.locator() IS allowed:
  ✓ await expect(page.locator(...)).toHaveCount(5);
  ✓ await expect(page.locator(...)).toHaveValue('expected');
  ✓ await expect(page.locator(...)).not.toHaveCount(0);
  ✓ await expect(page.locator(...)).toBeHidden();             — scoped with a semantic selector
  ✓ await expect(page.locator(...)).toHaveAttribute('href', /expected/);
  ✓ await expect(page.locator(...)).toHaveClass(/active/);
  ✓ await expect(page.locator(...)).toHaveCSS('color', 'rgb(0, 0, 0)');
    BAD:  const items = page.locator(...); await expect(items).toHaveCount(5);
    GOOD: await expect(page.locator(...)).toHaveCount(5);
  ✗ NEVER assert a hard-coded count on a generic container that may change (e.g. toHaveCount(5) on search results).
    Instead verify AT LEAST one result is visible: await expect(page.locator(...)).not.toHaveCount(0);

OTHER ASSERTIONS — these are fine as-is (do not wrap them):
  ✓ await expect(page).toHaveURL(...)
  ✓ await expect(page).toHaveTitle(...)
  ✓ await expect(locator).toHaveValue(...)
  ✓ await expect(locator).toBeEnabled()
  ✓ await expect(locator).toBeDisabled()
  ✓ await expect(locator).toBeChecked()

FORBIDDEN — never use these (they bypass self-healing and will break on selector changes):

  Clicks (use safeClick instead):
  ✗ page.click(...)
  ✗ page.locator(...).click()
  ✗ page.getByRole(...).click()
  ✗ page.getByText(...).click()
  ✗ page.getByLabel(...).click()
  ✗ page.getByPlaceholder(...).click()
  ✗ page.getByTestId(...).click()
  ✗ page.getByAltText(...).click()

  Taps (use safeTap instead):
  ✗ page.tap(...)
  ✗ page.locator(...).tap()

  Fills / typing (use safeFill instead):
  ✗ page.fill(...)
  ✗ page.type(...)
  ✗ page.locator(...).fill(...)
  ✗ page.locator(...).type(...)
  ✗ page.getByLabel(...).fill(...)
  ✗ page.getByLabel(...).type(...)
  ✗ page.getByPlaceholder(...).fill(...)
  ✗ page.getByPlaceholder(...).type(...)
  ✗ page.getByTestId(...).fill(...)
  ✗ page.getByTestId(...).type(...)
  ✗ page.getByRole(...).fill(...)
  ✗ page.getByRole(...).type(...)

  Form controls (use safeSelect/safeCheck/safeUncheck):
  ✗ page.check(...)
  ✗ page.uncheck(...)
  ✗ page.selectOption(...)
  ✗ page.locator(...).check()
  ✗ page.locator(...).uncheck()
  ✗ page.locator(...).selectOption(...)
  ✗ page.getByRole(...).check()
  ✗ page.getByRole(...).uncheck()
  ✗ page.getByRole(...).selectOption(...)
  ✗ page.getByLabel(...).selectOption(...)
  ✗ page.getByTestId(...).check()
  ✗ page.getByTestId(...).uncheck()
  ✗ page.getByTestId(...).selectOption(...)
  ✗ page.getByPlaceholder(...).check()
  ✗ page.getByPlaceholder(...).uncheck()
  ✗ page.getByPlaceholder(...).selectOption(...)

  Double-clicks (use safeDblClick instead):
  ✗ page.dblclick(...)
  ✗ page.locator(...).dblclick()
  ✗ page.getByText(...).dblclick()
  ✗ page.getByRole(...).dblclick()
  ✗ page.getByTestId(...).dblclick()

  Hovers (use safeHover instead):
  ✗ page.hover(...)
  ✗ page.locator(...).hover()
  ✗ page.getByText(...).hover()
  ✗ page.getByRole(...).hover()
  ✗ page.getByTestId(...).hover()

  Drag-and-drop (use safeDrag instead):
  ✗ page.dragAndDrop(...)
  ✗ locator.dragTo(...)

  File upload (use safeUpload instead):
  ✗ page.setInputFiles(...)
  ✗ locator.setInputFiles(...)

  Focus (use safeFocus instead):
  ✗ page.focus(...)
  ✗ locator.focus()

  Press on selector (use safePress instead):
  ✗ page.press(selector, key)              ← use safePress(page, label, key)

  Visibility assertions (use safeExpect instead):
  ✗ expect(page.getByRole(...)).toBeVisible()
  ✗ expect(page.getByText(...)).toBeVisible()
  ✗ expect(page.getByLabel(...)).toBeVisible()
  ✗ expect(page.getByPlaceholder(...)).toBeVisible()
  ✗ expect(page.getByTestId(...)).toBeVisible()
  ✗ expect(page.getByAltText(...)).toBeVisible()
  ✗ expect(page.locator(...)).toBeVisible()  ← use safeExpect with the text/label instead

  Variable-based locator declarations (always inline inside expect()):
  ✗ const searchInput = page.locator(...);   ← declare AND use in one line, or use safeFill/safeClick
  ✗ const results = page.locator(...);       ← inline: expect(page.locator(...)).toHaveCount(N)
  ✗ const searchButton = page.locator(...);  ← use safeClick(page, text) instead
`.trim();

/**
 * Return the appropriate prompt rules for the given tier.
 *
 * - `"cloud"` — full exhaustive rules (`SELF_HEALING_PROMPT_RULES`)
 * - `"local"` — compact core rules only (`CORE_RULES`)
 *
 * @param {"cloud"|"local"} tier - The prompt tier.
 * @returns {string} Prompt rules string.
 */
export function getPromptRules(tier) {
  return tier === "local" ? CORE_RULES : SELF_HEALING_PROMPT_RULES;
}