---
name: generate-playwright-code
description: >-
  Use when generating Playwright TypeScript automation code from Gherkin feature files or ADO
  work items. Triggers on: "Playwright", "page object", "locators", "step definitions",
  "automation", "self-healing", "TimingHealer", "VisualIntentChecker", "locator repository".
  Reads existing files before generating to prevent duplicates. Works with PBI, Bug, and
  Feature work item types.
license: MIT
compatibility: "Requires qa-playwright-generator, qa-test-case-manager, qa-gherkin-generator MCP servers and Playwright MCP running at localhost:8931 for live locator capture."
metadata:
  author: qa-gentic
  version: "1.1"
---

# Generate Playwright Code Skill

> **Read `AGENT-BEHAVIOR.md` before this skill.** The behavior rules there override any
> inference. This skill provides the step-by-step workflow only.

Generate production-quality, three-layer self-healing Playwright TypeScript automation from a
validated Gherkin feature file, using live Playwright MCP snapshots for verified locators.
Works with any ADO work item type: PBI, Bug, or Feature ID.

---

## Three Healing Layers

Every generated page object implements three layers of self-healing:

| Layer | Class | What it heals |
|---|---|---|
| 1 — Locator | `LocatorHealer` | primary selector → role → label → text → AI Vision → CDPSession AX tree |
| 2 — Timing | `TimingHealer` | network-trace drift → auto-adjusted timeouts → HealingDashboard |
| 3 — Visual | `VisualIntentChecker` | element screenshot diff at assertions → HealingDashboard |

Healed selectors are persisted in `LocatorRepository` — zero overhead on repeat runs.
HealingDashboard runs at `http://localhost:7890` during test execution for approve/reject.

---

## ⛔ Mandatory Pre-Flight: Deduplication Protocol

Before generating or attaching any file, run the deduplication protocol
(see the `deduplication-protocol` skill).

The protocol is **work-item-scoped**: PHASE 1 runs once per `work_item_id` and results are
cached. If `generate-gherkin` already ran for this `work_item_id`, skip PHASE 1 and read from
`CACHE[work_item_id]` directly. A different `work_item_id` always triggers a fresh PHASE 1.

---

## Step-by-Step Workflow

### Step 0 — Route: fresh project vs. existing boilerplate

Before any other step, determine whether the Helix-QA project already has the automation
infrastructure in place.

```
Call: qa-helix-writer:list_helix_tree(helix_root)
         │
         ├─ framework_state: "absent" or "partial"  →  FRESH PROJECT
         │        Call scaffold_locator_repository(...)  FIRST
         │        Then call generate_playwright_code(...)
         │
         └─ framework_state: "present"  →  EXISTING BOILERPLATE
                  Call generate_playwright_code(...)  ONLY
                  Do NOT call scaffold_locator_repository — infra already exists
```

**Why this matters:** `scaffold_locator_repository` generates the six shared infrastructure
files (`LocatorRepository`, `LocatorHealer`, `TimingHealer`, `VisualIntentChecker`,
`DevToolsHealer`, `cucumber.config.ts`). Calling it on an existing project overwrites those
files and destroys any customisations. Never call it when `framework_state: "present"`.

---

### Step 1 — Load existing Playwright artifacts from cache

Read from `CACHE[work_item_id]` (populated by the deduplication protocol pre-flight):

```
CACHE[work_item_id].existing_attachments.locators_ts   → existing locator keys
CACHE[work_item_id].existing_attachments.page_objects  → existing method names
CACHE[work_item_id].existing_attachments.steps_files   → existing step strings
```

If a file exists but content could not be read, treat it as fully covering its domain and
produce no new file of that type unless you can prove a gap.

---

### Step 2 — GAP CHECK: identify screens you cannot reach and ASK before proceeding

Before navigating the live app, scan the Gherkin feature file for every `Given` step that
requires a specific app state — a state only reachable by performing a real action (uploading
a file, submitting a form, triggering an error, completing a multi-step flow).

