| name | vibe-e2e |
| description | Generates and runs full end-to-end UI tests using Playwright against a live URL. Auto-installs Playwright and Chromium if not present — isolated in e2e/ folder, never pollutes app dependencies. Reads SPEC.md and FEATURES.md to automatically generate test flows covering every critical user journey. Runs tests with video recording on failure and HTML report. Two-device flows (poster + joiner, sender + receiver) use parallel browser contexts. OAuth flows are skipped with clear warnings. Generates a cleanup script that deletes all test data after the suite runs. e2e: regenerate rewrites all tests from current SPEC.md. Triggers: "e2e: https://url" to run against a live URL, "e2e: regenerate" to rewrite tests from spec.
|
Vibe E2E Skill
Generates Playwright E2E tests from your SPEC.md and FEATURES.md.
Runs them against a live URL. Records video on failure.
Cleans up test data after every run.
Triggers
| Trigger | What it does |
|---|
e2e: https://url | Generate tests (if none exist) + run against URL |
e2e: run https://url | Run existing tests against URL without regenerating |
e2e: regenerate | Rewrite all test files from current SPEC.md + FEATURES.md |
e2e: cleanup https://url | Run cleanup script only — delete test data |
Step 0 — Install Playwright if missing
Before anything else, check if Playwright is available and install if not.
Install into an isolated e2e/ folder — never touch the app's package.json.
ls e2e/node_modules/@playwright/test 2>/dev/null && echo "INSTALLED" || echo "NOT_INSTALLED"
If not installed:
mkdir -p e2e
ls yarn.lock 2>/dev/null && echo "yarn" || ls pnpm-lock.yaml 2>/dev/null && echo "pnpm" || echo "npm"
cd e2e
npm init -y
npm install -D @playwright/test
yarn init -y
yarn add -D @playwright/test
pnpm init
pnpm add -D @playwright/test
npx playwright install chromium --with-deps
If project is Python-only (no package.json anywhere):
pip install playwright
playwright install chromium
Tell the user: "Installing Playwright into e2e/ — this takes ~30 seconds for the first install."
Step 1 — Read project context
cat vibe/SPEC.md 2>/dev/null
cat vibe/FEATURES.md 2>/dev/null || cat FEATURES.md 2>/dev/null
cat vibe/ARCHITECTURE.md 2>/dev/null
cat BRIEF.md 2>/dev/null
Extract from these files:
- Every user-facing feature
- Acceptance criteria (from SPEC.md) — these become assertions
- Auth method — email/password, OAuth providers, phone OTP
- Multi-user flows — any flow requiring two users simultaneously
- Admin-gated features
- Time-sensitive behaviour (e.g. "ride appears within 10 seconds")
- API endpoints used by the frontend (for cleanup script)
Step 2 — Generate flow list
Before writing any test code, show the user the flows Claude has identified.
Group by domain. Number sequentially.
Flag multi-device flows and OAuth flows explicitly.
Flows detected from SPEC.md + FEATURES.md:
AUTH
01 · Register → complete profile → land on home [single device]
02 · Login with email and password [single device]
03 · Login with Google OAuth [⚠️ SKIP — OAuth]
04 · Login with Apple OAuth [⚠️ SKIP — OAuth]
05 · Forgot password flow [single device]
CORE
06 · Post a ride → appears in discover feed within 10s [single device]
07 · Discover → filter by vibe tag → request to join [single device]
08 · Poster approves join → joiner sees approval [TWO DEVICES]
09 · Approved joiner opens group chat → messages appear [TWO DEVICES]
TRIBE
10 · Send tribe invite → other user accepts [TWO DEVICES]
11 · Direct message between tribe members [TWO DEVICES]
ROUTES
12 · Browse routes → filter → tap route → Plan a Ride [single device]
13 · Submit a route → appears in feed [single device]
ADMIN
14 · Admin: paste URL → Gemini extracts → publish route [single device]
PROFILE
15 · Edit profile → change vibe tags → verify saved [single device]
16 · Post-ride rating → poster profile shows rating [TWO DEVICES]
──────────────────────────────────────────────
Total: 16 flows · 4 skipped (OAuth) · 6 two-device · 6 single-device
Generating Playwright tests for 12 active flows...
Wait briefly (2-3 seconds) then proceed with generation — no need for user confirmation unless the flow list looks wrong.
Step 3 — Generate file structure
e2e/
├── package.json
├── playwright.config.ts
├── tests/
│ ├── 01-auth.spec.ts
│ ├── 02-core-flow.spec.ts
│ ├── 03-tribe.spec.ts
│ ├── 04-routes.spec.ts
│ ├── 05-admin.spec.ts
│ ├── 06-profile.spec.ts
│ └── helpers/
│ ├── auth.ts ← login/register helpers
│ ├── fixtures.ts ← test data with timestamps
│ ├── selectors.ts ← centralised UI selectors
│ └── cleanup.ts ← delete all test data via API
├── cleanup.ts ← standalone cleanup script
├── .gitignore
└── test-results/ ← generated on run, gitignored
Step 4 — Generate each file
e2e/package.json
{
"name": "[project]-e2e",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"report": "playwright show-report e2e/report",
"cleanup": "npx ts-node cleanup.ts"
},
"devDependencies": {
"@playwright/test": "^1.44.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0"
}
}
e2e/playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 45000,
expect: { timeout: 10000 },
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 1 : 2,
fullyParallel: false,
use: {
baseURL: process.env.BASE_URL,
headless: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
actionTimeout: 15000,
navigationTimeout: 30000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
reporter: [
['list'],
['html', { outputFolder: 'report', open: 'never' }],
['json', { outputFile: 'report/results.json' }],
],
outputDir: 'test-results',
})
e2e/tests/helpers/fixtures.ts
Generate timestamps so every test run creates unique data and never conflicts:
const RUN_ID = Date.now()
export const USER_A = {
email: `qa-a-${RUN_ID}@e2e-test.invalid`,
password: 'E2eTest123!',
name: `QA Rider A ${RUN_ID}`,
bike: 'Royal Enfield Classic 350',
city: 'Pune',
}
export const USER_B = {
email: `qa-b-${RUN_ID}@e2e-test.invalid`,
password: 'E2eTest123!',
name: `QA Rider B ${RUN_ID}`,
bike: 'KTM Duke 390',
city: 'Pune',
}
export const TEST_RIDE = {
destination: `E2E Test Ride ${RUN_ID}`,
date: (() => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
})(),
maxRiders: 3,
}
export const TEST_ROUTE = {
name: `E2E Test Route ${RUN_ID}`,
distance: '45km',
duration: '2h',
startLocation: 'Pune',
}
export const CREATED: {
userIds: string[]
rideIds: string[]
routeIds: string[]
} = {
userIds: [],
rideIds: [],
routeIds: [],
}
e2e/tests/helpers/auth.ts
import { Page } from '@playwright/test'
import { USER_A, USER_B, CREATED } from './fixtures'
export async function register(page: Page, user = USER_A) {
await page.goto('/register')
await page.getByLabel(/email/i).fill(user.email)
await page.getByLabel(/password/i).first().fill(user.password)
await page.getByRole('button', { name: /continue|next|sign up/i }).click()
await page.getByLabel(/display name|name/i).fill(user.name)
const bikeField = page.getByLabel(/bike/i)
if (await bikeField.isVisible()) await bikeField.fill(user.bike)
const cityField = page.getByLabel(/city/i)
if (await cityField.isVisible()) await cityField.fill(user.city)
await page.getByRole('button', { name: /create|finish|done/i }).click()
await page.waitForURL(/discover|home|dashboard/, { timeout: 15000 })
const cookies = await page.context().cookies()
const token = cookies.find(c => c.name === 'auth-token' || c.name === 'token')
if (token) {
try {
const payload = JSON.parse(atob(token.value.split('.')[1]))
if (payload.sub || payload.user_id || payload.id) {
CREATED.userIds.push(payload.sub || payload.user_id || payload.id)
}
} catch {}
}
}
export async function login(page: Page, user = USER_A) {
await page.goto('/login')
await page.getByLabel(/email/i).fill(user.email)
await page.getByLabel(/password/i).fill(user.password)
await page.getByRole('button', { name: /log in|sign in|continue/i }).click()
await page.waitForURL(/discover|home|dashboard/, { timeout: 15000 })
}
export async function registerAndLogin(page: Page, user = USER_A) {
await register(page, user)
}
e2e/tests/helpers/cleanup.ts
import { APIRequestContext } from '@playwright/test'
import { CREATED } from './fixtures'
export async function cleanupTestData(request: APIRequestContext, baseURL: string, adminToken?: string) {
const headers = adminToken
? { Authorization: `Bearer ${adminToken}` }
: {}
console.log(`\nCleaning up test data...`)
for (const rideId of CREATED.rideIds) {
try {
await request.delete(`${baseURL}/api/rides/${rideId}`, { headers })
console.log(` ✓ Deleted ride ${rideId}`)
} catch (e) {
console.log(` ⚠ Could not delete ride ${rideId}: ${e}`)
}
}
for (const routeId of CREATED.routeIds) {
try {
await request.delete(`${baseURL}/api/routes/${routeId}`, { headers })
console.log(` ✓ Deleted route ${routeId}`)
} catch (e) {
console.log(` ⚠ Could not delete route ${routeId}: ${e}`)
}
}
for (const userId of CREATED.userIds) {
try {
await request.delete(`${baseURL}/api/users/${userId}`, { headers })
console.log(` ✓ Deleted user ${userId}`)
} catch (e) {
console.log(` ⚠ Could not delete user ${userId}: ${e}`)
}
}
console.log(`Cleanup complete.\n`)
}
e2e/cleanup.ts (standalone script)
import { chromium } from '@playwright/test'
import { CREATED } from './tests/helpers/fixtures'
import { cleanupTestData } from './tests/helpers/cleanup'
const BASE_URL = process.env.BASE_URL
if (!BASE_URL) {
console.error('ERROR: BASE_URL environment variable required')
console.error('Usage: BASE_URL=https://your-app.railway.app npm run cleanup')
process.exit(1)
}
;(async () => {
const browser = await chromium.launch()
const context = await browser.newContext()
const request = context.request
await cleanupTestData(request, BASE_URL)
await browser.close()
})()
Test specs — one per domain
Generate each spec fully from the actual flow list detected in Step 2.
Every assertion comes directly from acceptance criteria in SPEC.md.
Pattern for single-device tests:
import { test, expect, request } from '@playwright/test'
import { register, login } from './helpers/auth'
import { USER_A, USER_B } from './fixtures'
import { cleanupTestData } from './helpers/cleanup'
const BASE_URL = process.env.BASE_URL!
test.afterAll(async () => {
const ctx = await request.newContext()
await cleanupTestData(ctx, BASE_URL)
})
test.describe('Authentication', () => {
test('01 · register → complete profile → land on discover', async ({ page }) => {
await register(page, USER_A)
await expect(page).toHaveURL(/discover/)
await expect(page.getByText(/upcoming rides|discover/i)).toBeVisible()
})
test('02 · login with email and password', async ({ page }) => {
await register(page, USER_B)
await page.goto('/logout')
await login(page, USER_B)
await expect(page).toHaveURL(/discover/)
})
test.skip('03 · login with Google OAuth — skipped: requires real OAuth credentials', async ({ page }) => {
})
test.skip('04 · login with Apple OAuth — skipped: requires real Apple credentials', async ({ page }) => {
})
})
Pattern for two-device flows (parallel browser contexts):
test('08 · poster approves join → joiner sees approval notification', async ({ browser }) => {
const posterCtx = await browser.newContext()
const joinerCtx = await browser.newContext()
const posterPage = await posterCtx.newPage()
const joinerPage = await joinerCtx.newPage()
try {
await register(posterPage, USER_A)
await register(joinerPage, USER_B)
await posterPage.goto('/post-ride')
await posterPage.getByLabel(/destination/i).fill(TEST_RIDE.destination)
await posterPage.getByRole('button', { name: /post|create/i }).click()
await expect(posterPage.getByText(TEST_RIDE.destination)).toBeVisible()
await joinerPage.goto('/discover')
await joinerPage.getByText(TEST_RIDE.destination).click()
await joinerPage.getByRole('button', { name: /request to join/i }).click()
await expect(joinerPage.getByText(/request sent|pending/i)).toBeVisible()
await posterPage.goto('/my-rides')
await posterPage.getByText(/pending request|join request/i).first().click()
await posterPage.getByRole('button', { name: /approve|accept/i }).click()
await joinerPage.reload()
await expect(joinerPage.getByText(/approved|open chat/i)).toBeVisible({ timeout: 15000 })
} finally {
await posterCtx.close()
await joinerCtx.close()
}
})
Pattern for time-sensitive assertions (from SPEC acceptance criteria):
test('06 · post a ride → appears in discover feed within 10 seconds', async ({ page }) => {
await login(page, USER_A)
await page.goto('/post-ride')
await page.getByLabel(/destination/i).fill(TEST_RIDE.destination)
await page.getByLabel(/date/i).fill(TEST_RIDE.date)
await page.getByRole('button', { name: /post|create/i }).click()
await page.goto('/discover')
await expect(
page.getByText(TEST_RIDE.destination)
).toBeVisible({ timeout: 10000 })
})
e2e/.gitignore
node_modules/
test-results/
report/
*.webm
*.zip
playwright-report/
Step 5 — Run the suite
cd e2e && BASE_URL=[url] npx playwright test --project=chromium 2>&1
Stream output to terminal in real time as tests run.
After run completes, automatically run cleanup:
cd e2e && BASE_URL=[url] npx ts-node cleanup.ts
Step 6 — Report results
Present a clean summary after every run:
E2E REPORT — [Project Name]
URL: [url]
Run: [timestamp]
──────────────────────────────────────────────
01 auth · Register → profile → discover ✅ 4.2s
02 auth · Login email/password ✅ 1.8s
03 auth · Google OAuth ⚠️ SKIPPED
04 auth · Apple OAuth ⚠️ SKIPPED
05 core · Post ride → discover in 10s ✅ 7.1s
06 core · Discover → filter → join request ✅ 5.4s
07 core · Approve join → notification ❌ FAILED 8.2s
08 core · Group chat send/receive ⏭ SKIPPED (depends on 07)
09 tribe · Send invite → accept ✅ 6.8s
10 tribe · Direct message ✅ 9.3s
11 routes · Browse → filter → plan ride ✅ 3.9s
12 routes · Submit route → appears in feed ✅ 4.1s
13 admin · URL → Gemini → publish ✅ 11.2s
14 profile · Edit profile → verify saved ✅ 2.3s
──────────────────────────────────────────────
12 passed · 1 failed · 2 skipped (OAuth) · 1 skipped (dependency)
FAILURES
07 · core · Approve join → joiner notification
Step: joiner reloads page → expected "approved" text
Got: "pending" still showing
Likely cause: real-time update not propagating — WebSocket or polling issue
Video: e2e/test-results/07-core-approve/video.webm
Trace: e2e/test-results/07-core-approve/trace.zip
Open trace: npx playwright show-trace e2e/test-results/07-core-approve/trace.zip
OAUTH WARNINGS (manual testing required)
03 · Google OAuth — test manually: tap "Sign in with Google" → complete flow → verify redirect
04 · Apple OAuth — test manually: tap "Sign in with Apple" → complete flow → verify redirect
CLEANUP
✓ Deleted 2 test rides
✓ Deleted 2 test users
Cleanup complete.
VERDICT: ❌ 1 failure — fix before deploy
HTML report: e2e/report/index.html
Step 7 — e2e: regenerate
Rewrites all spec files from current SPEC.md + FEATURES.md.
Preserves helpers/ (auth, fixtures, cleanup, selectors) — only rewrites spec files.
Show new flow list first:
Regenerating from SPEC.md + FEATURES.md...
New flows detected (current SPEC):
[updated flow list]
Changes from previous run:
+ Flow 17 added: Weather badge on discover
~ Flow 06 updated: ride now appears in 5s (was 10s per updated SPEC)
- Flow 14 removed: Admin scraper UI (moved to separate admin suite)
Rewriting spec files... (helpers preserved)
✅ 6 spec files rewritten
Step 8 — Update vibe docs
After a successful run, log to TASKS.md:
✅ E2E suite passed — [date]
URL: [url]
Flows: [N] passed · [N] failed · [N] skipped
Report: e2e/report/index.html
If failures:
⚠️ E2E suite — [N] failures — [date]
URL: [url]
Failed: [flow names]
Videos: e2e/test-results/
Absolute rules
OAuth flows are always skipped — never attempt to automate them.
Google and Apple OAuth involve third-party login screens that actively
block automation. Mark them as test.skip() with a manual testing note.
Never try to interact with Google or Apple login screens.
Test data uses .invalid domain emails.
The .invalid TLD is reserved and guaranteed to never deliver email.
Use qa-[role]-[timestamp]@[project]-test.invalid — never a real domain.
Cleanup always runs after the suite.
Even if tests fail. Wrap cleanup in afterAll hooks AND run the
standalone cleanup script. Failing tests still create data on the server.
Two-device tests always use browser.newContext().
Never share auth state between USER_A and USER_B in the same test.
Each context is a fresh, independent browser — simulates two real devices.
Always close both contexts in a finally block.
Selectors prefer accessible roles over CSS.
Use getByRole, getByLabel, getByText before locator('.class').
This makes tests more resilient to UI changes and works across frameworks.
Time-sensitive assertions use exact timeouts from SPEC.
If SPEC says "within 10 seconds" — toBeVisible({ timeout: 10000 }).
Don't use arbitrary timeouts — they hide real performance regressions.
Never hardcode the base URL.
Always read from process.env.BASE_URL.
Never fallback to localhost in production test runs.
Video and trace only on failure.
video: 'retain-on-failure' — don't record passing tests.
Videos and traces are large. Only keep them when they're needed.