Usage Examples
Three worked examples spanning the ng-playwright-* family. Each is grounded
in the actual references/* code shipped with the skill — not an invented
API — so you can trace every snippet below back to its source skill.
1. Adding a data-testid via the typed catalog
Two skills work together here: ng-playwright-testid-catalog owns the
registry that generates typed, collision-checked id constants, and
ng-playwright-testid-attributes owns binding those constants onto the
component template. Say you're adding search and per-row selection to a new
"widgets" feature.
First, the catalog module (ng-playwright-testid-catalog, built with the
shared createTestIdRegistry helper — see
skills/ng-playwright-testid-catalog/references/example-catalog.ts):
// widgets/example-catalog.ts
import { createTestIdRegistry } from './utils'
export const WIDGETS_TEST_IDS = createTestIdRegistry({
list: {
page: 'widgets-list-page',
table: 'widgets-list-table',
searchInput: 'widgets-list-search-input',
// Dynamic per-row id: a function, not a static string, so every row
// gets a distinct, predictable id.
row: (id: string) => `widgets-row-${id}`,
},
})
createTestIdRegistry walks the object at call time and throws on any
duplicate leaf string in dev mode — so a copy-pasted id surfaces immediately
instead of silently colliding with another feature's id in production.
Then the component template binds those constants — never a literal string —
through [attr.data-testid], deriving the data-load-state/aria-busy pair
from the exact signal that drives the render (ng-playwright-testid-attributes
invariants 1–3):
<!-- widgets-list.component.html -->
<table
[attr.data-testid]="testIds.list.table"
[attr.data-load-state]="loadState()"
[attr.aria-busy]="isLoading()"
>
<tbody>
@for (row of rows(); track row.id) {
<tr [attr.data-testid]="testIds.list.row(row.id)">
<td>{{ row.label }}</td>
</tr>
}
</tbody>
</table>
Finally, a Playwright-side mirror module re-exports the identical catalog
object, so specs read WIDGETS_TEST_IDS.list.row(id) and can never drift
from what the app actually renders:
// e2e/widgets/example-catalog.mirror.ts
export { WIDGETS_TEST_IDS } from '../../src/widgets/example-catalog'
2. Writing a Playwright Page Object for a new route
ng-playwright-page-objects defines a stub-first, purely-additive shape: a
new route starts as route + a title locator, fully compilable, and every
later addition is purely additive so specs written against the stub keep
passing. Adding a /items route (see
skills/ng-playwright-page-objects/references/ExampleDataTablePage.ts):
// BEFORE — legitimate first commit for a new route.
export class ItemsListPage extends BasePage {
readonly route = '/items'
get title(): Locator {
return this.page.getByRole('heading', { name: 'Items' })
}
}
Expanding it adds locators (role-based first, per invariant 2 — a
data-testid selector is the fallback only when no reliable role/label
exists), an action method, and a verify* assertion that asserts internally
rather than returning a boolean:
// AFTER — route and title are unchanged, so the stub's contract still holds.
export class ItemsListPage extends BasePage {
readonly route = '/items'
get title(): Locator {
return this.page.getByRole('heading', { name: 'Items' })
}
get searchInput(): Locator {
return this.page.getByRole('textbox', { name: 'Search items' })
}
get rows(): Locator {
return this.page.getByRole('row')
}
async search(query: string): Promise<void> {
await this.searchInput.fill(query)
// A bare waitForTimeout() is a red flag per invariant 3 unless it
// carries an inline justification for the exact budget — this one
// does: it matches this app's known search debounce window, not an
// arbitrary guess. A real suite would prefer the sibling
// ng-playwright-attribute-waits helper over this fallback where
// available.
await this.page.waitForTimeout(400) // 400ms matches the search debounce window
}
async verifyRowVisible(itemName: string): Promise<void> {
await expect(this.rows.filter({ hasText: itemName })).toBeVisible()
}
}
Every concrete page object extends the shared BasePage (route,
constructor(page: Page), a navigate() method) from
skills/ng-playwright-page-objects/references/BasePage.ts, so the
goto-then-settle sequence is defined once, not per page.
3. Waiting on an async-loading component with the readiness helpers
ng-playwright-attribute-waits supplies one canonical helper module
(skills/ng-playwright-attribute-waits/references/readiness.ts) that every
spec and page-object method imports rather than re-deriving its own polling
loop. Consider the debounced search input from
ng-playwright-testid-attributes's own reference component,
async-data-table.component.html, which exposes three search-pipeline
stages (data-search-typed/-settled/-applied) plus a combined
data-search-state.
waitForSearchSettled (readiness.ts lines 71-87) polls
data-search-state, data-search-query, and data-search-loading — not
the data-search-typed/-settled/-applied trio the async data table
component above actually emits. data-search-query and data-search-loading
aren't produced by any component under skills/ today. Calling
waitForSearchSettled against async-data-table.component.html as written
will time out waiting for attributes that don't exist — this is a real
mismatch between the two skills, tracked as a bug, not something papered
over with an invented API below.
The snippet below is honest about that gap: it shows exactly what the helper polls today, so it type-checks against the real implementation, but it does not claim this passes against the async data table shown above:
import { waitForSearchSettled, waitForReady } from './readiness'
test('waits for the search state machine to settle', async ({ page }) => {
const search = page.getByTestId('async-data-table-search')
await search.fill('drill')
// waitForSearchSettled's REAL poll target (readiness.ts lines 78-86) is
// data-search-state / data-search-query / data-search-loading — attributes
// this skill's own async-data-table component does not emit (it emits
// data-search-typed/-settled/-applied/-state instead). See the callout
// above; this call will time out until that gap is closed.
await waitForSearchSettled(search, { state: 'ready', query: 'drill' })
await waitForReady(page.getByTestId('async-data-table'))
})
For a repeatable destructive action — refreshing the item-details modal from
ng-playwright-testid-attributes's async-details-modal.component.html,
say — pair waitForEpochAdvance with a structural assertion rather than
trusting a terminal state string alone, since a string like "ready" can
repeat identically across two separate action cycles. The modal is the
component that actually carries data-refresh-epoch:
import { waitForEpochAdvance, waitForReady } from './readiness'
test('refreshing the item details modal advances its epoch', async ({ page }) => {
const modal = page.getByTestId('item-details-modal')
const before = Number(await modal.getAttribute('data-refresh-epoch'))
await page.getByRole('button', { name: 'Refresh' }).click()
await waitForEpochAdvance(modal, 'data-refresh-epoch', before)
// Pair the epoch check with its own structural assertion — a repeated
// "ready" load-state string alone can't distinguish this refresh cycle
// from the one before it.
await waitForReady(modal)
})
See Architecture for how these four skills fit together as a distribution pipeline, or the Skills catalog for a page per skill with its full invariant list.