For each such step, ask: **can I reach this screen right now with the information I have?**

#### Blockers that require a user question before proceeding

| Blocker | Example | What to ask |
|---|---|---|
| File upload required to reach a screen | "Given I am on the failure summary screen" — only after uploading a CSV with bad rows | "Please provide the CSV file or tell me which emails to use as known duplicates" |
| Specific record / ID required | "Given work item #12345 has linked test cases" | "Which org / record should I test against?" |
| Error state requires specific bad data | "Given the upload is rejected for unsupported format" | "Should I use .txt or .pdf to test the rejection?" |
| Multi-step prerequisite from a sibling PBI | "Given the mass add has completed" | "Should I replay the upload step, or is there a seed/shortcut?" |
| Screen only exists after a backend event | "Given the processing is complete" | "Is there a test endpoint I can hit to seed this state?" |

**If any blocker is found, output a question block and wait for answers.**

#### Question format

```
Before I navigate the live app to capture locators for "<feature title>", I need:

**[Blocker type]: [screen or element name]**
[One sentence describing what the Gherkin step requires]
→ [Specific question — what file, what email, what record, what sequence?]

I will not proceed until these are answered, to avoid inventing selectors
(stability: 0) for screens I cannot reach.
```

**Only proceed to Step 3 once every blocker is resolved, OR the user explicitly grants
sign-off to proceed with known `stability: 0` limitations documented.**

---

### Step 3 — Live locator verification through the FULL flow

Navigate through **every screen in the Gherkin feature** — including state-dependent screens —
using the real test data provided in Step 2. Never snapshot only the landing page and infer.

```
playwright:browser_navigate(APP_BASE_URL)
playwright:browser_snapshot()                    ← login screen

playwright:browser_fill_form(fields: [
  { name: "Email",    type: "textbox", ref: "<ref>", value: "<email>" },
  { name: "Password", type: "textbox", ref: "<ref>", value: "<password>" }
])
playwright:browser_click(ref: "<login-button-ref>")
playwright:browser_snapshot()                    ← dashboard — verify logged in

← Navigate through EVERY prerequisite step to reach state-dependent screens
playwright:browser_click(...)                    ← e.g. "Add Users" button
playwright:browser_click(...)                    ← e.g. "Add Multiple" tab
playwright:browser_snapshot()                    ← capture screen locators

← Upload real test file (from user's Step 2 answer)
playwright:browser_file_upload(
  files: ["<real file path>"],
  element: "file upload input",
  ref: "<ref from snapshot>"
)
playwright:browser_snapshot()                    ← verify file attached, Next enabled

playwright:browser_click(...)                    ← "Next" / Submit
playwright:browser_snapshot()                    ← capture processing screen (if any)
playwright:browser_snapshot()                    ← capture state-dependent result screen
                                                    ← capture ALL interactive elements here

playwright:browser_click(...)                    ← any further actions (Export, Accept, etc.)
playwright:browser_snapshot()                    ← capture post-action state
```

**Rule:** Every locator in the generated `locators.ts` must appear in one of these snapshots.
If a screen cannot be reached because test data is missing, go back to Step 2 and ask — do
not assign `stability: 0` and continue silently.

---

### Step 4 — Build the context_map

From each snapshot, map AX tree nodes to locator entries using this stability rank:

| Selector source | Stability |
|---|---|
| `data-testid` attribute | 100 |
| `aria-role` + accessible name | 90 |
| `id` attribute | 80 |
| `aria-label` | 70 |
| `placeholder` text | 60 |
| Gherkin-inferred (no live verification) | 0 — `⚠ NOT VERIFIED` comment required |

Only include keys NOT already present in the existing `locators.ts` (from Step 1).

**Icon-only controls — prefer `data-testid` over visual/structural classes.** Some
elements have no semantic anchor for the self-healing fallback chain: icon-only
buttons (a gear/cog, kebab `⋮`, trash, pencil), badges, and similar controls with
**no accessible name, no visible text, and no `aria-label`**. For these, the healer's
role/label/text strategies have nothing to match on, and AI Vision is unreliable when
many identical icons appear on one screen (e.g. a per-widget cog on a dashboard of
widgets). So:

