marathon-ralph/skills/write-playwright-test/SKILL.md
Write Playwright E2E tests using fixtures and best practices. Use when creating E2E tests, writing browser automation tests, or testing user flows.
npx skillsauth add gruckion/chief-wiggum-anthropic-agent-harness write-playwright-testInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Write end-to-end tests using Playwright fixtures, Page Object Model, and accessibility-first queries.
Follow Kent C. Dodds' testing philosophy:
"The more your tests resemble the way your software is used, the more confidence they can give you."
Use queries in this order of preference:
// Buttons, links, headings
await page.getByRole('button', { name: /submit/i })
await page.getByRole('link', { name: /home/i })
await page.getByRole('heading', { level: 1 })
// Form elements
await page.getByRole('textbox', { name: /email/i })
await page.getByRole('checkbox', { name: /remember me/i })
await page.getByRole('combobox', { name: /country/i })
// With state
await page.getByRole('checkbox', { checked: true })
await page.getByRole('tab', { selected: true })
await page.getByLabel(/email address/i)
await page.getByLabel(/password/i)
await page.getByLabel(/phone number/i)
await page.getByText(/welcome back/i)
await page.getByText('Exact Match')
await page.getByPlaceholder('Search...')
// Only when no accessible alternative exists
await page.getByTestId('complex-widget')
// tests/e2e/fixtures/test-fixtures.ts
import { test as base } from '@playwright/test'
type MyFixtures = {
testUser: { email: string; password: string }
}
export const test = base.extend<MyFixtures>({
testUser: async ({}, use) => {
// Setup: Create test data
const user = {
email: `test-${Date.now()}@example.com`,
password: 'TestPassword123!',
}
// Provide to test
await use(user)
// Teardown: Cleanup (runs after test)
// await deleteUser(user.email)
},
})
export { expect } from '@playwright/test'
// tests/e2e/fixtures/auth-fixtures.ts
import { test as base, type Page } from '@playwright/test'
type AuthFixtures = {
authenticatedPage: Page
}
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Login before test
await page.goto('/login')
await page.getByLabel(/email/i).fill('[email protected]')
await page.getByLabel(/password/i).fill('password123')
await page.getByRole('button', { name: /sign in/i }).click()
await page.waitForURL('/dashboard')
// Provide authenticated page
await use(page)
// Logout after test (optional)
// await page.goto('/logout')
},
})
// tests/e2e/fixtures/db-fixtures.ts
import { test as base } from '@playwright/test'
type DbFixtures = {
insertTestData: (data: TestData) => Promise<string>
}
export const test = base.extend<DbFixtures>({
insertTestData: async ({ request }, use) => {
const createdIds: string[] = []
// Provide factory function
await use(async (data) => {
const response = await request.post('/api/test-data', { data })
const { id } = await response.json()
createdIds.push(id)
return id
})
// Cleanup all created data
for (const id of createdIds) {
await request.delete(`/api/test-data/${id}`)
}
},
})
// tests/e2e/fixtures/worker-fixtures.ts
import { test as base } from '@playwright/test'
type WorkerFixtures = {
sharedAccount: { username: string; token: string }
}
export const test = base.extend<{}, WorkerFixtures>({
sharedAccount: [
async ({ browser }, use, workerInfo) => {
// Create unique account per worker
const username = `worker-${workerInfo.workerIndex}`
// Setup runs once per worker
const page = await browser.newPage()
await page.goto('/signup')
// ... create account ...
await page.close()
await use({ username, token: 'token' })
// Teardown when worker shuts down
// await deleteAccount(username)
},
{ scope: 'worker' },
],
})
// tests/e2e/fixtures/index.ts
import { mergeTests } from '@playwright/test'
import { test as authTest } from './auth-fixtures'
import { test as dbTest } from './db-fixtures'
import { test as pageTest } from './page-fixtures'
export const test = mergeTests(authTest, dbTest, pageTest)
export { expect } from '@playwright/test'
// tests/e2e/pages/checkout.page.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class CheckoutPage {
readonly page: Page
// Locators
readonly cartItems: Locator
readonly subtotal: Locator
readonly checkoutButton: Locator
readonly promoCodeInput: Locator
readonly applyPromoButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.cartItems = page.getByRole('list', { name: /cart items/i })
this.subtotal = page.getByTestId('subtotal')
this.checkoutButton = page.getByRole('button', { name: /checkout/i })
this.promoCodeInput = page.getByLabel(/promo code/i)
this.applyPromoButton = page.getByRole('button', { name: /apply/i })
this.errorMessage = page.getByRole('alert')
}
async goto() {
await this.page.goto('/checkout')
}
async applyPromoCode(code: string) {
await this.promoCodeInput.fill(code)
await this.applyPromoButton.click()
}
async proceedToCheckout() {
await this.checkoutButton.click()
}
async expectItemCount(count: number) {
await expect(this.cartItems.getByRole('listitem')).toHaveCount(count)
}
async expectSubtotal(amount: string) {
await expect(this.subtotal).toHaveText(amount)
}
}
// tests/e2e/fixtures/page-fixtures.ts
import { test as base } from '@playwright/test'
import { CheckoutPage } from '../pages/checkout.page'
import { LoginPage } from '../pages/login.page'
type PageFixtures = {
checkoutPage: CheckoutPage
loginPage: LoginPage
}
export const test = base.extend<PageFixtures>({
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page))
},
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
})
// tests/e2e/checkout.spec.ts
import { test, expect } from './fixtures'
test.describe('Checkout Flow', () => {
test('user can complete purchase', async ({ page, checkoutPage }) => {
// Given: User has items in cart
await checkoutPage.goto()
await checkoutPage.expectItemCount(2)
// When: User proceeds to checkout
await checkoutPage.proceedToCheckout()
// Then: User sees payment form
await expect(page).toHaveURL('/payment')
await expect(
page.getByRole('heading', { name: /payment/i })
).toBeVisible()
})
test('user can apply valid promo code', async ({ checkoutPage }) => {
await checkoutPage.goto()
await checkoutPage.applyPromoCode('SAVE20')
await expect(
checkoutPage.page.getByText(/20% discount applied/i)
).toBeVisible()
})
test('user sees error for invalid promo code', async ({ checkoutPage }) => {
await checkoutPage.goto()
await checkoutPage.applyPromoCode('INVALID')
await expect(checkoutPage.errorMessage).toHaveText(/invalid promo code/i)
})
})
import { test, expect } from './fixtures'
test.describe('Dashboard', () => {
test('authenticated user sees dashboard', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
await expect(
authenticatedPage.getByRole('heading', { name: /dashboard/i })
).toBeVisible()
await expect(
authenticatedPage.getByText(/welcome back/i)
).toBeVisible()
})
})
import { test, expect } from './fixtures'
test.describe('Products', () => {
test('user can view product details', async ({ page, insertTestData }) => {
// Create test product (auto-cleaned up after test)
const productId = await insertTestData({
name: 'Test Product',
price: 99.99,
})
await page.goto(`/products/${productId}`)
await expect(page.getByRole('heading')).toHaveText('Test Product')
await expect(page.getByText('$99.99')).toBeVisible()
})
})
Always use web-first assertions that auto-wait:
// GOOD - Auto-waits and retries
await expect(page.getByText('Success')).toBeVisible()
await expect(page.getByRole('button')).toBeEnabled()
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveTitle(/Dashboard/)
// BAD - Manual check, no retry
const isVisible = await page.getByText('Success').isVisible()
expect(isVisible).toBe(true)
// Visibility
await expect(locator).toBeVisible()
await expect(locator).toBeHidden()
// Text content
await expect(locator).toHaveText('exact text')
await expect(locator).toContainText('partial')
// Attributes
await expect(locator).toHaveAttribute('href', '/path')
await expect(locator).toHaveClass(/active/)
// Form state
await expect(locator).toBeEnabled()
await expect(locator).toBeDisabled()
await expect(locator).toBeChecked()
await expect(locator).toHaveValue('input value')
// Count
await expect(locator).toHaveCount(5)
// Page
await expect(page).toHaveURL('/path')
await expect(page).toHaveTitle('Page Title')
Continue test even if assertion fails:
test('multiple checks', async ({ page }) => {
await page.goto('/dashboard')
// Soft assertions don't stop the test
await expect.soft(page.getByTestId('status')).toHaveText('Active')
await expect.soft(page.getByTestId('count')).toHaveText('10')
// Test continues even if above fail
await page.getByRole('link', { name: /settings/i }).click()
})
// Wait for specific API response
await page.goto('/dashboard')
await page.waitForResponse((response) =>
response.url().includes('/api/data') && response.status() === 200
)
const fileInput = page.getByLabel(/upload file/i)
await fileInput.setInputFiles('./test-data/file.pdf')
page.on('dialog', (dialog) => dialog.accept())
await page.getByRole('button', { name: /delete/i }).click()
await page.goto('/dashboard')
await page.screenshot({ path: 'dashboard.png' })
page.locator('.class-name') - prefer role/label queriespage.waitForTimeout(1000) - use web-first assertionstests/e2e/
├── fixtures/
│ ├── index.ts # Combined fixtures export
│ ├── auth-fixtures.ts # Authentication
│ ├── db-fixtures.ts # Database operations
│ └── page-fixtures.ts # Page objects
├── pages/
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ └── checkout.page.ts
├── auth.setup.ts # Auth setup project
├── auth.spec.ts # Auth tests (one login flow)
├── dashboard.spec.ts
├── checkout.spec.ts
└── search.spec.ts
user can complete checkout not test1tools
# SQLite Skill for better-t-stack ## Overview SQLite database implementation using LibSQL client and Drizzle ORM. This skill covers local development, Turso cloud, and Cloudflare D1 deployments. --- ## CRITICAL WARNING **NEVER use `bun:sqlite` with Next.js applications.** Next.js runs on Node.js, not Bun runtime. Using `bun:sqlite` causes: ``` Cannot find module 'bun:sqlite' ``` **Always use `@libsql/client`** - it works in both Node.js and Bun environments. --- ## Library Stack | Pack
development
Visually verify implemented features work correctly before marking complete. Use when testing UI changes, verifying web features, or checking user flows work in the browser.
tools
Programmatically update marathon-ralph state file using deterministic jq commands. Use this instead of manually editing the JSON file.
development
Configure Vitest for unit and integration testing. Use when setting up a test framework, when no test runner is detected, or when the user asks to configure testing.