Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
# Browser Tests
This directory contains Playwright browser tests for Slopsmith keyboard shortcuts.
## Prerequisites
1. **Slopsmith web server**: Tests need the server reachable at `http://localhost:8000`.
Playwright auto-starts it via `webServer.command` in `playwright.config.ts`, so manual
startup is optional. Start it manually if you want to debug the server, run tests
outside Playwright, or skip the per-run boot delay:
```bash
LIBRARY_PATH=/path/to/your/library docker compose up -d
```
Playwright reuses an already-running server locally (`reuseExistingServer: true`).
2. **Node.js installed**: Required for running Playwright tests
```bash
node --version # Should be v18 or higher
```
## Installation
Install dependencies:
```bash
npm install
```
Install Playwright browsers:
```bash
npm run install:playwright
```
## Running Tests
Run all tests:
```bash
npm test
```
Run tests in headed mode (watch the browser):
```bash
npm run test:headed
```
Debug tests with interactive inspector:
```bash
npm run test:debug
```
## Test Files
- `basic-load.spec.ts` - Basic app load and shortcut registry availability
- `check-errors.spec.ts` - Check for console errors
- `keyboard-shortcuts.spec.ts` - Comprehensive keyboard shortcut tests
## Writing Tests
Tests use Playwright's test API. Example:
```typescript
import { test, expect } from '@playwright/test';
test('my test', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active');
// Interact with the page
await page.keyboard.press('?');
// Assert
await expect(page.locator('#shortcuts-modal')).toBeVisible();
});
```
## Troubleshooting
### Container won't start
If the Docker container exits immediately, check the logs:
```bash
docker compose logs
```
Common issue: Missing dependencies. Rebuild the container:
```bash
docker compose build --no-cache
docker compose up -d
```
### Tests timeout
Increase timeout in `playwright.config.ts` if needed.
### LIBRARY_PATH not set
Make sure to set the LIBRARY_PATH environment variable:
```bash
LIBRARY_PATH=~/slopsmith-library docker compose up -d
```
## CI/CD
In CI environments, Playwright will:
- Run tests in headless mode
- Retry failed tests up to 2 times
- Generate HTML report with screenshots/videos on failure
@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test('legacy audio fader and analyser bridges stay visible in browser diagnostics', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const result = await page.evaluate(() => {
const appWindow = window as any;
let faderValue = 1;
appWindow.slopsmith.audio.registerFader({
id: 'browser-smoke',
label: 'Browser Smoke',
min: 0,
max: 1,
step: 0.1,
defaultValue: 1,
getValue: () => faderValue,
setValue: (value: number) => { faderValue = value; },
});
appWindow.slopsmith.audioSession.recordBridgeHit({
domain: 'audio-mix',
bridgeId: 'audio-mix.analyser',
legacySurface: 'browser smoke analyser',
participantId: 'highway_3d',
});
const snapshot = appWindow.slopsmith.audioSession.snapshot();
const diagnostics = appWindow.slopsmith.capabilities.snapshotDiagnostics();
return {
hasFader: snapshot.domains['audio-mix'].participants.some((entry: any) => entry.participantId === 'fader.browser-smoke'),
hasAnalyserBridge: snapshot.domains['audio-mix'].bridges.some((entry: any) => entry.bridgeId === 'audio-mix.analyser'),
shimHit: diagnostics.compatibilityShims.some((entry: any) => entry.shimId === 'audio-mix.analyser' && entry.hitCount >= 1),
};
});
expect(result.hasFader).toBe(true);
expect(result.hasAnalyserBridge).toBe(true);
expect(result.shimHit).toBe(true);
});
+20
View File
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test('audio session runtime is available on page load', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const snapshot = await page.evaluate(() => {
const appWindow = window as any;
if (!appWindow.slopsmith?.audioSession?.snapshot) {
throw new Error('audioSession host not available');
}
return appWindow.slopsmith.audioSession.snapshot();
});
expect(snapshot.schema).toBe('slopsmith.audio_session.diagnostics.v1');
expect(snapshot.domains['audio-mix']).toBeTruthy();
expect(snapshot.domains['audio-input']).toBeTruthy();
expect(snapshot.domains['audio-monitoring']).toBeTruthy();
expect(snapshot.domains.stems).toBeTruthy();
});
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';
test('app loads', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// Check if the page loaded
const title = await page.title();
expect(title).toBe('Slopsmith');
});
test('check if window has any shortcuts', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// Wait a bit for JS to load
await page.waitForTimeout(2000);
// Check if the keyboard shortcuts system is loaded
const hasShortcuts = await page.evaluate(() => {
return typeof window._listShortcuts === 'function';
});
expect(hasShortcuts).toBe(true);
});
@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';
test('capability inspector renders runtime snapshot', async ({ page }) => {
await page.goto('/');
// Plugins load asynchronously after the initial HTML, so `.screen.active`
// (already satisfied by the home screen) is not a sufficient signal. Wait
// until showScreen exists AND the bundled inspector plugin's screen has been
// injected before navigating, otherwise showScreen() targets a missing id
// and the test races.
await page.waitForFunction(() => {
const appWindow = window as any;
return typeof appWindow.showScreen === 'function'
&& document.getElementById('plugin-capability_inspector') !== null;
}, { timeout: 30000 });
// showScreen is async; await it so navigation completes before we assert.
await page.evaluate(async () => {
await (window as any).showScreen('plugin-capability_inspector');
});
await page.waitForSelector('#capability-inspector-content', { timeout: 10000 });
await expect(page.locator('#capability-inspector-summary')).toContainText('domains', { timeout: 10000 });
await expect(page.locator('#capability-inspector-content')).toContainText('playback', { timeout: 10000 });
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
test('check for console errors', async ({ page }) => {
const errors: string[] = [];
const logs: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
logs.push(`[${msg.type()}] ${msg.text()}`);
});
page.on('pageerror', error => {
errors.push(`PAGE ERROR: ${error.message}\n${error.stack}`);
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// Wait for JS to load
await page.waitForTimeout(3000);
console.log('=== Console Errors ===');
errors.forEach(e => console.log(e));
console.log('=== All Logs ===');
logs.forEach(l => console.log(l));
// Assert no unexpected errors
const allowedErrors = ['favicon.ico']; // benign 404s and known noise
const unexpected = errors.filter(e => !allowedErrors.some(a => e.includes(a)));
console.log('Unexpected errors:', unexpected);
expect(unexpected).toEqual([]);
});
test('audio mixer opens with audio-mix command-backed controls', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('#btn-mixer', { state: 'attached', timeout: 10000 });
await page.evaluate(() => window.slopsmith?.audio?.openMixer?.());
await expect(page.locator('#mixer-popover')).not.toHaveClass(/hidden/);
const faderState = await page.evaluate(async () => {
const api = window.slopsmith?.capabilities;
if (!api?.command) return { outcome: 'no-owner' };
const result = await api.command('audio-mix', 'list-faders', { requester: 'browser-smoke' });
return { outcome: result.outcome, count: result.payload?.faders?.length || 0 };
});
expect(faderState.outcome).toBe('handled');
expect(faderState.count).toBeGreaterThan(0);
await expect(page.locator('#mixer-popover .mixer-strip').first()).toBeAttached();
});
@@ -0,0 +1,220 @@
import { test, expect } from '@playwright/test';
interface SettingsPayload {
dlc_dir: string;
default_arrangement: string;
demucs_server_url: string;
master_difficulty: number;
av_offset_ms: number;
}
interface SettingsPostPayload {
default_arrangement?: string;
[key: string]: unknown;
}
const settingsPayload: SettingsPayload = {
dlc_dir: '',
default_arrangement: 'Rhythm',
demucs_server_url: '',
master_difficulty: 100,
av_offset_ms: 0,
};
test.beforeEach(async ({ page }) => {
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
await route.continue();
});
});
test('settings labels auto arrangement as most notes', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('#default-arrangement', { state: 'attached' });
const labels = await page.locator('#default-arrangement option').allTextContents();
expect(labels).toContain('Most notes (auto)');
expect(labels).not.toContain('Auto (most notes)');
});
test('player arrangement pin saves the selected arrangement name', async ({ page }) => {
const settingsPosts: SettingsPostPayload[] = [];
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
settingsPosts.push(route.request().postDataJSON());
await route.fulfill({ json: { message: 'Settings saved' } });
});
await page.goto('/');
await page.waitForSelector('#arr-select', { state: 'attached' });
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.showScreen('player');
const arrangements = [
{ index: 0, name: 'Lead', notes: 420 },
{ index: 1, name: 'Rhythm', notes: 553 },
{ index: 2, name: 'Bass', notes: 386 },
];
const select = document.getElementById('arr-select') as HTMLSelectElement;
select.innerHTML = arrangements
.map(a => `<option value="${a.index}">${a.name} (${a.notes})</option>`)
.join('');
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
};
// @ts-ignore - browser app namespace
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
});
const pin = page.locator('#arr-default-pin');
await expect(pin).toBeVisible();
await expect(pin).toHaveAttribute('aria-pressed', 'false');
await expect(pin).toHaveAttribute('aria-label', 'Make Bass the default for new songs');
await expect(pin).toHaveAttribute('title', 'Make Bass the default for new songs');
await expect.poll(async () => (
await page.locator('#arr-default-pin').evaluate(el => el.previousElementSibling?.id)
)).toBe('arr-select');
await pin.click();
await expect.poll(() => settingsPosts.length).toBe(1);
expect(settingsPosts[0]).toEqual({ default_arrangement: 'Bass' });
await expect(pin).toHaveAttribute('aria-pressed', 'true');
await expect(pin).toHaveAttribute('aria-label', 'Bass is the default arrangement');
await expect(pin).toHaveAttribute('title', 'Bass is the default arrangement');
await expect(page.locator('#default-arrangement')).toHaveValue('Bass');
await pin.click();
const unexpectedPost = page
.waitForRequest(
req => req.url().includes('/api/settings') && req.method() === 'POST',
{ timeout: 300 }
)
.then(() => true)
.catch(() => false);
expect(await unexpectedPost).toBe(false);
expect(settingsPosts).toHaveLength(1);
});
test('player arrangement pin preserves non-built-in arrangement names', async ({ page }) => {
const settingsPosts: SettingsPostPayload[] = [];
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
settingsPosts.push(route.request().postDataJSON());
await route.fulfill({ json: { message: 'Settings saved' } });
});
await page.goto('/');
await page.waitForSelector('#arr-select', { state: 'attached' });
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.showScreen('player');
const arrangements = [
{ index: 0, name: 'Lead', notes: 420 },
{ index: 1, name: 'Rhythm', notes: 553 },
{ index: 2, name: 'Combo', notes: 610 },
];
const select = document.getElementById('arr-select') as HTMLSelectElement;
select.innerHTML = arrangements
.map(a => `<option value="${a.index}">${a.name} (${a.notes})</option>`)
.join('');
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
};
// @ts-ignore - browser app namespace
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
});
await page.locator('#arr-default-pin').click();
await expect.poll(() => settingsPosts.length).toBe(1);
expect(settingsPosts[0]).toEqual({ default_arrangement: 'Combo' });
await expect(page.locator('#default-arrangement')).toHaveValue('Combo');
await expect(page.locator('#default-arrangement option[value="Combo"]')).toHaveText('Combo (saved default)');
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.saveSettings();
});
await expect.poll(() => settingsPosts.length).toBe(2);
expect(settingsPosts[1]).toMatchObject({ default_arrangement: 'Combo' });
});
test('failed settings save does not mark arrangement default as persisted', async ({ page }) => {
const settingsPosts: SettingsPostPayload[] = [];
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
settingsPosts.push(route.request().postDataJSON());
await route.fulfill({ status: 500, json: { error: 'settings write failed' } });
});
await page.goto('/');
await page.waitForSelector('#arr-select', { state: 'attached' });
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.showScreen('player');
const arrangements = [
{ index: 0, name: 'Lead', notes: 420 },
{ index: 1, name: 'Rhythm', notes: 553 },
{ index: 2, name: 'Bass', notes: 386 },
];
const select = document.getElementById('arr-select') as HTMLSelectElement;
select.innerHTML = arrangements
.map(a => `<option value="${a.index}">${a.name} (${a.notes})</option>`)
.join('');
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
};
// @ts-ignore - browser app namespace
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
});
const pin = page.locator('#arr-default-pin');
await expect(pin).toHaveAttribute('aria-pressed', 'false');
await page.evaluate(() => {
// @ts-ignore - browser app helper
document.getElementById('default-arrangement').value = 'Bass';
// @ts-ignore - browser app helper
window.saveSettings();
});
await expect.poll(() => settingsPosts.length).toBe(1);
expect(settingsPosts[0]).toMatchObject({ default_arrangement: 'Bass' });
await expect(page.locator('#settings-status')).toHaveText('settings write failed');
await expect(pin).toHaveAttribute('aria-pressed', 'false');
await expect(pin).toHaveAttribute('aria-label', 'Make Bass the default for new songs');
await expect(pin).toHaveAttribute('title', 'Make Bass the default for new songs');
});
+120
View File
@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
test('left-handed setting reaches the 3D Highway renderer with a mocked song stream', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', error => {
errors.push(`PAGE ERROR: ${error.message}`);
});
await page.addInitScript(() => {
localStorage.setItem('lefty', '1');
localStorage.setItem('vizSelection', 'highway_3d');
class MockHighwayWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
readyState = MockHighwayWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor(url: string) {
this.url = url;
setTimeout(() => {
this.readyState = MockHighwayWebSocket.OPEN;
this.onopen?.(new Event('open'));
this.emitSong();
}, 0);
}
send() {}
close() {
this.readyState = MockHighwayWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
private emit(payload: unknown) {
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }));
}
private emitSong() {
this.emit({
type: 'song_info',
artist: 'Smoke Test',
title: 'Lefty Highway',
arrangement: 'Lead',
arrangement_smart_name: 'Lead',
arrangement_index: 0,
naming_mode: 'smart',
tuning: [0, 0, 0, 0, 0, 0],
stringCount: 6,
duration: 30,
audio_url: null,
});
this.emit({ type: 'beats', data: [{ time: 0 }, { time: 1 }] });
this.emit({ type: 'sections', data: [] });
this.emit({ type: 'anchors', data: [{ time: 0, fret: 1, width: 4 }] });
this.emit({ type: 'chord_templates', data: [] });
this.emit({ type: 'notes', data: [{ t: 1, s: 0, f: 3, d: 0 }] });
this.emit({ type: 'chords', data: [] });
this.emit({ type: 'handshapes', data: [] });
this.emit({ type: 'ready' });
}
}
Object.assign(MockHighwayWebSocket, {
CONNECTING: MockHighwayWebSocket.CONNECTING,
OPEN: MockHighwayWebSocket.OPEN,
CLOSING: MockHighwayWebSocket.CLOSING,
CLOSED: MockHighwayWebSocket.CLOSED,
});
window.WebSocket = MockHighwayWebSocket as unknown as typeof WebSocket;
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.waitForFunction(() => typeof (window as any).playSong === 'function' && !!(window as any).highway);
await page.waitForFunction(() => {
const picker = document.getElementById('viz-picker') as HTMLSelectElement | null;
return picker?.value === 'highway_3d';
});
await expect(page.locator('#viz-picker')).toHaveValue('highway_3d');
await page.evaluate(() => {
(window as any).__h3dReadySeen = false;
(window as any).slopsmith.on('viz:renderer:ready', () => {
(window as any).__h3dReadySeen = true;
}, { once: true });
});
await page.evaluate(() => (window as any).playSong('mock-lefty.sloppak', 0));
await page.waitForSelector('#player.active', { timeout: 10000 });
await page.waitForFunction(() => (window as any).highway?.getSongInfo?.().title === 'Lefty Highway');
await page.waitForFunction(() => (window as any).__h3dReadySeen === true, { timeout: 10000 });
await expect.poll(
async () => page.evaluate(() => !(window as any).highway.isDefaultRenderer()),
{ timeout: 5000 },
).toBe(true);
await expect.poll(
async () => page.evaluate(() => (window as any).highway.getLefty()),
{ timeout: 5000 },
).toBe(true);
await page.evaluate(() => (window as any).showScreen('settings'));
await page.waitForSelector('#settings.active', { timeout: 5000 });
await expect(page.locator('#setting-lefty')).toBeChecked();
const allowedErrors = ['favicon.ico'];
expect(errors.filter(e => !allowedErrors.some(allowed => e.includes(allowed)))).toEqual([]);
});
+706
View File
@@ -0,0 +1,706 @@
import { test, expect } from '@playwright/test';
async function openPlayerWithMockSong(page) {
await page.evaluate(() => {
const messages = [
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
{ type: 'ready' },
];
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen = null;
onmessage = null;
onerror = null;
onclose = null;
url;
constructor(url) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen(new Event('open'));
for (const message of messages) {
if (this.onmessage) this.onmessage({ data: JSON.stringify(message) });
}
}, 0);
}
send() {}
close() {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) this.onclose(new CloseEvent('close'));
}
}
// @ts-ignore
window.WebSocket = MockWebSocket;
});
await page.evaluate(async () => {
// @ts-ignore
await window.playSong('mock-song.sloppak');
});
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
}
test.describe('Keyboard Shortcuts', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
});
test.afterEach(async ({ page }) => {
// Clean up any modals
await page.evaluate(() => {
const modal = document.getElementById('shortcuts-modal');
if (modal) modal.remove();
});
});
test('should have shortcut registry available', async ({ page }) => {
const hasRegistry = await page.evaluate(() => {
// @ts-ignore
return typeof window._listShortcuts === 'function';
});
expect(hasRegistry).toBe(true);
});
test('should list all registered shortcuts', async ({ page }) => {
const shortcuts = await page.evaluate(() => {
// @ts-ignore
window._listShortcuts();
// @ts-ignore
const activePanel = window._panels.get(window.getActiveShortcutPanel());
if (activePanel) {
return Array.from(activePanel.shortcuts.values()).map((s: any) => ({
key: s.key, scope: s.scope
}));
}
return [];
});
console.log('Registered shortcuts:', shortcuts);
// Assert every built-in is present rather than an exact count, so adding a
// shortcut elsewhere doesn't break this test for the wrong reason.
const required = [
{ key: '?', scope: 'global' },
{ key: '/', scope: 'library' },
{ key: 'c', scope: 'library' },
{ key: 'f', scope: 'library' },
{ key: 'e', scope: 'library' },
{ key: 'Space', scope: 'player' },
{ key: 'ArrowLeft', scope: 'player' },
{ key: 'ArrowRight', scope: 'player' },
{ key: 'Escape', scope: 'player' },
{ key: 'Escape', scope: 'settings' },
{ key: '[', scope: 'player' },
{ key: ']', scope: 'player' },
];
for (const r of required) {
expect(shortcuts).toContainEqual(r);
}
expect(shortcuts.filter(s => s.key === '?' && s.scope === 'global')).toHaveLength(1);
});
test('should have global ? shortcut for help', async ({ page }) => {
await page.keyboard.press('?');
const modal = page.locator('#shortcuts-modal');
await expect(modal).toBeVisible({ timeout: 5000 });
// Title should be "Keyboard shortcuts"
await expect(modal.locator('h3')).toContainText('Keyboard shortcuts');
});
test('should show library shortcuts in help modal', async ({ page }) => {
await page.keyboard.press('?');
const modal = page.locator('#shortcuts-modal');
// Library shortcuts should be visible on library screen
await expect(modal).toContainText('Focus search');
await expect(modal).toContainText('/');
await expect(modal).toContainText('Convert library entry');
await expect(modal).toContainText('c');
// Player shortcuts should NOT be visible on library screen
await expect(modal).not.toContainText('Play/Pause');
});
test('should have correct shortcut scopes', async ({ page }) => {
const shortcuts = await page.evaluate(() => {
// @ts-ignore
const shortcuts = [];
// @ts-ignore
const activePanel = window._panels.get(window.getActiveShortcutPanel());
if (activePanel) {
for (const [, s] of activePanel.shortcuts) {
shortcuts.push({ key: s.key, scope: s.scope, description: s.description });
}
}
return shortcuts;
});
const expectedShortcuts = [
{ key: '?', scope: 'global' },
{ key: '/', scope: 'library' },
{ key: 'c', scope: 'library' },
{ key: 'f', scope: 'library' },
{ key: 'e', scope: 'library' },
{ key: 'Space', scope: 'player' },
{ key: 'ArrowLeft', scope: 'player' },
{ key: 'ArrowRight', scope: 'player' },
{ key: 'Escape', scope: 'player' },
{ key: 'Escape', scope: 'settings' },
{ key: '[', scope: 'player' },
{ key: ']', scope: 'player' },
];
for (const expected of expectedShortcuts) {
const found = shortcuts.find(s => s.key === expected.key && s.scope === expected.scope);
expect(found, `expected shortcut ${expected.scope}::${expected.key}`).toBeDefined();
}
});
test('should trigger ? shortcut on library screen', async ({ page }) => {
// On library screen
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
});
test('should focus library search for unshifted / without opening shortcut help', async ({ page }) => {
await page.keyboard.press('/');
await expect(page.locator('#lib-filter')).toBeFocused();
await expect(page.locator('#shortcuts-modal')).toHaveCount(0);
});
test('should not open shortcut help for Shift+Slash while typing in search', async ({ page }) => {
await page.locator('#lib-filter').focus();
await page.evaluate(() => {
const input = document.getElementById('lib-filter');
input?.dispatchEvent(new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
await expect(page.locator('#lib-filter')).toBeFocused();
await expect(page.locator('#shortcuts-modal')).toHaveCount(0);
});
test('Linux Shift+Slash on the library opens help without focusing search behind it (#602)', async ({ page }) => {
// Linux/Electron reports Shift+/ as key='/', code='Slash'. The help
// handler must open the modal AND stop the event so the shortcut
// registry's plain `/` library-search shortcut can't also fire and pull
// focus to #lib-filter behind the modal (regression for the Copilot
// review finding on this PR).
await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#lib-filter')).not.toBeFocused();
});
test('should trigger ? shortcut on player screen', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
});
test('should trigger shortcut help on player screen for Linux Electron Shift+Slash event', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
});
test('should trigger shortcut help on player screen when visualization picker is focused', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.locator('#viz-picker').focus();
await expect(page.locator('#viz-picker')).toBeFocused();
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
});
test('should trigger ? shortcut on settings screen', async ({ page }) => {
// Navigate to settings
await page.click('text=Settings');
await page.waitForSelector('#settings.active', { timeout: 5000 });
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
// Should show Settings section with Esc to go back
await expect(page.locator('#shortcuts-modal')).toContainText('Settings');
await expect(page.locator('#shortcuts-modal')).toContainText('Go back to previous screen');
// Should NOT show Library or Global shortcuts
await expect(page.locator('#shortcuts-modal')).not.toContainText('Focus search');
await expect(page.locator('#shortcuts-modal')).not.toContainText('Show keyboard shortcuts');
});
test('should close modal on Close button', async ({ page }) => {
await page.keyboard.press('?');
const modal = page.locator('#shortcuts-modal');
await expect(modal).toBeVisible();
// Click the close button (SVG icon)
await page.click('#shortcuts-modal button[data-shortcuts-close]');
await expect(modal).not.toBeVisible();
});
test('should unregister shortcut', async ({ page }) => {
const result = await page.evaluate(() => {
// @ts-ignore
window.registerShortcut({
key: 'test-key',
description: 'Test shortcut',
scope: 'global',
handler: () => {}
});
// @ts-ignore
const activePanel = window._panels.get(window.getActiveShortcutPanel());
const beforeUnregister = activePanel ? activePanel.shortcuts.has('global::test-key') : false;
// @ts-ignore
const unregistered = window.unregisterShortcut('test-key');
const afterUnregister = activePanel ? activePanel.shortcuts.has('global::test-key') : false;
return { beforeUnregister, unregistered, afterUnregister };
});
expect(result.beforeUnregister).toBe(true);
expect(result.unregistered).toBe(true);
expect(result.afterUnregister).toBe(false);
});
test('should support condition callbacks', async ({ page }) => {
const result = await page.evaluate(() => {
let conditionMet = false;
// @ts-ignore
window.registerShortcut({
key: 'test-cond',
description: 'Test with condition',
scope: 'global',
condition: () => conditionMet,
// @ts-ignore
handler: () => { window._conditionHandlerCalled = true; }
});
// Test with condition false
// @ts-ignore
window._conditionHandlerCalled = false;
const event1 = new KeyboardEvent('keydown', { key: 'test-cond' });
document.dispatchEvent(event1);
// @ts-ignore
const called1 = window._conditionHandlerCalled;
// Test with condition true
conditionMet = true;
// @ts-ignore
window._conditionHandlerCalled = false;
const event2 = new KeyboardEvent('keydown', { key: 'test-cond' });
document.dispatchEvent(event2);
// @ts-ignore
const called2 = window._conditionHandlerCalled;
// Cleanup
// @ts-ignore
window.unregisterShortcut('test-cond');
return { called1, called2 };
});
expect(result.called1).toBe(false); // Should not fire when condition is false
expect(result.called2).toBe(true); // Should fire when condition is true
});
test('should support modifier key combinations', async ({ page }) => {
const result = await page.evaluate(() => {
let ctrlCalled = false;
let shiftCalled = false;
let noModifierCalled = false;
let sWithoutCtrlCalled = false;
// @ts-ignore
window.registerShortcut({
key: 's',
description: 'Save with Ctrl',
scope: 'global',
modifiers: { ctrl: true },
handler: () => { ctrlCalled = true; }
});
// @ts-ignore
window.registerShortcut({
key: 't',
description: 'Test with Shift',
scope: 'global',
modifiers: { shift: true },
handler: () => { shiftCalled = true; }
});
// @ts-ignore
window.registerShortcut({
key: 'n',
description: 'No modifier',
scope: 'global',
handler: () => { noModifierCalled = true; }
});
// Test S without Ctrl first (should not fire)
const event0 = new KeyboardEvent('keydown', { key: 's' });
document.dispatchEvent(event0);
sWithoutCtrlCalled = ctrlCalled;
// Test Ctrl+S
const event1 = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
document.dispatchEvent(event1);
// Test Shift+T
const event2 = new KeyboardEvent('keydown', { key: 't', shiftKey: true });
document.dispatchEvent(event2);
// Test N without modifier
const event3 = new KeyboardEvent('keydown', { key: 'n' });
document.dispatchEvent(event3);
// Cleanup
// @ts-ignore
window.unregisterShortcut('s');
// @ts-ignore
window.unregisterShortcut('t');
// @ts-ignore
window.unregisterShortcut('n');
return { ctrlCalled, shiftCalled, noModifierCalled, sWithoutCtrlCalled };
});
expect(result.ctrlCalled).toBe(true);
expect(result.shiftCalled).toBe(true);
expect(result.noModifierCalled).toBe(true);
expect(result.sWithoutCtrlCalled).toBe(false); // Should not fire without Ctrl
});
test('should support panel-specific shortcuts', async ({ page }) => {
const result = await page.evaluate(() => {
// Create a new panel
// @ts-ignore
const panel1 = window.createShortcutPanel('panel-1');
let panelShortcutCalled = false;
let globalShortcutCalled = false;
// Register a panel-specific shortcut
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
// @ts-ignore
window.registerShortcut({
key: 'w',
description: 'Panel-specific action',
scope: 'global',
handler: () => { panelShortcutCalled = true; }
});
// Register a global shortcut in default panel
// @ts-ignore
window.setActiveShortcutPanel('default');
// @ts-ignore
window.registerShortcut({
key: 'g',
description: 'Global action',
scope: 'global',
handler: () => { globalShortcutCalled = true; }
});
// Test panel-specific shortcut (set panel-1 as active)
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
const event1 = new KeyboardEvent('keydown', { key: 'w' });
document.dispatchEvent(event1);
// Test global shortcut (set default as active)
// @ts-ignore
window.setActiveShortcutPanel('default');
const event2 = new KeyboardEvent('keydown', { key: 'g' });
document.dispatchEvent(event2);
// Cleanup
// @ts-ignore
panel1.clearShortcuts();
// @ts-ignore
window.unregisterShortcut('g');
// @ts-ignore
window.setActiveShortcutPanel('default');
return { panelShortcutCalled, globalShortcutCalled };
});
expect(result.panelShortcutCalled).toBe(true);
expect(result.globalShortcutCalled).toBe(true);
});
test('should show panel-specific shortcuts in modal', async ({ page }) => {
const result = await page.evaluate(() => {
// Create a new panel
// @ts-ignore
const panel1 = window.createShortcutPanel('panel-1');
// Register a panel-specific shortcut
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
// @ts-ignore
window.registerShortcut({
key: 'x',
description: 'Panel-specific action',
scope: 'global',
handler: () => {}
});
// Register a shortcut in default panel
// @ts-ignore
window.setActiveShortcutPanel('default');
// @ts-ignore
window.registerShortcut({
key: 'y',
description: 'Default panel action',
scope: 'global',
handler: () => {}
});
// Open the modal (default panel is active)
// @ts-ignore
window._openShortcutsModal();
// Check if the modal exists and contains the panel-specific shortcut.
// Use section-targeted DOM queries so an unrelated string elsewhere in
// the modal can't satisfy these assertions.
const modal = document.getElementById('shortcuts-modal');
let hasPanelSection = false;
let hasShortcut = false;
let hasKey = false;
if (modal) {
const sections = modal.querySelectorAll('section');
for (const section of sections) {
const heading = section.querySelector('h4');
if (heading && heading.textContent.trim() === 'Panel panel-1') {
hasPanelSection = true;
hasShortcut = (section.textContent || '').includes('Panel-specific action');
const kbd = section.querySelector('kbd');
if (kbd && kbd.textContent.trim() === 'x') {
hasKey = true;
}
break;
}
}
}
// The "Default panel action" entry should live in a row in the Global
// section; assert that a row description matches exactly rather than
// matching anywhere in innerHTML (the modal renders rows as <div>s
// containing a description <span> and a key <kbd>).
const hasDefaultShortcut = modal
? Array.from(modal.querySelectorAll('section span')).some(
(s) => (s.textContent || '').trim() === 'Default panel action'
)
: false;
// Cleanup
if (modal) modal.remove();
// @ts-ignore
panel1.clearShortcuts();
// @ts-ignore
window.unregisterShortcut('y');
// @ts-ignore
window.setActiveShortcutPanel('default');
return { hasPanelSection, hasShortcut, hasKey, hasDefaultShortcut };
});
expect(result.hasPanelSection).toBe(true); // Should show "Panel panel-1" section
expect(result.hasShortcut).toBe(true); // Should show the shortcut description
expect(result.hasKey).toBe(true); // Should show the shortcut key
expect(result.hasDefaultShortcut).toBe(true); // Should show default panel shortcut
});
test('should clear panel shortcuts on cleanup', async ({ page }) => {
const result = await page.evaluate(() => {
// Create a new panel
// @ts-ignore
const panel1 = window.createShortcutPanel('panel-1');
// Register panel-specific shortcuts
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
// @ts-ignore
window.registerShortcut({
key: 'x',
description: 'Panel shortcut 1',
scope: 'global',
handler: () => {}
});
// @ts-ignore
window.registerShortcut({
key: 'y',
description: 'Panel shortcut 2',
scope: 'global',
handler: () => {}
});
// Register a global shortcut in default panel (should not be cleared)
// @ts-ignore
window.setActiveShortcutPanel('default');
// @ts-ignore
window.registerShortcut({
key: 'z',
description: 'Global shortcut',
scope: 'global',
handler: () => {}
});
// Clear panel shortcuts
// @ts-ignore
panel1.clearShortcuts();
// Check that panel shortcuts are gone
// @ts-ignore
const hasX = panel1.shortcuts.has('global::x');
// @ts-ignore
const hasY = panel1.shortcuts.has('global::y');
// @ts-ignore
const defaultPanel = window._panels.get('default');
const hasZ = defaultPanel ? defaultPanel.shortcuts.has('global::z') : false;
// Cleanup
// @ts-ignore
window.unregisterShortcut('z');
// @ts-ignore
window.setActiveShortcutPanel('default');
return { hasX, hasY, hasZ };
});
expect(result.hasX).toBe(false); // Panel shortcut should be gone
expect(result.hasY).toBe(false); // Panel shortcut should be gone
expect(result.hasZ).toBe(true); // Global shortcut should still exist
});
test('should match shortcut by e.code (Space)', async ({ page }) => {
// The dispatcher matches against both e.key and e.code so that special
// keys (Space, ArrowLeft, …) registered by their code still fire when the
// browser delivers e.key=' ' / 'ArrowLeft'. Lock that behaviour in.
const result = await page.evaluate(() => {
let calledByCode = false;
let calledByKey = false;
// @ts-ignore
window.registerShortcut({
key: 'Space',
description: 'Test e.code match',
scope: 'global',
// @ts-ignore
handler: () => { window._codeHandlerCalled = (window._codeHandlerCalled || 0) + 1; }
});
// @ts-ignore
window._codeHandlerCalled = 0;
// Real-keyboard event: e.key=' ', e.code='Space'
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' }));
// @ts-ignore
calledByCode = window._codeHandlerCalled === 1;
// Synthetic event by e.key='Space' (legacy-style) should also work
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' }));
// @ts-ignore
calledByKey = window._codeHandlerCalled === 2;
// Cleanup
// @ts-ignore
window.unregisterShortcut('Space');
return { calledByCode, calledByKey };
});
expect(result.calledByCode).toBe(true);
expect(result.calledByKey).toBe(true);
});
test('should warn on invalid scope', async ({ page }) => {
const messages: string[] = [];
page.on('console', msg => {
if (msg.type() === 'warning') messages.push(msg.text());
});
await page.evaluate(() => {
// @ts-ignore
window.registerShortcut({
key: 'test-key',
description: 'Test shortcut',
scope: 'invalid-scope',
handler: () => {}
});
});
expect(messages.some(m => m.includes('invalid scope'))).toBe(true);
});
});
test.describe('Debug Helpers', () => {
test('should list shortcuts', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active');
const result = await page.evaluate(() => {
// @ts-ignore
if (typeof window._listShortcuts === 'function') {
// @ts-ignore
window._listShortcuts();
return true;
}
return false;
});
expect(result).toBe(true);
});
test('should test specific shortcut', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active');
const result = await page.evaluate(() => {
// @ts-ignore
if (typeof window._testShortcut === 'function') {
// @ts-ignore
window._testShortcut('Space');
return true;
}
return false;
});
expect(result).toBe(true);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';
// Progression (spec 010) smoke: nav entries render, the Progress screen shows
// a fresh rank-0 state, the `progression` capability domain has its core
// owner, and equipping a theme toggles the html[data-fb-theme] gate.
// A fresh profile shows the blocking onboarding overlay; onboard via the API
// so nav clicks aren't intercepted (idempotent on an already-onboarded db).
// The 3-step flow requires: (1) profile, (2) path selection, (3) skip calibration.
test.beforeEach(async ({ request }) => {
await request.post('/api/profile', { data: { display_name: 'Smoke Tester' } });
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
});
test('progress + shop nav entries render and route', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const progressNav = page.locator('[data-v3-nav="progress"]');
const shopNav = page.locator('[data-v3-nav="shop"]');
await expect(progressNav).toBeVisible();
await expect(shopNav).toBeVisible();
await progressNav.click();
await expect(page.locator('#v3-progress')).toHaveClass(/active/);
// Fresh install: Mastery Rank hero renders (rank value present).
await expect(page.locator('#v3-progress')).toContainText('Mastery Rank');
await expect(page.locator('#v3-progress')).toContainText('Decibels');
await shopNav.click();
await expect(page.locator('#v3-shop')).toHaveClass(/active/);
await expect(page.locator('#v3-shop')).toContainText('Your Decibels');
});
test('progression capability domain is owned by core', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const result = await page.evaluate(async () => {
const appWindow = window as any;
const inspectCmd = await appWindow.slopsmith.capabilities.command('progression', 'inspect', {
requester: 'browser-smoke',
});
const pipeline = appWindow.slopsmith.capabilities.inspect('progression');
const owner = (pipeline.participants || []).find((p: any) => p.pluginId === 'core.progression');
return {
outcome: inspectCmd.outcome,
masteryRank: inspectCmd.payload ? inspectCmd.payload.mastery_rank : null,
ownerRoles: owner ? owner.roles : [],
// buy-item without user-action authorization must be denied.
deniedBuy: (await appWindow.slopsmith.capabilities.command('progression', 'buy-item', {
requester: 'browser-smoke',
payload: { item_id: 'theme.sunset-strat' },
})).outcome,
};
});
expect(result.outcome).toBe('handled');
expect(typeof result.masteryRank).toBe('number');
expect(result.ownerRoles).toContain('owner');
expect(result.deniedBuy).toBe('denied');
});
test('theme apply toggles the data-fb-theme gate', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const states = await page.evaluate(() => {
const appWindow = window as any;
const before = document.documentElement.hasAttribute('data-fb-theme');
appWindow.v3Theme.apply({ colors: { bg: '#101010', card: '#202020', text: '#ffffff' } });
const applied = document.documentElement.hasAttribute('data-fb-theme');
appWindow.v3Theme.apply(null);
const cleared = document.documentElement.hasAttribute('data-fb-theme');
return { before, applied, cleared };
});
expect(states.before).toBe(false);
expect(states.applied).toBe(true);
expect(states.cleared).toBe(false);
});
+158
View File
@@ -0,0 +1,158 @@
import { test, expect } from '@playwright/test';
// Issue #686 — a Screen Wake Lock is held only while a song plays so the OS
// screensaver doesn't kick in during windowed-mode playback. We stub
// navigator.wakeLock before app.js runs (headless Chromium rejects a real
// 'screen' request — there is no display) and drive the song:* bus events the
// wake-lock helper listens to. `held` flips true only once a request actually
// resolves and the lock is kept, so the fast play→pause race is observable.
const installWakeLockSpy = () => {
(window as any).__wakeLockSpy = {
requestCount: 0,
releaseCount: 0,
lastType: null as string | null,
held: false,
lastSentinel: null as any,
};
Object.defineProperty(navigator, 'wakeLock', {
configurable: true,
value: {
request(type: string) {
const spy = (window as any).__wakeLockSpy;
spy.requestCount++;
spy.lastType = type;
const listeners: Array<() => void> = [];
const sentinel = {
released: false,
addEventListener(t: string, fn: () => void) {
if (t === 'release') listeners.push(fn);
},
removeEventListener() {},
release() {
if (this.released) return Promise.resolve();
this.released = true;
spy.releaseCount++;
spy.held = false;
listeners.forEach((fn) => fn());
return Promise.resolve();
},
};
spy.lastSentinel = sentinel;
// The lock is only "held" once the request resolves and is kept — model
// that on the microtask so a release that lands first wins the race.
Promise.resolve().then(() => { if (!sentinel.released) spy.held = true; });
return Promise.resolve(sentinel);
},
},
});
};
test.beforeEach(async ({ page }) => {
await page.addInitScript(installWakeLockSpy);
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.waitForFunction(() => typeof (window as any).slopsmith?.emit === 'function');
});
test('acquires a single screen wake lock on play and releases on pause', async ({ page }) => {
// The audio 'play' listener emits song:play AND song:resume synchronously;
// only one 'screen' lock should be requested (the in-flight guard must hold
// before the first request resolves).
await page.evaluate(() => {
(window as any).slopsmith.emit('song:play');
(window as any).slopsmith.emit('song:resume');
});
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.requestCount)).toBe(1);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.lastType)).toBe('screen');
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
});
test('song:ended and song:stop release the wake lock', async ({ page }) => {
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
await page.evaluate(() => (window as any).slopsmith.emit('song:ended'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
await page.evaluate(() => (window as any).slopsmith.emit('song:stop'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
});
test('fast play→pause before the request resolves leaves no stale lock', async ({ page }) => {
const before = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
// Pause arrives while navigator.wakeLock.request is still in flight — the
// resolved sentinel must release itself instead of being held stale.
await page.evaluate(() => {
(window as any).slopsmith.emit('song:play');
(window as any).slopsmith.emit('song:pause');
});
await page.waitForTimeout(150);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.held)).toBe(false);
expect(await page.evaluate((n) => (window as any).__wakeLockSpy.requestCount === n + 1, before)).toBe(true);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.lastSentinel.released)).toBe(true);
});
test('re-acquires the wake lock when the UA releases it while still playing', async ({ page }) => {
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
const before = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
// Simulate the UA releasing the lock (power policy / page hide) while
// playback continues; the release handler re-acquires when still visible.
await page.evaluate(() => {
(window as any).slopsmith.isPlaying = true;
(window as any).__wakeLockSpy.lastSentinel.release();
});
await page.waitForFunction((n) => (window as any).__wakeLockSpy.requestCount === n + 1 && (window as any).__wakeLockSpy.held === true, before);
// After a real pause there must be no further re-acquire churn.
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
const settled = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
await page.waitForTimeout(150);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.requestCount)).toBe(settled);
});
test('drives the slopsmith-desktop native power bridge, deduped and visibility-gated', async ({ page }) => {
// In the packaged Electron app navigator.wakeLock is unreliable, so the
// helper also drives window.slopsmithDesktop.power.setScreenAwake — to
// exactly (wanted && visible), emitting only on change. Inject a spy bridge
// before app.js runs and assert it tracks playback without duplicate starts.
await page.addInitScript(() => {
(window as any).__bridgeCalls = [];
(window as any).slopsmithDesktop = {
power: { setScreenAwake: (keep: boolean) => (window as any).__bridgeCalls.push(keep) },
};
});
await page.reload();
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.waitForFunction(() => typeof (window as any).slopsmith?.emit === 'function');
// song:play + song:resume fire together but must produce a single `true`.
await page.evaluate(() => {
(window as any).slopsmith.emit('song:play');
(window as any).slopsmith.emit('song:resume');
});
await page.waitForFunction(() => (window as any).__bridgeCalls.filter((x: boolean) => x === true).length === 1);
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
await page.waitForFunction(() => (window as any).__bridgeCalls.filter((x: boolean) => x === false).length === 1);
// Hidden while playing → bridge OFF (a minimized window mustn't keep the
// whole display awake); restoring visibility while playing turns it back ON.
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === true);
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === false);
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === true);
});