The Three Tracks
Tool, selectors, and config split by target type. The three tracks share the same phase flow, so rather than break them across pages they are gathered here. Which track your SUT belongs to gets settled in /3-analyze-target, and the tool choice lands in e2e-suite-config.json during /4-design-suite.
web Playwright
Section titled “web Playwright”The default tool is Playwright. Cypress is considered only when there is an existing Cypress asset to keep.
The core of the config:
import { defineConfig, devices } from '@playwright/test';export default defineConfig({ testDir: './tests/web', fullyParallel: true, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['blob']] : [['html']], use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173', trace: 'on-first-retry' }, webServer: process.env.E2E_BASE_URL ? undefined : { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, timeout: 120_000, }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'tests/web/.auth/user.json' }, dependencies: ['setup'] }, ],});webServer starts the dev server automatically, and if E2E_BASE_URL is set it uses that URL as is. With trace: 'on-first-retry', a trace is kept only on the first retry, so artifacts do not balloon. The setup project logs in once and saves storageState, and the chromium project reuses it through dependencies: ['setup'].
Selectors go getByRole first, then getByLabel and getByText, with getByTestId as the last resort. CSS and XPath are skipped — they are fragile under DOM changes. Waiting is handled with web-first assertions (await expect(...).toBeVisible()) and waitForResponse; a hard waitForTimeout(n) is the number-one cause of flakiness and is banned.
The POM keeps locators caged inside the class.
export class LoginPage { constructor(private page: Page) {} goto = () => this.page.goto('/login'); login = async (email: string, pw: string) => { await this.page.getByLabel('Email').fill(email); await this.page.getByLabel('Password').fill(pw); await this.page.getByRole('button', { name: /sign in/i }).click(); };}The SUT’s stack brings two traps. If the SUT runs on Supabase, the session lands in localStorage rather than a cookie. In auth.setup.ts, log in over REST (/auth/v1/token?grant_type=password), inject the sb-<ref>-auth-token key with addInitScript, and save to storageState. If the SUT uses Stripe payments, Hosted Checkout (checkout.stripe.com) is cross-origin, so you cannot drive the page directly. Verify payment success through the webhook rather than a click (Stripe CLI stripe listen / stripe trigger), and watch recurring billing through Test Clocks.
electron Playwright _electron
Section titled “electron Playwright _electron”The default is Playwright’s _electron. It fits fast verification during development; when you also need to verify the packaged distribution build, add @wdio/electron-service as an option on top.
launch brings the app up and reaches both the renderer and the main process.
import { test, expect, _electron as electron } from '@playwright/test';
test('app launch + first window', async () => { const app = await electron.launch({ args: ['.'] }); // package.json main entry const win = await app.firstWindow(); // renderer → ordinary Page await expect(win).toHaveTitle(/My App/);
const isPackaged = await app.evaluate(async ({ app }) => app.isPackaged); expect(isPackaged).toBe(false);
await app.close();});What firstWindow() hands back is an ordinary Page, so the renderer is driven like web with getByRole. Main-process code runs inside app.evaluate(({ app, dialog, BrowserWindow, Menu }) => ...).
File dialogs, menus, the tray, and autoUpdater are native OS UI and cannot be automated with clicks. Stub the main-process API or call it directly.
await app.evaluate(({ dialog }, paths) => { dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: paths });}, ['/tmp/a.txt']);Unlike web, the config is fullyParallel: false, workers: 1. Being single-instance, launching in parallel collides. In CI, only the Linux job lacks a display, so it gets wrapped in xvfb-run; mac and windows run as is. The CI branching is detailed in ci.
mobile Maestro by default
Section titled “mobile Maestro by default”The default is Maestro. It carries the lowest setup and maintenance cost and is cross-platform. The tool splits by app type.
| App type | Tool |
|---|---|
| Expo | Maestro (Expo’s official choice) |
| React Native (bare) | Detox (graybox) or Maestro |
| iOS native | XCUITest |
| Android native | Espresso |
| Capacitor/Ionic | Ionic E2E (WDIO + Appium, context switching) |
| General/system flows | Appium |
Maestro flows are declarative .yml.
appId: com.example.app---- launchApp: { clearState: true }- tapOn: { id: "email_field" } # testID first (steadier than text)- tapOn: "Sign in"- assertVisible: "Welcome"- runFlow: subflows/logged-in-check.ymlRun it with maestro test tests/mobile/flows/login.yml, and author it with the help of maestro studio (visual) or maestro record (recording). Auto-wait and retries are built in, so no hard sleeps. Selectors go testID first; text-based ones are fragile under i18n.
Pick Detox on a bare RN app and graybox auto-synchronization waits on the network, animations, and RN thread idle to cut flakiness.
await element(by.text('Login')).tap();await expect(element(by.label('Welcome'))).toBeVisible();A hybrid like Capacitor/Ionic brings a webview context into play, and hardcoding the context ID breaks because it shifts from run to run. Query it with getContextHandles() and switch.
Auth is log-in-once-then-reuse on all three tracks, but the mechanics differ. mobile handles it through a pre-login subflow (Maestro runFlow) or Detox beforeAll. The strategy decision lives in analyze-and-design.
analyze-and-design comes next. How /3-analyze-target draws out the SUT and the critical flows, how /4-design-suite pins the tools, auth, environment, and CI into e2e-suite-config.json, and which decisions earn an ADR.