mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user