diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 11f3ec4..961eb6c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -35,7 +35,8 @@ pnpm dev # http://localhost:3000 pnpm build pnpm lint pnpm typecheck -pnpm test +pnpm test # Vitest: logic and component tests (jsdom, no Web Audio) +pnpm test:e2e # Playwright: the production build in real Chrome ``` -CI runs lint, typecheck, test, and build on every PR. +CI runs lint, typecheck, test, build, and the browser smoke tests on every PR. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cdf25c..e48b4bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,17 @@ jobs: - name: Build run: pnpm build + + - name: Install Chrome for browser tests + run: pnpm exec playwright install --with-deps chromium + + - name: Browser smoke test + run: pnpm test:e2e + + - name: Upload browser test traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index d70bb9c..caa7a74 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules dist .DS_Store *.local +test-results +playwright-report diff --git a/README.md b/README.md index 9a4a57e..e04d3d9 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ Its step row, knobs, firing color, and share-link field follow automatically. | `pnpm build` | Production build | | `pnpm lint` | ESLint | | `pnpm typecheck` | TypeScript | -| `pnpm test` | Vitest | +| `pnpm test` | Vitest unit and component tests | +| `pnpm test:e2e` | Playwright smoke tests of the production build in Chrome | ## Credits diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..d5190c4 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,75 @@ +import { expect, test, type Page } from '@playwright/test' + +/** Page errors and console errors seen while the test runs. */ +function collectErrors(page: Page): string[] { + const errors: string[] = [] + page.on('pageerror', (e) => errors.push(e.message)) + page.on('console', (m) => { + if (m.type() === 'error') errors.push(m.text()) + }) + return errors +} + +const pressedSteps = (page: Page, row: string) => + page.locator(`button[aria-label^="${row} step "][aria-pressed="true"]`).evaluateAll((keys) => + keys.map((k) => Number(k.getAttribute('aria-label')!.split(' ').pop())), + ) + +test('opens on the first pattern without errors', async ({ page }) => { + const errors = collectErrors(page) + await page.goto('/') + await expect(page.getByRole('radio', { name: 'Deep Night' })).toHaveAttribute('aria-checked', 'true') + await expect(page.getByLabel('Tempo in BPM')).toHaveValue('122') + expect(errors).toEqual([]) +}) + +test('plays, moves the playhead, and stops', async ({ page }) => { + const errors = collectErrors(page) + await page.goto('/') + await page.getByRole('button', { name: 'Start' }).click() + await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible() + + const playhead = page.locator('[aria-current="step"]').first() + await expect(playhead).toBeVisible() + const first = await playhead.getAttribute('aria-label') + await expect.poll(() => playhead.getAttribute('aria-label')).not.toBe(first) + + await page.getByRole('button', { name: 'Stop' }).click() + await expect(page.getByRole('button', { name: 'Start' })).toBeVisible() + await expect(page.locator('[aria-current="step"]')).toHaveCount(0) + expect(errors).toEqual([]) +}) + +test('switches patterns and keeps them through a reload', async ({ page }) => { + await page.goto('/') + await page.getByRole('radio', { name: 'Jack Box' }).click() + await expect(page.getByLabel('Tempo in BPM')).toHaveValue('124') + // The share link trails edits by a moment. + await expect(page).toHaveURL(/[?&]s=2~124~/) + await page.reload() + await expect(page.getByRole('radio', { name: 'Jack Box' })).toHaveAttribute('aria-checked', 'true') +}) + +test('loads share links from every format', async ({ page }) => { + // Previous app, Jul–Dec 2025: steps plus one-digit knobs. + await page.goto('/?s=8888080880a2_000500') + expect(await pressedSteps(page, 'KICK')).toEqual([1, 9, 11, 15]) + + // Previous app, Dec 2025 on: steps, knobs, tempo. + await page.goto('/?s=8888080880a2_cc0000cc3300cc00ff_098') + expect(await pressedSteps(page, 'HI-HAT')).toEqual([1, 5, 9, 13]) + await expect(page.getByLabel('Tempo in BPM')).toHaveValue('098') + + // Current format. + await page.goto('/?s=2~124~hihat.2222.990099~snare.0808.cc3380~kick.8888.e60066~syn.db6d.044880.b.16.s.9f.0') + await expect(page.getByRole('radio', { name: 'French Filter' })).toHaveAttribute('aria-checked', 'true') +}) + +test('undo steps back a change', async ({ page }) => { + await page.goto('/') + const step = page.getByRole('button', { name: 'KICK step 2', exact: true }) + await step.click() + await expect(step).toHaveAttribute('aria-pressed', 'true') + await page.keyboard.press('ControlOrMeta+Z') + await expect(step).toHaveAttribute('aria-pressed', 'false') +}) diff --git a/index.html b/index.html index 05a8d70..177989e 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,10 @@ LM-919 · Loop Machine +
diff --git a/package.json b/package.json index 8e00092..f69b603 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint src", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "react": "^19.3.0", @@ -17,8 +18,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.63.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.2.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4d04a3e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Browser smoke tests: the production build in real Chrome, where Web Audio + * and layout exist (unlike jsdom). CI runs them after the unit tests. + */ +export default defineConfig({ + testDir: 'e2e', + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://localhost:4173', + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + // Audio starts from the Start click, a user gesture, but CI has no speakers to wake. + use: { ...devices['Desktop Chrome'], launchOptions: { args: ['--autoplay-policy=no-user-gesture-required'] } }, + }, + ], + webServer: { + command: 'pnpm build && pnpm preview --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 751b23e..e4a4e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,12 +18,18 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -498,6 +504,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -637,6 +648,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1641,6 +1658,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2498,6 +2525,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.53.3': @@ -2598,6 +2629,10 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -3802,6 +3837,12 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + possible-typed-array-names@1.1.0: {} postcss@8.5.6: diff --git a/src/test/setup.ts b/src/test/setup.ts index a9d0dd3..f2b0349 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,6 @@ import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +// Testing Library only unmounts between tests on its own when Vitest globals are on; they're off here. +afterEach(cleanup) diff --git a/src/ui/TempoControl.test.tsx b/src/ui/TempoControl.test.tsx new file mode 100644 index 0000000..b6870d0 --- /dev/null +++ b/src/ui/TempoControl.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { TempoControl } from './TempoControl' + +describe('TempoControl', () => { + it('applies a typed tempo on Enter', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + await user.click(screen.getByLabelText('Tempo in BPM')) + await user.keyboard('130{Enter}') + expect(onChange).toHaveBeenCalledWith(130) + }) + + it('drops a typed tempo on Escape', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const input = screen.getByLabelText('Tempo in BPM') + await user.click(input) + await user.keyboard('150{Escape}') + expect(onChange).not.toHaveBeenCalled() + expect(input).toHaveValue('120') + }) +}) diff --git a/src/ui/controls/Knob.test.tsx b/src/ui/controls/Knob.test.tsx new file mode 100644 index 0000000..55b2a6e --- /dev/null +++ b/src/ui/controls/Knob.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { Knob } from './Knob' + +function Harness() { + const [value, setValue] = useState(0.5) + return +} + +const notch = () => Number(screen.getByRole('slider', { name: 'Test knob' }).getAttribute('aria-valuenow')) +const wheel = (init: WheelEventInit) => fireEvent(screen.getByRole('slider', { name: 'Test knob' }), new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init })) + +describe('Knob wheel', () => { + it('turns one notch per mouse-wheel click', () => { + render() + wheel({ deltaY: 100 }) + expect(notch()).toBe(4) + wheel({ deltaY: -100 }) + wheel({ deltaY: -100 }) + expect(notch()).toBe(6) + }) + + it('adds up small trackpad deltas instead of jumping a notch per event', () => { + render() + // 30 px of scrolling stays put; reaching 40 px turns one notch. + for (let i = 0; i < 6; i++) wheel({ deltaY: 5 }) + expect(notch()).toBe(5) + for (let i = 0; i < 2; i++) wheel({ deltaY: 5 }) + expect(notch()).toBe(4) + }) + + it('ignores sideways swipes', () => { + render() + wheel({ deltaX: 80, deltaY: 0 }) + expect(notch()).toBe(5) + }) +}) diff --git a/src/ui/controls/LedButton.test.tsx b/src/ui/controls/LedButton.test.tsx new file mode 100644 index 0000000..5672427 --- /dev/null +++ b/src/ui/controls/LedButton.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { LedChoice, PlainKey } from './LedButton' + +const OPTIONS = [ + { value: 'a', content: 'A', name: 'Alpha' }, + { value: 'b', content: 'B', name: 'Bravo' }, + { value: 'c', content: 'C', name: 'Charlie' }, +] + +function Choice() { + const [value, setValue] = useState('b') + return ( + <> + + + + ) +} + +describe('LedChoice', () => { + it('is one Tab stop that lands on the lit key', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByText('before')) + await user.tab() + expect(screen.getByRole('radio', { name: 'Bravo' })).toHaveFocus() + await user.tab() + expect(document.body).toHaveFocus() + }) + + it('moves and selects with arrows, wrapping, and jumps with Home and End', async () => { + const user = userEvent.setup() + render() + screen.getByRole('radio', { name: 'Bravo' }).focus() + + await user.keyboard('{ArrowRight}') + expect(screen.getByRole('radio', { name: 'Charlie' })).toHaveFocus() + expect(screen.getByRole('radio', { name: 'Charlie' })).toHaveAttribute('aria-checked', 'true') + + await user.keyboard('{ArrowRight}') + expect(screen.getByRole('radio', { name: 'Alpha' })).toHaveAttribute('aria-checked', 'true') + + await user.keyboard('{End}') + expect(screen.getByRole('radio', { name: 'Charlie' })).toHaveFocus() + await user.keyboard('{Home}{ArrowLeft}') + expect(screen.getByRole('radio', { name: 'Charlie' })).toHaveAttribute('aria-checked', 'true') + }) +}) + +function Undo() { + const [left, setLeft] = useState(1) + return ( + setLeft((n) => n - 1)}> + {left} + + ) +} + +describe('PlainKey', () => { + it('keeps focus and ignores presses once disabled', async () => { + const user = userEvent.setup() + render() + const key = screen.getByRole('button', { name: 'Undo' }) + key.focus() + + await user.keyboard('{Enter}') + expect(key).toHaveAttribute('aria-disabled', 'true') + expect(key).toHaveFocus() + + await user.keyboard('{Enter}') + expect(key).toHaveTextContent('0') + }) +}) diff --git a/src/ui/controls/StepKey.tsx b/src/ui/controls/StepKey.tsx index d8b7b21..3b6a706 100644 --- a/src/ui/controls/StepKey.tsx +++ b/src/ui/controls/StepKey.tsx @@ -18,7 +18,7 @@ interface StepKeyProps { export const StepKey = memo(function StepKey({ step, on, now, variant = 'drum', label, onToggle }: StepKeyProps) { const className = [styles.step, variant === 'synth' && styles.synth, now && styles.now].filter(Boolean).join(' ') return ( - diff --git a/tsconfig.json b/tsconfig.json index fc14e5f..33326c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src", "vite.config.ts"] + "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index b7ec64f..608b574 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config' +import { configDefaults, defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' export default defineConfig({ @@ -6,5 +6,7 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: './src/test/setup.ts', + // Browser tests in e2e/ run in real Chrome through Playwright, not here. + exclude: [...configDefaults.exclude, 'e2e/**'], }, })