From qa-stlc-agents
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-stlc-agents:generate-playwright-codeThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> **Read `AGENT-BEHAVIOR.md` before this skill.** The behavior rules there override any
Read
AGENT-BEHAVIOR.mdbefore 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.
Every conversation turn re-sends and re-caches the entire context, so large blobs left in the conversation (DOM snapshots, full generated files, re-read files) are billed on every subsequent turn — this is the single biggest cost driver. Follow these rules:
One feature per session. Start a fresh session for each work item. Long multi-feature sessions accumulate snapshots/files that get re-cached every turn.
Never keep raw DOM snapshots in the conversation. A browser_snapshot AX tree is large
and transient. The instant a snapshot returns, extract only the locator entries you need
into the running context_map (Step 4), then move on — do not re-quote or summarise a
prior snapshot in later turns. Treat each snapshot as write-once → extract → discard.
Persist the capture, then split sessions for big features. When a flow needs many screens,
run capture as its own short session that ends by writing a compact
recording/<feature>.context.json artefact (the context_map only — selectors, intent,
stability; no raw DOM). Then start a fresh code-generation session that reads that small
JSON and never touches the browser. This keeps the ~hundreds-of-k-token DOM capture out of
the generation session entirely.
Compact any recording. If you have a recording.md/transcript, reduce it to just the
fields the generator needs (URLs, step→element mapping, selectors) before feeding it in.
Never paste a full recording or transcript into the generation prompt.
Keep generated file content server-side — use cache_key, never inline.
generate_playwright_code already returns only cache_key + files_manifest (contents are
cached server-side). Do not call get_generated_files to pull contents into the
conversation unless you are about to attach to ADO. To write to the project on disk, pass the
cache_key straight to qa-helix-writer:write_helix_files(cache_key=...) — the MCP server
reads the cache and writes the files; the large TS never enters the conversation or output.
Read each existing file at most once. Step 1 loads existing artifacts once into
CACHE[work_item_id]. Reference that cache afterwards — do not re-read the same file later in
the session.
Target: a focused single-feature generation session (input = a compact context artefact, no live browser navigation in-session) should run well under $2.
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.
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.
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 five shared infrastructure
files (LocatorHealer.ts, LocatorRepository.ts, TimingHealer.ts, VisualIntentChecker.ts,
HealingDashboard.ts). The CDPSession AX-tree fallback runs inside LocatorHealer at runtime —
it is not a separate scaffolded file. Calling scaffold on an existing project overwrites those
files and destroys any customisations. Never call it when framework_state: "present".
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.
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?
| 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.
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.
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.
Cost rule (see Cost discipline §2): after each browser_snapshot, immediately extract the
locator entries you need into the context_map (Step 4) and then drop the raw snapshot from
your working set. Do not re-quote a previous snapshot's AX tree in later turns — extracting the
few selectors you need and discarding the rest is what keeps cache-read cost flat instead of
growing with every screen.
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:
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)..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.data-testid is required
for a reliable locator.Example context_map (built from Playwright MCP AX snapshots):
{
"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 }
}
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 — and out of every
subsequent turn's re-cached context. Keep them there.
get_generated_files. Pass the cache_key straight through:
qa-helix-writer:write_helix_files(helix_root: "<root>", cache_key: "<cache_key from above>").
The helix-writer reads the cache and writes the files itself, so the large TS never enters the
conversation or your output tokens.get_generated_files when you must hand raw content to a tool that requires it
inline (i.e. ADO attach_code_to_work_item in Step 7). Even then, pull it as late as possible
and in a single turn — never re-quote it afterwards.# disk write — preferred, content stays server-side:
qa-helix-writer:write_helix_files(helix_root: "<root>", cache_key: "<cache_key from above>")
# only if you must attach to ADO (Step 7):
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 definitionscucumber-profile.js snippet — profile config to add to config/cucumber.jsDiff 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:
// 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.
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.
For TypeScript code templates and run commands, see references/file-structures.md.
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.
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.
stability: 0 locators for screens reachable with provided test datastability: 0 locator has ⚠ NOT VERIFIED comment with explicit user sign-offfixture().locatorHealer — never raw page.click / page.fill_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 Beforeconst _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)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 entirelyverifyMessage(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 selectorprocess.env, not hardcoded| 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 |
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.
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.
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.
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:
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.
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:
.first() / .locator("visible=true").first(); helper doesn't widen..click() auto-wait for load hanging the step on slow servers — bypassed with noWaitAfter: true.beforeunload / confirm() prompts blocking navigation — persistent page.on("dialog", accept) handler scoped via try/finally.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.| 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 |
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
});
}
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):
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/");
});
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:
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>.
| 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.
Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub qa-gentic/stlc-agents --plugin qa-stlc-agents