Files
Saxon Fletcher bb02df5f69 Update Cursor rules structure (#41338)
* update and expand cursor rules structure

* rule copy

* rule updates
2026-01-20 09:25:29 +10:00

8.2 KiB

description, globs, alwaysApply
description globs alwaysApply
Testing: Playwright E2E best practices for Studio tests (avoid flake + race conditions)
e2e/studio/**/*.ts
e2e/studio/**/*.spec.ts
false

E2E Testing Best Practices

Getting Context

Before writing or modifying tests, use the Playwright MCP to understand:

  • Available page elements and their roles/locators
  • Current page state and network activity
  • Existing test patterns in the codebase

Avoid extensive code reading - let Playwright's inspection tools guide your understanding of the UI.

Avoiding Race Conditions

Set up API waiters BEFORE triggering actions

The most common source of flaky tests is race conditions between UI actions and API calls. Always create response waiters before clicking buttons or navigating.

// ❌ Bad - race condition: response might complete before waiter is set up
await page.getByRole('button', { name: 'Save' }).click()
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')

// ✅ Good - waiter is ready before action
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await apiPromise

Use createApiResponseWaiter for pre-navigation waits

When you need to wait for a response that happens during navigation:

// ✅ Good - waiter created before navigation
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise

When an action triggers multiple API calls, wait for all of them:

// ✅ Good - wait for all related API calls
const createTablePromise = waitForApiResponseWithTimeout(page, (response) =>
  response.url().includes('query?key=table-create')
)
const tablesPromise = waitForApiResponseWithTimeout(page, (response) =>
  response.url().includes('tables?include_columns=true')
)
const entitiesPromise = waitForApiResponseWithTimeout(page, (response) =>
  response.url().includes('query?key=entity-types-')
)

await page.getByRole('button', { name: 'Save' }).click()
await Promise.all([createTablePromise, tablesPromise, entitiesPromise])

Waiting Strategies

Prefer Playwright's built-in auto-waiting

Playwright automatically waits for elements to be actionable. Use this instead of manual timeouts:

// ❌ Bad - arbitrary timeout
await page.waitForTimeout(2000)
await page.getByRole('button', { name: 'Submit' }).click()

// ✅ Good - auto-waits for element to be visible and enabled
await page.getByRole('button', { name: 'Submit' }).click()

Use expect.poll for dynamic assertions

When waiting for state to change:

// ✅ Good - polls until condition is met
await expect
  .poll(async () => {
    return await page.getByLabel(`View ${tableName}`).count()
  })
  .toBe(0)

Use waitForSelector with state for element lifecycle

// ✅ Good - wait for panel to close
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })

Avoid networkidle - use specific API waits instead

// ❌ Bad - unreliable and slow
await page.waitForLoadState('networkidle')

// ✅ Good - wait for specific API response
await waitForApiResponse(page, 'pg-meta', ref, 'tables')

Use timeouts sparingly and only for non-API waits

// ✅ Acceptable - waiting for client-side debounce
await page.getByRole('textbox').fill('search term')
await page.waitForTimeout(300) // Allow debounce to complete

// ✅ Acceptable - waiting for clipboard API
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)

Test Structure

Use the custom test utility

Always import from the custom test utility for consistent fixtures:

import { test } from '../utils/test.js'

Use withFileOnceSetup for expensive setup

When setup is expensive (cleanup, seeding), run it once per file:

test.beforeAll(async ({ browser, ref }) => {
  await withFileOnceSetup(import.meta.url, async () => {
    const ctx = await browser.newContext()
    const page = await ctx.newPage()

    // Expensive setup logic (e.g., cleanup old test data)
    await deleteTestTables(page, ref)
  })
})

test.afterAll(async () => {
  await releaseFileOnceCleanup(import.meta.url)
})

Dismiss toasts before interacting with UI

Toasts can overlay buttons and block interactions:

const dismissToastsIfAny = async (page: Page) => {
  const closeButtons = page.getByRole('button', { name: 'Close toast' })
  const count = await closeButtons.count()
  for (let i = 0; i < count; i++) {
    await closeButtons.nth(i).click()
  }
}

// ✅ Good - dismiss toasts before clicking
await dismissToastsIfAny(page)
await page.getByRole('button', { name: 'New table' }).click()

Assertions

Always include descriptive messages

// ❌ Bad - no context on failure
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()

// ✅ Good - clear message on failure
await expect(
  page.getByRole('button', { name: 'Save' }),
  'Save button should be visible after form is filled'
).toBeVisible()

Use appropriate timeouts for slow operations

// ✅ Good - explicit timeout for slow operations
await expect(
  page.getByText(`Table ${tableName} is good to go!`),
  'Success toast should be visible after table creation'
).toBeVisible({ timeout: 50000 })

Locators

Prefer role-based locators

// ✅ Good - semantic and resilient
page.getByRole('button', { name: 'Save' })
page.getByRole('textbox', { name: 'Username' })
page.getByRole('menuitem', { name: 'Delete' })

// ❌ Avoid - brittle CSS selectors
page.locator('.btn-primary')
page.locator('#submit-button')

Use test IDs for complex elements

// ✅ Good - stable identifier for complex elements
page.getByTestId('table-editor-side-panel')
page.getByTestId('action-bar-save-row')

Use filter for finding elements in context

// ✅ Good - find button within specific row
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
await bucketRow.getByRole('button').click()

Helper Functions

Extract reusable operations into helpers

Create helper functions for common operations:

// e2e/studio/utils/storage-helpers.ts
export const createBucket = async (
  page: Page,
  ref: string,
  bucketName: string,
  isPublic: boolean = false
) => {
  await navigateToStorageFiles(page, ref)

  // Check if already exists
  const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
  if ((await bucketRow.count()) > 0) return

  await dismissToastsIfAny(page)

  // Create bucket with proper waits
  const apiPromise = waitForApiResponse(page, 'storage', ref, 'bucket', { method: 'POST' })
  await page.getByRole('button', { name: 'New bucket' }).click()
  await page.getByRole('textbox', { name: 'Bucket name' }).fill(bucketName)
  await page.getByRole('button', { name: 'Create' }).click()
  await apiPromise

  await expect(
    page.getByRole('row').filter({ hasText: bucketName }),
    `Bucket ${bucketName} should be visible`
  ).toBeVisible()
}

Use the existing wait utilities

import {
  createApiResponseWaiter,
  waitForApiResponse,
  waitForGridDataToLoad,
  waitForTableToLoad,
} from '../utils/wait-for-response.js'

API Mocking

Mock APIs for isolated testing

// ✅ Good - mock API response
await page.route('*/**/logs.all*', async (route) => {
  await route.fulfill({ body: JSON.stringify(mockAPILogs) })
})

Use soft waits for optional API calls

// ✅ Good - don't fail if API doesn't respond
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
  soft: true,
  fallbackWaitMs: 1000,
})

Cleanup

Clean up test data in beforeAll/beforeEach

test.beforeEach(async ({ page, ref }) => {
  await deleteAllBuckets(page, ref)
})

Handle existing state gracefully

// ✅ Good - check before trying to delete
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) === 0) return
// proceed with deletion

Reset local storage when needed

import { resetLocalStorage } from '../utils/reset-local-storage.js'

// Clean up after tests that modify local storage
await resetLocalStorage(page, ref)