- When an element carries a `data-testid`, **always select on the `data-testid`**, even
  if it also has a convenient-looking class. A class like `.editCog` on
  `<button class="btn btn-default dropdown-toggle editCog" data-testid="dashboard-button-widget_settings">`
  is a trap: it reads as a clean selector but gives the healer no semantic signal if it
  ever breaks, and it can match the wrong element. Prefer
  `[data-testid='dashboard-button-widget_settings']` (stability 100).
- **Do not assign a visual/structural class selector (e.g. `.editCog`, `.icon-trash`,
  `:nth-child`) a stability above 30** when the element is icon-only and a `data-testid`
  exists. If no `data-testid` exists, flag it: the locator is fragile and self-healing
  cannot recover it — surface this to the user rather than silently shipping it.
- Rule of thumb: if you cannot describe the element by its role **and** an accessible
  name/text/label, it is unhealable by the heuristic chain — a `data-testid` is required
  for a reliable locator.

**Example context_map (built from Playwright MCP AX snapshots):**
```json
{
  "emailInput":         { "selector": "[data-testid='auth0_login-input-email']",        "intent": "Auth0 login email field",              "stability": 100 },
  "loginButton":        { "selector": "[data-testid='auth0_login-button-login']",       "intent": "Submit login credentials",             "stability": 100 },
  "addUsersButton":     { "selector": "[data-testid='person_header-button-add_users']", "intent": "Add Users button, opens slide-out",    "stability": 100 },
  "addMultipleTab":     { "selector": "[data-testid='person_slider-tab-add_multiple']", "intent": "Add Multiple tab in Add Users panel",  "stability": 100 },
  "fileInput":          { "selector": "input[type='file']",                             "intent": "Hidden file input for CSV upload",     "stability": 70  },
  "nextButton":         { "selector": "button:has-text('Next')",                        "intent": "Proceed after file upload",            "stability": 90  },
  "exportListButton":   { "selector": "button:has-text('Export List')",                 "intent": "Export failed users CSV",              "stability": 90, "visualIntent": true },
  "failureSummaryTable":{ "selector": "table",                                          "intent": "Failed user rows table",               "stability": 80, "visualIntent": true }
}
```

---

### Step 5 — Generate the code

```
qa-playwright-generator:generate_playwright_code(
  gherkin_content          : <validated .feature file content>,
  page_class_name          : "<ScreenName>Page",
  app_name                 : "<AppName>",
  context_map              : <context_map from Step 4>,
  healing_strategy         : "role-label-text-ai",
  enable_timing_healing    : true,
  enable_visual_regression : true
)
```

The response contains `cache_key`, `files_manifest` (list of file paths), and `validation`. File
contents are held server-side to keep them out of conversation history. Retrieve them only when
you are about to attach or write to disk:

```
qa-playwright-generator:get_generated_files(cache_key: "<cache_key from above>")
```

The four generated files are:
- `locators.ts` — selector registry with intent, stability score, visualIntent flag
- `{ScreenName}Page.ts` — three-layer self-healing page object
- `{feature}.steps.ts` — Cucumber step definitions
- `cucumber-profile.js` snippet — profile config to add to `config/cucumber.js`

---

### Step 6 — Apply the deduplication delta filter

Diff generated output against `CACHE[work_item_id].existing_attachments`:

**locators.ts:** Remove keys already in existing file. Empty delta → skip. Non-empty → attach as `locators.delta.ts`:
```typescript
// DELTA — merge these keys into the existing locators.ts
// Generated: <date> | Work item: #<id>
```

**{ScreenName}Page.ts:** Remove methods already in existing page object. Empty delta → skip. Non-empty → attach as `{ScreenName}Page.delta.ts` with merge header.

