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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-06 09:39:20 +01:00
parent fd34df8db0
commit efb233b18f
6 changed files with 168 additions and 17 deletions

View File

@@ -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

View File

@@ -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/<slug>.spec.ts
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
# 3. Or use the MCP Playwright tool directly in your session to drive the browser
# e.g. navigate to localhost:<N>, open Cmd+K, verify command appears, etc.
# 3. Or run all smoke tests
BASE_URL="http://localhost:<N>" pnpm playwright:smoke
kill $DEV_PID
```

View File

@@ -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"
},

View File

@@ -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,
},
})

View File

@@ -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()
})
})

65
tests/smoke/helpers.ts Normal file
View File

@@ -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<void> {
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<void> {
await page.keyboard.press('Escape')
await expect(page.locator(COMMAND_INPUT)).not.toBeVisible()
}
export async function findCommand(
page: Page,
name: string,
): Promise<boolean> {
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<void> {
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<void> {
await expect(page.locator(selector).first()).toBeVisible()
}
export async function verifyFocusable(
page: Page,
selector: string,
): Promise<void> {
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<void> {
const combo = [...modifiers, key].join('+')
await page.keyboard.press(combo)
}