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