From efb233b18f53226b0d44c548d5b721a1b408dbff Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Mar 2026 09:39:20 +0100 Subject: [PATCH] feat: add Playwright smoke test infrastructure for task-scoped QA Adds headless Chromium smoke tests that run before push, catching UI/UX bugs before Brian QA. Includes shared helpers for command palette and keyboard shortcut testing. Co-Authored-By: Claude Opus 4.6 --- .husky/pre-push | 29 ++++++++++----- CLAUDE.md | 6 ++-- package.json | 1 + playwright.config.ts | 13 ++++--- tests/smoke/example.spec.ts | 71 +++++++++++++++++++++++++++++++++++++ tests/smoke/helpers.ts | 65 +++++++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 tests/smoke/example.spec.ts create mode 100644 tests/smoke/helpers.ts diff --git a/.husky/pre-push b/.husky/pre-push index 9a7f4322..77b1be2e 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -47,26 +47,26 @@ fi # ── 0. TypeScript + Vite build ────────────────────────────────────────── echo "" -echo "📦 [0/4] TypeScript + Vite build..." +echo "📦 [0/5] TypeScript + Vite build..." pnpm exec tsc --noEmit pnpm build echo " ✅ Build OK" # ── 1. Frontend coverage (≥70%) — includes all unit tests ─────────────── echo "" -echo "📊 [1/4] Frontend tests + coverage (≥70%)..." +echo "📊 [1/5] Frontend tests + coverage (≥70%)..." pnpm test:coverage --silent echo " ✅ Frontend coverage OK" # ── 2. Rust lint (clippy + fmt) — fast, run before coverage ───────────── echo "" if [ "$RUST_CHANGED" = true ]; then - echo "🔧 [2/4] Clippy + rustfmt..." + echo "🔧 [2/5] Clippy + rustfmt..." cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check echo " ✅ Rust lint OK" else - echo "⏭️ [2/4] Rust lint — skipped (no src-tauri/ changes)" + echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)" fi # ── 3. Rust coverage (≥85% lines) ────────────────────────────────────── @@ -75,9 +75,9 @@ if [ "$RUST_CHANGED" = true ]; then LLVM_COV_FLAGS="--no-clean" if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then LLVM_COV_FLAGS="" - echo "🦀 [3/4] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..." + echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..." else - echo "🦀 [3/4] Rust coverage (≥85%, incremental)..." + echo "🦀 [3/5] Rust coverage (≥85%, incremental)..." fi # Unset GIT_DIR so git tests create isolated repos without inheriting hook context unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE @@ -90,12 +90,23 @@ if [ "$RUST_CHANGED" = true ]; then -- --test-threads=1 echo " ✅ Rust coverage OK" else - echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)" + echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)" fi -# ── 4. CodeScene code health gate (≥9.2) ──────────────────────────────── +# ── 4. Playwright smoke tests (if any exist) ────────────────────────── echo "" -echo "🏥 [4/4] CodeScene code health gate (≥9.2)..." +SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1) +if [ -n "$SMOKE_FILES" ]; then + echo "🎭 [4/5] Playwright smoke tests..." + pnpm playwright:smoke + echo " ✅ Smoke tests OK" +else + echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)" +fi + +# ── 5. CodeScene code health gate (≥9.2) ──────────────────────────────── +echo "" +echo "🏥 [5/5] CodeScene code health gate (≥9.2)..." if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping" else diff --git a/CLAUDE.md b/CLAUDE.md index 56feb576..36e55f18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,10 @@ DEV_PID=$! sleep 3 # wait for vite to be ready # 2. Run Playwright smoke test for this task -npx playwright test --headed=false tests/smoke/.spec.ts +BASE_URL="http://localhost:" npx playwright test tests/smoke/.spec.ts -# 3. Or use the MCP Playwright tool directly in your session to drive the browser -# e.g. navigate to localhost:, open Cmd+K, verify command appears, etc. +# 3. Or run all smoke tests +BASE_URL="http://localhost:" pnpm playwright:smoke kill $DEV_PID ``` diff --git a/package.json b/package.json index ff82259a..2385012a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "playwright:smoke": "playwright test tests/smoke/", "test:coverage": "vitest run --coverage", "prepare": "husky" }, diff --git a/playwright.config.ts b/playwright.config.ts index 344dec11..86b08894 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,15 +1,18 @@ import { defineConfig } from '@playwright/test' export default defineConfig({ - testDir: './e2e', - timeout: 30000, + testDir: './tests/smoke', + timeout: 15_000, + retries: 0, workers: 1, use: { - baseURL: 'http://localhost:5173', + baseURL: process.env.BASE_URL || 'http://localhost:5201', + headless: true, }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], webServer: { - command: 'pnpm dev', - port: 5173, + command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`, + url: process.env.BASE_URL || 'http://localhost:5201', reuseExistingServer: true, }, }) diff --git a/tests/smoke/example.spec.ts b/tests/smoke/example.spec.ts new file mode 100644 index 00000000..fc849167 --- /dev/null +++ b/tests/smoke/example.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test' +import { + openCommandPalette, + closeCommandPalette, + findCommand, + sendShortcut, + verifyVisible, +} from './helpers' + +test.describe('Command Palette smoke tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('Cmd+K opens the command palette', async ({ page }) => { + await openCommandPalette(page) + await verifyVisible(page, 'input[placeholder="Type a command..."]') + }) + + test('Escape closes the command palette', async ({ page }) => { + await openCommandPalette(page) + await closeCommandPalette(page) + await expect( + page.locator('input[placeholder="Type a command..."]'), + ).not.toBeVisible() + }) + + test('typing filters the command list', async ({ page }) => { + await openCommandPalette(page) + const found = await findCommand(page, 'settings') + expect(found).toBe(true) + }) + + test('arrow keys navigate commands', async ({ page }) => { + await openCommandPalette(page) + const first = await page + .locator('[data-selected="true"]') + .first() + .textContent() + await page.keyboard.press('ArrowDown') + const second = await page + .locator('[data-selected="true"]') + .first() + .textContent() + expect(first).not.toBe(second) + }) +}) + +test.describe('Keyboard shortcuts smoke tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('Cmd+P opens quick open palette', async ({ page }) => { + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + await expect( + page.locator('input[placeholder="Search notes..."]'), + ).toBeVisible() + }) + + test('Escape closes command palette after Cmd+K', async ({ page }) => { + await openCommandPalette(page) + await page.keyboard.press('Escape') + await expect( + page.locator('input[placeholder="Type a command..."]'), + ).not.toBeVisible() + }) +}) diff --git a/tests/smoke/helpers.ts b/tests/smoke/helpers.ts new file mode 100644 index 00000000..65ef0281 --- /dev/null +++ b/tests/smoke/helpers.ts @@ -0,0 +1,65 @@ +import { type Page, expect } from '@playwright/test' + +const COMMAND_INPUT = 'input[placeholder="Type a command..."]' + +export async function openCommandPalette(page: Page): Promise { + await page.locator('body').click() + await page.keyboard.press('Control+k') + await expect(page.locator(COMMAND_INPUT)).toBeVisible() +} + +export async function closeCommandPalette(page: Page): Promise { + await page.keyboard.press('Escape') + await expect(page.locator(COMMAND_INPUT)).not.toBeVisible() +} + +export async function findCommand( + page: Page, + name: string, +): Promise { + await page.locator(COMMAND_INPUT).fill(name) + const match = page.locator('[data-selected="true"]').first() + try { + await match.waitFor({ timeout: 2_000 }) + const text = await match.textContent() + return text?.toLowerCase().includes(name.toLowerCase()) ?? false + } catch { + return false + } +} + +export async function executeCommand( + page: Page, + name: string, +): Promise { + await page.locator(COMMAND_INPUT).fill(name) + const match = page.locator('[data-selected="true"]').first() + await match.waitFor({ timeout: 2_000 }) + await page.keyboard.press('Enter') +} + +export async function verifyVisible( + page: Page, + selector: string, +): Promise { + await expect(page.locator(selector).first()).toBeVisible() +} + +export async function verifyFocusable( + page: Page, + selector: string, +): Promise { + const el = page.locator(selector).first() + await expect(el).toBeVisible() + await el.focus() + await expect(el).toBeFocused() +} + +export async function sendShortcut( + page: Page, + key: string, + modifiers: Array<'Meta' | 'Control' | 'Shift' | 'Alt'> = [], +): Promise { + const combo = [...modifiers, key].join('+') + await page.keyboard.press(combo) +}