# Skill: migrate-framework

Migrate an existing Playwright test framework into the Helix-QA layout so every
QA STLC agent (qa-test-case-manager, qa-gherkin-generator, qa-playwright-generator,
qa-helix-writer, qa-jira-manager) can operate against it. The migration ships
self-healing locator infrastructure, an HTTP healing dashboard, agent skill
files, an `.mcp.json`, and every other asset the agents expect.

## When to use this skill

Trigger on any of:
- "migrate my Playwright tests"
- "convert existing tests to Helix"
- "migrate playwright+cucumber project"
- "bring old tests into the agent framework"
- "stlc-migrate", "qa-migration", "migrate_framework" tool names

## Supported source frameworks

| Framework | Detection |
|-----------|-----------|
| Plain Playwright JS | `playwright.config.js`, no `.feature` files |
| Plain Playwright TS | `playwright.config.ts`, no `.feature` files |
| Playwright + Cucumber JS | `playwright.config.js` + `.feature` files or `@cucumber/cucumber` in package.json |
| Playwright + Cucumber TS | `playwright.config.ts` + `.feature` files or `@cucumber/cucumber` in package.json |

## Step-by-step workflow

### Step 1 — Confirm source and target directories

Ask the user for:
- **Source directory** — root of the existing Playwright project (must exist)
- **Helix root** — root of the Helix-QA project to migrate into (must exist unless dry-run)
- **Integration** (optional) — `ado`, `jira`, or `both` (default `both`). Controls which agent skills + MCP entries get installed.

### Step 2 — Detect the framework

```
detect_framework(source_dir=<source>)
```

Report back:
- `framework` — one of: `playwright-bdd-ts`, `playwright-bdd-js`, `playwright-ts`, `playwright-js`
- `language` — `typescript` or `javascript`
- `bdd` — whether BDD/Cucumber files were found
- `file_counts` — number of `.ts`, `.js`, `.feature` files found

### Step 3 — Preview the migration plan

```
preview_migration(source_dir=<source>, helix_root=<helix>)
```

Show the user:
- Total mappable files and their roles
- Any conflicts (files already existing in Helix)
- Files with unknown/unrecognised roles that will be skipped

Ask the user to confirm before proceeding if there are conflicts.

### Step 4 — Run the migration

```
migrate_framework(
  source_dir=<source>,
  helix_root=<helix>,
  conflict_strategy="interactive",   # "overwrite" | "skip" | "interactive"
  integration="both",                # "ado" | "jira" | "both"
  features="",                        # CSV: currently only "vrt" recognised
  preserve_tree=false,                # group features/steps/pages/locators under source subfolders
  dry_run=false,
)
```

`features` and `preserve_tree` are optional. Both default off; the CLI exposes
`--features=vrt` and `--preserve-tree`. See the dedicated subsections below.

The tool applies this pipeline to each file:

| Step | What happens |
|------|-------------|
| **JS → TS** | `require(...)` → `import`, `module.exports` → `export default`, async return types added |
| **Locator modernisation** | `[data-testid]` → `getByTestId`, `text=` → `getByText`, `[aria-label]` → `getByLabel`, `[placeholder]` → `getByPlaceholder`, `role=X[name=Y]` → `getByRole`. Complex XPath/CSS flagged with `// TODO`. **Multi-line string-concat selectors** (`"a" + "b"`) are registered as a single `{selector, intent, stability}` entry preserving the concatenation. |
| **Healer injection** | EVERY locator interaction (regardless of caller shape) is routed through `fixture().locatorHealer.<verb>WithHealing`. The healers live as fields on the cucumber World (`PageFixture.locatorHealer` / `.locatorTiming` / `.locatorVisual`), constructed once per scenario in `hooks.ts` `Before`. The injector does NOT emit per-class `_repo`/`_logger`/`_healer`/`_timing`/`_visual` fields, nor per-step `const _healer = new LocatorHealer(...)` setup blocks. **Covered verbs (page-level + chained `.locator(X).Y()` + var-chained `<var>.Y()`):** `click`, `fill`, `check`, `uncheck`, `selectOption`, `hover`, `dblclick`, `focus`, `blur`, `tap`, `press`, `type`, `setInputFiles`. **Custom wrappers:** `base.waitAndClick` / `…Fill` / `…Check` / `…Uncheck` / `…Hover` / `…Dblclick` / `…Focus` / `…SelectOption` / `…Press` / `…Type` / `…SetInputFiles` (all `*ByTestId` variants too). **Assertions (positive + negated `.not.` forms):** `toBeVisible`, `toBeHidden`, `toBeEnabled`, `toBeDisabled`, `toBeChecked`, `toBeFocused`, `toBeEditable`, `toBeAttached`, `toBeInViewport`, `toHaveValue`, `toHaveText`, `toContainText`, `toHaveCount`, `toHaveClass`, `toHaveId`, `toHaveAttribute`, `toHaveCSS`. Negated forms route to either a natural antonym (`.not.toBeVisible` → `assertHiddenWithHealing`) or to a dedicated `assertNot<Verb>WithHealing`. **Other covered patterns:** non-awaited calls inside `Promise.all([...])`, `currentFixture.page.*` receivers, aliased references (`this.Elements.foo`, `const E = fooLocators`), inline class-field locator dictionaries (`private MassInviteElements = { ... }`), and dynamic selectors (auto-wrapped with `dyn.<prefix>.<file-slug>.<n>` keys). Arguments are extracted by **balanced-paren scan** so calls like `fill(X, String(visitorCount))` extract the full second arg. |
| **Locator registration** | Every page-object constructor gets `Object.entries(xxxLocators).forEach(([k, v]) => { fixture().locatorRepository.register(\`xxxPage.${k}\`, ...); fixture().locatorRepository.register(k, ...); })` so the SHARED runtime store on `PageFixture` knows about every key. Registers under both **namespaced** and **bare** key conventions so legacy hand-written `…locatorHealer.<…>("usernameInput", …)` and freshly-emitted `…locatorHealer.<…>("loginPage.usernameInput", …)` both resolve. |
| **Upgrade pass on re-migration** | When the injector sees a file that an earlier migration already touched (`new LocatorHealer(` / `this._healer` / `_healer.<verb>WithHealing` detected), it runs an **upgrade pass first**: strips the per-class field declarations, the 3-line constructor wiring, the per-step `const _healer = new LocatorHealer(...)` setup blocks, and the 5 healer imports; then renames every `this._healer.X` → `fixture().locatorHealer.X` (and timing/visual/repository likewise). After the upgrade the file looks like a fresh one, so the normal pipeline proceeds. Idempotent: re-running on already-new code is a no-op. |
| **Import fixing** | Relative imports remapped to the **mapper's authoritative target paths** (no more "predicted" alias names that don't match what was actually written). Source-project `@pages/scweb/locators` → `@locators/locators.locators` exactly as the mapper renamed it. |
| **Spec → BDD** | See "Zero-inference guarantees" below. |
| **Config merge** | `playwright.config` settings merged. `cucumber.js` is rewritten with `dotenv/config` injected as the FIRST `requireModule` entry — so `.env` is loaded before any TS file is compiled. `package.json` gets `dotenv` runtime dep, `@types/*` companions for known JS-only deps (fs-extra, lodash, glob, mkdirp, rimraf, uuid, js-yaml, jsonwebtoken, module-alias, cookie, express, cors, node-fetch, minimist, yargs, semver), and two new scripts: `healix:dashboard` and `healix:review`. |
| **tsconfig merge** | Adds path aliases (`@pages`, `@steps`, `@features`, `@locators`, `@hooks`, `@helper`, `@utils`, `@config`). Does **not** fabricate `strict: true` / `strictNullChecks` — the source's strictness setting is preserved verbatim (introducing strictness the source didn't have would surface cascading errors on code that compiled fine before). |

