diff --git a/.claude/skills/playwright-cli/SKILL.md b/.claude/skills/playwright-cli/SKILL.md new file mode 100644 index 0000000..6f34a18 --- /dev/null +++ b/.claude/skills/playwright-cli/SKILL.md @@ -0,0 +1,420 @@ +--- +name: playwright-cli +description: Automate browser interactions, test web pages and work with Playwright tests. +allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*) +--- + +# Browser Automation with playwright-cli + +## Quick start + +```bash +# open new browser +playwright-cli open +# navigate to a page +playwright-cli goto https://playwright.dev +# interact with the page using refs from the snapshot +playwright-cli click e15 +playwright-cli type "page.click" +playwright-cli press Enter +# take a screenshot (rarely used, as snapshot is more common) +playwright-cli screenshot +# close the browser +playwright-cli close +``` + +## Commands + +### Core + +```bash +playwright-cli open +# open and navigate right away +playwright-cli open https://example.com/ +playwright-cli goto https://playwright.dev +playwright-cli type "search query" +playwright-cli click e3 +playwright-cli dblclick e7 +# --submit presses Enter after filling the element +playwright-cli fill e5 "user@example.com" --submit +playwright-cli drag e2 e8 +# drop files or data onto an element (from outside the page) +playwright-cli drop e4 --path=./image.png +playwright-cli drop e4 --data="text/plain=hello world" +playwright-cli hover e4 +playwright-cli select e9 "option-value" +playwright-cli upload ./document.pdf +playwright-cli check e12 +playwright-cli uncheck e12 +playwright-cli snapshot +# search the snapshot for text or a regexp, returns matching nodes with surrounding context +playwright-cli find "Sign in" +playwright-cli find --regex "Sign (in|up)" +# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive +playwright-cli find --regex "/sign (in|up)/i" +playwright-cli eval "document.title" +playwright-cli eval "el => el.textContent" e5 +# get element id, class, or any attribute not visible in the snapshot +playwright-cli eval "el => el.id" e5 +playwright-cli eval "el => el.getAttribute('data-testid')" e5 +playwright-cli dialog-accept +playwright-cli dialog-accept "confirmation text" +playwright-cli dialog-dismiss +playwright-cli resize 1920 1080 +playwright-cli close +``` + +### Navigation + +```bash +playwright-cli go-back +playwright-cli go-forward +playwright-cli reload +``` + +### Keyboard + +```bash +playwright-cli press Enter +playwright-cli press ArrowDown +playwright-cli keydown Shift +playwright-cli keyup Shift +``` + +### Mouse + +```bash +playwright-cli mousemove 150 300 +playwright-cli mousedown +playwright-cli mousedown right +playwright-cli mouseup +playwright-cli mouseup right +playwright-cli mousewheel 0 100 +``` + +### Save as + +```bash +playwright-cli screenshot +playwright-cli screenshot e5 +playwright-cli screenshot --filename=page.png +playwright-cli screenshot --hires +playwright-cli pdf --filename=page.pdf +``` + +### Tabs + +```bash +playwright-cli tab-list +playwright-cli tab-new +playwright-cli tab-new https://example.com/page +playwright-cli tab-close +playwright-cli tab-close 2 +playwright-cli tab-select 0 +``` + +### Storage + +```bash +playwright-cli state-save +playwright-cli state-save auth.json +playwright-cli state-load auth.json + +# Cookies +playwright-cli cookie-list +playwright-cli cookie-list --domain=example.com +playwright-cli cookie-get session_id +playwright-cli cookie-set session_id abc123 +playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure +playwright-cli cookie-delete session_id +playwright-cli cookie-clear + +# LocalStorage +playwright-cli localstorage-list +playwright-cli localstorage-get theme +playwright-cli localstorage-set theme dark +playwright-cli localstorage-delete theme +playwright-cli localstorage-clear + +# SessionStorage +playwright-cli sessionstorage-list +playwright-cli sessionstorage-get step +playwright-cli sessionstorage-set step 3 +playwright-cli sessionstorage-delete step +playwright-cli sessionstorage-clear +``` + +### Network + +```bash +playwright-cli route "**/*.jpg" --status=404 +playwright-cli route "https://api.example.com/**" --body='{"mock": true}' +playwright-cli route-list +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +``` + +### DevTools + +```bash +playwright-cli console +playwright-cli console warning +playwright-cli requests +playwright-cli request 5 +playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])" +playwright-cli run-code --filename=script.js +playwright-cli tracing-start +playwright-cli tracing-stop +playwright-cli video-start video.webm +playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 +playwright-cli video-stop + +# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target +playwright-cli video-show-actions --duration=600 --position=top-right +playwright-cli video-hide-actions + +# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes +playwright-cli show --annotate + +# generate a Playwright locator for an element from its ref or selector +playwright-cli generate-locator e5 --raw + +# show a persistent highlight overlay for an element, optionally with a custom style +playwright-cli highlight e5 +playwright-cli highlight e5 --style="outline: 3px dashed red" +# hide a single element highlight, or all page highlights when no target is given +playwright-cli highlight e5 --hide +playwright-cli highlight --hide +``` + +## Raw output + +The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing. + +```bash +playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart' +playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json +playwright-cli --raw snapshot > before.yml +playwright-cli click e5 +playwright-cli --raw snapshot > after.yml +diff before.yml after.yml +TOKEN=$(playwright-cli --raw cookie-get session_id) +playwright-cli --raw localstorage-get theme +``` + +For structured output wrapping every reply as JSON, pass --json +```bash +playwright-cli list --json +``` + +## Open parameters +```bash +# Use specific browser when creating session +playwright-cli open --browser=chrome +playwright-cli open --browser=firefox +playwright-cli open --browser=webkit +playwright-cli open --browser=msedge + +# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). +# Prefer this when a mobile layout is acceptable: mobile pages are usually +# lighter, so snapshots are smaller and cheaper. +playwright-cli open --mobile +playwright-cli open --device="iPhone 15" + +# Use persistent profile (by default profile is in-memory) +playwright-cli open --persistent +# Use persistent profile with custom directory +playwright-cli open --profile=/path/to/profile + +# Connect to browser via Playwright Extension +playwright-cli attach --extension=chrome + +# Connect to a running Chrome or Edge by channel name +playwright-cli attach --cdp=chrome +playwright-cli attach --cdp=msedge + +# Connect to a running browser via CDP endpoint +playwright-cli attach --cdp=http://localhost:9222 + +# Start with config file +playwright-cli open --config=my-config.json + +# Close the browser +playwright-cli close +# Detach from an attached browser (leaves the external browser running) +playwright-cli -s=msedge detach +# Delete user data for the default session +playwright-cli delete-data +``` + +## URLs with `&` on Windows + +On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell: + +```batch +playwright-cli goto "https://example.com/?a=1^&b=2" +``` + +```powershell +playwright-cli --% goto "https://example.com/?a=1&b=2" +``` + +## Snapshots + +After each command, playwright-cli provides a snapshot of the current browser state. + +```bash +> playwright-cli goto https://example.com +### Page +- Page URL: https://example.com/ +- Page Title: Example Domain +### Snapshot +[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml) +``` + +You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed. + +```bash +# default - save to a file with timestamp-based name +playwright-cli snapshot + +# save to file, use when snapshot is a part of the workflow result +playwright-cli snapshot --filename=after-click.yaml + +# snapshot an element instead of the whole page +playwright-cli snapshot "#main" + +# limit snapshot depth for efficiency, take a partial snapshot afterwards +playwright-cli snapshot --depth=4 +playwright-cli snapshot e34 + +# include each element's bounding box as [box=x,y,width,height] +playwright-cli snapshot --boxes + +# search a large snapshot instead of capturing it all — returns matching nodes +# with 3 lines of context around each match (like grep -C) +playwright-cli find "Add to cart" +playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}" +``` + +## Targeting elements + +By default, use refs from the snapshot to interact with page elements. + +```bash +# get snapshot with refs +playwright-cli snapshot + +# interact using a ref +playwright-cli click e15 +``` + +You can also use css selectors or Playwright locators. + +```bash +# css selector +playwright-cli click "#main > button.submit" + +# role locator +playwright-cli click "getByRole('button', { name: 'Submit' })" + +# test id +playwright-cli click "getByTestId('submit-button')" +``` + +## Browser Sessions + +```bash +# create new browser session named "mysession" with persistent profile +playwright-cli -s=mysession open example.com --persistent +# same with manually specified profile directory (use when requested explicitly) +playwright-cli -s=mysession open example.com --profile=/path/to/profile +playwright-cli -s=mysession click e6 +playwright-cli -s=mysession close # stop a named browser +playwright-cli -s=mysession delete-data # delete user data for persistent session + +playwright-cli list +# Close all browsers +playwright-cli close-all +# Forcefully kill all browser processes +playwright-cli kill-all +``` + +## Installation + +If global `playwright-cli` command is not available, try a local version via `npx playwright cli`: + +```bash +npx --no-install playwright --version +``` + +When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command: + +```bash +npm install -g @playwright/cli@latest +``` + +## Example: Form submission + +```bash +playwright-cli open https://example.com/form +playwright-cli snapshot + +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 +playwright-cli snapshot +playwright-cli close +``` + +## Example: Multi-tab workflow + +```bash +playwright-cli open https://example.com +playwright-cli tab-new https://example.com/other +playwright-cli tab-list +playwright-cli tab-select 0 +playwright-cli snapshot +playwright-cli close +``` + +## Example: Debugging with DevTools + +```bash +playwright-cli open https://example.com +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli console +playwright-cli requests +playwright-cli close +``` + +```bash +playwright-cli open https://example.com +playwright-cli tracing-start +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli tracing-stop +playwright-cli close +``` + +## Example: Interactive session + +Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean": + +```bash +playwright-cli open https://example.com +playwright-cli show --annotate +``` + +## Specific tasks + +* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md) +* **Request mocking** [references/request-mocking.md](references/request-mocking.md) +* **Running Playwright code** [references/running-code.md](references/running-code.md) +* **Browser session management** [references/session-management.md](references/session-management.md) +* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) +* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md) +* **Tracing** [references/tracing.md](references/tracing.md) +* **Video recording** [references/video-recording.md](references/video-recording.md) +* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/.claude/skills/playwright-cli/references/element-attributes.md b/.claude/skills/playwright-cli/references/element-attributes.md new file mode 100644 index 0000000..4e9fa6b --- /dev/null +++ b/.claude/skills/playwright-cli/references/element-attributes.md @@ -0,0 +1,23 @@ +# Inspecting Element Attributes + +When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them. + +## Examples + +```bash +playwright-cli snapshot +# snapshot shows a button as e7 but doesn't reveal its id or data attributes + +# get the element's id +playwright-cli eval "el => el.id" e7 + +# get all CSS classes +playwright-cli eval "el => el.className" e7 + +# get a specific attribute +playwright-cli eval "el => el.getAttribute('data-testid')" e7 +playwright-cli eval "el => el.getAttribute('aria-label')" e7 + +# get a computed style property +playwright-cli eval "el => getComputedStyle(el).display" e7 +``` diff --git a/.claude/skills/playwright-cli/references/playwright-tests.md b/.claude/skills/playwright-cli/references/playwright-tests.md new file mode 100644 index 0000000..bec2ec9 --- /dev/null +++ b/.claude/skills/playwright-cli/references/playwright-tests.md @@ -0,0 +1,39 @@ +# Running Playwright Tests + +To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable. + +```bash +# Run all tests +PLAYWRIGHT_HTML_OPEN=never npx playwright test + +# Run all tests through a custom npm script +PLAYWRIGHT_HTML_OPEN=never npm run special-test-command +``` + +# Debugging Playwright Tests + +To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions. + +**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished. + +Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page. + +```bash +# Run the test +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli +# ... +# ... debugging instructions for "tw-abcdef" session ... +# ... + +# Attach to the test +playwright-cli attach tw-abcdef +``` + +Keep the test running in the background while you explore and look for a fix. +The test is paused at the start, so you should step over or pause at a particular location +where the problem is most likely to be. + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. +This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement. + +After fixing the test, stop the background test run. Rerun to check that test passes. diff --git a/.claude/skills/playwright-cli/references/request-mocking.md b/.claude/skills/playwright-cli/references/request-mocking.md new file mode 100644 index 0000000..9005fda --- /dev/null +++ b/.claude/skills/playwright-cli/references/request-mocking.md @@ -0,0 +1,87 @@ +# Request Mocking + +Intercept, mock, modify, and block network requests. + +## CLI Route Commands + +```bash +# Mock with custom status +playwright-cli route "**/*.jpg" --status=404 + +# Mock with JSON body +playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json + +# Mock with custom headers +playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value" + +# Remove headers from requests +playwright-cli route "**/*" --remove-header=cookie,authorization + +# List active routes +playwright-cli route-list + +# Remove a route or all routes +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +``` + +## URL Patterns + +``` +**/api/users - Exact path match +**/api/*/details - Wildcard in path +**/*.{png,jpg,jpeg} - Match file extensions +**/search?q=* - Match query parameters +``` + +## Advanced Mocking with run-code + +For conditional responses, request body inspection, response modification, or delays: + +### Conditional Response Based on Request + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/login', route => { + const body = route.request().postDataJSON(); + if (body.username === 'admin') { + route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) }); + } else { + route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) }); + } + }); +}" +``` + +### Modify Real Response + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/user', async route => { + const response = await route.fetch(); + const json = await response.json(); + json.isPremium = true; + await route.fulfill({ response, json }); + }); +}" +``` + +### Simulate Network Failures + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/offline', route => route.abort('internetdisconnected')); +}" +# Options: connectionrefused, timedout, connectionreset, internetdisconnected +``` + +### Delayed Response + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/slow', async route => { + await new Promise(r => setTimeout(r, 3000)); + route.fulfill({ body: JSON.stringify({ data: 'loaded' }) }); + }); +}" +``` diff --git a/.claude/skills/playwright-cli/references/running-code.md b/.claude/skills/playwright-cli/references/running-code.md new file mode 100644 index 0000000..98b541f --- /dev/null +++ b/.claude/skills/playwright-cli/references/running-code.md @@ -0,0 +1,241 @@ +# Running Custom Playwright Code + +Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands. + +## Syntax + +```bash +playwright-cli run-code "async page => { + // Your Playwright code here + // Access page.context() for browser context operations +}" +``` + +You can also load the function from a file: + +```bash +playwright-cli run-code --filename=./my-script.js +``` + + +The code must be a single function expression, it is wrapped in `(...)` and evaluated. +import/export/require syntax is not supported. + +## Geolocation + +```bash +# Grant geolocation permission and set location +playwright-cli run-code "async page => { + await page.context().grantPermissions(['geolocation']); + await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); +}" + +# Set location to London +playwright-cli run-code "async page => { + await page.context().grantPermissions(['geolocation']); + await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 }); +}" + +# Clear geolocation override +playwright-cli run-code "async page => { + await page.context().clearPermissions(); +}" +``` + +## Permissions + +```bash +# Grant multiple permissions +playwright-cli run-code "async page => { + await page.context().grantPermissions([ + 'geolocation', + 'notifications', + 'camera', + 'microphone' + ]); +}" + +# Grant permissions for specific origin +playwright-cli run-code "async page => { + await page.context().grantPermissions(['clipboard-read'], { + origin: 'https://example.com' + }); +}" +``` + +## Media Emulation + +```bash +# Emulate dark color scheme +playwright-cli run-code "async page => { + await page.emulateMedia({ colorScheme: 'dark' }); +}" + +# Emulate light color scheme +playwright-cli run-code "async page => { + await page.emulateMedia({ colorScheme: 'light' }); +}" + +# Emulate reduced motion +playwright-cli run-code "async page => { + await page.emulateMedia({ reducedMotion: 'reduce' }); +}" + +# Emulate print media +playwright-cli run-code "async page => { + await page.emulateMedia({ media: 'print' }); +}" +``` + +## Wait Strategies + +```bash +# Wait for network idle +playwright-cli run-code "async page => { + await page.waitForLoadState('networkidle'); +}" + +# Wait for specific element +playwright-cli run-code "async page => { + await page.locator('.loading').waitFor({ state: 'hidden' }); +}" + +# Wait for function to return true +playwright-cli run-code "async page => { + await page.waitForFunction(() => window.appReady === true); +}" + +# Wait with timeout +playwright-cli run-code "async page => { + await page.locator('.result').waitFor({ timeout: 10000 }); +}" +``` + +## Frames and Iframes + +```bash +# Work with iframe +playwright-cli run-code "async page => { + const frame = page.locator('iframe#my-iframe').contentFrame(); + await frame.locator('button').click(); +}" + +# Get all frames +playwright-cli run-code "async page => { + const frames = page.frames(); + return frames.map(f => f.url()); +}" +``` + +## File Downloads + +```bash +# Handle file download +playwright-cli run-code "async page => { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'Download' }).click(); + const download = await downloadPromise; + await download.saveAs('./downloaded-file.pdf'); + return download.suggestedFilename(); +}" +``` + +## Clipboard + +```bash +# Read clipboard (requires permission) +playwright-cli run-code "async page => { + await page.context().grantPermissions(['clipboard-read']); + return await page.evaluate(() => navigator.clipboard.readText()); +}" + +# Write to clipboard +playwright-cli run-code "async page => { + await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!'); +}" +``` + +## Page Information + +```bash +# Get page title +playwright-cli run-code "async page => { + return await page.title(); +}" + +# Get current URL +playwright-cli run-code "async page => { + return page.url(); +}" + +# Get page content +playwright-cli run-code "async page => { + return await page.content(); +}" + +# Get viewport size +playwright-cli run-code "async page => { + return page.viewportSize(); +}" +``` + +## JavaScript Execution + +```bash +# Execute JavaScript and return result +playwright-cli run-code "async page => { + return await page.evaluate(() => { + return { + userAgent: navigator.userAgent, + language: navigator.language, + cookiesEnabled: navigator.cookieEnabled + }; + }); +}" + +# Pass arguments to evaluate +playwright-cli run-code "async page => { + const multiplier = 5; + return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier); +}" +``` + +## Error Handling + +```bash +# Try-catch in run-code +playwright-cli run-code "async page => { + try { + await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 }); + return 'clicked'; + } catch (e) { + return 'element not found'; + } +}" +``` + +## Complex Workflows + +```bash +# Login and save state +playwright-cli run-code "async page => { + await page.goto('https://example.com/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'Password' }).fill('secret'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL('**/dashboard'); + await page.context().storageState({ path: 'auth.json' }); + return 'Login successful'; +}" + +# Scrape data from multiple pages +playwright-cli run-code "async page => { + const results = []; + for (let i = 1; i <= 3; i++) { + await page.goto(\`https://example.com/page/\${i}\`); + const items = await page.locator('.item').allTextContents(); + results.push(...items); + } + return results; +}" +``` diff --git a/.claude/skills/playwright-cli/references/session-management.md b/.claude/skills/playwright-cli/references/session-management.md new file mode 100644 index 0000000..bf39acd --- /dev/null +++ b/.claude/skills/playwright-cli/references/session-management.md @@ -0,0 +1,225 @@ +# Browser Session Management + +Run multiple isolated browser sessions concurrently with state persistence. + +## Named Browser Sessions + +Use `-s` flag to isolate browser contexts: + +```bash +# Browser 1: Authentication flow +playwright-cli -s=auth open https://app.example.com/login + +# Browser 2: Public browsing (separate cookies, storage) +playwright-cli -s=public open https://example.com + +# Commands are isolated by browser session +playwright-cli -s=auth fill e1 "user@example.com" +playwright-cli -s=public snapshot +``` + +## Browser Session Isolation Properties + +Each browser session has independent: +- Cookies +- LocalStorage / SessionStorage +- IndexedDB +- Cache +- Browsing history +- Open tabs + +## Browser Session Commands + +```bash +# List all browser sessions +playwright-cli list + +# Stop a browser session (close the browser) +playwright-cli close # stop the default browser +playwright-cli -s=mysession close # stop a named browser + +# Stop all browser sessions +playwright-cli close-all + +# Forcefully kill all daemon processes (for stale/zombie processes) +playwright-cli kill-all + +# Delete browser session user data (profile directory) +playwright-cli delete-data # delete default browser data +playwright-cli -s=mysession delete-data # delete named browser data +``` + +## Environment Variable + +Set a default browser session name via environment variable: + +```bash +export PLAYWRIGHT_CLI_SESSION="mysession" +playwright-cli open example.com # Uses "mysession" automatically +``` + +## Common Patterns + +### Concurrent Scraping + +```bash +#!/bin/bash +# Scrape multiple sites concurrently + +# Start all browsers +playwright-cli -s=site1 open https://site1.com & +playwright-cli -s=site2 open https://site2.com & +playwright-cli -s=site3 open https://site3.com & +wait + +# Take snapshots from each +playwright-cli -s=site1 snapshot +playwright-cli -s=site2 snapshot +playwright-cli -s=site3 snapshot + +# Cleanup +playwright-cli close-all +``` + +### A/B Testing Sessions + +```bash +# Test different user experiences +playwright-cli -s=variant-a open "https://app.com?variant=a" +playwright-cli -s=variant-b open "https://app.com?variant=b" + +# Compare +playwright-cli -s=variant-a screenshot +playwright-cli -s=variant-b screenshot +``` + +### Persistent Profile + +By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk: + +```bash +# Use persistent profile (auto-generated location) +playwright-cli open https://example.com --persistent + +# Use persistent profile with custom directory +playwright-cli open https://example.com --profile=/path/to/profile +``` + +## Attaching to a Running Browser + +Use `attach` to connect to a browser that is already running, instead of launching a new one. + +### Attach by channel name + +Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance". + +```bash +# Attach to Chrome +playwright-cli attach --cdp=chrome + +# Attach to Chrome Canary +playwright-cli attach --cdp=chrome-canary + +# Attach to Microsoft Edge +playwright-cli attach --cdp=msedge + +# Attach to Edge Dev +playwright-cli attach --cdp=msedge-dev +``` + +Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`. + +When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=` to override. + +### Attach via CDP endpoint + +Connect to a browser that exposes a Chrome DevTools Protocol endpoint: + +```bash +playwright-cli attach --cdp=http://localhost:9222 +``` + +### Attach via browser extension + +Connect to a browser with the Playwright extension installed: + +```bash +playwright-cli attach --extension +``` + +### Detach + +Tear down an attached session without affecting the external browser: + +```bash +# Detach the default attached session +playwright-cli detach + +# Detach a specific attached session +playwright-cli -s=msedge detach +``` + +`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`. + +## Default Browser Session + +When `-s` is omitted, commands use the default browser session: + +```bash +# These use the same default browser session +playwright-cli open https://example.com +playwright-cli snapshot +playwright-cli close # Stops default browser +``` + +## Browser Session Configuration + +Configure a browser session with specific settings when opening: + +```bash +# Open with config file +playwright-cli open https://example.com --config=.playwright/my-cli.json + +# Open with specific browser +playwright-cli open https://example.com --browser=firefox + +# Open in headed mode +playwright-cli open https://example.com --headed + +# Open with persistent profile +playwright-cli open https://example.com --persistent +``` + +## Best Practices + +### 1. Name Browser Sessions Semantically + +```bash +# GOOD: Clear purpose +playwright-cli -s=github-auth open https://github.com +playwright-cli -s=docs-scrape open https://docs.example.com + +# AVOID: Generic names +playwright-cli -s=s1 open https://github.com +``` + +### 2. Always Clean Up + +```bash +# Stop browsers when done +playwright-cli -s=auth close +playwright-cli -s=scrape close + +# Or stop all at once +playwright-cli close-all + +# If browsers become unresponsive or zombie processes remain +playwright-cli kill-all +``` + +### 3. Delete Stale Browser Data + +```bash +# Remove old browser data to free disk space +playwright-cli -s=oldsession delete-data +``` diff --git a/.claude/skills/playwright-cli/references/storage-state.md b/.claude/skills/playwright-cli/references/storage-state.md new file mode 100644 index 0000000..bb5021a --- /dev/null +++ b/.claude/skills/playwright-cli/references/storage-state.md @@ -0,0 +1,275 @@ +# Storage Management + +Manage cookies, localStorage, sessionStorage, and browser storage state. + +## Storage State + +Save and restore complete browser state including cookies and storage. + +### Save Storage State + +```bash +# Save to auto-generated filename (storage-state-{timestamp}.json) +playwright-cli state-save + +# Save to specific filename +playwright-cli state-save my-auth-state.json +``` + +### Restore Storage State + +```bash +# Load storage state from file +playwright-cli state-load my-auth-state.json + +# Reload page to apply cookies +playwright-cli open https://example.com +``` + +### Storage State File Format + +The saved file contains: + +```json +{ + "cookies": [ + { + "name": "session_id", + "value": "abc123", + "domain": "example.com", + "path": "/", + "expires": 1893456000, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + } + ], + "origins": [ + { + "origin": "https://example.com", + "localStorage": [ + { "name": "theme", "value": "dark" }, + { "name": "user_id", "value": "12345" } + ] + } + ] +} +``` + +## Cookies + +### List All Cookies + +```bash +playwright-cli cookie-list +``` + +### Filter Cookies by Domain + +```bash +playwright-cli cookie-list --domain=example.com +``` + +### Filter Cookies by Path + +```bash +playwright-cli cookie-list --path=/api +``` + +### Get Specific Cookie + +```bash +playwright-cli cookie-get session_id +``` + +### Set a Cookie + +```bash +# Basic cookie +playwright-cli cookie-set session abc123 + +# Cookie with options +playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax + +# Cookie with expiration (Unix timestamp) +playwright-cli cookie-set remember_me token123 --expires=1893456000 +``` + +### Delete a Cookie + +```bash +playwright-cli cookie-delete session_id +``` + +### Clear All Cookies + +```bash +playwright-cli cookie-clear +``` + +### Advanced: Multiple Cookies or Custom Options + +For complex scenarios like adding multiple cookies at once, use `run-code`: + +```bash +playwright-cli run-code "async page => { + await page.context().addCookies([ + { name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true }, + { name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' } + ]); +}" +``` + +## Local Storage + +### List All localStorage Items + +```bash +playwright-cli localstorage-list +``` + +### Get Single Value + +```bash +playwright-cli localstorage-get token +``` + +### Set Value + +```bash +playwright-cli localstorage-set theme dark +``` + +### Set JSON Value + +```bash +playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}' +``` + +### Delete Single Item + +```bash +playwright-cli localstorage-delete token +``` + +### Clear All localStorage + +```bash +playwright-cli localstorage-clear +``` + +### Advanced: Multiple Operations + +For complex scenarios like setting multiple values at once, use `run-code`: + +```bash +playwright-cli run-code "async page => { + await page.evaluate(() => { + localStorage.setItem('token', 'jwt_abc123'); + localStorage.setItem('user_id', '12345'); + localStorage.setItem('expires_at', Date.now() + 3600000); + }); +}" +``` + +## Session Storage + +### List All sessionStorage Items + +```bash +playwright-cli sessionstorage-list +``` + +### Get Single Value + +```bash +playwright-cli sessionstorage-get form_data +``` + +### Set Value + +```bash +playwright-cli sessionstorage-set step 3 +``` + +### Delete Single Item + +```bash +playwright-cli sessionstorage-delete step +``` + +### Clear sessionStorage + +```bash +playwright-cli sessionstorage-clear +``` + +## IndexedDB + +### List Databases + +```bash +playwright-cli run-code "async page => { + return await page.evaluate(async () => { + const databases = await indexedDB.databases(); + return databases; + }); +}" +``` + +### Delete Database + +```bash +playwright-cli run-code "async page => { + await page.evaluate(() => { + indexedDB.deleteDatabase('myDatabase'); + }); +}" +``` + +## Common Patterns + +### Authentication State Reuse + +```bash +# Step 1: Login and save state +playwright-cli open https://app.example.com/login +playwright-cli snapshot +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 + +# Save the authenticated state +playwright-cli state-save auth.json + +# Step 2: Later, restore state and skip login +playwright-cli state-load auth.json +playwright-cli open https://app.example.com/dashboard +# Already logged in! +``` + +### Save and Restore Roundtrip + +```bash +# Set up authentication state +playwright-cli open https://example.com +playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }" + +# Save state to file +playwright-cli state-save my-session.json + +# ... later, in a new session ... + +# Restore state +playwright-cli state-load my-session.json +playwright-cli open https://example.com +# Cookies and localStorage are restored! +``` + +## Security Notes + +- Never commit storage state files containing auth tokens +- Add `*.auth-state.json` to `.gitignore` +- Delete state files after automation completes +- Use environment variables for sensitive data +- By default, sessions run in-memory mode which is safer for sensitive operations diff --git a/.claude/skills/playwright-cli/references/test-generation.md b/.claude/skills/playwright-cli/references/test-generation.md new file mode 100644 index 0000000..35a8d57 --- /dev/null +++ b/.claude/skills/playwright-cli/references/test-generation.md @@ -0,0 +1,433 @@ +# Test generation (plan → generate → heal) + +End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently: + +- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions. +- **Plan** — explore the app, produce a spec file describing what to test. +- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale. +- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality. + +Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics. + +--- + +## 0. How generation works + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files. + +```bash +# Start a session +playwright-cli open https://example.com/login + +# Take a snapshot to see elements +playwright-cli snapshot +# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"] + +# Fill form fields - generates code automatically +playwright-cli fill e1 "user@example.com" +# Ran Playwright code: +# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + +playwright-cli fill e2 "password123" +# Ran Playwright code: +# await page.getByRole('textbox', { name: 'Password' }).fill('password123'); + +playwright-cli click e3 +# Ran Playwright code: +# await page.getByRole('button', { name: 'Sign In' }).click(); +``` + +### Building a test file + +Collect the generated code into a Playwright test: + +```typescript +import { test, expect } from '@playwright/test'; + +test('login flow', async ({ page }) => { + // Generated code from playwright-cli session: + await page.goto('https://example.com/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'Password' }).fill('password123'); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Add assertions + await expect(page).toHaveURL(/.*dashboard/); +}); +``` + +### Use semantic locators + +The generated code uses role-based locators when possible, which are more resilient: + +```typescript +// Generated (good - semantic) +await page.getByRole('button', { name: 'Submit' }).click(); + +// Avoid (fragile - CSS selectors) +await page.locator('#submit-btn').click(); +``` + +### Explore before recording + +Take snapshots to understand the page structure before recording actions: + +```bash +playwright-cli open https://example.com +playwright-cli snapshot +# Review the element structure +playwright-cli click e5 +``` + +### Add assertions manually + +Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers: + +- `toBeVisible()` — element is rendered and visible +- `toHaveText(text)` — element text content matches +- `toHaveValue(value) / toBeEmpty()` — input/select value matches +- `toBeChecked() / toBeUnchecked()` — checkbox state matches +- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot + +Use `playwright-cli generate-locator ` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value. + +When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead. + +Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values. + +```bash +# Get a stable locator for an element ref to use in the assertion +playwright-cli --raw generate-locator e5 +# getByRole('button', { name: 'Submit' }) + +# Capture expected text content for toHaveText +playwright-cli --raw eval "el => el.textContent" e5 + +# Capture expected input value for toHaveValue/toBeEmpty +playwright-cli --raw eval "el => el.value" e5 + +# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked +# (whole page, or use a ref to scope to a region) +playwright-cli --raw snapshot +playwright-cli --raw snapshot e5 +``` + +```typescript +// Generated action +await page.getByRole('button', { name: 'Submit' }).click(); + +// Manual assertions using the outputs above: +await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible(); +await expect(page.getByTestId('main-header')).toHaveText('Welcome, user'); +await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com'); +await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked(); + +// toMatchAriaSnapshot on the whole page, finds a matching region +await expect(page).toMatchAriaSnapshot(` + - heading "Welcome, user" + - link /\\d+ new messages?/ + - button "Sign out" +`); + +// toMatchAriaSnapshot scoped to a region +await expect(page.getByRole('navigation')).toMatchAriaSnapshot(` + - link "Home" + - link /\\d+ new messages?/ + - link "Profile" +`); +``` + +--- + +## 1. Planning + +Goal: produce a spec file (e.g. `specs/.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file. + +### 1.1 Prerequisite: workspace + +Check the workspace has Playwright installed before anything else: + +```bash +# Either of these confirms a workspace: +test -f playwright.config.ts || test -f playwright.config.js +npx --no-install playwright --version +``` + +If there is no Playwright install, bootstrap one and let the user pick the defaults: + +```bash +npm init playwright@latest +``` + +### 1.2 Prerequisite: seed test + +A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins. + +Minimum viable seed: + +```ts +// tests/seed.spec.ts +import { test } from '@playwright/test'; + +test('seed', async ({ page }) => { + await page.goto('https://example.com/'); +}); +``` + +Preferred — push navigation into a fixture so scenario tests reuse it: + +```ts +// tests/fixtures.ts +import { test as baseTest } from '@playwright/test'; +export { expect } from '@playwright/test'; + +export const test = baseTest.extend({ + page: async ({ page }, use) => { + await page.goto('https://example.com/'); + await use(page); + }, +}); +``` + +```ts +// tests/seed.spec.ts +import { test } from './fixtures'; + +test('seed', async ({ page }) => { + // Fixture already navigates. This empty body tells agents where to start. +}); +``` + +If no seed exists, create one that at least navigates to the app. + +### 1.3 Explore the app + +Launch the app via the seed in the background and attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli +# wait for "Debugging Instructions" and the session name tw-XXXX +playwright-cli attach tw-XXXX +``` + +Resume so the seed runs, then probe the app: + +```bash +playwright-cli resume # resume so that seed test runs fully +playwright-cli snapshot # inventory of interactive elements +playwright-cli click e5 # follow a flow +playwright-cli eval "location.href" # read URL / state +playwright-cli show --annotate # ask the user to point at something +``` + +Map out: + +- Interactive surfaces (forms, buttons, lists, filters, modals). +- Primary user journeys end-to-end. +- Edge cases: empty states, validation errors, very long input, boundary values. +- Persistence: reload, local/session storage, URL fragments. +- Navigation: which controls change the URL, back/forward behaviour. + +**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there. +**Important**: Stop the background test when done exploring. + +### 1.4 Write the spec file + +Save under `specs/.plan.md`. Use this structure: + +```markdown +# Test Plan + +## Application Overview + + + +## Test Scenarios + +### 1. + +**Seed:** `tests/seed.spec.ts` + +#### 1.1. + +**File:** `tests//.spec.ts` + +**Steps:** + 1. + - expect: + - expect: + 2. + - expect: + +#### 1.2. +... + +### 2. + +**Seed:** `tests/seed.spec.ts` +... +``` + +Guidelines: + +- Each scenario is independent and starts from the seed's fresh state — never chain scenarios. +- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`). +- Cover happy path, edge cases, validation, negative flows, persistence. +- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`"). +- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation. + +--- + +## 2. Generate + +Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted. + +### 2.1 Inputs + +- **Spec file**, e.g. `specs/basic-operations.plan.md`. +- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all. +- **Seed file**, read from the `**Seed:**` line of the scenario's group. + +### 2.2 Generate one scenario + +For each target scenario, in sequence (never in parallel — scenarios share the seed session): + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli # background +playwright-cli attach tw-XXXX +# resume +``` + +**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there. + +Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected. + +Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)): + +```bash +playwright-cli snapshot # find refs +playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...) +playwright-cli press Enter +playwright-cli click e7 +``` + +For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details. + +Collect the generated code and write the test file at the path given in the spec: + +```ts +// spec: specs/basic-operations.plan.md +// seed: tests/seed.spec.ts +import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file + +test.describe('Signing in and out', () => { + test('should sign in', async ({ page }) => { + // 1. Navigate to the application + // (handled by the seed fixture) + + // 2. Type 'John Doe' into the username field + await page.getByRole('textbox', { name: 'username' }).fill('John Doe'); + + // 3. Type password + await page.getByRole('textbox', { name: 'password' }).fill('TestPassword'); + + // 4. Press Enter to submit + await page.getByRole('textbox', { name: 'password' }).press('Enter'); + + await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!'); + }); +}); +``` + +Rules: + +- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal). +- Prefix each numbered step with a `// N. ` comment before its actions. +- Use the describe group name verbatim from the spec (no `1.` ordinal). +- Import from `./fixtures` if the project has one; otherwise `@playwright/test`. +- **Important**: close the CLI session and stop the background test before moving to the next scenario. + +### 2.3 Generate multiple scenarios + +Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped. + +### 2.4 Run generated tests + +After generation, run the new tests once: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts +``` + +Any failure goes to Section 3. + +--- + +## 3. Heal + +Goal: fix failing tests, and update the spec if the app's intended behaviour changed. + +### 3.1 Find failing tests + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test +``` + +Record the list of failing `:` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile. + +### 3.2 Debug one failure + +Run the single failing test in debug mode in the background, then attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts: --debug=cli +# wait for "Debugging Instructions" and the tw-XXXX session name +playwright-cli attach tw-XXXX +``` + +The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose: + +```bash +playwright-cli snapshot # did the element change / move / rename? +playwright-cli console # app-side errors? +playwright-cli requests # failed request? wrong payload? +playwright-cli show --annotate # ask the user to point somewhere +``` + +Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs. + +Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test. + +### 3.3 Apply the fix + +Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green. + +Never skip hooks or add sleeps as a fix. Never use `networkidle`. + +### 3.4 Reconcile with the spec + +Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test. + +- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone. +- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change. +- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide: + - the scenario id (e.g. `2.3`), + - the spec lines that no longer match, + - the observed app behaviour (quote a snapshot excerpt or a concrete outcome). + +Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression). + +### 3.5 Iteration and giving up + +- Fix failures one at a time; rerun after each. +- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. + +--- + +## Cross-references + +| For... | See | +|---|---| +| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | +| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | +| Managing the CLI browser session | [session-management.md](session-management.md) | diff --git a/.claude/skills/playwright-cli/references/tracing.md b/.claude/skills/playwright-cli/references/tracing.md new file mode 100644 index 0000000..7ce7bab --- /dev/null +++ b/.claude/skills/playwright-cli/references/tracing.md @@ -0,0 +1,139 @@ +# Tracing + +Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs. + +## Basic Usage + +```bash +# Start trace recording +playwright-cli tracing-start + +# Perform actions +playwright-cli open https://example.com +playwright-cli click e1 +playwright-cli fill e2 "test" + +# Stop trace recording +playwright-cli tracing-stop +``` + +## Trace Output Files + +When you start tracing, Playwright creates a `traces/` directory with several files: + +### `trace-{timestamp}.trace` + +**Action log** - The main trace file containing: +- Every action performed (clicks, fills, navigations) +- DOM snapshots before and after each action +- Screenshots at each step +- Timing information +- Console messages +- Source locations + +### `trace-{timestamp}.network` + +**Network log** - Complete network activity: +- All HTTP requests and responses +- Request headers and bodies +- Response headers and bodies +- Timing (DNS, connect, TLS, TTFB, download) +- Resource sizes +- Failed requests and errors + +### `resources/` + +**Resources directory** - Cached resources: +- Images, fonts, stylesheets, scripts +- Response bodies for replay +- Assets needed to reconstruct page state + +## What Traces Capture + +| Category | Details | +|----------|---------| +| **Actions** | Clicks, fills, hovers, keyboard input, navigations | +| **DOM** | Full DOM snapshot before/after each action | +| **Screenshots** | Visual state at each step | +| **Network** | All requests, responses, headers, bodies, timing | +| **Console** | All console.log, warn, error messages | +| **Timing** | Precise timing for each operation | + +## Use Cases + +### Debugging Failed Actions + +```bash +playwright-cli tracing-start +playwright-cli open https://app.example.com + +# This click fails - why? +playwright-cli click e5 + +playwright-cli tracing-stop +# Open trace to see DOM state when click was attempted +``` + +### Analyzing Performance + +```bash +playwright-cli tracing-start +playwright-cli open https://slow-site.com +playwright-cli tracing-stop + +# View network waterfall to identify slow resources +``` + +### Capturing Evidence + +```bash +# Record a complete user flow for documentation +playwright-cli tracing-start + +playwright-cli open https://app.example.com/checkout +playwright-cli fill e1 "4111111111111111" +playwright-cli fill e2 "12/25" +playwright-cli fill e3 "123" +playwright-cli click e4 + +playwright-cli tracing-stop +# Trace shows exact sequence of events +``` + +## Trace vs Video vs Screenshot + +| Feature | Trace | Video | Screenshot | +|---------|-------|-------|------------| +| **Format** | .trace file | .webm video | .png/.jpeg image | +| **DOM inspection** | Yes | No | No | +| **Network details** | Yes | No | No | +| **Step-by-step replay** | Yes | Continuous | Single frame | +| **File size** | Medium | Large | Small | +| **Best for** | Debugging | Demos | Quick capture | + +## Best Practices + +### 1. Start Tracing Before the Problem + +```bash +# Trace the entire flow, not just the failing step +playwright-cli tracing-start +playwright-cli open https://example.com +# ... all steps leading to the issue ... +playwright-cli tracing-stop +``` + +### 2. Clean Up Old Traces + +Traces can consume significant disk space: + +```bash +# Remove traces older than 7 days +find .playwright-cli/traces -mtime +7 -delete +``` + +## Limitations + +- Traces add overhead to automation +- Large traces can consume significant disk space +- Some dynamic content may not replay perfectly diff --git a/.claude/skills/playwright-cli/references/video-recording.md b/.claude/skills/playwright-cli/references/video-recording.md new file mode 100644 index 0000000..5209d21 --- /dev/null +++ b/.claude/skills/playwright-cli/references/video-recording.md @@ -0,0 +1,143 @@ +# Video Recording + +Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec). + +## Basic Recording + +```bash +# Open browser first +playwright-cli open + +# Start recording +playwright-cli video-start demo.webm + +# Add a chapter marker for section transitions +playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000 + +# Navigate and perform actions +playwright-cli goto https://example.com +playwright-cli snapshot +playwright-cli click e1 + +# Add another chapter +playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000 +playwright-cli fill e2 "test input" + +# Stop and save +playwright-cli video-stop +``` + +## Best Practices + +### 1. Use Descriptive Filenames + +```bash +# Include context in filename +playwright-cli video-start recordings/login-flow-2024-01-15.webm +playwright-cli video-start recordings/checkout-test-run-42.webm +``` + +### 2. Record entire hero scripts. + +When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code. +It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that. + +1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight. +2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses. +3) Use playwright-cli run-code --filename your-script.js + +**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page. + +```js +async page => { + await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } }); + await page.goto('https://demo.playwright.dev/todomvc'); + + // Show a chapter card — blurs the page and shows a dialog. + // Blocks until duration expires, then auto-removes. + // Use this for simple use cases, but always feel free to hand-craft your own beautiful + // overlay via await page.screencast.showOverlay(). + await page.screencast.showChapter('Adding Todo Items', { + description: 'We will add several items to the todo list.', + duration: 2000, + }); + + // Perform action + await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 }); + await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); + await page.waitForTimeout(1000); + + // Show next chapter + await page.screencast.showChapter('Verifying Results', { + description: 'Checking the item appeared in the list.', + duration: 2000, + }); + + // Add a sticky annotation that stays while you perform actions. + // Overlays are pointer-events: none, so they won't block clicks. + const annotation = await page.screencast.showOverlay(` +
+ ✓ Item added successfully +
+ `); + + // Perform more actions while the annotation is visible + await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 }); + await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); + await page.waitForTimeout(1500); + + // Remove the annotation when done + await annotation.dispose(); + + // You can also highlight relevant locators and provide contextual annotations. + const bounds = await page.getByText('Walk the dog').boundingBox(); + await page.screencast.showOverlay(` +
+
+
Check it out, it is right above this text +
+ `, { duration: 2000 }); + + await page.screencast.stop(); +} +``` + +Embrace creativity, overlays are powerful. + +### Overlay API Summary + +| Method | Use Case | +|--------|----------| +| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions | +| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights | +| `disposable.dispose()` | Remove a sticky overlay added without duration | +| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays | + +## Tracing vs Video + +| Feature | Video | Tracing | +|---------|-------|---------| +| Output | WebM file | Trace file (viewable in Trace Viewer) | +| Shows | Visual recording | DOM snapshots, network, console, actions | +| Use case | Demos, documentation | Debugging, analysis | +| Size | Larger | Smaller | + +## Limitations + +- Recording adds slight overhead to automation +- Large recordings can consume significant disk space diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..cfcef9b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,299 @@ +name: CI + +# The other three workflows package and publish; none of them test +# anything, so a green tick on this repo used to mean "the Arch package +# built", which is not the question anyone was asking. This is the +# workflow that gates. +# +# Both jobs were prototyped end to end in a bare ubuntu:24.04 container +# before being written here, so every step below is a transcription of +# something observed working rather than something expected to. + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +# A newer push supersedes an older one on the same ref. Job 2 binds +# :34115, so overlapping runs on one runner would fight over the port. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: '1.25.0' + # Shared by all three Playwright consumers (@playwright/cli, e2e/'s + # @playwright/test, frontend/'s Vitest provider). See the browsers + # step in job 2 for why that is not the whole story. + PLAYWRIGHT_BROWSERS_PATH: /cache/ms-playwright + # Best-effort pnpm store reuse; pnpm reads npm_config_* for its own + # config keys. If it ever stops honouring this we lose cache warmth + # and nothing else. + npm_config_store_dir: /cache/pnpm-store + +jobs: + # ---------------------------------------------------------------- # + # Job 1: everything that does not need a display. # + # ---------------------------------------------------------------- # + check: + runs-on: ubuntu-latest + container: + # Not golang:1.25 — this job runs `make ui-test`, which is Vitest + # *browser* mode and needs a Chromium and its system libraries + # anyway, so the "fast job needs no browser" split does not hold. + # Not the Playwright image either: e2e/ pins @playwright/test + # ^1.56 and frontend/ pins playwright ^1.62, so a prebuilt browser + # set matches at most one of them. Ubuntu 24.04 is also what + # Playwright's WebKit build links against, which job 2 needs. + image: ubuntu:24.04 + # GOMODCACHE / GOCACHE / GOLANGCI_LINT_CACHE are already mounted + # and exported for every job by the runner's container.options, so + # only the Node-side caches are listed here. The runner's + # valid_volumes allows anything under the cache root. + volumes: + - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool + - /home/logan/docker/gitea/data/runner/cache/ms-playwright:/cache/ms-playwright + - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store + env: + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + DEBIAN_FRONTEND: noninteractive + steps: + - name: System packages + run: | + set -eu + apt-get update -qq + # libwebkit2gtk-4.1-dev and libasound2-dev are not optional: + # the app is cgo, and without alsa.pc oto/v3 fails at + # `pkg-config --cflags -- alsa` before anything is compiled. + apt-get install -y -qq --no-install-recommends \ + ca-certificates curl git jq build-essential pkg-config \ + libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev ffmpeg + + # Cloned by hand rather than with actions/checkout: that is a JS + # action and needs node inside the job container before any step + # has had a chance to install it. Same approach as the other + # three workflows in this directory. + - name: Clone repo at this commit + run: | + set -eu + git clone --quiet \ + "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src + git -C /src checkout --quiet --detach "$SHA" + git -C /src log --oneline -1 + # make bindings-check compares against the work tree, so git + # has to be willing to operate on a directory it does not own. + git config --global --add safe.directory /src + + - name: Go toolchain + run: | + set -eu + if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then + mkdir -p /cache/tool && rm -rf /cache/tool/go + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz + fi + echo "/cache/tool/go/bin" >> "$GITHUB_PATH" + /cache/tool/go/bin/go version + + - name: Node toolchain + run: | + set -eu + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y -qq --no-install-recommends nodejs + corepack enable + node --version + + - name: Vitest provider browser + working-directory: /src/frontend + run: | + set -eu + pnpm install --frozen-lockfile + npx playwright install --with-deps chromium + + # `make lint` and `make test` each run all three build + # configurations (app / indexbuild / dev) with matching tag sets. + - name: Lint + working-directory: /src + run: make lint + + - name: Test + working-directory: /src + run: make test + + - name: Typecheck the frontend + working-directory: /src/frontend + run: npx tsc --noEmit + + - name: Component and store suite + working-directory: /src + run: make ui-test + + # frontend/wailsjs is generated by `wails`, not by `go generate`, + # so the codegen pre-commit hook does not cover it. + - name: Bindings are current + working-directory: /src + run: make bindings-check + + # Every `make ` named under .pi/**/*.md must exist, so an + # agent is never sent at a command that was renamed away. + - name: Documented make targets exist + working-directory: /src + run: make skill-check + + # ---------------------------------------------------------------- # + # Job 2: the real app, under a virtual display. # + # ---------------------------------------------------------------- # + e2e: + runs-on: ubuntu-latest + needs: check + container: + image: ubuntu:24.04 + volumes: + - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool + - /home/logan/docker/gitea/data/runner/cache/ms-playwright:/cache/ms-playwright + - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store + env: + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + DEBIAN_FRONTEND: noninteractive + # The explore artifact is stubbed with a dead address, exactly as + # scripts/seed-sandbox.sh does it. Serving a real cut-down + # artifact would mean building one under the indexbuild tag from + # dump state this runner does not have, and no spec asserts on + # explore content, so it would buy nothing. Note that + # dev-headless.sh does *not* set this itself — only seed-sandbox + # does — so the run would otherwise fetch the real artifact over + # the network. It is also worth ~8x on suite wall clock: the + # testctl DB restore spec copies every table, and the real + # artifact makes that table set enormous. + YJ_CORE_INDEX_URL: 'http://127.0.0.1:1/none.tar.zst' + steps: + - name: System packages + run: | + set -eu + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + ca-certificates curl git jq build-essential pkg-config \ + libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev \ + xvfb dbus dbus-x11 ffmpeg libasound2t64 + + - name: Clone repo at this commit + run: | + set -eu + git clone --quiet \ + "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src + git -C /src checkout --quiet --detach "$SHA" + git config --global --add safe.directory /src + + - name: Go toolchain + run: | + set -eu + if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then + mkdir -p /cache/tool && rm -rf /cache/tool/go + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz + fi + echo "/cache/tool/go/bin" >> "$GITHUB_PATH" + + - name: Node toolchain + run: | + set -eu + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y -qq --no-install-recommends nodejs + corepack enable + + # scripts/seed-sandbox.sh drives the real AddLibrary binding + # through playwright-cli, so the CLI has to be on PATH. + - name: Playwright CLI + run: npm install -g @playwright/cli + + - name: Browsers + working-directory: /src/e2e + run: | + set -eu + # PLAYWRIGHT_BROWSERS_PATH unifies the *location*, not the + # *revisions*: @playwright/cli bundles its own playwright-core + # pinned to a different Chromium build than @playwright/test, + # so each installs its own into the shared directory. Drop + # either line and the other fails with "Browser chromium is + # not installed; expected executable at ...". + pnpm install --frozen-lockfile + npx playwright install --with-deps chromium webkit + playwright-cli install-browser chromium + + # oto/v3 talks to libasound directly, and a container has no + # PulseAudio socket to fall back on. ALSA's null plugin advances + # its pointer on a timer rather than discarding instantly, so beep + # is consumed at real-time rate and the elapsed clock actually + # moves — which playback.spec.ts asserts. Measured: InitSpeaker + # succeeds in ~36 ms and all six playback specs pass. Without + # this, app.go joins the failure into startupErr and everything + # except playback still works, so the suite fails looking like + # flake rather than like a missing dependency. + - name: Null audio sink + run: | + printf 'pcm.!default { type null }\nctl.!default { type null }\n' > /etc/asound.conf + + - name: Fixtures and seed + working-directory: /src + run: | + set -eu + make testdata + # A seed is built by *running the app* and driving the real + # AddLibrary binding — never by writing config.toml and DB + # rows, which would be a second description of a valid YJ_HOME. + make sandbox-seed NAME=default + + # dev-headless daemonises (writes .dev/app.pid and returns), which + # is why Playwright's webServer cannot supervise it and why this is + # a step of its own. e2e/'s globalSetup checks /__test/health. + - name: Start the app headless + working-directory: /src + run: make dev-headless SEED=default + + - name: E2E — chromium + working-directory: /src + run: make e2e + + # Playwright's Linux WebKit links Ubuntu 24.04 libraries that Arch + # does not provide, so this cannot run on a dev machine at all: CI + # is the only place we get any signal about the WebKit2GTK renderer + # we actually ship. Required rather than advisory because it was + # measured green (19/19) in this exact container before being + # enabled, and because nothing in e2e/ compares pixels — every + # assertion is an event payload, a testid, an attribute or backend + # state, so a WebKit failure here is an engine bug, not baseline + # noise. It costs ~11 s. + - name: E2E — webkit + working-directory: /src + env: + YJ_E2E_WEBKIT: '1' + run: make e2e E2E_ARGS="--project=webkit" + + # The app log is the only place a hung binding call explains + # itself, so put it in the job log where `gitea_ci job_logs` can + # reach it without downloading an artifact. + - name: App log on failure + if: failure() + working-directory: /src + run: tail -n 200 .dev/app.log || true + + - name: Upload traces and screenshots + if: failure() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: e2e-report-${{ github.run_id }} + path: | + /src/e2e/playwright-report/ + /src/.dev/app.log + retention-days: 7 + + - name: Stop the app + if: always() + working-directory: /src + run: make dev-stop || true diff --git a/.gitignore b/.gitignore index f4da55b..c4354b6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,20 @@ node_modules build test_data test.db + +# ── Agent development harness (plan 005) ── +# playwright-cli scratch output (snapshots, console logs, screenshots) +.playwright-cli/ +# headless app process state: pid file + captured stdout +.dev/ +# Playwright spec output: traces, screenshots and videos of failures +e2e/test-results/ +e2e/playwright-report/ + +# Vitest browser-mode scratch (screenshot diffs, failure captures). +# The committed baselines under frontend/test/**/__screenshots__ stay. +frontend/.vitest-attachments/ + .aider* lefthook-local.yml diff --git a/.pi/journal.md b/.pi/journal.md new file mode 100644 index 0000000..fa89783 --- /dev/null +++ b/.pi/journal.md @@ -0,0 +1,112 @@ +# Work log + +Temporal memory: what happened and what's next. Structure lives in +`CLAUDE.md`, operational instructions in `.pi/skills/yellowjacket-dev/`, +measured discoveries in `.planning/NOTES.md`. Don't duplicate those here. + +## Current state + +Plan 005 (agent development harness) is **complete — all seven +phases**. Everything from phase 1 onward is still **uncommitted**: one +large but coherent working-tree diff, nothing pushed. + +All four tiers verified green from a cold, cleaned state: +`make ui-test` 313 passed, `make lint` 0 issues × 3 configurations, +`make test` green × 3 passes, `make e2e` 19 passed. Both CI jobs +verified green in a bare `ubuntu:24.04` container, including 19/19 on +WebKit. + +- [ ] **Push `.gitea/workflows/ci.yml` and confirm with `gitea_ci`.** + Nothing has run on the shared runner yet — the whole workflow was + validated locally in Docker. A run that never starts looks + identical to one that passed, so the push is not done until + `gitea_ci` says so. +- [ ] Decide whether to commit phases 1–7 as one commit or split by + phase (waiting on the user; nothing is committed yet). + +Unverified on the real runner, and the likeliest first failures: +`actions/upload-artifact` needs node in the container (it is installed +by an earlier step, and the step is `continue-on-error`, so a failure +there cannot mask a real one); and the `npm_config_store_dir` pnpm +cache is best-effort — if pnpm ignores it we lose warmth, nothing else. + +Open items deliberately not fixed: WAV tags are write-only +(`TestWAVTagsAreNotReadableYet`), `themeStore.loadFromBackend`'s failure +handler cannot recover, `backend/playlist` has no CRUD suite. + +## Log + +### 2026-08-11 — cold skill run, then phase 7 (CI) + +- **Followed the skill cold first**, as the last session asked. It + works: app up from a wiped `.dev/`, an undocumented flow driven + (queue panel + shuffle, asserted on `QueueModeChanged`), stopped — + ~1 minute, no dead ends. One real config bug: `outputDir` in + `.playwright/cli.config.json` resolves against **cwd**, not the + config file's directory (only `initScript` does that), so snapshots + were landing above the repo and a *stale* one from the previous + session answered `ls -t` instead. That cost a DOM walk to disprove a + regression that did not exist. Four smaller doc gaps fixed + (`sandbox-seed` already runs `testdata`; `ui-setup`/`e2e-setup` were + undocumented prerequisites; `snapshot` prints a path; `dev-stop` + leaves the browser open), plus `dev-headless.sh`'s own banner, which + was suggesting the bare `window.go` call its next paragraph warns + against. +- **Built both CI jobs as container scripts before writing any YAML**, + then transcribed the YAML back out and re-ran it to prove the + transcription. Push-and-see is a bad loop on a self-hosted runner. +- **It found a real bug immediately**: `make lint` omitted + `webkit2_41` on all three passes, so it was linting configurations + nothing builds. Invisible on Arch (which still ships + `webkit2gtk-4.0.pc`), fatal on Ubuntu 24.04. Tag sets now match + `make test`. +- **Both open decisions settled by measurement**: ALSA `null` PCM for + audio (no daemon; the elapsed clock really advances), dead-address + stub for the explore artifact (and setting it for the *app* run, not + just seeding, is worth 8x on suite wall clock). **WebKit is a + required step** — it had never been run anywhere, so one throwaway + container run replaced a coin flip with 19/19 at +11 s. + +### 2026-08-10 — phase 6, pi affordances + +- Added `.pi/skills/yellowjacket-dev/` as a directory rather than a flat + file: only the description is always in context, so `SKILL.md` stays + short enough that reading it whole is never a decision, and the deeper + material sits in `references/{harness,fixtures,ui-tier,schema-change}.md`. +- Settled the CLAUDE.md-vs-skill split **grammatically, not topically**, + because a topical split is what rots — every new fact gets two + plausible homes. Three docs, three tenses: NOTES.md is past + (measured, dated, append-only), CLAUDE.md is present (what the system + is), the skill is imperative (what to run). A new paragraph's tense + decides where it goes. +- The five gotchas (binding timeouts, first-run wizard, `pkill -f`, + seeds-by-running, WebKit-is-CI-only) went **inline in SKILL.md**, not + into a reference: you need them before the failure, not after. +- Trimmed CLAUDE.md's "Fixtures and the headless harness" section by + about half — the command sequences and gotchas it was carrying are now + the skill's, and leaving both would have created exactly the duplicate + description this repo has a standing rule against. +- Added `make skill-check` / `scripts/skill-check.sh` + a pre-commit + hook: every command in `.pi/**/*.md` must be a real `make` target, so + the Makefile stays the source of truth for invocation and a renamed + target fails a commit instead of misleading an agent later. Verified + it fails (it caught its own not-yet-created target) and passes. +- Added the `/e2e` prompt template: promoting a hand-driven + `playwright-cli` session into a spec is a transcription with four + fixed substitutions (refs → testids, sleeps → `waitForEvent`, raw + `window.go` → `callBinding`, short fixture → `LONG_TRACK`), plus three + runs — pass, pass again, pass after a DB restore — because the usual + failure is a spec depending on state the hand-driving left behind. +- One shell trap: under `set -euo pipefail`, `x="$(make -pqRr | …)"` + fails the whole assignment, because `make -q` exits non-zero when a + target is out of date and `pipefail` propagates it. + +### Earlier + +Phases 1–5 of plan 005: fixture generator and manifest, headless launch +and seeds, the event bridge + `data-testid` pass + `backend/testctl` + +`e2e/`, the Vitest component tier + `make bindings-check`, and the +`events.Emit` wrapper with its in-process service-event tests. Recaps +and the five "verified end to end" blocks are in +`.planning/plans/active/005-agent-development-harness.md`; the lessons +are in `.planning/NOTES.md`. diff --git a/.pi/prompts/e2e.md b/.pi/prompts/e2e.md new file mode 100644 index 0000000..d3995e4 --- /dev/null +++ b/.pi/prompts/e2e.md @@ -0,0 +1,55 @@ +--- +description: Promote a hand-driven playwright-cli session into a committed spec in e2e/ +argument-hint: "[name of the flow]" +--- +Promote the flow I just drove by hand into a committed Playwright spec. +Flow: ${@:-infer it from the playwright-cli commands in this session} + +This is a transcription with fixed substitutions, not a fresh test. +Work from what actually happened in this session, not from what the UI +looks like it should do. + +**1. Recover the flow.** List the `playwright-cli` calls made this +session, in order, and the assertion each one was really checking. Then, +before writing anything, ask the running app what fired: + +``` +playwright-cli -s=yj eval "() => window.__yjEvents.names()" +``` + +Await the events that are actually in that list. Do not guess event +names from `backend/events/`. + +**2. Substitute, one for one.** + +- `click e15` → a role or `data-testid` selector. Snapshot refs are + per-snapshot and meaningless in a spec. If the only stable selector + would be structural, add a `data-testid` to the Lit component and + re-run `make ui-test`. +- any sleep, or "it looked settled" → `waitForEvent(app, 'X')`. +- `window.go.…` → `callBinding(app, path, args)`, which times out. +- a short fixture track → `LONG_TRACK`, if the flow needs playback to + still be running on the next line. Every other fixture is 2–6 s. +- `getByRole('button', { name })` → add `exact: true`. + +**3. Place it.** `e2e/specs/.spec.ts`, importing `test`, `expect` +and the helpers from `../support/fixtures.js` — never `@playwright/test` +directly. Match the surrounding specs' comment style: say what the test +is protecting against, not what the lines do. + +**4. Prove it is a spec and not a recording.** Three runs, in order: + +``` +make e2e E2E_ARGS='--grep ""' # it passes +make e2e E2E_ARGS='--grep ""' # again — catches dependence on + # state the first run left +``` + +then once more after restoring the database through `/__test/`, which +catches dependence on state *my hand-driving* left behind — the single +most likely way a promoted spec passes here and fails in CI. Leave the +database as you found it: snapshot/restore, or reset in `beforeEach`. + +**5. Then the whole suite:** `make e2e`. If the promotion turned up a +new trap, append it to `.planning/NOTES.md`; if it turned up a bug, +tell me rather than asserting the broken behaviour. diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 0000000..130a365 --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,3 @@ +{ + "skills": ["../.claude/skills"] +} diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md new file mode 100644 index 0000000..23ccda8 --- /dev/null +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -0,0 +1,163 @@ +--- +name: yellowjacket-dev +description: Operating YellowJacket's development harness — which of the four test tiers to use for a given change, how to run the app headless and drive it with playwright-cli, seed and sandbox lifecycle, the three build-tag passes, and the failure modes that waste a cycle if you meet them cold. Use whenever building, running, testing or debugging this repo. +--- + +# Working on YellowJacket + +`CLAUDE.md` says what this system **is**. This skill says what to +**run**. `.planning/NOTES.md` records what we **measured** and when. +Keep them in those three tenses: if something here is wrong, fix it +here and add the discovery to `NOTES.md` — do not add a corrective +paragraph to `CLAUDE.md`. + +Every command below is a `make` target on purpose. The Makefile is the +source of truth for *how* to invoke something; this file only decides +*which* and *in what order*. `make skill-check` fails if a target named +here has disappeared. + +## Read this part before you fail + +Five things cost a cycle each the first time. They are here, not in a +reference, because you need them *before* the failure, not after. + +- **Time out every binding call.** A bound Go method called with wrong + argument types makes the backend log `error parsing arguments` and + **never fire the callback**, so the promise hangs forever. Use + `window.__yjEvents.call(path, args, ms)` (browser) or `callBinding` + (specs), never a bare `window.go.…`. When one hangs anyway, + `make dev-logs` — `.dev/app.log` is the only place the reason appears. +- **Nothing is clickable on a fresh `YJ_HOME`.** `` + intercepts all pointer events until a library exists, and the click + fails with a Playwright interception error that reads like a selector + bug. Use a seed unless you are *testing* the wizard, in which case + `make dev-headless-fresh`. +- **Never `pkill -f`.** The pattern matches the invoking shell's own + command line, killing it and silently dropping the rest of your + compound command. `make dev-stop` kills by saved PID. +- **Seeds are produced by running the app**, never by hand-writing a + `config.toml` and DB rows — a hand-built `YJ_HOME` is a second + description of a valid one and will drift. `make sandbox-seed` drives + the real `AddLibrary` binding and waits for the real scan. +- **Playwright's WebKit does not run on Arch** (Ubuntu-only libs). + `--browser=webkit` is CI-only; local work is Chromium. + +## Which tier + +Four tiers. Start at the cheapest one that can see your change, and +only climb when it cannot. + +| You changed | Run | Cost | +|---|---|---| +| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app | +| …and it renders differently | `make ui-visual` | + 6 baselines, opt-in | +| Any Go code | `make test` | 3 passes, ~2 min | +| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app | +| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s | +| A user-visible flow across frontend *and* backend | `make e2e` (needs the app up) | ~1 min | +| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive | +| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | | + +Two targets are once-per-clone prerequisites that are **not** +dependencies of the targets needing them, so on a fresh checkout each +fails with a missing-browser error that reads like a broken test: +`make ui-setup` before `make ui-test`, and `make e2e-setup` before +`make e2e`. (`make testdata` *is* a dependency of `make test` and +`make sandbox-seed`; run it by hand only when invoking `go test` +directly, since anything using `internal/testfixtures` **skips** +rather than fails without it — a green run without the library means +less than it looks.) + +Two rules about climbing: + +- **A component test passing is not the app rendering.** If you touched + anything in `frontend/src`, verify it in the real app too — start it + headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*. +- **Do not write an e2e spec first.** Drive the flow by hand, then + promote it with `/e2e`. Specs written blind assert on selectors that + do not exist. + +Before a commit, the gate is `make lint`, `make test`, `make ui-test` +and `make bindings-check` — all four are also lefthook hooks, so +skipping them locally only defers the failure. + +## Running the app + +The app cannot be started without a display: `devserver.Run` ends in a +blocking GTK window with no flag to suppress it. The harness gives it a +virtual one and returns. + +```bash +make sandbox-seed NAME=default # once (~10 s; runs make testdata itself, + # then builds a seed by running the app) +make dev-headless SEED=default # starts in the background, returns when :34115 answers +make dev-logs # tail .dev/app.log +make dev-stop # SIGTERM, so shutdown hooks persist state +``` + +Then drive it. Run `playwright-cli` **from the repo root** — it picks +up `.playwright/cli.config.json` from the cwd, and writes its snapshots +and console logs to `.playwright-cli/` relative to the cwd too. `playwright-cli`'s own skill covers the commands; what +is specific here is that a session must be *named* so it survives +across separate shell calls: + +```bash +playwright-cli -s=yj open http://localhost:34115 +playwright-cli -s=yj snapshot # a11y tree, pierces shadow DOM +playwright-cli -s=yj screenshot --filename=/tmp/shot.png +playwright-cli -s=yj eval "() => window.__yjEvents.names()" +playwright-cli -s=yj eval "() => window.__yjEvents.call('queue.Queue.GetState', [], 5000)" +playwright-cli -s=yj click e391 # ref from the snapshot +playwright-cli -s=yj close # `make dev-stop` does not do this +``` + +`snapshot` prints a *path*, not the tree — read the file it names, and +check the timestamp, because a stale one from a previous session sits +in the same directory. + +`.playwright/cli.config.json` is picked up automatically: it sets the +viewport, `data-testid`, and the init script that installs the event +bridge. **Assert on an event, not a timeout** — half this app is +push-driven. The bridge and the dev-only `/__test/` control surface are +documented in [references/harness.md](references/harness.md). + +Other `YJ_HOME`s exist for humans and block the terminal: `make dev`, +`make sandbox `, `make fresh-install`. Do not use them; you will +never get the shell back. + +## Go, and the three build configurations + +`make test` and `make lint` already run all three. Spell them out only +when iterating on a single package: + +```bash +go test -tags webkit2_41 ./backend/player/ # the app build +go test -tags webkit2_41 -run TestName ./backend/player/ +go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/... # dump importer +go test -tags "webkit2_41 dev" ./backend/testctl/... # control surface +``` + +Forgetting the tag gives a build error that looks like a missing +package. Audio integration tests additionally need +`YELLOWJACKET_INTEGRATION=1`. + +Three things golangci-lint v2 will reject that are easy to write: +a dynamic `fmt.Errorf` without a sentinel (`err113`), a `return` with +no blank line before it (`nlreturn`), and a long `//nolint` comment on +the same line as its statement (`golines` reflows it and breaks the +directive) — put the directive on its own line above. + +**Emit events through `events.Emit(ctx, …)`, never +`runtime.EventsEmit`.** `TestNoDirectRuntimeEmits` walks the tree and +fails the build otherwise, including in files no lint pass compiles. + +## References + +- [harness.md](references/harness.md) — the event bridge API, the + `/__test/` endpoints, and the config traps. +- [fixtures.md](references/fixtures.md) — the generated library, the + manifest, and selecting fixtures by case. +- [ui-tier.md](references/ui-tier.md) — how the Vitest tier fakes Wails, + and what breaks in it. +- [schema-change.md](references/schema-change.md) — the two-file + schema/migration checklist. diff --git a/.pi/skills/yellowjacket-dev/references/fixtures.md b/.pi/skills/yellowjacket-dev/references/fixtures.md new file mode 100644 index 0000000..77579ab --- /dev/null +++ b/.pi/skills/yellowjacket-dev/references/fixtures.md @@ -0,0 +1,67 @@ +# The fixture library + +`test_data/music_library_test/` is **generated, not committed**: +`make testdata` (~1 s) builds 31 deterministic tracks across MP3, FLAC, +Ogg Vorbis and WAV. `make testdata-force` rebuilds unconditionally, +`make testdata-clean` deletes it. `make test` and `make sandbox-seed` +depend on it, so it is rarely run by hand. + +Tests that need it fetch it through `internal/testfixtures` and skip +themselves when it has not been generated. + +## Select by case, never by path + +```go +m := testfixtures.Load(t) +paths := m.Case(t, testfixtures.CaseCoverDedup) +track := m.Track(t, rel) +``` + +Cases: `cover-dedup`, `multi-disc`, `various-artists`, `flac-album`, +`ogg-album`, `wav-tracks`, `partial-tags`, `unicode`, `duplicates`, +`edge-lengths`, `broken`. + +Two invariants worth not breaking: + +- **The clean library is exactly 31 tracks**, because `sandbox-seed` + verifies the scan against that count. Deliberately malformed files + live in a *sibling* root, `test_data/music_library_broken/` + (`m.BrokenPath()`), so the scanner never sees them. +- **Tags are written by `backend/tagwriter`, not by ffmpeg** (which + encodes with `-map_metadata -1`). Fixture and reader therefore cannot + drift into agreeing with each other and disagreeing with reality. + +The manifest (`test_data/music_library_test.manifest.json`, outside the +scanned root) hashes the *spec* — paths, formats, durations, tags, +cover identity — not the bytes, because ffmpeg stamps encoder version +strings and identical specs produce different bytes on different builds. + +## In e2e specs + +- **Every fixture except one is 2–6 seconds.** A spec that starts + playback and then clicks pause races the track ending and fails + against a correct UI. Use `LONG_TRACK` (90 s, `edge-lengths`) exported + from `e2e/support/fixtures.ts`. +- **WAV tracks scan in untitled.** `backend/tagwriter` writes WAV tags + into a RIFF `id3 ` chunk and `dhowden/tag` has no RIFF parser, so + there is no "Field Recordings" artist in the Artists view. This is a + known open bug pinned by `TestWAVTagsAreNotReadableYet`; do not + "fix" a spec by asserting the broken behaviour elsewhere. + +## Seeds + +```bash +make sandbox-seed NAME=default # build (boots a fresh YJ_HOME and drives the app) +make sandbox-seeds # list +make dev-headless SEED=default # restore into a run +``` + +A seed is a tarred `YJ_HOME` produced by *running the app*: fresh home → +real `AddLibrary` binding → poll until the real scan reports the +manifest's track count → SIGTERM so shutdown hooks persist state → tar. +Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead +address on purpose, so no seed depends on what the explore artifact +server happened to be serving. + +Rebuild a seed after any schema change, or the restored database is +migrated on open in a way the seed's author never saw. diff --git a/.pi/skills/yellowjacket-dev/references/harness.md b/.pi/skills/yellowjacket-dev/references/harness.md new file mode 100644 index 0000000..7b30a7c --- /dev/null +++ b/.pi/skills/yellowjacket-dev/references/harness.md @@ -0,0 +1,85 @@ +# The harness: event bridge and control surface + +Two things ride on top of the headless app. Both exist only in dev +builds; neither is reachable from a shipped binary. + +## The event bridge (`.playwright/init-events.js`) + +Loaded as an `initScript` by `.playwright/cli.config.json` and by +`e2e/support/fixtures.ts`, so an exploratory session and a committed +spec see an identical page. It records every backend event by wrapping +`window.wails.EventsNotify` — the single choke point all 46 events pass +through, whether or not the app subscribes to them. + +```js +window.__yjEvents.wait('LibraryScanComplete', { timeoutMs: 60000 }) +window.__yjEvents.names() // name -> count; use this to find out + // what actually fired before asserting +window.__yjEvents.last('QueueChanged') +window.__yjEvents.since(seq) +window.__yjEvents.reset() // drop the buffer +window.__yjEvents.ready(20000) // resolves when a binding round-trips, + // which is later than DOM-ready and true +window.__yjEvents.call('queue.Queue.GetState', [], 5000) +``` + +- **`wait` resolves against already-buffered events as well as future + ones**, so there is no race between doing the thing and listening. +- **Install exactly one recorder.** Listeners survive across `eval` + calls; a second recorder double-counts. Call `reset()`, never + re-register. +- **`call` times out on purpose.** A binding with wrong argument types + never fires its callback. A 5 s rejection naming `.dev/app.log` beats + an infinite hang. + +In specs, use the wrappers rather than `page.evaluate`: +`waitForEvent`, `resetEvents`, `eventNames`, `callBinding`, and the +`app` fixture (a page with the bridge installed and the backend +actually answering) from `e2e/support/fixtures.ts`. + +## The control surface (`backend/testctl`, mounted at `/__test/`) + +Gated twice: behind the `dev` build tag (with a no-op `!dev` twin) and +behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and +`make dev` does not. + +| Endpoint | Use | +|---|---| +| `GET /__test/health` | is this a seeded dev build, and which library | +| `POST /__test/db/snapshot?name=X` | save the SQLite state | +| `POST /__test/db/restore?name=X` | put it back (see below) | +| `POST /__test/emit` `{name, data}` | force any backend event | +| `POST /__test/sql` `{sql, args}` | read rows, or a write count | + +`TestCtl` in `e2e/support/fixtures.ts` is the typed client. + +- **`emit` is the fast way to render a push-driven view** without + staging the work that would produce it — job progress, download + progress, scan progress. It calls `events.Deliver`, which *errors* + when the event reaches nobody, so a `200` means it really arrived. +- **`restore` is slow** (~40 s in the suite) because it copies every + table. Prefer snapshotting once and restoring only when a spec + genuinely mutates state. + +## Traps in the config + +- **The two path keys in `.playwright/cli.config.json` resolve + differently.** `initScript` is relative to the *config file's* + directory (`"init-events.js"`, not `".playwright/init-events.js"`); + `outputDir` is relative to the *shell's cwd*. Set `outputDir` to + `".playwright-cli"` and run `playwright-cli` from the repo root, or + snapshots land somewhere neither `.gitignore` nor your next `ls` + will find, and you will read a stale one from a previous session + and think a component regressed. +- **`snapshot` writes a file, it does not print the tree.** The + command prints a path under `outputDir`; read that. Only the tail + is echoed. +- **Three separate browser caches.** `playwright-cli`, `@playwright/test` + (`make e2e-setup`) and the Vitest provider (`make ui-setup`) each + download their own Chromium. One working is no guarantee for the next. +- **`getByRole('button', { name })` matches substrings.** "Play" also + matches "Add queue to playlist"; transport controls need + `exact: true`. +- **`e2e/` is its own npm package** with `"type": "module"`. Without + that, Playwright transpiles the specs to CJS and every `import.meta` + throws — reported, unhelpfully, as "No tests found". diff --git a/.pi/skills/yellowjacket-dev/references/schema-change.md b/.pi/skills/yellowjacket-dev/references/schema-change.md new file mode 100644 index 0000000..b74f1c3 --- /dev/null +++ b/.pi/skills/yellowjacket-dev/references/schema-change.md @@ -0,0 +1,52 @@ +# Changing the database schema + +The reasoning — why there are two files, what the old 48-step migration +chain got wrong, and when squashing is legitimate — is in `CLAUDE.md` +under *Backend packages → database*. Read it once. This is the +checklist. + +A schema change needs **two** files, not one: + +1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT + EXISTS`, the literal target shape, what sqlc reads and what a fresh + install gets verbatim. Add the new column **last** in the + `CREATE TABLE`. +2. **`backend/database/sql/migrations/NNNN_description.sql`** — the + `ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an + existing database to the same shape. Schema files are a no-op against + a table that already exists, so without this an upgrade never gets + the column. + +Then: + +```bash +make generate # sqlc + templ +go test -tags webkit2_41 ./backend/database/ # migration + column-order tests +make test +``` + +Rebuild any seed you rely on (`make sandbox-seed NAME=default`) and +delete your own dev `YJ_HOME` if you want to see the fresh-install path +rather than the migrated one. + +## The three ways this goes wrong + +- **Column order must match between the two paths.** `ADD COLUMN` + always appends, so a migrated column declared anywhere but last in + `CREATE TABLE` leaves fresh and upgraded installs disagreeing on + order — and sqlc binds `SELECT *` positionally, so one of them + silently reads the wrong field. + `TestMigrations_ColumnOrderMatchesFreshInstall` is the regression test. +- **Do not put an index on a migrated column in `sql/schemas/`.** + Schema files run *before* migrations, against a database that may not + have the column yet, and the predicate fails. Declare the index in the + migration, after the `ALTER TABLE`. +- **Do not add a third description of the schema anywhere.** A + migration's `ADD COLUMN` failing with "duplicate column name" against + an already-current database is expected and tolerated, not an error to + route around. + +New queries go in `backend/database/sql/queries/`; generated Go lands in +`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests +use `database.NewTestDB(t)`, built by the same `applySchema` production +uses, so the two cannot diverge. diff --git a/.pi/skills/yellowjacket-dev/references/ui-tier.md b/.pi/skills/yellowjacket-dev/references/ui-tier.md new file mode 100644 index 0000000..37f7d6d --- /dev/null +++ b/.pi/skills/yellowjacket-dev/references/ui-tier.md @@ -0,0 +1,74 @@ +# The component and store tier (`make ui-test`) + +313 tests in a real Chromium in ~2 s with no Wails, no backend, no +seeded library and no virtual display. This is the cheapest coverage +available and where the bulk of UI regression belongs. + +```bash +make ui-setup # once: the Vitest provider's own Chromium +make ui-test # behaviour only +make ui-watch +make ui-visual # + toMatchScreenshot baselines (YJ_VISUAL=1) +make ui-visual-update # re-record them +make ui-test UI_ARGS='store/queue' # filter +``` + +## How it works + +`frontend/wailsjs/` is a pure passthrough — every binding is +`window.go[svc][Type][Method](args)`, every runtime call is +`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts` +replaces **those two globals and nothing else**, and the tests then +exercise the *real* generated bindings and the *real* store code. No +module mocking, and no second description of the Wails layer. + +```ts +emit(Events.QueueChanged, payload); // push a backend event +stub('queue.Queue.GetState', state); // a value, or a function of the args +stubFailure('queue.Queue.SetQueue'); // reject, as a Go error does +calls('queue.Queue.SetQueue'); // what the frontend called back with +lastArgs('queue.Queue.SetQueue'); +const el = await fixture('now-playing'); // mount; shadow()/text() query it +``` + +The dispatcher mirrors wails' own `desktop/events.js`, including +`maxCallbacks` expiry and the fact that a frontend `EventsEmit` +notifies local listeners *before* Go. + +## Four things that will cost you time + +- **Store singletons are constructed at module import**, before any test + can stub. `test/setup.ts` therefore carries import-time defaults for + the stores that read config in their constructor. Without one, a store + caches `undefined` where Go would have sent `[]`, and components crash + on `.length` — which reads exactly like a component bug and is not. + Adding a store that reads config on construction means adding its + default there. +- **`vitest.config.mts`, not `.ts`** — it `mergeConfig`s the repo's + `vite.config.mts` to reuse the `@go`/`@store`/`@components` aliases, + and a `.ts` sibling cannot import it. +- **Screenshots need the theme.** The setup file imports + `@store/theme-store` for its side effect (it applies the `--yj-*` + ramp to `:root`); without it a component renders white-on-white and + the baseline is blank. +- **`@lit-labs/virtualizer` never produces two identical frames**, so + `toMatchScreenshot` on `` fails with "could not capture a + stable screenshot" rather than a diff. Assert on its rows instead. + +Visual baselines are font-hinting and compositing sensitive, which is +why they are opt-in: they only mean anything on the machine that +recorded them. + +## Bindings + +`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`, +so the pre-commit codegen check does not cover it — a renamed Go bound +method first shows up at runtime, as a call that never settles. + +```bash +make bindings-check # ~1.5 s, also a pre-commit hook +make bindings # regenerate for real +``` + +The generator rewrites `wailsjs/runtime/*` as mode 755 every run; that +is churn, not drift, and the check ignores it. diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 25248d7..1d5c5fb 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -168,3 +168,473 @@ built from scratch rather than restored; the old implementation gated several component code paths in ad hoc ways. A separate `deep_catalog_enabled` backend flag briefly existed for the same idea and was removed earlier, when the dump importer left the app binary. + +## The dev server is a real, drivable app (verified 2026-08-10) + +`wails dev` binds an HTTP + WebSocket server on `localhost:34115` +(`internal/frontend/devserver/devserver.go`) that serves the frontend +with the generated bindings on `window.go` and bridges every call and +every `runtime.EventsEmit` to the **same** Go backend the desktop +window uses. A browser pointed at it is not a mock — measured: +`queue.Queue.GetState()` returned real JSON, `SetVolume(42)` produced a +real `VolumeChanged`. Multiple clients are supported by design +(`notifyExcludingSender` fans events to the other web clients *and* the +desktop frontend). See plan 005. + +Four facts that cost time to find: + +- **The GTK window cannot be suppressed.** `devserver.Run` ends in + `d.Frontend.Run(ctx)` with no flag to skip it, so headless needs + Xvfb. Nobody upstream has a way around this. +- **Build the dev binary directly.** `app_dev.go` parses `-devserver`, + `-assetdir`, `-loglevel` from `os.Args`, so + `go build -tags "dev webkit2_41"` plus those flags gives the same + server with no watcher, no reload broadcast and one PID. +- **A binding call with wrong argument types hangs forever.** The + backend logs `error parsing arguments` and never fires the callback, + so the caller's promise never settles. Always use a timeout; the app + log is the only place the reason shows up. +- **`dbus-run-session` does not break audio, and fixes MPRIS.** It + replaces the bus, not `/run/user/1000`, so PulseAudio still works and + `org.mpris.MediaPlayer2.yellowjacket` registers on the private bus. + +## Playwright's WebKit does not run on Arch (measured 2026-08-10) + +`playwright-cli install-browser webkit` downloads fine and then fails to +link: its Linux build wants Ubuntu 24.04 libraries (`libicu74`, +`libWPEWebKit-2.0.so.1`, `libflite`) that Arch does not provide, and the +dependency check emits `apt-get` advice. So `--browser=webkit` — the +cheap way to approximate the WebKit2GTK renderer we actually ship — is a +CI-only capability. Local browser work is Chromium, which is unaffected. + +## The fixture library is generated, and generated by our own writers + +`test_data/music_library_test/` is produced by `cmd/gentestdata` +(`make testdata`), not committed — 31 tracks across MP3, FLAC, Ogg +Vorbis and WAV, ~700 KB, ~1 s to build. Two rules keep it honest: + +- **Tags are written by `backend/tagwriter`, not by ffmpeg.** ffmpeg + only encodes (with `-map_metadata -1`); every tag comes from the same + writers the app uses, so a fixture and the reader under test cannot + drift into agreeing with each other and disagreeing with reality. + `tagwriter.WriteFileTags` exists for this — it is the format switch + `WriteUntrackedFileTags` already had, lifted out so tooling with no + app to construct can call it. +- **The manifest hash covers the spec, not the bytes.** ffmpeg stamps + encoder version strings, so identical specs produce different bytes + on different ffmpeg builds. `test_data/music_library_test.manifest.json` + hashes paths, formats, durations, tags and cover identity instead, + and lives *outside* the library root so the scanner never sees it. + +Fixtures are selected in tests by *case* (`testfixtures.CaseCoverDedup`, +`CaseUnicode`, `CaseDuplicates`, …) rather than by path. Deliberately +malformed files live in a sibling root, `test_data/music_library_broken/` +— the clean library's track count has to stay at exactly 31 for seeds +to be verifiable, and a zero-byte `.flac` in the scanned tree used to +get swept into `testFlacFiles` and fail the duration parser. + +## WAV tags are write-only (found 2026-08-10) + +`backend/tagwriter` writes WAV tags into a RIFF `id3 ` chunk, and +`backend/metadata` reads through `dhowden/tag`, which recognises MP3, +FLAC, OGG, MP4 and DSF and has **no RIFF parser at all**. So every tag +the app writes to a WAV is invisible to the app that wrote it, and WAV +tracks always scan in untitled — visible in the Artists view, where the +fixture library's WAV tracks produce no "Field Recordings" artist. + +The fix is small (unwrap the `id3 ` chunk and hand the payload to +`tag.ReadFrom`) but was out of scope for plan 005. +`TestWAVTagsAreNotReadableYet` asserts the gap so that fixing the +reader turns into a failing test rather than nothing at all. + +## The headless harness: how to run this app without a window + +`make dev-headless [SEED=]` starts the app in the background and +returns; `make dev-stop`, `make dev-logs`. It runs the dev *binary* +(`go build -tags "dev webkit2_41"`), not `wails dev`, under +`dbus-run-session -- xvfb-run`. See plan 005 and +`scripts/dev-headless.sh` for why each of those three is load-bearing. + +**Seeds are built by running the app.** The first-run wizard's +dismissal condition is not a config file — it is +`GetAllLibrariesWithTrackCounts()` returning something — so +`make sandbox-seed NAME=` boots a fresh `YJ_HOME`, calls the real +`AddLibrary` binding through `playwright-cli`, waits for the real scan +to reach the manifest's track count, stops the app with SIGTERM so the +shutdown hooks persist state, and tars the result. Never hand-write a +`config.toml` and DB rows: that is a second description of a valid +`YJ_HOME`, free to drift, exactly like the migration chain was. + +Seeding points `YJ_CORE_INDEX_URL` at a dead address on purpose, so no +seed depends on what the explore artifact server was serving that day. + +Two parsing traps, both already paid for: + +- `playwright-cli` echoes the evaluated source back after the result, + so scraping its output for bare digits picks up numbers from your own + JavaScript. Return a tagged sentinel (`'YJTRACKS' + '=' + n`) and + grep for that. +- Waiting on a fixed sleep or on a scan event is worse than waiting on + the observable outcome. Polling the track count the app itself + reports also validates the fixture manifest against the real scanner. + +## Restoring a database needs foreign keys *off*, not deferred + +`/__test/db/restore` (`backend/testctl`) copies every ordinary table +out of an ATTACHed snapshot. The obvious implementation — one +transaction with `PRAGMA defer_foreign_keys = ON` — fails at COMMIT +with a bare `FOREIGN KEY constraint failed (787)` that names nothing. + +Deferring postpones the *check*; it does not stop `ON DELETE CASCADE` +from firing. Tables are copied in name order, which is not dependency +order, so `DELETE FROM libraries` cascades away rows of a child table +that was already restored earlier in the loop, and the final state is +genuinely inconsistent. + +`PRAGMA foreign_keys` is a no-op inside a transaction, so it has to be +set on the connection around it. That is safe only because the writer +is a single connection (`SetMaxOpenConns(1)`); the restore re-enables +enforcement afterwards and runs `PRAGMA foreign_key_check`, so a bad +restore is reported instead of left in place. +`TestRestoreRoundTrip` pins it. + +Two related traps in the same path: FTS5 virtual tables cannot be +written with `SELECT *` and their shadow tables (`_data`, `_idx`, +`_docsize`, `_config`) must be rebuilt rather than copied — but the +prefix test that excludes them must not swallow `explore_index`, an +ordinary table whose name is a prefix of two virtual ones. + +## The event bridge hooks EventsNotify, not EventsOn + +`.playwright/init-events.js` records backend events by wrapping +`window.wails.EventsNotify`. That is the single choke point: wails' +`ipc_websocket.js` does `case "n": window.wails.EventsNotify(msg)` and +fans out to listeners from there, so one wrap captures all 46 events +whether or not the app subscribes to them. Wrapping `EventsOn` would +have needed 46 registrations and would have missed anything the app +does not listen for. + +`window.wails` does not exist when an initScript runs, so the script +installs an accessor on `window` and wraps at assignment time (wails' +`main.js` does a plain `window.wails = {...}`), then redefines the +property as a plain value so nothing downstream can tell. + +The buffer lives on `window.__yjEvents` with `wait()`, `reset()`, +`names()`, a `ready()` that resolves only when a binding actually +round-trips, and a `call()` that **times out** — a binding invoked with +wrong argument types never fires its callback, and a 2s rejection +naming `.dev/app.log` is worth more than an infinite hang. + +## Small harness traps, each of which cost a cycle + +- **Paths in `.playwright/cli.config.json` resolve against the config + file's directory**, not the repo root. `".playwright/init-events.js"` + becomes `.playwright/.playwright/init-events.js`. +- **`playwright-cli` and `@playwright/test` have separate browser + caches.** The CLI working is no guarantee `npx playwright test` can + launch; it needs its own `npx playwright install chromium` (the + runner wants `chrome-headless-shell`, which the CLI never fetched). +- **`getByRole('button', { name: 'Play' })` also matches "Add queue to + playlist".** Accessible-name matching is substring by default; the + transport controls need `exact: true`. +- **Every fixture track except one is 2–6 seconds.** A spec that plays + a track and then clicks pause races the track ending and fails + against a correct UI. Use the 90-second `Long Player` + (`edge-lengths`), exported as `LONG_TRACK` from `e2e/support`. +- **`e2e/` needs `"type": "module"`** or Playwright transpiles the + specs to CJS and every `import.meta` in the support code throws + "Cannot use 'import.meta' outside a module" — reported as + "No tests found". + +## The component tier fakes two globals, and that is all it fakes + +`frontend/wailsjs/` is a pure passthrough: every generated binding is +`window['go'][svc][Type][Method](args)` and every runtime call is +`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts` +replaces those two globals and nothing else, and the tests then run the +*real* generated bindings and the *real* store code. No module mocking, +and no second description of the Wails layer to drift from the first — +the same discipline `sql/schemas/` and the seeds get. + +The dispatcher mirrors wails' own `internal/frontend/runtime/desktop/ +events.js`, which matters in two places: listeners registered with +`maxCallbacks` expire and are removed mid-iteration, and `EventsEmit` +from the frontend notifies local JS listeners *before* it notifies Go +(so a frontend emit is observable in-page). + +Four things that cost time: + +- **Store singletons are constructed at module import**, so the fake + must be installed from `setupFiles`, and any store that reads config + in its constructor loads before a test can stub it. `test/setup.ts` + carries import-time defaults for exactly those. Without them a store + caches `undefined` where Go would have sent `[]`, and four + components then crash on `.length` — which reads as a component bug + and is not one. +- **`vitest.config.mts`, not `.ts`.** The repo's vite config is + `vite.config.mts`; a `.ts` sibling cannot import it, and `mergeConfig` + is how the `@go`/`@store`/`@components` aliases get reused rather + than restated. +- **Vitest 4 takes a provider factory, not a string.** + `provider: playwright()` from `@vitest/browser-playwright`, which is + a third package beyond `vitest` and `@vitest/browser`, and needs its + own `npx playwright install chromium` — a third browser cache after + `playwright-cli`'s and `@playwright/test`'s. +- **Pre-bundle Web Awesome or Vite reloads mid-test.** Its components + are one deep import per element; discovering them lazily makes Vite + re-optimise and reload the page underneath a running test. The glob + `@awesome.me/webawesome/dist/components/*/*.js` in `optimizeDeps. + include` settles it. + +Screenshots need the app's surface, not the default white page: the +setup file imports `@store/theme-store` for its side effect (it applies +the `--yj-*` ramp to `:root`) and sets the two `index.css` declarations +that matter, or a component renders white-on-white and the baseline is +blank. And a `@lit-labs/virtualizer` list never produces two identical +frames, so `toMatchScreenshot` on `` fails with "could not +capture a stable screenshot" rather than a diff — assert its rows +instead. + +## Binding drift is now checked, and it is fast + +`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`, +so the pre-commit codegen check never covered it: a renamed Go bound +method or struct field first showed up at runtime, in a window, as a +call that never settles. `wails generate module` (v2.10.2) rebuilds it +in ~1.5 s, which is cheap enough to gate a commit on — +`scripts/bindings-check.sh`, `make bindings-check`, and a `lefthook` +pre-commit entry. Verified by renaming `queue.GetState` and watching it +fail. + +One quirk: the generator rewrites the three `wailsjs/runtime/` files as +mode 755 every run. That is not drift, so the check compares with +`git -c core.fileMode=false` and restores the modes afterwards. + +## Emitting an event is now one call, and it cannot kill the process + +`events.Emit(ctx, name, data...)` (`backend/events/emit.go`) is the +only supported way to push a Wails event; `TestNoDirectRuntimeEmits` +fails the build on any other `runtime.EventsEmit` in the tree. + +The reason is that `runtime.getEvents` (wails `runtime.go:47`) +`log.Fatalf`s — `os.Exit`, unrecoverable — whenever the context lacks +its internal `"events"` value. That is any `context.Background()`, so +in-process service tests were impossible and background workers that +outlived their context could take the app down at launch. + +Four packages had independently discovered this and hand-rolled a +guard (`library.emit`, `download.emit`, `playlist.emitEvent`, and +`autotagservice.emitEvent` with a whole `ctxReady` field). Nine other +sites guarded on `ctx != nil`, **which does not help** — a non-nil +context without the runtime is exactly the fatal case. The wrapper +replicates wails' own precondition once and drops at debug level. + +Three things worth knowing: + +- **The test sink rides in the context**, `events.WithSink(ctx, rec)`, + not in a package global. A global cannot survive `t.Parallel()` and + would put a mutex on every production emit. +- **`events.Deliver` is `Emit` that returns `ErrNoRuntime`**, and has + exactly one caller: `/__test/emit` in `backend/testctl`. That + endpoint exists to *impersonate* a backend emit, so answering `200` + for an event that reached nobody would send you debugging the + frontend for a backend no-op. Ordinary emitters want `Emit`. +- **Enforcement is a walk of the tree, not a lint rule.** + golangci-lint runs once per build configuration, so a stray emit in + an `indexbuild`- or `dev`-tagged file is only visible to the pass + that compiles it. One text walk sees all three, plus anything tagged + out entirely. Two traps if you touch that test: the needle has to be + built at runtime or the file matches itself, and it has to be the + qualified selector (`.EventsEmit(`) or it matches the test's own + function name. + +What it unblocks is a fourth test tier — services, in-process, with no +app: `backend/queue/emit_test.go`, `backend/config/emit_test.go`, +`backend/playlist/emit_test.go` assert on the payload the *frontend +receives*, which had never been covered. Two gotchas found writing +them: `config.Save` refuses to write a config that was never +`Load`ed (so a test that only calls `applyDefaults` sees its second +setter fail, not its first), and `queue.SetQueue` resolves anything +over `initialBatchSize` in a background phase, so assert with +`rec.Wait` rather than immediately after the call. + +## Docs are split by tense, and the split is checkable + +Three places now describe this repo, and the rule for which one a new +paragraph goes in is **grammatical, not topical** — a topical split +("architecture here, testing there") is what rots, because every new +fact has two plausible homes. + +- `.planning/NOTES.md` — **past**: measured, dated, append-only. +- `CLAUDE.md` — **present**: what the system is, and why. +- `.pi/skills/yellowjacket-dev/` — **imperative**: what to run, in what + order, and what it looks like when it fails. + +So the skill carries the checklist for a schema change and CLAUDE.md +carries the reasoning behind the two-file rule; the skill carries the +headless lifecycle and CLAUDE.md carries only the invariant that seeds +are produced by running the app. Phase 6 deleted about half of +CLAUDE.md's harness section on those grounds. + +`make skill-check` (`scripts/skill-check.sh`, pre-commit) makes it +enforceable: every `make ` mentioned under `.pi/**/*.md` must +exist. That is the actual anti-drift mechanism — the Makefile is the +source of truth for *how* to invoke something and the skill only decides +*which*, so a renamed target fails a commit instead of sending an agent +confidently at a command that no longer exists. A skill that documents a +command slightly wrong is worse than no skill. + +One shell trap it cost: under `set -euo pipefail`, +`x="$(make -pqRr | awk … )"` sinks the whole assignment, because +`make -q` exits non-zero whenever a target is out of date and `pipefail` +propagates that. Wrap it in `{ …; || true; }`. + +## The skill was followed cold, and lost time in exactly one place + +An agent that did not write `.pi/skills/yellowjacket-dev/` brought the +app up from a wiped `.dev/` and no fixture library, drove a flow the +skill does not describe (open the queue panel, toggle shuffle, assert +on `QueueModeChanged`, confirm against `queue.Queue.GetState`) and +stopped it — about a minute of wall clock, no dead ends. All four +tiers then re-ran green from that cold state: 313 ui-test, 0 issues × +3 lint configurations, 3 test passes, 19/19 e2e. + +The one expensive thing was a genuine config bug, not a doc error. +`.playwright/cli.config.json` had `outputDir: "../.playwright-cli"`, +written on the belief — which `references/harness.md` stated as a flat +rule — that every path in that file resolves against the config file's +directory. **Only `initScript` does.** `outputDir` resolves against the +shell's cwd, so every snapshot and console log was landing in +`/home/logan/Development/.playwright-cli`, one level *above* the repo: +outside `.gitignore`, outside `find`, and invisible to the obvious +`ls .playwright-cli/`. That directory still held a stale snapshot from +the previous session, so the obvious `ls -t | head -1` returned it +silently, and the transport buttons appeared to have lost their +accessible names — a fabricated regression in phase 3's work that took +a DOM walk to disprove. Reading a *stale* artifact is much worse than +reading none, because it answers. + +Four smaller corrections, all now in the skill: + +- `make sandbox-seed` already depends on `make testdata`, so listing + both made the fixture step look separately required. It also takes + ~10 s with warm caches, not the ~30 s claimed. +- `make ui-setup` and `make e2e-setup` are once-per-clone + prerequisites and are *not* dependencies of `make ui-test` / + `make e2e`. The skill never mentioned them; on a fresh clone both + fail with a missing-browser error that reads like a broken test. + This matters for CI, which has no warm caches by definition. +- `snapshot` prints a *path*, not the tree. Not said anywhere. +- `make dev-stop` does not close the browser session; + `playwright-cli -s=yj close` is a separate step. + +And one place the tooling taught the opposite of the skill: +`scripts/dev-headless.sh`'s own success banner suggested +`eval "async () => await window.go.queue.Queue.GetState()"` — a bare +`window.go` call with no timeout, which is precisely the hang the +banner's next paragraph warns about. A gotcha documented in prose and +contradicted by the copy-pasteable line three inches above it will lose +every time; the banner now prints the `__yjEvents.call` form. + +## An ALSA null PCM is enough for CI audio, and it clocks + +Phase 7's job 2 needs playback to actually advance, because +`e2e/specs/playback.spec.ts` asserts the elapsed clock moves — a +missing audio device fails it in a way that reads like flake, since +`app.go` joins `InitSpeaker` failure into `startupErr` and lets +everything else work. + +Measured locally, with PulseAudio made unreachable +(`XDG_RUNTIME_DIR` pointed at an empty dir, `PULSE_SERVER=none`) and +`ALSA_CONFIG_PATH` pointing at four lines: + +``` + +pcm.!default { type null } +ctl.!default { type null } +``` + +`InitSpeaker` succeeded in 36 ms and all six playback/queue specs +passed, including "the elapsed time advances". oto/v3 talks to +libasound directly, and ALSA's `null` plugin advances its pointer on a +timer rather than discarding instantly, so beep's stream is consumed at +real-time rate. **No PipeWire, no PulseAudio and no daemon of any kind +is required in the container** — one env var and a file. + +Also found while setting this up: `scripts/dev-headless.sh` does *not* +set `YJ_CORE_INDEX_URL`; only `scripts/seed-sandbox.sh` does. So a +seeded run started by hand still reaches for the real explore artifact. +Harmless locally, a network dependency and a minute of wall clock in +CI — job 2 must set the dead-address override itself. + +## CI was prototyped in a container before it was written, and it found a real bug + +Both jobs of `.gitea/workflows/ci.yml` were built as shell scripts and +run to green in a bare `ubuntu:24.04` container (`docker run -v +repo:/src -v cache:/cache`) before a line of YAML existed, then the +YAML was transcribed back out of the workflow and re-run in the same +container to prove the transcription. That is worth the extra half +hour on a self-hosted runner: the alternative is push-and-see, and a +Gitea Actions run that never starts looks exactly like one that passed. + +**`make lint` was linting three configurations nothing builds.** All +three passes omitted `webkit2_41`, so wails resolved `webkit2gtk-4.0`. +Arch still ships `webkit2gtk-4.0.pc`, so it passed locally and had +done for the life of the repo; Ubuntu 24.04 dropped 4.0, and the +**`dev` pass** fails there — wails' own `app_dev.go` is `dev`-tagged +and drags in the 4.0 assetserver, which the other two passes never +compile. The tag sets now match `make test` exactly +(`webkit2_41`, `webkit2_41 indexbuild`, `webkit2_41 dev`). Still 0 +issues × 3 on Arch, and now 0 × 3 on Ubuntu too. Note what this means: +"lint passes" and "the thing lint compiled is the thing we ship" were +different claims, and only a second distro could tell them apart. + +Five smaller container facts, all now comments in the workflow: + +- **`libasound2-dev`, not just `libasound2t64`.** oto/v3 dies at + `pkg-config --cflags -- alsa` before a line is compiled. +- **`PLAYWRIGHT_BROWSERS_PATH` unifies the location, not the + revisions.** `@playwright/cli` bundles its own `playwright-core` + pinned to a different Chromium build than `e2e/`'s + `@playwright/test`, so both must install into the shared directory. + Installing one gives the other "Browser chromium is not installed; + expected executable at …/chromium-1237/…". The "three separate + browser caches" trap survives being pointed at one path. +- **`git config --global --add safe.directory`** or `bindings-check` + fails on a clone the container user does not own. +- **The runner already mounts and exports `GOMODCACHE`, `GOCACHE` and + `GOLANGCI_LINT_CACHE`** for every job via `container.options`, and + `valid_volumes` is a glob over the cache root + (`/home/logan/docker/gitea/data/runner/cache/**`), so new caches need + no runner-side change. Only the Node-side ones had to be declared. +- **The fixture hash is deterministic per ffmpeg, not across + versions**: `5425fbb454a2` on Arch (ffmpeg n8.1.2), `599a8dd4f152` + on Ubuntu 24.04. Nothing asserts a literal hash, so this is + harmless — but a test that pinned one would be portable only by + accident. + +**Setting `YJ_CORE_INDEX_URL` for the *app* run, not just for seeding, +is worth 8x on the suite.** `scripts/dev-headless.sh` never set it — +only `seed-sandbox.sh` did — so a seeded local run still fetches the +real explore artifact, and `testctl.spec.ts`'s restore then copies +every table of a database full of catalogue: 42 s locally, versus a +whole 19-spec suite in 7.3 s in CI with the artifact stubbed out. + +## Playwright's WebKit passes, so it gates + +19/19, in the same container, ~11 s on top of Chromium's ~7 s. It had +never been run anywhere before — Arch cannot start it — so the honest +default would have been advisory. Running it once in a throwaway +container turned a coin flip into a decision: it is a **required** +step. + +Two things make that safe rather than brave. Nothing in `e2e/` +compares pixels — every assertion is an event payload, a `data-testid`, +an attribute or backend state, and the `toMatchScreenshot` baselines +live in the Chromium-only Vitest tier — so a WebKit failure cannot be +antialiasing noise; it is an engine difference in custom-element +upgrade, a11y-tree shape or event ordering, which is exactly the +WebKit2GTK signal we otherwise have no way to get. And it is cheap +enough that the earlier plan to scope it (skip `testctl.spec.ts`, +which tests Go and has no engine content) is not worth the +complexity at 11 s. diff --git a/.planning/plans/active/005-agent-development-harness.md b/.planning/plans/active/005-agent-development-harness.md new file mode 100644 index 0000000..ce8f98d --- /dev/null +++ b/.planning/plans/active/005-agent-development-harness.md @@ -0,0 +1,692 @@ +# 005 — Agent development harness + +**Status:** complete — all seven phases shipped +**Branch:** — +**Created:** 2026-08-10 +**Follows:** 004-wanted-list + +## Progress + +| Phase | State | Notes | +|---|---|---| +| 1 — Reproducible fixtures | **done** | `cmd/gentestdata`, `make testdata`, `internal/testfixtures` | +| 2 — Headless launch | **done** | `scripts/dev-headless.sh`, `dev-stop.sh`, `seed-sandbox.sh` | +| 3 — Driving and seeing | **done** | event bridge, `data-testid`/aria pass, `backend/testctl`, `e2e/` smoke suite | +| 4 — Component coverage | **done** | Vitest 4 browser mode, 313 tests, `make ui-test`; `make bindings-check` | +| 5 — `events.Emit` wrapper | **done** | `backend/events/emit.go`, `Recorder`, 35 sites converted, service tests in `queue`/`config`/`playlist` | +| 6 — pi affordances | **done** | `.pi/skills/yellowjacket-dev/`, `.pi/prompts/e2e.md`, `.pi/journal.md`, `make skill-check` | +| 7 — CI that gates | **done** | `.gitea/workflows/ci.yml`, two jobs, both prototyped in a container first | + +**Verified end to end after phase 7:** both jobs were built as shell +scripts and run to green in a bare `ubuntu:24.04` container before any +YAML existed, then the steps were transcribed *back out of the +workflow* and re-run in the same container to prove the transcription — +job 1 (lint × 3, test × 3, `tsc --noEmit`, 313 ui-tests, +`bindings-check`, `skill-check`) and job 2 (fixtures, seed, +`dev-headless`, 19/19 chromium, 19/19 **webkit**). Push-and-see was +not an acceptable loop here: a Gitea Actions run that never starts +looks identical to one that passed. + +It found a real bug on day one. **`make lint` was linting three +configurations that nothing builds** — all three passes omitted +`webkit2_41`, so wails resolved `webkit2gtk-4.0`, which Arch still +ships and Ubuntu 24.04 dropped. The `dev` pass is the one that breaks, +because wails' own `app_dev.go` is `dev`-tagged and drags in the 4.0 +assetserver that the other two passes never compile. The tag sets now +match `make test` exactly. "Lint passes" and "lint compiled what we +ship" were different claims, and only a second distro could tell them +apart. + +Two decisions were settled by measurement rather than argument. The +**audio sink** is a four-line ALSA `null` PCM, no daemon: `InitSpeaker` +succeeds in 36 ms and the elapsed clock advances, because ALSA's null +plugin advances its pointer on a timer. The **explore artifact** is +stubbed at a dead address as `seed-sandbox.sh` already does — and +setting it for the *app* run too, which `dev-headless.sh` never did, +turned out to be worth 8x on suite wall clock. **Playwright's WebKit +is a required step**, not an advisory one: it had never been run +anywhere, so one throwaway container run turned a coin flip into a +decision (19/19, +11 s), and nothing in `e2e/` compares pixels, so a +WebKit failure is an engine bug rather than baseline noise. + +**Verified end to end after phase 6:** all four tiers were re-run +green *before* anything was written — `make ui-test` (313), +`make lint` (0 issues × 3 configurations), `make test` (3 passes), +`make e2e` (19/19 against a seeded `dev-headless` app) — so the skill +documents commands that were observed working, not remembered. +`make skill-check` then verified the 25 make targets the skill cites +all exist, and was itself verified to fail on a missing one. The +remaining check is the one no tooling can do: an agent following +`.pi/skills/yellowjacket-dev/` cold on a real task, which should +happen before phase 7 encodes the same commands into CI. + +**That cold run has now happened.** An agent that did not write the +skill brought the app up from a wiped `.dev/` and no fixture library, +drove an undocumented flow (queue panel + shuffle, asserted on +`QueueModeChanged`, confirmed against `queue.Queue.GetState`) and +stopped it, in about a minute with no dead ends; all four tiers then +re-ran green from that cold state. It found one real config bug — +`outputDir` in `.playwright/cli.config.json` resolves against the +shell's cwd, not the config file's directory, so every snapshot was +landing one level *above* the repo where a stale copy from the +previous session answered instead — and four missing or wrong steps +(`sandbox-seed` already runs `testdata`; `make ui-setup` / +`make e2e-setup` are undocumented once-per-clone prerequisites; +`snapshot` prints a path, not a tree; `make dev-stop` leaves the +browser session open). All fixed in place, with the detail in +`.planning/NOTES.md`. + +**Verified end to end after phase 5:** all 35 `runtime.EventsEmit` +call sites across 14 files now route through `events.Emit`, and +`TestNoDirectRuntimeEmits` fails the build if a new one appears. Four +packages had each hand-rolled their own guard against the same +`log.Fatalf` (`library.emit`, `download.emit`, `autotagservice. +emitEvent` with its own `ctxReady` field, `playlist.emitEvent`) and +nine more sites guarded on `ctx != nil`, which does not actually +prevent it; all of that collapsed into one place. 16 new tests assert +what the *frontend receives* — queue mode/index/delta payloads, +config theme and shortcut snapshots, playlist create/add/delete — +none of which was reachable before. `make lint` (3 configurations), +`make test` (3 passes), `make bindings-check`, `make ui-test` and +`make e2e` (19/19 against the seeded headless app) all green. + +**Verified end to end after phase 4:** `make ui-test` runs 313 tests in +a real Chromium in ~2 s with no app, no backend and no display — 196 +covering all 13 stores plus the keyboard shortcut service, 117 covering +components (transport, sidebar, library filter, status indicator, +track-info, now-playing, queue panel) including a smoke mount of all 46 +custom elements against an empty backend. `make ui-visual` adds six +`toMatchScreenshot` baselines. `make bindings-check` regenerates +`frontend/wailsjs` in ~1.5 s and was verified to fail on a renamed +bound method. `tsc --noEmit`, `make lint` (all three configurations) +and `make e2e` (19/19) all stayed green, and the one frontend fix the +tier surfaced was confirmed in the running app by screenshot. + +**Verified end to end after phase 3:** `make e2e` runs 19 Playwright +specs against the seeded app — harness self-tests, library views +(31 fixture tracks, unicode, sidebar navigation), playback (play, +pause, elapsed time, volume round-trip), queue (population, shuffle +state) and the control surface (snapshot → mutate → restore, forced +event, SQL, input validation). All 19 pass; `make lint` is at 0 issues +across all three build configurations and `make test` is green. + +**Verified end to end after phase 2:** `make sandbox-seed NAME=default` +built a seed by driving the real `AddLibrary` binding and waiting for +the real scan to reach 31 tracks; `make dev-headless SEED=default` +restored it and landed *in* the app with no first-run wizard; +`playwright-cli` clicked through to Artists and screenshotted six real +artists with generated cover art, unicode names and the long-artist +truncation case; `LoadFile` + `Play` produced audible playback with the +transport bar at 00:04. + +One re-sequencing against the plan below: `sandbox-seed` is described +under phase 1 but shipped at the end of phase 2, because seeding *by +running the app* makes it a consumer of the launcher. + +One bug found by the fixtures, not yet fixed: **WAV tags are +write-only.** `backend/tagwriter` writes them into a RIFF `id3 ` chunk; +`backend/metadata` reads through `dhowden/tag`, which has no RIFF +parser, so every WAV scans in untitled. Pinned by +`TestWAVTagsAreNotReadableYet`. + +## Problem + +A coding agent can develop the Go packages of this repo competently and +cannot develop the *application* at all. It can read 66k lines of +backend, run 31k lines of tests, and lint two build configurations. It +cannot start the app, see a window, click anything, or find out whether +a change it made to a Lit component rendered. + +The gap is not "we lack tests". It is that every path to running +YellowJacket ends in a blocking GTK window: + +| Entry point | Behaviour | +|---|---| +| `make dev` | launches a WebKit window, blocks the terminal forever | +| `make sandbox ` | same, plus an interactive name argument | +| `make fresh-install` | same, and lands on the first-run wizard every time | + +So 265 bound methods across 11 services, 46 backend events, 33 Lit +component directories, 13 reactive stores and a 357-line keyboard +shortcut service have exactly one form of verification available to an +agent: `tsc --noEmit`. + +Three secondary facts make it worse. `test_data/music_library_test/` is +referenced by three test files, is in `.gitignore`, is not on disk, and +has no generator — so the audio path and `YELLOWJACKET_INTEGRATION=1` +are unreachable from a clean clone. No Gitea workflow runs `make test` +or `make lint`; quality gating exists only in `lefthook.yml`, which is +local and `--no-verify`-skippable. And there is no `.pi/` directory, so +none of the awkward invocations (`-tags "webkit2_41 indexbuild"`, +sandbox lifecycle, log tailing) are wrapped in anything an agent can +call. + +## The unlock + +`wails dev` already runs a full HTTP + WebSocket dev server on +`localhost:34115` (`internal/frontend/devserver/devserver.go`). It +serves the real frontend assets, injects the real generated bindings, +and bridges every method call and every `runtime.EventsEmit` over a +websocket to the **same running Go backend** the desktop window is +attached to. + +A plain Chromium can load `http://localhost:34115` and get a fully +functional YellowJacket. Not a mock, not a stub `wailsjs` layer: the +actual application, talking to the actual `explore`, `library`, +`player` and `queue` services, receiving the actual events. The +bindings land on `window.go`, so anything reachable from the frontend +is reachable from a one-line `page.evaluate`. + +This is not a trick we invented. Wails v3's documentation ships an +"End-to-End Testing" guide that is exactly this, and the v2 community +arrived at the same answer independently +(`wailsapp/wails` discussion #4205). It is the sanctioned approach. + +**The one caveat:** `devserver.Run` still calls `d.Frontend.Run(ctx)`, +which opens the GTK window and blocks. No flag suppresses it, and +nobody upstream has found a way around it. The app needs a display — +a virtual one. + +## Validated end to end, 2026-08-10 + +The premise was proven before this plan was committed, on a scratch +`YJ_HOME` under `~/.cache/yellowjacket-harness`: + +``` +go build -tags "dev webkit2_41" -o build/bin/yj-dev . +setsid dbus-run-session -- xvfb-run -a ./build/bin/yj-dev \ + -devserver localhost:34115 -assetdir frontend/dist +playwright-cli -s=yj open http://localhost:34115 +``` + +| Claim | Result | +|---|---| +| App boots headless under Xvfb | yes, ~1 s; `:34115` listening | +| `YJ_HOME` isolates the sandbox | yes, own `yj.db`, untouched real install | +| Chromium loads the real app | yes, console shows `wails dev / Connected to backend` | +| a11y snapshot pierces shadow DOM | yes — sidebar, queue panel, transport buttons, all with stable refs, through Lit **and** Web Awesome roots | +| `window.go` carries the bindings | yes, all 11 services | +| A bound method round-trips to Go | yes — `queue.Queue.GetState()` returned real JSON | +| Events reach the browser | yes — `SetVolume(42)` produced `VolumeChanged` with payload `42` | +| Screenshot is readable by the agent | yes — full render, correct theme, fonts and icons | +| MPRIS registers | yes — `org.mpris.MediaPlayer2.yellowjacket` on the private bus | +| Audio initialises | **yes** — see below | + +Five things the run taught that were not obvious beforehand: + +- **No null audio sink is needed.** `dbus-run-session` replaces the + *bus*, not the runtime dir, so `/run/user/1000/pulse` stays reachable + and `InitSpeaker` succeeded in 17 ms. The mitigation planned for + Phase 3 is unnecessary on a developer machine. A CI container with no + `/run/user` will still need one. +- **The first-run wizard blocks every interaction.** The first click + attempt failed with ` intercepts pointer events`. + Phase 1 is not a convenience; nothing downstream works without it. +- **A malformed binding call hangs forever.** `SetVolume(0.42)` against + a `player.UserVolume` (an `int`) made the backend log + `error parsing arguments` and never fire the callback, so the + in-page promise never settled. Every harness call needs a timeout, + and the app log is the only place the reason appears. +- **Playwright's WebKit does not run on Arch.** Its Linux build links + Ubuntu 24.04 libraries — `libicu74`, `libWPEWebKit-2.0.so.1`, + `libflite` — none of which Arch provides. `--browser=webkit` is a + **CI-only** capability, not a local one. Chromium is unaffected. +- **Event listeners accumulate across calls.** Hooks registered by one + `eval` survive into the next, so a naive recorder double-counts. The + `initScript` must install exactly one recorder, and tests must reset + its buffer rather than re-register. + +## Tooling decisions taken up front + +Three things exist that we would otherwise have built badly. + +**`@playwright/cli`** (`npm i -g @playwright/cli`) is Microsoft's +CLI-plus-agent-skills front end to Playwright, built specifically +because coding agents do better with terse commands than with MCP tool +schemas. `playwright-cli install --skills` drops the skills where an +agent finds them. It gives us, for free, everything this plan was +otherwise going to hand-roll: + +| Need | Command | +|---|---| +| See the page | `snapshot` — a11y tree with stable `ref=eNN` handles, pierces open shadow roots | +| Search a big page | `find ` / `find --regex` | +| Call a bound method | `eval "() => window.go.player.Player.Play(1)"` | +| Screenshot for the agent to read | `screenshot --filename=` | +| Frontend errors | `console` — Lit render failures are currently invisible | +| Stub the explore artifact | `route ` | +| Keep a browser across separate shell calls | `-s=` | +| Watch, and take over | `show` — live dashboard, per-session screencast, click in to grab the mouse | + +Plus video and trace recording when a flow needs explaining rather than +asserting. It is v0.1.x and moving; `@playwright/mcp` is the same engine +behind an MCP server and is the fallback if the CLI churns. + +**Playwright's WebKit build.** The shipped binary is WebKit2GTK, so a +Chromium-only suite would validate a renderer we do not ship. +`--browser=webkit` is not byte-identical to WebKit2GTK but shares the +engine core, and it is a flag rather than a project. The X11-grab of the +real GTK window drops to an optional spot-check. + +**Vitest 4 browser mode.** Stable Browser Mode plus `toMatchScreenshot` +landed in Vitest 4.0, it is the Lit ecosystem's current recommendation +over `@web/test-runner`, and it uses Playwright as its provider — the +same browsers already cached. Components render in a real browser with +real shadow DOM, and get visual regression, **with no Wails, no backend, +no seeded library and no virtual display**. This is a tier the earlier +draft of this plan did not have and is the cheapest coverage available. + +So the harness is three tiers, cheapest first: + +1. **Vitest browser mode** — components and stores. Seconds. No app. +2. **`playwright-cli` against `:34115`** — real flows against the real + backend, driven interactively by an agent. +3. **Playwright specs** — the same thing, frozen as a regression suite, + in CI. + +Only tier 2 and 3 need the app running, and therefore Xvfb. + +## Phase 1 — Reproducible fixtures *(shipped)* + +Nothing can be driven end-to-end against an empty library, and no two +runs are comparable unless the library is identical. No tool provides +this; it is ours to write. + +**`cmd/gentestdata`** writes `test_data/music_library_test/` +deterministically: silent/tone audio at known durations across MP3, +FLAC, OGG Vorbis and WAV, with tags written by our own `tagwriter` so +fixtures and reader cannot drift. Coverage must include the cases the +app has code for — embedded cover art shared across an album (dedup), +missing and partial tags, unicode and RTL text, multi-disc, various +artists, and a deliberate duplicate pair for +`duplicate-tracks-dialog`. + +`make testdata` generates it; it stays gitignored. A manifest hash lets +a test assert it is looking at the library it thinks it is. + +**Seeded sandboxes.** `make sandbox-seed NAME=` builds a `YJ_HOME` +with `config.toml` already pointing at the fixture library and `yj.db` +already scanned, so a run starts *in the app* rather than in the +first-run wizard. A `--fresh` variant deliberately omits config, because +the wizard is itself a surface that needs testing. Seeds rebuild from +scratch in seconds and are never hand-edited. + +Explicitly **not** seeded: the explore artifact. `artifactfetch.go` +already honours `YJ_CORE_INDEX_URL` ("overridable for testing"), so +tests point it at a local file server holding a cut-down artifact — +`cmd/indexexport` already produces that shape, so a tiny core is a +config change, not new code. This also makes the failure paths testable +(404, checksum mismatch, the `206` partial-content resume). A nightly +job can use the real artifact. + +## Phase 2 — Headless launch *(shipped)* + +`scripts/dev-headless.sh` wraps: + +``` +dbus-run-session -- xvfb-run -a \ + ./build/bin/yj-dev -devserver localhost:34115 -assetdir frontend/dist +``` + +**Run the dev binary directly, not `wails dev`.** `app_dev.go` parses +`-assetdir`, `-devserver`, `-frontenddevserverurl` and `-loglevel` +straight from `os.Args`, so `go build -tags "dev webkit2_41"` produces a +binary that serves the identical devserver with no file watcher, no +rebuild supervisor and no reload broadcast. One process, one PID, +deterministic startup. `wails dev`'s watcher is a human ergonomic; an +agent that just edited a file knows to rebuild. (`-noreload` and +`-nogorebuild` exist if the watcher is ever wanted anyway.) + +**`dbus-run-session` is not incidental.** A private session bus means +`backend/mediacontrols/mpris_linux.go` actually registers, which turns +MPRIS from "untestable" into a surface assertable with `busctl` — +properties out, `Play`/`Pause`/`Next` in. + +The script backgrounds the process, writes `.dev/app.pid` and +`.dev/app.log`, polls `:34115` until it answers, then exits, leaving the +app up. `make dev-headless SEED=`, `make dev-stop`, `make dev-logs`. + +**Kill by saved PID, never `pkill -f`.** A `pkill -f` whose pattern +appears in the invoking shell's own command line kills that shell and +silently drops the rest of the chain. + +New dependency: `xorg-server-xvfb`. Everything else — Playwright and its +Chromium, ffmpeg, `import`, `dbus-run-session`, `busctl`, `pactl` — is +already present. + +**Audio needs nothing locally.** Measured: `InitSpeaker` succeeds under +`dbus-run-session` + Xvfb because the PulseAudio socket in +`/run/user/1000` is untouched by a private bus. Only a CI container +without `/run/user` needs a null sink (PipeWire null sink, or an ALSA +`null` PCM via a scoped `asoundrc`), and even then `app.go:325` joins +`InitSpeaker()` failure into `startupErr` rather than aborting, so +everything except playback still runs. Sample-level correctness stays +where it already is, in `backend/player` unit tests. + +## Phase 3 — Driving and seeing the app *(shipped)* + +What landed, and the five things that were not obvious: + +- **`.playwright/cli.config.json`** now sets `testIdAttribute`, a + 1440×900 viewport, timeouts and the `initScript`. Every path in it is + resolved **relative to the config file**, not the repo root. +- **`.playwright/init-events.js`** is the event bridge, and it hooks + `window.wails.EventsNotify` rather than `EventsOn` — every backend + event enters the page at that one call (`case "n"` in wails' + `ipc_websocket.js`), so one wrap captures all 46 whether or not the + app subscribes. `window.wails` does not exist when an initScript + runs, so it is wrapped via an accessor installed on `window` that + collapses back to a data property on assignment. It also carries + `ready()` and `call()`, the latter timing out so the + "malformed binding call hangs forever" trap is paid for once. +- **No closed shadow roots** anywhere: nothing in `frontend/src` + overrides `createRenderRoot`/`shadowRootOptions` and Web Awesome's + dist never calls `attachShadow` directly. Snapshots pierce + everything. +- **The `data-testid` pass was mostly an accessibility fix.** The five + transport buttons had no accessible name at all, so they were + unnameable to a screen reader *and* to a selector; they now carry + `aria-label` plus `aria-pressed` for the shuffle/repeat toggles. + `data-testid` was added only where a selector would otherwise be + structural: `track-row`, `queue-row` (both with `data-file-path`), + `main-content` (plus a `data-active-view` attribute, since which view + is showing was previously only inferable from which cached child + lacked `.view-hidden`), the now-playing title/artist and the seek + bar's two clocks. Sidebar items got `data-testid` and `aria-current`. +- **`backend/testctl`** mounts `/__test/` on the existing asset + handler: `health`, `db/snapshot`, `db/restore`, `emit`, `sql`. Gated + twice — the implementation is behind the `dev` build tag with a no-op + `!dev` twin, and it refuses to register unless `YJ_TESTCTL=1`, which + `dev-headless.sh` sets and `make dev` does not. +- **`e2e/`** is its own npm package (`make e2e`), deliberately not + inside `frontend/`, so phase 4's Vitest browser mode does not have to + share a package with the Playwright runner. + +The original plan for the phase follows. + +- `playwright-cli install --skills`, and a project + `.playwright/cli.config.json` setting `testIdAttribute`, viewport, and + an `initScript`. +- **The `initScript` is the event bridge.** It runs before any app + script, so it can hook `EventsOn` and buffer all 46 backend events on + `window.__yjEvents`, read back with `eval`. Half of what this app does + is push-driven — scan progress, job updates, download progress, + `WantedListChanged` — and assertions must await an event, not a + timeout. +- Add `data-testid` where selectors would otherwise be structural. First + task of the phase is confirming nothing in the Lit / Web Awesome tree + uses a *closed* shadow root, which would defeat snapshots. +- A **dev-only control surface**. `backend/assets/handler.go` is ours and + already has `RegisterHandler(pattern, handler)`, so `/__test/...` can + be mounted on the same port with no new server: seed, snapshot and + restore the SQLite DB mid-run, force backend-internal state. Roughly + five endpoints, compiled out of non-dev builds. This is the residue of + what Playwright genuinely cannot reach — everything browser-side is + already covered by the CLI. + +**Screenshots as the primary agent primitive.** `screenshot --filename=` +then reading the PNG is the feedback loop that makes UI iteration +possible at all, and it matters more than the assertion suite built on +top of it. `snapshot` is the cheaper companion for structure. + +**Smoke suite**, once flows are stable, frozen as Playwright specs: +first-run wizard, library views (artists / genres / cover grid / track +list), playback and queue manipulation, playlist and smart-playlist +editing, explore search and detail pages, settings (the HTMX/templ path, +which renders differently from everything else), jobs, downloads. + +**Renderer fidelity is CI-only.** Playwright's Linux WebKit is built +against Ubuntu 24.04 and will not start on Arch (missing `libicu74`, +`libWPEWebKit-2.0.so.1`, `libflite`); the download succeeds and the +binary then fails to link. So `--browser=webkit` runs in Job 2 of CI, +where the runner image is Debian-family, and local work is Chromium +only. The X11 grab of the real GTK window stays available as an +optional spot-check for views where WebKit2GTK-specific rendering +matters — and is the *only* WebKit2GTK signal obtainable on this +machine. + +**Two live frontends, one backend.** The GTK window and the browser are +both websocket clients of the same backend. This is supported — +`devserver.go` keeps a client map and `notifyExcludingSender` +deliberately fans frontend-emitted events out to the other clients *and* +the desktop frontend; `-browser` exists for exactly this. The risk is +not the transport but our own singletons: 13 stores × 2 instances means +duplicate cover-art fetches on connect and two clients able to issue +`player.Play`. If that proves noisy, the fix is contained — our asset +handler can serve a blank page to the WebKitGTK user agent under +`YJ_HEADLESS=1`, making the window inert. Start without it. + +## Phase 4 — Component and store coverage *(shipped)* + +What landed: + +- **The Wails fake is the whole trick.** Everything in + `frontend/wailsjs/` is a pure passthrough to `window.go` and + `window.runtime`, so `test/support/wails-fake.ts` replaces those two + globals and every test then runs the *real* generated bindings and + the *real* store code. No module mocking, and no second description + of the Wails layer free to drift. Its event dispatcher mirrors + `desktop/events.js`, including `maxCallbacks` expiry and the fact + that a frontend `EventsEmit` notifies local listeners before Go. +- **Stores are singletons constructed at import**, so the fake is + installed from `setupFiles`, which runs first. A handful of stores + read config in their constructor before any test can stub, so the + setup file carries import-time defaults — without them a store + caches `undefined` where Go would have sent `[]`, and every consumer + crashes on `.length` in a way that looks like a component bug. +- **`make ui-test` / `ui-watch` / `ui-visual` / `ui-visual-update`.** + Visual regression is opt-in (`YJ_VISUAL=1`) because baselines are + font-hinting and compositing sensitive; the default run asserts + behaviour only, so nobody's loop breaks over antialiasing. +- **`make bindings-check`** (`scripts/bindings-check.sh`) runs + `wails generate module` and fails on a dirty tree, ignoring the file + modes the generator churns. Now in `lefthook.yml` pre-commit; the + Vitest suite is in pre-push. +- Two frontend bugs the tier found: `ScrollManager.setupResizeObserver` + threw an unhandled rejection on an empty library (fixed, one guard), + and `themeStore.loadFromBackend`'s failure handler throws again on + the state that failed it, so it cannot recover (left alone — + reachable only if the backend returns an empty accent). + +The original plan for the phase follows. + +Vitest 4 browser mode with the Playwright provider, in `frontend/`. + +- The 13 stores and the keyboard shortcut service. Queue mutation, + shuffle, repeat transitions, explore cache invalidation and shortcut + dispatch are near-pure TypeScript with zero tests today. +- Component rendering for the 33 component directories, with + `toMatchScreenshot` visual regression per component. Real browser, + real shadow DOM, no app, no display — this is where the bulk of UI + regression should live, leaving e2e for flows. +- **Binding drift check.** `frontend/wailsjs/` is generated by + `wails build`, *not* `go generate`, so the existing pre-commit codegen + check does not cover it. A renamed Go struct field currently surfaces + at runtime, in a window. Add a target that regenerates bindings and + fails on a dirty tree. + +## Phase 5 — An `events.Emit` wrapper *(shipped)* + +What landed: + +- **`events.Emit(ctx, name, data...)`** drops an event that has + nowhere to go, at debug level, instead of taking the process down. + **`events.Deliver`** is the same call returning `ErrNoRuntime`, for + the one caller that must know: `/__test/emit`, whose job is to + impersonate a backend emit and which would otherwise answer `200` + for an event that reached nobody. +- **The sink is carried in the context**, not in a package-level + variable — `events.WithSink(ctx, rec)` — so parallel tests cannot + observe each other's events and production emits pay no + synchronisation cost. `events.Recorder` implements it with + `Events`/`Named`/`Names`/`Count`/`Last`/`Reset` and a `Wait` that + blocks on background emitters (scan progress, `SetQueue` phase 2). +- **Enforcement is a test, not a linter.** golangci-lint runs once per + build configuration, so a stray emit in an `indexbuild`- or + `dev`-tagged file would only be seen by the pass that compiles it; + `TestNoDirectRuntimeEmits` walks the tree and sees all of them. +- **The tier it unblocks, exercised**: `backend/queue` (7), + `backend/config` (5), `backend/playlist` (4). Playlist is the one + that matters beyond the wrapper itself — it proves the pattern on a + service whose emits interleave with SQLite writes and M3U8 file + writes, and its test reads the playlist back the way the frontend + would on receipt of the event. + +Deferred out of this phase: a general `backend/playlist` CRUD suite. +The service is 2,900 lines with no CRUD coverage today, and that is +its own piece of work rather than a rider on a mechanical refactor. + +The original plan for the phase follows. + +34 call sites use `runtime.EventsEmit` directly. `runtime.getEvents` +(`runtime.go:47`) `log.Fatalf`s unless `ctx.Value("events")` satisfies +`frontend.Events` — an interface under `wails/v2/internal/`, which we +cannot implement. So none of those code paths can run outside a real +Wails app, and in-process service tests are impossible. + +A thin `events.Emit(ctx, name, data...)` in `backend/events`, +delegating to `runtime.EventsEmit` normally and to a recorder when a +test sink is installed, unblocks that. It is mechanical, and it pays for +itself independently as the one place to log or trace all 46 events. + +Sequenced after the e2e tiers because it is a refactor touching many +packages, and the tiers above deliver value without it. + +## Phase 6 — pi affordances *(shipped)* + +What landed, and the one decision that mattered: + +- **`.pi/skills/yellowjacket-dev/`**, a directory rather than a flat + file. Only a skill's description is always in context, so `SKILL.md` + holds the tier decision table, the canonical command sequences and + the five gotchas — the last inline rather than in a reference, + because they are needed *before* the failure — and + `references/{harness,fixtures,ui-tier,schema-change}.md` hold the + per-surface depth. +- **The split from `CLAUDE.md` is grammatical, not topical.** A topical + split is what rots: every new fact has two plausible homes. Three + docs, three tenses — `NOTES.md` past (measured, dated, append-only), + `CLAUDE.md` present (what the system is), the skill imperative (what + to run). CLAUDE.md's harness section lost about half its length to + this; leaving both would have been exactly the duplicate description + this repo has a standing rule against. +- **`make skill-check`** makes the rule enforceable rather than + aspirational: every command in `.pi/**/*.md` must be a real make + target, so the Makefile stays the source of truth for *how* to invoke + something and the skill only decides *which* and *in what order*. A + pre-commit hook; instant. +- **`.pi/prompts/e2e.md`** treats promotion as a transcription with + four fixed substitutions (snapshot refs → testids, sleeps → + `waitForEvent`, raw `window.go` → `callBinding`, short fixture → + `LONG_TRACK`) and three runs — pass, pass again, pass after a DB + restore — because the characteristic failure of a promoted spec is + depending on state the hand-driving left behind. +- **`.pi/journal.md`**, per the `/handoff` convention. + +The original plan for the phase follows. + +With the mechanics settled, wrap them. Much less than the first draft +assumed, because `playwright-cli`'s own skills cover browser work. + +`.pi/` gains: + +- **`skills/yellowjacket-dev/`** — the build-tag matrix, the two-file + schema rule, seed and sandbox lifecycle, the harness commands, and + when to reach for which of the three test tiers. `CLAUDE.md` has the + architectural half; this is the operational half. It must also carry + the gotchas the live run surfaced: time out every binding call, check + `.dev/app.log` when one hangs, and never assume a click will land + while the first-run wizard is up. +- **`settings.json`** pointing at `../.claude/skills`, because + `playwright-cli install --skills` writes to `.claude/skills/` and pi + does not discover that path by default. *(Already in place.)* +- **`.pi/journal.md`**, per the `/handoff` convention. +- A `/e2e` prompt template for promoting an exploratory + `playwright-cli` session into a committed spec. + +No custom extension. Browser control is a solved, actively maintained +problem and a hand-rolled version would be worse and would rot. + +## Phase 7 — CI that actually gates *(shipped)* + +What landed, and the decisions behind it: + +- **One image for both jobs, `ubuntu:24.04`.** Not `golang:1.25`, + because job 1 runs `make ui-test` — Vitest *browser* mode — so the + "fast job needs no browser" split does not survive contact. Not the + Playwright image either, because `e2e/` pins `@playwright/test` + ^1.56 and `frontend/` pins `playwright` ^1.62, so a prebuilt browser + set matches at most one of them. Ubuntu 24.04 is also what + Playwright's WebKit links against, which job 2 needs. +- **Caching needed no runner-side change.** `valid_volumes` is already + a glob over the runner's cache root, and `GOMODCACHE` / `GOCACHE` / + `GOLANGCI_LINT_CACHE` are mounted and exported for every job by + `container.options`. Only the Node-side caches (browsers, pnpm store, + the Go tarball) are declared in the workflow. +- **The repo is cloned by hand**, as the other three workflows do: + `actions/checkout` is a JS action and needs node in the container + before any step has had a chance to install it. +- **Failure output goes to the job log, not only to an artifact.** + `.dev/app.log` is tailed into the log on failure so `gitea_ci + job_logs` can reach it, with the Playwright report uploaded + alongside as `continue-on-error` so a broken upload cannot mask the + real failure. + +The original plan for the phase follows. + +`.gitea/workflows/ci.yml` — the repository has three workflows and none +of them test anything, so `gitea_ci` currently reports only packaging +jobs, which actively misleads an agent checking whether a push was +healthy. + +- **Job 1 (fast, no display):** `make lint`, both `make test` passes, + `tsc --noEmit`, Vitest browser mode. +- **Job 2 (display):** Xvfb + `dbus-run-session` + a seeded sandbox + + `make dev-headless` + the Playwright smoke suite, with screenshots and + traces uploaded on failure. + +Job 2 depends on the fixture generator and the stubbed artifact, so it +lands last. + +## Order and why + +1 and 2 are the hard blockers and are worth doing even if nothing else +follows — a seeded, scriptable, non-blocking launch is the difference +between an agent that can and cannot run this app. 3 is the payoff and +is now mostly configuration. 4 is the cheapest coverage per hour and can +proceed in parallel with everything else, since it depends on none of +it. 5 is a refactor that unblocks a fourth tier we do not have yet. 6 is +ergonomics and should wait until the commands stop changing. 7 is last +because it depends on all of it. + +## Risks + +- **`@playwright/cli` is v0.1.x.** Interface churn is likely. The + mitigation is that `@playwright/mcp` is the same engine behind a + different front end, so a switch is a config change, and the specs + written in Phase 3 are plain Playwright either way. +- **Playwright's WebKit is not WebKit2GTK, and does not run here at + all.** Closer than Chromium in CI, unavailable locally. A + GTK-specific rendering bug can still escape, and will not be caught + until CI runs — or ever, for views not in the smoke suite. +- **Xvfb is X11, and the app has a Wayland-specific NVIDIA workaround** + (`main.go`'s DMABuf disable). CI will not exercise the Wayland path at + all. Acceptable — that path is a crash workaround, not a feature — but + it should be a known blind spot rather than a surprise. +- **Seeds are a second description of a valid `YJ_HOME`.** If the + generator drifts from what the app actually writes, tests pass against + a state no real install has. Seeds must be produced by *running the + app*, not by writing config and DB rows by hand — the same discipline + `sql/schemas/` gets, for the same reason. + +## Deferred + +- Driving the real WebKit2GTK window directly. + `WEBKIT_INSPECTOR_SERVER=127.0.0.1:9222` exposes WebKit's remote + inspector, but the protocol is not CDP and Playwright cannot attach. + A bespoke client is the only route and is not worth it. +- Wails v3, whose e2e story is better documented and whose dev server is + the same idea on port 9245. Not a reason to migrate. +- Component testing via `playwright-ct-web`. Vitest browser mode covers + the same ground with fewer moving parts and a first-party visual + regression story. diff --git a/.playwright/cli.config.json b/.playwright/cli.config.json new file mode 100644 index 0000000..80e1bde --- /dev/null +++ b/.playwright/cli.config.json @@ -0,0 +1,21 @@ +{ + "browser": { + "browserName": "chromium", + "launchOptions": { + "channel": "chromium" + }, + "contextOptions": { + "viewport": { "width": 1440, "height": 900 } + }, + "initScript": ["init-events.js"] + }, + "testIdAttribute": "data-testid", + "outputDir": ".playwright-cli", + "console": { + "level": "warning" + }, + "timeouts": { + "action": 10000, + "navigation": 30000 + } +} diff --git a/.playwright/init-events.js b/.playwright/init-events.js new file mode 100644 index 0000000..9d75d21 --- /dev/null +++ b/.playwright/init-events.js @@ -0,0 +1,302 @@ +/* + * YellowJacket harness bridge — installed as a Playwright initScript, so + * it runs in every page *before* any application script. + * + * Why this file exists: half of what this app does is push-driven. Scan + * progress, job updates, download progress, WantedListChanged and 40-odd + * other events arrive from Go whenever they arrive. An assertion that + * sleeps and hopes is flaky; an assertion that awaits the event is not. + * + * Three things it provides on `window.__yjEvents`: + * + * record every backend -> frontend event, in order, with payloads + * wait a promise that settles on a matching event (or rejects + * with the list of events that *did* arrive, which is the + * single most useful failure message this harness can give) + * call a bound Go method that is guaranteed to settle: a binding + * invoked with wrong argument types makes the backend log + * "error parsing arguments" and never fire the callback, so + * the in-page promise hangs forever. Timing out here fixes + * that once instead of in every eval. + * + * WHERE IT HOOKS. Not EventsOn. Every backend event enters the page + * at exactly one place — wails' ipc_websocket.js does + * + * case "n": window.wails.EventsNotify(message) + * + * and EventsNotify fans out to listeners from there. Wrapping that + * single choke point captures all 46 events whether or not the app + * subscribes to them, and needs one wrap rather than 46. + * + * `window.wails` does not exist yet when this script runs, so we install + * an accessor on `window` and wrap at assignment time (wails' main.js + * does a plain `window.wails = {...}`), then collapse the accessor back + * to a data property so nothing downstream can tell. + * + * INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into + * the next, so a recorder that re-registers double-counts. Tests call + * `__yjEvents.reset()`; they never re-install. + */ +(() => { + if (window.__yjEvents) { + return; + } + + const LIMIT = 2000; + + let seq = 0; + const log = []; + const waiters = new Set(); + + const summarize = () => { + const counts = {}; + for (const e of log) { + counts[e.name] = (counts[e.name] || 0) + 1; + } + return counts; + }; + + const record = (name, data, dir) => { + const entry = { seq: ++seq, name, data, dir, t: Date.now() }; + log.push(entry); + if (log.length > LIMIT) { + log.splice(0, log.length - LIMIT); + } + for (const w of Array.from(waiters)) { + let hit = false; + try { + hit = w.test(entry); + } catch { + hit = false; + } + if (hit) { + waiters.delete(w); + clearTimeout(w.timer); + w.resolve(entry); + } + } + return entry; + }; + + // `name` is a string, or "*" for any event. `match` is an optional + // predicate over (data, entry) — only usable from an eval'd function, + // which is how every harness call is written anyway. + const makeTest = (name, match) => (entry) => { + if (name && name !== "*" && entry.name !== name) { + return false; + } + return match ? !!match(entry.data, entry) : true; + }; + + const api = { + version: 1, + + /** Every recorded event, oldest first. */ + get log() { + return log.slice(); + }, + + /** The sequence number of the most recent event. */ + get seq() { + return seq; + }, + + /** Drop the buffer. Does NOT touch the recorder or waiters. */ + reset() { + const n = log.length; + log.length = 0; + return n; + }, + + /** Every recorded event, optionally filtered by name. */ + all(name) { + return name ? log.filter((e) => e.name === name) : log.slice(); + }, + + /** How many of `name` (or of everything) have arrived. */ + count(name) { + return this.all(name).length; + }, + + /** The most recent matching event, or null. */ + last(name) { + const hits = this.all(name); + return hits.length ? hits[hits.length - 1] : null; + }, + + /** Everything after a sequence number — pairs with `.seq`. */ + since(n) { + return log.filter((e) => e.seq > n); + }, + + /** name -> count, for "what actually happened?" */ + names() { + return summarize(); + }, + + /** + * Settle on the next (or already-buffered) matching event. + * + * await __yjEvents.wait('LibraryScanComplete', { timeoutMs: 60000 }) + * await __yjEvents.wait('JobsChanged', { match: (d) => d.length > 0 }) + * + * Rejects on timeout with the names that did arrive, because + * "timed out waiting for X" without that list is a dead end. + */ + wait(name, opts) { + const o = opts || {}; + const test = makeTest(name, o.match); + const since = o.since || 0; + + for (const entry of log) { + if (entry.seq > since && test(entry)) { + return Promise.resolve(entry); + } + } + + return new Promise((resolve, reject) => { + const w = { test, resolve }; + w.timer = setTimeout(() => { + waiters.delete(w); + reject( + new Error( + `__yjEvents.wait(${JSON.stringify(name)}) timed out after ` + + `${o.timeoutMs || 5000}ms; events seen: ` + + JSON.stringify(summarize()), + ), + ); + }, o.timeoutMs || 5000); + waiters.add(w); + }); + }, + + /** + * Resolve when the backend is actually answering calls — not + * when the DOM is ready, which is earlier and lies. + */ + async ready(timeoutMs) { + const deadline = Date.now() + (timeoutMs || 15000); + for (;;) { + if (window.go?.queue?.Queue?.GetState) { + try { + await api.call("queue.Queue.GetState", [], 2000); + return true; + } catch { + /* backend not up yet */ + } + } + if (Date.now() > deadline) { + throw new Error("__yjEvents.ready timed out"); + } + await new Promise((r) => setTimeout(r, 100)); + } + }, + + /** + * Call a bound Go method by dotted path, with a timeout. + * + * await __yjEvents.call('player.Player.SetVolume', [42]) + * + * A binding called with the wrong argument types never fires its + * callback — the reason appears only in .dev/app.log. Without a + * timeout the caller waits forever; with one it gets told where + * to look. + */ + call(path, args, timeoutMs) { + const parts = String(path).split("."); + let fn = window.go; + for (const p of parts) { + fn = fn?.[p]; + } + if (typeof fn !== "function") { + return Promise.reject( + new Error(`__yjEvents.call: no such binding: ${path}`), + ); + } + + return Promise.race([ + Promise.resolve(fn(...(args || []))), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `__yjEvents.call(${path}) did not settle in ` + + `${timeoutMs || 10000}ms — almost always wrong ` + + `argument types; check .dev/app.log for ` + + `"error parsing arguments"`, + ), + ), + timeoutMs || 10000, + ), + ), + ]); + }, + }; + + Object.defineProperty(window, "__yjEvents", { + value: api, + configurable: false, + enumerable: false, + writable: false, + }); + + // Wrap `obj[method]` once, routing every invocation through `tap`. + const wrap = (obj, method, tap) => { + const original = obj[method]; + if (typeof original !== "function" || original.__yjWrapped) { + return; + } + const wrapped = function (...args) { + try { + tap(args); + } catch { + /* a broken recorder must never break the app */ + } + return original.apply(this, args); + }; + wrapped.__yjWrapped = true; + obj[method] = wrapped; + }; + + // Install an accessor that wraps on first assignment, then collapses + // back into an ordinary property. + const hookOnAssign = (name, onAssign) => { + let value; + Object.defineProperty(window, name, { + configurable: true, + enumerable: true, + get: () => value, + set: (v) => { + value = v; + try { + onAssign(v); + } catch { + /* ditto */ + } + Object.defineProperty(window, name, { + value: v, + configurable: true, + enumerable: true, + writable: true, + }); + }, + }); + }; + + // Inbound: every backend -> frontend event. + hookOnAssign("wails", (w) => { + wrap(w, "EventsNotify", ([message]) => { + const parsed = JSON.parse(message); + record(parsed.name, parsed.data, "in"); + }); + }); + + // Outbound: events the frontend emits, so a flow that round-trips + // through Go is legible from one buffer. + hookOnAssign("runtime", (r) => { + wrap(r, "EventsEmit", (args) => { + record(args[0], args.slice(1), "out"); + }); + }); +})(); diff --git a/CLAUDE.md b/CLAUDE.md index 0768503..125597e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,11 +22,23 @@ Numbering is sequential and stable across status moves (a plan keeps its `NNN-` ```bash make dev # Hot-reload development (installs deps, generates code, cleans frontend) make dev-debug # Same as dev but with YJ_LOG_LEVEL=debug +make dev-headless # Start headless in the background and return (SEED= to seed) +make dev-stop # Stop it (SIGTERM, so shutdown hooks run) +make dev-logs # Tail .dev/app.log +make testdata # Generate the deterministic fixture music library +make sandbox-seed NAME= # Build a seeded YJ_HOME by *running* the app make build-dev # Debug build with symbols make build-prod # Production build (stripped, UPX-compressed) make generate # Run code generators (sqlc + templ via go generate) -make lint # golangci-lint v2 (strict), both build configurations -make test # All tests with race detector, both build configurations +make e2e # Playwright smoke suite against a running dev-headless app +make e2e-setup # Install the e2e runner + its browser (once) +make ui-test # Vitest component/store suite in a real browser (no app) +make ui-visual # Same, including toMatchScreenshot comparisons +make ui-setup # Install the Vitest provider's own Chromium (once) +make bindings-check # Fail if frontend/wailsjs is stale vs the Go bindings +make skill-check # Fail if .pi/ documents a make target that doesn't exist +make lint # golangci-lint v2 (strict), all three build configurations +make test # All tests with race detector, all three build configurations make vulncheck # govulncheck for CVEs make setup # Install go tools, frontend deps, git hooks (lefthook) ``` @@ -49,8 +61,75 @@ needs it spelled out: go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/... ``` +`backend/testctl` is behind a third tag and needs its own pass too +(`make test` runs all three): + +```bash +go test -tags "webkit2_41 dev" ./backend/testctl/... +``` + Audio playback integration tests require `YELLOWJACKET_INTEGRATION=1`. +### Fixtures and the headless harness + +`test_data/music_library_test/` is **generated, not committed**: run +`make testdata` (~1 s) before anything that needs audio. Tests reach it +through `internal/testfixtures`, selecting files by *case* +(`CaseCoverDedup`, `CaseUnicode`, `CaseDuplicates`, …) rather than by +path, and skip themselves when it has not been generated. + +The app itself can be run without a blocking window — `make +dev-headless` — and driven with `playwright-cli` against the dev server +on `:34115`, which is the real app with real bindings on `window.go`, +bridged to the same Go backend a desktop window would use. + +**The operational half of all this lives in the +`yellowjacket-dev` skill** (`.pi/skills/yellowjacket-dev/`): which tier +to reach for, the exact command sequences, seed lifecycle, and the +failure modes worth knowing before you meet them. It is deliberately +not repeated here — this section describes what exists, the skill says +what to run. + +Two things ride on top of the headless launch, both from plan 005 +phase 3: + +- **The event bridge.** `.playwright/cli.config.json` loads + `.playwright/init-events.js` as an `initScript`, which records every + backend event on `window.__yjEvents`. Half this app is push-driven, + so assertions **await an event, not a timeout**: + `await window.__yjEvents.wait('LibraryScanComplete', {timeoutMs: 60000})`. + It also provides `ready()` and `call('queue.Queue.GetState', [])`, + which times out instead of hanging. +- **The dev-only control surface**, `backend/testctl`, mounted at + `/__test/` on the same port: `health`, `db/snapshot`, `db/restore`, + `emit` (force any backend event, which renders push-driven views + without staging the work that would produce them) and `sql`. It is + compiled out of non-dev builds and additionally requires + `YJ_TESTCTL=1`, which `dev-headless.sh` sets and `make dev` does not. + +Frozen regression specs live in `e2e/` (its own npm package, so the +Vitest browser mode does not share a package with the Playwright +runner): `make e2e` against an already-running app. + +**The cheapest tier needs none of that.** `make ui-test` runs 313 +Vitest tests in a real Chromium in ~2 s with no Wails, no backend, no +seeded library and no virtual display, because `frontend/wailsjs/` is a +pure passthrough to `window.go` / `window.runtime` and +`frontend/test/support/wails-fake.ts` replaces just those two globals — +so the tests exercise the real generated bindings and the real store +code. + +**`frontend/wailsjs/` is generated by `wails`, not `go generate`**, so +the pre-commit codegen check does not cover it. `make bindings-check` +(~1.5 s, also a pre-commit hook) regenerates it and fails on a dirty +tree; `make bindings` regenerates it for real. + +**Seeds are produced by running the app**, never by hand-writing a +`config.toml` and DB rows — the same discipline `sql/schemas/` gets, +for the same reason. + +See `.planning/plans/active/005-agent-development-harness.md`. + ## Architecture **Wails app lifecycle** (`main.go` → `backend/app.go`): `YellowJacketApp` is the root struct bound to Wails. Its methods are callable from the frontend. Lifecycle hooks: `OnStartup` (init audio), `OnDomReady` (start library scan), `OnBeforeClose` (save window state), `OnShutdown` (persist player/queue state). @@ -140,6 +219,19 @@ work happens **once, centrally**, and users download the result: **Event-driven communication**: Backend emits events via Wails runtime; frontend stores subscribe to them. Event names are constants in `backend/events/`. +Emit through **`events.Emit(ctx, name, data...)`**, never +`runtime.EventsEmit` — wails `log.Fatalf`s (unrecoverably) on any +context that does not carry its runtime, which includes every +`context.Background()`, so a direct call cannot run under test and can +kill the app from a background worker. `TestNoDirectRuntimeEmits` fails +the build on a direct call anywhere outside `backend/events`. + +That wrapper is what makes services testable in-process: install a +recorder with `events.WithSink(ctx, rec)` and assert on the payload the +frontend would receive (`backend/queue/emit_test.go` is the model). +`events.Deliver` is the same call returning an error instead of +dropping, and has one legitimate caller — `/__test/emit`. + ## Code Generation Two generators run via `go generate ./...` (or `make generate`): @@ -162,3 +254,36 @@ Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same ## Git Workflow Feature branches and PRs are the norm, but direct pushes to `main` are allowed. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite. + +## CI + +Four workflows in `.gitea/workflows/`. Three of them package and +publish (`arch-package`, `homebrew-formula`, `index-artifact`); only +`ci.yml` gates, and it is the one to look at when deciding whether a +push was healthy. + +Two jobs, both in an `ubuntu:24.04` container: + +- **`check`** — no display: `make lint` and `make test` (three build + configurations each), `tsc --noEmit`, `make ui-test`, + `make bindings-check`, `make skill-check`. +- **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by + running the app, `make dev-headless`, then the Playwright suite + against **both** Chromium and WebKit. Playwright's Linux WebKit links + Ubuntu 24.04 libraries that Arch does not provide, so CI is the only + place it can run, and it is the closest available approximation of + the WebKit2GTK renderer that ships. + +Two things the container needs that a developer machine does not. It +has no PulseAudio socket, so `/etc/asound.conf` makes ALSA's `null` +plugin the default device — that plugin advances its pointer on a +timer, so playback is consumed at real-time rate and the elapsed clock +moves, which `e2e/specs/playback.spec.ts` asserts. And +`YJ_CORE_INDEX_URL` points at a dead address so no run fetches the real +explore artifact, matching what `scripts/seed-sandbox.sh` already does. + +**`make lint`'s tag sets must stay identical to `make test`'s.** +Without `webkit2_41` wails resolves `webkit2gtk-4.0`, which Arch still +ships and Ubuntu 24.04 does not — so a mismatch lints a configuration +that only builds on one developer's distro, and says nothing about what +ships. diff --git a/Makefile b/Makefile index a413eee..7d21559 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,93 @@ dev: setup generate clean dev-debug: setup generate clean if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2 +# ── Headless harness (plan 005) ────────────────────────────────────── +# The same dev server `make dev` runs, minus the blocking GTK window: +# Xvfb gives it the display it insists on, and the script returns once +# :34115 answers. This is the only entry point an agent can use, since +# every other one blocks the terminal forever. +dev-headless: ## Start the app headless in the background (SEED= to seed) + @./scripts/dev-headless.sh $(if $(SEED),--seed $(SEED),) $(HEADLESS_ARGS) + +dev-headless-fresh: ## Same, but on an empty YJ_HOME (first-run wizard) + @./scripts/dev-headless.sh --fresh $(HEADLESS_ARGS) + +dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run) + @./scripts/dev-stop.sh + +dev-logs: ## Tail the headless app log + @tail -f .dev/app.log + +# Seeds are produced by *running the app* — driving the real AddLibrary +# binding and waiting for the real scan — never by hand-writing a +# config.toml and DB rows. A hand-built seed is a second description +# of a valid YJ_HOME and would drift from the real one. +sandbox-seed: testdata ## Build a seeded YJ_HOME snapshot: make sandbox-seed NAME= + @./scripts/seed-sandbox.sh $(if $(NAME),--name $(NAME),) + +sandbox-seeds: ## List built seeds + @ls -1 .dev/seeds/*.tar 2>/dev/null | sed 's|.*/||; s|\.tar$$||' \ + || echo " (none; build one with: make sandbox-seed NAME=default)" + +# The specs drive the app that is *already* running: `make dev-headless` +# daemonises, which is the opposite of what Playwright's `webServer` +# supervises, and starting one per run would rebuild the frontend every +# time. globalSetup fails with the exact commands to run if it is down. +e2e: ## Run the Playwright smoke suite against a running dev-headless app + @cd e2e && pnpm install --silent && npx playwright test $(E2E_ARGS) + +e2e-setup: ## Install the e2e runner and its browser (once) + @cd e2e && pnpm install && npx playwright install chromium + +e2e-report: ## Open the HTML report from the last e2e run + @cd e2e && npx playwright show-report + +# The cheapest tier: components and stores in a real browser, with no +# Wails, no backend, no seeded library and no virtual display. Lives in +# frontend/ rather than e2e/ so the Vitest browser provider and the +# Playwright runner cannot fight over versions or globs. +ui-test: ## Run the Vitest component and store suite (frontend/) + @cd frontend && pnpm install --silent && npx vitest run $(UI_ARGS) + +ui-watch: ## Same suite, in watch mode + @cd frontend && npx vitest + +# Visual regression is opt-in: toMatchScreenshot baselines depend on +# font hinting and compositing, so they only mean anything on the +# machine (or container) that took them. +ui-visual: ## Run the suite including screenshot comparisons + @cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS) + +ui-visual-update: ## Re-record the screenshot baselines + @cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS) + +ui-setup: ## Install the Vitest browser provider's own Chromium (once) + @cd frontend && pnpm install && npx playwright install chromium + +# frontend/wailsjs is generated by `wails`, NOT by `go generate`, so the +# pre-commit codegen check does not cover it: a renamed Go struct field +# currently surfaces at runtime, in a window. File modes are ignored +# because `wails generate module` rewrites the runtime files as 755. +bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings + @./scripts/bindings-check.sh + +# .pi/ documents commands, and a skill that documents a command wrongly +# is worse than no skill: an agent runs it confidently. Every command +# in there is a make target on purpose, so this is checkable. +skill-check: ## Fail if .pi/ documents a make target that does not exist + @./scripts/skill-check.sh + +bindings: ## Regenerate frontend/wailsjs from the bound Go structs + go tool wails generate module -tags webkit2_41 + @chmod 644 frontend/wailsjs/runtime/runtime.js \ + frontend/wailsjs/runtime/runtime.d.ts \ + frontend/wailsjs/runtime/package.json + +.PHONY: dev-headless dev-headless-fresh dev-stop dev-logs \ + sandbox-seed sandbox-seeds e2e e2e-setup e2e-report \ + ui-test ui-watch ui-visual ui-visual-update ui-setup \ + bindings bindings-check skill-check + # Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR: # on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so # the search index dump import — which wants 6GB free before it will even @@ -119,17 +206,45 @@ clean: generate: go generate ./... -lint: - go tool golangci-lint run - go tool golangci-lint run --build-tags indexbuild +# The fixture library is generated, not committed: deterministic audio +# across all four supported formats, tagged by backend/tagwriter so the +# fixtures and the reader under test cannot drift. Regenerates only +# when the spec's manifest hash has changed, so it is cheap to depend on. +testdata: ## Generate the deterministic fixture music library + go run ./cmd/gentestdata -# Two passes: the app build, then the `indexbuild` build that adds the -# CI-only dump importer. Without the second pass nothing would compile -# or exercise backend/explore/dump*.go or cmd/indexbuild at all. -test: +testdata-force: ## Regenerate the fixture library unconditionally + go run ./cmd/gentestdata -force + +testdata-clean: ## Delete the generated fixture library + rm -rf test_data/music_library_test test_data/music_library_broken \ + test_data/music_library_test.manifest.json + +.PHONY: testdata testdata-force testdata-clean + +# The tag sets must match `make test` exactly, or lint is checking three +# configurations that nothing builds. webkit2_41 is not optional: without +# it wails resolves webkit2gtk-4.0, which Ubuntu 24.04 no longer ships, so +# the `dev` pass (wails' own app_dev.go is dev-tagged and pulls in the 4.0 +# assetserver) fails to typecheck anywhere but Arch. +lint: + go tool golangci-lint run --build-tags webkit2_41 + go tool golangci-lint run --build-tags "webkit2_41 indexbuild" + go tool golangci-lint run --build-tags "webkit2_41 dev" + +# Three passes: the app build, the `indexbuild` build that adds the +# CI-only dump importer, and the `dev` build that adds profiling and +# backend/testctl. Without the extra passes nothing would compile or +# exercise backend/explore/dump*.go, cmd/indexbuild or the harness +# control surface at all. +test: testdata go test -tags webkit2_41 -race -count=1 -timeout 120s ./... go test -tags "webkit2_41 indexbuild" -race -count=1 -timeout 300s \ ./backend/explore/... ./cmd/... + # backend/testctl only exists under the `dev` tag, so the pass above + # does not compile it, let alone run it. + go test -tags "webkit2_41 dev" -race -count=1 -timeout 120s \ + ./backend/testctl/... vulncheck: go tool govulncheck ./... diff --git a/backend/app.go b/backend/app.go index 9f03aac..ae2a597 100644 --- a/backend/app.go +++ b/backend/app.go @@ -33,6 +33,7 @@ import ( "yellowjacket/backend/queue" "yellowjacket/backend/system" "yellowjacket/backend/tagwriter" + "yellowjacket/backend/testctl" ) // YellowJacketApp is the main application struct for Wails. @@ -131,6 +132,17 @@ func NewYellowJacketApp( yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler) } + // Dev-only /__test/ control surface: the residue of harness work the + // browser cannot reach (snapshot/restore the DB mid-run, force a + // backend event). Compiled out of non-dev builds entirely, and even + // in a dev build it registers nothing unless YJ_TESTCTL=1. The + // context is read lazily because it only exists after OnStartup. + testctl.Register(yjApp.assetHandler, testctl.Deps{ + Logger: logger, + DB: yjApp.database, + Context: func() context.Context { return yjApp.appContext }, + }) + // create playlist service yjApp.playlist = playlist.NewService( yjApp.logger, yjApp.database, yjApp.appConfig, @@ -290,7 +302,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) { yj.wanted.SetInterval(cfg.WantedInterval()) yj.wanted.SetBatch(cfg.WantedBatch) yj.wanted.SetOnChange(func() { - wailsruntime.EventsEmit(ctx, events.RequestsChanged) + events.Emit(ctx, events.RequestsChanged) }) yj.wanted.Start(ctx) } diff --git a/backend/autotagservice/service.go b/backend/autotagservice/service.go index 8d6a851..11d0313 100644 --- a/backend/autotagservice/service.go +++ b/backend/autotagservice/service.go @@ -19,8 +19,6 @@ import ( "sync" "time" - wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/autotag" "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" @@ -85,13 +83,6 @@ type Service struct { exp *explore.Service logger *slog.Logger ctx context.Context - // ctxReady reports whether ctx is the Wails lifecycle context set - // via SetContext (rather than the context.Background() default). It - // gates event emission: calling wailsruntime.EventsEmit with a - // non-runtime context triggers log.Fatalf (os.Exit) inside Wails, so - // a background worker that fires before OnStartup wires the context - // would otherwise take the whole app down on launch. - ctxReady bool // Queue cursor — the group_key of the last item returned. // GetNextPending uses it to advance. Reset by StartAutotagQueue. @@ -210,32 +201,18 @@ func (s *Service) SetContext(ctx context.Context) { defer s.mu.Unlock() s.ctx = ctx - s.ctxReady = ctx != nil } -// emitEvent emits a Wails runtime event, but only when the stored -// context actually carries the Wails runtime. Wails' EventsEmit calls -// log.Fatalf — which os.Exit()s the process and cannot be recovered — -// whenever the context lacks its internal "events" value (e.g. the -// context.Background() default, or any non-lifecycle context). A -// background worker (the prefetch/apply sweeps) that emits before, or -// independently of, OnStartup wiring the real context would otherwise -// take the whole app down on launch. We replicate Wails' own -// precondition here so a not-yet-ready context degrades to a no-op -// instead of a crash. +// emitEvent emits a Wails runtime event under the service lock, which +// the background prefetch/apply sweeps need because they can emit +// before OnStartup has wired the real context. events.Emit tolerates +// that; see its doc comment. func (s *Service) emitEvent(eventName string, data any) { s.mu.Lock() - ready := s.ctxReady ctx := s.ctx s.mu.Unlock() - // hasWailsRuntime mirrors the check in wails/pkg/runtime.getEvents: - // the runtime is present only when ctx.Value("events") is non-nil. - if !ready || ctx == nil || ctx.Value("events") == nil { - return - } - - wailsruntime.EventsEmit(ctx, eventName, data) + events.Emit(ctx, eventName, data) } // StartBackgroundPrefetch kicks off (or restarts) the prefetch diff --git a/backend/config/config.go b/backend/config/config.go index e340f67..d30c461 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -10,7 +10,6 @@ import ( "path" "github.com/BurntSushi/toml" - "github.com/wailsapp/wails/v2/pkg/runtime" "yellowjacket/backend/download" "yellowjacket/backend/events" @@ -306,15 +305,13 @@ func (c *Config) SetLibraryDirectory(dir string) error { ) } - if c.ctx != nil { - runtime.EventsEmit( - c.ctx, - events.LibraryConfigChanged, - map[string]any{ - "DirectoryPath": dir, - }, - ) - } + events.Emit( + c.ctx, + events.LibraryConfigChanged, + map[string]any{ + "DirectoryPath": dir, + }, + ) c.logger.Info( "library directory updated", @@ -494,11 +491,11 @@ func (c *Config) SetThemeBackgroundShade( // emitThemeChanged sends the ThemeConfigChanged event to the frontend. func (c *Config) emitThemeChanged() { - if c.ctx == nil || c.Theme == nil { + if c.Theme == nil { return } - runtime.EventsEmit( + events.Emit( c.ctx, events.ThemeConfigChanged, map[string]any{ @@ -564,7 +561,7 @@ func (c *Config) emitTrackListChanged() { }) } - runtime.EventsEmit( + events.Emit( c.ctx, events.TrackListConfigChanged, map[string]any{ @@ -688,11 +685,11 @@ func (c *Config) SetPinDefaultPlaylist(pin bool) error { // emitFavoritesChanged sends the FavoritesConfigChanged event // to the frontend. func (c *Config) emitFavoritesChanged() { - if c.ctx == nil || c.Favorites == nil { + if c.Favorites == nil { return } - runtime.EventsEmit( + events.Emit( c.ctx, events.FavoritesConfigChanged, map[string]any{ @@ -729,13 +726,11 @@ func (c *Config) SetShortcuts( ) } - if c.ctx != nil { - runtime.EventsEmit( - c.ctx, - events.ShortcutsConfigChanged, - bindings, - ) - } + events.Emit( + c.ctx, + events.ShortcutsConfigChanged, + bindings, + ) c.logger.Info("shortcuts config updated") @@ -759,13 +754,11 @@ func (c *Config) SetShortcut( ) } - if c.ctx != nil { - runtime.EventsEmit( - c.ctx, - events.ShortcutsConfigChanged, - c.Shortcuts.Bindings, - ) - } + events.Emit( + c.ctx, + events.ShortcutsConfigChanged, + c.Shortcuts.Bindings, + ) c.logger.Info( "shortcut updated", @@ -788,13 +781,11 @@ func (c *Config) ResetShortcuts() error { ) } - if c.ctx != nil { - runtime.EventsEmit( - c.ctx, - events.ShortcutsConfigChanged, - c.Shortcuts.Bindings, - ) - } + events.Emit( + c.ctx, + events.ShortcutsConfigChanged, + c.Shortcuts.Bindings, + ) c.logger.Info("shortcuts reset to defaults") diff --git a/backend/config/emit_test.go b/backend/config/emit_test.go new file mode 100644 index 0000000..c2099f1 --- /dev/null +++ b/backend/config/emit_test.go @@ -0,0 +1,185 @@ +package config + +import ( + "context" + "log/slog" + "path/filepath" + "testing" + + "yellowjacket/backend/events" +) + +// setupRecordedConfig builds a Config that saves to a temp directory +// and records the events it would push to the frontend. +func setupRecordedConfig(t *testing.T) (*Config, *events.Recorder) { + t.Helper() + + conf := &Config{ + logger: slog.Default(), + filePath: filepath.Join(t.TempDir(), "config.toml"), + } + conf.applyDefaults() + + // Load, not just applyDefaults: Save refuses to write a config that + // was never hydrated from disk, so without this only the first + // setter in a test succeeds. + if err := conf.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + rec := events.NewRecorder() + conf.SetContext(events.WithSink(context.Background(), rec)) + + return conf, rec +} + +// payloadMap returns the map payload of the most recent named event. +func payloadMap( + t *testing.T, + rec *events.Recorder, + name string, +) map[string]any { + t.Helper() + + ev, ok := rec.Last(name) + if !ok { + t.Fatalf("no %s emitted; got %v", name, rec.Names()) + } + + data, ok := ev.Payload().(map[string]any) + if !ok { + t.Fatalf("%s payload is %T, want map[string]any", name, ev.Payload()) + } + + return data +} + +// TestEmit_ThemeChangeCarriesBothFields pins that the theme event is a +// snapshot of both fields, not a delta: the frontend applies the whole +// colour ramp from it, so an accent change that omitted the shade would +// re-derive the ramp against a default background. +func TestEmit_ThemeChangeCarriesBothFields(t *testing.T) { + t.Parallel() + + conf, rec := setupRecordedConfig(t) + + if err := conf.SetThemeBackgroundShade("light"); err != nil { + t.Fatalf("SetThemeBackgroundShade: %v", err) + } + + if err := conf.SetThemeAccentColor("#ff0000"); err != nil { + t.Fatalf("SetThemeAccentColor: %v", err) + } + + if got := rec.Count(events.ThemeConfigChanged); got != 2 { + t.Errorf("emitted %d ThemeConfigChanged, want 2", got) + } + + data := payloadMap(t, rec, events.ThemeConfigChanged) + if data["AccentColor"] != "#ff0000" { + t.Errorf("AccentColor = %v, want #ff0000", data["AccentColor"]) + } + + if data["BackgroundShade"] != "light" { + t.Errorf("BackgroundShade = %v, want light", data["BackgroundShade"]) + } +} + +// TestEmit_ThemeChangeIsNotEmittedOnRejectedValue pins that a rejected +// write does not tell the frontend the theme changed. +func TestEmit_ThemeChangeIsNotEmittedOnRejectedValue(t *testing.T) { + t.Parallel() + + conf, rec := setupRecordedConfig(t) + + if err := conf.SetThemeAccentColor("not-a-colour"); err == nil { + t.Fatal("SetThemeAccentColor accepted an invalid colour") + } + + if got := rec.Count(events.ThemeConfigChanged); got != 0 { + t.Errorf("emitted %d ThemeConfigChanged for a rejected write, want 0", got) + } +} + +// TestEmit_ShortcutChangeSendsWholeBindingMap covers the surface the +// 357-line frontend shortcut service rebuilds itself from. +func TestEmit_ShortcutChangeSendsWholeBindingMap(t *testing.T) { + t.Parallel() + + conf, rec := setupRecordedConfig(t) + + if err := conf.SetShortcut("playPause", "k"); err != nil { + t.Fatalf("SetShortcut: %v", err) + } + + ev, ok := rec.Last(events.ShortcutsConfigChanged) + if !ok { + t.Fatalf("no ShortcutsConfigChanged; got %v", rec.Names()) + } + + bindings, ok := ev.Payload().(map[string]string) + if !ok { + t.Fatalf("payload is %T, want map[string]string", ev.Payload()) + } + + if bindings["playPause"] != "k" { + t.Errorf("playPause = %q, want k", bindings["playPause"]) + } + + // The whole map, not just the changed key — the frontend replaces + // its binding table wholesale on this event. + if len(bindings) < 2 { + t.Errorf("emitted %d bindings, want the full default set", len(bindings)) + } +} + +func TestEmit_ResetShortcutsRepublishesDefaults(t *testing.T) { + t.Parallel() + + conf, rec := setupRecordedConfig(t) + + if err := conf.SetShortcut("playPause", "k"); err != nil { + t.Fatalf("SetShortcut: %v", err) + } + + rec.Reset() + + if err := conf.ResetShortcuts(); err != nil { + t.Fatalf("ResetShortcuts: %v", err) + } + + ev, ok := rec.Last(events.ShortcutsConfigChanged) + if !ok { + t.Fatalf("no ShortcutsConfigChanged after reset; got %v", rec.Names()) + } + + bindings, ok := ev.Payload().(map[string]string) + if !ok { + t.Fatalf("payload is %T, want map[string]string", ev.Payload()) + } + + if bindings["playPause"] == "k" { + t.Error("reset emitted the overridden binding, not the default") + } +} + +func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) { + t.Parallel() + + conf, rec := setupRecordedConfig(t) + + if err := conf.SetFavoritesPlaylistID(7); err != nil { + t.Fatalf("SetFavoritesPlaylistID: %v", err) + } + + data := payloadMap(t, rec, events.FavoritesConfigChanged) + if data["PlaylistID"] != int64(7) { + t.Errorf("PlaylistID = %#v, want int64(7)", data["PlaylistID"]) + } + + for _, key := range []string{"IconStyle", "PinDefault"} { + if _, ok := data[key]; !ok { + t.Errorf("payload is missing %q; the settings page reads it", key) + } + } +} diff --git a/backend/download/service.go b/backend/download/service.go index e7b905c..db2e04a 100644 --- a/backend/download/service.go +++ b/backend/download/service.go @@ -7,8 +7,6 @@ import ( "log/slog" "strconv" - "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/events" ) @@ -55,14 +53,9 @@ func (s *Service) SetContext(ctx context.Context) { } // emit publishes an event, tolerating a service that has no runtime -// context yet. Emitting on a non-runtime context is fatal in Wails, so -// the nil check is load-bearing rather than defensive. +// context yet. func (s *Service) emit(name string, data ...any) { - if s.ctx == nil { - return - } - - runtime.EventsEmit(s.ctx, name, data...) + events.Emit(s.ctx, name, data...) } // --------------------------------------------------------------------------- diff --git a/backend/events/emit.go b/backend/events/emit.go new file mode 100644 index 0000000..25ee01a --- /dev/null +++ b/backend/events/emit.go @@ -0,0 +1,90 @@ +package events + +import ( + "context" + "errors" + "log/slog" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ErrNoRuntime is returned by Deliver when the context carries neither +// a test Sink nor a live Wails runtime, so the event went nowhere. +var ErrNoRuntime = errors.New( + "no Wails runtime or event sink in context", +) + +// Sink receives events in place of the Wails runtime. +// +// Installing one with WithSink is what makes a service that emits +// events testable in-process: see Deliver for why the real runtime +// cannot be used there. +type Sink interface { + Emit(name string, data ...any) +} + +// sinkKey is the private context key an installed Sink is stored under. +type sinkKey struct{} + +// WithSink returns a context whose events are recorded by sink rather +// than pushed to the frontend. +// +// The sink travels in the context rather than in a package-level +// variable so that parallel tests cannot observe each other's events +// and so that production emits pay no synchronisation cost. +func WithSink(ctx context.Context, sink Sink) context.Context { + return context.WithValue(ctx, sinkKey{}, sink) +} + +// sinkFrom returns the Sink installed in ctx, or nil. +func sinkFrom(ctx context.Context) Sink { + sink, _ := ctx.Value(sinkKey{}).(Sink) + + return sink +} + +// Emit publishes a Wails event, tolerating any context. +// +// This is the only supported way to emit an event: nothing outside this +// package may call runtime.EventsEmit, which TestNoDirectEventsEmit +// enforces. +// +// runtime.EventsEmit calls log.Fatalf when the context is nil or lacks +// the runtime's "events" value, terminating the process rather than +// returning an error. Background workers that outlive a context, and +// any test that constructs a service directly, both hit that path — so +// an event with nowhere to go is dropped and logged here instead. +func Emit(ctx context.Context, name string, data ...any) { + if err := Deliver(ctx, name, data...); err != nil { + slog.Default().Debug( + "dropping event, no Wails runtime in context", + "event", name, + ) + } +} + +// Deliver is Emit for the one caller that must know whether delivery +// happened: the dev control surface (backend/testctl), whose whole +// purpose is to impersonate a backend emit and which would otherwise +// report success for an event that went nowhere. +// +// Ordinary emitters want Emit. +func Deliver(ctx context.Context, name string, data ...any) error { + if ctx == nil { + return ErrNoRuntime + } + + if sink := sinkFrom(ctx); sink != nil { + sink.Emit(name, data...) + + return nil + } + + if ctx.Value("events") == nil { + return ErrNoRuntime + } + + runtime.EventsEmit(ctx, name, data...) + + return nil +} diff --git a/backend/events/emit_test.go b/backend/events/emit_test.go new file mode 100644 index 0000000..e6941d6 --- /dev/null +++ b/backend/events/emit_test.go @@ -0,0 +1,186 @@ +package events_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "yellowjacket/backend/events" +) + +func TestEmitDropsWithoutRuntimeOrSink(t *testing.T) { + t.Parallel() + + // The point of the wrapper: neither of these may reach + // runtime.EventsEmit, which would log.Fatalf and take the test + // binary down with it. + events.Emit(context.Background(), events.QueueChanged, "payload") + + //nolint:staticcheck // a nil context is the case under test. + events.Emit(nil, events.QueueChanged, "payload") +} + +func TestDeliverReportsMissingRuntime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ctx context.Context + }{ + {name: "nil context", ctx: nil}, + {name: "no runtime", ctx: context.Background()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := events.Deliver(tt.ctx, events.QueueChanged) + if !errors.Is(err, events.ErrNoRuntime) { + t.Fatalf("got %v, want ErrNoRuntime", err) + } + }) + } +} + +func TestSinkReceivesEmittedEvents(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + ctx := events.WithSink(context.Background(), rec) + + events.Emit(ctx, events.QueueChanged, "one") + events.Emit(ctx, events.VolumeChanged, 42) + + if err := events.Deliver(ctx, events.TrackChanged); err != nil { + t.Fatalf("Deliver with a sink installed: %v", err) + } + + want := []string{ + events.QueueChanged, + events.VolumeChanged, + events.TrackChanged, + } + + got := rec.Names() + if len(got) != len(want) { + t.Fatalf("recorded %v, want %v", got, want) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("event %d = %q, want %q", i, got[i], want[i]) + } + } + + ev, ok := rec.Last(events.VolumeChanged) + if !ok { + t.Fatal("VolumeChanged not recorded") + } + + if ev.Payload() != 42 { + t.Errorf("VolumeChanged payload = %v, want 42", ev.Payload()) + } +} + +func TestRecorderPayloadOfArgumentlessEvent(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + rec.Emit(events.SeekFailed) + + ev, ok := rec.Last(events.SeekFailed) + if !ok { + t.Fatal("SeekFailed not recorded") + } + + if ev.Payload() != nil { + t.Errorf("payload = %v, want nil", ev.Payload()) + } +} + +func TestRecorderWaitSeesEventsAlreadyRecorded(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + rec.Emit(events.LibraryScanComplete, 31) + + ev, ok := rec.Wait(events.LibraryScanComplete, time.Second) + if !ok { + t.Fatal("Wait missed an event recorded before the call") + } + + if ev.Payload() != 31 { + t.Errorf("payload = %v, want 31", ev.Payload()) + } +} + +func TestRecorderWaitBlocksForBackgroundEmit(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + ctx := events.WithSink(context.Background(), rec) + + go func() { + time.Sleep(10 * time.Millisecond) + events.Emit(ctx, events.LibraryScanProgress, 1) + events.Emit(ctx, events.LibraryScanComplete, 2) + }() + + if _, ok := rec.Wait(events.LibraryScanComplete, 2*time.Second); !ok { + t.Fatal("Wait timed out on a background emit") + } +} + +func TestRecorderWaitTimesOut(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + + if _, ok := rec.Wait(events.QueueChanged, 20*time.Millisecond); ok { + t.Fatal("Wait returned an event that was never emitted") + } +} + +func TestRecorderIsConcurrencySafe(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + ctx := events.WithSink(context.Background(), rec) + + const emitters, each = 8, 25 + + var wg sync.WaitGroup + + wg.Add(emitters) + + for range emitters { + go func() { + defer wg.Done() + + for range each { + events.Emit(ctx, events.QueueChanged, 1) + } + }() + } + + wg.Wait() + + if got := rec.Count(events.QueueChanged); got != emitters*each { + t.Errorf("recorded %d events, want %d", got, emitters*each) + } +} + +func TestRecorderReset(t *testing.T) { + t.Parallel() + + rec := events.NewRecorder() + rec.Emit(events.QueueChanged) + rec.Reset() + + if got := rec.Events(); len(got) != 0 { + t.Errorf("after Reset: %v, want empty", got) + } +} diff --git a/backend/events/noemit_test.go b/backend/events/noemit_test.go new file mode 100644 index 0000000..d3ce57c --- /dev/null +++ b/backend/events/noemit_test.go @@ -0,0 +1,85 @@ +package events_test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// allowedEmitters are the only files permitted to call the Wails +// runtime's event emitter directly. +var allowedEmitters = map[string]bool{ + filepath.Join("backend", "events", "emit.go"): true, +} + +// TestNoDirectRuntimeEmits fails if anything outside backend/events +// calls the Wails runtime's event emitter directly. +// +// This is a text walk rather than a golangci-lint rule because +// golangci-lint runs once per build configuration, so a call in an +// indexbuild- or dev-tagged file is only seen by the pass that compiles +// it. Walking the tree sees all three, plus anything tagged out +// entirely. +func TestNoDirectRuntimeEmits(t *testing.T) { + // A selector, not a bare name: built at runtime so this file does + // not match itself, and qualified so it catches every import alias + // the tree uses (plain runtime., and wailsruntime.) without also + // matching an identifier that merely ends in the same letters. + needle := ".Events" + "Emit(" + + root := filepath.Join("..", "..") + + skipDirs := map[string]bool{ + ".git": true, + "node_modules": true, + "frontend": true, + "build": true, + } + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + if skipDirs[d.Name()] { + return filepath.SkipDir + } + + return nil + } + + if filepath.Ext(path) != ".go" { + return nil + } + + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + + if allowedEmitters[rel] { + return nil + } + + src, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + + for i, line := range strings.Split(string(src), "\n") { + if strings.Contains(line, needle) { + t.Errorf( + "%s:%d calls the Wails emitter directly; use events.Emit\n\t%s", + rel, i+1, strings.TrimSpace(line), + ) + } + } + + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } +} diff --git a/backend/events/recorder.go b/backend/events/recorder.go new file mode 100644 index 0000000..1b85bdf --- /dev/null +++ b/backend/events/recorder.go @@ -0,0 +1,153 @@ +package events + +import ( + "sync" + "time" +) + +// Event is one recorded emission. +type Event struct { + Name string + Data []any +} + +// Payload returns the single data argument almost every event carries, +// or nil for the handful emitted with none. +func (e Event) Payload() any { + if len(e.Data) == 0 { + return nil + } + + return e.Data[0] +} + +// Recorder is a Sink that buffers events for later assertion. +// +// It is safe for concurrent use: several services emit from background +// goroutines, and Wait exists so a test can block on one of those +// rather than sleep. +type Recorder struct { + mu sync.Mutex + events []Event + + // notify is closed and replaced on every emit, so waiters wake + // without the Recorder having to track them individually. + notify chan struct{} +} + +// NewRecorder returns an empty Recorder. +func NewRecorder() *Recorder { + return &Recorder{notify: make(chan struct{})} +} + +// Emit implements Sink. +func (r *Recorder) Emit(name string, data ...any) { + r.mu.Lock() + defer r.mu.Unlock() + + r.events = append(r.events, Event{Name: name, Data: data}) + + close(r.notify) + r.notify = make(chan struct{}) +} + +// Events returns every event recorded so far, in order. +func (r *Recorder) Events() []Event { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]Event(nil), r.events...) +} + +// Named returns every recorded event with the given name, in order. +func (r *Recorder) Named(name string) []Event { + r.mu.Lock() + defer r.mu.Unlock() + + var out []Event + + for _, ev := range r.events { + if ev.Name == name { + out = append(out, ev) + } + } + + return out +} + +// Names returns the name of every recorded event, in order. +// +// Assertions read better against this than against Events when what +// matters is which events fired and in what order. +func (r *Recorder) Names() []string { + r.mu.Lock() + defer r.mu.Unlock() + + out := make([]string, 0, len(r.events)) + for _, ev := range r.events { + out = append(out, ev.Name) + } + + return out +} + +// Count returns how many times the named event was recorded. +func (r *Recorder) Count(name string) int { + return len(r.Named(name)) +} + +// Last returns the most recent event with the given name. +func (r *Recorder) Last(name string) (Event, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + for i := len(r.events) - 1; i >= 0; i-- { + if r.events[i].Name == name { + return r.events[i], true + } + } + + return Event{}, false +} + +// Reset discards everything recorded so far. +func (r *Recorder) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.events = nil +} + +// Wait blocks until an event with the given name is recorded, and +// returns it. It returns false if timeout elapses first. +// +// Events already recorded count, so a test cannot lose a race by +// calling Wait after the emit it is waiting for. +func (r *Recorder) Wait(name string, timeout time.Duration) (Event, bool) { + deadline := time.After(timeout) + from := 0 + + for { + r.mu.Lock() + + for i := from; i < len(r.events); i++ { + if r.events[i].Name == name { + ev := r.events[i] + r.mu.Unlock() + + return ev, true + } + } + + from = len(r.events) + notify := r.notify + + r.mu.Unlock() + + select { + case <-notify: + case <-deadline: + return Event{}, false + } + } +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go index d995e60..483e076 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -9,7 +9,6 @@ import ( "sync" "time" - "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/sync/singleflight" "yellowjacket/backend/database" @@ -650,8 +649,8 @@ func (e *Service) ensureReleasesAsync(releaseGroupMBID string) { go func() { _, _, _ = e.releasesSF.Do(releaseGroupMBID, func() (any, error) { _, err := e.mb.BrowseReleases(e.ctx, releaseGroupMBID) - if err == nil && e.ctx != nil { - runtime.EventsEmit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID) + if err == nil { + events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID) } return nil, nil @@ -804,9 +803,7 @@ func (e *Service) ensureDiscographyAsync(artistMBID string) { _, _, _ = e.discogSF.Do(artistMBID, func() (any, error) { e.index.EnsureArtistDiscography(e.ctx, artistMBID) - if e.ctx != nil { - runtime.EventsEmit(e.ctx, events.ArtistDiscographyReady, artistMBID) - } + events.Emit(e.ctx, events.ArtistDiscographyReady, artistMBID) return nil, nil }) @@ -864,9 +861,7 @@ func (e *Service) ensureSimilarArtistsAsync(artistMBID string) { if err == nil { e.index.PersistSimilarArtists(artistMBID, similar) - if e.ctx != nil { - runtime.EventsEmit(e.ctx, events.ArtistSimilarReady, artistMBID) - } + events.Emit(e.ctx, events.ArtistSimilarReady, artistMBID) } return nil, nil diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index 69b61e4..7cd54fa 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/sync/singleflight" "yellowjacket/backend/database" @@ -690,7 +689,7 @@ func (si *SearchIndex) emitStatus() { status.Building = si.cancel != nil si.mu.RUnlock() - runtime.EventsEmit(si.runtimeCtx, events.IndexStatusChanged, status) + events.Emit(si.runtimeCtx, events.IndexStatusChanged, status) // Mirror into the shared job registry. Every status mutation goes // through emitStatus, so hooking here covers all update paths. diff --git a/backend/jobs/jobs.go b/backend/jobs/jobs.go index e274aaa..1f94956 100644 --- a/backend/jobs/jobs.go +++ b/backend/jobs/jobs.go @@ -13,8 +13,6 @@ import ( "sync/atomic" "time" - "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/events" ) @@ -245,7 +243,7 @@ func (r *Registry) emit() { return } - runtime.EventsEmit(ctx, events.JobsChanged, r.Snapshot()) + events.Emit(ctx, events.JobsChanged, r.Snapshot()) } // touch marks the registry dirty so the next emitter tick publishes it. diff --git a/backend/library/library.go b/backend/library/library.go index 74746de..72333ea 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -16,7 +16,6 @@ import ( "sync/atomic" "time" - "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/sync/errgroup" "yellowjacket/backend/autotag" @@ -193,29 +192,16 @@ func (l *Library) SetContext(ctx context.Context) { l.registerEventHandlers() } -// emit publishes a Wails event, tolerating a context that carries no -// Wails runtime. -// -// runtime.EventsEmit calls log.Fatalf when the context is nil or lacks -// the runtime's "events" value, which terminates the process rather -// than returning an error. Background workers that outlive a context -// and tests that construct a Library directly both hit that path, so -// every emit in this package routes through here. +// emit publishes a Wails event under the library lock, which the +// background scan workers need because they outlive the context that +// started them. events.Emit tolerates a context with no Wails runtime; +// see its doc comment. func (l *Library) emit(event string, data ...any) { l.mu.Lock() ctx := l.ctx l.mu.Unlock() - if ctx == nil || ctx.Value("events") == nil { - l.logger.Debug( - "skipping event emit, no Wails runtime in context", - "event", event, - ) - - return - } - - runtime.EventsEmit(ctx, event, data...) + events.Emit(ctx, event, data...) } // registerEventHandlers sets up Wails runtime event listeners. diff --git a/backend/metadata/flacduration_test.go b/backend/metadata/flacduration_test.go index 1f3c7a0..528f39c 100644 --- a/backend/metadata/flacduration_test.go +++ b/backend/metadata/flacduration_test.go @@ -4,40 +4,32 @@ import ( "os" "path/filepath" "testing" + + "yellowjacket/internal/testfixtures" ) -// testFlacFiles returns the paths to all .flac files in the -// test_data directory. It skips the test if none are found. +// testFlacFiles returns every .flac in the generated fixture library +// (`make testdata`). +// +// Sourced from the manifest rather than by walking test_data/, which +// used to sweep up the deliberately malformed fixtures — a zero-byte +// .flac is there to prove the scanner survives it, not to be handed to +// a duration parser. func testFlacFiles(t *testing.T) []string { t.Helper() - root := filepath.Join("..", "..", "test_data") - - if _, err := os.Stat(root); os.IsNotExist(err) { - t.Skip("test_data directory not present, skipping") - } + m := testfixtures.Load(t) var files []string - err := filepath.Walk(root, func( - path string, info os.FileInfo, err error, - ) error { - if err != nil { - return err + for _, track := range m.Tracks { + if track.Format == "flac" { + files = append(files, m.Abs(track.Path)) } - - if !info.IsDir() && filepath.Ext(path) == ".flac" { - files = append(files, path) - } - - return nil - }) - if err != nil { - t.Fatalf("walking test_data: %v", err) } if len(files) == 0 { - t.Skip("no .flac test fixtures found in test_data/") + t.Skip("no .flac fixtures in the manifest") } return files diff --git a/backend/metadata/mp3duration_test.go b/backend/metadata/mp3duration_test.go index a68c641..2fa397a 100644 --- a/backend/metadata/mp3duration_test.go +++ b/backend/metadata/mp3duration_test.go @@ -4,44 +4,30 @@ import ( "os" "path/filepath" "testing" + + "yellowjacket/internal/testfixtures" ) -// testMP3Files returns the paths to all .mp3 files in the curated -// fixture library (`test_data/music_library_test/`). Scoped -// narrowly so that ad-hoc scramble / autotag fixtures placed -// elsewhere under `test_data/` (e.g. `test_data/mb-tag/`) don't -// get pulled into the assertion and fail on non-curated codecs. -// Skips the test when the directory isn't present. +// testMP3Files returns every .mp3 in the generated fixture library +// (`make testdata`). Scoped to that manifest so ad-hoc scramble / +// autotag fixtures elsewhere under `test_data/` don't get pulled into +// the assertion and fail on non-curated codecs. Skips when the +// fixtures haven't been generated. func testMP3Files(t *testing.T) []string { t.Helper() - root := filepath.Join("..", "..", "test_data", "music_library_test") - - if _, err := os.Stat(root); os.IsNotExist(err) { - t.Skip("test_data/music_library_test not present, skipping") - } + m := testfixtures.Load(t) var files []string - err := filepath.Walk(root, func( - path string, info os.FileInfo, err error, - ) error { - if err != nil { - return err + for _, track := range m.Tracks { + if track.Format == "mp3" { + files = append(files, m.Abs(track.Path)) } - - if !info.IsDir() && filepath.Ext(path) == ".mp3" { - files = append(files, path) - } - - return nil - }) - if err != nil { - t.Fatalf("walking test_data: %v", err) } if len(files) == 0 { - t.Skip("no .mp3 test fixtures found in test_data/") + t.Skip("no .mp3 fixtures in the manifest") } return files diff --git a/backend/player/player.go b/backend/player/player.go index 7ea5e8c..bf826c7 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -16,7 +16,6 @@ import ( "github.com/gopxl/beep/v2/effects" "github.com/gopxl/beep/v2/generators" "github.com/gopxl/beep/v2/speaker" - "github.com/wailsapp/wails/v2/pkg/runtime" "yellowjacket/backend/coverart" "yellowjacket/backend/database" @@ -192,7 +191,7 @@ func (p *Player) emitPlaybackStateChanged(state State) { "Emitting PlaybackStateChangedEvent", "state", state, ) - runtime.EventsEmit( + events.Emit( p.ctx, events.PlaybackStateChanged, map[string]string{"state": string(state)}, @@ -214,7 +213,7 @@ func (p *Player) emitPlaybackFinished() { } p.logger.Info("Emitting PlaybackFinishedEvent") - runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil) + events.Emit(p.ctx, events.PlaybackFinished, nil) } func (p *Player) emitVolumeChanged() { @@ -229,7 +228,7 @@ func (p *Player) emitVolumeChanged() { "Emitting VolumeChangedEvent", "volume", volume, ) - runtime.EventsEmit(p.ctx, events.VolumeChanged, volume) + events.Emit(p.ctx, events.VolumeChanged, volume) if p.mediaControls != nil { // MPRIS volume is 0.0–1.0 linear. @@ -263,7 +262,7 @@ func (p *Player) emitTrackChanged() { p.trackChangeID++ trackInfo.TrackChangeID = p.trackChangeID - runtime.EventsEmit( + events.Emit( p.ctx, events.TrackChanged, trackInfo, ) @@ -381,13 +380,11 @@ func (p *Player) onPlaybackFinished() { // calls that don't need player state. p.emitPlaybackFinished() - if p.ctx != nil { - runtime.EventsEmit( - p.ctx, - events.PlaybackStateChanged, - map[string]string{"state": string(Stopped)}, - ) - } + events.Emit( + p.ctx, + events.PlaybackStateChanged, + map[string]string{"state": string(Stopped)}, + ) // Notify media controls outside the lock. The track just // ended so position is 0. @@ -643,7 +640,7 @@ func (p *Player) UnloadTrack() { // Notify frontend that there is no longer a current track. p.emitPlaybackStateChanged(p.state) - runtime.EventsEmit(p.ctx, events.TrackChanged, nil) + events.Emit(p.ctx, events.TrackChanged, nil) if p.mediaControls != nil { p.mediaControls.UpdateMetadata(mediacontrols.Metadata{}) @@ -754,7 +751,7 @@ func (p *Player) Seek(targetSeconds int) error { func (p *Player) seekLocked(targetSeconds int) error { if p.seeker == nil { - runtime.EventsEmit(p.ctx, events.SeekFailed) + events.Emit(p.ctx, events.SeekFailed) return errNoAudioFileLoaded } diff --git a/backend/player/player_test.go b/backend/player/player_test.go index 0ce63fc..078ca57 100644 --- a/backend/player/player_test.go +++ b/backend/player/player_test.go @@ -4,13 +4,9 @@ import ( "log/slog" "os" "testing" -) -var testQueue = []string{ - "../../test_data/music_library_test/other_music/03 PONPONPON.mp3", - "../../test_data/music_library_test/01 Some Chords.mp3", - "../../test_data/music_library_test/03 anything.mp3", -} + "yellowjacket/internal/testfixtures" +) func TestPlayer(t *testing.T) { // This is an integration test that requires: @@ -24,6 +20,17 @@ func TestPlayer(t *testing.T) { ) } + // One track per supported container, so a decoder regression in + // any of the four shows up here rather than only in whichever + // format the fixtures happened to lead with. + m := testfixtures.Load(t) + testQueue := []string{ + m.Case(t, testfixtures.CaseCoverDedup)[0], + m.Case(t, testfixtures.CaseFLACAlbum)[0], + m.Case(t, testfixtures.CaseOGGAlbum)[0], + m.Case(t, testfixtures.CaseWAVTracks)[0], + } + t.Logf("Starting test") p := NewPlayer(slog.Default(), nil) diff --git a/backend/playlist/emit_test.go b/backend/playlist/emit_test.go new file mode 100644 index 0000000..f1b49d0 --- /dev/null +++ b/backend/playlist/emit_test.go @@ -0,0 +1,199 @@ +package playlist + +import ( + "context" + "fmt" + "log/slog" + "testing" + + "yellowjacket/backend/database" + "yellowjacket/backend/events" +) + +// stubLibraryDir satisfies LibraryDirProvider. +type stubLibraryDir struct{ dir string } + +func (s stubLibraryDir) GetLibraryDirectory() string { return s.dir } + +// setupRecordedService builds a playlist service on an in-memory DB +// that writes its M3U8 files to a temp directory and records the events +// it would push to the frontend. +func setupRecordedService( + t *testing.T, +) (*Service, *database.DB, *events.Recorder) { + t.Helper() + + db := database.NewTestDB(t) + libDir := t.TempDir() + + svc := NewService(slog.Default(), db, stubLibraryDir{dir: libDir}) + svc.dataDirOverride = t.TempDir() + + rec := events.NewRecorder() + svc.SetContext(events.WithSink(context.Background(), rec)) + + return svc, db, rec +} + +// seedPlaylistTracks inserts `count` audio_file rows and returns their +// paths. +func seedPlaylistTracks(t *testing.T, db *database.DB, count int) []string { + t.Helper() + + _, err := db.ExecContext( + "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + paths := make([]string, count) + + for i := range count { + id := i + 1 + paths[i] = fmt.Sprintf("/test/pl-track%d.mp3", id) + + if _, err := db.ExecContext( + "INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) "+ + "VALUES (?, ?, 1)", + id, fmt.Sprintf("Track %d", id), + ); err != nil { + t.Fatalf("insert recording %d: %v", id, err) + } + + if _, err := db.ExecContext( + "INSERT OR IGNORE INTO audio_files (id, file_path, "+ + "length_milliseconds, file_type_id, recording_id) "+ + "VALUES (?, ?, 180000, 0, ?)", + id, paths[i], id, + ); err != nil { + t.Fatalf("insert audio_file %d: %v", id, err) + } + } + + return paths +} + +func TestEmit_CreatePlaylistAnnouncesTheNewRow(t *testing.T) { + t.Parallel() + + svc, _, rec := setupRecordedService(t) + + created, err := svc.CreatePlaylist("Road Trip") + if err != nil { + t.Fatalf("CreatePlaylist: %v", err) + } + + ev, ok := rec.Last(events.PlaylistCreated) + if !ok { + t.Fatalf("no PlaylistCreated; got %v", rec.Names()) + } + + summary, ok := ev.Payload().(Summary) + if !ok { + t.Fatalf("payload is %T, want playlist.Summary", ev.Payload()) + } + + // The frontend adds the sidebar entry straight from this payload + // rather than re-fetching, so an empty field here is a blank row. + if summary.ID != created.ID { + t.Errorf("emitted ID %d, want %d", summary.ID, created.ID) + } + + if summary.Name != "Road Trip" { + t.Errorf("emitted name %q, want Road Trip", summary.Name) + } + + if summary.CreatedAt == "" || summary.UpdatedAt == "" { + t.Errorf("emitted empty timestamps: %+v", summary) + } +} + +func TestEmit_RejectedCreateIsSilent(t *testing.T) { + t.Parallel() + + svc, _, rec := setupRecordedService(t) + + if _, err := svc.CreatePlaylist(" "); err == nil { + t.Fatal("CreatePlaylist accepted a blank name") + } + + if got := rec.Count(events.PlaylistCreated); got != 0 { + t.Errorf("emitted %d PlaylistCreated for a rejected create, want 0", got) + } +} + +// TestEmit_TracksChangedFiresAfterTheWriteIsVisible is the reason this +// package is worth covering as well as queue: the frontend re-reads the +// playlist when it sees PlaylistTracksChanged, so an event emitted +// before the rows were committed would have it read the old contents. +func TestEmit_TracksChangedFiresAfterTheWriteIsVisible(t *testing.T) { + t.Parallel() + + svc, db, rec := setupRecordedService(t) + paths := seedPlaylistTracks(t, db, 3) + + created, err := svc.CreatePlaylist("Mix") + if err != nil { + t.Fatalf("CreatePlaylist: %v", err) + } + + rec.Reset() + + if err := svc.AddTracksToPlaylist(created.ID, paths); err != nil { + t.Fatalf("AddTracksToPlaylist: %v", err) + } + + ev, ok := rec.Last(events.PlaylistTracksChanged) + if !ok { + t.Fatalf("no PlaylistTracksChanged; got %v", rec.Names()) + } + + id, ok := ev.Payload().(int64) + if !ok { + t.Fatalf("payload is %T, want int64", ev.Payload()) + } + + if id != created.ID { + t.Errorf("emitted playlist ID %d, want %d", id, created.ID) + } + + // Read back the way the frontend would on receipt of the event. + tracks, err := svc.GetPlaylistTracks(created.ID) + if err != nil { + t.Fatalf("GetPlaylistTracks: %v", err) + } + + if len(tracks) != len(paths) { + t.Errorf( + "a frontend reacting to the event reads %d tracks, want %d", + len(tracks), len(paths), + ) + } +} + +func TestEmit_DeletePlaylistAnnouncesTheID(t *testing.T) { + t.Parallel() + + svc, _, rec := setupRecordedService(t) + + created, err := svc.CreatePlaylist("Temp") + if err != nil { + t.Fatalf("CreatePlaylist: %v", err) + } + + rec.Reset() + + if err := svc.DeletePlaylist(created.ID); err != nil { + t.Fatalf("DeletePlaylist: %v", err) + } + + ev, ok := rec.Last(events.PlaylistDeleted) + if !ok { + t.Fatalf("no PlaylistDeleted; got %v", rec.Names()) + } + + if id, _ := ev.Payload().(int64); id != created.ID { + t.Errorf("emitted ID %v, want %d", ev.Payload(), created.ID) + } +} diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 1e45273..2d0d120 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -15,8 +15,6 @@ import ( "sync" "time" - "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/coverart" "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" @@ -1451,11 +1449,7 @@ func (s *Service) emitEvent( eventName string, data any, ) { - if s.ctx == nil { - return - } - - runtime.EventsEmit(s.ctx, eventName, data) + events.Emit(s.ctx, eventName, data) } // migrateExistingPlaylists generates M3U8 files for any diff --git a/backend/queue/emit.go b/backend/queue/emit.go index e936246..45eb617 100644 --- a/backend/queue/emit.go +++ b/backend/queue/emit.go @@ -1,17 +1,11 @@ package queue import ( - "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/events" ) // emitQueueChanged emits the full queue state to the frontend. func (q *Queue) emitQueueChanged() { - if q.ctx == nil { - return - } - state := State{ Tracks: q.tracks, CurrentIndex: q.currentIndex, @@ -25,16 +19,12 @@ func (q *Queue) emitQueueChanged() { state.Tracks = []Track{} } - runtime.EventsEmit(q.ctx, events.QueueChanged, state) + events.Emit(q.ctx, events.QueueChanged, state) } // emitIndexChanged emits only the current index to the frontend. func (q *Queue) emitIndexChanged() { - if q.ctx == nil { - return - } - - runtime.EventsEmit( + events.Emit( q.ctx, events.QueueIndexChanged, IndexChanged{CurrentIndex: q.currentIndex}, @@ -43,11 +33,7 @@ func (q *Queue) emitIndexChanged() { // emitModeChanged emits only the shuffle/repeat mode to the frontend. func (q *Queue) emitModeChanged() { - if q.ctx == nil { - return - } - - runtime.EventsEmit( + events.Emit( q.ctx, events.QueueModeChanged, ModeChanged{ @@ -64,11 +50,7 @@ func (q *Queue) emitTracksModified( index int, positions []int, ) { - if q.ctx == nil { - return - } - - runtime.EventsEmit( + events.Emit( q.ctx, events.QueueTracksModified, TracksModified{ diff --git a/backend/queue/emit_test.go b/backend/queue/emit_test.go new file mode 100644 index 0000000..5e8fbe1 --- /dev/null +++ b/backend/queue/emit_test.go @@ -0,0 +1,283 @@ +package queue + +import ( + "context" + "log/slog" + "testing" + "time" + + "yellowjacket/backend/database" + "yellowjacket/backend/events" +) + +// waitFor is how long a test waits for an event emitted from a +// background goroutine (SetQueue resolves large queues in phases). +const waitFor = 5 * time.Second + +// setupRecordedQueue is setupTestQueue with an event sink installed, so +// what the frontend would receive is assertable. +// +// Before events.Emit existed this was impossible: SetContext with a +// context.Background() made every emit call log.Fatalf inside Wails, +// and SetContext(nil) made the queue skip emitting entirely — so the +// payloads below have never been covered. +func setupRecordedQueue(t *testing.T) (*Queue, *database.DB, *events.Recorder) { + t.Helper() + + db := database.NewTestDB(t) + q := NewQueue(slog.Default(), db) + q.SetPlayer(&mockTrackLoader{}) + + rec := events.NewRecorder() + q.SetContext(events.WithSink(context.Background(), rec)) + + return q, db, rec +} + +// stateOf returns the State payload of the most recent QueueChanged. +func stateOf(t *testing.T, rec *events.Recorder) State { + t.Helper() + + ev, ok := rec.Last(events.QueueChanged) + if !ok { + t.Fatalf("no QueueChanged emitted; got %v", rec.Names()) + } + + state, ok := ev.Payload().(State) + if !ok { + t.Fatalf("QueueChanged payload is %T, want queue.State", ev.Payload()) + } + + return state +} + +// modifiedOf returns the TracksModified payload of the most recent +// QueueTracksModified. +func modifiedOf(t *testing.T, rec *events.Recorder) TracksModified { + t.Helper() + + ev, ok := rec.Last(events.QueueTracksModified) + if !ok { + t.Fatalf("no QueueTracksModified emitted; got %v", rec.Names()) + } + + mod, ok := ev.Payload().(TracksModified) + if !ok { + t.Fatalf( + "QueueTracksModified payload is %T, want queue.TracksModified", + ev.Payload(), + ) + } + + return mod +} + +func TestEmit_SetQueuePushesFullState(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 2, false) + + if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { + t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) + } + + state := stateOf(t, rec) + if len(state.Tracks) != 5 { + t.Errorf("emitted %d tracks, want 5", len(state.Tracks)) + } + + if state.CurrentIndex != 2 { + t.Errorf("emitted currentIndex %d, want 2", state.CurrentIndex) + } +} + +// TestEmit_ClearSendsEmptyNotNilTrackList pins a frontend contract that +// only exists in the emitted payload: the queue's own tracks field is +// nil after Clear, and the store does `state.tracks.length` on receipt. +func TestEmit_ClearSendsEmptyNotNilTrackList(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 3) + + q.SetQueue(paths, 0, false) + rec.Reset() + q.Clear() + + state := stateOf(t, rec) + if state.Tracks == nil { + t.Error("emitted tracks is nil; the frontend reads .length on it") + } + + if len(state.Tracks) != 0 { + t.Errorf("emitted %d tracks after Clear, want 0", len(state.Tracks)) + } + + if state.CurrentIndex != -1 { + t.Errorf("emitted currentIndex %d after Clear, want -1", state.CurrentIndex) + } +} + +func TestEmit_CycleRepeatWalksAllModes(t *testing.T) { + t.Parallel() + + q, _, rec := setupRecordedQueue(t) + + want := []RepeatMode{RepeatAll, RepeatOne, RepeatOff} + + for i, wantMode := range want { + q.CycleRepeat() + + modeEvents := rec.Named(events.QueueModeChanged) + if len(modeEvents) != i+1 { + t.Fatalf("after %d cycles: %d QueueModeChanged events, want %d", + i+1, len(modeEvents), i+1) + } + + mode, ok := modeEvents[i].Payload().(ModeChanged) + if !ok { + t.Fatalf("payload is %T, want queue.ModeChanged", modeEvents[i].Payload()) + } + + if mode.RepeatMode != wantMode { + t.Errorf("cycle %d emitted %v, want %v", i+1, mode.RepeatMode, wantMode) + } + + if mode.ShuffleMode { + t.Errorf("cycle %d emitted shuffleMode true; only repeat changed", i+1) + } + } +} + +func TestEmit_ToggleShuffleReportsBothModes(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + q.SetQueue(seedAudioFiles(t, db, 5), 0, false) + rec.Reset() + + q.ToggleShuffle() + + ev, ok := rec.Last(events.QueueModeChanged) + if !ok { + t.Fatalf("no QueueModeChanged; got %v", rec.Names()) + } + + mode, ok := ev.Payload().(ModeChanged) + if !ok { + t.Fatalf("payload is %T, want queue.ModeChanged", ev.Payload()) + } + + if !mode.ShuffleMode { + t.Error("emitted shuffleMode false after ToggleShuffle") + } + + // The mode event carries both modes, so a frontend that renders the + // two toggles from one event cannot desync them. + if mode.RepeatMode != RepeatOff { + t.Errorf("emitted repeatMode %v, want RepeatOff", mode.RepeatMode) + } +} + +// TestEmit_AddTrackSendsDeltaNotSnapshot pins the distinction the whole +// TracksModified type exists for: appending must not re-push the queue. +func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 4) + + q.SetQueue(paths[:3], 0, false) + + if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { + t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) + } + + rec.Reset() + q.AddTrack(paths[3]) + + if got := rec.Count(events.QueueChanged); got != 0 { + t.Errorf("AddTrack emitted %d QueueChanged; want a delta only", got) + } + + mod := modifiedOf(t, rec) + if mod.Action != "add" { + t.Errorf("action = %q, want \"add\"", mod.Action) + } + + if mod.Index != 3 { + t.Errorf("index = %d, want 3 (appended at the end)", mod.Index) + } + + if len(mod.Tracks) != 1 || mod.Tracks[0].FilePath != paths[3] { + t.Errorf("tracks = %v, want just %s", mod.Tracks, paths[3]) + } +} + +func TestEmit_RemoveTracksReportsPositions(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + + if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { + t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) + } + + rec.Reset() + q.RemoveTracks([]int{3, 1}) + + mod := modifiedOf(t, rec) + if mod.Action != "remove" { + t.Errorf("action = %q, want \"remove\"", mod.Action) + } + + if len(mod.Positions) != 2 { + t.Fatalf("positions = %v, want two entries", mod.Positions) + } +} + +// TestEmit_NextPushesIndexOnly covers auto-advance, which is what the +// player calls at the end of a track: the frontend must be able to move +// the now-playing highlight without re-rendering the queue. +func TestEmit_NextPushesIndexOnly(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 3) + + q.SetQueue(paths, 0, false) + + if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { + t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) + } + + rec.Reset() + q.Next() + q.Next() + + idxEvents := rec.Named(events.QueueIndexChanged) + if len(idxEvents) != 2 { + t.Fatalf("got %d QueueIndexChanged, want 2 (%v)", len(idxEvents), rec.Names()) + } + + for i, ev := range idxEvents { + idx, ok := ev.Payload().(IndexChanged) + if !ok { + t.Fatalf("payload is %T, want queue.IndexChanged", ev.Payload()) + } + + if idx.CurrentIndex != i+1 { + t.Errorf("advance %d emitted index %d, want %d", i+1, idx.CurrentIndex, i+1) + } + } + + if got := rec.Count(events.QueueChanged); got != 0 { + t.Errorf("advancing emitted %d QueueChanged; want index deltas only", got) + } +} diff --git a/backend/queue/playhistory.go b/backend/queue/playhistory.go index cf5c23d..ba60a37 100644 --- a/backend/queue/playhistory.go +++ b/backend/queue/playhistory.go @@ -3,8 +3,6 @@ package queue import ( "time" - "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/events" ) @@ -62,9 +60,5 @@ func (q *Queue) recordPlay(audioFileID int64) { ) // Notify frontend so the track list refreshes play count. - if q.ctx != nil { - runtime.EventsEmit( - q.ctx, events.TrackMetadataChanged, - ) - } + events.Emit(q.ctx, events.TrackMetadataChanged) } diff --git a/backend/tagwriter/pipeline.go b/backend/tagwriter/pipeline.go index b22f6f6..5d6e4f2 100644 --- a/backend/tagwriter/pipeline.go +++ b/backend/tagwriter/pipeline.go @@ -7,8 +7,6 @@ import ( "log/slog" "time" - wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/database" "yellowjacket/backend/events" ) @@ -175,8 +173,8 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { } // 7. Emit event (suppressed during batch writes). - if tw.ctx != nil && !tw.suppressEvents { - wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged, + if !tw.suppressEvents { + events.Emit(tw.ctx, events.TrackMetadataChanged, map[string]any{ "trackId": trackID, "filePath": audioFile.FilePath, @@ -214,6 +212,22 @@ func (tw *TagWriter) WriteUntrackedFileTags( return errNoChanges } + return WriteFileTags(tw.logger, filePath, changes) +} + +// WriteFileTags writes tags straight to an audio file, with no +// database, player or lock involvement. It is the format-dispatch +// half of WriteUntrackedFileTags, exported so tooling that has no +// app to construct — the fixture generator in cmd/gentestdata — can +// tag files with the same writers the app uses, rather than growing a +// second tagger that is free to drift from this one. +// +// Callers owning a *TagWriter should use WriteUntrackedFileTags. +func WriteFileTags( + logger *slog.Logger, + filePath string, + changes TagChanges, +) error { format, err := DetectFormat(filePath) if err != nil { return fmt.Errorf("detect format: %w", err) @@ -221,13 +235,13 @@ func (tw *TagWriter) WriteUntrackedFileTags( switch format { case FormatMP3: - err = writeMp3Tags(tw.logger, filePath, changes) + err = writeMp3Tags(logger, filePath, changes) case FormatFLAC: - err = writeFlacTags(tw.logger, filePath, changes) + err = writeFlacTags(logger, filePath, changes) case FormatWAV: - err = writeWavTags(tw.logger, filePath, changes) + err = writeWavTags(logger, filePath, changes) case FormatOGG: - err = writeOggTags(tw.logger, filePath, changes) + err = writeOggTags(logger, filePath, changes) default: err = fmt.Errorf("%w: %s", errUnsupportedFormat, format) } @@ -334,30 +348,26 @@ func (tw *TagWriter) BatchWriteTrackTags( } // Emit progress after each track (success or failure). - if tw.ctx != nil { - wailsruntime.EventsEmit(tw.ctx, - events.BatchWriteProgress, - map[string]any{ - "current": i + 1, - "total": total, - "filePath": filePath, - "succeeded": result.Succeeded, - "failed": result.Failed, - }, - ) - } + events.Emit(tw.ctx, + events.BatchWriteProgress, + map[string]any{ + "current": i + 1, + "total": total, + "filePath": filePath, + "succeeded": result.Succeeded, + "failed": result.Failed, + }, + ) } // Emit a single TrackMetadataChanged after the batch completes // so the library store invalidates once rather than per-track. - if tw.ctx != nil { - wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged, - map[string]any{ - "batch": true, - "total": result.Succeeded, - }, - ) - } + events.Emit(tw.ctx, events.TrackMetadataChanged, + map[string]any{ + "batch": true, + "total": result.Succeeded, + }, + ) tw.logger.Info("batch write complete", "total", total, diff --git a/backend/testctl/dbstate_dev.go b/backend/testctl/dbstate_dev.go new file mode 100644 index 0000000..7501458 --- /dev/null +++ b/backend/testctl/dbstate_dev.go @@ -0,0 +1,326 @@ +//go:build dev + +package testctl + +import ( + "database/sql" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +// snapshotDir keeps snapshots inside the sandbox's own YJ_HOME, so +// deleting the home deletes them and nothing leaks between runs. +func snapshotDir() (string, error) { + dir := filepath.Join(filepath.Dir(dbPath()), "testctl") + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + + return dir, nil +} + +func snapshotPath(name string) (string, error) { + if !safeName.MatchString(name) { + return "", errBadName + } + + dir, err := snapshotDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, name+".db"), nil +} + +// handleSnapshot copies the live database with VACUUM INTO, which takes +// a consistent copy without stopping the app or closing the handle. +// +// POST /__test/db/snapshot?name=pristine +func handleSnapshot(d Deps, r *http.Request) (any, error) { + path, err := snapshotPath(r.URL.Query().Get("name")) + if err != nil { + return nil, err + } + + // VACUUM INTO refuses to overwrite, and a spec re-snapshotting the + // same name means "replace", not "fail". + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return nil, err + } + + if _, err := d.DB.ExecContext("VACUUM INTO ?", path); err != nil { + return nil, err + } + + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + return map[string]any{"path": path, "bytes": info.Size()}, nil +} + +// handleRestore puts the database back to a previous snapshot without +// restarting the app. +// +// It copies rows rather than files because the app holds the file open +// (two connection pools, WAL) and cannot be made to reopen it from +// here. ATTACH runs on the writer connection — an attachment is +// invisible to the read pool, which is a separate sql.DB over the same +// file, so anything touching `snap.` must avoid QueryContext. +// +// POST /__test/db/restore?name=pristine +func handleRestore(d Deps, r *http.Request) (any, error) { + path, err := snapshotPath(r.URL.Query().Get("name")) + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + return nil, errNoSnapshot + } + + if _, err := d.DB.ExecContext("ATTACH DATABASE ? AS snap", path); err != nil { + return nil, err + } + + defer func() { + if _, err := d.DB.ExecContext("DETACH DATABASE snap"); err != nil { + d.Logger.Error("testctl could not detach snapshot", + "err", err.Error()) + } + }() + + tables, err := restorableTables(d) + if err != nil { + return nil, err + } + + if err := copyTables(d, tables); err != nil { + return nil, err + } + + if err := checkForeignKeys(d); err != nil { + return nil, err + } + + // search_index and lyrics_index are FTS5 tables maintained by Go, + // not by triggers, so a row copy leaves them stale. The explore + // FTS tables *are* trigger-maintained off explore_index and + // re-synced by the copy above. + if err := d.DB.RebuildSearchIndex(); err != nil { + return nil, err + } + + if err := d.DB.RebuildLyricsIndex(); err != nil { + return nil, err + } + + return map[string]any{"restored": path, "tables": len(tables)}, nil +} + +// restorableTables lists the ordinary tables to copy. +// +// Two kinds are excluded. FTS5 virtual tables cannot be written by +// SELECT * (their column shape is not their storage shape), and every +// shadow table backing one — _data, _idx, _content, _docsize, +// _config — is an implementation detail that must be rebuilt rather +// than copied. +func restorableTables(d Deps) ([]string, error) { + // main.sqlite_master is readable from the read pool; only `snap.` + // requires the writer connection. + rows, err := d.DB.QueryContext( + `SELECT name, COALESCE(sql, '') FROM main.sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name`, + ) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + var ( + ordinary []string + virtual []string + ) + + for rows.Next() { + var name, ddl string + if err := rows.Scan(&name, &ddl); err != nil { + return nil, err + } + + if strings.HasPrefix(strings.ToUpper(ddl), "CREATE VIRTUAL TABLE") { + virtual = append(virtual, name) + + continue + } + + ordinary = append(ordinary, name) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]string, 0, len(ordinary)) + + for _, name := range ordinary { + if isShadowTable(name, virtual) { + continue + } + + out = append(out, name) + } + + return out, nil +} + +// isShadowTable reports whether name is storage belonging to one of the +// given virtual tables. +func isShadowTable(name string, virtual []string) bool { + for _, v := range virtual { + if strings.HasPrefix(name, v+"_") { + return true + } + } + + return false +} + +// copyTables replaces the contents of every named table from `snap`. +// +// Foreign keys are switched **off** for the duration, not merely +// deferred. Deferring only postpones the *check*; it does not stop +// ON DELETE CASCADE from firing, and the tables are copied in name +// order, which is not dependency order — so `DELETE FROM libraries` +// cascades away the rows of a child table that was restored earlier in +// the loop, and the commit then fails with a bare "FOREIGN KEY +// constraint failed (787)" that points at nothing. Measured, not +// theorised. +// +// PRAGMA foreign_keys is a no-op inside a transaction, so it has to be +// set on the connection around it. That is safe here only because the +// writer is a single connection and this is a dev-only endpoint; the +// caller re-enables and then verifies with PRAGMA foreign_key_check, +// so an inconsistent restore is reported rather than left in place. +func copyTables(d Deps, tables []string) error { + if _, err := d.DB.ExecContext("PRAGMA foreign_keys = OFF"); err != nil { + return err + } + + defer func() { + if _, err := d.DB.ExecContext("PRAGMA foreign_keys = ON"); err != nil { + d.Logger.Error("testctl could not re-enable foreign keys", + "err", err.Error()) + } + }() + + tx, err := d.DB.BeginTx() + if err != nil { + return err + } + + defer func() { _ = tx.Rollback() }() + + for _, name := range tables { + quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"` + + if _, err := tx.Exec("DELETE FROM main." + quoted); err != nil { + return err + } + + if _, err := tx.Exec( + "INSERT INTO main." + quoted + " SELECT * FROM snap." + quoted, + ); err != nil { + return err + } + } + + return tx.Commit() +} + +// checkForeignKeys verifies the restored database is self-consistent, +// since the copy ran with enforcement off. +func checkForeignKeys(d Deps) error { + rows, err := d.DB.QueryContext("PRAGMA main.foreign_key_check") + if err != nil { + return err + } + + defer func() { _ = rows.Close() }() + + var tables []string + + for rows.Next() { + var ( + table, parent string + rowid, fkid sql.NullInt64 + ) + + if err := rows.Scan(&table, &rowid, &parent, &fkid); err != nil { + return err + } + + tables = append(tables, table+"->"+parent) + } + + if err := rows.Err(); err != nil { + return err + } + + if len(tables) > 0 { + return fmt.Errorf("%w: %s", errInconsistent, + strings.Join(tables[:min(len(tables), 5)], ", ")) + } + + return nil +} + +// scanAll turns a result set into JSON-shaped rows. Values arrive as +// any so that a spec can assert on them without the endpoint having to +// know the schema. +func scanAll(rows *sql.Rows) (any, error) { + cols, err := rows.Columns() + if err != nil { + return nil, err + } + + out := []map[string]any{} + + for rows.Next() { + cells := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + + for i := range cells { + ptrs[i] = &cells[i] + } + + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + + row := make(map[string]any, len(cols)) + + for i, col := range cols { + // []byte encodes as base64 in JSON, which is unreadable + // for the text columns this mostly returns. + if b, ok := cells[i].([]byte); ok { + row[col] = string(b) + + continue + } + + row[col] = cells[i] + } + + out = append(out, row) + } + + return map[string]any{"columns": cols, "rows": out}, rows.Err() +} diff --git a/backend/testctl/dbstate_dev_test.go b/backend/testctl/dbstate_dev_test.go new file mode 100644 index 0000000..fcb3b40 --- /dev/null +++ b/backend/testctl/dbstate_dev_test.go @@ -0,0 +1,207 @@ +//go:build dev + +package testctl + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "yellowjacket/backend/database" +) + +// newDeps wires the control surface to an in-memory database and a +// throwaway YJ_HOME, so snapshots land somewhere the test owns. +func newDeps(t *testing.T) Deps { + t.Helper() + + t.Setenv("YJ_HOME", t.TempDir()) + + return Deps{ + Logger: slog.New(slog.DiscardHandler), + DB: database.NewTestDB(t), + Context: context.Background, + } +} + +// TestRestoreRoundTrip is the regression test for the failure this +// endpoint shipped with first: copying tables in *name* order deletes a +// parent row whose ON DELETE CASCADE then wipes a child table already +// restored earlier in the loop, and the commit fails with a bare +// "FOREIGN KEY constraint failed (787)" naming nothing. Deferring the +// check is not enough — the cascade still fires — so the copy runs with +// foreign keys off and is verified afterwards. +func TestRestoreRoundTrip(t *testing.T) { + // No t.Parallel: newDeps uses t.Setenv (YJ_HOME), which the testing + // package forbids in parallel tests because the environment is + // process-wide. + d := newDeps(t) + + if _, err := d.DB.ExecContext( + `INSERT INTO libraries (id, name, path) VALUES (1, 'fixtures', '/music')`, + ); err != nil { + t.Fatalf("seed library: %v", err) + } + + if _, err := d.DB.ExecContext( + `INSERT INTO artist_credit (id, text) VALUES (1, 'Fixture Artist')`, + ); err != nil { + t.Fatalf("seed artist credit: %v", err) + } + + if _, err := d.DB.ExecContext( + `INSERT INTO recordings (id, name, artist_credit_id) + VALUES (1, 'A', 1)`, + ); err != nil { + t.Fatalf("seed recording: %v", err) + } + + if _, err := d.DB.ExecContext( + `INSERT INTO audio_files + (file_path, length_milliseconds, file_type_id, recording_id, library_id) + VALUES ('/music/a.mp3', 2000, 1, 1, 1)`, + ); err != nil { + t.Fatalf("seed track: %v", err) + } + + snapReq := httptest.NewRequest(http.MethodPost, "/__test/db/snapshot?name=unit", nil) + + if _, err := handleSnapshot(d, snapReq); err != nil { + t.Fatalf("snapshot: %v", err) + } + + if _, err := d.DB.ExecContext(`DELETE FROM audio_files`); err != nil { + t.Fatalf("mutate: %v", err) + } + + if got := countTracks(t, d); got != 0 { + t.Fatalf("after delete: got %d tracks, want 0", got) + } + + restoreReq := httptest.NewRequest(http.MethodPost, "/__test/db/restore?name=unit", nil) + + if _, err := handleRestore(d, restoreReq); err != nil { + t.Fatalf("restore: %v", err) + } + + if got := countTracks(t, d); got != 1 { + t.Fatalf("after restore: got %d tracks, want 1", got) + } + + // Enforcement must be back on afterwards; leaving it off would let + // every later test — and the running app — write garbage silently. + var fk int + if err := d.DB.QueryRowWriter("PRAGMA foreign_keys").Scan(&fk); err != nil { + t.Fatalf("read pragma: %v", err) + } + + if fk != 1 { + t.Fatal("foreign keys left disabled after restore") + } +} + +// TestRestorableTablesSkipsFTSInternals guards the other half of the +// copy: FTS5 virtual tables cannot be written with SELECT *, and their +// shadow tables (_data, _idx, _docsize, _config) are storage details +// that must be rebuilt rather than copied. +func TestRestorableTablesSkipsFTSInternals(t *testing.T) { + tables, err := restorableTables(newDeps(t)) + if err != nil { + t.Fatalf("restorableTables: %v", err) + } + + if len(tables) == 0 { + t.Fatal("no restorable tables found") + } + + for _, name := range tables { + switch name { + case "search_index", "lyrics_index", "explore_index_fts", + "explore_champion_fts": + t.Errorf("virtual table %q must not be copied", name) + case "search_index_data", "lyrics_index_idx", + "explore_index_fts_config": + t.Errorf("shadow table %q must not be copied", name) + } + } + + // explore_index is an ordinary table whose name is a prefix of two + // virtual ones; excluding it would silently drop the catalog. + if !contains(tables, "explore_index") { + t.Error("explore_index was wrongly treated as an FTS internal") + } +} + +func TestSnapshotNameIsValidated(t *testing.T) { + d := newDeps(t) + + for _, name := range []string{"", "../escape", "has space", "a/b"} { + req := httptest.NewRequest( + http.MethodPost, + "/__test/db/snapshot?name="+url.QueryEscape(name), + nil, + ) + + if _, err := handleSnapshot(d, req); err == nil { + t.Errorf("snapshot(%q) was accepted", name) + } + } +} + +// TestRegisterRequiresOptIn pins the second gate: a dev build alone must +// not expose the surface, because `make dev` is something a human runs. +func TestRegisterRequiresOptIn(t *testing.T) { + _ = os.Unsetenv(EnvEnable) + + var r recordingRegistrar + + Register(&r, Deps{Logger: slog.New(slog.DiscardHandler)}) + + if len(r.patterns) != 0 { + t.Fatalf("registered %v without %s=1", r.patterns, EnvEnable) + } + + t.Setenv(EnvEnable, "1") + Register(&r, Deps{Logger: slog.New(slog.DiscardHandler)}) + + if len(r.patterns) != 1 || r.patterns[0] != Prefix { + t.Fatalf("got patterns %v, want [%s]", r.patterns, Prefix) + } +} + +// recordingRegistrar stands in for *assets.Handler and remembers what +// was mounted. +type recordingRegistrar struct { + patterns []string +} + +func (r *recordingRegistrar) RegisterHandler(pattern string, _ http.Handler) { + r.patterns = append(r.patterns, pattern) +} + +func countTracks(t *testing.T, d Deps) int { + t.Helper() + + var n int + if err := d.DB.QueryRowWriter( + "SELECT COUNT(*) FROM audio_files", + ).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + + return n +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + + return false +} diff --git a/backend/testctl/handlers_dev.go b/backend/testctl/handlers_dev.go new file mode 100644 index 0000000..f5ddba9 --- /dev/null +++ b/backend/testctl/handlers_dev.go @@ -0,0 +1,199 @@ +//go:build dev + +package testctl + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "yellowjacket/backend/events" + "yellowjacket/backend/system" +) + +// handleHealth answers the one question every spec starts with: is the +// backend up, and is it looking at the library it should be? +// +// The frontend can answer parts of this, but only after it has rendered +// — which is exactly the thing under test. This answers before a +// single component has mounted, so it is usable as a gate. +func handleHealth(d Deps, _ *http.Request) (any, error) { + out := map[string]any{ + "ok": true, + "home": os.Getenv("YJ_HOME"), + "dbPath": dbPath(), + "pid": os.Getpid(), + "context": d.Context() != nil, + } + + counts := map[string]int64{} + + for table, query := range map[string]string{ + "tracks": "SELECT COUNT(*) FROM audio_files", + "libraries": "SELECT COUNT(*) FROM libraries", + "playlists": "SELECT COUNT(*) FROM playlists", + "queueTracks": "SELECT COUNT(*) FROM queue_tracks", + "exploreIndex": "SELECT COUNT(*) FROM explore_index", + } { + var n int64 + if err := d.DB.QueryRowWriter(query).Scan(&n); err != nil { + counts[table] = -1 + + continue + } + + counts[table] = n + } + + out["counts"] = counts + + libs, err := libraryRows(d) + if err != nil { + return nil, err + } + + out["libraries"] = libs + + return out, nil +} + +// libraryRows lists the configured libraries by name and path, so a +// spec can assert it is driving the fixture library and not somebody's +// real music collection. +func libraryRows(d Deps) ([]map[string]any, error) { + rows, err := d.DB.QueryContext( + "SELECT id, name, path FROM libraries ORDER BY id", + ) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + out := []map[string]any{} + + for rows.Next() { + var ( + id int64 + name, path string + ) + + if err := rows.Scan(&id, &name, &path); err != nil { + return nil, err + } + + out = append(out, map[string]any{ + "id": id, "name": name, "path": path, + }) + } + + return out, rows.Err() +} + +// handleEmit pushes a backend event into every connected frontend. +// +// This is the biggest lever the surface has. Half this app is +// push-driven, and several of those events are only produced by work +// that takes minutes to hours (a full scan, a download, an artifact +// import). Emitting one directly renders the view that consumes it +// without staging the work that would normally produce it. +// +// POST /__test/emit {"name":"LibraryScanProgress","data":[{"...":1}]} +func handleEmit(d Deps, r *http.Request) (any, error) { + var body struct { + Name string `json:"name"` + Data []any `json:"data"` + } + + if err := decode(r, &body); err != nil { + return nil, err + } + + if body.Name == "" { + return nil, errNoEventName + } + + // events.Deliver rather than events.Emit: an ordinary emitter wants + // an event with nowhere to go dropped, but this endpoint exists to + // impersonate one, and reporting a 200 for an event that never + // reached a frontend would send a caller debugging the wrong half of + // the app. + if err := events.Deliver(d.Context(), body.Name, body.Data...); err != nil { + return nil, fmt.Errorf("emit %s: %w", body.Name, err) + } + + return map[string]any{"emitted": body.Name, "args": len(body.Data)}, nil +} + +// handleSQL runs a statement against the writer connection. +// +// One general escape hatch rather than a bespoke endpoint per piece of +// forced state — "mark this track played", "insert a wanted-list row", +// "age this cache entry" — each of which would otherwise arrive one at +// a time and never be removed. +// +// POST /__test/sql {"sql":"UPDATE ...","args":[1,"x"]} +func handleSQL(d Deps, r *http.Request) (any, error) { + var body struct { + SQL string `json:"sql"` + Args []any `json:"args"` + } + + if err := decode(r, &body); err != nil { + return nil, err + } + + if body.SQL == "" { + return nil, errNoSQL + } + + // Route by statement kind rather than by trying one and falling + // back: the read pool is opened query_only, so sending a write + // there fails in a way that looks like a bug in the caller's SQL. + if isQuery(body.SQL) { + rows, err := d.DB.QueryContextWith(r.Context(), body.SQL, body.Args...) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + return scanAll(rows) + } + + res, err := d.DB.ExecContext(body.SQL, body.Args...) + if err != nil { + return nil, err + } + + affected, err := res.RowsAffected() + if err != nil { + return nil, err + } + + return map[string]any{"rowsAffected": affected}, nil +} + +// isQuery reports whether a statement returns rows. +func isQuery(sql string) bool { + first, _, _ := strings.Cut(strings.TrimSpace(sql), " ") + + switch strings.ToUpper(first) { + case "SELECT", "WITH", "PRAGMA", "EXPLAIN": + return true + default: + return false + } +} + +// dbPath reports where the SQLite file lives, mirroring database.NewDB. +func dbPath() string { + dir, err := system.GetUserDataDirPath() + if err != nil { + return "" + } + + return filepath.Join(dir, "yj.db") +} diff --git a/backend/testctl/register_dev.go b/backend/testctl/register_dev.go new file mode 100644 index 0000000..c8a5e31 --- /dev/null +++ b/backend/testctl/register_dev.go @@ -0,0 +1,98 @@ +//go:build dev + +package testctl + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "regexp" +) + +// Static errors — err113 forbids fmt.Errorf with a dynamic message at +// the point of failure, and these are all conditions a caller may want +// to match on anyway. +var ( + errBadName = errors.New("name must match [A-Za-z0-9_-]{1,64}") + errNoSnapshot = errors.New("no such snapshot") + errNoEventName = errors.New("emit needs a non-empty name") + errNoSQL = errors.New("sql must be non-empty") + errBadBody = errors.New("request body is not valid JSON") + errInconsistent = errors.New( + "restore left foreign key violations") +) + +// safeName keeps snapshot names to something that cannot escape the +// snapshot directory or surprise a shell. +var safeName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// Register mounts the control surface, if and only if this is a dev +// build *and* YJ_TESTCTL=1. A human running `make dev` gets neither +// the routes nor the risk. +func Register(r Registrar, d Deps) { + if os.Getenv(EnvEnable) != "1" { + return + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /__test/health", jsonHandler(d, handleHealth)) + mux.HandleFunc("POST /__test/db/snapshot", jsonHandler(d, handleSnapshot)) + mux.HandleFunc("POST /__test/db/restore", jsonHandler(d, handleRestore)) + mux.HandleFunc("POST /__test/emit", jsonHandler(d, handleEmit)) + mux.HandleFunc("POST /__test/sql", jsonHandler(d, handleSQL)) + + r.RegisterHandler(Prefix, mux) + + d.Logger.Warn( + "test control surface enabled — dev build with YJ_TESTCTL=1", + "prefix", Prefix, + ) +} + +// handlerFunc is the shape every endpoint has: take the request, +// return something JSON-encodable or an error. +type handlerFunc func(Deps, *http.Request) (any, error) + +// jsonHandler centralises encoding, status codes and logging so each +// endpoint is only its own logic. A failure is a 400 with the reason +// in the body — an agent reading a spec failure needs the reason, not +// a bare status code. +func jsonHandler(d Deps, fn handlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := fn(d, r) + + w.Header().Set("Content-Type", "application/json") + + if err != nil { + d.Logger.Error("testctl request failed", + "path", r.URL.Path, "err", err.Error()) + w.WriteHeader(http.StatusBadRequest) + + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": err.Error(), + }) + + return + } + + _ = json.NewEncoder(w).Encode(result) + } +} + +// decode reads a JSON request body into v. An empty body is not an +// error: several endpoints take everything in the query string. +func decode(r *http.Request, v any) error { + if r.Body == nil { + return nil + } + + dec := json.NewDecoder(r.Body) + + if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) { + return errBadBody + } + + return nil +} diff --git a/backend/testctl/register_nondev.go b/backend/testctl/register_nondev.go new file mode 100644 index 0000000..68c0344 --- /dev/null +++ b/backend/testctl/register_nondev.go @@ -0,0 +1,8 @@ +//go:build !dev + +package testctl + +// Register is a no-op in non-dev builds: the control surface's entire +// implementation is behind the `dev` build tag, so a release binary +// contains neither the handlers nor the routes. +func Register(_ Registrar, _ Deps) {} diff --git a/backend/testctl/testctl.go b/backend/testctl/testctl.go new file mode 100644 index 0000000..dfe39be --- /dev/null +++ b/backend/testctl/testctl.go @@ -0,0 +1,53 @@ +// Package testctl mounts a dev-only HTTP control surface at /__test/ +// on the app's own asset server. +// +// It exists for the residue of what an end-to-end harness genuinely +// cannot reach from the browser. Everything the frontend can do is +// already reachable through the generated bindings on `window.go` — +// clicking, reading the DOM, calling a service — so this deliberately +// does *not* re-expose any of that. What is left is server-side state: +// snapshotting and restoring the SQLite database mid-run, forcing a +// backend event so a push-driven view can be rendered without staging +// hours of real work, and reading a single authoritative "is the +// backend actually ready" answer. +// +// It is gated twice. The implementation lives behind the `dev` build +// tag (the non-dev twin is an empty function, so nothing links into a +// release binary), and even in a dev build it refuses to register +// unless YJ_TESTCTL=1 — otherwise every `make dev` session a human runs +// would carry an arbitrary-SQL endpoint on a listening port. +package testctl + +import ( + "context" + "log/slog" + "net/http" + + "yellowjacket/backend/database" +) + +// EnvEnable must be set to "1" for the surface to register, even in a +// dev build. scripts/dev-headless.sh sets it; `make dev` does not. +const EnvEnable = "YJ_TESTCTL" + +// Prefix is the single mount point. One pattern, one ServeMux entry. +const Prefix = "/__test/" + +// Registrar is the slice of *assets.Handler this package needs, taken as +// an interface so testctl does not import the asset server (which would +// make the non-dev build's import graph differ from the dev one). +type Registrar interface { + RegisterHandler(pattern string, handler http.Handler) +} + +// Deps is everything the control surface is allowed to touch. It is +// deliberately small: a database handle and a way to reach the Wails +// runtime context for event emission. +type Deps struct { + Logger *slog.Logger + DB *database.DB + // Context returns the live Wails application context. It is a + // function rather than a value because the context only exists + // after OnStartup, which is later than registration. + Context func() context.Context +} diff --git a/cmd/gentestdata/audio.go b/cmd/gentestdata/audio.go new file mode 100644 index 0000000..595950f --- /dev/null +++ b/cmd/gentestdata/audio.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "image" + "image/color" + "image/jpeg" + "math" + "os" + "os/exec" + "path/filepath" + "time" + + "yellowjacket/backend/tagwriter" +) + +// Synthesis parameters. Mono 22.05 kHz keeps the whole fixture +// library in the single-digit megabytes while staying a format every +// decoder in the app handles. +const ( + sampleRate = 22050 + amplitude = 0.3 + fadeSeconds = 0.02 + jpegQuality = 80 + coverSizePx = 64 + ffmpegTimeout = 2 * time.Minute +) + +var errFFmpegMissing = errors.New( + "ffmpeg not found in PATH; install it to generate fixtures", +) + +// synthesizeWAV writes a mono 16-bit WAV holding a sine wave at freqHz +// for the given duration, with a short fade at each end so lossy +// encoders do not introduce a click that shifts the reported length. +// +// The waveform is a pure function of (duration, freqHz), which is what +// makes a fixture reproducible: the same spec always yields the same +// PCM, and a decoded sample identifies which track is playing. +func synthesizeWAV(path string, dur time.Duration, freqHz float64) error { + total := int(float64(sampleRate) * dur.Seconds()) + fade := int(sampleRate * fadeSeconds) + + pcm := make([]byte, total*2) + + for i := range total { + t := float64(i) / sampleRate + v := math.Sin(2*math.Pi*freqHz*t) * amplitude + + switch { + case i < fade: + v *= float64(i) / float64(fade) + case i >= total-fade: + v *= float64(total-i) / float64(fade) + } + + binary.LittleEndian.PutUint16( + pcm[i*2:], uint16(int16(v*math.MaxInt16)), + ) + } + + return writeWAVContainer(path, pcm) +} + +// writeWAVContainer wraps raw PCM in a canonical 44-byte RIFF header. +func writeWAVContainer(path string, pcm []byte) error { + const ( + headerSize = 44 + fmtChunkSize = 16 + pcmFormat = 1 + channels = 1 + bitsPerSample = 16 + ) + + byteRate := sampleRate * channels * bitsPerSample / 8 + blockAlign := channels * bitsPerSample / 8 + + buf := make([]byte, 0, headerSize+len(pcm)) + buf = append(buf, "RIFF"...) + buf = binary.LittleEndian.AppendUint32(buf, uint32(36+len(pcm))) + buf = append(buf, "WAVEfmt "...) + buf = binary.LittleEndian.AppendUint32(buf, fmtChunkSize) + buf = binary.LittleEndian.AppendUint16(buf, pcmFormat) + buf = binary.LittleEndian.AppendUint16(buf, channels) + buf = binary.LittleEndian.AppendUint32(buf, sampleRate) + buf = binary.LittleEndian.AppendUint32(buf, uint32(byteRate)) + buf = binary.LittleEndian.AppendUint16(buf, uint16(blockAlign)) + buf = binary.LittleEndian.AppendUint16(buf, bitsPerSample) + buf = append(buf, "data"...) + buf = binary.LittleEndian.AppendUint32(buf, uint32(len(pcm))) + buf = append(buf, pcm...) + + if err := os.WriteFile(path, buf, filePerm); err != nil { + return fmt.Errorf("write wav %s: %w", path, err) + } + + return nil +} + +// encodeArgs returns the ffmpeg codec arguments for a target format. +// +// Metadata is stripped (-map_metadata -1): every tag this library +// carries is written afterwards by backend/tagwriter, so the fixtures +// and the app's reader cannot drift apart. +func encodeArgs(format tagwriter.AudioFormat) ([]string, error) { + switch format { + case tagwriter.FormatMP3: + return []string{"-c:a", "libmp3lame", "-q:a", "5"}, nil + case tagwriter.FormatFLAC: + return []string{"-c:a", "flac", "-compression_level", "5"}, nil + case tagwriter.FormatOGG: + return []string{"-c:a", "libvorbis", "-q:a", "2"}, nil + case tagwriter.FormatWAV: + return nil, nil + default: + return nil, fmt.Errorf("%w: %s", errUnknownFormat, format) + } +} + +// transcode converts the synthesized WAV at src into dst's format. +func transcode(src, dst string, format tagwriter.AudioFormat) error { + args, err := encodeArgs(format) + if err != nil { + return err + } + + full := append([]string{ + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-i", src, "-map_metadata", "-1", + }, args...) + full = append(full, dst) + + ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "ffmpeg", full...).CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg %s: %w: %s", dst, err, out) + } + + return nil +} + +// requireFFmpeg fails early with an actionable message rather than +// letting the first transcode blow up halfway through generation. +func requireFFmpeg() error { + if _, err := exec.LookPath("ffmpeg"); err != nil { + return errFFmpegMissing + } + + return nil +} + +// coverJPEG renders a small, deterministic cover image for a key. +// +// Identical keys produce byte-identical JPEGs, which is exactly what +// the library's cover-art deduplication is supposed to collapse into a +// single stored blob. +func coverJPEG(key string) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, coverSizePx, coverSizePx)) + + // A per-key hue derived from the key's bytes, plus a diagonal + // band, so covers are distinguishable by eye in a screenshot. + var seed uint32 + for _, b := range []byte(key) { + seed = seed*31 + uint32(b) + } + + base := color.RGBA{ + R: uint8(seed >> 16), + G: uint8(seed >> 8), + B: uint8(seed), + A: 255, + } + + for y := range coverSizePx { + for x := range coverSizePx { + c := base + if (x+y)%16 < 8 { + c.R /= 2 + c.G /= 2 + c.B /= 2 + } + + img.Set(x, y, c) + } + } + + var buf bytes.Buffer + + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil { + return nil, fmt.Errorf("encode cover %q: %w", key, err) + } + + return buf.Bytes(), nil +} + +// stampMTime pins a fixture's modification time. The library scanner +// keys incremental rescans off audio_files.modified_at, so a fixed +// mtime makes "has this changed since the last scan" reproducible. +func stampMTime(path string) error { + if err := os.Chtimes(path, fixedMTime, fixedMTime); err != nil { + return fmt.Errorf("chtimes %s: %w", path, err) + } + + return nil +} + +// ensureDir creates a fixture's parent directory. +func ensureDir(path string) error { + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + + return nil +} diff --git a/cmd/gentestdata/main.go b/cmd/gentestdata/main.go new file mode 100644 index 0000000..c06928a --- /dev/null +++ b/cmd/gentestdata/main.go @@ -0,0 +1,248 @@ +// Command gentestdata generates the deterministic fixture library used +// by tests and by seeded development sandboxes. +// +// The fixtures are audio the app can actually decode, tagged by +// backend/tagwriter — the same writers the application uses — so the +// fixtures and the reader under test cannot drift apart. Everything is +// derived from the spec in spec.go, so two machines running +// `make testdata` get libraries that agree on every logical property +// (paths, durations, tags, cover identity). Encoded bytes may differ +// between ffmpeg builds; the manifest hash covers the spec, not bytes. +// +// Usage: +// +// go run ./cmd/gentestdata # generate if out of date +// go run ./cmd/gentestdata -force # regenerate unconditionally +package main + +import ( + "flag" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "yellowjacket/backend/tagwriter" +) + +// Fixed file modes and mtime. The scanner keys incremental rescan off +// modified_at, so pinning mtime makes "changed since last scan" +// reproducible rather than a function of when generation ran. +const ( + filePerm = 0o644 + dirPerm = 0o755 +) + +//nolint:gochecknoglobals // a package-level constant time value. +var fixedMTime = time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + +func main() { + var ( + outDir string + brokenDir string + manifestOut string + force bool + ) + + flag.StringVar( + &outDir, "out", "test_data/music_library_test", + "library root to generate", + ) + flag.StringVar( + &brokenDir, "broken", "test_data/music_library_broken", + "root for deliberately malformed files", + ) + flag.StringVar( + &manifestOut, "manifest", "test_data/music_library_test.manifest.json", + "manifest path (kept outside the library root)", + ) + flag.BoolVar( + &force, "force", false, + "regenerate even when the manifest is already up to date", + ) + flag.Parse() + + if err := run(outDir, brokenDir, manifestOut, force); err != nil { + fmt.Fprintln(os.Stderr, "gentestdata:", err) + os.Exit(1) + } +} + +func run(outDir, brokenDir, manifestOut string, force bool) error { + want, err := buildManifest(outDir, brokenDir) + if err != nil { + return err + } + + if !force && upToDate(manifestOut, want, outDir, brokenDir) { + fmt.Printf( + "up to date (%d tracks, hash %s)\n", + len(want.Tracks), want.Hash[:12], + ) + + return nil + } + + if err := requireFFmpeg(); err != nil { + return err + } + + for _, dir := range []string{outDir, brokenDir} { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("clean %s: %w", dir, err) + } + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelError, + })) + + for _, f := range libraryFixtures { + if err := generateFixture(logger, outDir, f); err != nil { + return err + } + } + + if err := writeAuxFiles(outDir, outDir, libraryExtras); err != nil { + return err + } + + if err := writeAuxFiles(outDir, brokenDir, brokenFiles); err != nil { + return err + } + + if err := writeManifest(manifestOut, want); err != nil { + return err + } + + fmt.Printf( + "generated %d tracks + %d broken files in %s (hash %s)\n", + len(want.Tracks), len(want.Broken), outDir, want.Hash[:12], + ) + + return nil +} + +// upToDate reports whether the recorded manifest matches the spec and +// both roots still exist. Cheap enough to run on every make invocation. +func upToDate(manifestOut string, want *manifest, roots ...string) bool { + if readManifestHash(manifestOut) != want.Hash { + return false + } + + for _, root := range roots { + if _, err := os.Stat(root); err != nil { + return false + } + } + + return true +} + +// generateFixture synthesizes, encodes and tags a single fixture. +// +// Tags are written after encoding, by backend/tagwriter, rather than +// handed to ffmpeg: the fixtures must be tagged by the code the app +// reads back with, or a tag bug becomes invisible to every test. +func generateFixture( + logger *slog.Logger, + root string, + f fixture, +) error { + dst := filepath.Join(root, filepath.FromSlash(f.Rel)) + + if err := ensureDir(dst); err != nil { + return err + } + + wav := dst + if f.Format != tagwriter.FormatWAV { + wav = dst + ".src.wav" + } + + if err := synthesizeWAV(wav, f.Duration, f.FreqHz); err != nil { + return err + } + + if f.Format != tagwriter.FormatWAV { + if err := transcode(wav, dst, f.Format); err != nil { + return err + } + + if err := os.Remove(wav); err != nil { + return fmt.Errorf("remove scratch wav: %w", err) + } + } + + changes := f.Tags.changes() + + if f.Cover != "" { + img, err := coverJPEG(f.Cover) + if err != nil { + return err + } + + changes[tagwriter.FieldCoverArt] = img + } + + if len(changes) > 0 { + if err := tagwriter.WriteFileTags(logger, dst, changes); err != nil { + return fmt.Errorf("tag %s: %w", f.Rel, err) + } + } + + return stampMTime(dst) +} + +// writeAuxFiles writes non-audio and malformed files into dstRoot. +// +// Truncated fixtures are cut from an already-encoded file under +// libraryRoot, so this must run after the audio has been generated. +// The malformed set lands outside the library root on purpose: the +// clean library's track count has to stay deterministic, so a test +// that wants the scanner's error paths registers the broken root as a +// second library deliberately. +func writeAuxFiles(libraryRoot, dstRoot string, files []auxFile) error { + for _, b := range files { + dst := filepath.Join(dstRoot, filepath.FromSlash(b.Rel)) + + if err := ensureDir(dst); err != nil { + return err + } + + var content []byte + + switch { + case b.Source != "": + src := filepath.Join(libraryRoot, filepath.FromSlash(b.Source)) + + raw, err := os.ReadFile(src) //nolint:gosec // generated path. + if err != nil { + return fmt.Errorf("read source %s: %w", src, err) + } + + content = raw[:min(b.Bytes, len(raw))] + case strings.HasSuffix(b.Rel, ".jpg"): + img, err := coverJPEG(b.Rel) + if err != nil { + return err + } + + content = img + default: + content = []byte(b.Literal) + } + + if err := os.WriteFile(dst, content, filePerm); err != nil { + return fmt.Errorf("write %s: %w", dst, err) + } + + if err := stampMTime(dst); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/gentestdata/manifest.go b/cmd/gentestdata/manifest.go new file mode 100644 index 0000000..9853227 --- /dev/null +++ b/cmd/gentestdata/manifest.go @@ -0,0 +1,153 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// manifestVersion is bumped when the manifest's shape changes in a way +// that older readers cannot handle. +const manifestVersion = 1 + +// manifestTrack records what a fixture is supposed to be, so a test can +// assert against the spec rather than against whatever happens to be on +// disk. +type manifestTrack struct { + Path string `json:"path"` + Case string `json:"case"` + Format string `json:"format"` + DurationMS int64 `json:"durationMs"` + FreqHz float64 `json:"freqHz"` + Cover string `json:"cover,omitempty"` + CoverSHA string `json:"coverSha,omitempty"` + Tags map[string]any `json:"tags"` +} + +// manifest describes a generated fixture library. +// +// Hash covers the *logical* spec — paths, formats, durations, tags, +// cover identity — and deliberately not the encoded bytes: ffmpeg +// stamps its own encoder strings, so byte hashes differ between ffmpeg +// builds while the fixtures they describe are identical. +type manifest struct { + Version int `json:"version"` + Generator string `json:"generator"` + Hash string `json:"hash"` + LibraryRoot string `json:"libraryRoot"` + BrokenRoot string `json:"brokenRoot"` + Cases map[string][]string `json:"cases"` + Tracks []manifestTrack `json:"tracks"` + Extras []string `json:"extras"` + Broken []string `json:"broken"` +} + +// buildManifest derives the manifest from the spec alone. It runs +// before any file is written, which is what lets generation be skipped +// when the on-disk manifest already matches. +func buildManifest(libraryRoot, brokenRoot string) (*manifest, error) { + m := &manifest{ + Version: manifestVersion, + Generator: "gentestdata", + LibraryRoot: filepath.ToSlash(libraryRoot), + BrokenRoot: filepath.ToSlash(brokenRoot), + Cases: map[string][]string{}, + Tracks: make([]manifestTrack, 0, len(libraryFixtures)), + } + + for _, f := range libraryFixtures { + track := manifestTrack{ + Path: f.Rel, + Case: f.Case, + Format: string(f.Format), + DurationMS: f.Duration.Milliseconds(), + FreqHz: f.FreqHz, + Cover: f.Cover, + Tags: f.Tags.changes(), + } + + if f.Cover != "" { + img, err := coverJPEG(f.Cover) + if err != nil { + return nil, err + } + + sum := sha256.Sum256(img) + track.CoverSHA = hex.EncodeToString(sum[:]) + } + + m.Tracks = append(m.Tracks, track) + m.Cases[f.Case] = append(m.Cases[f.Case], f.Rel) + } + + for _, e := range libraryExtras { + m.Extras = append(m.Extras, e.Rel) + } + + for _, b := range brokenFiles { + m.Broken = append(m.Broken, b.Rel) + m.Cases[caseBroken] = append(m.Cases[caseBroken], b.Rel) + } + + hash, err := hashManifest(m) + if err != nil { + return nil, err + } + + m.Hash = hash + + return m, nil +} + +// hashManifest hashes everything except the hash field itself. +func hashManifest(m *manifest) (string, error) { + clone := *m + clone.Hash = "" + + raw, err := json.Marshal(clone) + if err != nil { + return "", fmt.Errorf("marshal manifest for hashing: %w", err) + } + + sum := sha256.Sum256(raw) + + return hex.EncodeToString(sum[:]), nil +} + +// writeManifest persists the manifest next to (not inside) the library +// root, so the scanner never sees it as a stray file. +func writeManifest(path string, m *manifest) error { + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshal manifest: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + + if err := os.WriteFile(path, append(raw, '\n'), filePerm); err != nil { + return fmt.Errorf("write manifest %s: %w", path, err) + } + + return nil +} + +// readManifestHash returns the hash recorded in an existing manifest, +// or "" when there is no readable manifest at path. +func readManifestHash(path string) string { + raw, err := os.ReadFile(path) + if err != nil { + return "" + } + + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + + return m.Hash +} diff --git a/cmd/gentestdata/spec.go b/cmd/gentestdata/spec.go new file mode 100644 index 0000000..09c76c0 --- /dev/null +++ b/cmd/gentestdata/spec.go @@ -0,0 +1,497 @@ +package main + +import ( + "errors" + "time" + + "yellowjacket/backend/tagwriter" +) + +// Case names group fixtures by the application behaviour they exist to +// exercise. Tests select fixtures by case rather than by path, so a +// path can be renamed without breaking them. +const ( + caseCoverDedup = "cover-dedup" + caseMultiDisc = "multi-disc" + caseVariousArtist = "various-artists" + caseFLACAlbum = "flac-album" + caseOGGAlbum = "ogg-album" + caseWAVTracks = "wav-tracks" + casePartialTags = "partial-tags" + caseUnicode = "unicode" + caseDuplicates = "duplicates" + caseEdgeLengths = "edge-lengths" + caseBroken = "broken" +) + +var errUnknownFormat = errors.New("gentestdata: unknown audio format") + +// tags mirrors the subset of tagwriter fields a fixture can set. A +// struct rather than a bare map so the spec table stays readable and +// the manifest can record exactly what was written. +type tags struct { + Title string + Artist string + Album string + AlbumArtist string + Genre string + Composer string + Year int + TrackNumber int + DiscNumber int +} + +// changes converts a fixture's tags into a tagwriter diff map, +// omitting zero values so "no tag at all" is expressible. +func (t tags) changes() tagwriter.TagChanges { + c := tagwriter.TagChanges{} + + set := func(field, value string) { + if value != "" { + c[field] = value + } + } + + set(tagwriter.FieldTitle, t.Title) + set(tagwriter.FieldArtist, t.Artist) + set(tagwriter.FieldAlbum, t.Album) + set(tagwriter.FieldAlbumArtist, t.AlbumArtist) + set(tagwriter.FieldGenre, t.Genre) + set(tagwriter.FieldComposer, t.Composer) + + if t.Year != 0 { + c[tagwriter.FieldYear] = t.Year + } + + if t.TrackNumber != 0 { + c[tagwriter.FieldTrackNumber] = t.TrackNumber + } + + if t.DiscNumber != 0 { + c[tagwriter.FieldDiscNumber] = t.DiscNumber + } + + return c +} + +// fixture is one generated audio file. +type fixture struct { + // Rel is the path relative to the library root, with '/' + // separators regardless of platform. + Rel string + // Case is the behaviour group this fixture belongs to. + Case string + // Format decides both the container and the tag writer used. + Format tagwriter.AudioFormat + // Duration is the nominal length of the synthesized tone. + Duration time.Duration + // FreqHz identifies the track audibly and in a decoded sample. + FreqHz float64 + // Cover is a cover-art key; fixtures sharing a key get + // byte-identical images, which is what dedup must collapse. + Cover string + // Tags is what gets written after encoding. + Tags tags +} + +// Durations kept short deliberately; one long track exists so the +// progress bar and seeking have something to work against. +const ( + durShort = 2 * time.Second + durNormal = 4 * time.Second + durMedium = 6 * time.Second + durLong = 90 * time.Second +) + +// longTitle is long enough to force truncation in every list view. +const longTitle = "An Exhaustively Overlong Track Title That Exists " + + "Solely To Find Out Whether The Track List Truncates Or Overflows" + +const longArtist = "The Orchestra Of Very Considerable And " + + "Deliberately Unreasonable Length" + +// duplicateTags is shared by the deliberate duplicate pair so the +// duplicate-tracks dialog has an unambiguous match to find. +var duplicateTags = tags{ + Title: "Tideline", + Artist: "Aurora Fields", + Album: "Glass Harbour", + AlbumArtist: "Aurora Fields", + Genre: "Dream Pop", + Year: 2019, + TrackNumber: 2, +} + +// libraryFixtures is the full contents of the clean fixture library. +// +// Everything here is scannable audio: a seeded sandbox built from this +// root must produce a stable track count, so deliberately broken files +// live in a separate root (see brokenFiles). +// +//nolint:gochecknoglobals // the fixture spec is the point of this cmd. +var libraryFixtures = []fixture{ + // 1. A plain album whose four tracks carry the same embedded + // cover: the dedup path should store one blob, not four. + { + Rel: "Aurora Fields/Glass Harbour/01 Salt Air.mp3", + Case: caseCoverDedup, Format: tagwriter.FormatMP3, + Duration: durNormal, FreqHz: 220, Cover: "glass-harbour", + Tags: tags{ + Title: "Salt Air", Artist: "Aurora Fields", + Album: "Glass Harbour", AlbumArtist: "Aurora Fields", + Genre: "Dream Pop", Year: 2019, TrackNumber: 1, + }, + }, + { + Rel: "Aurora Fields/Glass Harbour/02 Tideline.mp3", + Case: caseCoverDedup, Format: tagwriter.FormatMP3, + Duration: durMedium, FreqHz: 247, Cover: "glass-harbour", + Tags: duplicateTags, + }, + { + Rel: "Aurora Fields/Glass Harbour/03 Harbour Lights.mp3", + Case: caseCoverDedup, Format: tagwriter.FormatMP3, + Duration: durNormal, FreqHz: 262, Cover: "glass-harbour", + Tags: tags{ + Title: "Harbour Lights", Artist: "Aurora Fields", + Album: "Glass Harbour", AlbumArtist: "Aurora Fields", + Genre: "Dream Pop", Year: 2019, TrackNumber: 3, + }, + }, + { + Rel: "Aurora Fields/Glass Harbour/04 Low Water.mp3", + Case: caseCoverDedup, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 294, Cover: "glass-harbour", + Tags: tags{ + Title: "Low Water", Artist: "Aurora Fields", + Album: "Glass Harbour", AlbumArtist: "Aurora Fields", + Genre: "Dream Pop", Year: 2019, TrackNumber: 4, + }, + }, + + // 2. Multi-disc, with the disc split reflected both in the + // directory layout and in the disc number tag. The + // semicolon-separated genre also covers metadata.ParseGenres. + { + Rel: "Aurora Fields/Long Way Round/Disc 1/01 Departure.mp3", + Case: caseMultiDisc, Format: tagwriter.FormatMP3, + Duration: durNormal, FreqHz: 330, Cover: "long-way-round", + Tags: tags{ + Title: "Departure", Artist: "Aurora Fields", + Album: "Long Way Round", AlbumArtist: "Aurora Fields", + Genre: "Dream Pop; Ambient", Year: 2021, + TrackNumber: 1, DiscNumber: 1, + }, + }, + { + Rel: "Aurora Fields/Long Way Round/Disc 1/02 Waystation.mp3", + Case: caseMultiDisc, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 349, Cover: "long-way-round", + Tags: tags{ + Title: "Waystation", Artist: "Aurora Fields", + Album: "Long Way Round", AlbumArtist: "Aurora Fields", + Genre: "Dream Pop; Ambient", Year: 2021, + TrackNumber: 2, DiscNumber: 1, + }, + }, + { + Rel: "Aurora Fields/Long Way Round/Disc 2/01 Return.mp3", + Case: caseMultiDisc, Format: tagwriter.FormatMP3, + Duration: durNormal, FreqHz: 392, Cover: "long-way-round", + Tags: tags{ + Title: "Return", Artist: "Aurora Fields", + Album: "Long Way Round", AlbumArtist: "Aurora Fields", + Genre: "Ambient", Year: 2021, + TrackNumber: 1, DiscNumber: 2, + }, + }, + { + Rel: "Aurora Fields/Long Way Round/Disc 2/02 Homing.mp3", + Case: caseMultiDisc, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 440, Cover: "long-way-round", + Tags: tags{ + Title: "Homing", Artist: "Aurora Fields", + Album: "Long Way Round", AlbumArtist: "Aurora Fields", + Genre: "Ambient", Year: 2021, + TrackNumber: 2, DiscNumber: 2, + }, + }, + + // 3. Compilation: per-track artists under a Various Artists + // album artist, which groups differently from everything else. + { + Rel: "Various Artists/Night Shift Vol. 1/01 Blue Hour.mp3", + Case: caseVariousArtist, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 466, Cover: "night-shift", + Tags: tags{ + Title: "Blue Hour", Artist: "Kilowatt", + Album: "Night Shift Vol. 1", AlbumArtist: "Various Artists", + Genre: "Electronic", Year: 2003, TrackNumber: 1, + }, + }, + { + Rel: "Various Artists/Night Shift Vol. 1/02 Concrete Sun.mp3", + Case: caseVariousArtist, Format: tagwriter.FormatMP3, + Duration: durNormal, FreqHz: 494, Cover: "night-shift", + Tags: tags{ + Title: "Concrete Sun", Artist: "Marisol Vega", + Album: "Night Shift Vol. 1", AlbumArtist: "Various Artists", + Genre: "Electronic", Year: 2003, TrackNumber: 2, + }, + }, + { + Rel: "Various Artists/Night Shift Vol. 1/03 Dry Season.mp3", + Case: caseVariousArtist, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 523, Cover: "night-shift", + Tags: tags{ + Title: "Dry Season", Artist: "The Hollow Coast", + Album: "Night Shift Vol. 1", AlbumArtist: "Various Artists", + Genre: "Jazz", Year: 2003, TrackNumber: 3, + }, + }, + + // 4. FLAC, whose cover art rides in a METADATA_BLOCK_PICTURE and + // whose reader/writer share no code with the ID3 path. + { + Rel: "Pale Circuit/Static Bloom/01 Static Bloom.flac", + Case: caseFLACAlbum, Format: tagwriter.FormatFLAC, + Duration: durShort, FreqHz: 262, Cover: "static-bloom", + Tags: tags{ + Title: "Static Bloom", Artist: "Pale Circuit", + Album: "Static Bloom", AlbumArtist: "Pale Circuit", + Genre: "Electronic", Year: 1998, TrackNumber: 1, + Composer: "P. Circuit", + }, + }, + { + Rel: "Pale Circuit/Static Bloom/02 Cold Cathode.flac", + Case: caseFLACAlbum, Format: tagwriter.FormatFLAC, + Duration: durNormal, FreqHz: 277, Cover: "static-bloom", + Tags: tags{ + Title: "Cold Cathode", Artist: "Pale Circuit", + Album: "Static Bloom", AlbumArtist: "Pale Circuit", + Genre: "Electronic", Year: 1998, TrackNumber: 2, + }, + }, + { + Rel: "Pale Circuit/Static Bloom/03 Dust Loop.flac", + Case: caseFLACAlbum, Format: tagwriter.FormatFLAC, + Duration: durShort, FreqHz: 311, Cover: "static-bloom", + Tags: tags{ + Title: "Dust Loop", Artist: "Pale Circuit", + Album: "Static Bloom", AlbumArtist: "Pale Circuit", + Genre: "Electronic", Year: 1998, TrackNumber: 3, + }, + }, + + // 5. Ogg Vorbis, whose writer rebuilds the page structure by hand + // and is the most fragile of the four. + { + Rel: "Pale Circuit/Ribbon Road/01 Ribbon Road.ogg", + Case: caseOGGAlbum, Format: tagwriter.FormatOGG, + Duration: durShort, FreqHz: 349, Cover: "ribbon-road", + Tags: tags{ + Title: "Ribbon Road", Artist: "Pale Circuit", + Album: "Ribbon Road", AlbumArtist: "Pale Circuit", + Genre: "Ambient", Year: 2015, TrackNumber: 1, + }, + }, + { + Rel: "Pale Circuit/Ribbon Road/02 Verge.ogg", + Case: caseOGGAlbum, Format: tagwriter.FormatOGG, + Duration: durNormal, FreqHz: 370, Cover: "ribbon-road", + Tags: tags{ + Title: "Verge", Artist: "Pale Circuit", + Album: "Ribbon Road", AlbumArtist: "Pale Circuit", + Genre: "Ambient", Year: 2015, TrackNumber: 2, + }, + }, + + // 6. WAV, where tags live in a RIFF ID3 chunk. One with cover + // art, one without, since the chunk layouts differ. + { + Rel: "Field Recordings/Test Tones/01 Tone A.wav", + Case: caseWAVTracks, Format: tagwriter.FormatWAV, + Duration: durShort, FreqHz: 400, Cover: "test-tones", + Tags: tags{ + Title: "Tone A", Artist: "Field Recordings", + Album: "Test Tones", AlbumArtist: "Field Recordings", + Genre: "Field Recording", Year: 2024, TrackNumber: 1, + }, + }, + { + Rel: "Field Recordings/Test Tones/02 Tone B.wav", + Case: caseWAVTracks, Format: tagwriter.FormatWAV, + Duration: durShort, FreqHz: 800, + Tags: tags{ + Title: "Tone B", Artist: "Field Recordings", + Album: "Test Tones", AlbumArtist: "Field Recordings", + Genre: "Field Recording", Year: 2024, TrackNumber: 2, + }, + }, + + // 7. Degrees of missing metadata, which is what the "Unknown + // Artist" fallbacks and the autotag candidate list are for. + { + Rel: "unsorted/no-tags-at-all.mp3", + Case: casePartialTags, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 200, + }, + { + Rel: "unsorted/title-only.mp3", + Case: casePartialTags, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 210, + Tags: tags{Title: "Title Only"}, + }, + { + Rel: "unsorted/no-track-number.mp3", + Case: casePartialTags, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 230, + Tags: tags{ + Title: "No Track Number", Artist: "Loose Ends", + Album: "Odds And Sods", + }, + }, + + // 8. Scripts the layout engine handles differently, plus + // filenames with characters that break naive URL building. + { + Rel: "Unicode Tests/多言語アルバム/01 さくら.mp3", + Case: caseUnicode, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 261, Cover: "unicode", + Tags: tags{ + Title: "さくら", Artist: "サンプル・アーティスト", + Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト", + Genre: "J-Pop", Year: 2020, TrackNumber: 1, + }, + }, + { + Rel: "Unicode Tests/多言語アルバム/02 Привет мир.mp3", + Case: caseUnicode, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 293, Cover: "unicode", + Tags: tags{ + Title: "Привет мир", Artist: "Тестовый исполнитель", + Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト", + Genre: "J-Pop", Year: 2020, TrackNumber: 2, + }, + }, + { + Rel: "Unicode Tests/多言語アルバム/03 مرحبا بالعالم.mp3", + Case: caseUnicode, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 329, Cover: "unicode", + Tags: tags{ + Title: "مرحبا بالعالم", Artist: "فنان تجريبي", + Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト", + Genre: "J-Pop", Year: 2020, TrackNumber: 3, + }, + }, + { + // Precomposed é in the filename, decomposed e+U+0301 in the + // title: a genuine source of "the same track twice" bugs. + Rel: "Unicode Tests/多言語アルバム/04 Café ☕ Über #1's.mp3", + Case: caseUnicode, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 415, Cover: "unicode", + Tags: tags{ + Title: "Cafe\u0301 ☕ Über #1's", Artist: "サンプル・アーティスト", + Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト", + Genre: "J-Pop", Year: 2020, TrackNumber: 4, + }, + }, + + // 9. The deliberate duplicate pair: identical tags and length as + // "02 Tideline.mp3" above, in another directory and another + // format, for the duplicate-tracks dialog to match on. + { + Rel: "unsorted/dupes/Tideline (copy).mp3", + Case: caseDuplicates, Format: tagwriter.FormatMP3, + Duration: durMedium, FreqHz: 247, Cover: "glass-harbour", + Tags: duplicateTags, + }, + { + Rel: "unsorted/dupes/Tideline.flac", + Case: caseDuplicates, Format: tagwriter.FormatFLAC, + Duration: durMedium, FreqHz: 247, Cover: "glass-harbour", + Tags: duplicateTags, + }, + + // 10. Extremes of text length and track length, plus a track with + // no year at all for smart-playlist range rules to exclude. + { + Rel: "Edge Cases/Extremes/01 Long Title.mp3", + Case: caseEdgeLengths, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 180, + Tags: tags{ + Title: longTitle, Artist: longArtist, + Album: "Extremes", AlbumArtist: longArtist, + Genre: "Jazz", Year: 1975, TrackNumber: 1, + }, + }, + { + Rel: "Edge Cases/Extremes/02 Brief.mp3", + Case: caseEdgeLengths, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 190, + Tags: tags{ + Title: "Brief", Artist: longArtist, + Album: "Extremes", AlbumArtist: longArtist, + Genre: "Jazz", Year: 1975, TrackNumber: 2, + }, + }, + { + Rel: "Edge Cases/Extremes/03 Long Player.mp3", + Case: caseEdgeLengths, Format: tagwriter.FormatMP3, + Duration: durLong, FreqHz: 165, + Tags: tags{ + Title: "Long Player", Artist: longArtist, + Album: "Extremes", AlbumArtist: longArtist, + Genre: "Jazz", Year: 1975, TrackNumber: 3, + }, + }, + { + Rel: "Edge Cases/Extremes/04 Undated.mp3", + Case: caseEdgeLengths, Format: tagwriter.FormatMP3, + Duration: durShort, FreqHz: 175, + Tags: tags{ + Title: "Undated", Artist: longArtist, + Album: "Extremes", AlbumArtist: longArtist, + Genre: "Jazz", TrackNumber: 4, + }, + }, +} + +// auxFile is a non-audio or malformed file: either debris that +// legitimately sits inside a music library, or a deliberately broken +// file used to exercise scanner error handling. +type auxFile struct { + Rel string + // Source, when set, names a library fixture whose encoded bytes + // get truncated to Bytes; otherwise Literal is written verbatim. + Source string + Bytes int + Literal string +} + +// brokenFiles live in their own root so the clean library's track count +// stays deterministic. A test that wants the error paths adds this +// root as a second library on purpose. +// +//nolint:gochecknoglobals // the fixture spec is the point of this cmd. +var brokenFiles = []auxFile{ + {Rel: "notes.txt", Literal: "not audio\n"}, + {Rel: "empty.flac"}, + { + Rel: "truncated.mp3", + Source: "Aurora Fields/Glass Harbour/01 Salt Air.mp3", + Bytes: 512, + }, +} + +// libraryExtras are the non-audio files a real library is full of. +// They belong inside the clean root because ignoring them is itself +// behaviour worth testing — folder art in particular, which is a +// separate cover source from embedded art. +// +//nolint:gochecknoglobals // the fixture spec is the point of this cmd. +var libraryExtras = []auxFile{ + {Rel: "Pale Circuit/Ribbon Road/cover.jpg"}, + {Rel: "Pale Circuit/Ribbon Road/ripping notes.txt", Literal: "EAC log\n"}, +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..4fff642 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "yellowjacket-e2e", + "private": true, + "type": "module", + "version": "0.0.0", + "description": "End-to-end specs driving the real app on the Wails dev server.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "@types/node": "^26.2.0", + "typescript": "^7.0.2" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..4b8a114 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * These specs drive the *real* application: the Wails dev server on + * :34115 serves the real frontend with real bindings on `window.go`, + * bridged to the same Go backend a desktop window would use. Nothing + * here is mocked. + * + * The app is not started by Playwright. `make dev-headless` daemonises + * (it writes .dev/app.pid and returns), which is the opposite of what + * `webServer` expects to supervise, and starting it per-run would also + * mean rebuilding the frontend per-run. globalSetup checks it is up + * and says exactly what to run if it is not. + * + * WebKit is CI-only: Playwright's Linux WebKit build links Ubuntu 24.04 + * libraries that Arch does not provide, so it cannot start on a local + * dev machine. It is the closest available approximation of the + * WebKit2GTK renderer we actually ship, so CI runs it and local runs + * do not. + */ +const PORT = Number(process.env.YJ_E2E_PORT ?? 34115); + +export default defineConfig({ + testDir: './specs', + globalSetup: './support/global-setup.ts', + // The backend is a single shared process with one SQLite database, so + // parallel workers would fight over the same state. + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], + timeout: 30_000, + expect: { timeout: 10_000 }, + use: { + baseURL: `http://localhost:${PORT}`, + testIdAttribute: 'data-testid', + viewport: { width: 1440, height: 900 }, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: process.env.CI ? 'retain-on-failure' : 'off', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ...(process.env.YJ_E2E_WEBKIT + ? [{ name: 'webkit', use: { ...devices['Desktop Safari'] } }] + : []), + ], +}); diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 0000000..5f9a330 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,278 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.56.0 + version: 1.62.1 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + +packages: + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + +snapshots: + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + fsevents@2.3.2: + optional: true + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} diff --git a/e2e/specs/harness.spec.ts b/e2e/specs/harness.spec.ts new file mode 100644 index 0000000..438df43 --- /dev/null +++ b/e2e/specs/harness.spec.ts @@ -0,0 +1,89 @@ +import { + test, + expect, + callBinding, + resetEvents, + waitForEvent, +} from '../support/fixtures.js'; + +/** + * The harness testing itself. + * + * If these fail, every other spec's failure is uninterpretable — a + * missing event could mean a broken feature or a broken recorder, and + * telling those apart afterwards is expensive. + */ +test.describe('harness', () => { + test('the app is the real app, not a mock', async ({ app }) => { + // All 11 bound services land on window.go through the dev server. + const services = await app.evaluate(() => Object.keys(window.go)); + + expect(services).toEqual( + expect.arrayContaining(['queue', 'player', 'library', 'explore']), + ); + + const state = await callBinding<{ tracks: unknown[] }>( + app, + 'queue.Queue.GetState', + ); + + expect(state).toHaveProperty('tracks'); + }); + + test('backend events are recorded, in order, with payloads', async ({ + app, + }) => { + await resetEvents(app); + await callBinding(app, 'player.Player.SetVolume', [37]); + + const ev = await waitForEvent(app, 'VolumeChanged'); + + expect(ev.data).toEqual([37]); + expect(ev.dir).toBe('in'); + }); + + test('exactly one recorder is installed', async ({ app }) => { + // Listeners registered by one evaluate survive into the next, so a + // recorder that re-registers counts every event twice. This is the + // regression test for that. + await resetEvents(app); + await callBinding(app, 'player.Player.SetVolume', [41]); + await waitForEvent(app, 'VolumeChanged'); + + const count = await app.evaluate(() => + window.__yjEvents.count('VolumeChanged'), + ); + + expect(count).toBe(1); + }); + + test('a binding called with wrong types fails fast', async ({ app }) => { + // player.UserVolume is an int. Passing a float makes the backend + // log "error parsing arguments" and never fire the callback; without + // a timeout the promise never settles and the spec hangs until the + // suite gives up. + const failure = await app.evaluate(async () => { + try { + await window.__yjEvents.call( + 'player.Player.SetVolume', + [0.42], + 2_000, + ); + + return 'settled'; + } catch (err) { + return (err as Error).message; + } + }); + + expect(failure).toContain('did not settle'); + }); + + test('the control surface is mounted and seeded', async ({ testctl }) => { + const health = await testctl.health(); + + expect(health.ok).toBe(true); + expect(health.libraries.length).toBeGreaterThan(0); + expect(health.counts.tracks).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/library.spec.ts b/e2e/specs/library.spec.ts new file mode 100644 index 0000000..495ceb6 --- /dev/null +++ b/e2e/specs/library.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '../support/fixtures.js'; + +/** + * The library views, against the generated fixture library + * (`make testdata`): 31 tracks chosen to cover the cases the app has + * code for — unicode and RTL titles, missing tags, a deliberately + * absurd artist name for truncation, duplicates. + */ +test.describe('library views', () => { + test('lands in the app, not the first-run wizard', async ({ app }) => { + // A fresh YJ_HOME puts over everything and it + // intercepts every pointer event, so "the click did nothing" is the + // symptom of an unseeded sandbox rather than a broken control. + // + // Asserted by clicking rather than by inspecting the wizard element: + // the element is always in the DOM and merely renders nothing once a + // library exists, so its presence proves nothing. Playwright's own + // actionability check fails a covered click with "intercepts pointer + // events", which is exactly the condition worth catching. + await expect(app.getByTestId('track-row').first()).toBeVisible(); + await app.getByTestId('nav-artists').click({ timeout: 5_000 }); + + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'artists', + ); + }); + + test('renders every fixture track, unicode included', async ({ + app, + testctl, + }) => { + const health = await testctl.health(); + const rows = app.getByTestId('track-row'); + + await expect(rows).toHaveCount(health.counts.tracks); + + // Non-Latin scripts survive the tag reader, the database and the + // renderer. These titles exist in the fixtures for this reason. + await expect(app.getByText('Привет мир')).toBeVisible(); + await expect(app.getByText('さくら')).toBeVisible(); + await expect(app.getByText('مرحبا بالعالم')).toBeVisible(); + }); + + test('the sidebar navigates between primary views', async ({ app }) => { + const main = app.getByTestId('main-content'); + + for (const view of ['artists', 'genres', 'albums', 'playlists', 'tracks']) { + await app.getByTestId(`nav-${view}`).click(); + await expect(main).toHaveAttribute('data-active-view', view); + await expect(app.getByTestId(`nav-${view}`)).toHaveAttribute( + 'aria-current', + 'page', + ); + } + }); + + test('the artists view shows the fixture artists', async ({ app }) => { + await app.getByTestId('nav-artists').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'artists', + ); + + await expect(app.getByText('Aurora Fields').first()).toBeVisible(); + await expect(app.getByText('Pale Circuit').first()).toBeVisible(); + }); +}); diff --git a/e2e/specs/playback.spec.ts b/e2e/specs/playback.spec.ts new file mode 100644 index 0000000..fd03e11 --- /dev/null +++ b/e2e/specs/playback.spec.ts @@ -0,0 +1,114 @@ +import { + test, + expect, + callBinding, + resetEvents, + waitForEvent, + LONG_TRACK, +} from '../support/fixtures.js'; + +/** The one fixture long enough to still be playing on the next line. */ +const longRow = (app: import('@playwright/test').Page) => + app.getByTestId('track-row').filter({ hasText: LONG_TRACK }).first(); + +/** + * Playback and the queue, driven through the UI and asserted on the + * events the backend actually emits. + * + * Audio really is initialised here: under `dbus-run-session` + Xvfb the + * PulseAudio socket in /run/user is untouched, so InitSpeaker succeeds + * and these tracks genuinely play. A CI container without /run/user + * needs a null sink; everything except the audio itself still works + * without one. + */ +test.describe('playback', () => { + test.beforeEach(async ({ app }) => { + await callBinding(app, 'queue.Queue.Clear').catch(() => { + /* older builds may not expose Clear; the specs below do not need it */ + }); + await resetEvents(app); + }); + + test('double-clicking a track plays it', async ({ app }) => { + await longRow(app).dblclick(); + + const changed = await waitForEvent(app, 'TrackChanged'); + + expect(changed.data[0]).toBeTruthy(); + + // The transport flips to Pause, which is the only place the UI + // states "we are playing" in a way a user can see. `exact` is not + // optional: "Add queue to playlist" also matches /play/i. + await expect( + app.getByRole('button', { name: 'Pause', exact: true }), + ).toBeVisible(); + + await expect(app.getByTestId('now-playing-title')).toContainText( + LONG_TRACK, + ); + }); + + test('the elapsed time advances', async ({ app }) => { + await longRow(app).dblclick(); + await waitForEvent(app, 'TrackChanged'); + + // Not a fixed sleep on a fixed value: assert the observable + // outcome, which is that the clock is no longer at zero. + await expect(app.getByTestId('elapsed-time')).not.toHaveText('--:--'); + await expect(app.getByTestId('elapsed-time')).not.toHaveText('00:00', { + timeout: 15_000, + }); + }); + + test('pause and play round-trip through the backend', async ({ app }) => { + await longRow(app).dblclick(); + await waitForEvent(app, 'TrackChanged'); + + await resetEvents(app); + await app.getByRole('button', { name: 'Pause', exact: true }).click(); + await waitForEvent(app, 'PlaybackStateChanged'); + + await expect( + app.getByRole('button', { name: 'Play', exact: true }), + ).toBeVisible(); + }); + + test('volume changes are pushed back from Go', async ({ app }) => { + await resetEvents(app); + await callBinding(app, 'player.Player.SetVolume', [55]); + + const ev = await waitForEvent(app, 'VolumeChanged'); + + expect(ev.data).toEqual([55]); + }); +}); + +test.describe('queue', () => { + test('playing a track populates the queue panel', async ({ app }) => { + await resetEvents(app); + await longRow(app).dblclick(); + await waitForEvent(app, 'QueueChanged'); + + await expect(app.getByTestId('queue-row')).toHaveCount(1); + + const state = await callBinding<{ tracks: unknown[] }>( + app, + 'queue.Queue.GetState', + ); + + expect(state.tracks).toHaveLength(1); + }); + + test('shuffle and repeat toggles report their state', async ({ app }) => { + const shuffle = app.getByRole('button', { name: 'Shuffle' }); + + await resetEvents(app); + await shuffle.click(); + await waitForEvent(app, 'QueueModeChanged'); + + await expect(shuffle).toHaveAttribute('aria-pressed', 'true'); + + await shuffle.click(); + await expect(shuffle).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/e2e/specs/testctl.spec.ts b/e2e/specs/testctl.spec.ts new file mode 100644 index 0000000..82e4787 --- /dev/null +++ b/e2e/specs/testctl.spec.ts @@ -0,0 +1,68 @@ +import { test, expect, resetEvents, waitForEvent } from '../support/fixtures.js'; + +/** + * The dev-only control surface (backend/testctl), which exists for the + * things a browser genuinely cannot do. + */ +test.describe('control surface', () => { + test('database snapshot and restore round-trip', async ({ testctl }) => { + // VACUUM INTO copies the whole file and the restore copies every + // row back; on a database carrying an explore catalog that is tens + // of seconds, not the default 30s budget for a whole test. + test.setTimeout(180_000); + + const before = (await testctl.health()).counts.tracks; + + await testctl.snapshot('e2e-pristine'); + await testctl.sql('DELETE FROM audio_files'); + + expect((await testctl.health()).counts.tracks).toBe(0); + + // Restore copies rows rather than files, because the app holds the + // database open across two connection pools and cannot be made to + // reopen it from here. + await testctl.restore('e2e-pristine'); + + expect((await testctl.health()).counts.tracks).toBe(before); + }); + + test('a forced backend event reaches the browser', async ({ + app, + testctl, + }) => { + // LibraryScanProgress normally only arrives during a real scan. + // Emitting it directly is how a push-driven view gets exercised + // without staging the work that would produce it. + await resetEvents(app); + await testctl.emit('LibraryScanProgress', { + current: 7, + total: 31, + currentFile: 'probe.mp3', + }); + + const ev = await waitForEvent(app, 'LibraryScanProgress'); + + expect(ev.data[0]).toMatchObject({ current: 7, total: 31 }); + }); + + test('sql reads return rows, writes return a count', async ({ testctl }) => { + const read = await testctl.sql( + 'SELECT COUNT(*) AS n FROM audio_files', + ); + + expect(read.rows[0].n).toBeGreaterThan(0); + + const write = await testctl.sql( + 'UPDATE player_state SET volume = volume', + ); + + expect(write).toHaveProperty('rowsAffected'); + }); + + test('bad input is rejected with a reason, not a bare status', async ({ + testctl, + }) => { + await expect(testctl.snapshot('../escape')).rejects.toThrow(/name must/); + await expect(testctl.restore('nope')).rejects.toThrow(/no such snapshot/); + }); +}); diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts new file mode 100644 index 0000000..6bc19f9 --- /dev/null +++ b/e2e/support/fixtures.ts @@ -0,0 +1,172 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { test as base, expect, type Page } from '@playwright/test'; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** The same bridge `playwright-cli` loads, so an exploratory session and + * a committed spec see an identical page. */ +const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js'); + +/** + * The 90-second fixture track (`cmd/gentestdata`, case `edge-lengths`). + * + * Every other fixture is 2–6 seconds, which is shorter than the time a + * spec takes to click something — a "pause it" test against one of + * those races the track finishing and fails on a UI that is correct. + */ +export const LONG_TRACK = 'Long Player'; + +/** Shape of the recorder installed by .playwright/init-events.js. */ +export type YjEvent = { + seq: number; + name: string; + data: unknown[]; + dir: 'in' | 'out'; + t: number; +}; + +/** + * Await a backend event instead of a timeout. + * + * Half of this app is push-driven, and the events that matter + * (scan progress, job updates, playback state) arrive whenever the + * backend gets to them. `waitForEvent` resolves against events already + * buffered as well as future ones, so there is no race between doing + * the thing and starting to listen. + */ +export async function waitForEvent( + page: Page, + name: string, + opts: { timeoutMs?: number; since?: number } = {}, +): Promise { + return page.evaluate( + ([n, o]) => window.__yjEvents.wait(n as string, o as object), + [name, { timeoutMs: 10_000, ...opts }] as const, + ) as Promise; +} + +/** Drop the event buffer. Never re-register a recorder: listeners + * survive across evaluate calls and a second recorder double-counts. */ +export async function resetEvents(page: Page): Promise { + await page.evaluate(() => void window.__yjEvents.reset()); +} + +/** name -> count, for asserting on (or debugging) what actually fired. */ +export async function eventNames( + page: Page, +): Promise> { + return page.evaluate(() => window.__yjEvents.names()); +} + +/** + * Call a bound Go method with a timeout. + * + * Wrong argument types make the backend log "error parsing arguments" + * and never fire the callback, so an unguarded call hangs until the + * whole spec times out with no clue why. This fails in seconds and + * says where to look. + */ +export async function callBinding( + page: Page, + path: string, + args: unknown[] = [], + timeoutMs = 10_000, +): Promise { + return page.evaluate( + ([p, a, t]) => + window.__yjEvents.call(p as string, a as unknown[], t as number), + [path, args, timeoutMs] as const, + ) as Promise; +} + +/** Thin client for the dev-only /__test/ surface (backend/testctl). */ +export class TestCtl { + constructor(private readonly baseURL: string) {} + + private async req(path: string, init?: RequestInit) { + const res = await fetch(`${this.baseURL}${path}`, { + signal: AbortSignal.timeout(120_000), + ...init, + }); + const body = await res.json(); + + if (!res.ok) { + throw new Error(`testctl ${path}: ${body.error ?? res.status}`); + } + + return body; + } + + health() { + return this.req('/__test/health'); + } + + snapshot(name: string) { + return this.req(`/__test/db/snapshot?name=${name}`, { method: 'POST' }); + } + + restore(name: string) { + return this.req(`/__test/db/restore?name=${name}`, { method: 'POST' }); + } + + emit(name: string, ...data: unknown[]) { + return this.req('/__test/emit', { + method: 'POST', + body: JSON.stringify({ name, data }), + }); + } + + sql(sql: string, args: unknown[] = []) { + return this.req('/__test/sql', { + method: 'POST', + body: JSON.stringify({ sql, args }), + }); + } +} + +export const test = base.extend<{ app: Page; testctl: TestCtl }>({ + /** A page with the event bridge installed and the app loaded and + * actually talking to the backend — not merely DOM-ready, which is + * earlier and lies. */ + app: async ({ page, baseURL }, use) => { + await page.addInitScript({ path: INIT_SCRIPT }); + await page.goto(baseURL!); + await page.evaluate(() => window.__yjEvents.ready(20_000)); + await use(page); + }, + + testctl: async ({ baseURL }, use) => { + await use(new TestCtl(baseURL!)); + }, +}); + +export { expect }; + +declare global { + interface Window { + __yjEvents: { + version: number; + seq: number; + log: YjEvent[]; + reset(): number; + all(name?: string): YjEvent[]; + count(name?: string): number; + last(name?: string): YjEvent | null; + since(seq: number): YjEvent[]; + names(): Record; + wait( + name: string, + opts?: { + timeoutMs?: number; + since?: number; + match?: (data: unknown[], entry: YjEvent) => boolean; + }, + ): Promise; + ready(timeoutMs?: number): Promise; + call(path: string, args?: unknown[], timeoutMs?: number): Promise; + }; + go: Record any>>>; + } +} diff --git a/e2e/support/global-setup.ts b/e2e/support/global-setup.ts new file mode 100644 index 0000000..ffe23f7 --- /dev/null +++ b/e2e/support/global-setup.ts @@ -0,0 +1,62 @@ +import type { FullConfig } from '@playwright/test'; + +/** + * Fail fast, and fail legibly. + * + * Without this the first spec dies on a connection refused deep inside + * a `page.goto`, which reads like a Playwright problem rather than "you + * forgot to start the app". Checking /__test/health also verifies the + * two things every spec below assumes: that the control surface is + * mounted (dev build + YJ_TESTCTL=1) and that the app is pointed at the + * seeded fixture library rather than someone's real collection. + */ +const HELP = ` +The app is not running, or is not a seeded dev build. Start it with: + + make testdata # once — generates the fixtures + make sandbox-seed NAME=default # once — builds a seed by running the app + make dev-headless SEED=default # starts in the background and returns + +and stop it afterwards with 'make dev-stop'. +`; + +export default async function globalSetup(config: FullConfig) { + const baseURL = + config.projects[0]?.use?.baseURL ?? 'http://localhost:34115'; + + let health: any; + + try { + const res = await fetch(`${baseURL}/__test/health`, { + signal: AbortSignal.timeout(5_000), + }); + + if (!res.ok) throw new Error(`health returned ${res.status}`); + + health = await res.json(); + } catch (err) { + throw new Error( + `e2e: no healthy backend at ${baseURL} (${String(err)})\n${HELP}`, + ); + } + + if (!health.libraries?.length) { + throw new Error( + `e2e: backend has no library configured — specs would land on ` + + `the first-run wizard, which intercepts every pointer event.\n${HELP}`, + ); + } + + const tracks = health.counts?.tracks ?? 0; + + if (tracks < 1) { + throw new Error( + `e2e: backend library is empty (${tracks} tracks).\n${HELP}`, + ); + } + + console.log( + `e2e: backend up — ${tracks} tracks in ` + + `${health.libraries.map((l: any) => l.name).join(', ')}`, + ); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..a7dc5e3 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "allowImportingTsExtensions": true + }, + "include": ["specs/**/*.ts", "support/**/*.ts", "playwright.config.ts"] +} diff --git a/frontend/index.html b/frontend/index.html index 2878466..b23ad27 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,7 +23,7 @@
-
+
@@ -31,7 +31,7 @@
-
diff --git a/frontend/index.ts b/frontend/index.ts index 481b30f..9ee183f 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -98,6 +98,11 @@ document.addEventListener('navigate', (e: Event) => { searchStore.setCurrentView(view); + // Which view is showing is otherwise only inferable from which of + // the cached children lacks .view-hidden. Publishing it as an + // attribute keeps e2e selectors semantic instead of structural. + mainContent.dataset.activeView = view; + // --- Primary (cacheable) views ---------------------------------------- if (view in VIEW_TAGS) { // Navigating to a primary view clears the history stack. diff --git a/frontend/package.json b/frontend/package.json index c19e6f0..82eacfa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,12 +11,16 @@ "lit": "^3.2.1" }, "devDependencies": { + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", + "playwright": "^1.62.1", "stylelint-config-standard": "^40.0.0", "ts-lit-plugin": "^2.0.2", "typescript": "^5.9.3", "typescript-lit-html-plugin": "^0.9.0", "vite": "^7.0.0", - "vite-plugin-static-copy": "^3.0.0" + "vite-plugin-static-copy": "^3.0.0", + "vitest": "^4.1.10" }, "stylelint": { "extends": [ diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 587f753..d22e22d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -21,6 +21,15 @@ importers: specifier: ^3.2.1 version: 3.3.2 devDependencies: + '@vitest/browser': + specifier: ^4.1.10 + version: 4.1.10(vite@7.3.1)(vitest@4.1.10) + '@vitest/browser-playwright': + specifier: ^4.1.10 + version: 4.1.10(playwright@1.62.1)(vite@7.3.1)(vitest@4.1.10) + playwright: + specifier: ^1.62.1 + version: 1.62.1 stylelint-config-standard: specifier: ^40.0.0 version: 40.0.0(stylelint@17.3.0(typescript@5.9.3)) @@ -39,6 +48,9 @@ importers: vite-plugin-static-copy: specifier: ^3.0.0 version: 3.2.0(vite@7.3.1) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1) packages: @@ -58,6 +70,9 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@cacheable/memory@2.0.7': resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} @@ -275,6 +290,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -313,6 +331,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -347,66 +368,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -448,6 +482,15 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -457,6 +500,46 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + peerDependencies: + playwright: '*' + vitest: 4.1.10 + + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + peerDependencies: + vitest: 4.1.10 + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vscode/web-custom-data@0.4.13': resolution: {integrity: sha512-2ZUIRfhofZ/npLlf872EBnPmn27Kt4M2UssmQIfnJvgGgMYZJ5fvtHEDnttBBf2hnVtBgNCqZMVHJA+wsFVqTA==} @@ -486,6 +569,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -509,6 +596,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -542,6 +633,9 @@ packages: peerDependencies: '@floating-ui/utils': ^0.2.5 + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -590,6 +684,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -603,6 +700,13 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -642,6 +746,11 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -797,6 +906,9 @@ packages: lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mathml-tag-names@4.0.0: resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} @@ -815,6 +927,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -832,6 +948,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + p-map@7.0.4: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} @@ -847,6 +967,9 @@ packages: parse5@5.1.0: resolution: {integrity: sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -858,6 +981,20 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-safe-parser@7.0.1: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -913,6 +1050,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -920,6 +1060,10 @@ packages: signal-polyfill@0.2.2: resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -932,6 +1076,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -984,14 +1134,29 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + ts-lit-plugin@2.0.2: resolution: {integrity: sha512-DPXlVxhjWHxg8AyBLcfSYt2JXgpANV1ssxxwjY98o26gD8MzeiM68HFW9c2VeDd1CjoR3w7B/6/uKxwBQe+ioA==} @@ -1074,6 +1239,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-css-languageservice@3.0.13: resolution: {integrity: sha512-RWkO/c/A7iXhHEy3OuEqkCqavDjpD4NF2Ca8vjai+ZtEYNeHrm1ybTnBYLP4Ft1uXvvaaVtYA9HrDjD6+CUONg==} @@ -1116,6 +1322,11 @@ packages: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1124,6 +1335,18 @@ packages: resolution: {integrity: sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==} engines: {node: ^20.17.0 || >=22.9.0} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1163,6 +1386,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@blazediff/core@1.9.1': {} + '@cacheable/memory@2.0.7': dependencies: '@cacheable/utils': 2.3.4 @@ -1294,6 +1519,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.4.0 @@ -1334,6 +1561,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@polka/url@1.0.0-next.29': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -1415,6 +1644,15 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/react@19.2.14': @@ -1423,6 +1661,77 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@7.3.1)(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(vite@7.3.1)(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@7.3.1) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.10(vite@7.3.1)(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(vite@7.3.1) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@7.3.1)': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1 + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@vscode/web-custom-data@0.4.13': {} ajv@8.18.0: @@ -1451,6 +1760,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} balanced-match@3.0.1: {} @@ -1471,6 +1782,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -1513,6 +1826,8 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 + convert-source-map@2.0.0: {} + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -1551,6 +1866,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-module-lexer@2.3.1: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -1584,6 +1901,12 @@ snapshots: escape-string-regexp@1.0.5: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -1622,6 +1945,9 @@ snapshots: flatted@3.3.3: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -1757,6 +2083,10 @@ snapshots: lodash.truncate@4.4.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + mathml-tag-names@4.0.0: {} mdn-data@2.12.2: {} @@ -1770,6 +2100,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -1778,6 +2110,8 @@ snapshots: normalize-path@3.0.0: {} + obug@2.1.4: {} + p-map@7.0.4: {} parent-module@1.0.1: @@ -1793,12 +2127,24 @@ snapshots: parse5@5.1.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + postcss-safe-parser@7.0.1(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -1871,10 +2217,18 @@ snapshots: dependencies: queue-microtask: 1.2.3 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} signal-polyfill@0.2.2: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@5.1.0: {} slice-ansi@4.0.0: @@ -1885,6 +2239,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1979,15 +2337,23 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + ts-lit-plugin@2.0.2: dependencies: lit-analyzer: 2.0.3 @@ -2040,6 +2406,33 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.1) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.1 + vite: 7.3.1 + why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@7.3.1)(vitest@4.1.10) + transitivePeerDependencies: + - msw + vscode-css-languageservice@3.0.13: dependencies: vscode-languageserver-types: 3.17.5 @@ -2094,6 +2487,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -2105,6 +2503,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@8.21.3: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/frontend/src/components/audio-player/controls/player-controls.ts b/frontend/src/components/audio-player/controls/player-controls.ts index cb52479..3a5a524 100644 --- a/frontend/src/components/audio-player/controls/player-controls.ts +++ b/frontend/src/components/audio-player/controls/player-controls.ts @@ -119,19 +119,29 @@ export class PlayerControls extends LitElement { return html`
- - - - -
diff --git a/frontend/src/components/audio-player/seekbar/seek-bar.ts b/frontend/src/components/audio-player/seekbar/seek-bar.ts index 337c975..3fcd705 100644 --- a/frontend/src/components/audio-player/seekbar/seek-bar.ts +++ b/frontend/src/components/audio-player/seekbar/seek-bar.ts @@ -155,8 +155,9 @@ export class SeekBar extends LitElement { return html`
- ${elapsedTime} + ${elapsedTime} - ${remainingTime} + ${remainingTime}
`; } diff --git a/frontend/src/components/cover-grid/scroll-manager.ts b/frontend/src/components/cover-grid/scroll-manager.ts index bd4a3a1..ae47b0d 100644 --- a/frontend/src/components/cover-grid/scroll-manager.ts +++ b/frontend/src/components/cover-grid/scroll-manager.ts @@ -243,6 +243,12 @@ export class ScrollManager { ): void { // Guard against stacked observers. this.resizeObserver?.disconnect(); + + // An empty library renders no scroll container, so the caller's + // query returns undefined and observe() throws — asynchronously, + // out of loadAlbums, where nothing catches it. + if (!container) return; + this.currentColumnCount = this.getColumnCount(container); diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 6220fc7..cdf5f31 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -339,6 +339,7 @@ export class NowPlaying extends LitElement {
this.onScrollCycleEnd('title')} @@ -347,6 +348,7 @@ export class NowPlaying extends LitElement { this.onScrollCycleEnd('artist')} diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index cba9321..6a2b7cc 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -1412,6 +1412,8 @@ export class QueuePanel 'drop-after': showAfter, })} data-index=${index} + data-testid="queue-row" + data-file-path=${track.filePath} draggable="true" > diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index 4a9dd27..0940d3c 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -215,6 +215,10 @@ export class AppSidebar extends LitElement { return html`
  • this.navigate(item.id)} @dragover=${(e: DragEvent) => diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index e397c33..e0fe5ea 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -1740,6 +1740,8 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH })} draggable="true" data-index=${index} + data-testid="track-row" + data-file-path=${track.FilePath} >
    ', () => { + it('renders a testid per destination, which is how e2e navigates', async () => { + const el = await fixture('app-sidebar'); + + expect( + shadowAll(el, 'li').map((li) => li.getAttribute('data-testid')), + ).toEqual([ + 'nav-home', + 'nav-playlists', + 'nav-artists', + 'nav-genres', + 'nav-albums', + 'nav-tracks', + 'nav-explore', + 'nav-downloads', + 'nav-autotag', + 'nav-jobs', + 'nav-settings', + ]); + }); + + it('marks exactly one item as the current page', async () => { + const el = await fixture('app-sidebar'); + + const current = shadowAll(el, 'li').filter( + (li) => li.getAttribute('aria-current') === 'page', + ); + + expect(current).toHaveLength(1); + }); + + it('announces a navigation as a composed event, so the shell hears it through the shadow root', async () => { + const el = await fixture('app-sidebar'); + const seen: string[] = []; + + document.addEventListener('navigate', (e) => { + seen.push((e as CustomEvent<{ view: string }>).detail.view); + }, { once: true }); + + shadow(el, '[data-testid="nav-artists"]')?.click(); + await el.updateComplete; + + expect(seen).toEqual(['artists']); + }); + + it('moves aria-current to the clicked destination', async () => { + const el = await fixture('app-sidebar'); + + shadow(el, '[data-testid="nav-genres"]')?.click(); + await el.updateComplete; + + expect( + shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'), + ).toBe('page'); + }); + + it('looks the way it did last time', async () => { + const el = await fixture('app-sidebar'); + + await visual(el, 'app-sidebar'); + expect(shadowAll(el, 'li').length).toBeGreaterThan(0); + }); +}); + +describe('', () => { + beforeEach(() => { + stub('library.Library.GetAllLibrariesWithTrackCounts', [ + { id: 7, name: 'Music' }, + { id: 8, name: 'Field Recordings' }, + ]); + }); + + it('offers every library plus the merged view', async () => { + const el = await fixture('library-filter'); + + await flush(); + await el.updateComplete; + + expect(texts(el, 'option')).toEqual([ + 'All Libraries', + 'Music', + 'Field Recordings', + ]); + }); + + it('carries an accessible name — it is a bare select otherwise', async () => { + const el = await fixture('library-filter'); + + expect(shadow(el, 'select')?.getAttribute('aria-label')).toBe( + 'Library filter', + ); + }); + + it('selects a library by id, and the merged view by empty string', async () => { + const el = await fixture('library-filter'); + + await flush(); + await el.updateComplete; + + const select = shadow(el, 'select'); + + if (select) select.value = '8'; + + select?.dispatchEvent(new Event('change')); + await flush(); + + expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]); + }); + + it('picks up a library added while it was on screen', async () => { + const el = await fixture('library-filter'); + + await flush(); + await el.updateComplete; + + stub('library.Library.GetAllLibrariesWithTrackCounts', [ + { id: 7, name: 'Music' }, + { id: 8, name: 'Field Recordings' }, + { id: 9, name: 'Podcasts' }, + ]); + emit(Events.LibraryAdded, { id: 9 }); + await flush(); + await el.updateComplete; + + expect(texts(el, 'option')).toContain('Podcasts'); + }); +}); + +describe('', () => { + it('defaults to "not in library"', async () => { + const el = await fixture('library-status-indicator'); + + expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('plus'); + }); + + it('uses a distinct glyph per state', async () => { + const glyphs: (string | null | undefined)[] = []; + + for (const status of ['in-library', 'queued', 'not-in-library']) { + const el = await fixture('library-status-indicator', { status }); + + glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name')); + } + + expect(glyphs).toEqual(['check', 'hourglass-half', 'plus']); + }); + + it('phrases its label around the entity it describes', async () => { + const el = await fixture('library-status-indicator', { + status: 'in-library', + entityType: 'album', + label: 'Abbey Road', + }); + + expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe( + 'Album "Abbey Road" is in your library', + ); + }); + + it('phrases an unowned entity as an invitation', async () => { + const el = await fixture('library-status-indicator', { + entityType: 'artist', + label: 'Eno', + }); + + expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe( + 'Add artist "Eno" to library', + ); + }); + + it('drops the quoted name when it has none', async () => { + const el = await fixture('library-status-indicator', { status: 'queued' }); + + expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe( + 'Track is queued for download', + ); + }); + + it('mirrors the label into the tooltip', async () => { + const el = await fixture('library-status-indicator', { + status: 'in-library', + }); + + const button = shadow(el, 'button'); + + expect(button?.getAttribute('title')).toBe( + button?.getAttribute('aria-label'), + ); + }); + + it('swallows the click, so it does not navigate the card it sits on', async () => { + const el = await fixture('library-status-indicator'); + let bubbled = 0; + + el.addEventListener('click', () => { + bubbled += 1; + }); + + shadow(el, 'button')?.click(); + + expect([bubbled, calls()]).toEqual([0, []]); + }); + + it('swallows Enter and Space for the same reason', async () => { + const el = await fixture('library-status-indicator'); + let bubbled = 0; + + el.addEventListener('keydown', () => { + bubbled += 1; + }); + + for (const key of ['Enter', ' ', 'Tab']) { + shadow(el, 'button')?.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, composed: true }), + ); + } + + // Tab still gets through: it is navigation, not activation. + expect(bubbled).toBe(1); + }); + + it('honours a non-default size', async () => { + const el = await fixture('library-status-indicator', { size: 32 }); + + expect(el.style.getPropertyValue('--indicator-size')).toBe('32px'); + }); + + it('looks the way it did last time', async () => { + const el = await fixture('library-status-indicator', { + status: 'in-library', + }); + + await update(el, { size: 40 }); + await visual(el, 'library-status-indicator-in-library'); + expect(shadow(el, 'button')).not.toBeNull(); + }); +}); + +describe(' resilience', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the merged view even when the library list cannot be loaded', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + stub('library.Library.GetAllLibrariesWithTrackCounts', () => { + throw new Error('database locked'); + }); + // The store caches the library list; drop it so the failing stub is + // actually reached. + emit(Events.LibraryRemoved, { id: 7 }); + + const el = await fixture('library-filter'); + + await flush(); + await el.updateComplete; + + expect(texts(el, 'option')).toEqual(['All Libraries']); + }); +}); diff --git a/frontend/test/components/now-playing.test.ts b/frontend/test/components/now-playing.test.ts new file mode 100644 index 0000000..f12f829 --- /dev/null +++ b/frontend/test/components/now-playing.test.ts @@ -0,0 +1,254 @@ +/** + * `` and `` are the two components driven + * entirely by store state rather than by their own properties: feeding + * them backend events is how they are made to render anything at all. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/now-playing/now-playing'; +import '@components/queue-panel/queue-panel'; +import { Events } from '../../src/events'; +import { emit, calls, stub, flush, lastArgs } from '@test/support/harness'; +import { + fixture, + shadow, + shadowAll, + text, + visual, +} from '@test/support/render'; +import type { TrackInfo } from '@store/player-store'; +import type { QueueTrack } from '@store/queue-store'; + +const TRACK: TrackInfo = { + fileName: 'ashes.mp3', + filePath: '/music/ashes.mp3', + trackLength: 215, + seekPosition: 0, + state: 'playing', + title: 'Ashes to Ashes', + artist: 'David Bowie', + album: 'Scary Monsters', + coverArt: '', + coverArtSmall: '', + coverArtMedium: '', + coverArtLarge: '', + trackChangeId: 1, + artistMbid: '', + releaseGroupMbid: '', + recordingMbid: '', +}; + +function queueTrack(n: number, title: string): QueueTrack { + return { + id: n, + audioFileId: n, + filePath: `/music/${n}.mp3`, + position: n, + title, + artist: 'Artist', + album: 'Album', + coverArtPath: '', + artistMbid: '', + releaseGroupMbid: '', + recordingMbid: '', + }; +} + +function setQueue(tracks: QueueTrack[], currentIndex = 0): void { + emit(Events.QueueChanged, { + tracks, + currentIndex, + shuffleMode: false, + repeatMode: 'off', + sourcePlaylistId: 0, + }); +} + +describe('', () => { + beforeEach(() => { + emit(Events.TrackChanged, null); + }); + + it('shows only a placeholder when nothing is loaded', async () => { + const el = await fixture('now-playing'); + + expect([ + shadow(el, '.cover-placeholder'), + shadow(el, '[data-testid="now-playing-title"]'), + ]).toEqual([expect.anything(), null]); + }); + + it('renders the title and artist of the loaded track', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, TRACK); + await flush(); + await el.updateComplete; + + expect([ + text(el, '[data-testid="now-playing-title"]'), + text(el, '[data-testid="now-playing-artist"]'), + ]).toEqual(['Ashes to Ashes', 'David Bowie']); + }); + + it('names an artistless track rather than leaving the line blank', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { ...TRACK, artist: '', trackChangeId: 2 }); + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="now-playing-artist"]')).toBe( + 'Unknown Artist', + ); + }); + + it('prefers the small cover variant and falls back on error', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { + ...TRACK, + coverArt: '/covers/big.jpg', + coverArtSmall: '/covers/missing.jpg', + trackChangeId: 3, + }); + await flush(); + await el.updateComplete; + + const img = shadow(el, '.cover-art img'); + const initial = img?.getAttribute('src'); + + img?.dispatchEvent(new Event('error')); + + expect([initial, img?.src.endsWith('/covers/big.jpg')]).toEqual([ + '/covers/missing.jpg', + true, + ]); + }); + + it('offers a favourite toggle that names the target playlist', async () => { + stub('playlist.Service.GetDefaultPlaylistTrackPaths', []); + stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' }); + emit(Events.PlaylistRenamed, 1); + await flush(); + + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 4 }); + await flush(); + await el.updateComplete; + + expect(shadow(el, '.fav-btn')?.getAttribute('title')).toBe('Add to Loved'); + }); + + it('toggles the favourite through the backend', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 }); + await flush(); + await el.updateComplete; + + shadow(el, '.fav-btn')?.click(); + await flush(); + + expect(lastArgs('playlist.Service.ToggleDefaultPlaylistTrack')).toEqual([ + '/music/ashes.mp3', + ]); + }); + + it('looks the way it did last time', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 }); + await flush(); + await el.updateComplete; + + await visual(el, 'now-playing'); + expect(text(el, '[data-testid="now-playing-title"]')).toBe( + 'Ashes to Ashes', + ); + }); +}); + +describe('', () => { + beforeEach(() => { + setQueue([]); + }); + + it('says so when the queue is empty', async () => { + const el = await fixture('queue-panel'); + + expect(text(el, '.empty-state p')).toBe('Queue is empty'); + }); + + it('renders a row per queued track, tagged with its file path', async () => { + const el = await fixture('queue-panel'); + + setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]); + await flush(); + await el.updateComplete; + await new Promise((r) => { + requestAnimationFrame(() => r(null)); + }); + + const rows = shadowAll(el, '[data-testid="queue-row"]'); + + expect(rows.map((r) => r.getAttribute('data-file-path'))).toEqual([ + '/music/1.mp3', + '/music/2.mp3', + ]); + }); + + it('marks the playing row as active', async () => { + const el = await fixture('queue-panel'); + + setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1); + await flush(); + await el.updateComplete; + await new Promise((r) => { + requestAnimationFrame(() => r(null)); + }); + + const active = shadowAll(el, '[data-testid="queue-row"].active'); + + expect(active.map((r) => r.getAttribute('data-index'))).toEqual(['1']); + }); + + it('disables the clear button on an empty queue', async () => { + const el = await fixture('queue-panel'); + + const button = shadow(el, '.header-action-button'); + + expect(button?.disabled).toBe(true); + }); + + it('clears through the backend, not locally', async () => { + const el = await fixture('queue-panel'); + + setQueue([queueTrack(1, 'First')]); + await flush(); + await el.updateComplete; + + shadow(el, '.header-action-button')?.click(); + await flush(); + + expect(calls().map((c) => c.path)).toContain('queue.Queue.Clear'); + }); + + // No screenshot for the queue panel: its list is a + // @lit-labs/virtualizer, which keeps re-measuring, so + // toMatchScreenshot never gets two identical frames and fails with + // "could not capture a stable screenshot" rather than a real diff. + it('keeps rendering rows after the virtualizer settles', async () => { + const el = await fixture('queue-panel'); + + setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0); + await flush(); + await el.updateComplete; + await new Promise((r) => { + requestAnimationFrame(() => r(null)); + }); + + expect(shadowAll(el, '[data-testid="queue-row"]').length).toBe(2); + }); +}); diff --git a/frontend/test/components/smoke.test.ts b/frontend/test/components/smoke.test.ts new file mode 100644 index 0000000..c9368a7 --- /dev/null +++ b/frontend/test/components/smoke.test.ts @@ -0,0 +1,178 @@ +/** + * Every custom element in the tree, mounted against an empty backend. + * + * This is deliberately shallow: it asserts each component renders + * *something* and logs no error, which is the state an agent's change + * most often breaks and which nothing else here would notice. Depth + * belongs in the per-component specs and in e2e; breadth belongs here, + * because 46 elements is more than anyone will write specs for. + */ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; + +// One import per component module, so a component that fails to even +// load is a failure here rather than a silent absence. +import '@components/artist-details/artist-details'; +import '@components/artists-view/artists-view'; +import '@components/audio-player/audio-player'; +import '@components/audio-player/controls/player-controls'; +import '@components/audio-player/seekbar/seek-bar'; +import '@components/audio-player/volume-control/volume-control'; +import '@components/autotag-view/autotag-view'; +import '@components/combobox/combobox'; +import '@components/config-page/config-page'; +import '@components/config-page/config-field'; +import '@components/config-page/config-section'; +import '@components/config-page/download-clients'; +import '@components/config-page/shortcut-capture'; +import '@components/cover-grid/cover-grid'; +import '@components/cover-grid/album-dropdown'; +import '@components/download-picker/download-picker'; +import '@components/download-picker/candidate-row'; +import '@components/downloads-view/downloads-view'; +import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog'; +import '@components/explore-album-details/explore-album-details'; +import '@components/explore-artist-details/explore-artist-details'; +import '@components/explore-view/explore-view'; +import '@components/first-run-wizard/first-run-wizard'; +import '@components/genre-details/genre-details'; +import '@components/genres-view/genres-view'; +import '@components/jobs/job-details-drawer'; +import '@components/jobs/job-indicator'; +import '@components/jobs/job-log-view'; +import '@components/jobs/job-row'; +import '@components/jobs/jobs-view'; +import '@components/library-filter/library-filter'; +import '@components/library-status-indicator/library-status-indicator'; +import '@components/now-playing/now-playing'; +import '@components/phantom-resolver/phantom-resolver'; +import '@components/playlist-details/playlist-details'; +import '@components/playlist-picker/playlist-picker'; +import '@components/playlist-view/playlist-view'; +import '@components/queue-panel/queue-panel'; +import '@components/search-bar/search-bar'; +import '@components/sidebar/app-sidebar'; +import '@components/smart-playlist-details/smart-playlist-details'; +import '@components/smart-playlist-editor/smart-playlist-editor'; +import '@components/top-results-row/top-results-row'; +import '@components/track-details/track-details'; +import '@components/track-info/track-info'; +import '@components/track-list/track-list'; + +import { flush, stub } from '@test/support/harness'; +import { fixture } from '@test/support/render'; + +/** Every element the app registers, in registration order. */ +const TAGS = [ + 'album-dropdown', + 'app-sidebar', + 'artist-details', + 'artists-view', + 'audio-player', + 'autotag-view', + 'candidate-row', + 'config-field', + 'config-page', + 'config-section', + 'cover-grid', + 'download-clients', + 'download-picker', + 'downloads-view', + 'duplicate-tracks-dialog', + 'explore-album-details', + 'explore-artist-details', + 'explore-view', + 'first-run-wizard', + 'genre-details', + 'genres-view', + 'job-details-drawer', + 'job-indicator', + 'job-log-view', + 'job-row', + 'jobs-view', + 'library-filter', + 'library-status-indicator', + 'now-playing', + 'phantom-resolver', + 'player-controls', + 'playlist-details', + 'playlist-picker', + 'playlist-view', + 'queue-panel', + 'search-bar', + 'seek-bar', + 'shortcut-capture', + 'smart-playlist-details', + 'smart-playlist-editor', + 'top-results-row', + 'track-details', + 'track-info', + 'track-list', + 'volume-control', + 'yj-combobox', +]; + +/** + * An empty-but-valid backend. Unstubbed bindings resolve undefined, + * which is not what Go sends — an empty list is. + */ +function stubEmptyBackend(): void { + const emptyLists = [ + 'library.Library.GetAllTracks', + 'library.Library.GetAllAlbums', + 'library.Library.GetAllArtists', + 'library.Library.GetAllGenresWithCounts', + 'library.Library.GetAllLibrariesWithTrackCounts', + 'playlist.Service.GetAllPlaylists', + 'playlist.Service.GetAllPlaylistsWithTracks', + 'playlist.Service.GetDefaultPlaylistTrackPaths', + 'jobs.Service.GetJobs', + 'download.Service.ListProviders', + 'download.Service.ListDownloads', + 'download.Service.ListRequests', + 'download.Service.ProviderKinds', + ]; + + for (const path of emptyLists) stub(path, []); + + stub('config.Config.GetShortcuts', {}); + stub('config.Config.GetDownloadPreferences', {}); + stub('config.Config.GetThemeAccentColor', '#ffd43b'); + stub('config.Config.GetThemeBackgroundShade', 'dark'); +} + +describe('every component mounts on an empty library', () => { + let errors: unknown[][] = []; + + beforeEach(() => { + stubEmptyBackend(); + errors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers every element it defines', () => { + const missing = TAGS.filter((tag) => !customElements.get(tag)); + + expect(missing).toEqual([]); + }); + + for (const tag of TAGS) { + it(`<${tag}> renders without logging an error`, async () => { + const el = await fixture(tag); + + await flush(); + await el.updateComplete; + + expect({ tag, root: el.shadowRoot !== null, errors }).toEqual({ + tag, + root: true, + errors: [], + }); + }); + } +}); diff --git a/frontend/test/components/track-info.test.ts b/frontend/test/components/track-info.test.ts new file mode 100644 index 0000000..fec74ea --- /dev/null +++ b/frontend/test/components/track-info.test.ts @@ -0,0 +1,160 @@ +/** + * `` is the shared row renderer: every list that shows a + * track goes through it, so its fallbacks (missing title, missing + * cover, missing duration) are visible in half the app. + */ +import { describe, expect, it } from 'vitest'; + +import '@components/track-info/track-info'; +import { fixture, shadow, text, update, visual } from '@test/support/render'; + +describe('', () => { + it('renders title, artist and album', async () => { + const el = await fixture('track-info', { + trackTitle: 'Ashes to Ashes', + artist: 'David Bowie', + album: 'Scary Monsters', + }); + + expect([text(el, '.title'), text(el, '.secondary')]).toEqual([ + 'Ashes to Ashes', + 'David Bowie — Scary Monsters', + ]); + }); + + it('joins artist and album with an em dash, and omits the separator when one is missing', async () => { + const el = await fixture('track-info', { + trackTitle: 'X', + artist: 'Only Artist', + }); + + expect(text(el, '.secondary')).toBe('Only Artist'); + }); + + it('omits the secondary line entirely when there is nothing to put in it', async () => { + const el = await fixture('track-info', { trackTitle: 'X' }); + + expect(shadow(el, '.secondary')).toBeNull(); + }); + + it('falls back to the filename, without its extension, when untitled', async () => { + // WAV tracks currently scan in untitled, so this path is live. + const el = await fixture('track-info', { + filePath: '/music/field/01 - Dawn Chorus.wav', + }); + + expect(text(el, '.title')).toBe('01 - Dawn Chorus'); + }); + + it('handles a Windows path in the same fallback', async () => { + const el = await fixture('track-info', { + filePath: 'C:\\Music\\Album\\Track.mp3', + }); + + expect(text(el, '.title')).toBe('Track'); + }); + + it('prefers a real title over the filename', async () => { + const el = await fixture('track-info', { + trackTitle: 'Real Title', + filePath: '/music/whatever.mp3', + }); + + expect(text(el, '.title')).toBe('Real Title'); + }); + + it('renders no title element at all when it has neither title nor path', async () => { + const el = await fixture('track-info', { artist: 'Someone' }); + + expect(shadow(el, '.title')).toBeNull(); + }); + + it('formats a duration given in milliseconds', async () => { + const el = await fixture('track-info', { + trackTitle: 'X', + duration: '215000', + }); + + expect(text(el, '.duration')).toBe('03:35'); + }); + + it('shows placeholder dashes for an unparseable duration', async () => { + const el = await fixture('track-info', { + trackTitle: 'X', + duration: 'unknown', + }); + + expect(text(el, '.duration')).toBe('--:--'); + }); + + it('omits the duration column when there is no duration', async () => { + const el = await fixture('track-info', { trackTitle: 'X' }); + + expect(shadow(el, '.duration')).toBeNull(); + }); + + it('shows no cover slot at all unless a cover was supplied', async () => { + const el = await fixture('track-info', { trackTitle: 'X' }); + + expect(shadow(el, '.cover-art')).toBeNull(); + }); + + it('prefers the small cover variant, which is what a row needs', async () => { + const el = await fixture('track-info', { + trackTitle: 'X', + coverArt: '/covers/big.jpg', + coverArtSmall: '/covers/small.jpg', + }); + + expect(shadow(el, '.cover-art img')?.getAttribute('src')).toBe( + '/covers/small.jpg', + ); + }); + + it('falls back to the full-size cover when the thumbnail fails to load', async () => { + const el = await fixture('track-info', { + trackTitle: 'X', + coverArt: '/covers/big.jpg', + coverArtSmall: '/covers/missing.jpg', + }); + + const img = shadow(el, '.cover-art img'); + + img?.dispatchEvent(new Event('error')); + + expect(img?.src).toContain('/covers/big.jpg'); + }); + + it('degrades to the music-note placeholder when both covers fail', async () => { + const el = await fixture('track-info', { + trackTitle: 'X', + coverArtSmall: '/covers/missing.jpg', + }); + + const img = shadow(el, '.cover-art img'); + + img?.dispatchEvent(new Event('error')); + + expect(shadow(el, '.cover-placeholder wa-icon')).not.toBeNull(); + }); + + it('re-renders when a property changes', async () => { + const el = await fixture('track-info', { trackTitle: 'Before' }); + + await update(el, { trackTitle: 'After' }); + + expect(text(el, '.title')).toBe('After'); + }); + + it('looks the way it did last time', async () => { + const el = await fixture('track-info', { + trackTitle: 'Ashes to Ashes', + artist: 'David Bowie', + album: 'Scary Monsters', + duration: '215000', + }); + + await visual(el, 'track-info'); + expect(el.shadowRoot).not.toBeNull(); + }); +}); diff --git a/frontend/test/components/transport.test.ts b/frontend/test/components/transport.test.ts new file mode 100644 index 0000000..1a7293c --- /dev/null +++ b/frontend/test/components/transport.test.ts @@ -0,0 +1,312 @@ +/** + * The transport bar: five buttons and a seek bar, all of them driven by + * backend push events rather than by their own clicks. These are the + * controls the e2e tier drives by accessible name, so the names are as + * much of a contract as the behaviour. + */ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; + +import '@components/audio-player/controls/player-controls'; +import '@components/audio-player/seekbar/seek-bar'; +import { Events } from '../../src/events'; +import { emit, calls, lastArgs, flush } from '@test/support/harness'; +import { + fixture, + shadow, + shadowAll, + text, + click, + visual, +} from '@test/support/render'; +import type { TrackInfo } from '@store/player-store'; + +const TRACK: TrackInfo = { + fileName: 'long.mp3', + filePath: '/music/long.mp3', + trackLength: 90, + seekPosition: 0, + state: 'playing', + title: 'Long Player', + artist: 'Test Artist', + album: 'Fixtures', + coverArt: '', + coverArtSmall: '', + coverArtMedium: '', + coverArtLarge: '', + trackChangeId: 1, + artistMbid: '', + releaseGroupMbid: '', + recordingMbid: '', +}; + +/** Reset the backend-owned state both components read from. */ +function idle(): void { + emit(Events.TrackChanged, null); + emit(Events.PlaybackStateChanged, { state: 'stopped' }); + emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'off' }); +} + +function labelOf(host: Element, index: number): string | null { + return shadowAll(host, 'button')[index]?.getAttribute('aria-label') ?? null; +} + +describe('', () => { + beforeEach(() => { + idle(); + }); + + it('names every button, so both a screen reader and a selector can find it', async () => { + const el = await fixture('player-controls'); + + expect(shadowAll(el, 'button').map((b) => b.getAttribute('aria-label'))).toEqual( + ['Shuffle', 'Previous track', 'Play', 'Next track', 'Repeat: off'], + ); + }); + + it('becomes a pause button while playing', async () => { + const el = await fixture('player-controls'); + + emit(Events.PlaybackStateChanged, { state: 'playing' }); + await flush(); + await el.updateComplete; + + expect([labelOf(el, 2), shadow(el, 'wa-icon[name="pause"]')]).not.toContain( + null, + ); + }); + + it('asks the queue to play, not the player — the queue owns what plays next', async () => { + const el = await fixture('player-controls'); + + await click(el, 'button[aria-label="Play"]'); + + expect(calls().map((c) => c.path)).toEqual(['queue.Queue.Play']); + }); + + it('pauses through the player once playing', async () => { + const el = await fixture('player-controls'); + + emit(Events.PlaybackStateChanged, { state: 'playing' }); + await flush(); + await el.updateComplete; + await click(el, 'button[aria-label="Pause"]'); + + expect(calls().map((c) => c.path)).toEqual(['player.Player.Pause']); + }); + + it('wires skip forward and back to the queue', async () => { + const el = await fixture('player-controls'); + + await click(el, 'button[aria-label="Next track"]'); + await click(el, 'button[aria-label="Previous track"]'); + + expect(calls().map((c) => c.path)).toEqual([ + 'queue.Queue.Next', + 'queue.Queue.Previous', + ]); + }); + + it('reports shuffle state through aria-pressed, not just colour', async () => { + const el = await fixture('player-controls'); + const before = shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute( + 'aria-pressed', + ); + + emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'off' }); + await flush(); + await el.updateComplete; + + expect([ + before, + shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute('aria-pressed'), + ]).toEqual(['false', 'true']); + }); + + it('spells the repeat mode into the label, since one icon covers three states', async () => { + const el = await fixture('player-controls'); + + emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'one' }); + await flush(); + await el.updateComplete; + + expect(labelOf(el, 4)).toBe('Repeat: one'); + }); + + it('marks repeat-one so its badge renders', async () => { + const el = await fixture('player-controls'); + + emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'one' }); + await flush(); + await el.updateComplete; + + expect(shadow(el, 'button.repeat-one')).not.toBeNull(); + }); + + it('does not toggle its own state — the backend confirms it', async () => { + const el = await fixture('player-controls'); + + await click(el, 'button[aria-label="Shuffle"]'); + + expect([ + calls().map((c) => c.path), + shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute('aria-pressed'), + ]).toEqual([['queue.Queue.ToggleShuffle'], 'false']); + }); + + it('stops listening to the queue once removed', async () => { + const el = await fixture('player-controls'); + + el.remove(); + emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' }); + await flush(); + + // A leaked subscription would keep rendering a detached element. + expect(el.isConnected).toBe(false); + }); + + it('looks the way it did last time', async () => { + const el = await fixture('player-controls'); + + emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' }); + await flush(); + await el.updateComplete; + + await visual(el, 'player-controls'); + expect(shadowAll(el, 'button')).toHaveLength(5); + }); +}); + +describe('', () => { + beforeEach(() => { + idle(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows placeholder clocks with nothing loaded', async () => { + const el = await fixture('seek-bar'); + + expect([ + text(el, '[data-testid="elapsed-time"]'), + text(el, '[data-testid="remaining-time"]'), + ]).toEqual(['--:--', '--:--']); + }); + + it('shows elapsed and remaining once a track is loaded', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, TRACK); + await flush(); + await el.updateComplete; + + expect([ + text(el, '[data-testid="elapsed-time"]'), + text(el, '[data-testid="remaining-time"]'), + ]).toEqual(['00:00', '01:30']); + }); + + it('resumes mid-track from the position the backend reported', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, seekPosition: 30, trackChangeId: 2 }); + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30'); + }); + + it('rewinds when the same file plays again, which only the change id reveals', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, seekPosition: 45, trackChangeId: 3 }); + await flush(); + await el.updateComplete; + + emit(Events.TrackChanged, { ...TRACK, seekPosition: 0, trackChangeId: 4 }); + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:00'); + }); + + it('ticks the clock forward while playing', async () => { + vi.useFakeTimers(); + + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, TRACK); + emit(Events.PlaybackStateChanged, { state: 'playing' }); + await vi.advanceTimersByTimeAsync(0); + await el.updateComplete; + + await vi.advanceTimersByTimeAsync(3000); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:03'); + }); + + it('stops ticking when paused', async () => { + vi.useFakeTimers(); + + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, TRACK); + emit(Events.PlaybackStateChanged, { state: 'playing' }); + await vi.advanceTimersByTimeAsync(2000); + await el.updateComplete; + + emit(Events.PlaybackStateChanged, { state: 'paused' }); + await vi.advanceTimersByTimeAsync(0); + await el.updateComplete; + await vi.advanceTimersByTimeAsync(5000); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:02'); + }); + + it('seeks to the position the slider was dropped at', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, TRACK); + await flush(); + await el.updateComplete; + + const slider = shadow(el, 'wa-slider'); + + if (slider) slider.value = 42; + + slider?.dispatchEvent(new Event('change')); + await el.updateComplete; + + expect(lastArgs('player.Player.Seek')).toEqual([42]); + }); + + it('bounds the slider by the track length', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, TRACK); + await flush(); + await el.updateComplete; + + expect(shadow(el, 'wa-slider')?.getAttribute('max')).toBe('90'); + }); + + it('carries an accessible name, since it is otherwise an unlabelled slider', async () => { + const el = await fixture('seek-bar'); + + expect(shadow(el, 'wa-slider')?.getAttribute('aria-label')).toBe('Seek'); + }); + + it('looks the way it did last time', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, seekPosition: 30, trackChangeId: 9 }); + await flush(); + await el.updateComplete; + + await visual(el, 'seek-bar'); + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30'); + }); +}); diff --git a/frontend/test/harness.test.ts b/frontend/test/harness.test.ts new file mode 100644 index 0000000..64753a3 --- /dev/null +++ b/frontend/test/harness.test.ts @@ -0,0 +1,96 @@ +/** + * Self-tests for the component tier, in the spirit of e2e/specs/ + * harness.spec.ts: prove the rig is what it claims before trusting a + * single assertion built on it. + */ +import { describe, expect, it } from 'vitest'; + +import { Events } from '../src/events'; +import { emit, calls, wails, flush } from '@test/support/harness'; +// Importing a store must be enough to make it start listening. +import { queueStore } from '@store/queue-store'; + +describe('component-tier harness', () => { + it('runs in a real browser with a real shadow DOM', () => { + const host = document.createElement('div'); + + host.attachShadow({ mode: 'open' }).innerHTML = 'x'; + + expect(host.shadowRoot?.querySelector('b')?.textContent).toBe('x'); + }); + + it('routes generated bindings through the fake, not a module mock', async () => { + // The import path under test is the real generated stub, which does + // window['go']['queue']['Queue']['GetState'](). + const Queue = await import('@go/queue/Queue'); + + wails.stub('queue.Queue.GetState', { currentIndex: 4 }); + + await expect(Queue.GetState()).resolves.toEqual({ currentIndex: 4 }); + expect(calls('queue.Queue.GetState')).toHaveLength(1); + }); + + it('resolves an unstubbed binding instead of hanging', async () => { + const Queue = await import('@go/queue/Queue'); + + // The real trap this pays for is the reverse: a *real* binding + // called with wrong argument types never settles. Here, silence is + // an immediate undefined so a test fails on the assertion rather + // than on a timeout. + await expect(Queue.Play()).resolves.toBeUndefined(); + }); + + it('registers listeners merely by importing a store', () => { + expect(wails.listenerNames()).toContain(Events.QueueChanged); + }); + + it('delivers events to store listeners with their payload', () => { + emit(Events.QueueIndexChanged, { currentIndex: 11 }); + + expect(queueStore.getState().currentIndex).toBe(11); + }); + + it('expires a once-listener after a single delivery', () => { + let fired = 0; + + wails.on('SyntheticEvent', () => { + fired += 1; + }, 1); + + wails.notify('SyntheticEvent', []); + wails.notify('SyntheticEvent', []); + + expect(fired).toBe(1); + }); + + it('notifies local listeners on a frontend-side EventsEmit', async () => { + // Wails' own runtime notifies JS listeners before it notifies Go + // (desktop/events.js), so a frontend emit is observable in-page. + const { EventsEmit } = await import('@runtime/runtime'); + let seen: unknown; + + wails.on('SyntheticEmit', (data) => { + seen = data; + }, -1); + + EventsEmit('SyntheticEmit', 42); + + expect(seen).toBe(42); + }); + + it('flushes the microtask queue stores notify on', async () => { + let notified = false; + + const off = queueStore.subscribe(() => { + notified = true; + }); + + emit(Events.QueueIndexChanged, { currentIndex: 1 }); + const beforeFlush = notified; + + await flush(); + off(); + + expect([beforeFlush, notified]).toEqual([false, true]); + }); +}); diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts new file mode 100644 index 0000000..76eeb6e --- /dev/null +++ b/frontend/test/setup.ts @@ -0,0 +1,69 @@ +/** + * Runs before every test module. Two jobs, in this order: + * + * 1. Install the Wails fake. Store singletons call `EventsOn` and load + * from the backend *in their constructors*, which run when a test + * module imports them — so the globals have to exist first. + * 2. Point Web Awesome at its assets. Without this every `` + * silently 404s and screenshots come out with holes in them. + */ +import { afterEach, beforeEach } from 'vitest'; +import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; +import '@awesome.me/webawesome/dist/styles/themes/default.css'; +import { installWailsFake, wails } from './support/wails-fake'; +import { resetHarness } from './support/harness'; +import { cleanupFixtures } from './support/render'; + +installWailsFake(); + +/** + * A few stores read config in their constructor, which runs when a test + * module imports them — before any test has had a chance to stub. An + * unstubbed binding resolves undefined, and `themeStore` in particular + * then derives a colour ramp from `undefined` and throws inside its own + * failure handler. These defaults keep import-time loads on the happy + * path; tests still stub whatever they assert on. + */ +const importTimeDefaults: Array<[string, unknown]> = [ + ['config.Config.GetThemeAccentColor', '#ffd43b'], + ['config.Config.GetThemeBackgroundShade', 'dark'], + ['config.Config.GetShortcuts', {}], + // libraryStore and playlistStore fetch eagerly at import. Left + // unstubbed they would cache `undefined` — not the empty list Go + // sends — and every consumer would then crash on `.length`. + ['library.Library.GetAllTracks', []], + ['library.Library.GetAllAlbums', []], + ['library.Library.GetAllArtists', []], + ['library.Library.GetAllGenresWithCounts', []], + ['library.Library.GetAllLibrariesWithTrackCounts', []], + ['playlist.Service.GetAllPlaylistsWithTracks', []], +]; + +for (const [path, value] of importTimeDefaults) { + wails.stub(path, value); +} + +// Vite serves the dependency's own directory, so icons resolve from +// node_modules rather than from the built `dist/webawesome` copy the +// app uses. +setBasePath('/node_modules/@awesome.me/webawesome/dist'); + +// index.ts imports the theme store for its side effect: it derives the +// --yj-* custom properties and applies them to :root, where every +// shadow root inherits them. Without it a component renders white text +// on a white page and screenshots come out blank. +await import('@store/theme-store'); + +// The app's surface colours come from index.css, whose grid layout is +// not wanted here — take the two declarations that matter. +document.body.style.backgroundColor = 'var(--yj-bg-base, black)'; +document.body.style.color = 'var(--yj-text-primary, white)'; +document.body.style.margin = '0'; + +beforeEach(() => { + resetHarness(); +}); + +afterEach(() => { + cleanupFixtures(); +}); diff --git a/frontend/test/stores/download-store.test.ts b/frontend/test/stores/download-store.test.ts new file mode 100644 index 0000000..89139ad --- /dev/null +++ b/frontend/test/stores/download-store.test.ts @@ -0,0 +1,344 @@ +/** + * The download store is mostly event-driven refreshes plus a set of + * pure formatters the downloads list and the picker share — they exist + * so those two views cannot disagree about what a state is called, and + * that only holds if both are tested against the same expectations. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { + downloadStore, + isDownloadTerminal, + stateLabel, + scorePercent, + candidateSummary, + formatBytes, + type DownloadView, + type DownloadCandidate, + type DownloadProvider, + type Request, +} from '@store/download-store'; +import { Events } from '../../src/events'; +import { + emit, + calls, + stub, + flush, + lastArgs, + resetHarness, +} from '@test/support/harness'; + +function view(id: string, state: string): DownloadView { + return { id, state } as DownloadView; +} + +function provider(id: number, enabled: boolean): DownloadProvider { + return { id, name: `p${id}`, enabled, kind: 'test' } as DownloadProvider; +} + +function request(overrides: Partial): Request { + return { + id: 1, + mbid: 'abc', + entity: 'release-group', + state: 'wanted', + ...overrides, + } as Request; +} + +function candidate(overrides: Partial): DownloadCandidate { + return { id: 'c', totalSize: 0, origin: '', ...overrides } as DownloadCandidate; +} + +describe('download formatters', () => { + it('treats complete, cancelled and failed as terminal', () => { + expect( + ['complete', 'cancelled', 'failed', 'grabbing'].map((s) => + isDownloadTerminal(view('d', s)), + ), + ).toEqual([true, true, true, false]); + }); + + it('labels every lifecycle state in the user’s terms', () => { + expect([ + stateLabel('found'), + stateLabel('grabbing'), + stateLabel('importing'), + ]).toEqual(['Waiting for you to choose', 'Downloading', 'Importing']); + }); + + it('passes an unknown state through rather than showing a blank', () => { + expect(stateLabel('teleporting')).toBe('teleporting'); + }); + + it('rounds a score to whole percent', () => { + expect([scorePercent(0), scorePercent(0.876), scorePercent(1)]).toEqual([ + '0%', + '88%', + '100%', + ]); + }); + + it('scales bytes to the largest unit that fits', () => { + expect([ + formatBytes(512), + formatBytes(1024), + formatBytes(5.5 * 1024 * 1024), + formatBytes(20 * 1024 * 1024 * 1024), + ]).toEqual(['512 B', '1.0 KB', '5.5 MB', '20 GB']); + }); + + it('renders no size at all rather than "0 B"', () => { + expect([formatBytes(0), formatBytes(-1)]).toEqual(['', '']); + }); + + it('summarises a single-format candidate', () => { + expect( + candidateSummary( + candidate({ + files: [ + { isAudio: true, format: 'flac' }, + { isAudio: true, format: 'flac' }, + { isAudio: false, format: 'jpg' }, + ], + totalSize: 300 * 1024 * 1024, + origin: 'Example', + } as Partial), + ), + ).toBe('FLAC · 2 tracks · 300 MB · Example'); + }); + + it('flags a mixed-format candidate instead of naming one format', () => { + expect( + candidateSummary( + candidate({ + files: [ + { isAudio: true, format: 'flac' }, + { isAudio: true, format: 'mp3' }, + ], + } as Partial), + ), + ).toBe('Mixed formats · 2 tracks'); + }); + + it('singularises a one-track candidate', () => { + expect( + candidateSummary( + candidate({ + files: [{ isAudio: true, format: 'mp3' }], + } as Partial), + ), + ).toBe('MP3 · 1 track'); + }); + + it('survives a candidate with no file list at all', () => { + expect(candidateSummary(candidate({}))).toBe(''); + }); +}); + +describe('download store: event-driven refresh', () => { + beforeEach(async () => { + stub('download.Service.ListProviders', []); + stub('download.Service.ListDownloads', []); + stub('download.Service.ListRequests', []); + stub('download.Service.ProviderKinds', []); + await downloadStore.init(); + await flush(); + resetHarness(); + stub('download.Service.ListProviders', [ + provider(1, true), + provider(2, false), + ]); + stub('download.Service.ListDownloads', [ + view('a', 'grabbing'), + view('b', 'complete'), + ]); + stub('download.Service.ListRequests', [ + request({ id: 1, mbid: 'abc', state: 'wanted' }), + request({ id: 2, mbid: 'def', state: 'satisfied' }), + request({ id: 3, mbid: 'ghi', entity: 'artist' }), + ]); + }); + + it('reloads providers when the backend says they changed', async () => { + emit(Events.DownloadProvidersChanged); + await flush(); + + expect(downloadStore.providers).toHaveLength(2); + }); + + it('reloads downloads on DownloadsChanged', async () => { + emit(Events.DownloadsChanged); + await flush(); + + expect(downloadStore.downloads.map((d) => d.id)).toEqual(['a', 'b']); + }); + + it('reloads requests, which change without the user doing anything', async () => { + // A background reconcile pass expands an artist or retires a want, + // so the list is push-driven rather than fetched on mount. + emit(Events.RequestsChanged); + await flush(); + + expect(downloadStore.requests).toHaveLength(3); + }); + + it('offers downloading only when a provider is enabled', async () => { + emit(Events.DownloadProvidersChanged); + await flush(); + const withEnabled = downloadStore.available; + + stub('download.Service.ListProviders', [provider(2, false)]); + emit(Events.DownloadProvidersChanged); + await flush(); + + expect([withEnabled, downloadStore.available]).toEqual([true, false]); + }); + + it('separates active downloads from finished ones', async () => { + emit(Events.DownloadsChanged); + await flush(); + + expect(downloadStore.activeDownloads.map((d) => d.id)).toEqual(['a']); + }); + + it('separates outstanding requests and artist subscriptions', async () => { + emit(Events.RequestsChanged); + await flush(); + + expect({ + active: downloadStore.activeRequests.map((r) => r.id), + subscriptions: downloadStore.subscriptions.map((r) => r.id), + }).toEqual({ active: [1, 3], subscriptions: [3] }); + }); + + it('survives a null list, which Go sends when nothing exists', async () => { + stub('download.Service.ListDownloads', null); + emit(Events.DownloadsChanged); + await flush(); + + expect(downloadStore.downloads).toEqual([]); + }); + + it('keeps the last good list when a refresh fails', async () => { + emit(Events.DownloadsChanged); + await flush(); + + stub('download.Service.ListDownloads', () => { + throw new Error('backend down'); + }); + emit(Events.DownloadsChanged); + await flush(); + + expect(downloadStore.downloads).toHaveLength(2); + }); + + it('coalesces three refreshes into one notification', async () => { + let notifications = 0; + const off = downloadStore.subscribe(() => { + notifications += 1; + }); + + emit(Events.DownloadProvidersChanged); + emit(Events.DownloadsChanged); + emit(Events.RequestsChanged); + await flush(); + off(); + + expect(notifications).toBe(1); + }); +}); + +describe('download store: request lookup', () => { + beforeEach(async () => { + stub('download.Service.ListRequests', [ + request({ id: 1, mbid: 'abc-123', state: 'wanted' }), + ]); + emit(Events.RequestsChanged); + await flush(); + resetHarness(); + stub('download.Service.ListRequests', [ + request({ id: 1, mbid: 'abc-123', state: 'wanted' }), + ]); + }); + + it('answers from the cached list, synchronously enough to render with', () => { + expect([ + downloadStore.isRequested('abc-123'), + downloadStore.isRequested('nope'), + ]).toEqual([true, false]); + }); + + it('normalises case and whitespace in the MBID it is given', () => { + expect(downloadStore.isRequested(' ABC-123 ')).toBe(true); + }); + + it('returns the request itself for the caller that needs its state', () => { + expect(downloadStore.requestFor('abc-123')?.id).toBe(1); + }); +}); + +describe('download store: writes refresh what they changed', () => { + beforeEach(async () => { + stub('download.Service.ListProviders', []); + stub('download.Service.ListDownloads', []); + stub('download.Service.ListRequests', []); + await flush(); + resetHarness(); + stub('download.Service.ListProviders', []); + stub('download.Service.ListDownloads', []); + stub('download.Service.ListRequests', []); + }); + + it('refreshes providers after adding one', async () => { + stub('download.Service.AddProvider', 5); + + await expect( + downloadStore.addProvider('sab', 'Local', { url: 'http://x' }), + ).resolves.toBe(5); + expect(calls().map((c) => c.path)).toEqual([ + 'download.Service.AddProvider', + 'download.Service.ListProviders', + ]); + }); + + it('refreshes downloads after picking a candidate', async () => { + await downloadStore.pick('d1', 'c1'); + + expect([ + lastArgs('download.Service.Pick'), + calls('download.Service.ListDownloads'), + ]).toEqual([['d1', 'c1'], [{ path: 'download.Service.ListDownloads', args: [50] }]]); + }); + + it('refreshes requests after removing one', async () => { + await downloadStore.removeRequest(3); + + expect(calls().map((c) => c.path)).toEqual([ + 'download.Service.RemoveRequest', + 'download.Service.ListRequests', + ]); + }); + + it('refreshes both lists after a manual reconcile, since it can start downloads', async () => { + stub('download.Service.ReconcileRequests', { added: 1 }); + + await downloadStore.reconcileRequests(); + + expect(calls().map((c) => c.path).sort()).toEqual([ + 'download.Service.ListDownloads', + 'download.Service.ListRequests', + 'download.Service.ReconcileRequests', + ]); + }); + + it('propagates a provider test failure, which is the user’s only clue', async () => { + stub('download.Service.TestProvider', () => { + throw new Error('connection refused'); + }); + + await expect(downloadStore.testProvider(1)).rejects.toThrow( + 'connection refused', + ); + }); +}); diff --git a/frontend/test/stores/favorites-store.test.ts b/frontend/test/stores/favorites-store.test.ts new file mode 100644 index 0000000..3b5fe29 --- /dev/null +++ b/frontend/test/stores/favorites-store.test.ts @@ -0,0 +1,194 @@ +/** + * Favourites is the one store that updates optimistically: the heart + * fills before Go has agreed. So the interesting cases are the reverts, + * and the set of playlist events that force a reload — a default + * playlist edited elsewhere has to show up here. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { favoritesStore } from '@store/favorites-store'; +import { Events } from '../../src/events'; +import { + emit, + calls, + stub, + stubFailure, + flush, + lastArgs, + resetHarness, +} from '@test/support/harness'; + +const PATHS = ['/music/a.mp3', '/music/b.mp3']; + +function stubReads(paths: string[] = PATHS): void { + stub('playlist.Service.GetDefaultPlaylistTrackPaths', paths); + stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' }); + stub('config.Config.GetFavoritesPlaylistID', 3); + stub('config.Config.GetFavoritesIconStyle', 'star'); + stub('config.Config.GetPinDefaultPlaylist', false); +} + +/** Push a config change and let the reloads it triggers settle. */ +async function reload(paths: string[] = PATHS): Promise { + stubReads(paths); + emit(Events.FavoritesConfigChanged, { + PlaylistID: 3, + IconStyle: 'star', + PinDefault: false, + }); + await flush(); + resetHarness(); + stubReads(paths); +} + +describe('favorites store: cached membership', () => { + beforeEach(async () => { + await reload(); + }); + + it('reports membership by file path', () => { + expect([ + favoritesStore.isFavorited('/music/a.mp3'), + favoritesStore.isFavorited('/music/z.mp3'), + ]).toEqual([true, false]); + }); + + it('requires every path for a multi-selection to count as favourited', () => { + expect([ + favoritesStore.allFavorited(PATHS), + favoritesStore.allFavorited([...PATHS, '/music/z.mp3']), + ]).toEqual([true, false]); + }); + + it('treats an empty selection as not favourited, so the button is not lit for nothing', () => { + expect(favoritesStore.allFavorited([])).toBe(false); + }); + + it('adopts the config the backend pushed', () => { + expect([ + favoritesStore.getPlaylistId(), + favoritesStore.getIconStyle(), + favoritesStore.getPinDefault(), + ]).toEqual([3, 'star', false]); + }); + + it('resolves the playlist name from the backend', () => { + expect(favoritesStore.getPlaylistName()).toBe('Loved'); + }); +}); + +describe('favorites store: optimistic writes', () => { + beforeEach(async () => { + await reload(); + }); + + it('fills the heart before the backend answers', () => { + void favoritesStore.toggleFavorite('/music/z.mp3'); + + expect(favoritesStore.isFavorited('/music/z.mp3')).toBe(true); + }); + + it('reverts an add the backend rejected', async () => { + stubFailure('playlist.Service.ToggleDefaultPlaylistTrack'); + + await favoritesStore.toggleFavorite('/music/z.mp3'); + + expect(favoritesStore.isFavorited('/music/z.mp3')).toBe(false); + }); + + it('reverts a removal the backend rejected', async () => { + stubFailure('playlist.Service.ToggleDefaultPlaylistTrack'); + + await favoritesStore.toggleFavorite('/music/a.mp3'); + + expect(favoritesStore.isFavorited('/music/a.mp3')).toBe(true); + }); + + it('adds a batch optimistically and forwards the whole list', async () => { + await favoritesStore.addToFavorites(['/music/y.mp3', '/music/z.mp3']); + + expect([ + favoritesStore.allFavorited(['/music/y.mp3', '/music/z.mp3']), + lastArgs('playlist.Service.AddToDefaultPlaylist'), + ]).toEqual([true, [['/music/y.mp3', '/music/z.mp3']]]); + }); + + it('removes a batch optimistically', async () => { + await favoritesStore.removeFromFavorites(['/music/a.mp3']); + + expect(favoritesStore.isFavorited('/music/a.mp3')).toBe(false); + }); + + it('resyncs from the backend when a batch write fails, rather than guessing', async () => { + stubFailure('playlist.Service.AddToDefaultPlaylist'); + + await favoritesStore.addToFavorites(['/music/z.mp3']); + await flush(); + + expect( + calls('playlist.Service.GetDefaultPlaylistTrackPaths'), + ).toHaveLength(1); + }); +}); + +describe('favorites store: reacting to playlist changes', () => { + beforeEach(async () => { + await reload(); + }); + + it('reloads when the default playlist itself changed', async () => { + emit(Events.PlaylistTracksChanged, 3); + await flush(); + + expect( + calls('playlist.Service.GetDefaultPlaylistTrackPaths'), + ).toHaveLength(1); + }); + + it('ignores changes to some other playlist', async () => { + emit(Events.PlaylistTracksChanged, 99); + await flush(); + + expect( + calls('playlist.Service.GetDefaultPlaylistTrackPaths'), + ).toHaveLength(0); + }); + + it('reloads after a restore, which rewrites every playlist', async () => { + emit(Events.PlaylistsRestored); + await flush(); + + expect( + calls('playlist.Service.GetDefaultPlaylistTrackPaths'), + ).toHaveLength(1); + }); + + it('re-reads the name when a playlist is renamed', async () => { + stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Renamed' }); + emit(Events.PlaylistRenamed, 3); + await flush(); + + expect(favoritesStore.getPlaylistName()).toBe('Renamed'); + }); + + it('falls back to "Favorites" when no default playlist is configured', async () => { + await favoritesStore.setDefaultPlaylist(0); + + expect(favoritesStore.getPlaylistName()).toBe('Favorites'); + }); + + it('persists a changed icon style', async () => { + await favoritesStore.setIconStyle('heart'); + + expect([ + favoritesStore.getIconStyle(), + lastArgs('config.Config.SetFavoritesIconStyle'), + ]).toEqual(['heart', ['heart']]); + }); + + it('persists the pin setting', async () => { + await favoritesStore.setPinDefault(true); + + expect(lastArgs('config.Config.SetPinDefaultPlaylist')).toEqual([true]); + }); +}); diff --git a/frontend/test/stores/job-store.test.ts b/frontend/test/stores/job-store.test.ts new file mode 100644 index 0000000..248f8cf --- /dev/null +++ b/frontend/test/stores/job-store.test.ts @@ -0,0 +1,273 @@ +/** + * The job store mirrors the backend registry from full snapshots, so + * the derivations on top of it — which jobs count as active, whether + * the indicator should be up, the linger after the last job finishes — + * are where the behaviour lives. + */ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; + +import { + jobStore, + isTerminal, + isActive, + isIndeterminate, + progressFraction, + type Job, +} from '@store/job-store'; +import { Events } from '../../src/events'; +import { emit, calls, stub, flush } from '@test/support/harness'; + +function job(overrides: Partial & { id: string }): Job { + return { + kind: 'library-scan', + state: 'running', + title: 'Scanning', + current: 0, + total: 0, + ...overrides, + } as Job; +} + +/** Push a full snapshot, which is all the backend ever sends. */ +function snapshot(jobs: Job[]): void { + emit(Events.JobsChanged, jobs); +} + +describe('job predicates', () => { + it('treats complete, cancelled and error as terminal', () => { + const states = ['complete', 'cancelled', 'error'] as const; + + expect( + states.map((state) => isTerminal(job({ id: state, state }))), + ).toEqual([true, true, true]); + }); + + it('treats every in-flight state, including paused, as active', () => { + const states = ['queued', 'running', 'pausing', 'paused', 'cancelling']; + + expect(states.map((state) => isActive(job({ id: state, state })))).toEqual([ + true, + true, + true, + true, + true, + ]); + }); + + it('calls a job with no denominator indeterminate', () => { + expect([ + isIndeterminate(job({ id: 'a', total: 0 })), + isIndeterminate(job({ id: 'b', total: -1 })), + isIndeterminate(job({ id: 'c', total: 10 })), + ]).toEqual([true, true, false]); + }); + + it('has no progress fraction when indeterminate', () => { + expect(progressFraction(job({ id: 'a', total: 0, current: 5 }))).toBeNull(); + }); + + it('clamps a progress fraction that overshoots its total', () => { + expect([ + progressFraction(job({ id: 'a', current: 5, total: 10 })), + progressFraction(job({ id: 'b', current: 30, total: 10 })), + progressFraction(job({ id: 'c', current: -5, total: 10 })), + ]).toEqual([0.5, 1, 0]); + }); +}); + +describe('job store: snapshots', () => { + beforeEach(() => { + snapshot([]); + }); + + it('partitions a snapshot by state', () => { + snapshot([ + job({ id: 'r', state: 'running' }), + job({ id: 'q', state: 'queued' }), + job({ id: 'p', state: 'paused' }), + job({ id: 'e', state: 'error' }), + job({ id: 'c', state: 'complete' }), + ]); + + expect({ + working: jobStore.workingJobs.map((j) => j.id), + paused: jobStore.pausedJobs.map((j) => j.id), + failed: jobStore.failedJobs.map((j) => j.id), + finished: jobStore.finishedJobs.map((j) => j.id), + active: jobStore.activeJobs.map((j) => j.id), + }).toEqual({ + working: ['r', 'q'], + paused: ['p'], + failed: ['e'], + finished: ['e', 'c'], + active: ['r', 'q', 'p'], + }); + }); + + it('tolerates a null snapshot, which Go sends for an empty registry', () => { + emit(Events.JobsChanged, null); + + expect(jobStore.jobs).toEqual([]); + }); + + it('replaces rather than merges, so a removed job disappears', () => { + snapshot([job({ id: 'a' }), job({ id: 'b' })]); + snapshot([job({ id: 'b' })]); + + expect(jobStore.jobs.map((j) => j.id)).toEqual(['b']); + }); + + it('finds a job by id', () => { + snapshot([job({ id: 'a', title: 'Indexing' })]); + + expect(jobStore.getJob('a')?.title).toBe('Indexing'); + }); + + it('fetches the initial snapshot at most once', async () => { + stub('jobs.Service.GetJobs', [job({ id: 'a' })]); + + await jobStore.init(); + await jobStore.init(); + + expect(calls('jobs.Service.GetJobs').length).toBeLessThanOrEqual(1); + }); +}); + +describe('job store: indicator linger', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Emptying the registry is itself "the last job finished", so it + // starts a linger; run it out before the test begins. + snapshot([]); + vi.advanceTimersByTime(4000); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the indicator up briefly after the last job finishes', () => { + snapshot([job({ id: 'a', state: 'running' })]); + snapshot([job({ id: 'a', state: 'complete' })]); + + const immediatelyAfter = jobStore.shouldShowIndicator; + + vi.advanceTimersByTime(4000); + + expect([immediatelyAfter, jobStore.shouldShowIndicator]).toEqual([ + true, + false, + ]); + }); + + it('cancels the linger when new work starts', () => { + snapshot([job({ id: 'a', state: 'running' })]); + snapshot([job({ id: 'a', state: 'complete' })]); + snapshot([ + job({ id: 'a', state: 'complete' }), + job({ id: 'b', state: 'running' }), + ]); + + vi.advanceTimersByTime(4000); + + // Still up, because b is still working — the linger timer must not + // hide the indicator out from under it. + expect(jobStore.shouldShowIndicator).toBe(true); + }); + + it('does not linger for a snapshot that was never active', () => { + snapshot([job({ id: 'a', state: 'complete' })]); + + expect(jobStore.shouldShowIndicator).toBe(false); + }); +}); + +describe('job store: logs', () => { + beforeEach(() => { + snapshot([]); + }); + + it('is empty until a log is fetched', () => { + expect(jobStore.cachedLog('a')).toEqual([]); + }); + + it('caches a fetched log', async () => { + stub('jobs.Service.GetJobLog', [{ level: 'warn', message: 'skipped' }]); + snapshot([job({ id: 'a' })]); + + await jobStore.loadLog('a'); + + expect(jobStore.cachedLog('a')).toHaveLength(1); + }); + + it('returns an empty log rather than throwing when the fetch fails', async () => { + stub('jobs.Service.GetJobLog', () => { + throw new Error('gone'); + }); + + await expect(jobStore.loadLog('a')).resolves.toEqual([]); + }); + + it('drops cached logs for jobs the backend has forgotten', async () => { + stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]); + snapshot([job({ id: 'a' })]); + await jobStore.loadLog('a'); + + snapshot([job({ id: 'b' })]); + + expect(jobStore.cachedLog('a')).toEqual([]); + }); + + it('forgets a dismissed job log without waiting for the next snapshot', async () => { + stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]); + snapshot([job({ id: 'a' })]); + await jobStore.loadLog('a'); + + await jobStore.dismiss('a'); + + expect(jobStore.cachedLog('a')).toEqual([]); + }); +}); + +describe('job store: controls', () => { + beforeEach(() => { + snapshot([]); + }); + + it('forwards each control to its bound method with the job id', async () => { + await jobStore.pause('a'); + await jobStore.resume('a'); + await jobStore.cancel('a'); + + expect(calls().map((c) => [c.path, c.args])).toEqual([ + ['jobs.Service.PauseJob', ['a']], + ['jobs.Service.ResumeJob', ['a']], + ['jobs.Service.CancelJob', ['a']], + ]); + }); + + it('clears logs for every finished job when they are cleared', async () => { + stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]); + snapshot([job({ id: 'a', state: 'complete' })]); + await jobStore.loadLog('a'); + + await jobStore.clearFinished(); + + expect(jobStore.cachedLog('a')).toEqual([]); + }); + + it('coalesces a burst of snapshots into one notification', async () => { + let notifications = 0; + const off = jobStore.subscribe(() => { + notifications += 1; + }); + + snapshot([job({ id: 'a', current: 1, total: 10 })]); + snapshot([job({ id: 'a', current: 2, total: 10 })]); + snapshot([job({ id: 'a', current: 3, total: 10 })]); + await flush(); + off(); + + expect(notifications).toBe(1); + }); +}); diff --git a/frontend/test/stores/keyboard-shortcuts.test.ts b/frontend/test/stores/keyboard-shortcuts.test.ts new file mode 100644 index 0000000..577c4ea --- /dev/null +++ b/frontend/test/stores/keyboard-shortcuts.test.ts @@ -0,0 +1,400 @@ +/** + * The keyboard shortcut service and the store behind it. This is the + * test that most justifies running in a real browser: the service walks + * shadow roots to find the deepest focused element, and no jsdom + * approximation of that is worth trusting. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; + +import { buildKeyString } from '../../src/services/keyboard-shortcut-service'; +import '../../src/services/keyboard-shortcut-service'; +import { shortcutsStore } from '@store/shortcuts-store'; +import { Events } from '../../src/events'; +import { emit, calls, lastArgs, stub } from '@test/support/harness'; + +/** Install a binding table, as the backend's config push does. */ +function bindings(table: Record): void { + emit(Events.ShortcutsConfigChanged, table); +} + +/** Send a keydown through the document, where the service listens. */ +function press( + key: string, + modifiers: Partial< + Record<'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey', boolean> + > = {}, +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...modifiers, + }); + + document.dispatchEvent(event); + + return event; +} + +const mounted: HTMLElement[] = []; + +/** Append an element to the body and remember to remove it. */ +function mount(el: T): T { + document.body.append(el); + mounted.push(el); + + return el; +} + +afterEach(() => { + while (mounted.length > 0) mounted.pop()?.remove(); + bindings({}); +}); + +// =================================================================== + +describe('buildKeyString', () => { + it('uppercases a bare printable key', () => { + expect(buildKeyString(new KeyboardEvent('keydown', { key: 'n' }))).toBe( + 'N', + ); + }); + + it('orders modifiers Ctrl, Alt, Shift regardless of press order', () => { + const e = new KeyboardEvent('keydown', { + key: 'f', + shiftKey: true, + altKey: true, + ctrlKey: true, + }); + + expect(buildKeyString(e)).toBe('Ctrl+Alt+Shift+F'); + }); + + it('folds Meta into Ctrl so macOS and Linux share one binding table', () => { + const meta = new KeyboardEvent('keydown', { key: 'f', metaKey: true }); + const ctrl = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }); + + expect(buildKeyString(meta)).toBe(buildKeyString(ctrl)); + }); + + it('aliases arrows and space to their canonical names', () => { + const names = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].map( + (key) => buildKeyString(new KeyboardEvent('keydown', { key })), + ); + + expect(names).toEqual(['Up', 'Down', 'Left', 'Right', 'Space']); + }); + + it('leaves multi-character named keys alone', () => { + expect( + buildKeyString(new KeyboardEvent('keydown', { key: 'Escape' })), + ).toBe('Escape'); + }); + + it('returns nothing for a bare modifier press', () => { + const bare = ['Control', 'Alt', 'Shift', 'Meta'].map((key) => + buildKeyString(new KeyboardEvent('keydown', { key })), + ); + + expect(bare).toEqual(['', '', '', '']); + }); +}); + +// =================================================================== + +describe('shortcuts store: lookup', () => { + beforeEach(() => { + bindings({ + 'player.playPause': 'Space', + 'player.next': 'Ctrl+Right', + 'tracklist.play': 'Enter', + 'tracklist.delete': 'Delete', + }); + }); + + it('resolves an action to its key', () => { + expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right'); + }); + + it('reverse-resolves a key to its global action', () => { + expect(shortcutsStore.getActionForKey('Space')).toBe('player.playPause'); + }); + + it('does not resolve a panel binding from global scope', () => { + // `tracklist.` is a panel prefix: Enter must do nothing unless the + // track list has focus. + expect(shortcutsStore.getActionForKey('Enter')).toBeUndefined(); + }); + + it('resolves a panel binding when the matching scope is supplied', () => { + expect(shortcutsStore.getActionForKey('Enter', 'panel:tracklist')).toBe( + 'tracklist.play', + ); + }); + + it('falls back to global inside a panel scope', () => { + expect(shortcutsStore.getActionForKey('Space', 'panel:tracklist')).toBe( + 'player.playPause', + ); + }); + + it('reports a conflict, excluding the action being rebound', () => { + expect( + shortcutsStore.findConflict('Space', 'global', 'player.next'), + ).toEqual({ action: 'player.playPause', key: 'Space' }); + }); + + it('does not report an action conflicting with itself', () => { + expect( + shortcutsStore.findConflict('Space', 'global', 'player.playPause'), + ).toBeNull(); + }); +}); + +// =================================================================== + +describe('shortcut dispatch: scope', () => { + beforeEach(() => { + bindings({ + 'player.playPause': 'Space', + 'tracklist.play': 'Enter', + }); + }); + + it('dispatches from global scope', () => { + press(' '); + + expect(calls('queue.Queue.Play')).toHaveLength(1); + }); + + it('preventDefaults a key it handled', () => { + expect(press(' ').defaultPrevented).toBe(true); + }); + + it('leaves an unbound key alone', () => { + expect(press('q').defaultPrevented).toBe(false); + }); + + it('suppresses shortcuts while a text input has focus', () => { + const input = mount(document.createElement('input')); + + input.type = 'text'; + input.focus(); + press(' '); + + expect(calls('queue.Queue.Play')).toHaveLength(0); + }); + + it('suppresses shortcuts inside a contenteditable', () => { + const div = mount(document.createElement('div')); + + div.contentEditable = 'true'; + div.tabIndex = 0; + div.focus(); + press(' '); + + expect(calls('queue.Queue.Play')).toHaveLength(0); + }); + + it('lets a checkbox through — it is not a text input', () => { + const input = mount(document.createElement('input')); + + input.type = 'checkbox'; + input.focus(); + press(' '); + + expect(calls('queue.Queue.Play')).toHaveLength(1); + }); + + it('blurs the input on Escape, and only on Escape', () => { + const input = mount(document.createElement('input')); + + input.type = 'search'; + input.focus(); + press('Escape'); + + expect(document.activeElement).not.toBe(input); + }); + + it('finds a text input nested in a shadow root', () => { + // document.activeElement stops at the shadow host, so a service + // that did not walk the chain would see
    and fire the shortcut. + const host = mount(document.createElement('div')); + const root = host.attachShadow({ mode: 'open' }); + const input = document.createElement('input'); + + input.type = 'text'; + root.append(input); + input.focus(); + press(' '); + + expect(calls('queue.Queue.Play')).toHaveLength(0); + }); + + it('resolves a panel scope from a data-shortcut-scope ancestor', () => { + const panel = mount(document.createElement('div')); + const button = document.createElement('button'); + + panel.dataset['shortcutScope'] = 'tracklist'; + panel.append(button); + button.focus(); + + let fired = 0; + const listener = (): void => { + fired += 1; + }; + + document.addEventListener('shortcut:tracklist-play', listener); + press('Enter'); + document.removeEventListener('shortcut:tracklist-play', listener); + + expect(fired).toBe(1); + }); + + it('crosses a shadow boundary looking for the panel scope', () => { + const panel = mount(document.createElement('div')); + const inner = document.createElement('div'); + const root = inner.attachShadow({ mode: 'open' }); + const button = document.createElement('button'); + + panel.dataset['shortcutScope'] = 'tracklist'; + panel.append(inner); + root.append(button); + button.focus(); + + let fired = 0; + const listener = (): void => { + fired += 1; + }; + + document.addEventListener('shortcut:tracklist-play', listener); + press('Enter'); + document.removeEventListener('shortcut:tracklist-play', listener); + + expect(fired).toBe(1); + }); +}); + +// =================================================================== + +describe('shortcut dispatch: actions', () => { + it('toggles between pause and play based on cached player state', () => { + bindings({ 'player.playPause': 'Space' }); + + emit(Events.PlaybackStateChanged, { state: 'playing' }); + press(' '); + emit(Events.PlaybackStateChanged, { state: 'paused' }); + press(' '); + + expect(calls().map((c) => c.path)).toEqual([ + 'player.Player.Pause', + 'queue.Queue.Play', + ]); + }); + + it('steps the volume by a fixed amount in each direction', () => { + bindings({ 'player.volumeUp': 'Up', 'player.volumeDown': 'Down' }); + + press('ArrowUp'); + press('ArrowDown'); + + expect(calls('player.Player.ChangeVolume').map((c) => c.args)).toEqual([ + [5], + [-5], + ]); + }); + + it('clamps a forward seek to the track length', async () => { + bindings({ 'player.seekForward': 'Right' }); + stub('player.Player.CurrentPositionSeconds', 98); + stub('player.Player.TrackLengthInSeconds', 100); + + press('ArrowRight'); + await new Promise((r) => { + setTimeout(r, 0); + }); + + expect(lastArgs('player.Player.Seek')).toEqual([100]); + }); + + it('clamps a backward seek at zero', async () => { + bindings({ 'player.seekBack': 'Left' }); + stub('player.Player.CurrentPositionSeconds', 2); + + press('ArrowLeft'); + await new Promise((r) => { + setTimeout(r, 0); + }); + + expect(lastArgs('player.Player.Seek')).toEqual([0]); + }); + + it('toggles the queue panel open and closed', () => { + bindings({ 'nav.queue': 'Q' }); + + const panel = mount(document.createElement('div')); + + panel.id = 'queue-panel'; + + press('q'); + const opened = panel.hasAttribute('open'); + + press('q'); + + expect([opened, panel.hasAttribute('open')]).toEqual([true, false]); + }); + + it('broadcasts select-all as a document event', () => { + bindings({ 'app.selectAll': 'Ctrl+A' }); + + let fired = 0; + const listener = (): void => { + fired += 1; + }; + + document.addEventListener('shortcut:select-all', listener); + press('a', { ctrlKey: true }); + document.removeEventListener('shortcut:select-all', listener); + + expect(fired).toBe(1); + }); + + it('ignores an action name the dispatcher does not know', () => { + bindings({ 'player.teleport': 'T' }); + + press('t'); + + expect(calls()).toEqual([]); + }); +}); + +// =================================================================== + +describe('shortcuts store: writes', () => { + it('sends a single rebind to the backend', async () => { + await shortcutsStore.updateBinding('player.next', 'Ctrl+N'); + + expect(lastArgs('config.Config.SetShortcut')).toEqual([ + 'player.next', + 'Ctrl+N', + ]); + }); + + it('sends a whole table at once', async () => { + await shortcutsStore.setAll({ 'player.next': 'N' }); + + expect(lastArgs('config.Config.SetShortcuts')).toEqual([ + { 'player.next': 'N' }, + ]); + }); + + it('does not update its cache optimistically', async () => { + bindings({ 'player.next': 'Ctrl+Right' }); + await shortcutsStore.updateBinding('player.next', 'Ctrl+N'); + + // The backend is the only writer; the cache waits for the + // ShortcutsConfigChanged push. + expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right'); + }); +}); diff --git a/frontend/test/stores/library-store.test.ts b/frontend/test/stores/library-store.test.ts new file mode 100644 index 0000000..b759d70 --- /dev/null +++ b/frontend/test/stores/library-store.test.ts @@ -0,0 +1,287 @@ +/** + * The library store is the frontend's read cache for the whole + * catalogue: four collections, each fetched once and held until + * something invalidates them. The interesting behaviour is not the + * fetching but the caching — deduplicating concurrent readers, throwing + * everything away on a rescan, and swapping to the by-library bindings + * when a filter is active. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { libraryStore } from '@store/library-store'; +import { Events } from '../../src/events'; +import { + emit, + calls, + stub, + flush, + lastArgs, + resetHarness, +} from '@test/support/harness'; + +const TRACKS = [{ ID: 1, Title: 'One' }]; +const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }]; +const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }]; +const ARTISTS = [{ ID: 1, Name: 'Artist' }]; +const GENRES = [{ Name: 'Ambient', Count: 3 }]; +const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }]; + +/** Stub every read binding the store can reach. Unstubbed bindings + * resolve undefined, which the store would cache as if it were data. */ +function stubReads(): void { + stub('library.Library.GetAllTracks', TRACKS); + stub('library.Library.GetAllAlbums', ALBUMS); + stub('library.Library.GetAllArtists', ARTISTS); + stub('library.Library.GetAllGenresWithCounts', GENRES); + stub('library.Library.GetAllTracksByLibrary', TRACKS); + stub('library.Library.GetAllAlbumsByLibrary', ALBUMS); + stub('library.Library.GetAllArtistsByLibrary', ARTISTS); + stub('library.Library.GetAllGenresWithCountsByLibrary', GENRES); + stub('library.Library.GetAlbumsByArtist', ALBUMS); + stub('library.Library.GetAlbumsByArtistByLibrary', ALBUMS); + stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES); +} + +/** + * Drop the cache and let the eager refetch settle, so each test starts + * from the same place. The store has no reset of its own; a scan + * completing is how the app itself clears it. + */ +async function reload(): Promise { + stubReads(); + emit(Events.LibraryScanComplete); + await flush(); + // The eager refetch the invalidation kicks off is recorded like any + // other call; clear it, or every count in every test is off by one. + resetHarness(); + stubReads(); +} + +describe('library store: caching', () => { + beforeEach(async () => { + await reload(); + }); + + it('serves a second read from cache without touching the backend', async () => { + await libraryStore.getTracks(); + + expect(calls('library.Library.GetAllTracks')).toHaveLength(0); + }); + + it('deduplicates concurrent first reads into one backend call', async () => { + emit(Events.LibraryScanComplete); + const [a, b] = await Promise.all([ + libraryStore.getArtists(), + libraryStore.getArtists(), + ]); + + expect([a, b, calls('library.Library.GetAllArtists').length]).toEqual([ + ARTISTS, + ARTISTS, + 1, + ]); + }); + + it('exposes cached collections synchronously once loaded', () => { + expect([ + libraryStore.getCachedTracks(), + libraryStore.getCachedAlbums(), + libraryStore.cachedArtists, + libraryStore.getCachedGenres(), + ]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]); + }); + + it('refetches everything when a scan completes', async () => { + emit(Events.LibraryScanComplete); + await flush(); + + expect(calls().map((c) => c.path).sort()).toEqual([ + 'library.Library.GetAllAlbums', + 'library.Library.GetAllArtists', + 'library.Library.GetAllGenresWithCounts', + 'library.Library.GetAllTracks', + ]); + }); + + it('refetches when a track is retagged', async () => { + emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' }); + await flush(); + + expect(calls('library.Library.GetAllTracks')).toHaveLength(1); + }); + + it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => { + libraryStore.setScrollPosition('albums', 4200); + emit(Events.LibraryScanComplete); + await flush(); + + expect(libraryStore.getScrollPosition('albums')).toBe(0); + }); + + it('bumps the change generation for data, not for loading flags', async () => { + const before = libraryStore.changeGeneration; + + await libraryStore.getTracks(); // cached: no change + const afterCachedRead = libraryStore.changeGeneration; + + emit(Events.LibraryScanComplete); + await flush(); + + expect([ + afterCachedRead === before, + libraryStore.changeGeneration > before, + ]).toEqual([true, true]); + }); +}); + +describe('library store: library filter', () => { + beforeEach(async () => { + libraryStore.setSelectedLibrary(null); + await reload(); + }); + + it('switches to the by-library bindings when a filter is set', async () => { + libraryStore.setSelectedLibrary(7); + await flush(); + + expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([7]); + }); + + it('ignores a redundant selection instead of invalidating', async () => { + libraryStore.setSelectedLibrary(null); + await flush(); + + expect(calls()).toEqual([]); + }); + + it('answers the cached artist-albums query only when unfiltered', async () => { + const unfiltered = libraryStore.getAlbumsByArtistNameCached('Artist'); + + libraryStore.setSelectedLibrary(7); + await flush(); + + // With a filter active the cache is already library-scoped, so the + // store declines to answer and forces a backend query instead. + expect([ + unfiltered, + libraryStore.getAlbumsByArtistNameCached('Artist'), + ]).toEqual([ALBUMS, null]); + }); + + it('scopes an artist drill-down to the selected library', async () => { + libraryStore.setSelectedLibrary(8); + await libraryStore.getAlbumsByArtist(3); + + expect(lastArgs('library.Library.GetAlbumsByArtistByLibrary')).toEqual([ + 3, 8, + ]); + }); +}); + +describe('library store: default library id', () => { + beforeEach(async () => { + libraryStore.setSelectedLibrary(null); + await reload(); + }); + + it('prefers the active filter', async () => { + libraryStore.setSelectedLibrary(8); + + await expect(libraryStore.getDefaultLibraryId()).resolves.toBe(8); + }); + + it('falls back to the first known library, never to zero', async () => { + // id 0 never exists and trips the download_requests foreign key. + await expect(libraryStore.getDefaultLibraryId()).resolves.toBe(7); + }); + + it('returns null when there are no libraries at all', async () => { + stub('library.Library.GetAllLibrariesWithTrackCounts', []); + emit(Events.LibraryRemoved, { id: 7 }); + + await expect(libraryStore.getDefaultLibraryId()).resolves.toBeNull(); + }); + + it('caches the library list until a library is added or renamed', async () => { + const fetched = (): number => + calls('library.Library.GetAllLibrariesWithTrackCounts').length; + + // The list survives a rescan — only library CRUD changes it. + emit(Events.LibraryAdded, { id: 9 }); + await libraryStore.getLibraries(); + const afterAdd = fetched(); + + await libraryStore.getLibraries(); + const afterCachedRead = fetched(); + + emit(Events.LibraryRenamed, { id: 7, name: 'Renamed' }); + await libraryStore.getLibraries(); + + expect([afterAdd, afterCachedRead, fetched()]).toEqual([1, 1, 2]); + }); +}); + +describe('library store: cover size', () => { + beforeEach(() => { + localStorage.removeItem('cover-grid-size'); + libraryStore.setCoverSize(176); + }); + + it('clamps below the minimum card width', () => { + libraryStore.setCoverSize(10); + + expect(libraryStore.getCoverSize()).toBe(100); + }); + + it('clamps above the maximum card width', () => { + libraryStore.setCoverSize(9000); + + expect(libraryStore.getCoverSize()).toBe(350); + }); + + it('rounds a fractional size, since it becomes a CSS pixel value', () => { + libraryStore.setCoverSize(180.6); + + expect(libraryStore.getCoverSize()).toBe(181); + }); + + it('persists the size for the next session', () => { + libraryStore.setCoverSize(200); + + expect(localStorage.getItem('cover-grid-size')).toBe('200'); + }); + + it('does not notify when the clamped size is unchanged', async () => { + libraryStore.setCoverSize(300); + await flush(); + + let notifications = 0; + const off = libraryStore.subscribe(() => { + notifications += 1; + }); + + libraryStore.setCoverSize(400); // clamps back to 350 ≠ 300 + libraryStore.setCoverSize(9999); // clamps to 350, unchanged + await flush(); + off(); + + expect(notifications).toBe(1); + }); +}); + +describe('library store: albums by artist name', () => { + beforeEach(async () => { + libraryStore.setSelectedLibrary(null); + await reload(); + }); + + it('filters the album cache by artist name', () => { + stub('library.Library.GetAllAlbums', [...ALBUMS, ...OTHER_ALBUMS]); + + expect(libraryStore.getAlbumsByArtistNameCached('Artist')).toEqual(ALBUMS); + }); + + it('returns an empty list, not null, for an artist with no albums', () => { + expect(libraryStore.getAlbumsByArtistNameCached('Nobody')).toEqual([]); + }); +}); diff --git a/frontend/test/stores/player-store.test.ts b/frontend/test/stores/player-store.test.ts new file mode 100644 index 0000000..204d50e --- /dev/null +++ b/frontend/test/stores/player-store.test.ts @@ -0,0 +1,136 @@ +/** + * The player store is a pure projection of backend push events. Its + * whole job is to be a truthful cache, so the tests are about what it + * derives (`isPlaying` from a state string) and what it refuses to + * invent (it never predicts the result of an action). + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { playerStore, type TrackInfo } from '@store/player-store'; +import { Events } from '../../src/events'; +import { emit, calls, lastArgs, flush } from '@test/support/harness'; + +const TRACK: TrackInfo = { + fileName: 'one.mp3', + filePath: '/music/one.mp3', + trackLength: 180, + seekPosition: 0, + state: 'playing', + title: 'One', + artist: 'Artist', + album: 'Album', + coverArt: '', + coverArtSmall: '', + coverArtMedium: '', + coverArtLarge: '', + trackChangeId: 1, + artistMbid: '', + releaseGroupMbid: '', + recordingMbid: '', +}; + +describe('player store: playback state', () => { + beforeEach(() => { + emit(Events.PlaybackStateChanged, { state: 'stopped' }); + emit(Events.TrackChanged, null); + }); + + it('is playing only for the literal "playing" state', () => { + const seen: boolean[] = []; + + for (const state of ['playing', 'paused', 'stopped', 'buffering']) { + emit(Events.PlaybackStateChanged, { state }); + seen.push(playerStore.getState().isPlaying); + } + + expect(seen).toEqual([true, false, false, false]); + }); + + it('stops playing when the track finishes', () => { + emit(Events.PlaybackStateChanged, { state: 'playing' }); + emit(Events.PlaybackFinished); + + expect(playerStore.getState().isPlaying).toBe(false); + }); + + it('caches the current track', () => { + emit(Events.TrackChanged, TRACK); + + expect(playerStore.getState().currentTrack).toEqual(TRACK); + }); + + it('normalises an absent track to null rather than undefined', () => { + emit(Events.TrackChanged, TRACK); + emit(Events.TrackChanged, undefined); + + expect(playerStore.getState().currentTrack).toBeNull(); + }); + + it('keeps the cached track across a pause', () => { + emit(Events.TrackChanged, TRACK); + emit(Events.PlaybackStateChanged, { state: 'paused' }); + + expect(playerStore.getState().currentTrack).toEqual(TRACK); + }); + + it('tracks volume pushed back from Go', () => { + emit(Events.VolumeChanged, 42); + + expect(playerStore.getState().volume).toBe(42); + }); + + it('replaces state rather than mutating it, so a saved reference is stable', () => { + emit(Events.VolumeChanged, 10); + const before = playerStore.getState(); + + emit(Events.VolumeChanged, 20); + + expect(before.volume).toBe(10); + }); + + it('coalesces a burst into a single notification', async () => { + let notifications = 0; + const off = playerStore.subscribe(() => { + notifications += 1; + }); + + emit(Events.VolumeChanged, 1); + emit(Events.VolumeChanged, 2); + emit(Events.PlaybackStateChanged, { state: 'playing' }); + await flush(); + off(); + + expect(notifications).toBe(1); + }); +}); + +describe('player store: actions', () => { + it('forwards each action to its bound method', () => { + playerStore.pause(); + playerStore.loadTrack('/music/one.mp3'); + playerStore.seek(30); + playerStore.setVolume(60); + + expect(calls().map((c) => c.path)).toEqual([ + 'player.Player.Pause', + 'player.Player.LoadFile', + 'player.Player.Seek', + 'player.Player.SetVolume', + ]); + }); + + it('sends the volume as an integer percentage, not a fraction', () => { + // player.UserVolume is an int in Go; a float never settles its + // callback. See .planning/NOTES.md. + playerStore.setVolume(42); + + expect(lastArgs('player.Player.SetVolume')).toEqual([42]); + }); + + it('does not optimistically change cached volume', () => { + emit(Events.VolumeChanged, 50); + playerStore.setVolume(80); + + expect(playerStore.getState().volume).toBe(50); + }); +}); diff --git a/frontend/test/stores/playlist-store.test.ts b/frontend/test/stores/playlist-store.test.ts new file mode 100644 index 0000000..504ad49 --- /dev/null +++ b/frontend/test/stores/playlist-store.test.ts @@ -0,0 +1,108 @@ +/** + * The playlist store caches one list and invalidates it on six + * different events. The distinction worth testing is `invalidate` vs + * `refetch`: one drops the cache (consumers render empty until the + * fetch lands), the other holds the stale list until the new one + * arrives. Using the wrong one shows up as a flash of empty list. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { playlistStore } from '@store/playlist-store'; +import { Events } from '../../src/events'; +import { emit, calls, stub, flush, resetHarness } from '@test/support/harness'; + +const PLAYLISTS = [ + { ID: 1, Name: 'Morning', Tracks: [] }, + { ID: 2, Name: 'Evening', Tracks: [] }, +]; + +async function reload(): Promise { + stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS); + playlistStore.invalidate(); + await flush(); + resetHarness(); + stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS); +} + +describe('playlist store: caching', () => { + beforeEach(async () => { + await reload(); + }); + + it('serves a second read from cache', async () => { + await playlistStore.getPlaylists(); + + expect(calls()).toEqual([]); + }); + + it('deduplicates concurrent first reads', async () => { + playlistStore.invalidate(); + await Promise.all([ + playlistStore.getPlaylists(), + playlistStore.getPlaylists(), + ]); + + expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toHaveLength(1); + }); + + it('exposes the cache synchronously for render', () => { + expect(playlistStore.getCachedPlaylists()).toEqual(PLAYLISTS); + }); + + it('normalises a null list to an empty one', async () => { + stub('playlist.Service.GetAllPlaylistsWithTracks', null); + playlistStore.invalidate(); + await flush(); + + expect(playlistStore.getCachedPlaylists()).toEqual([]); + }); + + it('holds the stale list across a refetch, so the view does not flash empty', async () => { + const pending = playlistStore.refetch(); + const during = playlistStore.getCachedPlaylists(); + + await pending; + + expect(during).toEqual(PLAYLISTS); + }); + + it('drops the cache on invalidate, which is the difference from refetch', () => { + playlistStore.invalidate(); + + expect(playlistStore.getCachedPlaylists()).toBeNull(); + }); + + it('resets the scroll position when the list is invalidated', async () => { + playlistStore.setScrollPosition(900); + playlistStore.invalidate(); + await flush(); + + expect(playlistStore.getScrollPosition()).toBe(0); + }); +}); + +describe('playlist store: invalidating events', () => { + beforeEach(async () => { + await reload(); + }); + + it('refetches for every event that can change a playlist', async () => { + const events = [ + Events.PlaylistCreated, + Events.PlaylistDeleted, + Events.PlaylistRenamed, + Events.PlaylistTracksChanged, + Events.PlaylistsRestored, + Events.LibraryScanComplete, + ]; + + for (const name of events) { + emit(name, 1); + await flush(); + } + + expect( + calls('playlist.Service.GetAllPlaylistsWithTracks'), + ).toHaveLength(events.length); + }); +}); diff --git a/frontend/test/stores/queue-store.test.ts b/frontend/test/stores/queue-store.test.ts new file mode 100644 index 0000000..da3b677 --- /dev/null +++ b/frontend/test/stores/queue-store.test.ts @@ -0,0 +1,284 @@ +/** + * The queue store's delta reducer is the most intricate pure logic in + * the frontend: four mutation actions arriving as events, applied to a + * cached array that must stay identical to the Go queue's own. `move` in + * particular adjusts its insertion index for elements removed before it, + * and gets that wrong silently. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { queueStore, type QueueTrack } from '@store/queue-store'; +import { Events } from '../../src/events'; +import { emit, flush, lastArgs, calls } from '@test/support/harness'; + +function track(n: number): QueueTrack { + return { + id: n, + audioFileId: n, + filePath: `/music/${n}.mp3`, + position: n, + title: `Track ${n}`, + artist: 'Artist', + album: 'Album', + coverArtPath: '', + artistMbid: '', + releaseGroupMbid: '', + recordingMbid: '', + }; +} + +/** Titles of the cached queue, the cheapest readable assertion. */ +function titles(): string[] { + return queueStore.getState().tracks.map((t) => t.title); +} + +/** Push an authoritative full-state sync, as the backend does on + * startup and after SetQueue. */ +function sync(tracks: QueueTrack[], currentIndex = 0): void { + emit(Events.QueueChanged, { + tracks, + currentIndex, + shuffleMode: false, + repeatMode: 'off', + sourcePlaylistId: 0, + }); +} + +describe('queue store: full-state sync', () => { + beforeEach(() => { + sync([]); + }); + + it('replaces cached state wholesale', () => { + sync([track(1), track(2)], 1); + + expect(queueStore.getState()).toEqual({ + tracks: [track(1), track(2)], + currentIndex: 1, + shuffleMode: false, + repeatMode: 'off', + sourcePlaylistId: 0, + }); + }); + + it('tolerates a null track list, which Go sends for an empty queue', () => { + emit(Events.QueueChanged, { + tracks: null, + currentIndex: -1, + shuffleMode: false, + repeatMode: 'off', + sourcePlaylistId: 0, + }); + + expect(queueStore.getState().tracks).toEqual([]); + }); +}); + +describe('queue store: track deltas', () => { + beforeEach(() => { + sync([track(1), track(2), track(3)], 0); + }); + + it('appends on add', () => { + emit(Events.QueueTracksModified, { + action: 'add', + tracks: [track(4)], + index: 0, + currentIndex: 0, + }); + + expect(titles()).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']); + }); + + it('splices at the index on insert', () => { + emit(Events.QueueTracksModified, { + action: 'insert', + tracks: [track(9)], + index: 1, + currentIndex: 0, + }); + + expect(titles()).toEqual(['Track 1', 'Track 9', 'Track 2', 'Track 3']); + }); + + it('removes every listed position at once, not one at a time', () => { + // Removing 0 then 2 sequentially would take the wrong second track; + // the reducer must treat the positions as indices into the original. + emit(Events.QueueTracksModified, { + action: 'remove', + positions: [0, 2], + index: 0, + currentIndex: 0, + }); + + expect(titles()).toEqual(['Track 2']); + }); + + it('adopts the backend current index from every delta', () => { + emit(Events.QueueTracksModified, { + action: 'remove', + positions: [0], + index: 0, + currentIndex: 1, + }); + + expect(queueStore.getState().currentIndex).toBe(1); + }); +}); + +describe('queue store: move', () => { + beforeEach(() => { + sync([track(1), track(2), track(3), track(4)], 0); + }); + + it('moves a track forward, compensating for its own removal', () => { + // Move index 0 to index 2. After removing it, the target shifts + // down by one, so track 1 lands between 2 and 3 — not after 3. + emit(Events.QueueTracksModified, { + action: 'move', + tracks: [track(1)], + positions: [0], + index: 2, + currentIndex: 1, + }); + + expect(titles()).toEqual(['Track 2', 'Track 1', 'Track 3', 'Track 4']); + }); + + it('moves a track backward without compensating', () => { + emit(Events.QueueTracksModified, { + action: 'move', + tracks: [track(4)], + positions: [3], + index: 1, + currentIndex: 0, + }); + + expect(titles()).toEqual(['Track 1', 'Track 4', 'Track 2', 'Track 3']); + }); + + it('moves a multi-selection, compensating once per element before the target', () => { + emit(Events.QueueTracksModified, { + action: 'move', + tracks: [track(1), track(2)], + positions: [0, 1], + index: 3, + currentIndex: 0, + }); + + expect(titles()).toEqual(['Track 3', 'Track 1', 'Track 2', 'Track 4']); + }); + + it('clamps a target past the end of the shortened list', () => { + emit(Events.QueueTracksModified, { + action: 'move', + tracks: [track(1)], + positions: [0], + index: 99, + currentIndex: 3, + }); + + expect(titles()).toEqual(['Track 2', 'Track 3', 'Track 4', 'Track 1']); + }); +}); + +describe('queue store: mode deltas', () => { + beforeEach(() => { + sync([track(1)], 0); + }); + + it('applies shuffle and repeat together', () => { + emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' }); + + const state = queueStore.getState(); + + expect([state.shuffleMode, state.repeatMode]).toEqual([true, 'one']); + }); + + it('leaves the track list untouched', () => { + emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' }); + + expect(titles()).toEqual(['Track 1']); + }); + + it('applies an index-only delta', () => { + emit(Events.QueueIndexChanged, { currentIndex: 7 }); + + expect(queueStore.getState().currentIndex).toBe(7); + }); +}); + +describe('queue store: subscriber notification', () => { + beforeEach(() => { + sync([]); + }); + + it('coalesces a burst of events into one notification', async () => { + let notifications = 0; + const unsubscribe = queueStore.subscribe(() => { + notifications += 1; + }); + + emit(Events.QueueIndexChanged, { currentIndex: 1 }); + emit(Events.QueueIndexChanged, { currentIndex: 2 }); + emit(Events.QueueIndexChanged, { currentIndex: 3 }); + await flush(); + + unsubscribe(); + + expect(notifications).toBe(1); + }); + + it('stops notifying after unsubscribe', async () => { + let notifications = 0; + const unsubscribe = queueStore.subscribe(() => { + notifications += 1; + }); + + unsubscribe(); + emit(Events.QueueIndexChanged, { currentIndex: 1 }); + await flush(); + + expect(notifications).toBe(0); + }); +}); + +describe('queue store: actions reach the backend', () => { + it('forwards setQueue with its default shuffleStart', () => { + queueStore.setQueue(['/a.mp3', '/b.mp3'], 1); + + expect(lastArgs('queue.Queue.SetQueue')).toEqual([ + ['/a.mp3', '/b.mp3'], + 1, + false, + ]); + }); + + it('maps each mutation onto its own bound method', () => { + queueStore.addToQueue('/a.mp3'); + queueStore.playNext('/b.mp3'); + queueStore.removeFromQueue(2); + queueStore.moveTracksInQueue([0, 1], 3); + queueStore.toggleShuffle(); + queueStore.cycleRepeat(); + queueStore.clearQueue(); + + expect(calls().map((c) => c.path)).toEqual([ + 'queue.Queue.AddTrack', + 'queue.Queue.InsertNext', + 'queue.Queue.RemoveTrack', + 'queue.Queue.MoveQueueTracks', + 'queue.Queue.ToggleShuffle', + 'queue.Queue.CycleRepeat', + 'queue.Queue.Clear', + ]); + }); + + it('does not optimistically mutate cached state', () => { + sync([track(1)], 0); + queueStore.clearQueue(); + + // The backend is the only writer; the cache waits for the event. + expect(titles()).toEqual(['Track 1']); + }); +}); diff --git a/frontend/test/stores/theme-store.test.ts b/frontend/test/stores/theme-store.test.ts new file mode 100644 index 0000000..e2c03e7 --- /dev/null +++ b/frontend/test/stores/theme-store.test.ts @@ -0,0 +1,147 @@ +/** + * The theme store is the only store that writes to the document: it + * derives a whole custom-property ramp from two settings and applies it + * to :root, where every shadow root inherits it. Asserting on the + * computed values of the real document is the point of running in a + * browser at all. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { themeStore } from '@store/theme-store'; +import { Events } from '../../src/events'; +import { emit, lastArgs } from '@test/support/harness'; + +/** Push a theme, as the backend's config change event does. */ +function applyTheme(AccentColor: string, BackgroundShade: string): void { + emit(Events.ThemeConfigChanged, { AccentColor, BackgroundShade }); +} + +function cssVar(name: string): string { + return document.documentElement.style.getPropertyValue(name).trim(); +} + +describe('theme store: derived variables', () => { + beforeEach(() => { + applyTheme('#ffd43b', 'dark'); + }); + + it('caches the pushed theme', () => { + expect(themeStore.getState()).toEqual({ + accentColor: '#ffd43b', + backgroundShade: 'dark', + }); + }); + + it('sets the accent verbatim', () => { + expect(cssVar('--yj-accent')).toBe('#ffd43b'); + }); + + it('derives a lighter hover accent and a darker muted one', () => { + expect([cssVar('--yj-accent-hover'), cssVar('--yj-accent-muted')]).toEqual([ + '#ffda58', + '#806a1e', + ]); + }); + + it('derives translucent accent backgrounds as rgba, for layering', () => { + expect(cssVar('--yj-accent-bg')).toBe('rgba(255, 212, 59, 0.1)'); + }); + + it('expands a three-digit hex before deriving from it', () => { + applyTheme('#fff', 'dark'); + + expect(cssVar('--yj-accent-hover')).toBe('#ffffff'); + }); + + it('swaps the whole background ramp with the shade', () => { + applyTheme('#ffd43b', 'darker'); + const darker = cssVar('--yj-bg-surface'); + + applyTheme('#ffd43b', 'light'); + + expect([darker, cssVar('--yj-bg-surface')]).toEqual(['#121212', '#f8f9fa']); + }); + + it('keeps semantic colours fixed across shades', () => { + const dark = cssVar('--yj-error'); + + applyTheme('#ffd43b', 'light'); + + expect([dark, cssVar('--yj-error')]).toEqual(['#e03131', '#e03131']); + }); +}); + +describe('theme store: document integration', () => { + it('flags dark shades to Web Awesome, which otherwise renders white surfaces', () => { + applyTheme('#ffd43b', 'dark'); + const darkFlagged = document.documentElement.classList.contains('wa-dark'); + + applyTheme('#ffd43b', 'light'); + + expect([ + darkFlagged, + document.documentElement.classList.contains('wa-dark'), + ]).toEqual([true, false]); + }); + + it('sets color-scheme so native controls and scrollbars match', () => { + applyTheme('#ffd43b', 'light'); + const light = document.documentElement.style.colorScheme; + + applyTheme('#ffd43b', 'darker'); + + expect([light, document.documentElement.style.colorScheme]).toEqual([ + 'light', + 'dark', + ]); + }); + + it('bridges the surface ramp onto Web Awesome custom properties', () => { + applyTheme('#ffd43b', 'darker'); + + expect([ + cssVar('--wa-color-surface-default'), + cssVar('--wa-color-surface-raised'), + cssVar('--wa-color-surface-lowered'), + ]).toEqual(['#000000', '#121212', '#1e1e1e']); + }); + + it('is inherited through a shadow root', () => { + applyTheme('#ff0000', 'dark'); + + const host = document.createElement('div'); + const root = host.attachShadow({ mode: 'open' }); + const inner = document.createElement('span'); + + inner.style.color = 'var(--yj-accent)'; + root.append(inner); + document.body.append(host); + + const colour = getComputedStyle(inner).color; + + host.remove(); + + expect(colour).toBe('rgb(255, 0, 0)'); + }); +}); + +describe('theme store: writes', () => { + it('sends a new accent to the backend rather than applying it locally', async () => { + applyTheme('#ffd43b', 'dark'); + await themeStore.setAccentColor('#00ff00'); + + // The backend is the writer; the store waits for ThemeConfigChanged. + expect([ + lastArgs('config.Config.SetThemeAccentColor'), + themeStore.getState().accentColor, + ]).toEqual([['#00ff00'], '#ffd43b']); + }); + + it('sends a new shade to the backend', async () => { + await themeStore.setBackgroundShade('light'); + + expect(lastArgs('config.Config.SetThemeBackgroundShade')).toEqual([ + 'light', + ]); + }); +}); diff --git a/frontend/test/stores/view-stores.test.ts b/frontend/test/stores/view-stores.test.ts new file mode 100644 index 0000000..f846575 --- /dev/null +++ b/frontend/test/stores/view-stores.test.ts @@ -0,0 +1,189 @@ +/** + * The three small stores behind view chrome: the global search term, + * the track list's column set, and the explore cache that keeps detail + * pages from re-fetching what a search already returned. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { searchStore } from '@store/search-store'; +import { trackListStore } from '@store/tracklist-store'; +import { exploreCache } from '@store/explore-cache'; +import { Events } from '../../src/events'; +import { emit, lastCall, flush } from '@test/support/harness'; + +describe('search store', () => { + beforeEach(() => { + searchStore.setTerm(''); + searchStore.setCurrentView('tracks'); + }); + + it('holds the term', () => { + searchStore.setTerm('bowie'); + + expect(searchStore.getTerm()).toBe('bowie'); + }); + + it('does not notify when the term is unchanged', () => { + let notifications = 0; + const off = searchStore.subscribe(() => { + notifications += 1; + }); + + searchStore.setTerm('bowie'); + searchStore.setTerm('bowie'); + off(); + + expect(notifications).toBe(1); + }); + + it('notifies synchronously — the search box has no batching to hide behind', () => { + let notified = false; + const off = searchStore.subscribe(() => { + notified = true; + }); + + searchStore.setTerm('x'); + off(); + + expect(notified).toBe(true); + }); + + it('knows which views the search box applies to', () => { + const searchable = [ + 'tracks', + 'albums', + 'playlists', + 'playlist-details', + 'artists', + 'genres', + ].map((view) => { + searchStore.setCurrentView(view); + + return searchStore.isSearchableView(); + }); + + expect(searchable.every(Boolean)).toBe(true); + }); + + it('hides the search box on views it cannot filter', () => { + const results = ['settings', 'explore', 'jobs', 'downloads'].map((view) => { + searchStore.setCurrentView(view); + + return searchStore.isSearchableView(); + }); + + expect(results).toEqual([false, false, false, false]); + }); +}); + +describe('track list store', () => { + it('starts from the default column set', () => { + expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0); + }); + + it('adopts the column order the backend pushes', () => { + emit(Events.TrackListConfigChanged, { + columns: [{ id: 'title' }, { id: 'artist' }], + }); + + expect(trackListStore.getState().columnIds).toEqual(['title', 'artist']); + }); + + it('sends columns back as objects, the shape the Go binding expects', async () => { + await trackListStore.setColumns(['title', 'album']); + + // A bare string array would be a type mismatch, and a Wails binding + // called with wrong argument types never settles its callback. + expect(lastCall('config.Config.SetTrackListColumns')?.args).toEqual([ + [{ id: 'title' }, { id: 'album' }], + ]); + }); + + it('does not apply a column change until the backend confirms it', async () => { + emit(Events.TrackListConfigChanged, { columns: [{ id: 'title' }] }); + await trackListStore.setColumns(['title', 'album', 'year']); + await flush(); + + expect(trackListStore.getState().columnIds).toEqual(['title']); + }); +}); + +describe('explore cache', () => { + it('round-trips an artist by mbid', () => { + exploreCache.setArtist('mbid-1', { mbid: 'mbid-1', name: 'Bowie' }); + + expect(exploreCache.getArtist('mbid-1')?.name).toBe('Bowie'); + }); + + it('misses cleanly for an unknown mbid', () => { + expect(exploreCache.getArtist('nothing-here')).toBeUndefined(); + }); + + it('refuses to key anything under an empty mbid', () => { + // An empty key would collide across every unidentified entity. + exploreCache.setArtist('', { mbid: '', name: 'Unknown' }); + exploreCache.setAlbum('', { mbid: '', title: 'X', artistName: 'Y' }); + + expect([exploreCache.getArtist(''), exploreCache.getAlbum('')]).toEqual([ + undefined, + undefined, + ]); + }); + + it('overwrites an entry with richer data from a later fetch', () => { + exploreCache.setArtist('mbid-2', { mbid: 'mbid-2', name: 'Eno' }); + exploreCache.setArtist('mbid-2', { + mbid: 'mbid-2', + name: 'Eno', + imageURL: 'http://x/eno.jpg', + }); + + expect(exploreCache.getArtist('mbid-2')?.imageURL).toBe('http://x/eno.jpg'); + }); + + it('caches an artist’s release groups and top tracks separately', () => { + exploreCache.setArtistAlbums('mbid-3', [{ mbid: 'rg-1' }] as never); + exploreCache.setArtistTopTracks('mbid-3', [{ mbid: 'rec-1' }] as never); + + expect([ + exploreCache.getArtistAlbums('mbid-3')?.length, + exploreCache.getArtistTopTracks('mbid-3')?.length, + ]).toEqual([1, 1]); + }); + + it('populates artists and albums from one search result', () => { + exploreCache.populateFromSearch( + [{ mbid: 'a-1', name: 'Artist', _imageSmall: 's.jpg' }], + [ + { + mbid: 'rg-1', + title: 'Album', + artistCredit: 'Artist', + _coverArt: 'c.jpg', + firstReleaseDate: '1977', + }, + ], + ); + + expect([ + exploreCache.getArtist('a-1')?.imageSmall, + exploreCache.getAlbum('rg-1')?.year, + exploreCache.getAlbum('rg-1')?.artistName, + ]).toEqual(['s.jpg', '1977', 'Artist']); + }); + + it('defaults a missing artist credit to empty rather than undefined', () => { + exploreCache.populateFromSearch([], [{ mbid: 'rg-2', title: 'Untitled' }]); + + expect(exploreCache.getAlbum('rg-2')?.artistName).toBe(''); + }); + + it('skips search entries that carry no mbid', () => { + exploreCache.populateFromSearch( + [{ name: 'Nameless' }], + [{ title: 'Nameless' }], + ); + + expect(exploreCache.getArtist('')).toBeUndefined(); + }); +}); diff --git a/frontend/test/support/harness.ts b/frontend/test/support/harness.ts new file mode 100644 index 0000000..3148081 --- /dev/null +++ b/frontend/test/support/harness.ts @@ -0,0 +1,71 @@ +/** + * Helpers on top of the Wails fake: pushing backend events, stubbing + * bound methods, and inspecting what the frontend called back. + */ +import { wails, type BindingCall } from './wails-fake'; + +export { wails } from './wails-fake'; + +/** + * Push a backend event into the page, exactly as `runtime.EventsEmit` + * on the Go side would. Extra arguments become the event's data array. + */ +export function emit(name: string, ...data: unknown[]): void { + wails.notify(name, data); +} + +/** + * Register the return value of a bound method. The path is the one the + * generated bindings use — `service.Type.Method`, e.g. + * `config.Config.GetShortcuts`. + * + * A function value is called with the invocation's arguments, so a stub + * can vary by input. + */ +export function stub(path: string, value: unknown): void { + wails.stub(path, value); +} + +/** + * Make a bound method fail, as a Go method returning an error does: + * the promise rejects, it does not throw into the caller. + */ +export function stubFailure(path: string, message = 'backend error'): void { + wails.stub(path, () => { + throw new Error(message); + }); +} + +/** Every call made to a bound method, in order. */ +export function calls(path?: string): BindingCall[] { + if (path === undefined) return wails.calls.slice(); + + return wails.calls.filter((c) => c.path === path); +} + +/** The most recent call to `path`, or undefined. */ +export function lastCall(path: string): BindingCall | undefined { + return calls(path).at(-1); +} + +/** The argument list of the most recent call to `path`. */ +export function lastArgs(path: string): unknown[] | undefined { + return lastCall(path)?.args; +} + +/** + * Flush pending microtasks. Stores coalesce subscriber notification + * through `queueMicrotask`, so state is observable immediately but + * subscribers are not — anything asserting on a subscriber must await + * this first. + */ +export async function flush(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +/** Clears recorded calls and stubs between tests. */ +export function resetHarness(): void { + wails.reset(); +} diff --git a/frontend/test/support/render.ts b/frontend/test/support/render.ts new file mode 100644 index 0000000..a0b1ba7 --- /dev/null +++ b/frontend/test/support/render.ts @@ -0,0 +1,123 @@ +/** + * Mounting helpers for component tests. + * + * Components are mounted into a real document and queried through their + * real (open) shadow roots — nothing here approximates the DOM, which + * is the whole reason this tier runs in a browser. + */ +import type { LitElement } from 'lit'; +import { expect } from 'vitest'; + +const mounted: HTMLElement[] = []; + +/** + * Create an element, apply properties, mount it and wait for Lit's + * first render. Properties are set as *properties*, not attributes, so + * non-string values survive. + */ +export async function fixture( + tag: string, + props: Record = {}, +): Promise { + const el = document.createElement(tag) as T; + + Object.assign(el, props); + document.body.append(el); + mounted.push(el); + + await el.updateComplete; + + return el; +} + +/** Apply properties to a mounted element and wait for the re-render. */ +export async function update( + el: T, + props: Record, +): Promise { + Object.assign(el, props); + el.requestUpdate(); + await el.updateComplete; + + return el; +} + +/** Remove everything mounted by this module. Called from setup. */ +export function cleanupFixtures(): void { + while (mounted.length > 0) mounted.pop()?.remove(); +} + +// =================================================================== +// SHADOW DOM QUERIES +// =================================================================== + +/** Query one element inside a component's shadow root. */ +export function shadow( + host: Element, + selector: string, +): E | null { + return host.shadowRoot?.querySelector(selector) ?? null; +} + +/** Query all matching elements inside a component's shadow root. */ +export function shadowAll( + host: Element, + selector: string, +): E[] { + return [...(host.shadowRoot?.querySelectorAll(selector) ?? [])]; +} + +/** Trimmed text content of the first match, or null if absent. */ +export function text(host: Element, selector: string): string | null { + return shadow(host, selector)?.textContent?.trim() ?? null; +} + +/** Trimmed text content of every match. */ +export function texts(host: Element, selector: string): string[] { + return shadowAll(host, selector).map((el) => el.textContent?.trim() ?? ''); +} + +/** The accessible names of every match, for assertions that mirror + * what a screen reader — and a Playwright selector — would see. */ +export function labels(host: Element, selector: string): string[] { + return shadowAll(host, selector).map( + (el) => el.getAttribute('aria-label') ?? '', + ); +} + +/** Click something inside a shadow root and let the update settle. */ +export async function click( + host: LitElement, + selector: string, +): Promise { + const target = shadow(host, selector); + + if (!target) throw new Error(`no element matching ${selector}`); + + target.click(); + await host.updateComplete; +} + +// =================================================================== +// VISUAL REGRESSION +// =================================================================== + +/** + * Visual regression is opt-in: `toMatchScreenshot` baselines depend on + * font hinting and compositing, so a baseline taken on one machine + * fails on another for reasons that have nothing to do with the + * component. `make ui-visual` sets YJ_VISUAL=1; the default run + * asserts behaviour only. + */ +export const visualEnabled = import.meta.env['YJ_VISUAL'] === '1'; + +/** + * Screenshot a component against its baseline, when visual regression + * is enabled. A no-op otherwise — deliberately not a skipped test, so + * the behavioural assertions around it still run. + */ +export async function visual(el: Element, name: string): Promise { + if (!visualEnabled) return; + + await expect(el).toMatchScreenshot(name); +} diff --git a/frontend/test/support/wails-fake.ts b/frontend/test/support/wails-fake.ts new file mode 100644 index 0000000..eacac94 --- /dev/null +++ b/frontend/test/support/wails-fake.ts @@ -0,0 +1,243 @@ +/** + * A fake of the two globals the Wails runtime installs: `window.runtime` + * and `window.go`. + * + * Everything in `frontend/wailsjs/` is a pure passthrough — every binding + * is `window['go'][svc][Type][Method](args)` and every runtime call is + * `window.runtime.X(...)`. So faking the globals means tests exercise the + * *real* generated bindings and the *real* store code, and there is no + * second description of the Wails layer free to drift from the first. + * + * The event dispatcher mirrors wails v2's + * `internal/frontend/runtime/desktop/events.js` exactly, including + * `maxCallbacks` expiry and the fact that `EventsEmit` notifies local JS + * listeners *before* it notifies Go. + */ + +// =================================================================== +// EVENT DISPATCH (mirrors desktop/events.js) +// =================================================================== + +type Callback = (...data: unknown[]) => void; + +class Listener { + private remaining: number; + + constructor( + readonly eventName: string, + private readonly callback: Callback, + maxCallbacks: number, + ) { + this.remaining = maxCallbacks || -1; + } + + /** Invokes the callback; returns true if this listener is spent. */ + fire(data: unknown[]): boolean { + this.callback(...data); + + if (this.remaining === -1) return false; + + this.remaining -= 1; + + return this.remaining === 0; + } +} + +/** Records one bound-method invocation. */ +export interface BindingCall { + /** Dotted path, e.g. `queue.Queue.SetQueue`. */ + path: string; + args: unknown[]; +} + +type StubValue = unknown | ((...args: unknown[]) => unknown); + +class WailsFake { + private listeners = new Map(); + private stubs = new Map(); + + /** Every bound-method call made since the last `reset()`. */ + readonly calls: BindingCall[] = []; + + /** Every runtime (non-binding) call, e.g. `WindowSetTitle`. */ + readonly runtimeCalls: BindingCall[] = []; + + // -- listener registry -- + + on(eventName: string, callback: Callback, maxCallbacks: number): () => void { + const listener = new Listener(eventName, callback, maxCallbacks); + const existing = this.listeners.get(eventName); + + if (existing) { + existing.push(listener); + } else { + this.listeners.set(eventName, [listener]); + } + + return () => this.off(eventName, listener); + } + + private off(eventName: string, listener: Listener): void { + const list = this.listeners.get(eventName); + + if (!list) return; + + const idx = list.indexOf(listener); + + if (idx >= 0) list.splice(idx, 1); + if (list.length === 0) this.listeners.delete(eventName); + } + + offNamed(eventName: string, ...more: string[]): void { + for (const name of [eventName, ...more]) { + this.listeners.delete(name); + } + } + + offAll(): void { + this.listeners.clear(); + } + + /** + * Deliver an event exactly as the backend push does. Iterates in + * reverse and drops spent listeners, like `notifyListeners`. + */ + notify(eventName: string, data: unknown[]): void { + const list = this.listeners.get(eventName); + + if (!list || list.length === 0) return; + + const snapshot = list.slice(); + + for (let i = snapshot.length - 1; i >= 0; i -= 1) { + const listener = snapshot[i]; + + if (!listener) continue; + + if (listener.fire(data)) snapshot.splice(i, 1); + } + + if (snapshot.length === 0) { + this.listeners.delete(eventName); + } else { + this.listeners.set(eventName, snapshot); + } + } + + /** Names with at least one live listener — useful for assertions. */ + listenerNames(): string[] { + return [...this.listeners.keys()].sort(); + } + + // -- binding stubs -- + + stub(path: string, value: StubValue): void { + this.stubs.set(path, value); + } + + invoke(path: string, args: unknown[]): Promise { + this.calls.push({ path, args }); + + const stub = this.stubs.get(path); + + if (typeof stub === 'function') { + // A throwing stub becomes a rejected promise, matching the real + // bridge: a Go method returning an error rejects, it does not + // throw synchronously into the caller. + try { + return Promise.resolve( + (stub as (...a: unknown[]) => unknown)(...args), + ); + } catch (err) { + return Promise.reject(err instanceof Error ? err : new Error(String(err))); + } + } + + return Promise.resolve(stub); + } + + recordRuntime(path: string, args: unknown[]): void { + this.runtimeCalls.push({ path, args }); + } + + /** Clears recorded calls and stubs. Listeners survive — the store + * singletons that registered them are never re-imported. */ + reset(): void { + this.calls.length = 0; + this.runtimeCalls.length = 0; + this.stubs.clear(); + } +} + +// =================================================================== +// GLOBAL INSTALLATION +// =================================================================== + +export const wails = new WailsFake(); + +/** A `window.go` that materialises `svc.Type.Method` lazily. */ +function makeGoProxy(): unknown { + const level = (prefix: string): unknown => + new Proxy(function () {} as unknown as Record, { + get(_target, prop: string | symbol) { + if (typeof prop !== 'string') return undefined; + + return level(prefix ? `${prefix}.${prop}` : prop); + }, + apply(_target, _thisArg, args: unknown[]) { + return wails.invoke(prefix, args); + }, + }); + + return level(''); +} + +/** A `window.runtime` with real event plumbing and recorded no-ops + * for everything else (window, clipboard, browser, log). */ +function makeRuntimeProxy(): unknown { + const real: Record = { + EventsOnMultiple: (name: string, cb: Callback, max: number) => + wails.on(name, cb, max), + EventsOn: (name: string, cb: Callback) => wails.on(name, cb, -1), + EventsOnce: (name: string, cb: Callback) => wails.on(name, cb, 1), + EventsOff: (name: string, ...more: string[]) => + wails.offNamed(name, ...more), + EventsOffAll: () => wails.offAll(), + // The real runtime notifies local JS listeners first, then Go. + EventsEmit: (name: string, ...data: unknown[]) => { + wails.recordRuntime(`EventsEmit:${name}`, data); + wails.notify(name, data); + }, + }; + + return new Proxy(real, { + get(target, prop: string | symbol) { + if (typeof prop !== 'string') return undefined; + if (prop in target) return target[prop]; + + return (...args: unknown[]) => { + wails.recordRuntime(prop, args); + + return undefined; + }; + }, + }); +} + +declare global { + interface Window { + go: unknown; + runtime: unknown; + } +} + +/** + * Installs the fake. Must run before any module that imports a store, + * because the store singletons call `EventsOn` in their constructors at + * import time. `setupFiles` runs before test modules, which is exactly + * the window we need. + */ +export function installWailsFake(): void { + window.go = makeGoProxy(); + window.runtime = makeRuntimeProxy(); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 91f61d3..0a826f8 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -31,9 +31,10 @@ "@pages/*": ["./src/pages/*"], "@runtime/*": ["./wailsjs/runtime/*"], "@utils/*": ["./src/utils/*"], - "@store/*": ["./src/store/*"] + "@store/*": ["./src/store/*"], + "@test/*": ["./test/*"] }, - "types": ["vite/client"], + "types": ["vite/client", "@vitest/browser-playwright"], // needed for Lit Elements "experimentalDecorators": true, "useDefineForClassFields": false, // allows us to define properties as class fields @@ -43,7 +44,7 @@ {"name": "typescript-lit-html-plugin"} ] }, - "include": ["src", "wailsjs"], + "include": ["src", "wailsjs", "test"], // Wails-generated JS stubs use @ts-check with untyped patterns that // violate strict mode. The companion .d.ts files provide the types. "exclude": ["wailsjs/go/**/*.js"] diff --git a/frontend/vitest.config.mts b/frontend/vitest.config.mts new file mode 100644 index 0000000..6bdbc7b --- /dev/null +++ b/frontend/vitest.config.mts @@ -0,0 +1,64 @@ +import path from 'path'; +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config.mts'; + +// Visual regression is opt-in. `toMatchScreenshot` baselines are +// sensitive to font hinting and GPU compositing, so a baseline taken on +// one machine fails on another for reasons that have nothing to do with +// the component. Behavioural assertions run everywhere; screenshots run +// where the baselines were taken (`make ui-visual`, or CI's container). +const screenshotsEnabled = process.env['YJ_VISUAL'] === '1'; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + // The Playwright *runner* lives in e2e/, a separate package, so its + // specs can never be picked up here and the two Playwright + // versions cannot collide. + include: ['test/**/*.test.ts'], + setupFiles: ['./test/setup.ts'], + globals: false, + browser: { + enabled: true, + provider: playwright(), + headless: true, + // WebKit's Linux build wants Ubuntu libraries Arch does not + // have, so it is a CI-only browser. See .planning/NOTES.md. + instances: [{ browser: 'chromium' }], + viewport: { width: 1280, height: 800 }, + screenshotFailures: false, + expect: { + toMatchScreenshot: { + comparatorName: 'pixelmatch', + comparatorOptions: { allowedMismatchedPixelRatio: 0.02 }, + }, + }, + }, + expect: { requireAssertions: true }, + env: { YJ_VISUAL: screenshotsEnabled ? '1' : '' }, + }, + resolve: { + alias: { + '@test': path.resolve(__dirname, 'test'), + }, + }, + // Pre-bundle what the components pull in, or Vite discovers it + // mid-run and reloads the page underneath a running test. + optimizeDeps: { + include: [ + 'lit', + 'lit/decorators.js', + 'lit/directives/repeat.js', + '@lit-labs/virtualizer', + // Web Awesome components are deep imports, one per element; + // the glob pre-bundles them all rather than discovering each + // one the first time a component under test imports it. + '@awesome.me/webawesome/dist/components/*/*.js', + '@awesome.me/webawesome/dist/webawesome.js', + ], + }, + }), +); diff --git a/internal/dev/devbuild.go b/internal/dev/devbuild.go index 27feac3..bf860e9 100644 --- a/internal/dev/devbuild.go +++ b/internal/dev/devbuild.go @@ -1,5 +1,7 @@ //go:build dev +// Package dev provides build-time flags for development mode. package dev -var IsDev bool = true +// IsDev indicates whether this is a development build. +var IsDev = true diff --git a/internal/testfixtures/fixtures_test.go b/internal/testfixtures/fixtures_test.go new file mode 100644 index 0000000..ba181f8 --- /dev/null +++ b/internal/testfixtures/fixtures_test.go @@ -0,0 +1,242 @@ +package testfixtures_test + +import ( + "crypto/sha256" + "encoding/hex" + "math" + "path/filepath" + "testing" + + "yellowjacket/backend/metadata" + "yellowjacket/internal/testfixtures" +) + +// durationToleranceMS is the slack allowed between the nominal length +// in the spec and what a decoder reports. Lossy encoders pad to a +// frame boundary, so exact equality is not achievable. +const durationToleranceMS = 250 + +// TestFixturesMatchManifest reads every generated fixture back with the +// application's own metadata extractor and asserts it says what the +// manifest claims. +// +// This is the check that keeps the generator honest: fixtures are +// tagged by backend/tagwriter and read by backend/metadata, so if those +// two ever disagree — a new format, a changed frame ID — it surfaces +// here rather than as a mystery in the UI. +func TestFixturesMatchManifest(t *testing.T) { + t.Parallel() + + m := testfixtures.Load(t) + + for _, want := range m.Tracks { + // WAV tags are write-only today; see + // TestWAVTagsAreNotReadableYet. + if want.Format == "wav" { + continue + } + + t.Run(want.Path, func(t *testing.T) { + t.Parallel() + + path := m.Abs(want.Path) + + got, err := metadata.ExtractTags(path) + if err != nil { + t.Fatalf("extract tags: %v", err) + } + + assertTag(t, "title", want, got.Title) + assertTag(t, "artist", want, got.Artist) + assertTag(t, "album", want, got.Album) + assertTag(t, "album_artist", want, got.AlbumArtist) + assertTag(t, "genre", want, got.Genre) + assertIntTag(t, "year", want, got.Year) + assertIntTag(t, "track_number", want, got.TrackNumber) + assertIntTag(t, "disc_number", want, got.DiscNumber) + + assertCover(t, want, got) + }) + } +} + +// TestFixtureDurationsMatchManifest decodes each fixture and checks its +// length, which is what makes seek, progress and queue-advance +// assertions meaningful elsewhere. +func TestFixtureDurationsMatchManifest(t *testing.T) { + t.Parallel() + + m := testfixtures.Load(t) + + for _, want := range m.Tracks { + t.Run(want.Path, func(t *testing.T) { + t.Parallel() + + got, err := metadata.GetTrackLengthMillis(m.Abs(want.Path)) + if err != nil { + t.Fatalf("decode duration: %v", err) + } + + if delta := math.Abs(float64(got - want.DurationMS)); delta > durationToleranceMS { + t.Errorf( + "duration: got %dms, want %dms (±%dms)", + got, want.DurationMS, durationToleranceMS, + ) + } + }) + } +} + +// TestCoverDedupFixturesShareOneImage guards the premise of the +// cover-dedup case: every track in that album must carry byte-identical +// artwork, or the dedup path is not actually under test. +func TestCoverDedupFixturesShareOneImage(t *testing.T) { + t.Parallel() + + m := testfixtures.Load(t) + + var first string + + for _, path := range m.Case(t, testfixtures.CaseCoverDedup) { + tags, err := metadata.ExtractTags(path) + if err != nil { + t.Fatalf("extract tags from %s: %v", path, err) + } + + if tags.Picture == nil { + t.Fatalf("%s: no embedded cover", filepath.Base(path)) + } + + sum := sha256.Sum256(tags.Picture.Data) + digest := hex.EncodeToString(sum[:]) + + if first == "" { + first = digest + + continue + } + + if digest != first { + t.Errorf( + "%s: cover differs from the album's first track", + filepath.Base(path), + ) + } + } +} + +// TestDuplicateFixturesAreIndistinguishable guards the premise of the +// duplicates case: the pair must agree on everything the duplicate +// detector compares, across two different formats. +func TestDuplicateFixturesAreIndistinguishable(t *testing.T) { + t.Parallel() + + m := testfixtures.Load(t) + + paths := m.Case(t, testfixtures.CaseDuplicates) + if len(paths) < 2 { + t.Fatalf("expected at least two duplicate fixtures, got %d", len(paths)) + } + + ref, err := metadata.ExtractTags(paths[0]) + if err != nil { + t.Fatalf("extract reference tags: %v", err) + } + + for _, path := range paths[1:] { + got, err := metadata.ExtractTags(path) + if err != nil { + t.Fatalf("extract tags from %s: %v", path, err) + } + + if got.Title != ref.Title || got.Artist != ref.Artist || + got.Album != ref.Album { + t.Errorf( + "%s: (%q, %q, %q) differs from reference (%q, %q, %q)", + filepath.Base(path), + got.Title, got.Artist, got.Album, + ref.Title, ref.Artist, ref.Album, + ) + } + } +} + +// TestWAVTagsAreNotReadableYet pins a known gap rather than hiding it. +// +// backend/tagwriter writes WAV tags into a RIFF "id3 " chunk, but +// backend/metadata reads through dhowden/tag, which recognises MP3, +// FLAC, OGG, MP4 and DSF and has no RIFF parser at all. So every tag +// the app writes to a WAV is invisible to the app that wrote it, and +// WAV tracks always scan in as untitled. +// +// The fixtures are tagged correctly on disk, so when the reader learns +// to unwrap the RIFF chunk this test starts failing — which is the +// point. Delete it then and drop the "wav" skip in +// TestFixturesMatchManifest. +func TestWAVTagsAreNotReadableYet(t *testing.T) { + t.Parallel() + + m := testfixtures.Load(t) + + for _, path := range m.Case(t, testfixtures.CaseWAVTracks) { + got, err := metadata.ExtractTags(path) + if err != nil { + t.Fatalf("extract tags from %s: %v", path, err) + } + + if got.Title != "" { + t.Errorf( + "%s: WAV tags are now readable (%q) — good news; "+ + "see this test's comment for what to update", + filepath.Base(path), got.Title, + ) + } + } +} + +func assertTag(t *testing.T, field string, want testfixtures.Track, got string) { + t.Helper() + + expected, _ := want.Tags[field].(string) + + if got != expected { + t.Errorf("%s: got %q, want %q", field, got, expected) + } +} + +func assertIntTag(t *testing.T, field string, want testfixtures.Track, got int) { + t.Helper() + + // JSON numbers decode as float64. + expected, _ := want.Tags[field].(float64) + + if got != int(expected) { + t.Errorf("%s: got %d, want %d", field, got, int(expected)) + } +} + +func assertCover( + t *testing.T, + want testfixtures.Track, + got *metadata.TrackMetadata, +) { + t.Helper() + + if want.CoverSHA == "" { + if got.Picture != nil { + t.Errorf("cover: got embedded artwork, want none") + } + + return + } + + if got.Picture == nil { + t.Fatalf("cover: no embedded artwork, want %s", want.CoverSHA[:12]) + } + + sum := sha256.Sum256(got.Picture.Data) + + if digest := hex.EncodeToString(sum[:]); digest != want.CoverSHA { + t.Errorf("cover: got sha %s, want %s", digest[:12], want.CoverSHA[:12]) + } +} diff --git a/internal/testfixtures/testfixtures.go b/internal/testfixtures/testfixtures.go new file mode 100644 index 0000000..f3120ae --- /dev/null +++ b/internal/testfixtures/testfixtures.go @@ -0,0 +1,190 @@ +// Package testfixtures gives tests typed access to the deterministic +// fixture library produced by cmd/gentestdata (`make testdata`). +// +// The library is gitignored and generated, so every accessor here +// skips the calling test when it is absent rather than failing: a +// clean clone must still be able to run `go test ./...`. Tests select +// fixtures by case name — the behaviour they exercise — so fixture +// paths can be renamed without touching test code. +package testfixtures + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" +) + +// ManifestName is the manifest's filename, kept outside the library +// root so the scanner never sees it. +const ManifestName = "music_library_test.manifest.json" + +// Case names, mirroring cmd/gentestdata's spec. +const ( + CaseCoverDedup = "cover-dedup" + CaseMultiDisc = "multi-disc" + CaseVariousArtist = "various-artists" + CaseFLACAlbum = "flac-album" + CaseOGGAlbum = "ogg-album" + CaseWAVTracks = "wav-tracks" + CasePartialTags = "partial-tags" + CaseUnicode = "unicode" + CaseDuplicates = "duplicates" + CaseEdgeLengths = "edge-lengths" + CaseBroken = "broken" +) + +// Track is one generated fixture, as specified rather than as encoded. +type Track struct { + Path string `json:"path"` + Case string `json:"case"` + Format string `json:"format"` + DurationMS int64 `json:"durationMs"` + FreqHz float64 `json:"freqHz"` + Cover string `json:"cover,omitempty"` + CoverSHA string `json:"coverSha,omitempty"` + Tags map[string]any `json:"tags"` +} + +// Manifest describes a generated fixture library. +type Manifest struct { + Version int `json:"version"` + Generator string `json:"generator"` + Hash string `json:"hash"` + LibraryRoot string `json:"libraryRoot"` + BrokenRoot string `json:"brokenRoot"` + Cases map[string][]string `json:"cases"` + Tracks []Track `json:"tracks"` + Extras []string `json:"extras"` + Broken []string `json:"broken"` + + repoRoot string +} + +// Root returns the absolute path of the fixture library root. +func (m *Manifest) Root() string { + return filepath.Join(m.repoRoot, filepath.FromSlash(m.LibraryRoot)) +} + +// BrokenPath returns the absolute path of the malformed-file root, +// which is deliberately a sibling of the library rather than part of +// it: the clean library's track count has to stay deterministic. +func (m *Manifest) BrokenPath() string { + return filepath.Join(m.repoRoot, filepath.FromSlash(m.BrokenRoot)) +} + +// Abs resolves a manifest-relative track path to an absolute one. +func (m *Manifest) Abs(rel string) string { + return filepath.Join(m.Root(), filepath.FromSlash(rel)) +} + +// Case returns the absolute paths belonging to a case, failing the +// test when the case is unknown — a typo should not silently pass as +// an empty set. +func (m *Manifest) Case(t *testing.T, name string) []string { + t.Helper() + + rels, ok := m.Cases[name] + if !ok { + t.Fatalf("testfixtures: unknown case %q", name) + } + + paths := make([]string, 0, len(rels)) + for _, rel := range rels { + paths = append(paths, m.Abs(rel)) + } + + return paths +} + +// Track looks up a fixture by its manifest-relative path. +func (m *Manifest) Track(t *testing.T, rel string) Track { + t.Helper() + + for _, track := range m.Tracks { + if track.Path == rel { + return track + } + } + + t.Fatalf("testfixtures: no fixture at %q", rel) + + return Track{} +} + +//nolint:gochecknoglobals // memoised manifest load, keyed to the process. +var ( + loadOnce sync.Once + loaded *Manifest +) + +// Load returns the fixture manifest, skipping the test when the +// library has not been generated (`make testdata`). +func Load(t *testing.T) *Manifest { + t.Helper() + + loadOnce.Do(func() { + loaded = load() + }) + + if loaded == nil { + t.Skip( + "testfixtures: fixture library not generated; " + + "run `make testdata`", + ) + } + + return loaded +} + +// load reads and validates the manifest, returning nil when the +// fixtures are missing or stale. +func load() *Manifest { + repo, err := repoRoot() + if err != nil { + return nil + } + + raw, err := os.ReadFile(filepath.Join(repo, "test_data", ManifestName)) + if err != nil { + return nil + } + + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + + m.repoRoot = repo + + // A manifest without its library is worse than no manifest: it + // would point every test at paths that do not exist. + if _, err := os.Stat(m.Root()); err != nil { + return nil + } + + return &m +} + +// repoRoot walks up from the working directory to the module root, so +// fixtures resolve identically from any package's test. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + + dir = parent + } +} diff --git a/lefthook.yml b/lefthook.yml index b05cce6..e6202fa 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -23,6 +23,18 @@ pre-commit: exit 1 fi + # frontend/wailsjs is generated by `wails`, not `go generate`, so the + # check above does not cover it. Takes ~1.5s. + bindings-check: + glob: "*.go" + run: ./scripts/bindings-check.sh + + # .pi/ documents make targets; a stale one sends an agent off a + # cliff with total confidence. Instant. + skill-check: + glob: "{Makefile,.pi/**/*.md}" + run: ./scripts/skill-check.sh + frontend-typecheck: glob: "frontend/**/*.{ts,tsx}" root: "frontend/" @@ -37,3 +49,9 @@ pre-push: go-mod-verify: run: go mod verify + + # The component and store tier: a real browser, no app. ~2s. + ui-test: + glob: "frontend/**/*.ts" + root: "frontend/" + run: ./node_modules/.bin/vitest run diff --git a/scripts/bindings-check.sh b/scripts/bindings-check.sh new file mode 100755 index 0000000..a2ac8ff --- /dev/null +++ b/scripts/bindings-check.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# Fails when frontend/wailsjs/ is stale against the bound Go structs. +# +# frontend/wailsjs/ is generated by `wails`, not by `go generate`, so the +# pre-commit codegen check does not cover it at all. Without this, a +# renamed Go struct field or a changed method signature first shows up at +# runtime, inside a window, as a binding that never settles. +# +# `wails generate module` builds the app with the `bindings` tag and runs +# it to dump the bindings — about three seconds, so it is cheap enough to +# gate a commit on. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +TARGET="frontend/wailsjs" + +if [ -n "$(git status --porcelain -- "$TARGET")" ]; then + echo "bindings-check: $TARGET has uncommitted changes; stage or stash them first" >&2 + git status --short -- "$TARGET" >&2 + exit 1 +fi + +go tool wails generate module -tags webkit2_41 >/dev/null 2>&1 + +# `wails generate module` rewrites the three runtime files as 755 every +# time. That is not drift, so compare content only. +if ! git -c core.fileMode=false diff --quiet -- "$TARGET"; then + echo "bindings-check: $TARGET is out of date with the Go bindings." >&2 + echo "Run 'make bindings' and stage the result." >&2 + git -c core.fileMode=false diff --stat -- "$TARGET" >&2 + exit 1 +fi + +# Restore the modes the generator churned, so the tree is left clean. +chmod 644 \ + frontend/wailsjs/runtime/runtime.js \ + frontend/wailsjs/runtime/runtime.d.ts \ + frontend/wailsjs/runtime/package.json + +echo "bindings-check: frontend/wailsjs is current" diff --git a/scripts/dev-headless.sh b/scripts/dev-headless.sh new file mode 100755 index 0000000..6bdce4b --- /dev/null +++ b/scripts/dev-headless.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# +# Start YellowJacket headless, in the background, and return. +# +# `wails dev` binds an HTTP + WebSocket dev server on :34115 that serves +# the real frontend with the real generated bindings on `window.go` and +# bridges every call and every runtime.EventsEmit to the *same* Go +# backend the desktop window uses. A browser pointed at it is not a +# mock. That is what makes this app drivable by a coding agent. +# +# Three details are load-bearing: +# +# * We run the dev *binary*, not `wails dev`. app_dev.go parses +# -devserver / -assetdir / -loglevel straight from os.Args, so a +# `go build -tags "dev webkit2_41"` binary serves the identical +# devserver with no file watcher, no rebuild supervisor and no +# reload broadcast: one process, one PID, deterministic startup. +# +# * Xvfb is not optional. devserver.Run ends in Frontend.Run(ctx), +# which opens the GTK window and blocks; no flag suppresses it. +# +# * dbus-run-session is not incidental. A private session bus makes +# backend/mediacontrols register MPRIS for real, so it becomes +# assertable with busctl. It replaces the bus, not /run/user, so +# PulseAudio still works and InitSpeaker succeeds. +# +# Usage: +# scripts/dev-headless.sh [--seed NAME|--fresh] [--port N] [--no-build] +# +set -euo pipefail + +cd "$(dirname "$0")/.." + +REPO_ROOT="$PWD" +RUN_DIR="$REPO_ROOT/.dev" +PID_FILE="$RUN_DIR/app.pid" +LOG_FILE="$RUN_DIR/app.log" +HOME_FILE="$RUN_DIR/app.home" +SEED_DIR="$RUN_DIR/seeds" +BIN="$REPO_ROOT/build/bin/yj-dev" + +PORT=34115 +SEED="" +FRESH=0 +BUILD=1 +LOG_LEVEL="${YJ_LOG_LEVEL:-debug}" +STARTUP_TIMEOUT=60 + +usage() { + sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --seed) + SEED="${2:?--seed needs a name}" + shift 2 + ;; + --fresh) + FRESH=1 + shift + ;; + --port) + PORT="${2:?--port needs a number}" + shift 2 + ;; + --no-build) + BUILD=0 + shift + ;; + -h | --help) usage 0 ;; + *) + echo "dev-headless: unknown argument: $1" >&2 + usage 2 + ;; + esac +done + +mkdir -p "$RUN_DIR" + +# ── Refuse to stack instances ──────────────────────────────────────── +# Two backends on one port fails obscurely; two backends on one YJ_HOME +# corrupts a SQLite database. Check the saved PID, not the port. +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "dev-headless: already running (pid $(cat "$PID_FILE")); " \ + "run 'make dev-stop' first" >&2 + exit 1 +fi +rm -f "$PID_FILE" + +# ── Choose the YJ_HOME ─────────────────────────────────────────────── +# A seed is a YJ_HOME that a previous run of the app produced, tarred +# up (see scripts/seed-sandbox.sh). Restoring it means starting *in* +# the app instead of on the first-run wizard, which intercepts every +# pointer event until a library exists. +if [ -n "$SEED" ] && [ "$FRESH" = 1 ]; then + echo "dev-headless: --seed and --fresh are mutually exclusive" >&2 + exit 2 +fi + +if [ -n "$SEED" ]; then + SEED_TAR="$SEED_DIR/$SEED.tar" + if [ ! -f "$SEED_TAR" ]; then + echo "dev-headless: no seed '$SEED' at $SEED_TAR" >&2 + echo " build one with: make sandbox-seed NAME=$SEED" >&2 + exit 1 + fi + + YJ_HOME="$RUN_DIR/home-$SEED" + rm -rf "$YJ_HOME" + mkdir -p "$YJ_HOME" + tar -xf "$SEED_TAR" -C "$YJ_HOME" + echo "dev-headless: restored seed '$SEED'" +elif [ "$FRESH" = 1 ]; then + # Deliberately empty: the first-run wizard is itself a surface + # that needs testing. + YJ_HOME="$RUN_DIR/home-fresh" + rm -rf "$YJ_HOME" + mkdir -p "$YJ_HOME" + echo "dev-headless: fresh YJ_HOME (expect the first-run wizard)" +else + YJ_HOME="${YJ_HOME:-$RUN_DIR/home}" + mkdir -p "$YJ_HOME" +fi + +export YJ_HOME +echo "$YJ_HOME" >"$HOME_FILE" + +# ── Build ──────────────────────────────────────────────────────────── +if [ "$BUILD" = 1 ]; then + echo "dev-headless: building frontend + dev binary..." + (cd frontend && pnpm install --silent && pnpm build >/dev/null) + go build -tags "dev webkit2_41" -o "$BIN" . +fi + +if [ ! -x "$BIN" ]; then + echo "dev-headless: $BIN missing; drop --no-build" >&2 + exit 1 +fi + +# ── Launch ─────────────────────────────────────────────────────────── +# setsid puts the app in its own process group so dev-stop can kill the +# whole tree (xvfb-run, dbus-daemon, the app) by group id. Never +# `pkill -f`: the pattern matches the invoking shell's own command line +# and silently drops the rest of the chain. +: >"$LOG_FILE" + +# YJ_TESTCTL mounts backend/testctl's /__test/ endpoints. It is opt-in +# rather than implied by the dev build so that a human's `make dev` does +# not carry an arbitrary-SQL endpoint on a listening port. +YJ_TESTCTL=1 \ + YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- xvfb-run -a \ + "$BIN" \ + -devserver "localhost:$PORT" \ + -assetdir "$REPO_ROOT/frontend/dist" \ + -loglevel Debug \ + >>"$LOG_FILE" 2>&1 & + +APP_PID=$! +echo "$APP_PID" >"$PID_FILE" + +# ── Wait for the dev server ────────────────────────────────────────── +deadline=$((SECONDS + STARTUP_TIMEOUT)) +until curl -sf -o /dev/null "http://localhost:$PORT/"; do + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "dev-headless: app exited during startup; last log lines:" >&2 + tail -n 30 "$LOG_FILE" >&2 + rm -f "$PID_FILE" + exit 1 + fi + + if [ "$SECONDS" -ge "$deadline" ]; then + echo "dev-headless: :$PORT did not answer within ${STARTUP_TIMEOUT}s" >&2 + tail -n 30 "$LOG_FILE" >&2 + exit 1 + fi + + sleep 0.25 +done + +cat < window.__yjEvents.call('queue.Queue.GetState', [], 5000)" + +A binding call that never settles means wrong argument types: the +backend logs 'error parsing arguments' and never fires the callback. +The app log is the only place that shows up, so always use a timeout. +EOF diff --git a/scripts/dev-stop.sh b/scripts/dev-stop.sh new file mode 100755 index 0000000..9260446 --- /dev/null +++ b/scripts/dev-stop.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Stop the headless app started by scripts/dev-headless.sh. +# +# Kills the saved process group, never `pkill -f`: a pkill pattern that +# appears in the invoking compound command's own command line matches +# the invoking shell, kills it, and silently drops everything after it. +# +# SIGTERM first, so OnBeforeClose / OnShutdown run and player and queue +# state are persisted — a seed built from an SIGKILLed app is a seed +# missing exactly the state those hooks write. +# +set -euo pipefail + +cd "$(dirname "$0")/.." + +PID_FILE=".dev/app.pid" +GRACE=10 + +if [ ! -f "$PID_FILE" ]; then + echo "dev-stop: nothing running (no $PID_FILE)" + exit 0 +fi + +PID="$(cat "$PID_FILE")" + +if ! kill -0 "$PID" 2>/dev/null; then + echo "dev-stop: pid $PID already gone" + rm -f "$PID_FILE" + exit 0 +fi + +# setsid made the app a process group leader, so -PID reaches the app, +# xvfb-run and the private dbus-daemon together. +kill -TERM -- "-$PID" 2>/dev/null || kill -TERM "$PID" + +deadline=$((SECONDS + GRACE)) +while kill -0 "$PID" 2>/dev/null; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "dev-stop: pid $PID ignored SIGTERM after ${GRACE}s, killing" + kill -KILL -- "-$PID" 2>/dev/null || kill -KILL "$PID" + break + fi + + sleep 0.2 +done + +rm -f "$PID_FILE" +echo "dev-stop: stopped $PID" diff --git a/scripts/seed-sandbox.sh b/scripts/seed-sandbox.sh new file mode 100755 index 0000000..a73a801 --- /dev/null +++ b/scripts/seed-sandbox.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# Build a seeded YJ_HOME snapshot by RUNNING THE APP. +# +# The point of a seed is to start a harness run *inside* the app rather +# than on the first-run wizard, which intercepts every pointer event +# until a library exists. The wizard's dismissal condition is not a +# config file — it is `GetAllLibrariesWithTrackCounts()` returning a +# non-empty list — so the only honest way to produce that state is to +# call the real AddLibrary binding and let the real scanner finish. +# +# Hand-writing a config.toml and DB rows would be a second description +# of a valid YJ_HOME, free to drift from what the app actually writes. +# That is the failure mode .planning/NOTES.md records for the old +# migration chain, and it is not worth repeating for seeds. +# +# Usage: +# scripts/seed-sandbox.sh [--name NAME] [--port N] [--no-build] +# +set -euo pipefail + +cd "$(dirname "$0")/.." + +REPO_ROOT="$PWD" +RUN_DIR="$REPO_ROOT/.dev" +SEED_DIR="$RUN_DIR/seeds" +LOG_FILE="$RUN_DIR/app.log" +MANIFEST="$REPO_ROOT/test_data/music_library_test.manifest.json" + +NAME="default" +PORT=34115 +SESSION="yj-seed" +BUILD_ARGS=() +SCAN_TIMEOUT=180 + +while [ $# -gt 0 ]; do + case "$1" in + --name) + NAME="${2:?--name needs a value}" + shift 2 + ;; + --port) + PORT="${2:?--port needs a number}" + shift 2 + ;; + --no-build) + BUILD_ARGS+=(--no-build) + shift + ;; + *) + echo "seed-sandbox: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "seed-sandbox: $1 not found in PATH" >&2 + exit 1 + } +} +need playwright-cli +need jq + +if [ ! -f "$MANIFEST" ]; then + echo "seed-sandbox: fixtures missing; run 'make testdata'" >&2 + exit 1 +fi + +LIBRARY_DIR="$REPO_ROOT/$(jq -r .libraryRoot "$MANIFEST")" +WANT_TRACKS="$(jq '.tracks | length' "$MANIFEST")" +FIXTURE_HASH="$(jq -r .hash "$MANIFEST")" + +cleanup() { + playwright-cli -s="$SESSION" close >/dev/null 2>&1 || true + ./scripts/dev-stop.sh >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "seed-sandbox: building '$NAME' from $WANT_TRACKS fixture tracks" + +# Start on an empty YJ_HOME: seeding must exercise the same first-run +# path a real install takes. +# +# YJ_CORE_INDEX_URL points at a dead address on purpose. A seed must +# not reach for the real explore artifact — that is a minute of network +# per seed, and it makes the result depend on what the artifact server +# happened to be serving that day. +YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" \ + ./scripts/dev-headless.sh --fresh --port "$PORT" "${BUILD_ARGS[@]}" + +YJ_HOME="$(cat "$RUN_DIR/app.home")" + +playwright-cli -s="$SESSION" open "http://localhost:$PORT" >/dev/null + +# Every binding call gets a timeout. A call with wrong argument types +# makes the backend log 'error parsing arguments' and never fire the +# callback, so the in-page promise never settles and a naive await +# hangs forever. +call() { + playwright-cli -s="$SESSION" eval "async () => { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('binding timeout')), 15000)); + return await Promise.race([(async () => { $1 })(), timeout]); + }" +} + +echo "seed-sandbox: registering library $LIBRARY_DIR" + +if ! call "return await window.go.library.Library.AddLibrary( + ${LIBRARY_DIR@Q});" >/dev/null; then + echo "seed-sandbox: AddLibrary failed; app log:" >&2 + tail -n 40 "$LOG_FILE" >&2 + exit 1 +fi + +# Wait on the observable outcome — the track count the app itself +# reports — rather than on a fixed sleep or a scan event. This also +# validates the manifest against the real scanner: if the two disagree, +# a fixture is not being ingested and the seed is wrong. +echo "seed-sandbox: waiting for the scan to reach $WANT_TRACKS tracks" + +# The result is tagged rather than scraped for bare digits: +# playwright-cli echoes the evaluated source back, and that source +# contains numbers of its own (the binding timeout, for one). +deadline=$((SECONDS + SCAN_TIMEOUT)) +got=0 + +while [ "$SECONDS" -lt "$deadline" ]; do + got="$(call "const libs = + await window.go.library.Library.GetAllLibrariesWithTrackCounts(); + const total = (libs ?? []).reduce( + (n, l) => n + (l.trackCount ?? 0), 0); + return 'YJTRACKS' + '=' + total;" | + grep -oE 'YJTRACKS=[0-9]+' | head -n 1 | cut -d= -f2)" + got="${got:-0}" + + [ "$got" = "$WANT_TRACKS" ] && break + + sleep 1 +done + +if [ "$got" != "$WANT_TRACKS" ]; then + echo "seed-sandbox: scan settled at $got/$WANT_TRACKS tracks" >&2 + echo " (a fixture the scanner rejects, or a scan still running)" >&2 + tail -n 40 "$LOG_FILE" >&2 + exit 1 +fi + +playwright-cli -s="$SESSION" close >/dev/null 2>&1 || true + +# SIGTERM, so OnBeforeClose / OnShutdown persist window, player and +# queue state. A seed built from a killed app is missing exactly the +# state those hooks write. +./scripts/dev-stop.sh + +mkdir -p "$SEED_DIR" +tar -cf "$SEED_DIR/$NAME.tar" -C "$YJ_HOME" . + +cat >"$SEED_DIR/$NAME.json" <` mentioned under .pi/ and asserts the +# target exists. Usage: scripts/skill-check.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +[ -d .pi ] || exit 0 + +# `make -pq` prints the database including every rule, without running +# anything. It exits non-zero when a target is out of date, and under +# `pipefail` that would sink the whole assignment, so swallow it. +targets="$({ make -pqRr 2>/dev/null || true; } | + awk '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {sub(/:.*/, "", $0); print}' | + sort -u)" + +# A mention counts only when it is code: backticked (`make ui-test`) or +# the first thing on a line, as in a fenced block. Bare prose is not +# scanned, because English says things like "a renamed make target". +mentioned="$(grep -rhoE '(`|^)make [a-z][a-z0-9-]*' .pi --include='*.md' | + sed 's/^`//' | awk '{print $2}' | sort -u)" + +missing="" + +for t in $mentioned; do + if ! printf '%s\n' "$targets" | grep -qx -- "$t"; then + missing="$missing $t" + fi +done + +if [ -n "$missing" ]; then + echo "skill-check: .pi/ documents make targets that do not exist:" >&2 + for t in $missing; do + echo " make $t" >&2 + grep -rln "make $t" .pi --include='*.md' | sed 's/^/ /' >&2 + done + echo "Fix the docs, or restore the target." >&2 + exit 1 +fi + +echo "skill-check: $(printf '%s\n' "$mentioned" | wc -w) documented make targets, all present"