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 }) => { // Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that // intercepts pointer/keyboard events — so the app behaves like a returning // user, which is the state these tests assume. await page.route('**/api/profile', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } }); } else { await route.continue(); } }); 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: '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('/'); // 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: '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