**{feature}.steps.ts:** Remove any `Given(` / `When(` / `Then(` whose step string already exists. Empty delta → skip. Non-empty → attach only net-new steps.
**CRITICAL: Never re-register an existing step string** — causes `Ambiguous step definition` at runtime.

---

### Step 7 — Delivery gate (mandatory before attaching to ADO)

Before calling `attach_code_to_work_item`, present the following to the user and wait:

```
I've generated the following Playwright TypeScript files for work item #{id}:

  📄 locators.delta.ts      — {N} new locator keys
  📄 {ScreenName}Page.delta.ts — {N} new page methods
  📄 {feature}.steps.delta.ts  — {N} new step definitions

**Do you want me to attach these to ADO work item #{id}? (yes / no)**

Note:
- Saying yes here only attaches the Playwright files.
- It does NOT create manual test cases or attach Gherkin files.
- If you also want those, say so separately.
```

**Do not call `attach_code_to_work_item` until the user replies "yes".**

If user says "yes", first retrieve the file contents then attach:

```
# 1 — Retrieve file contents from cache
qa-playwright-generator:get_generated_files(cache_key: "<cache_key>")

# 2 — Attach delta files only
qa-playwright-generator:attach_code_to_work_item(
  organization_url : "<org_url>",
  project_name     : "<project>",
  work_item_id     : <id>,
  files            : [
    { file_name: "locators.delta.ts",         content: "<from get_generated_files>" },
    { file_name: "{ScreenName}Page.delta.ts",  content: "<from get_generated_files>" },
    { file_name: "{feature}.steps.delta.ts",   content: "<from get_generated_files>" }
  ]
)
```

If user says "no": present the files inline. Offer local download only if user says
"save" or "download". Do not create a local file automatically.

**After attach — STOP. Do not trigger Helix write or any other action automatically.**
Helix-QA disk writes are a separate decision requiring a separate user request.

---

## Generated File Structures

For TypeScript code templates and run commands, see
[references/file-structures.md](references/file-structures.md).

### Prefix-grouped folders (auto-detected)

When the target project already has feature-grouped subfolders under the
role containers — e.g. `src/pages/scweb/member/`, `src/test/steps/scweb/group/`,
`src/locators/report/` — newly-generated files land **inside** the matching
group folder instead of cluttering the parent dir.

The match rule mirrors the migration agent's grouping: take the first
kebab-case segment of the new filename, singularise trailing `s` (so
`members-list.page.ts` looks for `member/` not `members/`), then check
whether that folder already exists under the role's base dir.

