Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules
dist
.DS_Store
*.local
test-results
playwright-report
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
75 changes: 75 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LM-919 · Loop Machine</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='3' y='9' width='26' height='20' rx='5' fill='%23ece6da' stroke='%232a2c2f' stroke-width='1.5'/%3E%3Crect x='10' y='3' width='12' height='3.5' rx='1.75' fill='%23ff3b2a'/%3E%3C/svg%3E"
/>
</head>
<body>
<div id="root"></div>
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"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",
Expand Down
28 changes: 28 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
41 changes: 41 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions src/ui/TempoControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TempoControl bpm={120} onChange={onChange} />)
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(<TempoControl bpm={120} onChange={onChange} />)
const input = screen.getByLabelText('Tempo in BPM')
await user.click(input)
await user.keyboard('150{Escape}')
expect(onChange).not.toHaveBeenCalled()
expect(input).toHaveValue('120')
})
})
38 changes: 38 additions & 0 deletions src/ui/controls/Knob.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Knob value={value} onChange={setValue} label="Test knob" />
}

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(<Harness />)
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(<Harness />)
// 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(<Harness />)
wheel({ deltaX: 80, deltaY: 0 })
expect(notch()).toBe(5)
})
})
Loading
Loading