#### Healer injection — heal vs. raw decision table

The injector ([`healer_injector.py`](../../src/stlc_agents/agent_migration/transformer/healer_injector.py))
scans every Playwright action call and decides per-site whether to wrap it
in `fixture().locatorHealer.*WithHealing(...)` or leave the call raw. The default biases
toward healing for direct locator references, and toward **raw** whenever
healing would mis-target the element. **"When in doubt, don't heal"** —
a heal call that resolves to the wrong element is worse than no heal at all.

##### Heals (semantic key from locator-repo)

| Input pattern | Becomes |
|---|---|
| `await page.click(loginLocators.submitButton.selector)` | `fixture().locatorHealer.clickWithHealing('login.submitButton', …selector, …intent)` |
| `await this.page.click(this.Elements.submitButton.selector)` (alias) | same — `this.Elements = loginLocators` is followed |
| `const btn = this.page.locator(this.Elements.submitButton.selector); await btn.click();` | same — variable resolved through `var_map` |
| `this.MassInviteElements.foo` (inline class-field re-export) | resolves to the original locator entry the field aliases |

##### Heals with a synthesised `dyn.*` key (still routed through healer)

Keys embed the **file slug** (so two files can't both claim `dyn.po.1`) and
**interpolate runtime parts of the selector at evaluation time** (so a
parameterised method invoked with many argument values gets one heal-store
slot per value instead of all colliding on the same row). The key shape is
`dyn.<prefix>.<file-slug>.<N>[.${runtime}…]` — JS template literals when
the selector varies, plain quoted strings when it's a static CSS literal.

| Input pattern (file: `member-view.page.ts`) | Becomes |
|---|---|
| `await page.click('#submit')` (bare CSS string) | `clickWithHealing('dyn.po.member-view.1', '#submit', "submit")` — static key, intent derived from the selector |
| ``await page.click(`tab-${label}`)`` (template literal) | `` clickWithHealing(`dyn.po.member-view.2.${label}`, `tab-${label}`, "dynamic locator") `` — `${label}` interpolated into the key |
| `await page.click(this.Elements.linkByName(name))` (function call) | `` clickWithHealing(`dyn.po.member-view.3.${name}`, this.Elements.linkByName(name), "dynamic locator") `` — call args become key segments |
| `await page.click(widgetMap[widgetType])` (subscript) | `` clickWithHealing(`dyn.po.member-view.4.${widgetType}`, widgetMap[widgetType], "dynamic locator") `` — index expression becomes the key segment |
| `await page.click(centralLocator)` (bare identifier) | `` clickWithHealing(`dyn.po.member-view.5.${centralLocator}`, centralLocator, "central locator") `` — identifier interpolated |

##### Stays **raw** (guards)

| Input pattern | Why raw |
|---|---|
| `const x = locator(X).filter({hasText:/Y/}).first(); await x.click()` | `.filter`/`.first`/`.last`/`.nth`/`.getBy*`/nested `.locator()` narrows the selection; the healer re-resolves from the base selector and would target a different element (or trip strict-mode). Guard: `_has_narrowing_chain`. |
| Same `const input` declared in two methods against different locator entries | File-scope variable-name collision — without scope analysis we can't tell which assignment a given `await input.fill(...)` refers to. Both call sites stay raw. Guard: collision check in `_build_var_to_semantic`. |
| `await page.click(fooLocators.errorLabel)` where `errorLabel: "Some text"` (string-valued entry) | Only object-valued entries (`{selector, intent, stability}`) qualify. `object_keys` (built by `_migrate.py`) filters string labels out. |
| `await someHelper().click()` | No `.locator(...)` start — no pattern match. |
| Already-healed call (`fixture().locatorHealer.clickWithHealing(...)` in source) | Idempotent — re-running the injector doesn't double-wrap. Old-shape calls (`this._healer.…` / `_healer.…`) are auto-upgraded to the fixture form by the re-migration upgrade pass. |

##### Decision locations (file/function index)

| Decision | Function | Location |
|---|---|---|
| Is this expression a locator-repo reference? | `_derive_semantic` | `healer_injector.py` |
| What semantic does this variable carry? | `_build_var_to_semantic` | `healer_injector.py` |
| Does the RHS have a narrowing chain? | `_has_narrowing_chain` | `healer_injector.py` |
| Is this name ambiguous across the file? | collision check in `_build_var_to_semantic` | `healer_injector.py` |
| Is the locator entry object-valued? | `object_keys` filter passed in by `_migrate.py` | `_semantic_for` |
| Fallback when no semantic found | `_next_dyn_key` + `_intent_for_dyn` | `_replace_actions` |

##### How to nudge the injector at write-time

If a hand-written page object should **heal**:
- Reference the locator directly: `this.Elements.X.selector`, not a chain.
- Push filtering INTO the selector (`:has-text(...)`, `:nth-of-type(N)`) rather than `.filter()` / `.nth()`.
- Add a *specific* locator entry per filtered variant (e.g. `chevronCollapseInfoHeader` next to `chevronCollapseHeader`) — keeps the call site ergonomic AND the intent text carries the specifier so the healer's `text` strategy can rediscover the element if class names drift.
- Don't reuse variable names like `input`, `btn`, `header` across methods in the same file.

If a hand-written page object should stay **raw** (e.g. fallback "any of these" clicks where there's no stable semantic target):
- Keep the chain (`.filter()`, `.first()`, `.nth()`, etc.) — the guard catches it automatically.
- Or build the locator from something other than a `xxxLocators.yyy` reference (e.g. `page.getByText(...)` directly).

##### Past incidents this table prevents

- **`@needs-test-member` Before hook timing out (60s):** member-add.page.ts declared `const input` in three methods (`fillFirstName`, `fillLastName`, `fillEmail`) against three different locator entries. Pre-fix, all three healer calls wrote to `emailInput` (last-seen wins) — saved members had blank fname/lname, never redirected to `/members/view/{uid}`. Fixed by the file-scope collision guard.
- **`navigateToEditGroup` "Group form not rendered after 30s":** group-add-edit.page.ts used `.filter({hasText:/Information/i}).first()` to pick the right chevron header; the injector dropped the chain and the healer clicked the first chevron instead, expanding the wrong section. Fixed by the narrowing-chain guard plus a new specific locator entry (`chevronCollapseInfoHeader`).
- **Page-object constructor injection bug — `this.page` undefined at healer creation:** when a source file uses separate-field constructors (`private page: Page;` + `this.page = page;` in body), the original injector inserted healer assignments at the *start* of the constructor body, BEFORE the user's `this.page = page;` line. The healer was constructed with `this.page === undefined` and every `this.page.locator(...)` call later threw `TypeError: Cannot read properties of undefined (reading 'locator')`. Fixed by `_find_matching_brace` end-of-body injection in `_inject_page_object`.

##### Extended verb migration (added 2026-05)

The healer now wraps **every** common Playwright verb, and the injector recognises them all. Source code that uses these verbs gets the heal chain automatically:

| Source pattern (any of these) | Rewritten to |
|---|---|
| `await page.check(X)`, `base.waitAndCheck(X)`, `loc.check()` | `await fixture().locatorHealer.checkWithHealing(key, X, intent)` |
| `await page.uncheck(X)` | `await fixture().locatorHealer.uncheckWithHealing(key, X, intent)` |
| `await page.selectOption(X, v)` | `await fixture().locatorHealer.selectOptionWithHealing(key, X, v, intent)` |
| `await page.hover(X)` | `await fixture().locatorHealer.hoverWithHealing(key, X, intent)` |
| `await expect(X).toBeEnabled()` | `await fixture().locatorHealer.assertEnabledWithHealing(key, sel, intent)` |
| `await expect(X).toBeChecked()` | `await fixture().locatorHealer.assertCheckedWithHealing(key, sel, intent)` |
| `await expect(X).toHaveValue(v)` | `await fixture().locatorHealer.assertValueWithHealing(key, sel, v, intent)` |
| `await expect(X).toHaveText(v)` | `await fixture().locatorHealer.assertTextWithHealing(key, sel, v, intent)` |

Same rules as for click/fill apply: locator-repo references heal via semantic key; bare CSS strings and runtime expressions heal via `dyn.<file-slug>.po.<N>.${...}` keys.

##### Iframe migration

When source uses `page.frameLocator(...).locator(X).click()`, the injector recognises the pattern and emits a `LocatorHealer.forFrame(...)` instance in the page-object constructor, then routes the click through it. The page-level healer is still accessed via `fixture().locatorHealer`; only the frame-scoped healer needs to live on the class (one instance per frame target, can't be shared via fixture).

```typescript
// AFTER migration — page-level operations use the shared fixture healer;
// frame-scoped operations need a dedicated forFrame instance.
import { LocatorHealer } from "@utils/locators/LocatorHealer";
import { fixture } from "@hooks/pageFixture";

private readonly frameHealer: LocatorHealer;

constructor(private page: Page) {
  this.frameHealer = LocatorHealer.forFrame(
    page,
    "iframe[name='checkout-frame']",
    fixture().logger,
    fixture().locatorRepository,
  );
}

async submit(): Promise<void> {
  // Page-level click — shared healer.
  await fixture().locatorHealer.clickWithHealing(...);
  // Iframe click — class-owned frameHealer.
  await this.frameHealer.fillWithHealing(...);
}
```

##### New-tab migration

When source clicks a `target="_blank"` anchor and awaits a popup via `context.waitForEvent("page")`, the injector rewrites to `clickAndAwaitNewTab(...)`:

```typescript
// BEFORE (source)
const [newPage] = await Promise.all([
  context.waitForEvent("page"),
  this.page.click(this.Elements.openInvoice.selector),
]);

// AFTER (migration emits)
import { clickAndAwaitNewTab } from "@utils/locators/clickAndAwaitNavigation";
const newPage = await clickAndAwaitNewTab(
  this.page.context(),
  this.page,
  this.page.locator(this.Elements.openInvoice.selector).locator("visible=true").first(),
);
```

##### Click-then-navigate pattern (rewrite during migration)

A whole class of CI flakes stems from "click an anchor, expect URL change" flows. The migration injector should recognise these patterns in source and rewrite them to use the `clickAndAwaitNavigation` helper that's now scaffolded alongside `LocatorHealer.ts`. Detection signals (any one is enough):

| Signal | Example |
|---|---|
| Method name | `clickCancel`, `clickBack`, `clickDiscard`, `clickLogout`, `clickViewProfile`, `clickReturn` |
| Click target is an `<a href>` AND the next statement is `waitForURL` / URL `expect` | `await link.click(); await page.waitForURL(...);` |
| `Promise.race([waitForURL, waitForLoadState])` or similar manual signal race | Caller already trying to bundle a navigation wait |
| Step text containing "navigate away from", "redirected to", "lands on" | The Then step assertion |

When ANY signal matches, rewrite the body:

```typescript
// BEFORE (source)
async clickCancel(): Promise<void> {
  const btn = this.page.locator(cancelLocators.selector).first();
  await Promise.all([
    this.page.waitForURL(/\/view\/\d+/, { timeout: 30000 }),
    btn.click(),
  ]);
}

// AFTER (migration emits)
import { clickAndAwaitNavigation } from "@utils/locators/clickAndAwaitNavigation";

async clickCancel(): Promise<void> {
  const editUidMatch = this.page.url().match(/\/edit\/(\d+)/);
  const editUid = editUidMatch?.[1] ?? null;
  const btn = editUid
    ? this.page.locator(`a[href$='/view/${editUid}']`).locator("visible=true").first()
    : this.page.locator(cancelLocators.selector).locator("visible=true").first();
  await clickAndAwaitNavigation(this.page, btn, {
    awayFromUrl: /\/edit\//,
    timeoutMs:   CommonTimeouts.DEFAULT_TIMEOUT,
    acceptDialogs: true,
  });
}
```

And in the matching Then step, replace `waitForLoadState("domcontentloaded")` before the URL assertion with an active `waitForURL` (60 s `IO_TIMEOUT`):

```typescript
// BEFORE
await page.waitForLoadState("domcontentloaded");
expect(page.url()).not.toContain("/edit/");

// AFTER
try {
  await page.waitForURL(u => !String(u).includes("/edit/"),
                        { timeout: CommonTimeouts.IO_TIMEOUT });
} catch { /* fall through to assertion below */ }
await page.waitForLoadState("domcontentloaded");
expect(page.url()).not.toContain("/edit/");
```

The two-part fix is necessary because:
- **Click side**: `noWaitAfter:true` (inside the helper) prevents Playwright's `.click()` from blocking on `load` events that may take >30s on slow servers. The helper's all-timeout detection throws if the click silently fails to trigger navigation.
- **Assertion side**: `waitForLoadState("domcontentloaded")` is a no-op when the current page is already loaded (which it is, on the still-on-edit page if the navigation is in-flight). Only `waitForURL` actively waits for the URL itself to change.

##### Cancel-style locator hardening (rewrite during migration)

When the source has a bare href-substring locator for a "back to view" / "Cancel" element:

```typescript
// BEFORE (source) — picks up profile-menu hidden links
cancelButton: { selector: "a[href*='/view/']", intent: "cancel button" }
```

Rewrite to a union with text-first specificity and dropdown-menu exclusions:

```typescript
// AFTER (migration emits)
cancelButton: {
  selector:
    "a[href*='/view/']:has-text('Cancel'), " +
    "form a[href*='/view/']:not(.profile-menu-item):not(.profile-action), " +
    ".form-actions a[href*='/view/'], " +
    "a.btn:has-text('Cancel'), " +
    "a[href*='/view/']:not(.profile-menu-item):not(.profile-action)",
  intent: "cancel form action",
  stability: 50,
},
```

Combined with the UID-extraction in the page object's click method, this defeats the false-positive matches that header profile-dropdowns, breadcrumbs, and recently-viewed strips otherwise produce.

#### Zero-inference guarantees for spec → BDD

The converter is intentionally **lossless** and **non-fabricating**:

- **No generic step text.** Step text is always the literal `test()` name (prefixed with the file slug for cross-file uniqueness). The tool does NOT invent `"I run step #N"`, `"I prepare state #N"`, or `"I execute if block #N"` text.
- **One step per test.** The full original body of each `test('name', body)` is placed inside a single step definition. No per-statement splitting; no Given/When/Then reclassification.
- **Hooks become real Cucumber hooks.** `test.beforeEach`/`afterEach`/`beforeAll`/`afterAll` → real `Before`/`After`/`BeforeAll`/`AfterAll`. `test.skip`/`only`/`fixme`/`fail`/`slow` modifiers → `@skip`/`@only`/… tags on the scenario.
- **Nothing is dropped.** Module-level helpers, suite-scope `let`/`const`/`var`, comments, and hook bodies are all preserved. `test.use(...)`/`test.step(...)` and other runner-only calls are commented out as `// MIGRATION TODO:` rather than silently removed.
- **Source bugs surface, not papered over.** If the original source references a non-existent property (e.g. `GlobalVars.Api.Username` when the class only exposes `username`), the migrated step keeps the original reference. `tsc` will flag it; the tool does not "fix" what it cannot verify.
- **Step text with `/`, `(`, `)`, `{`, `}`** is rendered as a RegExp step definition automatically so Cucumber Expression parsing doesn't reject the test name.

#### `--features=vrt` — Visual Regression Testing scaffold

When enabled (explicitly via `--features=vrt`, or **auto-enabled** when the
source has any `vrt:*` npm script or any `.env*` file mentioning `VRT_MODE`),
the migration drops:

- `src/helper/vrt/vrtCapture.util.ts` — pixelmatch capture + diff (defaults
  generic; masks/dismiss selectors read from `VRT_GLOBAL_MASK_SELECTORS` /
  `VRT_DISMISS_SELECTORS` env CSV).
- `src/helper/vrt/vrtReport.util.ts` — HTML report generator.
- `src/test/steps/vrt.steps.ts` — Cucumber step defs
  (`capture visual snapshot "..."`, `all visual snapshots should match baselines`).
- `package.json` additions: `vrt:baseline` / `vrt:test` scripts, runtime deps
  (`pngjs`, `pixelmatch`, `fs-extra`), type companions, `cross-env`.
- `.env.example` Visual Regression Testing block (suppressed otherwise).

If the source ships its own `src/hooks/hooks.ts` / `pageFixture.ts` the
migration leaves them alone and appends a wiring snippet to
`MIGRATION-REPORT.md` under `## VRT — manual wiring required`. When the
migration owns the hooks scaffold (BDD source with no hooks), VRTCapture is
wired in automatically — `vrtCapture` field on the fixture, init in
`Before`, results registered in `After`, `VRTReportGenerator.generate()`
in `AfterAll`.

#### `--preserve-tree` — keep source subfolder grouping

Default flattens everything into the standard Helix-QA top-level dirs:

```
src/test/features/<kebab>.feature
src/test/steps/<kebab>.steps.ts
src/pages/<kebab>.page.ts
src/locators/<kebab>.locators.ts
```

With `--preserve-tree`, the source's subfolder structure is mirrored under
each top-level dir:

```
src/test/features/amplify/e-commerce/checkout.feature
src/test/steps/amplify/api/organization.steps.ts
src/pages/amplify/ui/login.page.ts
src/locators/amplify/login.locators.ts
```

Subdirectory names are kebab-cased (`ECommerce` → `e-commerce`,
`mobileApp` → `mobile-app`, `NOVA` → `nova`). When the source has no
subgroup (file is directly under `features/`, etc.), the file falls back
to flat — same as `--preserve-tree=false`.

#### Prefix-based auto-grouping (always-on, applies to flat output)

After path mapping (whether `--preserve-tree` or flat), a final pass
clusters files that would otherwise sit flat in the same target dir into
**feature-named subfolders** based on shared kebab-case first token.

Trigger rule: at least 2 files of the same role (page object / step /
feature / locator) in the same flat dir share a normalised first token.
Singletons stay flat — no folder is created for a single file.

Normalisation: trailing `s` is stripped from the token (>= 4 chars, not
`ss` endings) so plural and singular forms group together — `member-edit`
and `members-list` both land in `member/`. Tokens without dashes are left
unnormalised (so a camelCase `groupsAggregates.feature.skip` stays a
singleton rather than getting mangled into `groupsAggregate`).

Example: a project with 30+ flat page objects becomes:

```
src/pages/<app>/
  member/                      ← 6 files share prefix "member"
    member-account.page.ts
    member-add.page.ts
    member-edit.page.ts
    member-family.page.ts
    member-view.page.ts
    members-list.page.ts
  group/                       ← 6 files share prefix "group"
    group-add-edit.page.ts
    group-detail-mass-invite.page.ts
    group-detail-operations.page.ts
    groups-aggregates.page.ts
    groups-list.page.ts
    groups-views.page.ts
  report/                      ← 3 files (reports-* → "report")
    reports-attendance.page.ts
    reports-giving.page.ts
    reports-membership.page.ts
  api.page.ts                  ← singletons stay flat
  dashboard.page.ts
  login.page.ts
  …
```

Doesn't override `--preserve-tree`: when the source already supplies a
non-empty subtree for a file, the auto-grouping pass leaves it alone —
only **flat** files are eligible for regrouping. The grouping is also
disambiguation-safe; it runs after `_disambiguate` so collision suffixes
don't compete with group names.

The same logic applies to `src/test/steps/<app>/`, `src/test/features/<app>/`,
and `src/locators/`. Cucumber's `paths` / `require` globs use `**` so the
moved steps and features are picked up without config changes.

#### Page-objects barrel (auto-emitted)

After grouping, the migration agent writes a `src/pages/<container>/index.ts`
barrel re-exporting every page-object class in that container (including
files in subgroups). The barrel handles both `export default class` (most
pages) and `export class` (named exports like `ApiPage`):

```ts
// Auto-generated barrel
export { ApiPage }                  from "./api.page";
export { default as DashboardPage } from "./dashboard.page";
export { default as MemberEditPage } from "./member/member-edit.page";
// …
```

This lets the `PageFixture` use `import * as P from "@pages/<container>"` —
**one import line** instead of N individual default-imports.

#### `currentFixture` alias removal

A transformer pass collapses three legacy patterns to direct `fixture()` calls:

| Before | After |
|---|---|
| `const currentFixture = fixture();` (deleted) | — |
| `private currentFixture: PageFixture;` (deleted) | — |
| `this.currentFixture = fixture();` (deleted) | — |
| `currentFixture.X` / `this.currentFixture.X` | `fixture().X` |

`global.currentFixture` (the bootstrap that `fixture()` reads from) is
preserved — only the per-step/per-instance caching aliases go away. The
pass uses a negative lookbehind so `global.currentFixture` and any
`someObj.currentFixture` paths are left alone.

#### Orphan cleanup (`--conflict overwrite` only)

Under `--conflict overwrite`, the migration treats `src/test/features/`,
`src/test/steps/`, `src/pages/`, `src/locators/` as **managed**: after
writing, any file in these dirs with a role suffix (`.feature`, `.steps.ts`,
`.page.ts`, `.locators.ts`) that isn't part of this migration's output is
removed. Empty subdirs left behind by the sweep are also pruned. This makes
a flat → `--preserve-tree` (or vice versa) re-migration self-correcting in
one pass — no manual `rm -rf` required.

Non-role files (READMEs, JSON test data, etc.) and boilerplate-owned files
(e.g. `src/locators/base.locators.ts`) are preserved. Other dirs
(`src/helper/`, `src/hooks/`, `src/utils/locators/`, `src/fixtures/`,
`src/test/support/`) are **not** swept — they mix source-mapped,
scaffolded, and operator-added files the migration can't reliably tell
apart.

Removed orphans appear in the CLI summary (`Removed : N orphan file(s)`)
and under `## Removed orphan files` in `MIGRATION-REPORT.md`.

### Step 5 — Scaffold & assets installed automatically

In addition to file-by-file migration, the tool emits a complete agent-ready
tree:

| Asset | What it does |
|---|---|
| `src/utils/locators/LocatorHealer.ts` | 8-strategy healing chain: cached-healed → primary → **attribute** → **type-hint** → role → label → text → AI Vision. After each successful strategy, the matched element is introspected and the **specific stable selector** (data-testid > id > name > placeholder > aria-label > autocomplete > input[type]) is persisted — not the search union. The **attribute** strategy is intent-shape-aware: `LocatorHealer._intentShapeFilter(intent)` derives a CSS `:is(...)` prefix from the intent keyword (e.g. `"password input"` → `:is(input, textarea, select, [contenteditable])`) so substring matches like `[id*="password"]` can't pick up `<a id="forgot-password">` when looking for an input. Strategy order tunable via `HEAL_STRATEGY_ORDER` CSV. |
| `src/utils/locators/LocatorRepository.ts` | Heal store: schema v1 envelope (`{_version, _writtenAt, entries}`), heal-only persist filter (baseline-only entries don't get written), debounced writes (500ms), singleton process exit hook, read-modify-write merge for multi-instance safety, ENV-suffixed file path (`healed-locators.qa4.json` when ENV=qa4), capped `healingHistory` (default 50, env-tunable). |
| `src/utils/locators/TimingHealer.ts` / `VisualIntentChecker.ts` | Network-trace timing healer + visual-regression checker. |
| `src/utils/locators/HealingDashboard.ts` | Two-server HTTP UI. Primary at `HEALING_DASHBOARD_PORT` (default 7890) has Healed-locators + Pending-suggestions tables with Confirm/Revert / Approve/Reject buttons. Read-only mirror at `HEALIX_REVIEW_PORT` (opt-in, default 0). Auto-discovers all sibling heal-store files in the directory (so `healix:dashboard` without an ENV picks up `*.qa4.json` automatically). **Confirm on a healed entry writes the healed selector back to the locator `.ts` file** so the next run uses it as the primary, then clears the entry from the dashboard (Revert clears it identically — both actions end the entry's lifecycle). When ≥ 2 healed entries are pending review, an **Approve all** button appears at the top right of the page; it bulk-writes every healed selector to its locator file in a single sweep. |
| `src/utils/locators/dashboard-server.ts` | Standalone launcher for the dashboard. Wired via `healix:dashboard` and `healix:review` npm scripts. |
| `src/utils/locators/{ElementContextHelper, HealApplicator, LocatorRules, LocatorStrategy, healix-ci-apply, review-server}.ts` | Filled from `agent_helix_writer.tools.boilerplate.BOILERPLATE` — purely additive infrastructure that integrates with the enhanced LocatorHealer. |
| `src/utils/helpers/{Logger, RetryHandler, WaitHelper}.ts` | Filled from BOILERPLATE — helpers the strategy layer depends on. |
| `src/config/environment.ts` | Filled from BOILERPLATE — referenced by Logger. |
| `src/locators/base.locators.ts` | Filled from BOILERPLATE — base locator file. |
| `.claude/skills/<name>/SKILL.md` + `.github/copilot-instructions/<name>.md` | Per-integration skill files (ado / jira / both). |
| `AGENT-BEHAVIOR.md` + `ORCHESTRATION_RULES.md` | Copied to project root, `.claude/`, and `.github/copilot-instructions/`. |
| `.mcp.json` | Auto-generated entries for every agent + Playwright MCP, with absolute binary paths. |
| `.env.example` | Documents every healing env var: `HEAL_STORE_PATH`, `HEAL_LOG_PATH`, `HEAL_LOG_DISABLED`, `ENABLE_LOCATOR_VERSIONING`, `HEAL_HISTORY_CAP`, `LOCATOR_TIMEOUT`, `LOCATOR_HEAL_ATTEMPTS`, `HEAL_STRATEGY_ORDER`, `HEALING_DASHBOARD_PORT`, `HEALIX_REVIEW_PORT`, `ENABLE_AI_HEALING`, `AI_HEALING_PROVIDER`, `AI_HEALING_API_KEY`, `ENV` / `HELIX_ENV`. When `vrt` is in `features`, the VRT block is appended: `VRT_MODE`, `VRT_SPRINT`, `VRT_THRESHOLD`, `VRT_FAILURE_THRESHOLD`, `VRT_STABILISATION_DELAY`, `VRT_GLOBAL_MASK_SELECTORS`, `VRT_DISMISS_SELECTORS`. |

### Step 6 — Review the migration report

The tool writes `MIGRATION-REPORT.md` to the Helix root. Report back to the user:
- Number of files written / skipped / conflicted
- TODO count (locators needing manual review)
- Path to `MIGRATION-REPORT.md`

### Step 7 — Post-migration steps (tell the user)

After migration, the user should:

1. `npm install` — picks up new deps (`dotenv`, `@types/fs-extra`, etc.).
2. `npx tsc --noEmit` — should be clean (any remaining errors are source bugs the migration preserved verbatim per the zero-inference contract).
3. Review `// TODO: convert to getByRole` comments in locator files (complex XPath/CSS the tool wouldn't auto-rewrite).
4. Fill in `.env` — at minimum, set `HEADLESS=false` if you want a visible browser.
5. Verify `inspect_helix_project` reports `framework_state: "present"` against the migrated tree.
6. Run the existing test suite (e.g. `npm test`) — healing fires automatically on broken primary selectors and writes to `self-heals/healed-locators[.<env>].json`.
7. Inspect heals visually:
   - `npm run healix:dashboard` → http://localhost:7890 (full UI with Confirm / Revert)
   - `npm run healix:review`    → http://localhost:7891 (read-only mirror)
8. When ready, click **Confirm** on a heal — the migration rewrites the source locator file's `selector:` field with the verified healed value. Future runs use it as the primary; the healing chain only fires if the page changes again.

## Helix-QA file layout

Default (flat):

| Source role | Helix target |
|-------------|-------------|
| Locators (`*Locators.ts`, `*selectors.ts`) | `src/locators/<kebab>.locators.ts` |
| Page objects (`*Page.ts`, `*.page.ts`) | `src/pages/<kebab>.page.ts` |
| Step definitions (`*.steps.ts`, `step_definitions/**`) | `src/test/steps/<kebab>.steps.ts` |
| Feature files (`*.feature`) | `src/test/features/<kebab>.feature` |
| Playwright config | `playwright.config.ts` (merged) |
| Cucumber config | `config/cucumber.js` (merged, with `dotenv/config` injected) |
| Heal-store JSON | `self-heals/healed-locators[.<env>].json` (heal-only) |
| Heal audit log | `self-heals/heal.log` (tab-separated `timestamp / key / strategy / selector`) |
| VRT runtime (when `vrt` in features) | `src/helper/vrt/{vrtCapture,vrtReport}.util.ts`, `src/test/steps/vrt.steps.ts` |

With `--preserve-tree`, the four role dirs gain a `<sub>/` segment from the
source's relative path beneath its `features/` / `steps/` / `pages/` /
`locators/` container — e.g. `src/test/features/amplify/e-commerce/checkout.feature`.
Helper, hook, and fixture roles **already** preserve their sub-tree (via the
existing `_preserve_under`) regardless of the flag.

## CLI usage (terminal)

```bash
# Preview migration without writing
stlc-migrate --source ./my-old-tests --helix ./helix-qa --dry-run

# Full migration (interactive conflict handling, every agent reachable)
stlc-migrate --source ./my-old-tests --helix ./helix-qa

# ADO-only integration (no Jira skill / MCP entry)
stlc-migrate --source ./my-old-tests --helix ./helix-qa --integration ado

# Jira-only
stlc-migrate --source ./my-old-tests --helix ./helix-qa --integration jira

# Overwrite all existing files; also auto-removes orphans in role dirs
stlc-migrate --source ./my-old-tests --helix ./helix-qa --conflict overwrite

# Preserve source subfolder grouping under features/steps/pages/locators
stlc-migrate --source ./my-old-tests --helix ./helix-qa --preserve-tree

# Explicit VRT scaffold (auto-enabled when source already uses vrt:* / VRT_MODE)
stlc-migrate --source ./my-old-tests --helix ./helix-qa --features=vrt

# Combine: grouped layout, overwrite, VRT, JSON output
stlc-migrate --source ./my-old-tests --helix ./helix-qa \
  --conflict overwrite --preserve-tree --features=vrt --json

# Makefile shortcut
make migrate-preview SOURCE=./my-old-tests HELIX=./helix-qa
```

CLI summary lines emitted at completion:

```
Migration complete
  Written   : <n>
  Skipped   : <n>
  Conflicts : <n>
  TODOs     : <n>
  Removed   : <n> orphan file(s) from role dirs     # only when --conflict overwrite cleaned anything
```

## Runtime environment variables

Every knob has a sensible default. Override per-run in `.env` or via `cross-env` in npm scripts.

| Variable | Default | Effect |
|---|---|---|
| `HEAL_STORE_PATH` | `./self-heals/healed-locators.json` | Where the heal-store JSON is read/written. |
| `HEAL_LOG_PATH` | sibling `heal.log` | Tab-separated audit log per heal. |
| `HEAL_LOG_DISABLED` | `false` | Set `true` to skip the audit log. |
| `ENV` / `HELIX_ENV` | unset | When set, auto-suffixes the heal-store path: `healed-locators.qa4.json`. |
| `ENABLE_LOCATOR_VERSIONING` | `true` | When `false`, keeps only the latest heal entry (no `healingHistory[]` accumulation). |
| `HEAL_HISTORY_CAP` | `50` | Max heals retained per locator. `0` = unlimited. |
| `LOCATOR_TIMEOUT` | `3000` | Per-strategy attach-probe timeout (ms). |
| `LOCATOR_HEAL_ATTEMPTS` | `0` (unlimited) | Bound on the total number of probes before AI Vision fallback. |
| `HEAL_STRATEGY_ORDER` | `attribute,type-hint,role,label,text` | CSV — omit names to disable; reorder to prioritise. |
| `HEALING_DASHBOARD_PORT` | `7890` | Dashboard server port. `0` to disable. |
| `HEALIX_REVIEW_PORT` | `0` (disabled) | Read-only review server port (rejects POSTs with 403). |
| `ENABLE_AI_HEALING` | `true` | When `false`, AI Vision strategy is skipped. |
| `AI_HEALING_PROVIDER` | `anthropic` | One of `anthropic` / `copilot` / `claude-code`. |
| `AI_HEALING_API_KEY` (or `ANTHROPIC_API_KEY`) | unset | Required when AI Vision is enabled and primary fails. |
| `LOCATOR_FILES_DIR` | `./src/locators` | Where dashboard write-back scans for locator `.ts` files when a Confirm/Approve action propagates a healed selector to source. |
| `VRT_MODE` *(vrt only)* | `off` | `baseline` writes new baselines, `test` compares against them, `off` no-ops. Set automatically by `npm run vrt:*`. |
| `VRT_SPRINT` *(vrt only)* | `default` | Sprint identifier appended to baseline filenames. |
| `VRT_THRESHOLD` *(vrt only)* | `0.1` | Per-pixel colour tolerance (0.0–1.0). |
| `VRT_FAILURE_THRESHOLD` *(vrt only)* | `0.1` | % of diff pixels above which a snapshot fails. |
| `VRT_STABILISATION_DELAY` *(vrt only)* | `500` | ms to wait before each capture. |
| `VRT_GLOBAL_MASK_SELECTORS` *(vrt only)* | unset | CSV of CSS selectors masked before every capture (dynamic content, avatars, etc.). |
| `VRT_DISMISS_SELECTORS` *(vrt only)* | unset | CSV of CSS selectors auto-dismissed before each capture (Pendo guides, Intercom, etc.). |

## Key rules

1. **Always run `preview_migration` first** — review the plan and conflicts before writing.
2. **Never infer conflict decisions** — ask the user whether to overwrite, skip, or abort.
3. **TODO comments are not errors** — complex locators flagged with `// TODO` are valid output; inform the user they need manual review.
4. **Feature files are copied as-is** — no Gherkin transformation is applied to `.feature` files during migration (the spec→BDD conversion is only for non-BDD `.spec.*` sources).
5. **BOILERPLATE fill is allowlisted** — `_BOILERPLATE_FILL_ALLOWLIST` in `_migrate.py` lists exactly which boilerplate entries are dropped in (the rest are held back because they assume APIs the enhanced scaffold doesn't expose).
6. **Config merge is non-destructive** — existing settings in Helix configs are never overwritten; only missing settings are added. `--conflict overwrite` refreshes `.mcp.json`, `.env.example`, generated scaffolds, and the cucumber config to pick up tool-side improvements.
7. **Healing scaffold takes precedence over BOILERPLATE** — the enhanced `LocatorHealer.ts` / `LocatorRepository.ts` / `TimingHealer.ts` / `VisualIntentChecker.ts` / `HealingDashboard.ts` are written first by `_scaffold_locator_repository`; the BOILERPLATE versions of the same files are never written.
8. **VRT is opt-in but auto-detected** — passing `--features=vrt` is only required for sources that don't already use VRT. When the source has any `vrt:*` npm script or any `.env*` file mentioning `VRT_MODE`, VRT is enabled automatically. The `.env.example` VRT block, runtime files, npm scripts, and deps only land when this is on.
9. **Orphan cleanup is gated to overwrite** — the auto-removal of stale `.feature` / `.steps.ts` / `.page.ts` / `.locators.ts` in their role dirs runs only when `--conflict overwrite` is set. `interactive` and `skip` leave every existing file in place. Operators with manual additions in role dirs should not use `--conflict overwrite` unless those files are reproducible from source.