| New file | Matching folder | Final path |
|---|---|---|
| `member-edit.page.ts` | `src/pages/<app>/member/` (exists) | `src/pages/<app>/member/member-edit.page.ts` |
| `members-list.locators.ts` | `src/locators/member/` (exists) | `src/locators/member/members-list.locators.ts` |
| `reports-monthly.steps.ts` | `src/test/steps/<app>/report/` (exists) | `src/test/steps/<app>/report/reports-monthly.steps.ts` |
| `dashboard.page.ts` | `src/pages/<app>/dashboard/` (doesn't exist) | `src/pages/<app>/dashboard.page.ts` (flat) |

The detection runs against `helix_project_root` when supplied. When no
matching group folder exists, the file lands flat — same as legacy
behaviour. New groups only get created by the migration agent's grouping
pass (when 2+ flat files share a prefix); generation by itself never
creates a one-file group folder.

### Pages barrel — auto-updated

When a new page object is emitted, the generator also appends a line to
`src/pages/index.ts` so the new class is exported through the barrel.
The shape (`export { default as X }` vs `export { X }`) is detected from
the generated source. Idempotent — re-running emits the same line, which
is deduplicated against the existing barrel content.

This lets the `PageFixture` factory registry stay as a one-import
declaration (`import * as P from "@pages"`) regardless of how many page
objects the project accumulates.

### `fixture()` direct access (no `currentFixture` alias)

Generated code uses `fixture().X` calls directly — no
`const currentFixture = fixture();` cache, no `this.currentFixture`
page-object field. The cucumber World is fetched on demand via the
global-property read inside `fixture()`. Reasoning: `fixture()` is a
one-line read with no measurable overhead; caching it adds three
patterns (local var, instance field, constructor wiring) that have to
stay in sync with the canonical `global.currentFixture`.

---

## Quality Gates — Do Not Attach Without These

- [ ] Deduplication protocol run — existing locator keys, methods, step strings extracted
- [ ] **Gap check (Step 2) completed** — every state-dependent screen identified
- [ ] **All blockers answered by user before any navigation began**
- [ ] **Live snapshots taken of every screen in the full flow** using real test data
- [ ] **Zero `stability: 0` locators** for screens reachable with provided test data
- [ ] Every `stability: 0` locator has `⚠ NOT VERIFIED` comment with explicit user sign-off
- [ ] No step string re-registers an already-registered Given/When/Then
- [ ] Page object methods use `fixture().locatorHealer` — never raw `page.click` / `page.fill`
- [ ] Page objects do NOT declare per-class `_repo`/`_logger`/`_healer`/`_timing`/`_visual` fields or instantiate them in the constructor — the shared instances on the `PageFixture` (`fixture().locatorHealer`, `.locatorTiming`, `.locatorVisual`, `.locatorRepository`) are the single source of truth, set up once per scenario in `hooks.ts` `Before`
- [ ] Step bodies do NOT contain `const _repo = new LocatorRepository(); const _healer = new LocatorHealer(...)` setup blocks — reach the healers via `fixture()` directly (or `const { locatorHealer, locatorTiming } = fixture();` if you reference them more than once)
- [ ] No dead locator captures — never `const x = page.locator(...)` followed by a `fixture().locatorHealer.X(...)` call that uses the raw selector instead of `x`. Either reference the captured variable directly (`await expect(x).toBeVisible()`, `await x.click()`) or drop the capture entirely
- [ ] When a page-object method takes a parameter intended to drive an assertion (e.g. `verifyMessage(expectedText: string)`), the parameter MUST be used by the assertion — apply it via a locator filter (`page.locator(sel, { hasText: expectedText })`) and assert on that filtered locator, not just on the bare selector
- [ ] Credentials sourced from `process.env`, not hardcoded
- [ ] Delta files have merge-instruction comment at top

---

## Locator Stability Reference

| Pattern | Stability | When to use |
|---|---|---|
| `[data-testid='...']` | 100 | Always prefer — survives refactors |
| `role=button[name='...']` | 90 | When data-testid absent |
| `#id` | 80 | When id is stable and meaningful |
| `[aria-label='...']` | 70 | Accessibility attributes |
| `[placeholder='...']` | 60 | Input fields only |
| Text / position selectors | 0 | Last resort — flag for upgrade |

---

## Healable verbs — every common stateful action

The runtime healer (`LocatorHealer.ts`) wraps every common Playwright verb so the chain fires for **all** state-changing interactions, not just clicks and fills. When generating page-object methods, use the heal wrapper that matches your action:

| Playwright call | Heal wrapper | Use for |
|---|---|---|
| `loc.click()` | `clickWithHealing(key, sel, intent)` | buttons, links, generic clicks |
| `loc.fill(v)` | `fillWithHealing(key, sel, v, intent)` | text inputs, textareas |
| `loc.check()` | `checkWithHealing(key, sel, intent)` | checkboxes (set to checked) |
| `loc.uncheck()` | `uncheckWithHealing(key, sel, intent)` | checkboxes (clear) |
| `loc.selectOption(v)` | `selectOptionWithHealing(key, sel, v, intent)` | `<select>`, role=combobox |
| `loc.hover()` | `hoverWithHealing(key, sel, intent)` | tooltip triggers, dropdown reveals |
| `loc.dblclick()` | `dblclickWithHealing(key, sel, intent)` | double-click (open / activate) |
| `loc.focus()` | `focusWithHealing(key, sel, intent)` | move keyboard focus to an element |
| `loc.blur()` | `blurWithHealing(key, sel, intent)` | remove keyboard focus from an element |
| `loc.tap()` | `tapWithHealing(key, sel, intent)` | mobile-style touch tap |
| `loc.press(k)` | `pressWithHealing(key, sel, k, intent)` | keyboard key on a focused element (`Enter`, `Tab`, etc.) |
| `loc.type(t)` / `pressSequentially(t)` | `typeWithHealing(key, sel, t, intent)` | typing-style text entry (per-keystroke) |
| `loc.setInputFiles(f)` | `setInputFilesWithHealing(key, sel, f, intent)` | `<input type=file>` upload |
| `expect(loc).toBeVisible()` | `assertVisibleWithHealing(key, sel, intent)` | visibility assertions |
| `expect(loc).toBeHidden()` / `.not.toBeVisible()` | `assertHiddenWithHealing(key, sel, intent)` | inverse visibility |
| `expect(loc).toBeEnabled()` | `assertEnabledWithHealing(key, sel, intent)` | enabled-state assertions |
| `expect(loc).toBeDisabled()` / `.not.toBeEnabled()` | `assertDisabledWithHealing(key, sel, intent)` | disabled-state assertions |
| `expect(loc).toBeChecked()` | `assertCheckedWithHealing(key, sel, intent)` | checkbox / radio state |
| `expect(loc).not.toBeChecked()` | `assertNotCheckedWithHealing(key, sel, intent)` | inverse of checked |
| `expect(loc).toBeFocused()` | `assertFocusedWithHealing(key, sel, intent)` | element currently has focus |
| `expect(loc).toBeEditable()` | `assertEditableWithHealing(key, sel, intent)` | input/textarea is editable |
| `expect(loc).toBeAttached()` | `assertAttachedWithHealing(key, sel, intent)` | element exists in DOM (not necessarily visible) |
| `expect(loc).toBeInViewport()` | `assertInViewportWithHealing(key, sel, intent)` | element is in the visible scroll region |
| `expect(loc).toHaveValue(v)` | `assertValueWithHealing(key, sel, v, intent)` | input value assertions |
| `expect(loc).toHaveText(v)` | `assertTextWithHealing(key, sel, v, intent)` | text content assertions |
| `expect(loc).toContainText(v)` | `assertContainsTextWithHealing(key, sel, v, intent)` | substring text match |
| `expect(loc).toHaveCount(n)` | `assertCountWithHealing(key, sel, n, intent)` | number of matched elements |
| `expect(loc).toHaveClass(v)` | `assertClassWithHealing(key, sel, v, intent)` | `class` attribute matches (string/regex/list) |
| `expect(loc).toHaveId(v)` | `assertIdWithHealing(key, sel, v, intent)` | `id` attribute matches |
| `expect(loc).toHaveAttribute(n, v)` | `assertAttributeWithHealing(key, sel, n, v, intent)` | arbitrary attribute (n = name, v = expected value) |
| `expect(loc).toHaveCSS(n, v)` | `assertCSSWithHealing(key, sel, n, v, intent)` | computed CSS property |

**Negated forms** (`.not.toBe*` / `.not.toHave*`): for matchers with a natural antonym (`toBeVisible`↔`toBeHidden`, `toBeEnabled`↔`toBeDisabled`), use the antonym's positive method. For every other matcher, use `assertNot<Verb>WithHealing` — e.g. `expect(loc).not.toHaveText(v)` → `assertNotTextWithHealing(key, sel, v, intent)`. The full set of `assertNot*` methods lives on `LocatorHealer.ts` alongside the positive forms.

**Always prefer the heal wrapper over the raw Playwright call.** The wrapper routes through the full chain (cached → primary → attribute → type-hint → role → label → text → AI Vision), so a single ID rename in a checkbox or dropdown won't break the test.

## iframe support

When the target element lives inside an iframe, scope the healer to that frame using `LocatorHealer.forFrame(...)`. This is the **one place** a page object holds its own healer instance — because the frame scope is unique to that page. The default (page-level) chain still comes from `fixture().locatorHealer`.

```typescript
import { Page } from "@playwright/test";
import { LocatorHealer } from "@utils/locators/LocatorHealer";
import { fixture } from "@hooks/pageFixture";
import { PaymentPageLocators } from "./locators";

export class PaymentPage {
  private readonly loc = PaymentPageLocators;
  // Per-frame healer — scoped to the Stripe iframe. This is the EXCEPTION
  // to the "no per-class healer fields" rule: a FrameLocator-scoped healer
  // can't be shared with other pages because the frame target differs.
  private readonly cardFrameHealer: LocatorHealer;

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

  async fillCardNumber(cc: string): Promise<void> {
    // Selector resolves INSIDE the iframe; heal chain still runs.
    await this.cardFrameHealer.fillWithHealing(
      "paymentPage.cardNumber", this.loc.cardNumber.selector, cc, this.loc.cardNumber.intent,
    );
  }

  async submitPayment(): Promise<void> {
    // Page-level actions still use the shared healer from the fixture.
    await fixture().locatorHealer.clickWithHealing(
      "paymentPage.submitButton", this.loc.submitButton.selector, this.loc.submitButton.intent,
    );
  }
}
```

The chain's locator queries (`locator`, `getByRole`, `getByLabel`, `getByText`) route through the FrameLocator. URL / dialog / navigation operations still use the top-level `page`.

## New-tab clicks (`target="_blank"`, `window.open(...)`)

`clickAndAwaitNavigation` won't work here — a new-tab click does NOT change the origin page's URL or fire networkidle on it. The new tab arrives via the BrowserContext's `page` event. Use the companion helper:

```typescript
import { clickAndAwaitNewTab } from "@utils/locators/clickAndAwaitNavigation";

async openInvoiceInNewTab(): Promise<Page> {
  const link = this.page.locator(this.Elements.openInvoiceLink.selector)
                        .locator("visible=true").first();
  const newTab = await clickAndAwaitNewTab(
    this.page.context(),  // BrowserContext — popups attach here, not to page
    this.page,            // origin page (for dialog handler scope)
    link,
    { timeoutMs: 10_000, waitForLoad: true },
  );
  return newTab;  // caller assigns to a new POM, asserts URL, etc.
}
```

Detection signals for emitting `clickAndAwaitNewTab` vs. `clickAndAwaitNavigation`:

| Signal | Helper |
|---|---|
| Target is `<a target="_blank">` | `clickAndAwaitNewTab` |
| Click triggers `window.open(...)` | `clickAndAwaitNewTab` |
| Method/step name contains: "newTab", "newWindow", "popup", "open in" | `clickAndAwaitNewTab` |
| Anything else with same-tab navigation | `clickAndAwaitNavigation` |

The new tab is a separate `Page` object — instantiate a new page-object class against it if you need POM methods, or assert against it directly.

## Click-then-navigate patterns (MANDATORY for navigating actions)

A class of step-failure that bit SCWeb in CI even after the heal chain was hardened:

```
✓ click action done
- waiting for scheduled navigations to finish      ← hangs 30s
```

OR (with `noWaitAfter` bypassing the auto-wait):

```
✓ click step passes
✖ Then user navigated away — URL still /edit/      ← click "succeeded" but URL never changed
```

When an action is **click-then-navigate** — Cancel, Back, Discard, Save-and-leave, Logout, View-this-record, anything that fires an `<a href="...">` or a form submit — emit code that uses the **`clickAndAwaitNavigation` helper** instead of a bare `.click()` + manual URL wait. The helper bundles four landmines:

1. Strict-mode multi-match on a CSS union — caller passes `.first()` / `.locator("visible=true").first()`; helper doesn't widen.
2. Playwright's `.click()` auto-wait for `load` hanging the step on slow servers — bypassed with `noWaitAfter: true`.
3. `beforeunload` / `confirm()` prompts blocking navigation — persistent `page.on("dialog", accept)` handler scoped via `try/finally`.
4. `Promise.race` rejecting on first rejection — each signal wrapped in `.catch() => "timeout"`; race resolves on first success regardless of others timing out. **The helper throws if every signal returns `"timeout"`** so a silent-but-broken click is surfaced loudly.

### When to use it

| Detection signal | Action |
|---|---|
| Method name contains: `cancel`, `back`, `discard`, `leave`, `exit`, `abort`, `logout`, `viewProfile` | Use `clickAndAwaitNavigation` |
| Target element is `<a href="…">` AND next assertion checks URL | Use `clickAndAwaitNavigation` |
| Step text contains: "navigate away from", "redirected to", "lands on" | The assertion side should `waitForURL` with a generous timeout (IO_TIMEOUT, 60 s) before checking — see "Then-step contract" below |

### Code template

```typescript
import { clickAndAwaitNavigation } from "@utils/locators/clickAndAwaitNavigation";

async clickCancel(): Promise<void> {
  // If the Cancel anchor points back at the SAME record, extract the
  // UID from the current URL and target that href specifically — bare
  // `a[href*='/view/']` matches breadcrumbs / recently-viewed strips
  // too and picks the wrong link.
  const editUidMatch = this.page.url().match(/\/(?:members|orders|invoices)\/edit\/(\d+)/);
  const editUid = editUidMatch?.[1] ?? null;
  const cancelButton = editUid
    ? this.page.locator(`a[href$='/view/${editUid}']`).locator("visible=true").first()
    : this.page.locator(cancelButtonLocators.selector).locator("visible=true").first();

  await clickAndAwaitNavigation(this.page, cancelButton, {
    awayFromUrl: /\/edit\//,           // expect to leave /edit/
    timeoutMs:   CommonTimeouts.DEFAULT_TIMEOUT,
    acceptDialogs: true,               // auto-accept beforeunload prompts
  });
}
```

### Then-step contract

Assertion steps that verify navigation occurred must **actively wait** for the URL change rather than calling `waitForLoadState("domcontentloaded")` (which is a no-op when the page is already loaded — exactly the scenario where the navigation hasn't completed yet):

```typescript
Then("user should navigate away from edit page", async function () {
  // Generous IO_TIMEOUT (60s) so slow CI runners can ride out server
  // redirects. Locally this returns in <1s; on a slow runner it
  // patiently waits until either navigation completes or the budget
  // expires. The expect.not.toContain below produces a clearer error
  // than waitForURL's deep stack trace, so we catch and fall through.
  try {
    await page.waitForURL(u => !String(u).includes("/edit/"),
                          { timeout: CommonTimeouts.IO_TIMEOUT });
  } catch { /* fall through to assertion */ }
  await page.waitForLoadState("domcontentloaded");
  expect(page.url()).not.toContain("/edit/");
});
```

### Cancel-button locator union

When generating the Cancel anchor's primary, **DO NOT** emit a bare href-substring like `a[href*='/view/']`. Header profile menus, breadcrumbs, and recently-viewed strips match too. Instead emit:

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

The `:has-text('Cancel')` segment goes FIRST so a `.first()` chain picks it; the broad href fallback is LAST. The page object's UID extraction overrides the union entirely when the current URL matches `/edit/<uid>`.

---

## Explicit Delivery Rules — No Inference

| Decision | Rule |
|---|---|
| Generate Playwright code | Always: show file summary and delta counts first |
| Attach to ADO | Requires explicit "yes" at Step 7 gate |
| User says "no" at Step 7 | Present files inline; offer local download only if user asks |
| Local file creation | Only if user says "save" or "download" |
| Helix-QA disk write | Must be a separate user request — not triggered by ADO attachment |
| Test case creation | Must be a separate user request — not triggered by this skill |
| Gherkin attachment | Must be a separate user request — not triggered by this skill |
| Prior Gherkin "yes" | Has NO bearing on Playwright attachment decision |
| Prior test case "no" | Has NO bearing on Playwright attachment decision |

**Each artifact delivery is independent. A yes/no for one does not answer any other.**
