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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
71
tests/smoke/example.spec.ts
Normal file
71
tests/smoke/example.spec.ts
Normal 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
65
tests/smoke/helpers.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user