experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(infra): split smoke.spec.ts into one-describe-per-file for parallelism

+5343 -5360
+15 -13
playwright.config.ts
··· 75 75 // --user-data-dir (see tests/fixtures/desktop-app.ts). The single-instance 76 76 // lock is bypassed when the profile starts with "test" (see 77 77 // `isTestProfile()` in backend/electron/config.ts), so parallel workers 78 - // don't collide on the lock. Default is 1 (both local and CI) for 79 - // reliability: the parallelism payoff is minor while smoke.spec.ts is a 80 - // single-file bottleneck, and concurrent Electron launches surface 81 - // occasional OS-level races. Set `PEEK_TEST_WORKERS=N` to opt in to 82 - // parallelism once smoke.spec.ts has been split into smaller files. 78 + // don't collide on the lock. Default is 4 since smoke.spec.ts was split 79 + // into one-describe-per-file (~44 files total), giving real parallelism 80 + // across spec files. Override with `PEEK_TEST_WORKERS=1` to debug or 81 + // on machines where 4 concurrent Electrons is too much. 83 82 // 84 83 // fullyParallel: false — tests within a file run serially in one worker, 85 - // different files distribute across workers. We tried fullyParallel: true 86 - // but OS-level cross-Electron focus/timing races under 4 parallel workers 87 - // surfaced in too many scattered tests. Current choice trades raw 88 - // parallelism for stability; smoke.spec.ts (the big file, ~218 tests) 89 - // bottlenecks on one worker but the rest of the suite distributes freely. 90 - // To recover more parallelism, the right move is to split smoke.spec.ts 91 - // into smaller files rather than re-enable fullyParallel. 84 + // different files distribute across workers. fullyParallel: true exposed 85 + // OS-level cross-Electron focus/timing races; also conflicts with the 86 + // intra-test ordering some describes rely on (e.g. Extension Lifecycle: 87 + // add → update → remove). File-level parallelism is enough. 88 + // 89 + // Cmd-panel-keyboard-flow tests live in tests/desktop-serial/ (separate 90 + // project) because they hit OS-level modal focus/blur that misbehaves 91 + // when N Electron instances are competing. `yarn test:electron` runs 92 + // the desktop project at PEEK_TEST_WORKERS, then runs desktop-serial 93 + // afterward at --workers=1. 92 94 fullyParallel: false, 93 - workers: parseInt(process.env.PEEK_TEST_WORKERS || '1'), 95 + workers: parseInt(process.env.PEEK_TEST_WORKERS || '4'), 94 96 95 97 // CI settings 96 98 forbidOnly: !!process.env.CI,
+137
tests/desktop/backup.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import fs from 'fs'; 5 + import os from 'os'; 6 + import path from 'path'; 7 + 8 + test.describe('Backup @desktop', () => { 9 + let app: DesktopApp; 10 + let bgWindow: Page; 11 + 12 + test.beforeAll(async () => { 13 + ({ app, bgWindow } = await createPerDescribeApp('backup')); 14 + }); 15 + 16 + test.afterAll(async () => { 17 + if (app) await app.close(); 18 + }); 19 + 20 + test('backup-get-config returns config object', async () => { 21 + const result = await bgWindow.evaluate(async () => { 22 + return await (window as any).app.backup.getConfig(); 23 + }); 24 + 25 + expect(result.success).toBe(true); 26 + expect(result.data).toBeDefined(); 27 + expect(typeof result.data.enabled).toBe('boolean'); 28 + expect(typeof result.data.backupDir).toBe('string'); 29 + expect(typeof result.data.retentionCount).toBe('number'); 30 + expect(typeof result.data.lastBackupTime).toBe('number'); 31 + }); 32 + 33 + test('backup is disabled when backupDir is not configured', async () => { 34 + const result = await bgWindow.evaluate(async () => { 35 + return await (window as any).app.backup.getConfig(); 36 + }); 37 + 38 + expect(result.success).toBe(true); 39 + // By default, backupDir should be empty and backups disabled 40 + expect(result.data.backupDir).toBe(''); 41 + expect(result.data.enabled).toBe(false); 42 + }); 43 + 44 + test('backup-create returns error when not configured', async () => { 45 + const result = await bgWindow.evaluate(async () => { 46 + return await (window as any).app.backup.create(); 47 + }); 48 + 49 + expect(result.success).toBe(false); 50 + expect(result.error).toContain('not configured'); 51 + }); 52 + 53 + test('backup-list returns empty when not configured', async () => { 54 + const result = await bgWindow.evaluate(async () => { 55 + return await (window as any).app.backup.list(); 56 + }); 57 + 58 + expect(result.success).toBe(true); 59 + expect(result.data.backups).toEqual([]); 60 + expect(result.data.backupDir).toBe(''); 61 + }); 62 + 63 + test('backup works when backupDir is configured', async () => { 64 + // Create temp directory for test backups 65 + const tempBackupDir = path.join(os.tmpdir(), `peek-backup-test-${Date.now()}`); 66 + fs.mkdirSync(tempBackupDir, { recursive: true }); 67 + 68 + try { 69 + // Store the current prefs and configure backup 70 + const setupResult = await bgWindow.evaluate(async (backupDir: string) => { 71 + const api = (window as any).app; 72 + 73 + // Get current prefs 74 + const prefsResult = await api.datastore.getTable('feature_settings'); 75 + const corePrefsRow = Object.values(prefsResult.data || {}).find( 76 + (r: any) => r.featureId === 'core' && r.key === 'prefs' 77 + ) as any; 78 + const originalPrefs = corePrefsRow ? JSON.parse(corePrefsRow.value) : {}; 79 + 80 + // Set backupDir in core prefs 81 + const newPrefs = { ...originalPrefs, backupDir }; 82 + await api.datastore.setRow('feature_settings', 'core:prefs', { 83 + featureId: 'core', 84 + key: 'prefs', 85 + value: JSON.stringify(newPrefs), 86 + updatedAt: Date.now() 87 + }); 88 + 89 + return { originalPrefs }; 90 + }, tempBackupDir); 91 + 92 + // Verify config reflects the change 93 + const configResult = await bgWindow.evaluate(async () => { 94 + return await (window as any).app.backup.getConfig(); 95 + }); 96 + expect(configResult.success).toBe(true); 97 + expect(configResult.data.backupDir).toBe(tempBackupDir); 98 + expect(configResult.data.enabled).toBe(true); 99 + 100 + // Create a backup 101 + const backupResult = await bgWindow.evaluate(async () => { 102 + return await (window as any).app.backup.create(); 103 + }); 104 + expect(backupResult.success).toBe(true); 105 + expect(backupResult.path).toBeTruthy(); 106 + expect(backupResult.path.endsWith('.zip')).toBe(true); 107 + 108 + // Verify the file exists 109 + expect(fs.existsSync(backupResult.path)).toBe(true); 110 + 111 + // List backups - should have one 112 + const listResult = await bgWindow.evaluate(async () => { 113 + return await (window as any).app.backup.list(); 114 + }); 115 + expect(listResult.success).toBe(true); 116 + expect(listResult.data.backups.length).toBe(1); 117 + 118 + // Restore original prefs 119 + await bgWindow.evaluate(async (originalPrefs: Record<string, unknown>) => { 120 + const api = (window as any).app; 121 + await api.datastore.setRow('feature_settings', 'core:prefs', { 122 + featureId: 'core', 123 + key: 'prefs', 124 + value: JSON.stringify(originalPrefs), 125 + updatedAt: Date.now() 126 + }); 127 + }, setupResult.originalPrefs); 128 + } finally { 129 + // Clean up temp directory 130 + try { 131 + fs.rmSync(tempBackupDir, { recursive: true, force: true }); 132 + } catch (e) { 133 + // Ignore cleanup errors 134 + } 135 + } 136 + }); 137 + });
+166
tests/desktop/cmd-palette.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForExtensionsReady, waitForCommandResults, waitForResultsWithContent, waitForPanelCommandsLoaded } from '../helpers/window-utils'; 5 + 6 + test.describe('Cmd Palette @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('cmd-palette')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('open cmd and execute gallery command', async () => { 19 + // Wait for cmd extension to be ready (critical for packaged mode where startup is slower) 20 + await waitForExtensionsReady(bgWindow, 15000); 21 + 22 + // Open cmd panel via window API 23 + const openResult = await bgWindow.evaluate(async () => { 24 + return await (window as any).app.window.open('peek://cmd/panel.html', { 25 + modal: true, 26 + width: 600, 27 + height: 50, 28 + frame: false, 29 + transparent: true, 30 + alwaysOnTop: true, 31 + center: true 32 + }); 33 + }); 34 + expect(openResult.success).toBe(true); 35 + 36 + // Find the cmd window (getWindow already polls until found) 37 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 38 + expect(cmdWindow).toBeTruthy(); 39 + 40 + // Wait for input to be ready and commands to be loaded 41 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 42 + await waitForPanelCommandsLoaded(cmdWindow, 10000); 43 + 44 + // Type a built-in command first to verify 45 + // Built-in commands (like 'settings') load faster than extension commands 46 + await cmdWindow.fill('input', 'settings'); 47 + // Press ArrowDown to show results (panel requires this to display dropdown) 48 + await cmdWindow.keyboard.press('ArrowDown'); 49 + await waitForCommandResults(cmdWindow, 1, 10000); // Longer timeout for initial load 50 + 51 + // Now search for the extension command 52 + await cmdWindow.fill('input', 'example:gallery'); 53 + await cmdWindow.keyboard.press('ArrowDown'); 54 + await waitForCommandResults(cmdWindow, 1, 10000); 55 + 56 + // Press Enter to execute 57 + await cmdWindow.keyboard.press('Enter'); 58 + 59 + // Close the cmd window 60 + if (openResult.id) { 61 + await bgWindow.evaluate(async (id: number) => { 62 + return await (window as any).app.window.close(id); 63 + }, openResult.id); 64 + } 65 + }); 66 + 67 + test('edit command Tab-completion shows autocomplete and opens editor', async () => { 68 + await waitForExtensionsReady(bgWindow, 15000); 69 + 70 + // Create a test note so the edit command has something to autocomplete 71 + const addResult = await bgWindow.evaluate(async () => { 72 + return await (window as any).app.datastore.addItem('text', { 73 + content: '# Edit Tab Test Note\nThis is a note for testing edit tab-completion.' 74 + }); 75 + }); 76 + expect(addResult.success).toBe(true); 77 + const noteId = addResult.data?.id; 78 + 79 + // Set up editor:open event capture BEFORE opening the cmd panel 80 + await bgWindow.evaluate(() => { 81 + (window as any).__editorOpenCaptured = []; 82 + (window as any).__editorOpenUnsub = (window as any).app.subscribe('editor:open', (data: any) => { 83 + (window as any).__editorOpenCaptured.push(data); 84 + }, (window as any).app.scopes.GLOBAL); 85 + }); 86 + 87 + // Open cmd panel 88 + const openResult = await bgWindow.evaluate(async () => { 89 + return await (window as any).app.window.open('peek://cmd/panel.html', { 90 + modal: true, 91 + width: 600, 92 + height: 50, 93 + frame: false, 94 + transparent: true, 95 + alwaysOnTop: true, 96 + center: true 97 + }); 98 + }); 99 + expect(openResult.success).toBe(true); 100 + 101 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 102 + expect(cmdWindow).toBeTruthy(); 103 + 104 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 105 + await waitForPanelCommandsLoaded(cmdWindow, 10000); 106 + 107 + // Type "edit " (with space) to commit to the edit command and enter param mode 108 + // (Tab would cycle to 'editor' since both "edit" and "editor" match) 109 + await cmdWindow.fill('input', 'edit '); 110 + 111 + // Wait for param mode and suggestions to populate 112 + await cmdWindow.waitForFunction( 113 + () => { 114 + const state = (window as any)._cmdState; 115 + return state && state.paramMode === true && state.paramCommand === 'edit' 116 + && state.paramSuggestions && state.paramSuggestions.length > 0; 117 + }, 118 + undefined, 119 + { timeout: 10000 } 120 + ); 121 + 122 + // Verify results are visible with suggestion items 123 + await waitForResultsWithContent(cmdWindow, 5000); 124 + 125 + // Press Enter to accept the first suggestion 126 + // This executes the edit command, publishes editor:open, and closes the panel 127 + await cmdWindow.keyboard.press('Enter'); 128 + 129 + // Verify editor:open was published by polling the captured events 130 + await bgWindow.waitForFunction(() => { 131 + return (window as any).__editorOpenCaptured && (window as any).__editorOpenCaptured.length > 0; 132 + }, undefined, { timeout: 10000 }); 133 + 134 + const editorOpenData = await bgWindow.evaluate(() => { 135 + return (window as any).__editorOpenCaptured[0]; 136 + }); 137 + expect(editorOpenData).toBeTruthy(); 138 + 139 + // Clean up event listener 140 + await bgWindow.evaluate(() => { 141 + if ((window as any).__editorOpenUnsub) { 142 + (window as any).__editorOpenUnsub(); 143 + } 144 + delete (window as any).__editorOpenCaptured; 145 + delete (window as any).__editorOpenUnsub; 146 + }); 147 + 148 + // Close cmd window if still open 149 + try { 150 + if (openResult.id) { 151 + await bgWindow.evaluate(async (id: number) => { 152 + return await (window as any).app.window.close(id); 153 + }, openResult.id); 154 + } 155 + } catch { 156 + // Panel may have already closed via shutdown() 157 + } 158 + 159 + // Clean up the test note 160 + if (noteId) { 161 + await bgWindow.evaluate(async (id: string) => { 162 + return await (window as any).app.datastore.deleteItem(id); 163 + }, noteId); 164 + } 165 + }); 166 + });
+476
tests/desktop/command-chaining.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForPanelCommandsLoaded, waitForClass, waitForResultsWithContent, waitForCommand } from '../helpers/window-utils'; 5 + 6 + test.describe('Command Chaining @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('cmd-chain')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('cmd panel loads with chain state initialized', async () => { 19 + // Open cmd panel to verify it loads correctly with chain support 20 + const openResult = await bgWindow.evaluate(async () => { 21 + return await (window as any).app.window.open('peek://cmd/panel.html', { 22 + modal: true, 23 + width: 600, 24 + height: 300, 25 + frame: false, 26 + transparent: true, 27 + alwaysOnTop: true, 28 + center: true 29 + }); 30 + }); 31 + expect(openResult.success).toBe(true); 32 + 33 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 34 + expect(cmdWindow).toBeTruthy(); 35 + 36 + // Verify state object has chain properties 37 + const hasChainState = await cmdWindow.evaluate(() => { 38 + // Access state through the module scope would require exposing it 39 + // Instead verify the UI elements that depend on chain state exist 40 + const chainIndicator = document.getElementById('chain-indicator'); 41 + const previewContainer = document.getElementById('preview-container'); 42 + return chainIndicator !== null && previewContainer !== null; 43 + }); 44 + expect(hasChainState).toBe(true); 45 + 46 + // Close the window 47 + if (openResult.id) { 48 + await bgWindow.evaluate(async (id: number) => { 49 + return await (window as any).app.window.close(id); 50 + }, openResult.id); 51 + } 52 + }); 53 + 54 + test('MIME type matching works correctly', async () => { 55 + // Test MIME matching logic in panel context 56 + const openResult = await bgWindow.evaluate(async () => { 57 + return await (window as any).app.window.open('peek://cmd/panel.html', { 58 + modal: true, 59 + width: 600, 60 + height: 50, 61 + frame: false, 62 + transparent: true, 63 + alwaysOnTop: true, 64 + center: true 65 + }); 66 + }); 67 + expect(openResult.success).toBe(true); 68 + 69 + // Find the cmd window 70 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 71 + expect(cmdWindow).toBeTruthy(); 72 + 73 + // Test MIME type matching function (if exposed, or test via behavior) 74 + // The panel.js has mimeTypeMatches function - we test the expected behavior 75 + 76 + // Test exact match: 'application/json' matches 'application/json' 77 + const exactMatch = await cmdWindow.evaluate(() => { 78 + // We can't directly call the function, but we can verify commands filter correctly 79 + // This is more of an integration test 80 + return true; 81 + }); 82 + expect(exactMatch).toBe(true); 83 + 84 + // Close the window 85 + if (openResult.id) { 86 + await bgWindow.evaluate(async (id: number) => { 87 + return await (window as any).app.window.close(id); 88 + }, openResult.id); 89 + } 90 + }); 91 + 92 + test('cmd panel input works correctly', async () => { 93 + // Open cmd panel 94 + const openResult = await bgWindow.evaluate(async () => { 95 + return await (window as any).app.window.open('peek://cmd/panel.html', { 96 + modal: true, 97 + width: 600, 98 + height: 400, 99 + frame: false, 100 + transparent: true, 101 + alwaysOnTop: true, 102 + center: true 103 + }); 104 + }); 105 + expect(openResult.success).toBe(true); 106 + 107 + // Find the cmd window 108 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 109 + expect(cmdWindow).toBeTruthy(); 110 + 111 + // Wait for input to be ready 112 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 113 + 114 + // Verify input is focusable and can receive text 115 + await cmdWindow.fill('input', 'test'); 116 + const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value); 117 + expect(inputValue).toBe('test'); 118 + 119 + // Close the window 120 + if (openResult.id) { 121 + await bgWindow.evaluate(async (id: number) => { 122 + return await (window as any).app.window.close(id); 123 + }, openResult.id); 124 + } 125 + }); 126 + 127 + test('panel has chain indicator, preview, and execution state elements', async () => { 128 + // Open cmd panel 129 + const openResult = await bgWindow.evaluate(async () => { 130 + return await (window as any).app.window.open('peek://cmd/panel.html', { 131 + modal: true, 132 + width: 600, 133 + height: 300, 134 + frame: false, 135 + transparent: true, 136 + alwaysOnTop: true, 137 + center: true 138 + }); 139 + }); 140 + expect(openResult.success).toBe(true); 141 + 142 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 143 + expect(cmdWindow).toBeTruthy(); 144 + 145 + // Check chain indicator element exists 146 + const chainIndicator = await cmdWindow.$('#chain-indicator'); 147 + expect(chainIndicator).toBeTruthy(); 148 + 149 + // Check preview container exists 150 + const previewContainer = await cmdWindow.$('#preview-container'); 151 + expect(previewContainer).toBeTruthy(); 152 + 153 + // Check execution state element exists 154 + const executionState = await cmdWindow.$('#execution-state'); 155 + expect(executionState).toBeTruthy(); 156 + 157 + // Verify chain indicator is initially hidden (no 'visible' class) 158 + const chainVisible = await cmdWindow.$eval('#chain-indicator', (el: HTMLElement) => el.classList.contains('visible')); 159 + expect(chainVisible).toBe(false); 160 + 161 + // Verify preview is initially hidden (no 'visible' class) 162 + const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible')); 163 + expect(previewVisible).toBe(false); 164 + 165 + // Verify execution state is initially hidden (no 'visible' class) 166 + const execVisible = await cmdWindow.$eval('#execution-state', (el: HTMLElement) => el.classList.contains('visible')); 167 + expect(execVisible).toBe(false); 168 + 169 + // Verify results is initially hidden (no 'visible' class) 170 + const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible')); 171 + expect(resultsVisible).toBe(false); 172 + 173 + // Close the window 174 + if (openResult.id) { 175 + await bgWindow.evaluate(async (id: number) => { 176 + return await (window as any).app.window.close(id); 177 + }, openResult.id); 178 + } 179 + }); 180 + 181 + test('list urls command produces array output and enters output selection mode', async () => { 182 + // Open cmd panel 183 + const openResult = await bgWindow.evaluate(async () => { 184 + return await (window as any).app.window.open('peek://cmd/panel.html', { 185 + modal: true, 186 + width: 600, 187 + height: 400, 188 + frame: false, 189 + transparent: true, 190 + alwaysOnTop: true, 191 + center: true 192 + }); 193 + }); 194 + expect(openResult.success).toBe(true); 195 + 196 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 197 + expect(cmdWindow).toBeTruthy(); 198 + 199 + // Wait for input to be ready and commands to be loaded 200 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 201 + await waitForPanelCommandsLoaded(cmdWindow); 202 + 203 + // Type 'list urls' command 204 + await cmdWindow.fill('input', 'list urls'); 205 + 206 + // Press down arrow to show results 207 + await cmdWindow.press('input', 'ArrowDown'); 208 + await waitForClass(cmdWindow, '#results', 'visible'); 209 + 210 + // Verify results are visible 211 + const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible')); 212 + expect(resultsVisible).toBe(true); 213 + 214 + // Press Enter to execute 215 + await cmdWindow.press('input', 'Enter'); 216 + await waitForResultsWithContent(cmdWindow); 217 + 218 + // After list urls executes, we should be in output selection mode 219 + // Results should show the items from the list urls output 220 + const hasResults = await cmdWindow.$eval('#results', (el: HTMLElement) => { 221 + return el.classList.contains('visible') && el.children.length > 0; 222 + }); 223 + expect(hasResults).toBe(true); 224 + 225 + // Preview should show the selected item 226 + const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible')); 227 + expect(previewVisible).toBe(true); 228 + 229 + // Close the window 230 + if (openResult.id) { 231 + await bgWindow.evaluate(async (id: number) => { 232 + return await (window as any).app.window.close(id); 233 + }, openResult.id); 234 + } 235 + }); 236 + 237 + test('selecting output item enters chain mode with filtered commands', async () => { 238 + // Architectural contract (see docs/cmd-chain-architecture.md): 239 + // `list urls` → OUTPUT_SELECTION → Enter on a row → CHAIN_MODE. 240 + // CHAIN_MODE is NEVER reached direct-from-EXECUTING; the user always 241 + // sees their rows first so they can pick which one to chain against. 242 + await waitForCommand(bgWindow, 'csv', 10000); 243 + 244 + // Seed a couple of urls so list urls returns a selectable list 245 + await bgWindow.evaluate(async () => { 246 + const api = (window as any).app; 247 + await api.datastore.addItem('url', { url: 'https://example.com/chain-a', title: 'chain-a' }); 248 + await api.datastore.addItem('url', { url: 'https://example.com/chain-b', title: 'chain-b' }); 249 + }); 250 + 251 + const openResult = await bgWindow.evaluate(async () => { 252 + return await (window as any).app.window.open('peek://cmd/panel.html', { 253 + modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 254 + }); 255 + }); 256 + expect(openResult.success).toBe(true); 257 + 258 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 259 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 260 + await waitForPanelCommandsLoaded(cmdWindow); 261 + 262 + await cmdWindow.fill('input', 'list urls'); 263 + await cmdWindow.press('input', 'Enter'); 264 + 265 + // Step 1: OUTPUT_SELECTION entered first (not CHAIN_MODE) 266 + await cmdWindow.waitForFunction( 267 + () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 268 + null, { timeout: 5000 } 269 + ); 270 + 271 + // Step 2: Enter on the selected row → CHAIN_MODE 272 + await cmdWindow.press('input', 'Enter'); 273 + await cmdWindow.waitForFunction( 274 + () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 275 + null, { timeout: 5000 } 276 + ); 277 + 278 + // Step 3: CHAIN_MODE suggestions include csv (accepts application/json) 279 + const suggestions = await cmdWindow.evaluate(() => (window as any)._cmdState?.matches || []); 280 + expect(suggestions).toContain('csv'); 281 + 282 + if (openResult.id) { 283 + await bgWindow.evaluate(async (id: number) => { 284 + return await (window as any).app.window.close(id); 285 + }, openResult.id); 286 + } 287 + }); 288 + 289 + test('csv command converts JSON to CSV format', async () => { 290 + // list urls → OUTPUT_SELECTION → select row → CHAIN_MODE → csv → text/csv 291 + await waitForCommand(bgWindow, 'csv', 10000); 292 + 293 + await bgWindow.evaluate(async () => { 294 + const api = (window as any).app; 295 + await api.datastore.addItem('url', { url: 'https://example.com/csv-a', title: 'csv-a' }); 296 + await api.datastore.addItem('url', { url: 'https://example.com/csv-b', title: 'csv-b' }); 297 + }); 298 + 299 + const openResult = await bgWindow.evaluate(async () => { 300 + return await (window as any).app.window.open('peek://cmd/panel.html', { 301 + modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 302 + }); 303 + }); 304 + expect(openResult.success).toBe(true); 305 + 306 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 307 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 308 + await waitForPanelCommandsLoaded(cmdWindow); 309 + 310 + await cmdWindow.fill('input', 'list urls'); 311 + await cmdWindow.press('input', 'Enter'); 312 + 313 + // OUTPUT_SELECTION first 314 + await cmdWindow.waitForFunction( 315 + () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 316 + null, { timeout: 5000 } 317 + ); 318 + 319 + // Enter a row → CHAIN_MODE 320 + await cmdWindow.press('input', 'Enter'); 321 + await cmdWindow.waitForFunction( 322 + () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 323 + null, { timeout: 5000 } 324 + ); 325 + 326 + // Type csv to filter matches, then ArrowDown+Enter to execute 327 + await cmdWindow.fill('input', 'csv'); 328 + await cmdWindow.waitForFunction( 329 + () => ((window as any)._cmdState?.matches || []).includes('csv'), 330 + null, { timeout: 5000 } 331 + ); 332 + await cmdWindow.press('input', 'ArrowDown'); 333 + await cmdWindow.press('input', 'Enter'); 334 + 335 + // csv produces text/csv — wait for state to leave EXECUTING (csv is lazy 336 + // so first invoke loads the tile; proxy has 30s timeout, bucket covers) 337 + await cmdWindow.waitForFunction( 338 + () => (window as any)._cmdState?.currentState !== 'EXECUTING', 339 + null, { timeout: 35000 } 340 + ); 341 + 342 + // After csv execution, either chainContext has text/csv (chain continues) 343 + // or the panel transitioned to CLOSING (terminal csv output). Both are valid 344 + // terminal states — we only fail if csv's result never arrived at all. 345 + const final = await cmdWindow.evaluate(() => { 346 + const s = (window as any)._cmdState; 347 + return { state: s?.currentState, mimeType: s?.chainContext?.mimeType }; 348 + }); 349 + if (final.state === 'CHAIN_MODE') { 350 + expect(final.mimeType).toBe('text/csv'); 351 + } else { 352 + // csv is lazy-loaded; first invoke may exceed the proxy's 30s timeout 353 + // in slow CI. ERROR is also acceptable — this test verifies the chain 354 + // plumbing reaches csv, not lazy-tile performance. 355 + expect(['CLOSING', 'IDLE', 'OUTPUT_SELECTION', 'ERROR']).toContain(final.state); 356 + } 357 + 358 + if (openResult.id) { 359 + await bgWindow.evaluate(async (id: number) => { 360 + return await (window as any).app.window.close(id); 361 + }, openResult.id); 362 + } 363 + }); 364 + 365 + test('escape exits chain mode before closing panel', async () => { 366 + // Canonical ESC layering: ESC in CHAIN_MODE exits chain (back to 367 + // OUTPUT_SELECTION or IDLE depending on stack), does NOT close the panel. 368 + await waitForCommand(bgWindow, 'csv', 10000); 369 + 370 + await bgWindow.evaluate(async () => { 371 + const api = (window as any).app; 372 + await api.datastore.addItem('url', { url: 'https://example.com/esc-a', title: 'esc-a' }); 373 + await api.datastore.addItem('url', { url: 'https://example.com/esc-b', title: 'esc-b' }); 374 + }); 375 + 376 + const openResult = await bgWindow.evaluate(async () => { 377 + return await (window as any).app.window.open('peek://cmd/panel.html', { 378 + modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 379 + }); 380 + }); 381 + expect(openResult.success).toBe(true); 382 + 383 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 384 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 385 + await waitForPanelCommandsLoaded(cmdWindow); 386 + 387 + await cmdWindow.fill('input', 'list urls'); 388 + await cmdWindow.press('input', 'Enter'); 389 + await cmdWindow.waitForFunction( 390 + () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 391 + null, { timeout: 5000 } 392 + ); 393 + await cmdWindow.press('input', 'Enter'); 394 + await cmdWindow.waitForFunction( 395 + () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 396 + null, { timeout: 5000 } 397 + ); 398 + 399 + // ESC in CHAIN_MODE exits the chain — state changes away from CHAIN_MODE 400 + // (target is OUTPUT_SELECTION, TYPING, or IDLE per implementation), but 401 + // NOT CLOSING — the panel remains open. 402 + await cmdWindow.press('input', 'Escape'); 403 + await cmdWindow.waitForFunction( 404 + () => { 405 + const s = (window as any)._cmdState?.currentState; 406 + return s !== 'CHAIN_MODE' && s !== 'CLOSING'; 407 + }, 408 + null, { timeout: 5000 } 409 + ); 410 + 411 + const inputExists = await cmdWindow.$('input'); 412 + expect(inputExists).toBeTruthy(); 413 + 414 + if (openResult.id) { 415 + await bgWindow.evaluate(async (id: number) => { 416 + return await (window as any).app.window.close(id); 417 + }, openResult.id); 418 + } 419 + }); 420 + 421 + test('arrow navigation in output selection mode', async () => { 422 + // Seed multiple urls so navigation has more than one row 423 + await bgWindow.evaluate(async () => { 424 + const api = (window as any).app; 425 + await api.datastore.addItem('url', { url: 'https://example.com/nav-a', title: 'nav-a' }); 426 + await api.datastore.addItem('url', { url: 'https://example.com/nav-b', title: 'nav-b' }); 427 + }); 428 + 429 + const openResult = await bgWindow.evaluate(async () => { 430 + return await (window as any).app.window.open('peek://cmd/panel.html', { 431 + modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 432 + }); 433 + }); 434 + expect(openResult.success).toBe(true); 435 + 436 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 437 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 438 + await waitForPanelCommandsLoaded(cmdWindow); 439 + 440 + await cmdWindow.fill('input', 'list urls'); 441 + await cmdWindow.press('input', 'Enter'); 442 + 443 + // OUTPUT_SELECTION with at least 2 rows 444 + await cmdWindow.waitForFunction( 445 + () => { 446 + const s = (window as any)._cmdState; 447 + return s?.currentState === 'OUTPUT_SELECTION' && (s?.outputItems?.length ?? 0) > 1; 448 + }, 449 + null, { timeout: 5000 } 450 + ); 451 + 452 + // First item is selected (outputItemIndex starts at 0) 453 + const initialIndex = await cmdWindow.evaluate(() => (window as any)._cmdState?.outputItemIndex); 454 + expect(initialIndex).toBe(0); 455 + 456 + // Arrow down → index 1 457 + await cmdWindow.press('input', 'ArrowDown'); 458 + await cmdWindow.waitForFunction( 459 + () => (window as any)._cmdState?.outputItemIndex === 1, 460 + null, { timeout: 2000 } 461 + ); 462 + 463 + // Arrow up → back to 0 464 + await cmdWindow.press('input', 'ArrowUp'); 465 + await cmdWindow.waitForFunction( 466 + () => (window as any)._cmdState?.outputItemIndex === 0, 467 + null, { timeout: 2000 } 468 + ); 469 + 470 + if (openResult.id) { 471 + await bgWindow.evaluate(async (id: number) => { 472 + return await (window as any).app.window.close(id); 473 + }, openResult.id); 474 + } 475 + }); 476 + });
+556
tests/desktop/command-execution.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { sleep } from '../helpers/window-utils'; 5 + 6 + // ============================================================================ 7 + // Command Execution Tests (uses shared app) 8 + // Tests the full command execution path through pubsub: 9 + // cmd:execute:<name> -> extension handler -> result via resultTopic 10 + // ============================================================================ 11 + 12 + test.describe('Command Execution @desktop', () => { 13 + let app: DesktopApp; 14 + let bgWindow: Page; 15 + let pageWindowId: number | null = null; 16 + const testPageUrl = `https://cmd-exec-test-${Date.now()}.example.com/`; 17 + 18 + test.beforeAll(async () => { 19 + ({ app, bgWindow } = await createPerDescribeApp('cmd-exec')); 20 + 21 + // Open a page window so tag commands have an "active window" to work with 22 + const openResult = await bgWindow.evaluate(async (url: string) => { 23 + return await (window as any).app.window.open(url, { 24 + width: 800, 25 + height: 600, 26 + key: 'cmd-exec-test-page' 27 + }); 28 + }, testPageUrl); 29 + 30 + if (openResult.success && openResult.id) { 31 + pageWindowId = openResult.id; 32 + } 33 + 34 + // Give the page window time to load page.js, complete api.initialize(), 35 + // and subscribe to tag pubsub events. Without this wait, the first tag 36 + // command fires before page.js's subscribe is installed → missed event. 37 + await sleep(2000); 38 + }); 39 + 40 + test.afterAll(async () => { 41 + // Close the page window we opened 42 + if (pageWindowId && bgWindow && !bgWindow.isClosed()) { 43 + try { 44 + await bgWindow.evaluate(async (id: number) => { 45 + return await (window as any).app.window.close(id); 46 + }, pageWindowId); 47 + } catch { /* app may already be closing */ } 48 + } 49 + if (app) await app.close(); 50 + }); 51 + 52 + test('tag command with # prefixed tags stores tags without prefix', async () => { 53 + const timestamp = Date.now(); 54 + const tag1 = `testfoo${timestamp}`; 55 + const tag2 = `testbar${timestamp}`; 56 + 57 + // Execute the tag command through pubsub 58 + const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 59 + const api = (window as any).app; 60 + return new Promise((resolve) => { 61 + const resultTopic = `cmd:execute:${args.name}:result`; 62 + api.subscribe(resultTopic, (result: any) => { 63 + resolve(result); 64 + }, api.scopes.GLOBAL); 65 + 66 + api.publish(`cmd:execute:${args.name}`, { 67 + search: args.search, 68 + params: [], 69 + expectResult: true, 70 + resultTopic 71 + }, api.scopes.GLOBAL); 72 + 73 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 74 + }); 75 + }, { name: 'tag', search: `#${tag1} #${tag2}` }); 76 + 77 + expect((result as any).success).toBe(true); 78 + 79 + // Verify tags are stored WITHOUT the # prefix 80 + const added = (result as any).added || []; 81 + expect(added).toContain(tag1); 82 + expect(added).toContain(tag2); 83 + // Ensure no # prefix leaked through 84 + expect(added.some((t: string) => t.startsWith('#'))).toBe(false); 85 + 86 + // Verify via datastore: find items tagged with tag1 (tag-centric check, 87 + // since getActiveWindow() may return a different window than testPageUrl) 88 + const itemCheck = await bgWindow.evaluate(async (tagName: string) => { 89 + const api = (window as any).app; 90 + const tagResult = await api.datastore.getOrCreateTag(tagName); 91 + if (!tagResult.success) return { found: false }; 92 + const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 93 + return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 }; 94 + }, tag1); 95 + 96 + expect(itemCheck.found).toBe(true); 97 + }); 98 + 99 + test('tag command without # prefix works the same way', async () => { 100 + const timestamp = Date.now(); 101 + const tagName = `testbaz${timestamp}`; 102 + 103 + const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 104 + const api = (window as any).app; 105 + return new Promise((resolve) => { 106 + const resultTopic = `cmd:execute:${args.name}:result`; 107 + api.subscribe(resultTopic, (result: any) => { 108 + resolve(result); 109 + }, api.scopes.GLOBAL); 110 + 111 + api.publish(`cmd:execute:${args.name}`, { 112 + search: args.search, 113 + params: [], 114 + expectResult: true, 115 + resultTopic 116 + }, api.scopes.GLOBAL); 117 + 118 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 119 + }); 120 + }, { name: 'tag', search: tagName }); 121 + 122 + expect((result as any).success).toBe(true); 123 + const added = (result as any).added || []; 124 + expect(added).toContain(tagName); 125 + }); 126 + 127 + test('tag command creates item if none exists', async () => { 128 + const timestamp = Date.now(); 129 + // Open a new page window with a URL that has no item yet 130 + const newUrl = `https://cmd-exec-new-item-${timestamp}.example.com/`; 131 + const tagName = `newtag${timestamp}`; 132 + 133 + // Close the shared page window so the new window becomes the "active" one 134 + // (getActiveWindow returns the first non-internal window) 135 + if (pageWindowId) { 136 + await bgWindow.evaluate(async (id: number) => { 137 + return await (window as any).app.window.close(id); 138 + }, pageWindowId); 139 + pageWindowId = null; 140 + await sleep(200); 141 + } 142 + 143 + const openResult = await bgWindow.evaluate(async (url: string) => { 144 + return await (window as any).app.window.open(url, { 145 + width: 800, 146 + height: 600, 147 + key: `cmd-exec-new-item-${Date.now()}` 148 + }); 149 + }, newUrl); 150 + expect(openResult.success).toBe(true); 151 + const newWindowId = openResult.id; 152 + 153 + // Give the window time to register 154 + await sleep(500); 155 + 156 + // Execute tag command — should create item and tag it 157 + const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 158 + const api = (window as any).app; 159 + return new Promise((resolve) => { 160 + const resultTopic = `cmd:execute:${args.name}:result`; 161 + api.subscribe(resultTopic, (result: any) => { 162 + resolve(result); 163 + }, api.scopes.GLOBAL); 164 + 165 + api.publish(`cmd:execute:${args.name}`, { 166 + search: args.search, 167 + params: [], 168 + expectResult: true, 169 + resultTopic 170 + }, api.scopes.GLOBAL); 171 + 172 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 173 + }); 174 + }, { name: 'tag', search: `#${tagName}` }); 175 + 176 + expect((result as any).success).toBe(true); 177 + expect((result as any).added).toContain(tagName); 178 + 179 + // Verify an item was created and tagged (tag-centric check, 180 + // since getActiveWindow() may not return newUrl if other windows exist) 181 + const itemCheck = await bgWindow.evaluate(async (tag: string) => { 182 + const api = (window as any).app; 183 + const tagResult = await api.datastore.getOrCreateTag(tag); 184 + if (!tagResult.success) return { found: false }; 185 + const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 186 + return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 }; 187 + }, tagName); 188 + 189 + expect(itemCheck.found).toBe(true); 190 + 191 + // Reopen a shared page window for remaining tests 192 + const reopenResult = await bgWindow.evaluate(async (url: string) => { 193 + return await (window as any).app.window.open(url, { 194 + width: 800, 195 + height: 600, 196 + key: 'cmd-exec-test-page' 197 + }); 198 + }, testPageUrl); 199 + if (reopenResult.success && reopenResult.id) { 200 + pageWindowId = reopenResult.id; 201 + } 202 + await sleep(300); 203 + 204 + // Clean up the test window 205 + if (newWindowId) { 206 + await bgWindow.evaluate(async (id: number) => { 207 + return await (window as any).app.window.close(id); 208 + }, newWindowId); 209 + } 210 + }); 211 + 212 + test('untag command removes tags from item', async () => { 213 + const timestamp = Date.now(); 214 + const tagName = `untagme${timestamp}`; 215 + 216 + // First, tag the item via command execution 217 + const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 218 + const api = (window as any).app; 219 + return new Promise((resolve) => { 220 + const resultTopic = `cmd:execute:${args.name}:result`; 221 + api.subscribe(resultTopic, (result: any) => { 222 + resolve(result); 223 + }, api.scopes.GLOBAL); 224 + 225 + api.publish(`cmd:execute:${args.name}`, { 226 + search: args.search, 227 + params: [], 228 + expectResult: true, 229 + resultTopic 230 + }, api.scopes.GLOBAL); 231 + 232 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 233 + }); 234 + }, { name: 'tag', search: `#${tagName}` }); 235 + 236 + expect((tagResult as any).success).toBe(true); 237 + expect((tagResult as any).added).toContain(tagName); 238 + 239 + // Verify tag exists (tag-centric check) 240 + const beforeCheck = await bgWindow.evaluate(async (tag: string) => { 241 + const api = (window as any).app; 242 + const tagResult = await api.datastore.getOrCreateTag(tag); 243 + if (!tagResult.success) return { found: false }; 244 + const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 245 + return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 }; 246 + }, tagName); 247 + 248 + expect(beforeCheck.found).toBe(true); 249 + 250 + // Now untag via the untag command 251 + const untagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 252 + const api = (window as any).app; 253 + return new Promise((resolve) => { 254 + const resultTopic = `cmd:execute:${args.name}:result`; 255 + api.subscribe(resultTopic, (result: any) => { 256 + resolve(result); 257 + }, api.scopes.GLOBAL); 258 + 259 + api.publish(`cmd:execute:${args.name}`, { 260 + search: args.search, 261 + params: [], 262 + expectResult: true, 263 + resultTopic 264 + }, api.scopes.GLOBAL); 265 + 266 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 267 + }); 268 + }, { name: 'untag', search: `#${tagName}` }); 269 + 270 + expect((untagResult as any).success).toBe(true); 271 + 272 + // Verify tag is removed (no items with this tag anymore) 273 + const afterCheck = await bgWindow.evaluate(async (tag: string) => { 274 + const api = (window as any).app; 275 + const tagResult = await api.datastore.getOrCreateTag(tag); 276 + if (!tagResult.success) return { removed: false }; 277 + const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 278 + return { removed: !tagged.data || tagged.data.length === 0 }; 279 + }, tagName); 280 + 281 + expect(afterCheck.removed).toBe(true); 282 + }); 283 + 284 + test('tags page widget updates dynamically when tag is added via command', async () => { 285 + const timestamp = Date.now(); 286 + const setupTag = `setupdyn${timestamp}`; 287 + const dynamicTag = `testdynamic${timestamp}`; 288 + // Per-test unique URL + key. Prevents cross-test window / item reuse in 289 + // the shared-app full-suite context (where the describe's `pageWindowId` 290 + // may have accumulated state from earlier tests or been closed/reopened 291 + // with stale subscribers). Isolating this test to its own page window is 292 + // the robust fix for the full-suite ordering flake — the other tests in 293 + // this describe don't query #tags-list, so they tolerate the shared 294 + // window; only this one is sensitive to page.js subscriber state. 295 + const dynamicUrl = `https://cmd-exec-dyn-${timestamp}.example.com/`; 296 + const dynamicKey = `cmd-exec-dyn-${timestamp}`; 297 + 298 + // Close the shared page window so getActiveWindow() picks our fresh one 299 + // (matches the pattern in "tag command creates item if none exists"). 300 + const hadSharedWindow = pageWindowId !== null; 301 + if (pageWindowId) { 302 + await bgWindow.evaluate(async (id: number) => { 303 + return await (window as any).app.window.close(id); 304 + }, pageWindowId); 305 + pageWindowId = null; 306 + await sleep(200); 307 + } 308 + 309 + // Open our isolated page window 310 + const openResult = await bgWindow.evaluate(async (args: { url: string; key: string }) => { 311 + return await (window as any).app.window.open(args.url, { 312 + width: 800, 313 + height: 600, 314 + key: args.key 315 + }); 316 + }, { url: dynamicUrl, key: dynamicKey }); 317 + expect(openResult.success).toBe(true); 318 + const testWindowId = openResult.id; 319 + 320 + // Wait for page.js to initialize and subscribe to pubsub 321 + // (same 2s wait as in beforeAll — matches page.js init timing) 322 + await sleep(2000); 323 + 324 + // Helper to execute a tag command and wait for the result 325 + const executeTag = async (tag: string) => { 326 + return bgWindow.evaluate(async (args: { name: string; search: string }) => { 327 + const api = (window as any).app; 328 + return new Promise((resolve) => { 329 + const resultTopic = `cmd:execute:${args.name}:result`; 330 + api.subscribe(resultTopic, (result: any) => { 331 + resolve(result); 332 + }, api.scopes.GLOBAL); 333 + api.publish(`cmd:execute:${args.name}`, { 334 + search: args.search, 335 + params: [], 336 + expectResult: true, 337 + resultTopic 338 + }, api.scopes.GLOBAL); 339 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 340 + }); 341 + }, { name: 'tag', search: tag }); 342 + }; 343 + 344 + try { 345 + // Grab the page window handle first so we can gate on page.js readiness 346 + // BEFORE firing the setup tag. Under full-suite load the 2s sleep above 347 + // is not enough — the tag command can otherwise publish `tag:item-added` 348 + // before page.js has run its top-level `api.subscribe('tag:item-added', 349 + // ...)`. tile-preload's `subscribeImpl` attaches the underlying 350 + // `ipcRenderer.on('pubsub:tag:item-added')` listener synchronously from 351 + // page.js module evaluation, so gating on `__pageModuleReady` (the 352 + // sentinel flipped at the very bottom of page.js) guarantees the 353 + // listener is live. Electron's `webContents.send` is fire-and-forget — 354 + // a pubsub event that arrives before the listener is attached is 355 + // silently dropped, which is the root cause of the full-suite flake. 356 + const pageWindow = await app.getWindow(dynamicKey, 10000); 357 + expect(pageWindow).toBeTruthy(); 358 + await pageWindow.waitForFunction( 359 + () => (window as unknown as { __pageModuleReady?: boolean }).__pageModuleReady === true, 360 + null, 361 + { timeout: 10000 } 362 + ); 363 + 364 + // First tag establishes the item in the datastore and triggers the page's 365 + // resolveItemId fallback, setting currentItemId for subsequent events 366 + const setupResult = await executeTag(setupTag); 367 + expect((setupResult as any).success).toBe(true); 368 + 369 + // Wait for page.js to initialize and for the setup tag to appear 370 + // (proves the reactive update path works and currentItemId is set) 371 + await pageWindow.waitForFunction( 372 + (expected: string) => { 373 + const list = document.getElementById('tags-list'); 374 + if (!list) return false; 375 + const names = Array.from(list.querySelectorAll('.tag-name')) 376 + .map(el => el.textContent); 377 + return names.includes(expected); 378 + }, 379 + setupTag, 380 + { timeout: 10000 } 381 + ); 382 + 383 + // Record tag count after setup 384 + const tagCountBefore = await pageWindow.evaluate(() => { 385 + const list = document.getElementById('tags-list'); 386 + return list ? list.querySelectorAll('.tag-btn').length : 0; 387 + }); 388 + 389 + // Now add a second tag — this should update the widget reactively 390 + // because currentItemId is already set from the first tag 391 + const result = await executeTag(dynamicTag); 392 + expect((result as any).success).toBe(true); 393 + expect((result as any).added).toContain(dynamicTag); 394 + 395 + // Verify the tags widget updates dynamically 396 + await pageWindow.waitForFunction( 397 + (expectedTag: string) => { 398 + const list = document.getElementById('tags-list'); 399 + if (!list) return false; 400 + const tagNames = Array.from(list.querySelectorAll('.tag-name')) 401 + .map(el => el.textContent); 402 + return tagNames.includes(expectedTag); 403 + }, 404 + dynamicTag, 405 + { timeout: 10000 } 406 + ); 407 + 408 + // Tag count increased 409 + const tagCountAfter = await pageWindow.evaluate(() => { 410 + const list = document.getElementById('tags-list'); 411 + return list ? list.querySelectorAll('.tag-btn').length : 0; 412 + }); 413 + expect(tagCountAfter).toBeGreaterThan(tagCountBefore); 414 + } finally { 415 + // Close our isolated page window 416 + if (testWindowId) { 417 + await bgWindow.evaluate(async (id: number) => { 418 + return await (window as any).app.window.close(id); 419 + }, testWindowId); 420 + } 421 + 422 + // Reopen the shared page window so the remaining tests in this describe 423 + // (and the afterAll cleanup) have the state they expect. 424 + if (hadSharedWindow) { 425 + const reopenResult = await bgWindow.evaluate(async (url: string) => { 426 + return await (window as any).app.window.open(url, { 427 + width: 800, 428 + height: 600, 429 + key: 'cmd-exec-test-page' 430 + }); 431 + }, testPageUrl); 432 + if (reopenResult.success && reopenResult.id) { 433 + pageWindowId = reopenResult.id; 434 + } 435 + await sleep(300); 436 + } 437 + } 438 + }); 439 + 440 + test('tag command with no args returns current tags', async () => { 441 + const timestamp = Date.now(); 442 + const tagName = `showme${timestamp}`; 443 + 444 + // First add a tag so there's something to show 445 + const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 446 + const api = (window as any).app; 447 + return new Promise((resolve) => { 448 + const resultTopic = `cmd:execute:${args.name}:result`; 449 + api.subscribe(resultTopic, (result: any) => { 450 + resolve(result); 451 + }, api.scopes.GLOBAL); 452 + 453 + api.publish(`cmd:execute:${args.name}`, { 454 + search: args.search, 455 + params: [], 456 + expectResult: true, 457 + resultTopic 458 + }, api.scopes.GLOBAL); 459 + 460 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 461 + }); 462 + }, { name: 'tag', search: tagName }); 463 + 464 + expect((tagResult as any).success).toBe(true); 465 + 466 + // Now execute tag with no search args — should return current tags 467 + const result = await bgWindow.evaluate(async (args: { name: string }) => { 468 + const api = (window as any).app; 469 + return new Promise((resolve) => { 470 + const resultTopic = `cmd:execute:${args.name}:result`; 471 + api.subscribe(resultTopic, (result: any) => { 472 + resolve(result); 473 + }, api.scopes.GLOBAL); 474 + 475 + api.publish(`cmd:execute:${args.name}`, { 476 + search: '', 477 + params: [], 478 + expectResult: true, 479 + resultTopic 480 + }, api.scopes.GLOBAL); 481 + 482 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 483 + }); 484 + }, { name: 'tag' }); 485 + 486 + expect((result as any).success).toBe(true); 487 + // Should return tags array for the active window's URL 488 + expect(Array.isArray((result as any).tags)).toBe(true); 489 + // The tag we just added should be in the list 490 + const tagNames = (result as any).tags.map((t: any) => t.name); 491 + expect(tagNames).toContain(tagName); 492 + }); 493 + 494 + test('tagset command creates tagset item with tags stripped of #', async () => { 495 + const timestamp = Date.now(); 496 + const tag1 = `setx${timestamp}`; 497 + const tag2 = `sety${timestamp}`; 498 + 499 + // Execute tagset command with # prefixed tags, comma separated 500 + const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 501 + const api = (window as any).app; 502 + return new Promise((resolve) => { 503 + const resultTopic = `cmd:execute:${args.name}:result`; 504 + api.subscribe(resultTopic, (result: any) => { 505 + resolve(result); 506 + }, api.scopes.GLOBAL); 507 + 508 + api.publish(`cmd:execute:${args.name}`, { 509 + search: args.search, 510 + params: [], 511 + expectResult: true, 512 + resultTopic 513 + }, api.scopes.GLOBAL); 514 + 515 + setTimeout(() => resolve({ error: 'timeout' }), 10000); 516 + }); 517 + }, { name: 'tagset', search: `#${tag1}, #${tag2}` }); 518 + 519 + expect((result as any).success).toBe(true); 520 + expect((result as any).message).toContain(tag1); 521 + expect((result as any).message).toContain(tag2); 522 + 523 + // Verify the tagset item was created in the datastore 524 + const tagsetCheck = await bgWindow.evaluate(async (args: { tag1: string; tag2: string }) => { 525 + const api = (window as any).app; 526 + // Query for tagset items 527 + const queryResult = await api.datastore.queryItems({ type: 'tagset', limit: 50 }); 528 + if (!queryResult.success) return { found: false }; 529 + 530 + // Find our tagset by content (tags joined with ", ") 531 + const tagset = queryResult.data.find((item: any) => 532 + item.content.includes(args.tag1) && item.content.includes(args.tag2) 533 + ); 534 + if (!tagset) return { found: false }; 535 + 536 + // Get the tags on the tagset item 537 + const tagsResult = await api.datastore.getItemTags(tagset.id); 538 + return { 539 + found: true, 540 + itemId: tagset.id, 541 + content: tagset.content, 542 + tags: tagsResult.data?.map((t: any) => t.name) || [] 543 + }; 544 + }, { tag1, tag2 }); 545 + 546 + expect(tagsetCheck.found).toBe(true); 547 + // Tags should be stored without # prefix 548 + expect(tagsetCheck.tags).toContain(tag1); 549 + expect(tagsetCheck.tags).toContain(tag2); 550 + // The content field should contain the normalized tag names 551 + expect(tagsetCheck.content).toContain(tag1); 552 + expect(tagsetCheck.content).toContain(tag2); 553 + // Should also have the from:cmd tag 554 + expect(tagsetCheck.tags).toContain('from:cmd'); 555 + }); 556 + });
+69
tests/desktop/command-registration.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForExtensionsReady } from '../helpers/window-utils'; 5 + 6 + test.describe('Command Registration Performance @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('cmd-perf')); 12 + // Wait for cmd extension to be fully ready before running performance tests 13 + await waitForExtensionsReady(bgWindow, 15000); 14 + }); 15 + 16 + test.afterAll(async () => { 17 + if (app) await app.close(); 18 + }); 19 + 20 + test('cmd:register-batch is handled by cmd extension', async () => { 21 + // Test that batch registration works by sending a batch and verifying commands appear 22 + const result = await bgWindow.evaluate(async () => { 23 + const api = (window as any).app; 24 + 25 + // Send a batch of test commands 26 + api.publish('cmd:register-batch', { 27 + commands: [ 28 + { name: 'test-batch-cmd-1', description: 'Test batch command 1', source: 'test' }, 29 + { name: 'test-batch-cmd-2', description: 'Test batch command 2', source: 'test' }, 30 + { name: 'test-batch-cmd-3', description: 'Test batch command 3', source: 'test' } 31 + ] 32 + }, api.scopes.GLOBAL); 33 + 34 + // Poll for commands to appear (deterministic retry instead of fixed timeout) 35 + const maxAttempts = 20; 36 + const pollInterval = 100; 37 + 38 + for (let attempt = 0; attempt < maxAttempts; attempt++) { 39 + await new Promise(r => setTimeout(r, pollInterval)); 40 + 41 + const commands = await new Promise<any[]>((resolve) => { 42 + const unsub = api.subscribe('cmd:query-commands-response', (msg: any) => { 43 + unsub?.(); 44 + resolve(msg.commands || []); 45 + }, api.scopes.GLOBAL); 46 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 47 + setTimeout(() => resolve([]), 500); 48 + }); 49 + 50 + const batchCmds = commands.filter((c: any) => c.name.startsWith('test-batch-cmd-')); 51 + if (batchCmds.length === 3) { 52 + return { 53 + totalCommands: commands.length, 54 + batchCommandsFound: batchCmds.length, 55 + batchCommandNames: batchCmds.map((c: any) => c.name) 56 + }; 57 + } 58 + } 59 + 60 + return { totalCommands: 0, batchCommandsFound: 0, batchCommandNames: [] }; 61 + }); 62 + 63 + expect(result.batchCommandsFound).toBe(3); 64 + expect(result.batchCommandNames).toContain('test-batch-cmd-1'); 65 + expect(result.batchCommandNames).toContain('test-batch-cmd-2'); 66 + expect(result.batchCommandNames).toContain('test-batch-cmd-3'); 67 + }); 68 + 69 + });
+166
tests/desktop/core.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForWindow, waitForCommand } from '../helpers/window-utils'; 5 + 6 + test.describe('Core Functionality @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('core')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('app launches and extensions load', async () => { 19 + // After v2 tile migration: 20 + // - V2 features load as separate background BrowserWindows (peek://{id}/background.html) 21 + // - Eager v2 features (e.g. entities, peeks, slides) launch at startup; 22 + // lazy v2 features (e.g. example) launch on first command/event 23 + 24 + // Check that at least one eager v2 background tile window exists. 25 + // peeks and slides are eager v2 background tiles that launch at startup. 26 + const v2BgWindow = await waitForWindow( 27 + () => app.windows(), 28 + 'peek://peeks/background.html', 29 + 15000 30 + ); 31 + expect(v2BgWindow).toBeDefined(); 32 + }); 33 + 34 + test('database is accessible', async () => { 35 + const result = await bgWindow.evaluate(async () => { 36 + return await (window as any).app.datastore.getStats(); 37 + }); 38 + expect(result.success).toBe(true); 39 + expect(typeof result.data.totalAddresses).toBe('number'); 40 + }); 41 + 42 + test('commands are registered', async () => { 43 + // Commands are now owned by the cmd extension via pubsub 44 + // Query via cmd:query-commands topic with retry for extension loading 45 + const result = await bgWindow.evaluate(async () => { 46 + const api = (window as any).app; 47 + 48 + const queryCommands = () => new Promise((resolve) => { 49 + api.subscribe('cmd:query-commands-response', (msg: any) => { 50 + resolve(msg.commands || []); 51 + }, api.scopes.GLOBAL); 52 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 53 + setTimeout(() => resolve([]), 1000); 54 + }); 55 + 56 + // Retry a few times to allow extensions to finish loading 57 + for (let i = 0; i < 5; i++) { 58 + const cmds = await queryCommands() as any[]; 59 + if (cmds.some((c: any) => c.name === 'example:gallery')) { 60 + return cmds; 61 + } 62 + await new Promise(r => setTimeout(r, 500)); 63 + } 64 + return await queryCommands(); 65 + }); 66 + expect(Array.isArray(result)).toBe(true); 67 + expect(result.length).toBeGreaterThan(0); 68 + 69 + // Should have gallery command from example extension 70 + const galleryCmd = result.find((c: any) => c.name === 'example:gallery'); 71 + expect(galleryCmd).toBeTruthy(); 72 + }); 73 + 74 + test('quit and restart commands are registered', async () => { 75 + // quit/restart are registered asynchronously during app boot (app/index.js). 76 + // Poll via waitForCommand before querying details to avoid startup-race flake. 77 + await waitForCommand(bgWindow, 'quit', 10000); 78 + await waitForCommand(bgWindow, 'restart', 10000); 79 + 80 + // Query commands via cmd extension to verify descriptions 81 + const result = await bgWindow.evaluate(async () => { 82 + const api = (window as any).app; 83 + 84 + return new Promise((resolve) => { 85 + api.subscribe('cmd:query-commands-response', (msg: any) => { 86 + const commands = msg.commands || []; 87 + resolve({ 88 + hasQuit: commands.some((c: any) => c.name === 'quit'), 89 + hasRestart: commands.some((c: any) => c.name === 'restart'), 90 + quitCmd: commands.find((c: any) => c.name === 'quit'), 91 + restartCmd: commands.find((c: any) => c.name === 'restart') 92 + }); 93 + }, api.scopes.GLOBAL); 94 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 95 + setTimeout(() => resolve({ hasQuit: false, hasRestart: false }), 2000); 96 + }); 97 + }); 98 + 99 + expect(result.hasQuit).toBe(true); 100 + expect(result.hasRestart).toBe(true); 101 + expect(result.quitCmd?.description).toBe('Quit the application'); 102 + expect(result.restartCmd?.description).toBe('Restart the application'); 103 + }); 104 + 105 + test('reload extension command is registered', async () => { 106 + const result = await bgWindow.evaluate(async () => { 107 + const api = (window as any).app; 108 + 109 + return new Promise((resolve) => { 110 + api.subscribe('cmd:query-commands-response', (msg: any) => { 111 + const commands = msg.commands || []; 112 + const reloadCmd = commands.find((c: any) => c.name === 'reload extension'); 113 + resolve({ 114 + hasReloadExtension: !!reloadCmd, 115 + description: reloadCmd?.description 116 + }); 117 + }, api.scopes.GLOBAL); 118 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 119 + setTimeout(() => resolve({ hasReloadExtension: false }), 2000); 120 + }); 121 + }); 122 + 123 + expect(result.hasReloadExtension).toBe(true); 124 + expect(result.description).toBe('Reload an external extension by ID'); 125 + }); 126 + 127 + test('api.quit and api.restart functions exist', async () => { 128 + const result = await bgWindow.evaluate(() => { 129 + const api = (window as any).app; 130 + return { 131 + hasQuit: typeof api.quit === 'function', 132 + hasRestart: typeof api.restart === 'function' 133 + }; 134 + }); 135 + 136 + expect(result.hasQuit).toBe(true); 137 + expect(result.hasRestart).toBe(true); 138 + }); 139 + 140 + test('window management works', async () => { 141 + // Open a test window 142 + const openResult = await bgWindow.evaluate(async () => { 143 + return await (window as any).app.window.open('about:blank', { 144 + width: 400, 145 + height: 300 146 + }); 147 + }); 148 + expect(openResult.success).toBe(true); 149 + expect(openResult.id).toBeDefined(); 150 + 151 + // Wait for window to open 152 + await app.getWindow('about:blank', 5000); 153 + 154 + // List windows 155 + const listResult = await bgWindow.evaluate(async () => { 156 + return await (window as any).app.window.list(); 157 + }); 158 + expect(listResult.success).toBe(true); 159 + expect(Array.isArray(listResult.windows)).toBe(true); 160 + 161 + // Close the window 162 + await bgWindow.evaluate(async (id: number) => { 163 + return await (window as any).app.window.close(id); 164 + }, openResult.id); 165 + }); 166 + });
+33
tests/desktop/cross-origin-fetch.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Cross-Origin Fetch @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('cors')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('peek:// pages can fetch from https:// origins', async () => { 18 + // peek:// scheme has corsEnabled: false, so fetch() to external origins should work. 19 + // If corsEnabled were true, this would throw "Failed to fetch" due to CORS. 20 + const result = await bgWindow.evaluate(async () => { 21 + try { 22 + const res = await fetch('https://public.api.bsky.app/xrpc/_health'); 23 + return { ok: res.ok, status: res.status, error: null }; 24 + } catch (err: any) { 25 + return { ok: false, status: 0, error: err.message }; 26 + } 27 + }); 28 + 29 + expect(result.error).toBeNull(); 30 + expect(result.ok).toBe(true); 31 + expect(result.status).toBe(200); 32 + }); 33 + });
+210
tests/desktop/data-persistence.spec.ts
··· 1 + import { test, expect, launchDesktopApp } from '../fixtures/desktop-app'; 2 + import { waitForExtensionsReady, sleep } from '../helpers/window-utils'; 3 + 4 + // ============================================================================ 5 + // Data Persistence Tests (consolidated - single restart for all persistence checks) 6 + // ============================================================================ 7 + 8 + test.describe('Data Persistence @desktop', () => { 9 + test('all data persists across restart (peeks, slides, addresses, tags, theme)', async () => { 10 + const PROFILE = 'test-all-persist-' + Date.now(); 11 + 12 + // ========== PHASE 1: Set up all data ========== 13 + let app = await launchDesktopApp(PROFILE); 14 + let bgWindow = await app.getBackgroundWindow(); 15 + 16 + // --- Peeks and Slides settings --- 17 + const testPeeks = [ 18 + { title: 'Test Peek 1', uri: 'https://test-peek-1.example.com', shortcut: 'Option+1' }, 19 + { title: 'Test Peek 2', uri: 'https://test-peek-2.example.com', shortcut: 'Option+2' }, 20 + { title: 'Custom Peek', uri: 'https://custom-peek.example.com', shortcut: 'Option+3' } 21 + ]; 22 + 23 + const testSlides = [ 24 + { title: 'Test Slide 1', uri: 'https://test-slide-1.example.com', position: 'right', size: 400 }, 25 + { title: 'Test Slide 2', uri: 'https://test-slide-2.example.com', position: 'bottom', size: 300 } 26 + ]; 27 + 28 + // Save peeks items 29 + const savePeeksResult = await bgWindow.evaluate(async (items) => { 30 + const api = (window as any).app; 31 + return await api.datastore.setRow('feature_settings', 'peeks:items', { 32 + featureId: 'peeks', 33 + key: 'items', 34 + value: JSON.stringify(items), 35 + updatedAt: Date.now() 36 + }); 37 + }, testPeeks); 38 + expect(savePeeksResult.success).toBe(true); 39 + 40 + // Save slides items 41 + const saveSlidesResult = await bgWindow.evaluate(async (items) => { 42 + const api = (window as any).app; 43 + return await api.datastore.setRow('feature_settings', 'slides:items', { 44 + featureId: 'slides', 45 + key: 'items', 46 + value: JSON.stringify(items), 47 + updatedAt: Date.now() 48 + }); 49 + }, testSlides); 50 + expect(saveSlidesResult.success).toBe(true); 51 + 52 + // Save prefs 53 + const savePeeksPrefs = await bgWindow.evaluate(async () => { 54 + const api = (window as any).app; 55 + return await api.datastore.setRow('feature_settings', 'peeks:prefs', { 56 + featureId: 'peeks', 57 + key: 'prefs', 58 + value: JSON.stringify({ shortcutKeyPrefix: 'Option+' }), 59 + updatedAt: Date.now() 60 + }); 61 + }); 62 + expect(savePeeksPrefs.success).toBe(true); 63 + 64 + const saveSlidesPrefs = await bgWindow.evaluate(async () => { 65 + const api = (window as any).app; 66 + return await api.datastore.setRow('feature_settings', 'slides:prefs', { 67 + featureId: 'slides', 68 + key: 'prefs', 69 + value: JSON.stringify({ defaultPosition: 'right', defaultSize: 350 }), 70 + updatedAt: Date.now() 71 + }); 72 + }); 73 + expect(saveSlidesPrefs.success).toBe(true); 74 + 75 + // --- Addresses and Tags --- 76 + const addr1 = await bgWindow.evaluate(async () => { 77 + return await (window as any).app.datastore.addAddress('https://persist-test-1.example.com', { 78 + title: 'Persist Test 1', 79 + starred: 1 80 + }); 81 + }); 82 + expect(addr1.success).toBe(true); 83 + 84 + const addr2 = await bgWindow.evaluate(async () => { 85 + return await (window as any).app.datastore.addAddress('https://persist-test-2.example.com', { 86 + title: 'Persist Test 2' 87 + }); 88 + }); 89 + expect(addr2.success).toBe(true); 90 + 91 + const tagResult = await bgWindow.evaluate(async () => { 92 + return await (window as any).app.datastore.getOrCreateTag('persist-tag'); 93 + }); 94 + expect(tagResult.success).toBe(true); 95 + const tagId = tagResult.data?.tag?.id || tagResult.data?.id; 96 + 97 + if (tagId && addr1.data?.id) { 98 + await bgWindow.evaluate(async ({ addressId, tagId }) => { 99 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 100 + }, { addressId: addr1.data.id, tagId }); 101 + } 102 + 103 + // --- Theme --- 104 + const setThemeResult = await bgWindow.evaluate(async () => { 105 + return await (window as any).app.theme.setTheme('peek'); 106 + }); 107 + expect(setThemeResult.success).toBe(true); 108 + 109 + // Verify theme is set before restart 110 + const themeState1 = await bgWindow.evaluate(async () => { 111 + return await (window as any).app.theme.get(); 112 + }); 113 + expect(themeState1.themeId).toBe('peek'); 114 + 115 + // Ensure data is flushed before closing 116 + await sleep(500); 117 + await app.close(); 118 + 119 + // Wait for app to fully shut down 120 + await sleep(1000); 121 + 122 + // ========== PHASE 2: Verify all data persisted ========== 123 + app = await launchDesktopApp(PROFILE); 124 + bgWindow = await app.getBackgroundWindow(); 125 + await waitForExtensionsReady(bgWindow); 126 + 127 + // --- Verify Peeks and Slides --- 128 + const persistedSettings = await bgWindow.evaluate(async () => { 129 + const api = (window as any).app; 130 + return await api.datastore.getTable('feature_settings'); 131 + }); 132 + expect(persistedSettings.success).toBe(true); 133 + 134 + const settingsData = persistedSettings.data as Record<string, any>; 135 + 136 + // Peeks items 137 + const peeksItems = settingsData['peeks:items']; 138 + expect(peeksItems).toBeTruthy(); 139 + expect(peeksItems.featureId).toBe('peeks'); 140 + const parsedPeeks = JSON.parse(peeksItems.value); 141 + expect(parsedPeeks.length).toBe(3); 142 + expect(parsedPeeks[0].title).toBe('Test Peek 1'); 143 + 144 + // Slides items 145 + const slidesItems = settingsData['slides:items']; 146 + expect(slidesItems).toBeTruthy(); 147 + const parsedSlides = JSON.parse(slidesItems.value); 148 + expect(parsedSlides.length).toBe(2); 149 + 150 + // Peeks prefs 151 + const peeksPrefs = settingsData['peeks:prefs']; 152 + expect(peeksPrefs).toBeTruthy(); 153 + const parsedPeeksPrefs = JSON.parse(peeksPrefs.value); 154 + expect(parsedPeeksPrefs.shortcutKeyPrefix).toBe('Option+'); 155 + 156 + // --- Verify Items (addresses are now stored as URL items) --- 157 + const itemsResult = await bgWindow.evaluate(async () => { 158 + return await (window as any).app.datastore.queryItems({ type: 'url' }); 159 + }); 160 + expect(itemsResult.success).toBe(true); 161 + 162 + const items = itemsResult.data; 163 + expect(items.length).toBeGreaterThanOrEqual(2); 164 + 165 + const persistedItem1 = items.find((a: any) => 166 + a.content === 'https://persist-test-1.example.com/' || 167 + a.content?.includes('persist-test-1') 168 + ); 169 + expect(persistedItem1).toBeTruthy(); 170 + expect(persistedItem1.title).toBe('Persist Test 1'); 171 + 172 + const tagsResult = await bgWindow.evaluate(async () => { 173 + return await (window as any).app.datastore.getTagsByFrecency(10); 174 + }); 175 + expect(tagsResult.success).toBe(true); 176 + const persistTag = tagsResult.data.find((t: any) => t.name === 'persist-tag'); 177 + expect(persistTag).toBeTruthy(); 178 + 179 + // --- Verify Theme --- 180 + const themeState2 = await bgWindow.evaluate(async () => { 181 + return await (window as any).app.theme.get(); 182 + }); 183 + expect(themeState2.themeId).toBe('peek'); 184 + 185 + // Open settings window to verify the theme CSS is loaded correctly 186 + await bgWindow.evaluate(async () => { 187 + return await (window as any).app.window.open('peek://app/settings/settings.html', { 188 + width: 800, height: 600 189 + }); 190 + }); 191 + 192 + const settingsWin = await app.getWindow('settings/settings.html', 5000); 193 + expect(settingsWin).toBeTruthy(); 194 + 195 + // Check that the theme CSS loaded (non-empty value for --theme-font-sans, 196 + // which the peek theme defines in variables.css). Fallback would yield an 197 + // empty string from getPropertyValue. Theme uses system sans proportional; 198 + // --theme-font-mono is the one with ServerMono. 199 + const fontVar = await settingsWin.evaluate(() => { 200 + return getComputedStyle(document.documentElement).getPropertyValue('--theme-font-sans'); 201 + }); 202 + expect(fontVar.trim().length).toBeGreaterThan(0); 203 + const monoVar = await settingsWin.evaluate(() => { 204 + return getComputedStyle(document.documentElement).getPropertyValue('--theme-font-mono'); 205 + }); 206 + expect(monoVar).toContain('ServerMono'); 207 + 208 + await app.close(); 209 + }); 210 + });
+226
tests/desktop/edit-param-mode.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForCommandResults, waitForPanelCommandsLoaded } from '../helpers/window-utils'; 5 + 6 + test.describe('Edit Command Param Mode @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('edit-param')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('Tab in param mode fills text, does NOT execute', async () => { 19 + // Create a test note so param mode has suggestions 20 + const createResult = await bgWindow.evaluate(async () => { 21 + return await (window as any).app.datastore.addItem('text', { 22 + content: '# Tab Test Note\nThis is a note for testing Tab in param mode.' 23 + }); 24 + }); 25 + expect(createResult.success).toBe(true); 26 + const noteId = createResult.data.id; 27 + 28 + // Open cmd panel 29 + const openResult = await bgWindow.evaluate(async () => { 30 + return await (window as any).app.window.open('peek://cmd/panel.html', { 31 + modal: true, 32 + width: 600, 33 + height: 400, 34 + frame: false, 35 + transparent: true, 36 + alwaysOnTop: true, 37 + center: true 38 + }); 39 + }); 40 + expect(openResult.success).toBe(true); 41 + 42 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 43 + expect(cmdWindow).toBeTruthy(); 44 + 45 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 46 + await waitForPanelCommandsLoaded(cmdWindow); 47 + 48 + // Type 'edit ' (with space) to commit to the edit command and enter param mode 49 + // (Tab would cycle to 'editor' since both match 'edit') 50 + await cmdWindow.fill('input', 'edit '); 51 + 52 + // Wait for param mode to activate 53 + await cmdWindow.waitForFunction(() => { 54 + const s = (window as any)._cmdState; 55 + return s.paramMode === true && s.paramCommand === 'edit'; 56 + }, { timeout: 5000 }); 57 + 58 + // Wait for param suggestions to load (items query) 59 + await cmdWindow.waitForFunction(() => { 60 + return (window as any)._cmdState.paramSuggestions.length > 0; 61 + }, { timeout: 10000 }); 62 + 63 + // Press Tab on a suggestion - should fill text, NOT execute 64 + await cmdWindow.keyboard.press('Tab'); 65 + 66 + // Verify param mode is still active (Tab fills, doesn't execute) 67 + const stillParamMode = await cmdWindow.evaluate(() => { 68 + return (window as any)._cmdState.paramMode === true; 69 + }); 70 + expect(stillParamMode).toBe(true); 71 + 72 + // Verify input text was updated with the suggestion value 73 + const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value); 74 + expect(inputValue.startsWith('edit ')).toBe(true); 75 + expect(inputValue.length).toBeGreaterThan('edit '.length); 76 + 77 + // Close the cmd window 78 + if (openResult.id) { 79 + await bgWindow.evaluate(async (id: number) => { 80 + return await (window as any).app.window.close(id); 81 + }, openResult.id); 82 + } 83 + 84 + // Clean up test note 85 + await bgWindow.evaluate(async (id: string) => { 86 + return await (window as any).app.datastore.deleteItem(id); 87 + }, noteId); 88 + }); 89 + 90 + test('Enter in param mode executes with correct itemId', async () => { 91 + // Create a test note 92 + const createResult = await bgWindow.evaluate(async () => { 93 + return await (window as any).app.datastore.addItem('text', { 94 + content: '# Enter Test Note\nThis is a note for testing Enter in param mode.' 95 + }); 96 + }); 97 + expect(createResult.success).toBe(true); 98 + const noteId = createResult.data.id; 99 + 100 + // Set up a listener for editor:open events BEFORE opening cmd panel 101 + await bgWindow.evaluate(async () => { 102 + (window as any).__editorOpenEvents = []; 103 + (window as any).app.subscribe('editor:open', (data: any) => { 104 + (window as any).__editorOpenEvents.push(data); 105 + }, { scope: 'global' }); 106 + }); 107 + 108 + // Open cmd panel 109 + const openResult = await bgWindow.evaluate(async () => { 110 + return await (window as any).app.window.open('peek://cmd/panel.html', { 111 + modal: true, 112 + width: 600, 113 + height: 400, 114 + frame: false, 115 + transparent: true, 116 + alwaysOnTop: true, 117 + center: true 118 + }); 119 + }); 120 + expect(openResult.success).toBe(true); 121 + 122 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 123 + expect(cmdWindow).toBeTruthy(); 124 + 125 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 126 + await waitForPanelCommandsLoaded(cmdWindow); 127 + 128 + // Type 'edit ' (with space) to commit to the edit command and enter param mode 129 + await cmdWindow.fill('input', 'edit '); 130 + 131 + // Wait for param mode and suggestions to load 132 + await cmdWindow.waitForFunction(() => { 133 + const s = (window as any)._cmdState; 134 + return s.paramMode === true && s.paramCommand === 'edit' && s.paramSuggestions.length > 0; 135 + }, { timeout: 10000 }); 136 + 137 + // Find the index of our test note in suggestions 138 + const testNoteIndex = await cmdWindow.evaluate((targetId: string) => { 139 + const suggestions = (window as any)._cmdState.paramSuggestions; 140 + return suggestions.findIndex((s: any) => s._item && s._item.id === targetId); 141 + }, noteId); 142 + 143 + // Navigate to the test note if needed 144 + if (testNoteIndex > 0) { 145 + for (let i = 0; i < testNoteIndex; i++) { 146 + await cmdWindow.keyboard.press('ArrowDown'); 147 + } 148 + } 149 + 150 + // Press Enter to execute with the selected item 151 + await cmdWindow.keyboard.press('Enter'); 152 + 153 + // Wait for editor:open event to be published 154 + await bgWindow.waitForFunction(() => { 155 + return (window as any).__editorOpenEvents && (window as any).__editorOpenEvents.length > 0; 156 + }, { timeout: 10000 }); 157 + 158 + // Verify editor:open was published with the correct itemId 159 + const editorEvents = await bgWindow.evaluate(() => { 160 + return (window as any).__editorOpenEvents; 161 + }); 162 + expect(editorEvents.length).toBeGreaterThan(0); 163 + const lastEvent = editorEvents[editorEvents.length - 1]; 164 + expect(lastEvent.itemId).toBe(noteId); 165 + 166 + // Close cmd window if still open 167 + if (openResult.id) { 168 + await bgWindow.evaluate(async (id: number) => { 169 + try { return await (window as any).app.window.close(id); } catch(e) { /* may already be closed */ } 170 + }, openResult.id); 171 + } 172 + 173 + // Clean up 174 + await bgWindow.evaluate(async (id: string) => { 175 + delete (window as any).__editorOpenEvents; 176 + return await (window as any).app.datastore.deleteItem(id); 177 + }, noteId); 178 + }); 179 + 180 + test('Tab in command mode completes name, does not execute', async () => { 181 + // Open cmd panel 182 + const openResult = await bgWindow.evaluate(async () => { 183 + return await (window as any).app.window.open('peek://cmd/panel.html', { 184 + modal: true, 185 + width: 600, 186 + height: 400, 187 + frame: false, 188 + transparent: true, 189 + alwaysOnTop: true, 190 + center: true 191 + }); 192 + }); 193 + expect(openResult.success).toBe(true); 194 + 195 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 196 + expect(cmdWindow).toBeTruthy(); 197 + 198 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 199 + await waitForPanelCommandsLoaded(cmdWindow); 200 + 201 + // Type partial command name 'edi' 202 + await cmdWindow.fill('input', 'edi'); 203 + await cmdWindow.keyboard.press('ArrowDown'); 204 + await waitForCommandResults(cmdWindow, 1, 10000); 205 + 206 + // Press Tab - should complete command name, not execute 207 + await cmdWindow.keyboard.press('Tab'); 208 + 209 + // Verify input is now 'edit' (completed from 'edi') 210 + const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value); 211 + expect(inputValue.toLowerCase().startsWith('edit')).toBe(true); 212 + 213 + // Verify the panel is still open and responsive (no command was executed) 214 + const panelStillOpen = await cmdWindow.evaluate(() => { 215 + return document.getElementById('command-input') !== null; 216 + }); 217 + expect(panelStillOpen).toBe(true); 218 + 219 + // Close the cmd window 220 + if (openResult.id) { 221 + await bgWindow.evaluate(async (id: number) => { 222 + return await (window as any).app.window.close(id); 223 + }, openResult.id); 224 + } 225 + }); 226 + });
+123
tests/desktop/extension-lifecycle.spec.ts
··· 1 + // NOTE: Tests in this describe have intra-test ordering dependencies: 2 + // validate → add → list → update (enable/disable) → remove 3 + // They must run in declared order. Under fullyParallel: false this is 4 + // guaranteed. If you ever enable per-test parallelism for this file, 5 + // refactor each test to be self-contained first. 6 + 7 + import { test, expect, DesktopApp, launchDesktopApp } from '../fixtures/desktop-app'; 8 + import { Page } from '@playwright/test'; 9 + import path from 'path'; 10 + import { fileURLToPath } from 'url'; 11 + 12 + const __filename = fileURLToPath(import.meta.url); 13 + const __dirname = path.dirname(__filename); 14 + const ROOT = path.join(__dirname, '../..'); 15 + 16 + test.describe('Extension Lifecycle @desktop', () => { 17 + let app: DesktopApp; 18 + let bgWindow: Page; 19 + 20 + const EXAMPLE_EXT_PATH = path.join(ROOT, 'features', 'example'); 21 + 22 + test.beforeAll(async () => { 23 + app = await launchDesktopApp('test-ext-lifecycle'); 24 + bgWindow = await app.getBackgroundWindow(); 25 + }); 26 + 27 + test.afterAll(async () => { 28 + if (app) await app.close(); 29 + }); 30 + 31 + test('validate extension folder', async () => { 32 + const result = await bgWindow.evaluate(async (extPath: string) => { 33 + return await (window as any).app.extensions.validateFolder(extPath); 34 + }, EXAMPLE_EXT_PATH); 35 + 36 + expect(result.success).toBe(true); 37 + expect(result.data).toBeTruthy(); 38 + expect(result.data.manifest).toBeTruthy(); 39 + expect(result.data.manifest.id || result.data.manifest.shortname || result.data.manifest.name).toBeTruthy(); 40 + }); 41 + 42 + test('add extension', async () => { 43 + // First validate to get manifest 44 + const validateResult = await bgWindow.evaluate(async (extPath: string) => { 45 + return await (window as any).app.extensions.validateFolder(extPath); 46 + }, EXAMPLE_EXT_PATH); 47 + 48 + const manifest = validateResult.data.manifest; 49 + 50 + // Add the extension 51 + const addResult = await bgWindow.evaluate(async ({ extPath, manifest }) => { 52 + return await (window as any).app.extensions.add(extPath, manifest, false); 53 + }, { extPath: EXAMPLE_EXT_PATH, manifest }); 54 + 55 + expect(addResult.success).toBe(true); 56 + expect(addResult.data).toBeTruthy(); 57 + expect(addResult.data.id).toBeTruthy(); 58 + }); 59 + 60 + test('list extensions includes added extension', async () => { 61 + const result = await bgWindow.evaluate(async () => { 62 + return await (window as any).app.extensions.getAll(); 63 + }); 64 + 65 + expect(result.success).toBe(true); 66 + expect(Array.isArray(result.data)).toBe(true); 67 + 68 + // Find the example extension 69 + const exampleExt = result.data.find((ext: any) => 70 + ext.id === 'example' || ext.path?.includes('example') 71 + ); 72 + expect(exampleExt).toBeTruthy(); 73 + }); 74 + 75 + test('update extension (enable/disable)', async () => { 76 + // Enable the extension 77 + const enableResult = await bgWindow.evaluate(async () => { 78 + return await (window as any).app.extensions.update('example', { enabled: true }); 79 + }); 80 + expect(enableResult.success).toBe(true); 81 + 82 + // Verify it's enabled (accept both boolean true and integer 1) 83 + const getResult1 = await bgWindow.evaluate(async () => { 84 + return await (window as any).app.extensions.get('example'); 85 + }); 86 + expect(getResult1.success).toBe(true); 87 + expect(getResult1.data.enabled === true || getResult1.data.enabled === 1).toBe(true); 88 + 89 + // Disable it 90 + const disableResult = await bgWindow.evaluate(async () => { 91 + return await (window as any).app.extensions.update('example', { enabled: false }); 92 + }); 93 + expect(disableResult.success).toBe(true); 94 + 95 + // Verify it's disabled 96 + const getResult2 = await bgWindow.evaluate(async () => { 97 + return await (window as any).app.extensions.get('example'); 98 + }); 99 + expect(getResult2.success).toBe(true); 100 + expect(getResult2.data.enabled === false || getResult2.data.enabled === 0).toBe(true); 101 + }); 102 + 103 + test('remove extension', async () => { 104 + const removeResult = await bgWindow.evaluate(async () => { 105 + return await (window as any).app.extensions.remove('example'); 106 + }); 107 + expect(removeResult.success).toBe(true); 108 + 109 + // Verify it's removed 110 + const getResult = await bgWindow.evaluate(async () => { 111 + return await (window as any).app.extensions.get('example'); 112 + }); 113 + expect(getResult.success).toBe(false); 114 + 115 + // Verify it's not in list 116 + const listResult = await bgWindow.evaluate(async () => { 117 + return await (window as any).app.extensions.getAll(); 118 + }); 119 + expect(listResult.success).toBe(true); 120 + const exampleExt = listResult.data.find((ext: any) => ext.id === 'example'); 121 + expect(exampleExt).toBeFalsy(); 122 + }); 123 + });
+382
tests/desktop/external-url.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForExtensionsReady, waitForAppReady, sleep } from '../helpers/window-utils'; 5 + 6 + test.describe('External URL Opening @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('external-url')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('open URL by calling executable', async () => { 19 + // Verify app is ready with background window 20 + expect(bgWindow).toBeTruthy(); 21 + // Ensure the API is ready 22 + await waitForAppReady(bgWindow); 23 + }); 24 + 25 + test('cmd panel detects and opens domain without protocol (youtube.com)', async () => { 26 + await waitForExtensionsReady(bgWindow, 15000); 27 + 28 + // Open cmd panel 29 + const openResult = await bgWindow.evaluate(async () => { 30 + return await (window as any).app.window.open('peek://cmd/panel.html', { 31 + modal: true, 32 + width: 600, 33 + height: 50, 34 + frame: false, 35 + transparent: true, 36 + alwaysOnTop: true, 37 + center: true 38 + }); 39 + }); 40 + expect(openResult.success).toBe(true); 41 + 42 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 43 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 44 + 45 + // Type a domain without protocol 46 + await cmdWindow.fill('input', 'example.com'); 47 + await cmdWindow.keyboard.press('Enter'); 48 + 49 + // Wait for window to open 50 + await sleep(1000); 51 + 52 + // Verify URL was opened (check window list for the URL) 53 + const windowList = await bgWindow.evaluate(async () => { 54 + return await (window as any).app.window.list(); 55 + }); 56 + 57 + expect(windowList.success).toBe(true); 58 + // URL should be wrapped in page loader with https:// protocol 59 + const exampleWindow = windowList.windows?.find((w: any) => 60 + w.url && (w.url.includes('https://example.com') || w.url.includes('https%3A%2F%2Fexample.com')) 61 + ); 62 + expect(exampleWindow).toBeTruthy(); 63 + 64 + // Clean up 65 + if (exampleWindow) { 66 + await bgWindow.evaluate(async (id: number) => { 67 + await (window as any).app.window.close(id); 68 + }, exampleWindow.id); 69 + } 70 + }); 71 + 72 + test('cmd panel opens URL with http protocol', async () => { 73 + await waitForExtensionsReady(bgWindow, 15000); 74 + 75 + // Open cmd panel 76 + const openResult = await bgWindow.evaluate(async () => { 77 + return await (window as any).app.window.open('peek://cmd/panel.html', { 78 + modal: true, 79 + width: 600, 80 + height: 50, 81 + frame: false, 82 + transparent: true, 83 + alwaysOnTop: true, 84 + center: true 85 + }); 86 + }); 87 + expect(openResult.success).toBe(true); 88 + 89 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 90 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 91 + 92 + // Type URL with http protocol 93 + await cmdWindow.fill('input', 'http://example.com'); 94 + await cmdWindow.keyboard.press('Enter'); 95 + 96 + // Wait for window to open 97 + await sleep(1000); 98 + 99 + // Verify URL was opened (should preserve http://) 100 + const windowList = await bgWindow.evaluate(async () => { 101 + return await (window as any).app.window.list(); 102 + }); 103 + 104 + expect(windowList.success).toBe(true); 105 + const httpWindow = windowList.windows?.find((w: any) => 106 + w.url && (w.url.includes('http://example.com') || w.url.includes('http%3A%2F%2Fexample.com')) 107 + ); 108 + expect(httpWindow).toBeTruthy(); 109 + 110 + // Clean up 111 + if (httpWindow) { 112 + await bgWindow.evaluate(async (id: number) => { 113 + await (window as any).app.window.close(id); 114 + }, httpWindow.id); 115 + } 116 + }); 117 + 118 + test('cmd panel opens URL with https protocol', async () => { 119 + await waitForExtensionsReady(bgWindow, 15000); 120 + 121 + // Open cmd panel 122 + const openResult = await bgWindow.evaluate(async () => { 123 + return await (window as any).app.window.open('peek://cmd/panel.html', { 124 + modal: true, 125 + width: 600, 126 + height: 50, 127 + frame: false, 128 + transparent: true, 129 + alwaysOnTop: true, 130 + center: true 131 + }); 132 + }); 133 + expect(openResult.success).toBe(true); 134 + 135 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 136 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 137 + 138 + // Type URL with https protocol 139 + await cmdWindow.fill('input', 'https://example.com'); 140 + await cmdWindow.keyboard.press('Enter'); 141 + 142 + // Wait for window to open 143 + await sleep(1000); 144 + 145 + // Verify URL was opened 146 + const windowList = await bgWindow.evaluate(async () => { 147 + return await (window as any).app.window.list(); 148 + }); 149 + 150 + expect(windowList.success).toBe(true); 151 + const httpsWindow = windowList.windows?.find((w: any) => 152 + w.url && (w.url.includes('https://example.com') || w.url.includes('https%3A%2F%2Fexample.com')) 153 + ); 154 + expect(httpsWindow).toBeTruthy(); 155 + 156 + // Clean up 157 + if (httpsWindow) { 158 + await bgWindow.evaluate(async (id: number) => { 159 + await (window as any).app.window.close(id); 160 + }, httpsWindow.id); 161 + } 162 + }); 163 + 164 + test('cmd panel opens localhost URLs', async () => { 165 + await waitForExtensionsReady(bgWindow, 15000); 166 + 167 + // Open cmd panel 168 + const openResult = await bgWindow.evaluate(async () => { 169 + return await (window as any).app.window.open('peek://cmd/panel.html', { 170 + modal: true, 171 + width: 600, 172 + height: 50, 173 + frame: false, 174 + transparent: true, 175 + alwaysOnTop: true, 176 + center: true 177 + }); 178 + }); 179 + expect(openResult.success).toBe(true); 180 + 181 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 182 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 183 + 184 + // Type localhost with port 185 + await cmdWindow.fill('input', 'localhost:3000'); 186 + await cmdWindow.keyboard.press('Enter'); 187 + 188 + // Wait for window to open 189 + await sleep(1000); 190 + 191 + // Verify URL was opened (normalized to https://localhost:3000) 192 + const windowList = await bgWindow.evaluate(async () => { 193 + return await (window as any).app.window.list(); 194 + }); 195 + 196 + expect(windowList.success).toBe(true); 197 + const localhostWindow = windowList.windows?.find((w: any) => 198 + w.url && (w.url.includes('localhost:3000') || w.url.includes('localhost%3A3000')) 199 + ); 200 + expect(localhostWindow).toBeTruthy(); 201 + 202 + // Clean up 203 + if (localhostWindow) { 204 + await bgWindow.evaluate(async (id: number) => { 205 + await (window as any).app.window.close(id); 206 + }, localhostWindow.id); 207 + } 208 + }); 209 + 210 + test('cmd panel ignores non-URL non-command text on Enter', async () => { 211 + await waitForExtensionsReady(bgWindow, 15000); 212 + 213 + // Snapshot window list before 214 + const beforeList = await bgWindow.evaluate(async () => { 215 + return await (window as any).app.window.list(); 216 + }); 217 + const beforeCount = beforeList.windows?.length || 0; 218 + 219 + // Open cmd panel 220 + const openResult = await bgWindow.evaluate(async () => { 221 + return await (window as any).app.window.open('peek://cmd/panel.html', { 222 + modal: true, 223 + width: 600, 224 + height: 50, 225 + frame: false, 226 + transparent: true, 227 + alwaysOnTop: true, 228 + center: true 229 + }); 230 + }); 231 + expect(openResult.success).toBe(true); 232 + 233 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 234 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 235 + 236 + // Type non-URL text (no dots, no protocol) — not a command either 237 + await cmdWindow.fill('input', 'notaurl'); 238 + await cmdWindow.keyboard.press('Enter'); 239 + 240 + // Wait briefly to confirm nothing happens 241 + await sleep(500); 242 + 243 + // Verify no new windows were opened (non-URL text is ignored, not routed anywhere) 244 + const afterList = await bgWindow.evaluate(async () => { 245 + return await (window as any).app.window.list(); 246 + }); 247 + 248 + // Should NOT be opened as a direct URL 249 + const directUrlWindow = afterList.windows?.find((w: any) => 250 + w.url === 'http://notaurl' || w.url === 'https://notaurl' 251 + ); 252 + expect(directUrlWindow).toBeFalsy(); 253 + 254 + // Should NOT be opened as a web search either (no fallback) 255 + const searchWindow = afterList.windows?.find((w: any) => 256 + w.url && w.url.includes('notaurl') 257 + ); 258 + expect(searchWindow).toBeFalsy(); 259 + 260 + // Close cmd panel if still open 261 + if (openResult.id) { 262 + await bgWindow.evaluate(async (id: number) => { 263 + try { 264 + await (window as any).app.window.close(id); 265 + } catch (e) { 266 + // Already closed 267 + } 268 + }, openResult.id); 269 + } 270 + }); 271 + 272 + test('external URL handler opens URL on first click (simulates OS open-url event)', async () => { 273 + // This test simulates clicking a URL from an external app (like clicking a link 274 + // in another app when Peek is set as default browser). 275 + // Tests the fix for: first click focuses app but doesn't open URL, second click works. 276 + 277 + await waitForExtensionsReady(bgWindow, 15000); 278 + 279 + // Get initial window count 280 + const initialList = await bgWindow.evaluate(async () => { 281 + return await (window as any).app.window.list(); 282 + }); 283 + const initialCount = initialList.windows?.length || 0; 284 + 285 + // Simulate external URL open event (what happens when clicking URL from another app) 286 + // This bypasses the normal window.open flow and tests the handleExternalUrl path 287 + const testUrl = 'https://example.com/external-test'; 288 + 289 + // Trigger external:open-url event directly (simulates what handleExternalUrl does) 290 + await bgWindow.evaluate(async (url: string) => { 291 + const api = (window as any).app; 292 + // Publish the same event that handleExternalUrl publishes — core 293 + // renderer subscribes on GLOBAL scope, so publish with GLOBAL explicitly 294 + // (default is SELF in tile-preload). 295 + await api.publish('external:open-url', { 296 + url, 297 + trackingSource: 'external', 298 + trackingSourceId: 'os', 299 + timestamp: Date.now() 300 + }, api.scopes.GLOBAL); 301 + }, testUrl); 302 + 303 + // Wait for window to be created (give it time to process the event) 304 + await sleep(500); 305 + 306 + // Verify URL was opened 307 + const finalList = await bgWindow.evaluate(async () => { 308 + return await (window as any).app.window.list(); 309 + }); 310 + 311 + expect(finalList.success).toBe(true); 312 + const finalCount = finalList.windows?.length || 0; 313 + 314 + // Should have created a new window 315 + expect(finalCount).toBeGreaterThan(initialCount); 316 + 317 + // Find the window with our test URL 318 + const externalWindow = finalList.windows?.find((w: any) => 319 + w.url && (w.url.includes(testUrl) || w.url.includes(encodeURIComponent(testUrl))) 320 + ); 321 + expect(externalWindow).toBeTruthy(); 322 + 323 + // Clean up 324 + if (externalWindow) { 325 + await bgWindow.evaluate(async (id: number) => { 326 + await (window as any).app.window.close(id); 327 + }, externalWindow.id); 328 + } 329 + }); 330 + 331 + test('handleExternalUrl from main process opens URL correctly', async () => { 332 + // This tests the REAL external URL path — calling handleExternalUrl from 333 + // the main process, which is what happens when macOS sends an open-url event 334 + // or when Peek receives a second-instance signal with a URL argument. 335 + // The previous test simulates via pubsub from the renderer; this test 336 + // exercises the full main-process -> pubsub -> renderer -> window-open flow. 337 + 338 + await waitForExtensionsReady(bgWindow, 15000); 339 + 340 + // Get initial window count (include internal to see ALL windows) 341 + const initialList = await bgWindow.evaluate(async () => { 342 + return await (window as any).app.window.list(); 343 + }); 344 + const initialCount = initialList.windows?.length || 0; 345 + 346 + const testUrl = 'https://example.com/main-process-external-test'; 347 + 348 + // Call handleExternalUrl from the main process (simulates real OS open-url) 349 + const mainResult = await app.evaluateMain!(({ app }) => { 350 + const { handleExternalUrl } = (globalThis as any).__peek_test; 351 + if (!handleExternalUrl) return { error: 'handleExternalUrl not found on __peek_test' }; 352 + handleExternalUrl('https://example.com/main-process-external-test', 'os'); 353 + return { success: true }; 354 + }); 355 + 356 + // Verify main process call succeeded 357 + expect((mainResult as any).error).toBeUndefined(); 358 + 359 + // Wait for window with the specific URL to appear (deterministic — no count-based checks) 360 + let externalWindow: any = null; 361 + const deadline = Date.now() + 5000; 362 + while (Date.now() < deadline) { 363 + const list = await bgWindow.evaluate(async () => { 364 + return await (window as any).app.window.list(); 365 + }); 366 + externalWindow = list.windows?.find((w: any) => 367 + w.url && (w.url.includes('main-process-external-test') || w.url.includes(encodeURIComponent('main-process-external-test'))) 368 + ); 369 + if (externalWindow) break; 370 + await sleep(100); 371 + } 372 + 373 + expect(externalWindow).toBeTruthy(); 374 + 375 + // Clean up 376 + if (externalWindow) { 377 + await bgWindow.evaluate(async (id: number) => { 378 + await (window as any).app.window.close(id); 379 + }, externalWindow.id); 380 + } 381 + }); 382 + });
+158
tests/desktop/groups-navigation.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForWindowCount } from '../helpers/window-utils'; 5 + 6 + test.describe('Groups Navigation @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('groups-nav')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('groups to group to url and back navigation', async () => { 19 + // Create a tag/group with some items and promote it to a group 20 + const tagResult = await bgWindow.evaluate(async () => { 21 + const result = await (window as any).app.datastore.getOrCreateTag('test-group'); 22 + if (result.success) { 23 + const tag = result.data.tag; 24 + let meta = {}; 25 + try { meta = tag.metadata ? JSON.parse(tag.metadata) : {}; } catch {} 26 + meta.isGroup = true; 27 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify(meta) }); 28 + } 29 + return result; 30 + }); 31 + expect(tagResult.success).toBe(true); 32 + const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; 33 + 34 + // Add URL items and tag them 35 + const item1 = await bgWindow.evaluate(async () => { 36 + return await (window as any).app.datastore.addItem('url', { 37 + content: 'https://group-test-1.example.com', 38 + metadata: JSON.stringify({ title: 'Group Test 1' }) 39 + }); 40 + }); 41 + expect(item1.success).toBe(true); 42 + 43 + const item2 = await bgWindow.evaluate(async () => { 44 + return await (window as any).app.datastore.addItem('url', { 45 + content: 'https://group-test-2.example.com', 46 + metadata: JSON.stringify({ title: 'Group Test 2' }) 47 + }); 48 + }); 49 + expect(item2.success).toBe(true); 50 + 51 + // Tag the items 52 + if (tagId && item1.data?.id) { 53 + await bgWindow.evaluate(async ({ itemId, tagId }) => { 54 + return await (window as any).app.datastore.tagItem(itemId, tagId); 55 + }, { itemId: item1.data.id, tagId }); 56 + } 57 + 58 + if (tagId && item2.data?.id) { 59 + await bgWindow.evaluate(async ({ itemId, tagId }) => { 60 + return await (window as any).app.datastore.tagItem(itemId, tagId); 61 + }, { itemId: item2.data.id, tagId }); 62 + } 63 + 64 + // Open groups home 65 + const groupsResult = await bgWindow.evaluate(async () => { 66 + return await (window as any).app.window.open('peek://groups/home.html', { 67 + width: 800, 68 + height: 600 69 + }); 70 + }); 71 + expect(groupsResult.success).toBe(true); 72 + 73 + // Find the groups window (getWindow polls) 74 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 75 + expect(groupsWindow).toBeTruthy(); 76 + await groupsWindow.waitForLoadState('domcontentloaded'); 77 + 78 + // Wait for cards to render 79 + await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 80 + 81 + // Click on the test-group card 82 + const groupCard = await groupsWindow.$('peek-card.group-card[data-tag-id="' + tagId + '"]'); 83 + if (!groupCard) { 84 + const anyGroupCard = await groupsWindow.$('peek-card.group-card'); 85 + expect(anyGroupCard).toBeTruthy(); 86 + await anyGroupCard!.click(); 87 + } else { 88 + await groupCard.click(); 89 + } 90 + 91 + // Wait for navigation to addresses view (address cards appear) 92 + await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 93 + 94 + // Verify we're in addresses view by checking search placeholder 95 + const placeholderInGroup = await groupsWindow.evaluate(() => { 96 + const searchInput = document.querySelector('peek-input.search-input') as any; 97 + return searchInput ? searchInput.placeholder : null; 98 + }); 99 + expect(placeholderInGroup).toContain('Search in'); 100 + 101 + // Click on an address card 102 + const addressCard = await groupsWindow.$('peek-card.address-card'); 103 + expect(addressCard).toBeTruthy(); 104 + 105 + const windowCountBefore = app.windows().length; 106 + await addressCard!.click(); 107 + 108 + // Wait for new window to open 109 + await waitForWindowCount(() => app.windows(), windowCountBefore + 1, 5000); 110 + 111 + // Verify a new window was opened 112 + const windowCountAfter = app.windows().length; 113 + expect(windowCountAfter).toBeGreaterThan(windowCountBefore); 114 + 115 + // Navigate back to groups view 116 + // Note: Playwright's keyboard.press('Escape') doesn't reliably trigger 117 + // Electron's before-input-event handler, so we call the navigation function directly 118 + await groupsWindow.evaluate(async () => { 119 + const showGroups = (window as any).showGroups; 120 + if (showGroups) { 121 + await showGroups(); 122 + } 123 + }); 124 + 125 + // Small delay for async operations 126 + await new Promise(resolve => setTimeout(resolve, 100)); 127 + 128 + // Wait for groups view (group cards appear, address cards disappear) 129 + await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 }); 130 + 131 + // Verify we're back in groups view by checking search placeholder 132 + const placeholderInGroups = await groupsWindow.evaluate(() => { 133 + const searchInput = document.querySelector('peek-input.search-input') as any; 134 + return searchInput ? searchInput.placeholder : null; 135 + }); 136 + expect(placeholderInGroups).toBe('Search groups...'); 137 + 138 + // Clean up 139 + if (groupsResult.id) { 140 + try { 141 + await bgWindow.evaluate(async (id: number) => { 142 + return await (window as any).app.window.close(id); 143 + }, groupsResult.id); 144 + } catch { 145 + // Window may already be closed 146 + } 147 + } 148 + 149 + // Verify items can be retrieved by tag 150 + if (tagId) { 151 + const taggedItems = await bgWindow.evaluate(async (tId: string) => { 152 + return await (window as any).app.datastore.getItemsByTag(tId); 153 + }, tagId); 154 + expect(taggedItems.success).toBe(true); 155 + expect(taggedItems.data.length).toBeGreaterThanOrEqual(2); 156 + } 157 + }); 158 + });
+148
tests/desktop/groups-view.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Groups View @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('groups-view')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('empty groups are not shown in groups list', async () => { 18 + // Create an empty tag (group with no items) and promote it 19 + const emptyTag = await bgWindow.evaluate(async () => { 20 + const result = await (window as any).app.datastore.getOrCreateTag('empty-group-test'); 21 + if (result.success) { 22 + const tag = result.data.tag; 23 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) }); 24 + } 25 + return result; 26 + }); 27 + expect(emptyTag.success).toBe(true); 28 + 29 + // Create a tag with an item and promote it 30 + const nonEmptyTag = await bgWindow.evaluate(async () => { 31 + const result = await (window as any).app.datastore.getOrCreateTag('non-empty-group-test'); 32 + if (result.success) { 33 + const tag = result.data.tag; 34 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) }); 35 + } 36 + return result; 37 + }); 38 + expect(nonEmptyTag.success).toBe(true); 39 + 40 + const item = await bgWindow.evaluate(async () => { 41 + return await (window as any).app.datastore.addItem('url', { 42 + content: 'https://non-empty-group-addr.example.com', 43 + metadata: JSON.stringify({ title: 'Non Empty Group Address' }) 44 + }); 45 + }); 46 + expect(item.success).toBe(true); 47 + 48 + await bgWindow.evaluate(async ({ itemId, tagId }) => { 49 + return await (window as any).app.datastore.tagItem(itemId, tagId); 50 + }, { itemId: item.data.id, tagId: nonEmptyTag.data.tag.id }); 51 + 52 + // Open groups home 53 + const groupsResult = await bgWindow.evaluate(async () => { 54 + return await (window as any).app.window.open('peek://groups/home.html', { 55 + width: 800, 56 + height: 600 57 + }); 58 + }); 59 + expect(groupsResult.success).toBe(true); 60 + 61 + // Find the groups window (getWindow polls) 62 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 63 + expect(groupsWindow).toBeTruthy(); 64 + await groupsWindow.waitForLoadState('domcontentloaded'); 65 + 66 + // Wait for group cards to render (async data loading + rendering) 67 + await groupsWindow.waitForSelector(`peek-card.group-card[data-tag-id="${nonEmptyTag.data.tag.id}"]`, { timeout: 10000 }); 68 + 69 + // Get all group card tag IDs 70 + const groupCards = await groupsWindow.$$eval('peek-card.group-card', (cards: any[]) => 71 + cards.map(c => c.dataset.tagId) 72 + ); 73 + 74 + // Non-empty group should be shown 75 + expect(groupCards.includes(String(nonEmptyTag.data.tag.id))).toBe(true); 76 + 77 + // Empty groups ARE shown (so newly created groups appear immediately) 78 + expect(groupCards.includes(String(emptyTag.data.tag.id))).toBe(true); 79 + 80 + // Clean up 81 + if (groupsResult.id) { 82 + try { 83 + await bgWindow.evaluate(async (id: number) => { 84 + return await (window as any).app.window.close(id); 85 + }, groupsResult.id); 86 + } catch { 87 + // Window may already be closed 88 + } 89 + } 90 + }); 91 + 92 + test('Untagged group shows when there are untagged items', async () => { 93 + // Create an untagged URL item 94 + const testUrl = 'https://untagged-for-groups-view.example.com/'; 95 + const item = await bgWindow.evaluate(async (url: string) => { 96 + return await (window as any).app.datastore.addItem('url', { 97 + content: url, 98 + metadata: JSON.stringify({ title: 'Untagged For Groups View' }) 99 + }); 100 + }, testUrl); 101 + expect(item.success).toBe(true); 102 + 103 + // Verify the item exists and has no tags 104 + const itemTags = await bgWindow.evaluate(async (itemId: string) => { 105 + return await (window as any).app.datastore.getItemTags(itemId); 106 + }, item.data.id); 107 + expect(itemTags.success).toBe(true); 108 + expect(itemTags.data.length).toBe(0); 109 + 110 + // Open groups home 111 + const groupsResult = await bgWindow.evaluate(async () => { 112 + return await (window as any).app.window.open('peek://groups/home.html', { 113 + width: 800, 114 + height: 600, 115 + key: 'groups-untagged-test' 116 + }); 117 + }); 118 + expect(groupsResult.success).toBe(true); 119 + 120 + // Find the groups window (getWindow polls) 121 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 122 + expect(groupsWindow).toBeTruthy(); 123 + await groupsWindow.waitForLoadState('domcontentloaded'); 124 + 125 + // Wait for cards to render 126 + await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 127 + 128 + const untaggedCard = await groupsWindow.waitForSelector('peek-card.group-card[data-tag-id="__untagged__"]', { timeout: 5000 }).catch(() => null); 129 + expect(untaggedCard).toBeTruthy(); 130 + 131 + // Verify it shows the special-group class 132 + const hasSpecialClass = await untaggedCard!.evaluate((el: HTMLElement) => 133 + el.classList.contains('special-group') 134 + ); 135 + expect(hasSpecialClass).toBe(true); 136 + 137 + // Clean up 138 + if (groupsResult.id) { 139 + try { 140 + await bgWindow.evaluate(async (id: number) => { 141 + return await (window as any).app.window.close(id); 142 + }, groupsResult.id); 143 + } catch { 144 + // Window may already be closed 145 + } 146 + } 147 + }); 148 + });
+145
tests/desktop/hybrid-extension.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForWindow, sleep } from '../helpers/window-utils'; 5 + 6 + test.describe('Hybrid Extension Mode @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('hybrid-mode')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('v2 background tile windows exist as separate BrowserWindows', async () => { 19 + // V2 background tiles (peeks, slides) launch as separate hidden BrowserWindows 20 + // at peek://{id}/background.html — NOT as iframes in the extension host. 21 + const peeksWin = await waitForWindow( 22 + () => app.windows(), 23 + 'peek://peeks/background.html', 24 + 15000 25 + ); 26 + expect(peeksWin).toBeDefined(); 27 + 28 + const slidesWin = await waitForWindow( 29 + () => app.windows(), 30 + 'peek://slides/background.html', 31 + 15000 32 + ); 33 + expect(slidesWin).toBeDefined(); 34 + }); 35 + 36 + test('api.extensions.reload() reloads external extension', async () => { 37 + // Reload the example extension (external v2 tile — lazy). reload() re-reads 38 + // the manifest, revokes any existing token, and relaunches the tile if it 39 + // was loaded. For a lazy tile that hasn't been invoked yet, reload is a 40 + // no-op on the tile side but still succeeds (manifest re-read). 41 + const reloadResult = await bgWindow.evaluate(async () => { 42 + return await (window as any).app.extensions.reload('example'); 43 + }); 44 + 45 + expect(reloadResult.success).toBe(true); 46 + expect(reloadResult.data?.id).toBe('example'); 47 + }); 48 + 49 + test('api.extensions.reload() fails for consolidated extensions', async () => { 50 + // Consolidated extensions (like cmd, groups) cannot be reloaded 51 + const reloadResult = await bgWindow.evaluate(async () => { 52 + return await (window as any).app.extensions.reload('cmd'); 53 + }); 54 + 55 + expect(reloadResult.success).toBe(false); 56 + expect(reloadResult.error).toContain('Failed to reload'); 57 + }); 58 + 59 + test('commands work from both consolidated and external extensions', async () => { 60 + // Wait a bit for extensions to initialize and register commands 61 + await sleep(1000); 62 + 63 + // Query commands - should include commands from all extensions 64 + const result = await bgWindow.evaluate(async () => { 65 + const api = (window as any).app; 66 + 67 + return new Promise((resolve) => { 68 + const timeout = setTimeout(() => { 69 + resolve({ success: false, commandCount: 0 }); 70 + }, 10000); 71 + 72 + api.subscribe('cmd:query-commands-response', (msg: any) => { 73 + clearTimeout(timeout); 74 + resolve({ 75 + success: true, 76 + commandCount: msg.commands?.length || 0, 77 + // example:gallery comes from external 'example' extension 78 + hasGalleryCommand: msg.commands?.some((c: any) => c.name === 'example:gallery'), 79 + // settings comes from core (via consolidated cmd) 80 + hasSettingsCommand: msg.commands?.some((c: any) => c.name === 'settings') 81 + }); 82 + }, api.scopes.GLOBAL); 83 + 84 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 85 + }); 86 + }); 87 + 88 + expect(result.success).toBe(true); 89 + expect(result.commandCount).toBeGreaterThan(0); 90 + // example:gallery proves external extension commands work 91 + expect(result.hasGalleryCommand).toBe(true); 92 + // settings proves consolidated extension commands work 93 + expect(result.hasSettingsCommand).toBe(true); 94 + }); 95 + 96 + test('pubsub works between consolidated and external extensions', async () => { 97 + // Test pubsub routing between extensions in different modes 98 + // cmd (consolidated) receives query, responds to core 99 + const result = await bgWindow.evaluate(async () => { 100 + const api = (window as any).app; 101 + 102 + return new Promise((resolve) => { 103 + const timeout = setTimeout(() => { 104 + resolve({ received: false, commandCount: 0 }); 105 + }, 5000); 106 + 107 + api.subscribe('cmd:query-commands-response', (msg: any) => { 108 + clearTimeout(timeout); 109 + resolve({ 110 + received: true, 111 + commandCount: msg.commands?.length || 0 112 + }); 113 + }, api.scopes.GLOBAL); 114 + 115 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 116 + }); 117 + }); 118 + 119 + expect(result.received).toBe(true); 120 + expect(result.commandCount).toBeGreaterThan(0); 121 + }); 122 + 123 + test('correct window count for hybrid mode', async () => { 124 + // After v2-tile migration: 125 + // - 1 core background window (peek://app/background.html) 126 + // - Multiple v2 eager-background tile windows (peeks, slides, entities, … 127 + // served from peek://{id}/background.html) 128 + // - Lazy v2 tiles (including 'example') do NOT load at startup; they 129 + // only launch at peek://{id}/background.html on first command invoke. 130 + // - Plus any UI windows (settings, etc.) 131 + const windows = app.windows(); 132 + 133 + const coreBgWindows = windows.filter(w => w.url().includes('peek://app/background.html')); 134 + expect(coreBgWindows.length).toBe(1); 135 + 136 + // Eager v2 tile background windows exist; at least a couple expected 137 + // (peeks, slides were the canonical ones in v2 migration tests). 138 + const v2TileBgWindows = windows.filter(w => /peek:\/\/[a-z-]+\/background\.html/.test(w.url())); 139 + expect(v2TileBgWindows.length).toBeGreaterThan(0); 140 + 141 + // Lazy 'example' tile shouldn't have a window unless it was already 142 + // invoked in a previous test. Don't assert presence or absence — this 143 + // test is about the core/v2 shape, not example specifically. 144 + }); 145 + });
+214
tests/desktop/hybrid-settings.spec.ts
··· 1 + import { test, expect, launchDesktopApp } from '../fixtures/desktop-app'; 2 + import { waitForExtensionsReady, sleep } from '../helpers/window-utils'; 3 + 4 + // ============================================================================ 5 + // Extension Settings in Hybrid Mode Tests 6 + // ============================================================================ 7 + 8 + test.describe('Extension Settings in Hybrid Mode @desktop', () => { 9 + // Tests 1 and 2 share an instance (defaults check first, then modify) 10 + // Test 3 (restart test) uses its own instances 11 + let settingsApp: any; 12 + let settingsBgWindow: any; 13 + 14 + test.beforeAll(async () => { 15 + // Launch fresh app for settings tests (tests 1 and 2 share this) 16 + settingsApp = await launchDesktopApp(`test-settings-hybrid-${Date.now()}`); 17 + settingsBgWindow = await settingsApp.getBackgroundWindow(); 18 + await waitForExtensionsReady(settingsBgWindow, 15000); 19 + }); 20 + 21 + test.afterAll(async () => { 22 + if (settingsApp) await settingsApp.close(); 23 + }); 24 + 25 + // This test runs FIRST - checks defaults before any modifications 26 + test('extension falls back to defaults when no custom settings exist', async () => { 27 + // This test verifies that when no custom settings exist in the datastore, 28 + // extensions correctly use their default settings 29 + 30 + // Query cmd's current settings 31 + const result = await settingsBgWindow.evaluate(async () => { 32 + const api = (window as any).app; 33 + const defaultShortcut = 'Option+Space'; // cmd's default 34 + 35 + return new Promise((resolve) => { 36 + const timeout = setTimeout(() => { 37 + resolve({ success: false, error: 'timeout' }); 38 + }, 5000); 39 + 40 + api.subscribe('cmd:settings-changed', (msg: any) => { 41 + clearTimeout(timeout); 42 + resolve({ 43 + success: true, 44 + shortcutKey: msg?.prefs?.shortcutKey, 45 + isDefault: msg?.prefs?.shortcutKey === defaultShortcut 46 + }); 47 + }, api.scopes.GLOBAL); 48 + 49 + // Poke cmd to report its settings - use the default to not change it 50 + api.publish('cmd:settings-update', { 51 + data: { prefs: { shortcutKey: defaultShortcut } } 52 + }, api.scopes.GLOBAL); 53 + }); 54 + }); 55 + 56 + expect(result.success).toBe(true); 57 + expect(result.isDefault).toBe(true); 58 + expect(result.shortcutKey).toBe('Option+Space'); 59 + }); 60 + 61 + // This test runs SECOND - can modify settings since defaults already checked 62 + test('hybrid mode extensions can access settings via api.settings.get()', async () => { 63 + // This test verifies that extensions running at peek://{extId}/... URLs 64 + // (hybrid mode) can successfully use the settings API 65 + // 66 + // The preload must correctly detect these URLs as extensions and return 67 + // the proper extension ID for settings lookups 68 + // 69 + // We test this by updating settings via pubsub and verifying: 70 + // 1. cmd receives the update (which requires api.settings.get() to have worked during init) 71 + // 2. cmd persists the settings (which requires api.settings.set() to work) 72 + 73 + // Custom shortcut to test with 74 + const customShortcut = 'Option+Shift+T'; 75 + 76 + // Update cmd settings via pubsub 77 + const updateResult = await settingsBgWindow.evaluate(async (shortcut) => { 78 + const api = (window as any).app; 79 + 80 + return new Promise((resolve) => { 81 + const timeout = setTimeout(() => { 82 + resolve({ success: false, error: 'timeout waiting for settings change' }); 83 + }, 5000); 84 + 85 + // Subscribe to settings changed notification from cmd 86 + api.subscribe('cmd:settings-changed', (msg: any) => { 87 + clearTimeout(timeout); 88 + resolve({ 89 + success: true, 90 + receivedShortcut: msg?.prefs?.shortcutKey, 91 + matchesExpected: msg?.prefs?.shortcutKey === shortcut 92 + }); 93 + }, api.scopes.GLOBAL); 94 + 95 + // Update cmd settings via pubsub (this is how Settings UI does it) 96 + api.publish('cmd:settings-update', { 97 + data: { prefs: { shortcutKey: shortcut } } 98 + }, api.scopes.GLOBAL); 99 + }); 100 + }, customShortcut); 101 + 102 + expect(updateResult.success).toBe(true); 103 + expect(updateResult.matchesExpected).toBe(true); 104 + expect(updateResult.receivedShortcut).toBe(customShortcut); 105 + 106 + // Wait a moment for persistence to complete 107 + await sleep(200); 108 + 109 + // Now verify the settings were persisted to datastore 110 + // Note: extension-settings-set stores with id format ${extId}_${key} 111 + const persistResult = await settingsBgWindow.evaluate(async (expectedShortcut) => { 112 + const api = (window as any).app; 113 + const stored = await api.datastore.getRow('feature_settings', 'cmd_prefs'); 114 + 115 + if (!stored.success || !stored.data?.value) { 116 + return { success: false, error: 'No stored settings found', stored }; 117 + } 118 + 119 + const parsed = JSON.parse(stored.data.value); 120 + return { 121 + success: true, 122 + persistedShortcut: parsed.shortcutKey, 123 + wasPersisted: parsed.shortcutKey === expectedShortcut 124 + }; 125 + }, customShortcut); 126 + 127 + expect(persistResult.success).toBe(true); 128 + expect(persistResult.wasPersisted).toBe(true); 129 + expect(persistResult.persistedShortcut).toBe(customShortcut); 130 + }); 131 + 132 + // This test needs restart - uses its own isolated instances 133 + test('extension loads custom settings instead of defaults on startup', async () => { 134 + // This test verifies that when custom settings exist in the datastore, 135 + // extensions load those settings instead of their defaults 136 + // 137 + // We set up custom settings, close the app, relaunch with the same profile, 138 + // and verify the extension loaded the custom settings on init 139 + 140 + // Use a fixed profile name so we can relaunch with the same settings 141 + const profileName = `test-custom-settings-${Date.now()}`; 142 + 143 + // First, launch app to set up custom settings in the datastore 144 + const setupApp = await launchDesktopApp(profileName); 145 + const setupWindow = await setupApp.getBackgroundWindow(); 146 + 147 + const customShortcut = 'Option+Ctrl+P'; 148 + 149 + // Store custom settings (using format ${extId}_${key} to match extension-settings-set handler) 150 + const saveResult = await setupWindow.evaluate(async (shortcut) => { 151 + const api = (window as any).app; 152 + return await api.datastore.setRow('feature_settings', 'cmd_prefs', { 153 + featureId: 'cmd', 154 + key: 'prefs', 155 + value: JSON.stringify({ shortcutKey: shortcut }), 156 + updatedAt: Date.now() 157 + }); 158 + }, customShortcut); 159 + 160 + expect(saveResult.success).toBe(true); 161 + 162 + // Close and relaunch - extensions should load custom settings on init 163 + await setupApp.close(); 164 + 165 + // Small delay to ensure clean shutdown 166 + await sleep(500); 167 + 168 + // Relaunch with SAME profile to pick up saved settings 169 + const testApp = await launchDesktopApp(profileName); 170 + 171 + try { 172 + const testWindow = await testApp.getBackgroundWindow(); 173 + 174 + // Wait for extensions to be fully initialized using proper wait helper 175 + await waitForExtensionsReady(testWindow, 15000); 176 + 177 + // Verify cmd loaded the custom settings on startup 178 + // We update settings with the same value and verify it was already set 179 + const result = await testWindow.evaluate(async (expectedShortcut) => { 180 + const api = (window as any).app; 181 + 182 + return new Promise((resolve) => { 183 + const timeout = setTimeout(() => { 184 + resolve({ success: false, error: 'timeout' }); 185 + }, 10000); 186 + 187 + api.subscribe('cmd:settings-changed', (msg: any) => { 188 + clearTimeout(timeout); 189 + resolve({ 190 + success: true, 191 + shortcutKey: msg?.prefs?.shortcutKey, 192 + matchesCustom: msg?.prefs?.shortcutKey === expectedShortcut, 193 + // Check if it's NOT the default (Option+Space) 194 + isNotDefault: msg?.prefs?.shortcutKey !== 'Option+Space' 195 + }); 196 + }, api.scopes.GLOBAL); 197 + 198 + // Poke cmd to report its current settings (update with same value) 199 + api.publish('cmd:settings-update', { 200 + data: { prefs: { shortcutKey: expectedShortcut } } 201 + }, api.scopes.GLOBAL); 202 + }); 203 + }, customShortcut); 204 + 205 + expect(result.success).toBe(true); 206 + expect(result.isNotDefault).toBe(true); 207 + expect(result.matchesCustom).toBe(true); 208 + expect(result.shortcutKey).toBe(customShortcut); 209 + 210 + } finally { 211 + await testApp.close(); 212 + } 213 + }); 214 + });
+140
tests/desktop/item-events.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Item Events @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('item-events')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('item:created is emitted when item is added', async () => { 18 + const timestamp = Date.now(); 19 + const testUrl = `https://item-created-event-${timestamp}.example.com`; 20 + 21 + const result = await bgWindow.evaluate(async (url: string) => { 22 + const api = (window as any).app; 23 + 24 + return new Promise((resolve) => { 25 + const timeout = setTimeout(() => { 26 + resolve({ received: false, error: 'timeout' }); 27 + }, 5000); 28 + 29 + api.subscribe('item:created', (msg: any) => { 30 + if (msg.content === url) { 31 + clearTimeout(timeout); 32 + resolve({ 33 + received: true, 34 + itemId: msg.itemId, 35 + itemType: msg.itemType, 36 + content: msg.content 37 + }); 38 + } 39 + }, api.scopes.GLOBAL); 40 + 41 + // Create item to trigger the event 42 + api.datastore.addItem('url', { 43 + content: url, 44 + metadata: JSON.stringify({ title: 'Item Created Event Test' }) 45 + }); 46 + }); 47 + }, testUrl); 48 + 49 + expect((result as any).received).toBe(true); 50 + expect((result as any).itemId).toBeTruthy(); 51 + expect((result as any).itemType).toBe('url'); 52 + expect((result as any).content).toBe(testUrl); 53 + }); 54 + 55 + test('item:updated is emitted when item is updated', async () => { 56 + const timestamp = Date.now(); 57 + 58 + const result = await bgWindow.evaluate(async (ts: number) => { 59 + const api = (window as any).app; 60 + 61 + // First create an item 62 + const itemResult = await api.datastore.addItem('url', { 63 + content: `https://item-updated-event-${ts}.example.com`, 64 + metadata: JSON.stringify({ title: 'Item Updated Event Test' }) 65 + }); 66 + if (!itemResult.success) { 67 + return { received: false, error: 'failed to create item' }; 68 + } 69 + const itemId = itemResult.data.id; 70 + 71 + return new Promise((resolve) => { 72 + const timeout = setTimeout(() => { 73 + resolve({ received: false, error: 'timeout' }); 74 + }, 5000); 75 + 76 + api.subscribe('item:updated', (msg: any) => { 77 + if (msg.itemId === itemId) { 78 + clearTimeout(timeout); 79 + resolve({ 80 + received: true, 81 + itemId: msg.itemId, 82 + itemType: msg.itemType 83 + }); 84 + } 85 + }, api.scopes.GLOBAL); 86 + 87 + // Update item to trigger the event 88 + api.datastore.updateItem(itemId, { 89 + content: `https://item-updated-event-${ts}-modified.example.com` 90 + }); 91 + }); 92 + }, timestamp); 93 + 94 + expect((result as any).received).toBe(true); 95 + expect((result as any).itemId).toBeTruthy(); 96 + expect((result as any).itemType).toBe('url'); 97 + }); 98 + 99 + test('item:deleted is emitted when item is deleted', async () => { 100 + const timestamp = Date.now(); 101 + 102 + const result = await bgWindow.evaluate(async (ts: number) => { 103 + const api = (window as any).app; 104 + 105 + // First create an item 106 + const itemResult = await api.datastore.addItem('url', { 107 + content: `https://item-deleted-event-${ts}.example.com`, 108 + metadata: JSON.stringify({ title: 'Item Deleted Event Test' }) 109 + }); 110 + if (!itemResult.success) { 111 + return { received: false, error: 'failed to create item' }; 112 + } 113 + const itemId = itemResult.data.id; 114 + 115 + return new Promise((resolve) => { 116 + const timeout = setTimeout(() => { 117 + resolve({ received: false, error: 'timeout' }); 118 + }, 5000); 119 + 120 + api.subscribe('item:deleted', (msg: any) => { 121 + if (msg.itemId === itemId) { 122 + clearTimeout(timeout); 123 + resolve({ 124 + received: true, 125 + itemId: msg.itemId, 126 + itemType: msg.itemType 127 + }); 128 + } 129 + }, api.scopes.GLOBAL); 130 + 131 + // Delete item to trigger the event 132 + api.datastore.deleteItem(itemId); 133 + }); 134 + }, timestamp); 135 + 136 + expect((result as any).received).toBe(true); 137 + expect((result as any).itemId).toBeTruthy(); 138 + expect((result as any).itemType).toBe('url'); 139 + }); 140 + });
+323
tests/desktop/izui-behavior.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { sleep } from '../helpers/window-utils'; 5 + 6 + test.describe('IZUI Behavior @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('izui-behavior')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('parentWindowId is set when opened from a content window', async () => { 19 + 20 + // Step 1: Open a content window (groups home) from the background window. 21 + // Since background.html is an internal URL, parentWindowId should be null. 22 + const groupsResult = await bgWindow.evaluate(async () => { 23 + return await (window as any).app.window.open('peek://groups/home.html', { 24 + width: 600, 25 + height: 400 26 + }); 27 + }); 28 + expect(groupsResult.success).toBe(true); 29 + const groupsWindowId = groupsResult.id; 30 + 31 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 32 + expect(groupsWindow).toBeTruthy(); 33 + await groupsWindow.waitForLoadState('domcontentloaded'); 34 + 35 + // Verify the groups window itself has parentWindowId=null (opened from background) 36 + const groupsListBefore = await bgWindow.evaluate(async (wid: number) => { 37 + const result = await (window as any).app.window.list({ includeInternal: true }); 38 + if (!result.success) return null; 39 + return result.windows.find((w: any) => w.id === wid); 40 + }, groupsWindowId); 41 + expect(groupsListBefore).toBeTruthy(); 42 + expect(groupsListBefore.params.parentWindowId).toBeNull(); 43 + 44 + // Step 2: Open a child window FROM the groups content window. 45 + // Since groups/home.html is a real content window (not background/extension-host), 46 + // the child should get parentWindowId set to the groups window's ID. 47 + const childResult = await groupsWindow.evaluate(async () => { 48 + return await (window as any).app.window.open('https://child-parent-test.example.com', { 49 + width: 400, 50 + height: 300 51 + }); 52 + }); 53 + expect(childResult.success).toBe(true); 54 + const childWindowId = childResult.id; 55 + 56 + // Verify child window has parentWindowId set to the groups window 57 + const childInfo = await bgWindow.evaluate(async (wid: number) => { 58 + const result = await (window as any).app.window.list({ includeInternal: true }); 59 + if (!result.success) return null; 60 + return result.windows.find((w: any) => w.id === wid); 61 + }, childWindowId); 62 + expect(childInfo).toBeTruthy(); 63 + expect(childInfo.params.parentWindowId).toBe(groupsWindowId); 64 + 65 + // Clean up 66 + for (const id of [childWindowId, groupsWindowId]) { 67 + if (id) { 68 + try { 69 + await bgWindow.evaluate(async (wid: number) => { 70 + return await (window as any).app.window.close(wid); 71 + }, id); 72 + } catch { /* window may already be closed */ } 73 + } 74 + } 75 + }); 76 + 77 + test('onEscape registers callback without changing backend escapeMode (role-based)', async () => { 78 + 79 + // Open a plain window with no escapeMode set (defaults to 'auto') 80 + const result = await bgWindow.evaluate(async () => { 81 + return await (window as any).app.window.open('about:blank', { 82 + width: 400, 83 + height: 300 84 + }); 85 + }); 86 + expect(result.success).toBe(true); 87 + const windowId = result.id; 88 + 89 + const contentWindow = await app.getWindow('about:blank', 5000); 90 + expect(contentWindow).toBeTruthy(); 91 + 92 + // Get escapeMode before registering handler 93 + const infoBefore = await bgWindow.evaluate(async (wid: number) => { 94 + const listResult = await (window as any).app.window.list({ includeInternal: true }); 95 + if (!listResult.success) return null; 96 + return listResult.windows.find((w: any) => w.id === wid); 97 + }, windowId); 98 + expect(infoBefore).toBeTruthy(); 99 + const escapeModeBefore = infoBefore.params.escapeMode; 100 + 101 + // Call api.escape.onEscape() — this should NOT change escapeMode on the backend 102 + // (self-declaration removed; role determines behavior now) 103 + await contentWindow.evaluate(() => { 104 + (window as any).app.escape.onEscape(() => ({ handled: false })); 105 + }); 106 + 107 + // Small delay to ensure any async IPC would have completed 108 + await new Promise(r => setTimeout(r, 300)); 109 + 110 + // Verify escapeMode was NOT changed by onEscape registration 111 + const infoAfter = await bgWindow.evaluate(async (wid: number) => { 112 + const listResult = await (window as any).app.window.list({ includeInternal: true }); 113 + if (!listResult.success) return null; 114 + return listResult.windows.find((w: any) => w.id === wid); 115 + }, windowId); 116 + expect(infoAfter).toBeTruthy(); 117 + expect(infoAfter.params.escapeMode).toBe(escapeModeBefore); 118 + 119 + // Verify the callback IS registered and responds via escape trigger 120 + const triggerResult = await contentWindow.evaluate(async () => { 121 + return await (window as any).app.escape.trigger(); 122 + }); 123 + expect(triggerResult).toEqual({ handled: false }); 124 + 125 + // Clean up 126 + if (windowId) { 127 + try { 128 + await bgWindow.evaluate(async (id: number) => { 129 + return await (window as any).app.window.close(id); 130 + }, windowId); 131 + } catch { /* window may already be closed */ } 132 + } 133 + }); 134 + 135 + test('izui-close-self closes the window', async () => { 136 + 137 + // Open a content window 138 + const result = await bgWindow.evaluate(async () => { 139 + return await (window as any).app.window.open('peek://groups/home.html', { 140 + width: 400, 141 + height: 300, 142 + escapeMode: 'navigate' 143 + }); 144 + }); 145 + expect(result.success).toBe(true); 146 + const windowId = result.id; 147 + 148 + const contentWindow = await app.getWindow('groups/home.html', 5000); 149 + expect(contentWindow).toBeTruthy(); 150 + await contentWindow.waitForLoadState('domcontentloaded'); 151 + 152 + // Close the window via the IPC path (tile:window:close). Fire-and-forget 153 + // — the IPC send doesn't block on the actual close. 154 + await bgWindow.evaluate(async (wid: number) => { 155 + await (window as any).app.window.close(wid); 156 + }, windowId); 157 + 158 + // Poll the window list until the closed window drops out. 5s is plenty 159 + // for a close — if it hasn't dropped by then, the close path is broken. 160 + await bgWindow.waitForFunction( 161 + async (wid: number) => { 162 + const listResult = await (window as any).app.window.list({ includeInternal: true }); 163 + if (!listResult.success) return false; 164 + return !listResult.windows.some((w: any) => w.id === wid); 165 + }, 166 + windowId, 167 + { timeout: 5000 } 168 + ); 169 + }); 170 + 171 + test('item:created fires from trackWindowLoad when opening external URL', async () => { 172 + const timestamp = Date.now(); 173 + const testUrl = `https://track-window-load-${timestamp}.example.com`; 174 + 175 + // Subscribe to item:created and then open a URL window 176 + const result = await bgWindow.evaluate(async (url: string) => { 177 + const api = (window as any).app; 178 + 179 + return new Promise((resolve) => { 180 + const timeout = setTimeout(() => { 181 + resolve({ received: false, error: 'timeout' }); 182 + }, 10000); 183 + 184 + api.subscribe('item:created', (msg: any) => { 185 + if (msg.content === url) { 186 + clearTimeout(timeout); 187 + resolve({ 188 + received: true, 189 + itemId: msg.itemId, 190 + itemType: msg.itemType, 191 + content: msg.content 192 + }); 193 + } 194 + }, api.scopes.GLOBAL); 195 + 196 + // Open a window with an external URL - this triggers trackWindowLoad 197 + // which emits item:created if the URL is new 198 + api.window.open(url, { 199 + width: 400, 200 + height: 300 201 + }); 202 + }); 203 + }, testUrl); 204 + 205 + expect((result as any).received).toBe(true); 206 + expect((result as any).itemId).toBeTruthy(); 207 + expect((result as any).itemType).toBe('url'); 208 + expect((result as any).content).toBe(testUrl); 209 + 210 + // Clean up - close the opened window 211 + const windowList = await bgWindow.evaluate(async () => { 212 + return await (window as any).app.window.list(); 213 + }); 214 + if (windowList.success) { 215 + for (const w of windowList.windows) { 216 + if (w.url?.includes('track-window-load')) { 217 + try { 218 + await bgWindow.evaluate(async (id: number) => { 219 + return await (window as any).app.window.close(id); 220 + }, w.id); 221 + } catch { /* ignore */ } 222 + } 223 + } 224 + } 225 + }); 226 + 227 + test('ESC debouncing: two rapid presses trigger only one handler call', async () => { 228 + 229 + // Open a groups window with navigate escape mode 230 + const result = await bgWindow.evaluate(async () => { 231 + return await (window as any).app.window.open('peek://groups/home.html', { 232 + width: 400, 233 + height: 300, 234 + escapeMode: 'navigate' 235 + }); 236 + }); 237 + expect(result.success).toBe(true); 238 + const windowId = result.id; 239 + 240 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 241 + expect(groupsWindow).toBeTruthy(); 242 + await groupsWindow.waitForLoadState('domcontentloaded'); 243 + await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 244 + 245 + // Navigate into a group to have a deep state (so ESC navigates back) 246 + // First create a group with items 247 + const tagResult = await bgWindow.evaluate(async () => { 248 + return await (window as any).app.datastore.getOrCreateTag('esc-debounce-test'); 249 + }); 250 + expect(tagResult.success).toBe(true); 251 + const tagId = tagResult.data?.tag?.id; 252 + 253 + const item = await bgWindow.evaluate(async () => { 254 + return await (window as any).app.datastore.addItem('url', { 255 + content: 'https://esc-debounce-test.example.com', 256 + metadata: JSON.stringify({ title: 'ESC Debounce Test' }) 257 + }); 258 + }); 259 + expect(item.success).toBe(true); 260 + 261 + if (tagId && item.data?.id) { 262 + await bgWindow.evaluate(async ({ itemId, tagId }) => { 263 + return await (window as any).app.datastore.tagItem(itemId, tagId); 264 + }, { itemId: item.data.id, tagId }); 265 + } 266 + 267 + // Refresh the groups view to pick up the new data 268 + // Navigate into the group to have a deep state. 269 + // Use a locator (auto-retrying) instead of elementHandle because the groups 270 + // view re-renders after tag:item-added pubsub events, detaching any handle. 271 + const groupLocator = groupsWindow.locator('peek-card.group-card').first(); 272 + try { 273 + await groupLocator.waitFor({ state: 'visible', timeout: 5000 }); 274 + await groupLocator.click({ timeout: 5000 }); 275 + await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 276 + } catch { 277 + // If no group cards render (e.g., visibility filtering), skip the deep 278 + // navigation — the ESC debounce behavior is independent of depth. 279 + } 280 + 281 + // Track how many times the escape handler is invoked by wrapping it 282 + // Use evaluate to set up a counter in the renderer 283 + await groupsWindow.evaluate(() => { 284 + (window as any).__escCallCount = 0; 285 + const origHandler = (window as any)._escapeCallback; 286 + if (origHandler) { 287 + (window as any)._origEscapeCallback = origHandler; 288 + // The preload stores the callback as _escapeCallback, but it's in a closure. 289 + // Instead, we'll use api.escape.onEscape to wrap the handler. 290 + (window as any).app.escape.onEscape(async () => { 291 + (window as any).__escCallCount++; 292 + return origHandler(); 293 + }); 294 + } 295 + }); 296 + 297 + // Send two ESC key presses in rapid succession (< 200ms apart) 298 + // The debouncing in windows.ts should filter the second one 299 + await groupsWindow.keyboard.press('Escape'); 300 + // Immediately press again - well within the 200ms debounce window 301 + await groupsWindow.keyboard.press('Escape'); 302 + 303 + // Wait a moment for any handlers to complete 304 + await sleep(500); 305 + 306 + // Check call count - due to debouncing, only 0 or 1 calls should have gone through 307 + // Note: Playwright keyboard.press sends both keyDown and keyUp, and the ESC handler 308 + // fires on keyDown via before-input-event. The debounce ensures rapid presses are collapsed. 309 + const callCount = await groupsWindow.evaluate(() => (window as any).__escCallCount); 310 + 311 + // The debounce should ensure at most 1 handler call for 2 rapid presses 312 + expect(callCount).toBeLessThanOrEqual(1); 313 + 314 + // Clean up 315 + if (windowId) { 316 + try { 317 + await bgWindow.evaluate(async (id: number) => { 318 + return await (window as any).app.window.close(id); 319 + }, windowId); 320 + } catch { /* window may already be closed */ } 321 + } 322 + }); 323 + });
+328
tests/desktop/izui-escape.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('IZUI Escape Protocol @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('izui-escape')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('navigate mode: escape navigates internally before requesting close', async () => { 18 + 19 + // Create a group with items so we can navigate into it 20 + const tagResult = await bgWindow.evaluate(async () => { 21 + return await (window as any).app.datastore.getOrCreateTag('izui-esc-test'); 22 + }); 23 + expect(tagResult.success).toBe(true); 24 + const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; 25 + 26 + const item = await bgWindow.evaluate(async () => { 27 + return await (window as any).app.datastore.addItem('url', { 28 + content: 'https://izui-esc-test.example.com', 29 + metadata: JSON.stringify({ title: 'IZUI ESC Test' }) 30 + }); 31 + }); 32 + expect(item.success).toBe(true); 33 + 34 + if (tagId && item.data?.id) { 35 + await bgWindow.evaluate(async ({ itemId, tagId }) => { 36 + return await (window as any).app.datastore.tagItem(itemId, tagId); 37 + }, { itemId: item.data.id, tagId }); 38 + } 39 + 40 + // Open groups window (background.js uses escapeMode: 'navigate') 41 + const groupsResult = await bgWindow.evaluate(async () => { 42 + return await (window as any).app.window.open('peek://groups/home.html', { 43 + width: 800, 44 + height: 600, 45 + escapeMode: 'navigate' 46 + }); 47 + }); 48 + expect(groupsResult.success).toBe(true); 49 + 50 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 51 + expect(groupsWindow).toBeTruthy(); 52 + await groupsWindow.waitForLoadState('domcontentloaded'); 53 + await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 54 + 55 + // Navigate to addresses view by clicking a group 56 + const groupCard = await groupsWindow.$('peek-card.group-card[data-tag-id="' + tagId + '"]'); 57 + if (groupCard) { 58 + await groupCard.click(); 59 + } else { 60 + const anyCard = await groupsWindow.$('peek-card.group-card'); 61 + expect(anyCard).toBeTruthy(); 62 + await anyCard!.click(); 63 + } 64 + await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 65 + 66 + // Verify we're in addresses view 67 + const viewBefore = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 68 + expect(viewBefore).toBe('addresses'); 69 + 70 + // Trigger escape via the IZUI chain - should navigate back to groups (handled: true) 71 + const escResult1 = await groupsWindow.evaluate(async () => { 72 + return await (window as any).app.escape.trigger(); 73 + }); 74 + expect(escResult1.handled).toBe(true); 75 + 76 + // Wait for navigation to complete — waitForSelector is the deterministic signal 77 + // that showGroups() has rendered (the setTimeout(0) in handleEscape fires and 78 + // DOM updates before group-card is inserted into the DOM). 79 + await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 }); 80 + 81 + // Verify we're back in groups view 82 + const viewAfter = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 83 + expect(viewAfter).toBe('groups'); 84 + 85 + // Trigger escape again at root - renderer returns { handled: false } at root 86 + // Backend handles close policy (child/transient windows close, active root stays) 87 + const escResult2 = await groupsWindow.evaluate(async () => { 88 + return await (window as any).app.escape.trigger(); 89 + }); 90 + // trigger() calls the handler directly - at root, groups returns { handled: false } 91 + expect(escResult2.handled).toBe(false); 92 + 93 + // Clean up - trigger() doesn't go through backend ESC path, so window is still open 94 + if (groupsResult.id) { 95 + try { 96 + await bgWindow.evaluate(async (id: number) => { 97 + return await (window as any).app.window.close(id); 98 + }, groupsResult.id); 99 + } catch { 100 + // Window may already be closed 101 + } 102 + } 103 + }); 104 + 105 + test('peek-card: Enter key activates card via card-click event', async () => { 106 + 107 + // Create a group with an item 108 + const tagResult = await bgWindow.evaluate(async () => { 109 + return await (window as any).app.datastore.getOrCreateTag('enter-key-test'); 110 + }); 111 + expect(tagResult.success).toBe(true); 112 + const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; 113 + 114 + const item = await bgWindow.evaluate(async () => { 115 + return await (window as any).app.datastore.addItem('url', { 116 + content: 'https://enter-key-test.example.com', 117 + metadata: JSON.stringify({ title: 'Enter Key Test' }) 118 + }); 119 + }); 120 + expect(item.success).toBe(true); 121 + 122 + if (tagId && item.data?.id) { 123 + await bgWindow.evaluate(async ({ itemId, tagId }) => { 124 + return await (window as any).app.datastore.tagItem(itemId, tagId); 125 + }, { itemId: item.data.id, tagId }); 126 + } 127 + 128 + // Open groups window 129 + const groupsResult = await bgWindow.evaluate(async () => { 130 + return await (window as any).app.window.open('peek://groups/home.html', { 131 + role: 'workspace', 132 + width: 800, 133 + height: 600 134 + }); 135 + }); 136 + expect(groupsResult.success).toBe(true); 137 + 138 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 139 + expect(groupsWindow).toBeTruthy(); 140 + await groupsWindow.waitForLoadState('domcontentloaded'); 141 + await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 }); 142 + 143 + // Verify we're in groups view 144 + const viewBefore = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 145 + expect(viewBefore).toBe('groups'); 146 + 147 + // Programmatically activate the first card (simulates Enter key path) 148 + const activated = await groupsWindow.evaluate(async () => { 149 + const card = document.querySelector('peek-card.group-card') as any; 150 + if (!card) return false; 151 + card.click(); 152 + return true; 153 + }); 154 + expect(activated).toBe(true); 155 + 156 + // Should navigate to addresses view 157 + await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 158 + const viewAfter = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 159 + expect(viewAfter).toBe('addresses'); 160 + 161 + // Clean up 162 + if (groupsResult.id) { 163 + try { 164 + await bgWindow.evaluate(async (id: number) => { 165 + return await (window as any).app.window.close(id); 166 + }, groupsResult.id); 167 + } catch { 168 + // Window may already be closed 169 + } 170 + } 171 + }); 172 + 173 + test('active mode: ESC at root does NOT close window', async () => { 174 + 175 + // Open groups window with role: 'workspace' (like the real groups extension does) 176 + // In headless/test mode, appFocused defaults to true → session is 'active' 177 + const groupsResult = await bgWindow.evaluate(async () => { 178 + return await (window as any).app.window.open('peek://groups/home.html', { 179 + role: 'workspace', 180 + width: 400, 181 + height: 300, 182 + }); 183 + }); 184 + expect(groupsResult.success).toBe(true); 185 + const groupsWindow = await app.getWindow('groups/home.html', 5000); 186 + expect(groupsWindow).toBeTruthy(); 187 + await groupsWindow.waitForLoadState('domcontentloaded'); 188 + await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 189 + 190 + // Verify the window's role is 'workspace' and session is 'active' 191 + const izuiState = await bgWindow.evaluate(async () => { 192 + return await (window as any).app.izui.getState(); 193 + }); 194 + expect(izuiState).toBe('active'); 195 + 196 + // Press ESC via keyboard — goes through full backend path: 197 + // before-input-event → handleEscapeForWindow → askRendererToHandleEscape → 198 + // renderer returns { handled: false } at root → escPolicy('active', 'workspace') → 'nothing' 199 + await groupsWindow.keyboard.press('Escape'); 200 + 201 + // Wait for the async ESC handling to complete 202 + await new Promise(resolve => setTimeout(resolve, 600)); 203 + 204 + // Verify window is still alive — if escPolicy is wrong, the window would be closed 205 + const stillAlive = await groupsWindow.evaluate(() => true).catch(() => false); 206 + expect(stillAlive).toBe(true); 207 + 208 + // Also verify the view is still at root (groups list) 209 + const view = await groupsWindow.evaluate(() => (window as any)._groupsState?.view); 210 + expect(view).toBe('groups'); 211 + 212 + // Clean up 213 + if (groupsResult.id) { 214 + try { 215 + await bgWindow.evaluate(async (wid: number) => { 216 + return await (window as any).app.window.close(wid); 217 + }, groupsResult.id); 218 + } catch { 219 + // Window may already be closed 220 + } 221 + } 222 + }); 223 + 224 + test('active mode: ESC on child-content window does NOT close it (regression)', async () => { 225 + 226 + // First open a workspace window (like groups) to establish an active session 227 + const workspaceResult = await bgWindow.evaluate(async () => { 228 + return await (window as any).app.window.open('peek://groups/home.html', { 229 + role: 'workspace', 230 + width: 400, 231 + height: 300, 232 + }); 233 + }); 234 + expect(workspaceResult.success).toBe(true); 235 + const workspaceWindow = await app.getWindow('groups/home.html', 5000); 236 + expect(workspaceWindow).toBeTruthy(); 237 + await workspaceWindow.waitForLoadState('domcontentloaded'); 238 + 239 + // Verify session is active 240 + const izuiState = await bgWindow.evaluate(async () => { 241 + return await (window as any).app.izui.getState(); 242 + }); 243 + expect(izuiState).toBe('active'); 244 + 245 + // Now open a child-content window (simulates opening a web page from groups) 246 + // Using the workspace window as the opener gives it child-content role 247 + const contentResult = await workspaceWindow.evaluate(async () => { 248 + return await (window as any).app.window.open('peek://search/home.html', { 249 + role: 'child-content', 250 + width: 400, 251 + height: 300, 252 + }); 253 + }); 254 + expect(contentResult.success).toBe(true); 255 + const contentWindow = await app.getWindow('search/home.html', 5000); 256 + expect(contentWindow).toBeTruthy(); 257 + await contentWindow.waitForLoadState('domcontentloaded'); 258 + 259 + // Trigger escape via the renderer callback directly — search/home.html has no 260 + // onEscape handler so trigger() returns { handled: false } immediately. 261 + // The regression: child-content would be closed on ESC before the escPolicy fix. 262 + // The backend policy (escPolicy('active','child-content') === 'nothing') is a 263 + // pure function tested in izui-state.test.ts. Here we verify the renderer path 264 + // doesn't close the window. 265 + const escResult = await contentWindow.evaluate(async () => { 266 + return await (window as any).app.escape.trigger(); 267 + }); 268 + expect(escResult).toEqual({ handled: false }); 269 + 270 + // Verify child-content window is still alive — if the window were incorrectly 271 + // closed on ESC, this evaluate() call would throw/return false. 272 + const stillAlive = await contentWindow.evaluate(() => true).catch(() => false); 273 + expect(stillAlive).toBe(true); 274 + 275 + // Clean up 276 + for (const id of [contentResult.id, workspaceResult.id]) { 277 + if (id) { 278 + try { 279 + await bgWindow.evaluate(async (wid: number) => { 280 + return await (window as any).app.window.close(wid); 281 + }, id); 282 + } catch { 283 + // Window may already be closed 284 + } 285 + } 286 + } 287 + }); 288 + 289 + test('navigate mode: timeout does not close window', async () => { 290 + 291 + // Open a window with navigate escape mode but NO escape handler registered 292 + // This simulates what happens when a window hasn't finished loading its IZUI 293 + const result = await bgWindow.evaluate(async () => { 294 + return await (window as any).app.window.open('peek://groups/home.html', { 295 + width: 400, 296 + height: 300, 297 + escapeMode: 'navigate' 298 + }); 299 + }); 300 + expect(result.success).toBe(true); 301 + 302 + const testWindow = await app.getWindow('groups/home.html', 5000); 303 + expect(testWindow).toBeTruthy(); 304 + await testWindow.waitForLoadState('domcontentloaded'); 305 + 306 + // The groups extension registers an escape handler via api.escape.onEscape. 307 + // At root (groups view), handler returns { handled: false }. 308 + // Backend handles close policy via navigate mode. 309 + await testWindow.waitForSelector('.cards', { timeout: 5000 }); 310 + const escResult = await testWindow.evaluate(async () => { 311 + return await (window as any).app.escape.trigger(); 312 + }); 313 + // trigger() calls handler directly - at root, groups returns { handled: false } 314 + expect(escResult.handled).toBe(false); 315 + 316 + // trigger() doesn't go through backend ESC path, window is still open 317 + // Clean up 318 + if (result.id) { 319 + try { 320 + await bgWindow.evaluate(async (id: number) => { 321 + return await (window as any).app.window.close(id); 322 + }, result.id); 323 + } catch { 324 + // Window may already be closed 325 + } 326 + } 327 + }); 328 + });
+55
tests/desktop/peeks.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Peeks @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('peeks')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('add a peek and test it opens', async () => { 18 + // Add a peek address to the datastore 19 + const addResult = await bgWindow.evaluate(async () => { 20 + return await (window as any).app.datastore.addAddress('https://example.com', { 21 + title: 'Example Peek', 22 + description: 'Test peek for smoke tests' 23 + }); 24 + }); 25 + expect(addResult.success).toBe(true); 26 + 27 + // Verify peeks extension is loaded (hybrid mode: may be iframe or separate window) 28 + const runningExts = await bgWindow.evaluate(async () => { 29 + return await (window as any).app.extensions.list(); 30 + }); 31 + const peeksRunning = runningExts.data?.some((ext: any) => ext.id === 'peeks'); 32 + expect(peeksRunning).toBe(true); 33 + 34 + // Open a peek window for the address we created 35 + const peekResult = await bgWindow.evaluate(async () => { 36 + return await (window as any).app.window.open('https://example.com', { 37 + width: 800, 38 + height: 600, 39 + key: 'test-peek' 40 + }); 41 + }); 42 + expect(peekResult.success).toBe(true); 43 + 44 + // Wait for window to open (getWindow polls) 45 + const peekWindow = await app.getWindow('example.com', 5000); 46 + expect(peekWindow).toBeTruthy(); 47 + 48 + // Close the peek 49 + if (peekResult.id) { 50 + await bgWindow.evaluate(async (id: number) => { 51 + return await (window as any).app.window.close(id); 52 + }, peekResult.id); 53 + } 54 + }); 55 + });
+220
tests/desktop/scripts-extension.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForExtensionsReady } from '../helpers/window-utils'; 5 + 6 + test.describe('Scripts Extension @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('scripts')); 12 + }); 13 + 14 + test.afterAll(async () => { 15 + if (app) await app.close(); 16 + }); 17 + 18 + test('create, save, and execute script', async () => { 19 + // Wait for scripts extension to be ready 20 + await waitForExtensionsReady(bgWindow, 15000); 21 + 22 + // Create a new script directly via datastore 23 + const scriptId = await bgWindow.evaluate(async () => { 24 + const api = (window as any).app; 25 + const scriptId = `script_test_${Date.now()}`; 26 + 27 + // Get current settings from datastore 28 + const settingsTable = await api.datastore.getTable('feature_settings'); 29 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 30 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 31 + 32 + // Add new script 33 + const newScript = { 34 + id: scriptId, 35 + name: 'Test Script', 36 + description: 'A test script', 37 + code: 'const h1 = document.querySelector("h1"); return { title: h1?.textContent || "No h1 found" };', 38 + matchPatterns: ['https://example.com/*'], 39 + excludePatterns: [], 40 + runAt: 'document-end', 41 + enabled: true, 42 + createdAt: Date.now(), 43 + updatedAt: Date.now(), 44 + lastExecutedAt: null 45 + }; 46 + 47 + scripts.push(newScript); 48 + 49 + // Save back to datastore 50 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 51 + featureId: 'scripts', 52 + key: 'scripts', 53 + value: JSON.stringify(scripts), 54 + updatedAt: Date.now() 55 + }); 56 + 57 + return scriptId; 58 + }); 59 + 60 + expect(scriptId).toBeTruthy(); 61 + 62 + // Verify script was saved 63 + const savedScript = await bgWindow.evaluate(async (scriptId) => { 64 + const api = (window as any).app; 65 + const settingsTable = await api.datastore.getTable('feature_settings'); 66 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 67 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 68 + return scripts.find((s: any) => s.id === scriptId); 69 + }, scriptId); 70 + 71 + expect(savedScript).toBeTruthy(); 72 + expect(savedScript.name).toBe('Test Script'); 73 + 74 + // Execute script - test executor directly 75 + const executeResult = await bgWindow.evaluate(async (scriptId) => { 76 + const api = (window as any).app; 77 + const { scriptExecutor } = await import('peek://scripts/script-executor.js'); 78 + 79 + // Get the script from datastore 80 + const settingsTable = await api.datastore.getTable('feature_settings'); 81 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 82 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 83 + const script = scripts.find((s: any) => s.id === scriptId); 84 + 85 + if (!script) { 86 + return { success: false, error: 'Script not found' }; 87 + } 88 + 89 + // Execute directly 90 + const result = await scriptExecutor.executeScript(script, { 91 + url: 'https://example.com/test', 92 + pageDOM: document, 93 + pageWindow: window 94 + }); 95 + 96 + return { success: true, data: result }; 97 + }, scriptId); 98 + 99 + expect(executeResult).toHaveProperty('success', true); 100 + expect((executeResult as any).data.status).toBe('success'); 101 + 102 + // Clean up - delete script 103 + await bgWindow.evaluate(async (scriptId) => { 104 + const api = (window as any).app; 105 + const settingsTable = await api.datastore.getTable('feature_settings'); 106 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 107 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 108 + const filtered = scripts.filter((s: any) => s.id !== scriptId); 109 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 110 + featureId: 'scripts', 111 + key: 'scripts', 112 + value: JSON.stringify(filtered), 113 + updatedAt: Date.now() 114 + }); 115 + }, scriptId); 116 + }); 117 + 118 + test('script pattern matching works', async () => { 119 + // Test pattern matching directly 120 + const patternTests = await bgWindow.evaluate(async () => { 121 + // Import the script executor module 122 + const { ScriptExecutor } = await import('peek://scripts/script-executor.js'); 123 + const executor = new ScriptExecutor(); 124 + 125 + return { 126 + exactMatch: executor.matchPattern('https://example.com/*', 'https://example.com/page'), 127 + noMatch: executor.matchPattern('https://example.com/*', 'https://other.com/page'), 128 + wildcardProtocol: executor.matchPattern('*://example.com/*', 'https://example.com/page'), 129 + wildcardAll: executor.matchPattern('*', 'https://anything.com/page') 130 + }; 131 + }); 132 + 133 + expect(patternTests.exactMatch).toBe(true); 134 + expect(patternTests.noMatch).toBe(false); 135 + expect(patternTests.wildcardProtocol).toBe(true); 136 + expect(patternTests.wildcardAll).toBe(true); 137 + }); 138 + 139 + test('script timeout protection works', async () => { 140 + // Create a script that runs forever 141 + const scriptId = await bgWindow.evaluate(async () => { 142 + const api = (window as any).app; 143 + const scriptId = `script_timeout_test_${Date.now()}`; 144 + 145 + // Get current settings from datastore 146 + const settingsTable = await api.datastore.getTable('feature_settings'); 147 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 148 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 149 + 150 + // Add timeout test script 151 + const newScript = { 152 + id: scriptId, 153 + name: 'Timeout Test', 154 + code: 'while(true) {}', // Infinite loop 155 + matchPatterns: ['*'], 156 + excludePatterns: [], 157 + runAt: 'document-end', 158 + enabled: true, 159 + createdAt: Date.now(), 160 + updatedAt: Date.now(), 161 + lastExecutedAt: null 162 + }; 163 + 164 + scripts.push(newScript); 165 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 166 + featureId: 'scripts', 167 + key: 'scripts', 168 + value: JSON.stringify(scripts), 169 + updatedAt: Date.now() 170 + }); 171 + 172 + return scriptId; 173 + }); 174 + 175 + // Execute with short timeout - test executor directly 176 + const executeResult = await bgWindow.evaluate(async (scriptId) => { 177 + const api = (window as any).app; 178 + const { scriptExecutor } = await import('peek://scripts/script-executor.js'); 179 + 180 + // Get the script from datastore 181 + const settingsTable = await api.datastore.getTable('feature_settings'); 182 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 183 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 184 + const script = scripts.find((s: any) => s.id === scriptId); 185 + 186 + if (!script) { 187 + return { success: false, error: 'Script not found' }; 188 + } 189 + 190 + // Execute directly with timeout 191 + const result = await scriptExecutor.executeScript(script, { 192 + url: 'https://example.com', 193 + pageDOM: document, 194 + pageWindow: window, 195 + timeout: 100 // 100ms timeout 196 + }); 197 + 198 + return { success: true, data: result }; 199 + }, scriptId); 200 + 201 + expect(executeResult).toHaveProperty('success', true); 202 + expect((executeResult as any).data.status).toBe('error'); 203 + expect((executeResult as any).data.error).toContain('timeout'); 204 + 205 + // Clean up 206 + await bgWindow.evaluate(async (scriptId) => { 207 + const api = (window as any).app; 208 + const settingsTable = await api.datastore.getTable('feature_settings'); 209 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 210 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 211 + const filtered = scripts.filter((s: any) => s.id !== scriptId); 212 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 213 + featureId: 'scripts', 214 + key: 'scripts', 215 + value: JSON.stringify(filtered), 216 + updatedAt: Date.now() 217 + }); 218 + }, scriptId); 219 + }); 220 + });
+30
tests/desktop/settings.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Settings @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('settings')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('open and close settings', async () => { 18 + // Settings opens on start in debug mode 19 + const settingsWindow = await app.getWindow('settings/settings.html'); 20 + expect(settingsWindow).toBeTruthy(); 21 + 22 + // Verify content loaded 23 + await settingsWindow.waitForSelector('.settings-layout', { timeout: 5000 }); 24 + expect(await settingsWindow.$('.sidebar')).toBeTruthy(); 25 + expect(await settingsWindow.$('#sidebarNav')).toBeTruthy(); 26 + 27 + // Close via window.close() 28 + await settingsWindow.evaluate(() => window.close()); 29 + }); 30 + });
+97
tests/desktop/shortcut-roundtrip.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { sleep } from '../helpers/window-utils'; 5 + 6 + // ============================================================================ 7 + // Shortcut Roundtrip Tests 8 + // 9 + // Tests the full IPC roundtrip for shortcut registration and callback firing. 10 + // The flow: renderer registers shortcut via IPC -> main stores callback with ev.reply -> 11 + // shortcut fires -> callback calls ev.reply(replyTopic) -> renderer ipcRenderer.on fires cb. 12 + // 13 + // Since Playwright keyboard.press does NOT reliably trigger Electron's before-input-event, 14 + // we trigger the shortcut callback by calling handleLocalShortcut from the main process 15 + // via evaluateMain with a synthetic input event. 16 + // ============================================================================ 17 + 18 + test.describe('Shortcut Roundtrip @desktop', () => { 19 + let app: DesktopApp; 20 + let bgWindow: Page; 21 + 22 + test.beforeAll(async () => { 23 + ({ app, bgWindow } = await createPerDescribeApp('shortcut-roundtrip')); 24 + }); 25 + 26 + test.afterAll(async () => { 27 + if (app) await app.close(); 28 + }); 29 + 30 + test('local shortcut from background window roundtrip', async () => { 31 + // Register a local shortcut from bgWindow, trigger it via handleLocalShortcut 32 + // in the main process, verify callback fires in the renderer. 33 + // This tests the basic ev.reply roundtrip for a normal BrowserWindow WebContents. 34 + 35 + // Register a local shortcut from the bgWindow 36 + await bgWindow.evaluate(() => { 37 + (window as any).__shortcutFired = false; 38 + (window as any).app.shortcuts.register('Alt+F7', () => { 39 + (window as any).__shortcutFired = true; 40 + }); 41 + }); 42 + 43 + // Wait for IPC registration to propagate to main process 44 + await sleep(300); 45 + 46 + // Trigger the shortcut from the main process by calling handleLocalShortcut 47 + // with a synthetic input event matching Alt+F7. 48 + // NOTE: handleLocalShortcut invokes the stored callback synchronously, which 49 + // in turn calls ev.reply() to send an IPC message back to the renderer. The 50 + // ev.reply is an async side-effect that can cause Playwright to see the main 51 + // process "evaluate" context as destroyed if we return the raw result. To 52 + // avoid this flakiness, wrap the call in setImmediate + return via a 53 + // pre-computed flag so the evaluate settles cleanly before IPC fans out. 54 + const handled = await app.evaluateMain!(({ app }) => { 55 + try { 56 + const { handleLocalShortcut } = (globalThis as any).__peek_test; 57 + const result = handleLocalShortcut({ 58 + type: 'keyDown', 59 + alt: true, 60 + shift: false, 61 + meta: false, 62 + control: false, 63 + code: 'F7' 64 + }); 65 + return !!result; 66 + } catch (e: any) { 67 + return 'peek_test-failed: ' + e.message; 68 + } 69 + }).catch((err: any) => { 70 + // Playwright sometimes reports "Execution context was destroyed" when the 71 + // shortcut callback fans out async IPC (ev.reply) as a side-effect of the 72 + // evaluate. The shortcut still fires — the waitForFunction below will 73 + // confirm it. Treat this as a soft success. 74 + if (/context was destroyed/i.test(err?.message || '')) return true; 75 + throw err; 76 + }); 77 + 78 + // handleLocalShortcut should return true (shortcut was found and callback invoked) 79 + expect(handled).toBe(true); 80 + 81 + // Wait for the reply to reach the renderer and trigger the callback 82 + await bgWindow.waitForFunction( 83 + () => (window as any).__shortcutFired === true, 84 + { timeout: 5000 } 85 + ); 86 + 87 + const fired = await bgWindow.evaluate(() => (window as any).__shortcutFired); 88 + expect(fired).toBe(true); 89 + 90 + // Clean up 91 + await bgWindow.evaluate(() => { 92 + (window as any).app.shortcuts.unregister('Alt+F7'); 93 + delete (window as any).__shortcutFired; 94 + }); 95 + }); 96 + 97 + });
+49
tests/desktop/slides.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Slides @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('slides')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('add slides and test they work', async () => { 18 + // Add multiple addresses to use as slides 19 + const urls = [ 20 + 'https://slide1.example.com', 21 + 'https://slide2.example.com', 22 + 'https://slide3.example.com' 23 + ]; 24 + 25 + for (const url of urls) { 26 + const result = await bgWindow.evaluate(async (uri: string) => { 27 + return await (window as any).app.datastore.addAddress(uri, { 28 + title: `Slide: ${uri}`, 29 + starred: 1 30 + }); 31 + }, url); 32 + expect(result.success).toBe(true); 33 + } 34 + 35 + // Verify slides extension is loaded (hybrid mode: may be iframe or separate window) 36 + const runningExts = await bgWindow.evaluate(async () => { 37 + return await (window as any).app.extensions.list(); 38 + }); 39 + const slidesRunning = runningExts.data?.some((ext: any) => ext.id === 'slides'); 40 + expect(slidesRunning).toBe(true); 41 + 42 + // Query addresses to verify they were added 43 + const queryResult = await bgWindow.evaluate(async () => { 44 + return await (window as any).app.datastore.queryAddresses({ starred: 1, limit: 10 }); 45 + }); 46 + expect(queryResult.success).toBe(true); 47 + expect(queryResult.data.length).toBeGreaterThanOrEqual(3); 48 + }); 49 + });
-5347
tests/desktop/smoke.spec.ts
··· 1 - /** 2 - * Peek Desktop Smoke Tests 3 - * 4 - * Cross-backend tests that run against both Electron and Tauri. 5 - * Uses the desktopApp fixture for backend abstraction. 6 - * 7 - * Run with: 8 - * BACKEND=electron yarn test:desktop 9 - * BACKEND=tauri yarn test:desktop 10 - */ 11 - 12 - import { test, expect, DesktopApp, launchDesktopApp } from '../fixtures/desktop-app'; 13 - import { Page } from '@playwright/test'; 14 - import path from 'path'; 15 - import { fileURLToPath } from 'url'; 16 - import { waitForCommandResults, waitForWindowCount, waitForVisible, waitForClass, waitForResultsWithContent, waitForSelectionChange, sleep, waitForWindow, waitForExtensionsReady, queryCommandsWithRetry, waitForAppReady, waitForCommand, waitForPanelCommandsLoaded, waitForCmdNotExecuting } from '../helpers/window-utils'; 17 - 18 - const __filename = fileURLToPath(import.meta.url); 19 - const __dirname = path.dirname(__filename); 20 - const ROOT = path.join(__dirname, '../..'); 21 - 22 - // ============================================================================ 23 - // PER-DESCRIBE APP INSTANCES 24 - // Each describe block launches its own Electron instance so that window leaks, 25 - // stale lastFocusedVisibleWindowId, and datastore pollution cannot cross 26 - // describe boundaries. launchDesktopApp() is called in each describe's 27 - // beforeAll and the app is closed in afterAll. 28 - // ============================================================================ 29 - 30 - /** 31 - * Launch a fresh app + bgWindow for a single describe block. 32 - * Call from test.beforeAll; close result.app in test.afterAll. 33 - */ 34 - async function createPerDescribeApp(label: string): Promise<{ app: DesktopApp; bgWindow: Page }> { 35 - // Profile MUST start with "test" — `isTestProfile()` in backend/electron/config.ts 36 - // keys on that prefix to skip the single-instance lock. Without it, parallel 37 - // Playwright workers would all contend for the same machine-wide lock and 38 - // only one Electron launch would succeed. 39 - const profile = `test-smoke-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`; 40 - const app = await launchDesktopApp(profile); 41 - const bgWindow = await app.getBackgroundWindow(); 42 - await waitForExtensionsReady(bgWindow); 43 - return { app, bgWindow }; 44 - } 45 - 46 - // ============================================================================ 47 - // Settings Tests 48 - // ============================================================================ 49 - 50 - test.describe('Settings @desktop', () => { 51 - let app: DesktopApp; 52 - let bgWindow: Page; 53 - 54 - test.beforeAll(async () => { 55 - ({ app, bgWindow } = await createPerDescribeApp('settings')); 56 - }); 57 - 58 - test.afterAll(async () => { 59 - if (app) await app.close(); 60 - }); 61 - 62 - test('open and close settings', async () => { 63 - // Settings opens on start in debug mode 64 - const settingsWindow = await app.getWindow('settings/settings.html'); 65 - expect(settingsWindow).toBeTruthy(); 66 - 67 - // Verify content loaded 68 - await settingsWindow.waitForSelector('.settings-layout', { timeout: 5000 }); 69 - expect(await settingsWindow.$('.sidebar')).toBeTruthy(); 70 - expect(await settingsWindow.$('#sidebarNav')).toBeTruthy(); 71 - 72 - // Close via window.close() 73 - await settingsWindow.evaluate(() => window.close()); 74 - }); 75 - }); 76 - 77 - // ============================================================================ 78 - // Cross-Origin Fetch Tests 79 - // ============================================================================ 80 - 81 - test.describe('Cross-Origin Fetch @desktop', () => { 82 - let app: DesktopApp; 83 - let bgWindow: Page; 84 - 85 - test.beforeAll(async () => { 86 - ({ app, bgWindow } = await createPerDescribeApp('cors')); 87 - }); 88 - 89 - test.afterAll(async () => { 90 - if (app) await app.close(); 91 - }); 92 - 93 - test('peek:// pages can fetch from https:// origins', async () => { 94 - // peek:// scheme has corsEnabled: false, so fetch() to external origins should work. 95 - // If corsEnabled were true, this would throw "Failed to fetch" due to CORS. 96 - const result = await bgWindow.evaluate(async () => { 97 - try { 98 - const res = await fetch('https://public.api.bsky.app/xrpc/_health'); 99 - return { ok: res.ok, status: res.status, error: null }; 100 - } catch (err: any) { 101 - return { ok: false, status: 0, error: err.message }; 102 - } 103 - }); 104 - 105 - expect(result.error).toBeNull(); 106 - expect(result.ok).toBe(true); 107 - expect(result.status).toBe(200); 108 - }); 109 - }); 110 - 111 - // ============================================================================ 112 - // Command Palette Tests 113 - // ============================================================================ 114 - 115 - test.describe('Cmd Palette @desktop', () => { 116 - let app: DesktopApp; 117 - let bgWindow: Page; 118 - 119 - test.beforeAll(async () => { 120 - ({ app, bgWindow } = await createPerDescribeApp('cmd-palette')); 121 - }); 122 - 123 - test.afterAll(async () => { 124 - if (app) await app.close(); 125 - }); 126 - 127 - test('open cmd and execute gallery command', async () => { 128 - // Wait for cmd extension to be ready (critical for packaged mode where startup is slower) 129 - await waitForExtensionsReady(bgWindow, 15000); 130 - 131 - // Open cmd panel via window API 132 - const openResult = await bgWindow.evaluate(async () => { 133 - return await (window as any).app.window.open('peek://cmd/panel.html', { 134 - modal: true, 135 - width: 600, 136 - height: 50, 137 - frame: false, 138 - transparent: true, 139 - alwaysOnTop: true, 140 - center: true 141 - }); 142 - }); 143 - expect(openResult.success).toBe(true); 144 - 145 - // Find the cmd window (getWindow already polls until found) 146 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 147 - expect(cmdWindow).toBeTruthy(); 148 - 149 - // Wait for input to be ready and commands to be loaded 150 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 151 - await waitForPanelCommandsLoaded(cmdWindow, 10000); 152 - 153 - // Type a built-in command first to verify 154 - // Built-in commands (like 'settings') load faster than extension commands 155 - await cmdWindow.fill('input', 'settings'); 156 - // Press ArrowDown to show results (panel requires this to display dropdown) 157 - await cmdWindow.keyboard.press('ArrowDown'); 158 - await waitForCommandResults(cmdWindow, 1, 10000); // Longer timeout for initial load 159 - 160 - // Now search for the extension command 161 - await cmdWindow.fill('input', 'example:gallery'); 162 - await cmdWindow.keyboard.press('ArrowDown'); 163 - await waitForCommandResults(cmdWindow, 1, 10000); 164 - 165 - // Press Enter to execute 166 - await cmdWindow.keyboard.press('Enter'); 167 - 168 - // Close the cmd window 169 - if (openResult.id) { 170 - await bgWindow.evaluate(async (id: number) => { 171 - return await (window as any).app.window.close(id); 172 - }, openResult.id); 173 - } 174 - }); 175 - 176 - test('edit command Tab-completion shows autocomplete and opens editor', async () => { 177 - await waitForExtensionsReady(bgWindow, 15000); 178 - 179 - // Create a test note so the edit command has something to autocomplete 180 - const addResult = await bgWindow.evaluate(async () => { 181 - return await (window as any).app.datastore.addItem('text', { 182 - content: '# Edit Tab Test Note\nThis is a note for testing edit tab-completion.' 183 - }); 184 - }); 185 - expect(addResult.success).toBe(true); 186 - const noteId = addResult.data?.id; 187 - 188 - // Set up editor:open event capture BEFORE opening the cmd panel 189 - await bgWindow.evaluate(() => { 190 - (window as any).__editorOpenCaptured = []; 191 - (window as any).__editorOpenUnsub = (window as any).app.subscribe('editor:open', (data: any) => { 192 - (window as any).__editorOpenCaptured.push(data); 193 - }, (window as any).app.scopes.GLOBAL); 194 - }); 195 - 196 - // Open cmd panel 197 - const openResult = await bgWindow.evaluate(async () => { 198 - return await (window as any).app.window.open('peek://cmd/panel.html', { 199 - modal: true, 200 - width: 600, 201 - height: 50, 202 - frame: false, 203 - transparent: true, 204 - alwaysOnTop: true, 205 - center: true 206 - }); 207 - }); 208 - expect(openResult.success).toBe(true); 209 - 210 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 211 - expect(cmdWindow).toBeTruthy(); 212 - 213 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 214 - await waitForPanelCommandsLoaded(cmdWindow, 10000); 215 - 216 - // Type "edit " (with space) to commit to the edit command and enter param mode 217 - // (Tab would cycle to 'editor' since both "edit" and "editor" match) 218 - await cmdWindow.fill('input', 'edit '); 219 - 220 - // Wait for param mode and suggestions to populate 221 - await cmdWindow.waitForFunction( 222 - () => { 223 - const state = (window as any)._cmdState; 224 - return state && state.paramMode === true && state.paramCommand === 'edit' 225 - && state.paramSuggestions && state.paramSuggestions.length > 0; 226 - }, 227 - undefined, 228 - { timeout: 10000 } 229 - ); 230 - 231 - // Verify results are visible with suggestion items 232 - await waitForResultsWithContent(cmdWindow, 5000); 233 - 234 - // Press Enter to accept the first suggestion 235 - // This executes the edit command, publishes editor:open, and closes the panel 236 - await cmdWindow.keyboard.press('Enter'); 237 - 238 - // Verify editor:open was published by polling the captured events 239 - await bgWindow.waitForFunction(() => { 240 - return (window as any).__editorOpenCaptured && (window as any).__editorOpenCaptured.length > 0; 241 - }, undefined, { timeout: 10000 }); 242 - 243 - const editorOpenData = await bgWindow.evaluate(() => { 244 - return (window as any).__editorOpenCaptured[0]; 245 - }); 246 - expect(editorOpenData).toBeTruthy(); 247 - 248 - // Clean up event listener 249 - await bgWindow.evaluate(() => { 250 - if ((window as any).__editorOpenUnsub) { 251 - (window as any).__editorOpenUnsub(); 252 - } 253 - delete (window as any).__editorOpenCaptured; 254 - delete (window as any).__editorOpenUnsub; 255 - }); 256 - 257 - // Close cmd window if still open 258 - try { 259 - if (openResult.id) { 260 - await bgWindow.evaluate(async (id: number) => { 261 - return await (window as any).app.window.close(id); 262 - }, openResult.id); 263 - } 264 - } catch { 265 - // Panel may have already closed via shutdown() 266 - } 267 - 268 - // Clean up the test note 269 - if (noteId) { 270 - await bgWindow.evaluate(async (id: string) => { 271 - return await (window as any).app.datastore.deleteItem(id); 272 - }, noteId); 273 - } 274 - }); 275 - }); 276 - 277 - // ============================================================================ 278 - // Peeks Tests (uses shared app) 279 - // ============================================================================ 280 - 281 - test.describe('Peeks @desktop', () => { 282 - let app: DesktopApp; 283 - let bgWindow: Page; 284 - 285 - test.beforeAll(async () => { 286 - ({ app, bgWindow } = await createPerDescribeApp('peeks')); 287 - }); 288 - 289 - test.afterAll(async () => { 290 - if (app) await app.close(); 291 - }); 292 - 293 - test('add a peek and test it opens', async () => { 294 - // Add a peek address to the datastore 295 - const addResult = await bgWindow.evaluate(async () => { 296 - return await (window as any).app.datastore.addAddress('https://example.com', { 297 - title: 'Example Peek', 298 - description: 'Test peek for smoke tests' 299 - }); 300 - }); 301 - expect(addResult.success).toBe(true); 302 - 303 - // Verify peeks extension is loaded (hybrid mode: may be iframe or separate window) 304 - const runningExts = await bgWindow.evaluate(async () => { 305 - return await (window as any).app.extensions.list(); 306 - }); 307 - const peeksRunning = runningExts.data?.some((ext: any) => ext.id === 'peeks'); 308 - expect(peeksRunning).toBe(true); 309 - 310 - // Open a peek window for the address we created 311 - const peekResult = await bgWindow.evaluate(async () => { 312 - return await (window as any).app.window.open('https://example.com', { 313 - width: 800, 314 - height: 600, 315 - key: 'test-peek' 316 - }); 317 - }); 318 - expect(peekResult.success).toBe(true); 319 - 320 - // Wait for window to open (getWindow polls) 321 - const peekWindow = await app.getWindow('example.com', 5000); 322 - expect(peekWindow).toBeTruthy(); 323 - 324 - // Close the peek 325 - if (peekResult.id) { 326 - await bgWindow.evaluate(async (id: number) => { 327 - return await (window as any).app.window.close(id); 328 - }, peekResult.id); 329 - } 330 - }); 331 - }); 332 - 333 - // ============================================================================ 334 - // Slides Tests (uses shared app) 335 - // ============================================================================ 336 - 337 - test.describe('Slides @desktop', () => { 338 - let app: DesktopApp; 339 - let bgWindow: Page; 340 - 341 - test.beforeAll(async () => { 342 - ({ app, bgWindow } = await createPerDescribeApp('slides')); 343 - }); 344 - 345 - test.afterAll(async () => { 346 - if (app) await app.close(); 347 - }); 348 - 349 - test('add slides and test they work', async () => { 350 - // Add multiple addresses to use as slides 351 - const urls = [ 352 - 'https://slide1.example.com', 353 - 'https://slide2.example.com', 354 - 'https://slide3.example.com' 355 - ]; 356 - 357 - for (const url of urls) { 358 - const result = await bgWindow.evaluate(async (uri: string) => { 359 - return await (window as any).app.datastore.addAddress(uri, { 360 - title: `Slide: ${uri}`, 361 - starred: 1 362 - }); 363 - }, url); 364 - expect(result.success).toBe(true); 365 - } 366 - 367 - // Verify slides extension is loaded (hybrid mode: may be iframe or separate window) 368 - const runningExts = await bgWindow.evaluate(async () => { 369 - return await (window as any).app.extensions.list(); 370 - }); 371 - const slidesRunning = runningExts.data?.some((ext: any) => ext.id === 'slides'); 372 - expect(slidesRunning).toBe(true); 373 - 374 - // Query addresses to verify they were added 375 - const queryResult = await bgWindow.evaluate(async () => { 376 - return await (window as any).app.datastore.queryAddresses({ starred: 1, limit: 10 }); 377 - }); 378 - expect(queryResult.success).toBe(true); 379 - expect(queryResult.data.length).toBeGreaterThanOrEqual(3); 380 - }); 381 - }); 382 - 383 - // ============================================================================ 384 - // Groups Navigation Tests (uses shared app) 385 - // ============================================================================ 386 - 387 - test.describe('Groups Navigation @desktop', () => { 388 - let app: DesktopApp; 389 - let bgWindow: Page; 390 - 391 - test.beforeAll(async () => { 392 - ({ app, bgWindow } = await createPerDescribeApp('groups-nav')); 393 - }); 394 - 395 - test.afterAll(async () => { 396 - if (app) await app.close(); 397 - }); 398 - 399 - test('groups to group to url and back navigation', async () => { 400 - // Create a tag/group with some items and promote it to a group 401 - const tagResult = await bgWindow.evaluate(async () => { 402 - const result = await (window as any).app.datastore.getOrCreateTag('test-group'); 403 - if (result.success) { 404 - const tag = result.data.tag; 405 - let meta = {}; 406 - try { meta = tag.metadata ? JSON.parse(tag.metadata) : {}; } catch {} 407 - meta.isGroup = true; 408 - await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify(meta) }); 409 - } 410 - return result; 411 - }); 412 - expect(tagResult.success).toBe(true); 413 - const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; 414 - 415 - // Add URL items and tag them 416 - const item1 = await bgWindow.evaluate(async () => { 417 - return await (window as any).app.datastore.addItem('url', { 418 - content: 'https://group-test-1.example.com', 419 - metadata: JSON.stringify({ title: 'Group Test 1' }) 420 - }); 421 - }); 422 - expect(item1.success).toBe(true); 423 - 424 - const item2 = await bgWindow.evaluate(async () => { 425 - return await (window as any).app.datastore.addItem('url', { 426 - content: 'https://group-test-2.example.com', 427 - metadata: JSON.stringify({ title: 'Group Test 2' }) 428 - }); 429 - }); 430 - expect(item2.success).toBe(true); 431 - 432 - // Tag the items 433 - if (tagId && item1.data?.id) { 434 - await bgWindow.evaluate(async ({ itemId, tagId }) => { 435 - return await (window as any).app.datastore.tagItem(itemId, tagId); 436 - }, { itemId: item1.data.id, tagId }); 437 - } 438 - 439 - if (tagId && item2.data?.id) { 440 - await bgWindow.evaluate(async ({ itemId, tagId }) => { 441 - return await (window as any).app.datastore.tagItem(itemId, tagId); 442 - }, { itemId: item2.data.id, tagId }); 443 - } 444 - 445 - // Open groups home 446 - const groupsResult = await bgWindow.evaluate(async () => { 447 - return await (window as any).app.window.open('peek://groups/home.html', { 448 - width: 800, 449 - height: 600 450 - }); 451 - }); 452 - expect(groupsResult.success).toBe(true); 453 - 454 - // Find the groups window (getWindow polls) 455 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 456 - expect(groupsWindow).toBeTruthy(); 457 - await groupsWindow.waitForLoadState('domcontentloaded'); 458 - 459 - // Wait for cards to render 460 - await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 461 - 462 - // Click on the test-group card 463 - const groupCard = await groupsWindow.$('peek-card.group-card[data-tag-id="' + tagId + '"]'); 464 - if (!groupCard) { 465 - const anyGroupCard = await groupsWindow.$('peek-card.group-card'); 466 - expect(anyGroupCard).toBeTruthy(); 467 - await anyGroupCard!.click(); 468 - } else { 469 - await groupCard.click(); 470 - } 471 - 472 - // Wait for navigation to addresses view (address cards appear) 473 - await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 474 - 475 - // Verify we're in addresses view by checking search placeholder 476 - const placeholderInGroup = await groupsWindow.evaluate(() => { 477 - const searchInput = document.querySelector('peek-input.search-input') as any; 478 - return searchInput ? searchInput.placeholder : null; 479 - }); 480 - expect(placeholderInGroup).toContain('Search in'); 481 - 482 - // Click on an address card 483 - const addressCard = await groupsWindow.$('peek-card.address-card'); 484 - expect(addressCard).toBeTruthy(); 485 - 486 - const windowCountBefore = app.windows().length; 487 - await addressCard!.click(); 488 - 489 - // Wait for new window to open 490 - await waitForWindowCount(() => app.windows(), windowCountBefore + 1, 5000); 491 - 492 - // Verify a new window was opened 493 - const windowCountAfter = app.windows().length; 494 - expect(windowCountAfter).toBeGreaterThan(windowCountBefore); 495 - 496 - // Navigate back to groups view 497 - // Note: Playwright's keyboard.press('Escape') doesn't reliably trigger 498 - // Electron's before-input-event handler, so we call the navigation function directly 499 - await groupsWindow.evaluate(async () => { 500 - const showGroups = (window as any).showGroups; 501 - if (showGroups) { 502 - await showGroups(); 503 - } 504 - }); 505 - 506 - // Small delay for async operations 507 - await new Promise(resolve => setTimeout(resolve, 100)); 508 - 509 - // Wait for groups view (group cards appear, address cards disappear) 510 - await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 }); 511 - 512 - // Verify we're back in groups view by checking search placeholder 513 - const placeholderInGroups = await groupsWindow.evaluate(() => { 514 - const searchInput = document.querySelector('peek-input.search-input') as any; 515 - return searchInput ? searchInput.placeholder : null; 516 - }); 517 - expect(placeholderInGroups).toBe('Search groups...'); 518 - 519 - // Clean up 520 - if (groupsResult.id) { 521 - try { 522 - await bgWindow.evaluate(async (id: number) => { 523 - return await (window as any).app.window.close(id); 524 - }, groupsResult.id); 525 - } catch { 526 - // Window may already be closed 527 - } 528 - } 529 - 530 - // Verify items can be retrieved by tag 531 - if (tagId) { 532 - const taggedItems = await bgWindow.evaluate(async (tId: string) => { 533 - return await (window as any).app.datastore.getItemsByTag(tId); 534 - }, tagId); 535 - expect(taggedItems.success).toBe(true); 536 - expect(taggedItems.data.length).toBeGreaterThanOrEqual(2); 537 - } 538 - }); 539 - }); 540 - 541 - // ============================================================================ 542 - // IZUI Escape Protocol Tests (uses shared app) 543 - // ============================================================================ 544 - 545 - test.describe('IZUI Escape Protocol @desktop', () => { 546 - let app: DesktopApp; 547 - let bgWindow: Page; 548 - 549 - test.beforeAll(async () => { 550 - ({ app, bgWindow } = await createPerDescribeApp('izui-escape')); 551 - }); 552 - 553 - test.afterAll(async () => { 554 - if (app) await app.close(); 555 - }); 556 - 557 - test('navigate mode: escape navigates internally before requesting close', async () => { 558 - 559 - // Create a group with items so we can navigate into it 560 - const tagResult = await bgWindow.evaluate(async () => { 561 - return await (window as any).app.datastore.getOrCreateTag('izui-esc-test'); 562 - }); 563 - expect(tagResult.success).toBe(true); 564 - const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; 565 - 566 - const item = await bgWindow.evaluate(async () => { 567 - return await (window as any).app.datastore.addItem('url', { 568 - content: 'https://izui-esc-test.example.com', 569 - metadata: JSON.stringify({ title: 'IZUI ESC Test' }) 570 - }); 571 - }); 572 - expect(item.success).toBe(true); 573 - 574 - if (tagId && item.data?.id) { 575 - await bgWindow.evaluate(async ({ itemId, tagId }) => { 576 - return await (window as any).app.datastore.tagItem(itemId, tagId); 577 - }, { itemId: item.data.id, tagId }); 578 - } 579 - 580 - // Open groups window (background.js uses escapeMode: 'navigate') 581 - const groupsResult = await bgWindow.evaluate(async () => { 582 - return await (window as any).app.window.open('peek://groups/home.html', { 583 - width: 800, 584 - height: 600, 585 - escapeMode: 'navigate' 586 - }); 587 - }); 588 - expect(groupsResult.success).toBe(true); 589 - 590 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 591 - expect(groupsWindow).toBeTruthy(); 592 - await groupsWindow.waitForLoadState('domcontentloaded'); 593 - await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 594 - 595 - // Navigate to addresses view by clicking a group 596 - const groupCard = await groupsWindow.$('peek-card.group-card[data-tag-id="' + tagId + '"]'); 597 - if (groupCard) { 598 - await groupCard.click(); 599 - } else { 600 - const anyCard = await groupsWindow.$('peek-card.group-card'); 601 - expect(anyCard).toBeTruthy(); 602 - await anyCard!.click(); 603 - } 604 - await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 605 - 606 - // Verify we're in addresses view 607 - const viewBefore = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 608 - expect(viewBefore).toBe('addresses'); 609 - 610 - // Trigger escape via the IZUI chain - should navigate back to groups (handled: true) 611 - const escResult1 = await groupsWindow.evaluate(async () => { 612 - return await (window as any).app.escape.trigger(); 613 - }); 614 - expect(escResult1.handled).toBe(true); 615 - 616 - // Wait for navigation to complete — waitForSelector is the deterministic signal 617 - // that showGroups() has rendered (the setTimeout(0) in handleEscape fires and 618 - // DOM updates before group-card is inserted into the DOM). 619 - await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 }); 620 - 621 - // Verify we're back in groups view 622 - const viewAfter = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 623 - expect(viewAfter).toBe('groups'); 624 - 625 - // Trigger escape again at root - renderer returns { handled: false } at root 626 - // Backend handles close policy (child/transient windows close, active root stays) 627 - const escResult2 = await groupsWindow.evaluate(async () => { 628 - return await (window as any).app.escape.trigger(); 629 - }); 630 - // trigger() calls the handler directly - at root, groups returns { handled: false } 631 - expect(escResult2.handled).toBe(false); 632 - 633 - // Clean up - trigger() doesn't go through backend ESC path, so window is still open 634 - if (groupsResult.id) { 635 - try { 636 - await bgWindow.evaluate(async (id: number) => { 637 - return await (window as any).app.window.close(id); 638 - }, groupsResult.id); 639 - } catch { 640 - // Window may already be closed 641 - } 642 - } 643 - }); 644 - 645 - test('peek-card: Enter key activates card via card-click event', async () => { 646 - 647 - // Create a group with an item 648 - const tagResult = await bgWindow.evaluate(async () => { 649 - return await (window as any).app.datastore.getOrCreateTag('enter-key-test'); 650 - }); 651 - expect(tagResult.success).toBe(true); 652 - const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; 653 - 654 - const item = await bgWindow.evaluate(async () => { 655 - return await (window as any).app.datastore.addItem('url', { 656 - content: 'https://enter-key-test.example.com', 657 - metadata: JSON.stringify({ title: 'Enter Key Test' }) 658 - }); 659 - }); 660 - expect(item.success).toBe(true); 661 - 662 - if (tagId && item.data?.id) { 663 - await bgWindow.evaluate(async ({ itemId, tagId }) => { 664 - return await (window as any).app.datastore.tagItem(itemId, tagId); 665 - }, { itemId: item.data.id, tagId }); 666 - } 667 - 668 - // Open groups window 669 - const groupsResult = await bgWindow.evaluate(async () => { 670 - return await (window as any).app.window.open('peek://groups/home.html', { 671 - role: 'workspace', 672 - width: 800, 673 - height: 600 674 - }); 675 - }); 676 - expect(groupsResult.success).toBe(true); 677 - 678 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 679 - expect(groupsWindow).toBeTruthy(); 680 - await groupsWindow.waitForLoadState('domcontentloaded'); 681 - await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 }); 682 - 683 - // Verify we're in groups view 684 - const viewBefore = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 685 - expect(viewBefore).toBe('groups'); 686 - 687 - // Programmatically activate the first card (simulates Enter key path) 688 - const activated = await groupsWindow.evaluate(async () => { 689 - const card = document.querySelector('peek-card.group-card') as any; 690 - if (!card) return false; 691 - card.click(); 692 - return true; 693 - }); 694 - expect(activated).toBe(true); 695 - 696 - // Should navigate to addresses view 697 - await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 698 - const viewAfter = await groupsWindow.evaluate(() => (window as any)._groupsState.view); 699 - expect(viewAfter).toBe('addresses'); 700 - 701 - // Clean up 702 - if (groupsResult.id) { 703 - try { 704 - await bgWindow.evaluate(async (id: number) => { 705 - return await (window as any).app.window.close(id); 706 - }, groupsResult.id); 707 - } catch { 708 - // Window may already be closed 709 - } 710 - } 711 - }); 712 - 713 - test('active mode: ESC at root does NOT close window', async () => { 714 - 715 - // Open groups window with role: 'workspace' (like the real groups extension does) 716 - // In headless/test mode, appFocused defaults to true → session is 'active' 717 - const groupsResult = await bgWindow.evaluate(async () => { 718 - return await (window as any).app.window.open('peek://groups/home.html', { 719 - role: 'workspace', 720 - width: 400, 721 - height: 300, 722 - }); 723 - }); 724 - expect(groupsResult.success).toBe(true); 725 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 726 - expect(groupsWindow).toBeTruthy(); 727 - await groupsWindow.waitForLoadState('domcontentloaded'); 728 - await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 729 - 730 - // Verify the window's role is 'workspace' and session is 'active' 731 - const izuiState = await bgWindow.evaluate(async () => { 732 - return await (window as any).app.izui.getState(); 733 - }); 734 - expect(izuiState).toBe('active'); 735 - 736 - // Press ESC via keyboard — goes through full backend path: 737 - // before-input-event → handleEscapeForWindow → askRendererToHandleEscape → 738 - // renderer returns { handled: false } at root → escPolicy('active', 'workspace') → 'nothing' 739 - await groupsWindow.keyboard.press('Escape'); 740 - 741 - // Wait for the async ESC handling to complete 742 - await new Promise(resolve => setTimeout(resolve, 600)); 743 - 744 - // Verify window is still alive — if escPolicy is wrong, the window would be closed 745 - const stillAlive = await groupsWindow.evaluate(() => true).catch(() => false); 746 - expect(stillAlive).toBe(true); 747 - 748 - // Also verify the view is still at root (groups list) 749 - const view = await groupsWindow.evaluate(() => (window as any)._groupsState?.view); 750 - expect(view).toBe('groups'); 751 - 752 - // Clean up 753 - if (groupsResult.id) { 754 - try { 755 - await bgWindow.evaluate(async (wid: number) => { 756 - return await (window as any).app.window.close(wid); 757 - }, groupsResult.id); 758 - } catch { 759 - // Window may already be closed 760 - } 761 - } 762 - }); 763 - 764 - test('active mode: ESC on child-content window does NOT close it (regression)', async () => { 765 - 766 - // First open a workspace window (like groups) to establish an active session 767 - const workspaceResult = await bgWindow.evaluate(async () => { 768 - return await (window as any).app.window.open('peek://groups/home.html', { 769 - role: 'workspace', 770 - width: 400, 771 - height: 300, 772 - }); 773 - }); 774 - expect(workspaceResult.success).toBe(true); 775 - const workspaceWindow = await app.getWindow('groups/home.html', 5000); 776 - expect(workspaceWindow).toBeTruthy(); 777 - await workspaceWindow.waitForLoadState('domcontentloaded'); 778 - 779 - // Verify session is active 780 - const izuiState = await bgWindow.evaluate(async () => { 781 - return await (window as any).app.izui.getState(); 782 - }); 783 - expect(izuiState).toBe('active'); 784 - 785 - // Now open a child-content window (simulates opening a web page from groups) 786 - // Using the workspace window as the opener gives it child-content role 787 - const contentResult = await workspaceWindow.evaluate(async () => { 788 - return await (window as any).app.window.open('peek://search/home.html', { 789 - role: 'child-content', 790 - width: 400, 791 - height: 300, 792 - }); 793 - }); 794 - expect(contentResult.success).toBe(true); 795 - const contentWindow = await app.getWindow('search/home.html', 5000); 796 - expect(contentWindow).toBeTruthy(); 797 - await contentWindow.waitForLoadState('domcontentloaded'); 798 - 799 - // Trigger escape via the renderer callback directly — search/home.html has no 800 - // onEscape handler so trigger() returns { handled: false } immediately. 801 - // The regression: child-content would be closed on ESC before the escPolicy fix. 802 - // The backend policy (escPolicy('active','child-content') === 'nothing') is a 803 - // pure function tested in izui-state.test.ts. Here we verify the renderer path 804 - // doesn't close the window. 805 - const escResult = await contentWindow.evaluate(async () => { 806 - return await (window as any).app.escape.trigger(); 807 - }); 808 - expect(escResult).toEqual({ handled: false }); 809 - 810 - // Verify child-content window is still alive — if the window were incorrectly 811 - // closed on ESC, this evaluate() call would throw/return false. 812 - const stillAlive = await contentWindow.evaluate(() => true).catch(() => false); 813 - expect(stillAlive).toBe(true); 814 - 815 - // Clean up 816 - for (const id of [contentResult.id, workspaceResult.id]) { 817 - if (id) { 818 - try { 819 - await bgWindow.evaluate(async (wid: number) => { 820 - return await (window as any).app.window.close(wid); 821 - }, id); 822 - } catch { 823 - // Window may already be closed 824 - } 825 - } 826 - } 827 - }); 828 - 829 - test('navigate mode: timeout does not close window', async () => { 830 - 831 - // Open a window with navigate escape mode but NO escape handler registered 832 - // This simulates what happens when a window hasn't finished loading its IZUI 833 - const result = await bgWindow.evaluate(async () => { 834 - return await (window as any).app.window.open('peek://groups/home.html', { 835 - width: 400, 836 - height: 300, 837 - escapeMode: 'navigate' 838 - }); 839 - }); 840 - expect(result.success).toBe(true); 841 - 842 - const testWindow = await app.getWindow('groups/home.html', 5000); 843 - expect(testWindow).toBeTruthy(); 844 - await testWindow.waitForLoadState('domcontentloaded'); 845 - 846 - // The groups extension registers an escape handler via api.escape.onEscape. 847 - // At root (groups view), handler returns { handled: false }. 848 - // Backend handles close policy via navigate mode. 849 - await testWindow.waitForSelector('.cards', { timeout: 5000 }); 850 - const escResult = await testWindow.evaluate(async () => { 851 - return await (window as any).app.escape.trigger(); 852 - }); 853 - // trigger() calls handler directly - at root, groups returns { handled: false } 854 - expect(escResult.handled).toBe(false); 855 - 856 - // trigger() doesn't go through backend ESC path, window is still open 857 - // Clean up 858 - if (result.id) { 859 - try { 860 - await bgWindow.evaluate(async (id: number) => { 861 - return await (window as any).app.window.close(id); 862 - }, result.id); 863 - } catch { 864 - // Window may already be closed 865 - } 866 - } 867 - }); 868 - }); 869 - 870 - // ============================================================================ 871 - // External URL Opening Tests (uses shared app) 872 - // ============================================================================ 873 - 874 - test.describe('External URL Opening @desktop', () => { 875 - let app: DesktopApp; 876 - let bgWindow: Page; 877 - 878 - test.beforeAll(async () => { 879 - ({ app, bgWindow } = await createPerDescribeApp('external-url')); 880 - }); 881 - 882 - test.afterAll(async () => { 883 - if (app) await app.close(); 884 - }); 885 - 886 - test('open URL by calling executable', async () => { 887 - // Verify app is ready with background window 888 - expect(bgWindow).toBeTruthy(); 889 - // Ensure the API is ready 890 - await waitForAppReady(bgWindow); 891 - }); 892 - 893 - test('cmd panel detects and opens domain without protocol (youtube.com)', async () => { 894 - await waitForExtensionsReady(bgWindow, 15000); 895 - 896 - // Open cmd panel 897 - const openResult = await bgWindow.evaluate(async () => { 898 - return await (window as any).app.window.open('peek://cmd/panel.html', { 899 - modal: true, 900 - width: 600, 901 - height: 50, 902 - frame: false, 903 - transparent: true, 904 - alwaysOnTop: true, 905 - center: true 906 - }); 907 - }); 908 - expect(openResult.success).toBe(true); 909 - 910 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 911 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 912 - 913 - // Type a domain without protocol 914 - await cmdWindow.fill('input', 'example.com'); 915 - await cmdWindow.keyboard.press('Enter'); 916 - 917 - // Wait for window to open 918 - await sleep(1000); 919 - 920 - // Verify URL was opened (check window list for the URL) 921 - const windowList = await bgWindow.evaluate(async () => { 922 - return await (window as any).app.window.list(); 923 - }); 924 - 925 - expect(windowList.success).toBe(true); 926 - // URL should be wrapped in page loader with https:// protocol 927 - const exampleWindow = windowList.windows?.find((w: any) => 928 - w.url && (w.url.includes('https://example.com') || w.url.includes('https%3A%2F%2Fexample.com')) 929 - ); 930 - expect(exampleWindow).toBeTruthy(); 931 - 932 - // Clean up 933 - if (exampleWindow) { 934 - await bgWindow.evaluate(async (id: number) => { 935 - await (window as any).app.window.close(id); 936 - }, exampleWindow.id); 937 - } 938 - }); 939 - 940 - test('cmd panel opens URL with http protocol', async () => { 941 - await waitForExtensionsReady(bgWindow, 15000); 942 - 943 - // Open cmd panel 944 - const openResult = await bgWindow.evaluate(async () => { 945 - return await (window as any).app.window.open('peek://cmd/panel.html', { 946 - modal: true, 947 - width: 600, 948 - height: 50, 949 - frame: false, 950 - transparent: true, 951 - alwaysOnTop: true, 952 - center: true 953 - }); 954 - }); 955 - expect(openResult.success).toBe(true); 956 - 957 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 958 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 959 - 960 - // Type URL with http protocol 961 - await cmdWindow.fill('input', 'http://example.com'); 962 - await cmdWindow.keyboard.press('Enter'); 963 - 964 - // Wait for window to open 965 - await sleep(1000); 966 - 967 - // Verify URL was opened (should preserve http://) 968 - const windowList = await bgWindow.evaluate(async () => { 969 - return await (window as any).app.window.list(); 970 - }); 971 - 972 - expect(windowList.success).toBe(true); 973 - const httpWindow = windowList.windows?.find((w: any) => 974 - w.url && (w.url.includes('http://example.com') || w.url.includes('http%3A%2F%2Fexample.com')) 975 - ); 976 - expect(httpWindow).toBeTruthy(); 977 - 978 - // Clean up 979 - if (httpWindow) { 980 - await bgWindow.evaluate(async (id: number) => { 981 - await (window as any).app.window.close(id); 982 - }, httpWindow.id); 983 - } 984 - }); 985 - 986 - test('cmd panel opens URL with https protocol', async () => { 987 - await waitForExtensionsReady(bgWindow, 15000); 988 - 989 - // Open cmd panel 990 - const openResult = await bgWindow.evaluate(async () => { 991 - return await (window as any).app.window.open('peek://cmd/panel.html', { 992 - modal: true, 993 - width: 600, 994 - height: 50, 995 - frame: false, 996 - transparent: true, 997 - alwaysOnTop: true, 998 - center: true 999 - }); 1000 - }); 1001 - expect(openResult.success).toBe(true); 1002 - 1003 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 1004 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 1005 - 1006 - // Type URL with https protocol 1007 - await cmdWindow.fill('input', 'https://example.com'); 1008 - await cmdWindow.keyboard.press('Enter'); 1009 - 1010 - // Wait for window to open 1011 - await sleep(1000); 1012 - 1013 - // Verify URL was opened 1014 - const windowList = await bgWindow.evaluate(async () => { 1015 - return await (window as any).app.window.list(); 1016 - }); 1017 - 1018 - expect(windowList.success).toBe(true); 1019 - const httpsWindow = windowList.windows?.find((w: any) => 1020 - w.url && (w.url.includes('https://example.com') || w.url.includes('https%3A%2F%2Fexample.com')) 1021 - ); 1022 - expect(httpsWindow).toBeTruthy(); 1023 - 1024 - // Clean up 1025 - if (httpsWindow) { 1026 - await bgWindow.evaluate(async (id: number) => { 1027 - await (window as any).app.window.close(id); 1028 - }, httpsWindow.id); 1029 - } 1030 - }); 1031 - 1032 - test('cmd panel opens localhost URLs', async () => { 1033 - await waitForExtensionsReady(bgWindow, 15000); 1034 - 1035 - // Open cmd panel 1036 - const openResult = await bgWindow.evaluate(async () => { 1037 - return await (window as any).app.window.open('peek://cmd/panel.html', { 1038 - modal: true, 1039 - width: 600, 1040 - height: 50, 1041 - frame: false, 1042 - transparent: true, 1043 - alwaysOnTop: true, 1044 - center: true 1045 - }); 1046 - }); 1047 - expect(openResult.success).toBe(true); 1048 - 1049 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 1050 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 1051 - 1052 - // Type localhost with port 1053 - await cmdWindow.fill('input', 'localhost:3000'); 1054 - await cmdWindow.keyboard.press('Enter'); 1055 - 1056 - // Wait for window to open 1057 - await sleep(1000); 1058 - 1059 - // Verify URL was opened (normalized to https://localhost:3000) 1060 - const windowList = await bgWindow.evaluate(async () => { 1061 - return await (window as any).app.window.list(); 1062 - }); 1063 - 1064 - expect(windowList.success).toBe(true); 1065 - const localhostWindow = windowList.windows?.find((w: any) => 1066 - w.url && (w.url.includes('localhost:3000') || w.url.includes('localhost%3A3000')) 1067 - ); 1068 - expect(localhostWindow).toBeTruthy(); 1069 - 1070 - // Clean up 1071 - if (localhostWindow) { 1072 - await bgWindow.evaluate(async (id: number) => { 1073 - await (window as any).app.window.close(id); 1074 - }, localhostWindow.id); 1075 - } 1076 - }); 1077 - 1078 - test('cmd panel ignores non-URL non-command text on Enter', async () => { 1079 - await waitForExtensionsReady(bgWindow, 15000); 1080 - 1081 - // Snapshot window list before 1082 - const beforeList = await bgWindow.evaluate(async () => { 1083 - return await (window as any).app.window.list(); 1084 - }); 1085 - const beforeCount = beforeList.windows?.length || 0; 1086 - 1087 - // Open cmd panel 1088 - const openResult = await bgWindow.evaluate(async () => { 1089 - return await (window as any).app.window.open('peek://cmd/panel.html', { 1090 - modal: true, 1091 - width: 600, 1092 - height: 50, 1093 - frame: false, 1094 - transparent: true, 1095 - alwaysOnTop: true, 1096 - center: true 1097 - }); 1098 - }); 1099 - expect(openResult.success).toBe(true); 1100 - 1101 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 1102 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 1103 - 1104 - // Type non-URL text (no dots, no protocol) — not a command either 1105 - await cmdWindow.fill('input', 'notaurl'); 1106 - await cmdWindow.keyboard.press('Enter'); 1107 - 1108 - // Wait briefly to confirm nothing happens 1109 - await sleep(500); 1110 - 1111 - // Verify no new windows were opened (non-URL text is ignored, not routed anywhere) 1112 - const afterList = await bgWindow.evaluate(async () => { 1113 - return await (window as any).app.window.list(); 1114 - }); 1115 - 1116 - // Should NOT be opened as a direct URL 1117 - const directUrlWindow = afterList.windows?.find((w: any) => 1118 - w.url === 'http://notaurl' || w.url === 'https://notaurl' 1119 - ); 1120 - expect(directUrlWindow).toBeFalsy(); 1121 - 1122 - // Should NOT be opened as a web search either (no fallback) 1123 - const searchWindow = afterList.windows?.find((w: any) => 1124 - w.url && w.url.includes('notaurl') 1125 - ); 1126 - expect(searchWindow).toBeFalsy(); 1127 - 1128 - // Close cmd panel if still open 1129 - if (openResult.id) { 1130 - await bgWindow.evaluate(async (id: number) => { 1131 - try { 1132 - await (window as any).app.window.close(id); 1133 - } catch (e) { 1134 - // Already closed 1135 - } 1136 - }, openResult.id); 1137 - } 1138 - }); 1139 - 1140 - test('external URL handler opens URL on first click (simulates OS open-url event)', async () => { 1141 - // This test simulates clicking a URL from an external app (like clicking a link 1142 - // in another app when Peek is set as default browser). 1143 - // Tests the fix for: first click focuses app but doesn't open URL, second click works. 1144 - 1145 - await waitForExtensionsReady(bgWindow, 15000); 1146 - 1147 - // Get initial window count 1148 - const initialList = await bgWindow.evaluate(async () => { 1149 - return await (window as any).app.window.list(); 1150 - }); 1151 - const initialCount = initialList.windows?.length || 0; 1152 - 1153 - // Simulate external URL open event (what happens when clicking URL from another app) 1154 - // This bypasses the normal window.open flow and tests the handleExternalUrl path 1155 - const testUrl = 'https://example.com/external-test'; 1156 - 1157 - // Trigger external:open-url event directly (simulates what handleExternalUrl does) 1158 - await bgWindow.evaluate(async (url: string) => { 1159 - const api = (window as any).app; 1160 - // Publish the same event that handleExternalUrl publishes — core 1161 - // renderer subscribes on GLOBAL scope, so publish with GLOBAL explicitly 1162 - // (default is SELF in tile-preload). 1163 - await api.publish('external:open-url', { 1164 - url, 1165 - trackingSource: 'external', 1166 - trackingSourceId: 'os', 1167 - timestamp: Date.now() 1168 - }, api.scopes.GLOBAL); 1169 - }, testUrl); 1170 - 1171 - // Wait for window to be created (give it time to process the event) 1172 - await sleep(500); 1173 - 1174 - // Verify URL was opened 1175 - const finalList = await bgWindow.evaluate(async () => { 1176 - return await (window as any).app.window.list(); 1177 - }); 1178 - 1179 - expect(finalList.success).toBe(true); 1180 - const finalCount = finalList.windows?.length || 0; 1181 - 1182 - // Should have created a new window 1183 - expect(finalCount).toBeGreaterThan(initialCount); 1184 - 1185 - // Find the window with our test URL 1186 - const externalWindow = finalList.windows?.find((w: any) => 1187 - w.url && (w.url.includes(testUrl) || w.url.includes(encodeURIComponent(testUrl))) 1188 - ); 1189 - expect(externalWindow).toBeTruthy(); 1190 - 1191 - // Clean up 1192 - if (externalWindow) { 1193 - await bgWindow.evaluate(async (id: number) => { 1194 - await (window as any).app.window.close(id); 1195 - }, externalWindow.id); 1196 - } 1197 - }); 1198 - 1199 - test('handleExternalUrl from main process opens URL correctly', async () => { 1200 - // This tests the REAL external URL path — calling handleExternalUrl from 1201 - // the main process, which is what happens when macOS sends an open-url event 1202 - // or when Peek receives a second-instance signal with a URL argument. 1203 - // The previous test simulates via pubsub from the renderer; this test 1204 - // exercises the full main-process -> pubsub -> renderer -> window-open flow. 1205 - 1206 - await waitForExtensionsReady(bgWindow, 15000); 1207 - 1208 - // Get initial window count (include internal to see ALL windows) 1209 - const initialList = await bgWindow.evaluate(async () => { 1210 - return await (window as any).app.window.list(); 1211 - }); 1212 - const initialCount = initialList.windows?.length || 0; 1213 - 1214 - const testUrl = 'https://example.com/main-process-external-test'; 1215 - 1216 - // Call handleExternalUrl from the main process (simulates real OS open-url) 1217 - const mainResult = await app.evaluateMain!(({ app }) => { 1218 - const { handleExternalUrl } = (globalThis as any).__peek_test; 1219 - if (!handleExternalUrl) return { error: 'handleExternalUrl not found on __peek_test' }; 1220 - handleExternalUrl('https://example.com/main-process-external-test', 'os'); 1221 - return { success: true }; 1222 - }); 1223 - 1224 - // Verify main process call succeeded 1225 - expect((mainResult as any).error).toBeUndefined(); 1226 - 1227 - // Wait for window with the specific URL to appear (deterministic — no count-based checks) 1228 - let externalWindow: any = null; 1229 - const deadline = Date.now() + 5000; 1230 - while (Date.now() < deadline) { 1231 - const list = await bgWindow.evaluate(async () => { 1232 - return await (window as any).app.window.list(); 1233 - }); 1234 - externalWindow = list.windows?.find((w: any) => 1235 - w.url && (w.url.includes('main-process-external-test') || w.url.includes(encodeURIComponent('main-process-external-test'))) 1236 - ); 1237 - if (externalWindow) break; 1238 - await sleep(100); 1239 - } 1240 - 1241 - expect(externalWindow).toBeTruthy(); 1242 - 1243 - // Clean up 1244 - if (externalWindow) { 1245 - await bgWindow.evaluate(async (id: number) => { 1246 - await (window as any).app.window.close(id); 1247 - }, externalWindow.id); 1248 - } 1249 - }); 1250 - }); 1251 - 1252 - // ============================================================================ 1253 - // Data Persistence Tests (consolidated - single restart for all persistence checks) 1254 - // ============================================================================ 1255 - 1256 - test.describe('Data Persistence @desktop', () => { 1257 - test('all data persists across restart (peeks, slides, addresses, tags, theme)', async () => { 1258 - const PROFILE = 'test-all-persist-' + Date.now(); 1259 - 1260 - // ========== PHASE 1: Set up all data ========== 1261 - let app = await launchDesktopApp(PROFILE); 1262 - let bgWindow = await app.getBackgroundWindow(); 1263 - 1264 - // --- Peeks and Slides settings --- 1265 - const testPeeks = [ 1266 - { title: 'Test Peek 1', uri: 'https://test-peek-1.example.com', shortcut: 'Option+1' }, 1267 - { title: 'Test Peek 2', uri: 'https://test-peek-2.example.com', shortcut: 'Option+2' }, 1268 - { title: 'Custom Peek', uri: 'https://custom-peek.example.com', shortcut: 'Option+3' } 1269 - ]; 1270 - 1271 - const testSlides = [ 1272 - { title: 'Test Slide 1', uri: 'https://test-slide-1.example.com', position: 'right', size: 400 }, 1273 - { title: 'Test Slide 2', uri: 'https://test-slide-2.example.com', position: 'bottom', size: 300 } 1274 - ]; 1275 - 1276 - // Save peeks items 1277 - const savePeeksResult = await bgWindow.evaluate(async (items) => { 1278 - const api = (window as any).app; 1279 - return await api.datastore.setRow('feature_settings', 'peeks:items', { 1280 - featureId: 'peeks', 1281 - key: 'items', 1282 - value: JSON.stringify(items), 1283 - updatedAt: Date.now() 1284 - }); 1285 - }, testPeeks); 1286 - expect(savePeeksResult.success).toBe(true); 1287 - 1288 - // Save slides items 1289 - const saveSlidesResult = await bgWindow.evaluate(async (items) => { 1290 - const api = (window as any).app; 1291 - return await api.datastore.setRow('feature_settings', 'slides:items', { 1292 - featureId: 'slides', 1293 - key: 'items', 1294 - value: JSON.stringify(items), 1295 - updatedAt: Date.now() 1296 - }); 1297 - }, testSlides); 1298 - expect(saveSlidesResult.success).toBe(true); 1299 - 1300 - // Save prefs 1301 - const savePeeksPrefs = await bgWindow.evaluate(async () => { 1302 - const api = (window as any).app; 1303 - return await api.datastore.setRow('feature_settings', 'peeks:prefs', { 1304 - featureId: 'peeks', 1305 - key: 'prefs', 1306 - value: JSON.stringify({ shortcutKeyPrefix: 'Option+' }), 1307 - updatedAt: Date.now() 1308 - }); 1309 - }); 1310 - expect(savePeeksPrefs.success).toBe(true); 1311 - 1312 - const saveSlidesPrefs = await bgWindow.evaluate(async () => { 1313 - const api = (window as any).app; 1314 - return await api.datastore.setRow('feature_settings', 'slides:prefs', { 1315 - featureId: 'slides', 1316 - key: 'prefs', 1317 - value: JSON.stringify({ defaultPosition: 'right', defaultSize: 350 }), 1318 - updatedAt: Date.now() 1319 - }); 1320 - }); 1321 - expect(saveSlidesPrefs.success).toBe(true); 1322 - 1323 - // --- Addresses and Tags --- 1324 - const addr1 = await bgWindow.evaluate(async () => { 1325 - return await (window as any).app.datastore.addAddress('https://persist-test-1.example.com', { 1326 - title: 'Persist Test 1', 1327 - starred: 1 1328 - }); 1329 - }); 1330 - expect(addr1.success).toBe(true); 1331 - 1332 - const addr2 = await bgWindow.evaluate(async () => { 1333 - return await (window as any).app.datastore.addAddress('https://persist-test-2.example.com', { 1334 - title: 'Persist Test 2' 1335 - }); 1336 - }); 1337 - expect(addr2.success).toBe(true); 1338 - 1339 - const tagResult = await bgWindow.evaluate(async () => { 1340 - return await (window as any).app.datastore.getOrCreateTag('persist-tag'); 1341 - }); 1342 - expect(tagResult.success).toBe(true); 1343 - const tagId = tagResult.data?.tag?.id || tagResult.data?.id; 1344 - 1345 - if (tagId && addr1.data?.id) { 1346 - await bgWindow.evaluate(async ({ addressId, tagId }) => { 1347 - return await (window as any).app.datastore.tagAddress(addressId, tagId); 1348 - }, { addressId: addr1.data.id, tagId }); 1349 - } 1350 - 1351 - // --- Theme --- 1352 - const setThemeResult = await bgWindow.evaluate(async () => { 1353 - return await (window as any).app.theme.setTheme('peek'); 1354 - }); 1355 - expect(setThemeResult.success).toBe(true); 1356 - 1357 - // Verify theme is set before restart 1358 - const themeState1 = await bgWindow.evaluate(async () => { 1359 - return await (window as any).app.theme.get(); 1360 - }); 1361 - expect(themeState1.themeId).toBe('peek'); 1362 - 1363 - // Ensure data is flushed before closing 1364 - await sleep(500); 1365 - await app.close(); 1366 - 1367 - // Wait for app to fully shut down 1368 - await sleep(1000); 1369 - 1370 - // ========== PHASE 2: Verify all data persisted ========== 1371 - app = await launchDesktopApp(PROFILE); 1372 - bgWindow = await app.getBackgroundWindow(); 1373 - await waitForExtensionsReady(bgWindow); 1374 - 1375 - // --- Verify Peeks and Slides --- 1376 - const persistedSettings = await bgWindow.evaluate(async () => { 1377 - const api = (window as any).app; 1378 - return await api.datastore.getTable('feature_settings'); 1379 - }); 1380 - expect(persistedSettings.success).toBe(true); 1381 - 1382 - const settingsData = persistedSettings.data as Record<string, any>; 1383 - 1384 - // Peeks items 1385 - const peeksItems = settingsData['peeks:items']; 1386 - expect(peeksItems).toBeTruthy(); 1387 - expect(peeksItems.featureId).toBe('peeks'); 1388 - const parsedPeeks = JSON.parse(peeksItems.value); 1389 - expect(parsedPeeks.length).toBe(3); 1390 - expect(parsedPeeks[0].title).toBe('Test Peek 1'); 1391 - 1392 - // Slides items 1393 - const slidesItems = settingsData['slides:items']; 1394 - expect(slidesItems).toBeTruthy(); 1395 - const parsedSlides = JSON.parse(slidesItems.value); 1396 - expect(parsedSlides.length).toBe(2); 1397 - 1398 - // Peeks prefs 1399 - const peeksPrefs = settingsData['peeks:prefs']; 1400 - expect(peeksPrefs).toBeTruthy(); 1401 - const parsedPeeksPrefs = JSON.parse(peeksPrefs.value); 1402 - expect(parsedPeeksPrefs.shortcutKeyPrefix).toBe('Option+'); 1403 - 1404 - // --- Verify Items (addresses are now stored as URL items) --- 1405 - const itemsResult = await bgWindow.evaluate(async () => { 1406 - return await (window as any).app.datastore.queryItems({ type: 'url' }); 1407 - }); 1408 - expect(itemsResult.success).toBe(true); 1409 - 1410 - const items = itemsResult.data; 1411 - expect(items.length).toBeGreaterThanOrEqual(2); 1412 - 1413 - const persistedItem1 = items.find((a: any) => 1414 - a.content === 'https://persist-test-1.example.com/' || 1415 - a.content?.includes('persist-test-1') 1416 - ); 1417 - expect(persistedItem1).toBeTruthy(); 1418 - expect(persistedItem1.title).toBe('Persist Test 1'); 1419 - 1420 - const tagsResult = await bgWindow.evaluate(async () => { 1421 - return await (window as any).app.datastore.getTagsByFrecency(10); 1422 - }); 1423 - expect(tagsResult.success).toBe(true); 1424 - const persistTag = tagsResult.data.find((t: any) => t.name === 'persist-tag'); 1425 - expect(persistTag).toBeTruthy(); 1426 - 1427 - // --- Verify Theme --- 1428 - const themeState2 = await bgWindow.evaluate(async () => { 1429 - return await (window as any).app.theme.get(); 1430 - }); 1431 - expect(themeState2.themeId).toBe('peek'); 1432 - 1433 - // Open settings window to verify the theme CSS is loaded correctly 1434 - await bgWindow.evaluate(async () => { 1435 - return await (window as any).app.window.open('peek://app/settings/settings.html', { 1436 - width: 800, height: 600 1437 - }); 1438 - }); 1439 - 1440 - const settingsWin = await app.getWindow('settings/settings.html', 5000); 1441 - expect(settingsWin).toBeTruthy(); 1442 - 1443 - // Check that the theme CSS loaded (non-empty value for --theme-font-sans, 1444 - // which the peek theme defines in variables.css). Fallback would yield an 1445 - // empty string from getPropertyValue. Theme uses system sans proportional; 1446 - // --theme-font-mono is the one with ServerMono. 1447 - const fontVar = await settingsWin.evaluate(() => { 1448 - return getComputedStyle(document.documentElement).getPropertyValue('--theme-font-sans'); 1449 - }); 1450 - expect(fontVar.trim().length).toBeGreaterThan(0); 1451 - const monoVar = await settingsWin.evaluate(() => { 1452 - return getComputedStyle(document.documentElement).getPropertyValue('--theme-font-mono'); 1453 - }); 1454 - expect(monoVar).toContain('ServerMono'); 1455 - 1456 - await app.close(); 1457 - }); 1458 - }); 1459 - 1460 - // ============================================================================ 1461 - // Core Functionality Tests (uses shared app) 1462 - // ============================================================================ 1463 - 1464 - test.describe('Core Functionality @desktop', () => { 1465 - let app: DesktopApp; 1466 - let bgWindow: Page; 1467 - 1468 - test.beforeAll(async () => { 1469 - ({ app, bgWindow } = await createPerDescribeApp('core')); 1470 - }); 1471 - 1472 - test.afterAll(async () => { 1473 - if (app) await app.close(); 1474 - }); 1475 - 1476 - test('app launches and extensions load', async () => { 1477 - // After v2 tile migration: 1478 - // - V2 features load as separate background BrowserWindows (peek://{id}/background.html) 1479 - // - Eager v2 features (e.g. entities, peeks, slides) launch at startup; 1480 - // lazy v2 features (e.g. example) launch on first command/event 1481 - 1482 - // Check that at least one eager v2 background tile window exists. 1483 - // peeks and slides are eager v2 background tiles that launch at startup. 1484 - const v2BgWindow = await waitForWindow( 1485 - () => app.windows(), 1486 - 'peek://peeks/background.html', 1487 - 15000 1488 - ); 1489 - expect(v2BgWindow).toBeDefined(); 1490 - }); 1491 - 1492 - test('database is accessible', async () => { 1493 - const result = await bgWindow.evaluate(async () => { 1494 - return await (window as any).app.datastore.getStats(); 1495 - }); 1496 - expect(result.success).toBe(true); 1497 - expect(typeof result.data.totalAddresses).toBe('number'); 1498 - }); 1499 - 1500 - test('commands are registered', async () => { 1501 - // Commands are now owned by the cmd extension via pubsub 1502 - // Query via cmd:query-commands topic with retry for extension loading 1503 - const result = await bgWindow.evaluate(async () => { 1504 - const api = (window as any).app; 1505 - 1506 - const queryCommands = () => new Promise((resolve) => { 1507 - api.subscribe('cmd:query-commands-response', (msg: any) => { 1508 - resolve(msg.commands || []); 1509 - }, api.scopes.GLOBAL); 1510 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 1511 - setTimeout(() => resolve([]), 1000); 1512 - }); 1513 - 1514 - // Retry a few times to allow extensions to finish loading 1515 - for (let i = 0; i < 5; i++) { 1516 - const cmds = await queryCommands() as any[]; 1517 - if (cmds.some((c: any) => c.name === 'example:gallery')) { 1518 - return cmds; 1519 - } 1520 - await new Promise(r => setTimeout(r, 500)); 1521 - } 1522 - return await queryCommands(); 1523 - }); 1524 - expect(Array.isArray(result)).toBe(true); 1525 - expect(result.length).toBeGreaterThan(0); 1526 - 1527 - // Should have gallery command from example extension 1528 - const galleryCmd = result.find((c: any) => c.name === 'example:gallery'); 1529 - expect(galleryCmd).toBeTruthy(); 1530 - }); 1531 - 1532 - test('quit and restart commands are registered', async () => { 1533 - // quit/restart are registered asynchronously during app boot (app/index.js). 1534 - // Poll via waitForCommand before querying details to avoid startup-race flake. 1535 - await waitForCommand(bgWindow, 'quit', 10000); 1536 - await waitForCommand(bgWindow, 'restart', 10000); 1537 - 1538 - // Query commands via cmd extension to verify descriptions 1539 - const result = await bgWindow.evaluate(async () => { 1540 - const api = (window as any).app; 1541 - 1542 - return new Promise((resolve) => { 1543 - api.subscribe('cmd:query-commands-response', (msg: any) => { 1544 - const commands = msg.commands || []; 1545 - resolve({ 1546 - hasQuit: commands.some((c: any) => c.name === 'quit'), 1547 - hasRestart: commands.some((c: any) => c.name === 'restart'), 1548 - quitCmd: commands.find((c: any) => c.name === 'quit'), 1549 - restartCmd: commands.find((c: any) => c.name === 'restart') 1550 - }); 1551 - }, api.scopes.GLOBAL); 1552 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 1553 - setTimeout(() => resolve({ hasQuit: false, hasRestart: false }), 2000); 1554 - }); 1555 - }); 1556 - 1557 - expect(result.hasQuit).toBe(true); 1558 - expect(result.hasRestart).toBe(true); 1559 - expect(result.quitCmd?.description).toBe('Quit the application'); 1560 - expect(result.restartCmd?.description).toBe('Restart the application'); 1561 - }); 1562 - 1563 - test('reload extension command is registered', async () => { 1564 - const result = await bgWindow.evaluate(async () => { 1565 - const api = (window as any).app; 1566 - 1567 - return new Promise((resolve) => { 1568 - api.subscribe('cmd:query-commands-response', (msg: any) => { 1569 - const commands = msg.commands || []; 1570 - const reloadCmd = commands.find((c: any) => c.name === 'reload extension'); 1571 - resolve({ 1572 - hasReloadExtension: !!reloadCmd, 1573 - description: reloadCmd?.description 1574 - }); 1575 - }, api.scopes.GLOBAL); 1576 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 1577 - setTimeout(() => resolve({ hasReloadExtension: false }), 2000); 1578 - }); 1579 - }); 1580 - 1581 - expect(result.hasReloadExtension).toBe(true); 1582 - expect(result.description).toBe('Reload an external extension by ID'); 1583 - }); 1584 - 1585 - test('api.quit and api.restart functions exist', async () => { 1586 - const result = await bgWindow.evaluate(() => { 1587 - const api = (window as any).app; 1588 - return { 1589 - hasQuit: typeof api.quit === 'function', 1590 - hasRestart: typeof api.restart === 'function' 1591 - }; 1592 - }); 1593 - 1594 - expect(result.hasQuit).toBe(true); 1595 - expect(result.hasRestart).toBe(true); 1596 - }); 1597 - 1598 - test('window management works', async () => { 1599 - // Open a test window 1600 - const openResult = await bgWindow.evaluate(async () => { 1601 - return await (window as any).app.window.open('about:blank', { 1602 - width: 400, 1603 - height: 300 1604 - }); 1605 - }); 1606 - expect(openResult.success).toBe(true); 1607 - expect(openResult.id).toBeDefined(); 1608 - 1609 - // Wait for window to open 1610 - await app.getWindow('about:blank', 5000); 1611 - 1612 - // List windows 1613 - const listResult = await bgWindow.evaluate(async () => { 1614 - return await (window as any).app.window.list(); 1615 - }); 1616 - expect(listResult.success).toBe(true); 1617 - expect(Array.isArray(listResult.windows)).toBe(true); 1618 - 1619 - // Close the window 1620 - await bgWindow.evaluate(async (id: number) => { 1621 - return await (window as any).app.window.close(id); 1622 - }, openResult.id); 1623 - }); 1624 - }); 1625 - 1626 - // ============================================================================ 1627 - // Tag Command Tests (uses shared app) 1628 - // ============================================================================ 1629 - 1630 - test.describe('Tag Command @desktop', () => { 1631 - let app: DesktopApp; 1632 - let bgWindow: Page; 1633 - 1634 - test.beforeAll(async () => { 1635 - ({ app, bgWindow } = await createPerDescribeApp('tag-cmd')); 1636 - }); 1637 - 1638 - test.afterAll(async () => { 1639 - if (app) await app.close(); 1640 - }); 1641 - 1642 - test('creates address if not exists when tagging', async () => { 1643 - // This tests the bug fix: addResult.data.id instead of addResult.id 1644 - // Use unique URI to avoid conflicts with other tests 1645 - // Note: datastore normalizes URLs (adds trailing slash) 1646 - const timestamp = Date.now(); 1647 - const testUri = `https://tag-test-new-address-${timestamp}.example.com/`; 1648 - 1649 - // Create tag with unique name 1650 - const tagResult = await bgWindow.evaluate(async (ts: number) => { 1651 - return await (window as any).app.datastore.getOrCreateTag('test-new-addr-tag-' + ts); 1652 - }, timestamp); 1653 - expect(tagResult.success).toBe(true); 1654 - const tagId = tagResult.data?.tag?.id; 1655 - expect(tagId).toBeTruthy(); 1656 - 1657 - // Create address 1658 - const addResult = await bgWindow.evaluate(async (uri: string) => { 1659 - return await (window as any).app.datastore.addAddress(uri, { title: 'New Tagged Address' }); 1660 - }, testUri); 1661 - expect(addResult.success).toBe(true); 1662 - // Bug fix verification: data.id is the correct path 1663 - expect(addResult.data?.id).toBeTruthy(); 1664 - 1665 - // Tag the address using the correct id path 1666 - const linkResult = await bgWindow.evaluate(async ({ addressId, tagId }) => { 1667 - return await (window as any).app.datastore.tagAddress(addressId, tagId); 1668 - }, { addressId: addResult.data.id, tagId }); 1669 - expect(linkResult.success).toBe(true); 1670 - 1671 - // Verify address is tagged 1672 - const taggedAddresses = await bgWindow.evaluate(async (tId: string) => { 1673 - return await (window as any).app.datastore.getAddressesByTag(tId); 1674 - }, tagId); 1675 - expect(taggedAddresses.success).toBe(true); 1676 - expect(taggedAddresses.data.some((a: any) => a.uri === testUri)).toBe(true); 1677 - }); 1678 - 1679 - test('getOrCreateTag returns tag in data.tag', async () => { 1680 - // This tests the bug fix: tagResult.data.tag.id instead of tagResult.data.id 1681 - const tagName = 'test-nested-tag-response'; 1682 - 1683 - const result = await bgWindow.evaluate(async (name: string) => { 1684 - return await (window as any).app.datastore.getOrCreateTag(name); 1685 - }, tagName); 1686 - 1687 - expect(result.success).toBe(true); 1688 - // Bug fix verification: tag is nested in data.tag 1689 - expect(result.data?.tag).toBeTruthy(); 1690 - expect(result.data?.tag?.id).toBeTruthy(); 1691 - expect(result.data?.tag?.name).toBe(tagName); 1692 - expect(typeof result.data?.created).toBe('boolean'); 1693 - }); 1694 - 1695 - test('tagAddress links tag to address correctly', async () => { 1696 - // Create address 1697 - const addr = await bgWindow.evaluate(async () => { 1698 - return await (window as any).app.datastore.addAddress('https://tag-link-test.example.com', { 1699 - title: 'Tag Link Test' 1700 - }); 1701 - }); 1702 - expect(addr.success).toBe(true); 1703 - 1704 - // Create tag 1705 - const tag = await bgWindow.evaluate(async () => { 1706 - return await (window as any).app.datastore.getOrCreateTag('link-test-tag'); 1707 - }); 1708 - expect(tag.success).toBe(true); 1709 - 1710 - // Link them 1711 - const link = await bgWindow.evaluate(async ({ addressId, tagId }) => { 1712 - return await (window as any).app.datastore.tagAddress(addressId, tagId); 1713 - }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 1714 - expect(link.success).toBe(true); 1715 - 1716 - // Verify link exists 1717 - const addressTags = await bgWindow.evaluate(async (addressId: string) => { 1718 - return await (window as any).app.datastore.getAddressTags(addressId); 1719 - }, addr.data.id); 1720 - expect(addressTags.success).toBe(true); 1721 - expect(addressTags.data.some((t: any) => t.name === 'link-test-tag')).toBe(true); 1722 - }); 1723 - 1724 - test('multiple tags can be added to same address', async () => { 1725 - // Create address 1726 - const addr = await bgWindow.evaluate(async () => { 1727 - return await (window as any).app.datastore.addAddress('https://multi-tag-test.example.com', { 1728 - title: 'Multi Tag Test' 1729 - }); 1730 - }); 1731 - expect(addr.success).toBe(true); 1732 - 1733 - // Create and link multiple tags 1734 - const tagNames = ['multi-tag-1', 'multi-tag-2', 'multi-tag-3']; 1735 - 1736 - for (const tagName of tagNames) { 1737 - const tag = await bgWindow.evaluate(async (name: string) => { 1738 - return await (window as any).app.datastore.getOrCreateTag(name); 1739 - }, tagName); 1740 - expect(tag.success).toBe(true); 1741 - 1742 - const link = await bgWindow.evaluate(async ({ addressId, tagId }) => { 1743 - return await (window as any).app.datastore.tagAddress(addressId, tagId); 1744 - }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 1745 - expect(link.success).toBe(true); 1746 - } 1747 - 1748 - // Verify all tags are linked 1749 - const addressTags = await bgWindow.evaluate(async (addressId: string) => { 1750 - return await (window as any).app.datastore.getAddressTags(addressId); 1751 - }, addr.data.id); 1752 - expect(addressTags.success).toBe(true); 1753 - expect(addressTags.data.length).toBeGreaterThanOrEqual(3); 1754 - 1755 - for (const tagName of tagNames) { 1756 - expect(addressTags.data.some((t: any) => t.name === tagName)).toBe(true); 1757 - } 1758 - }); 1759 - 1760 - test('untagAddress removes tag from address', async () => { 1761 - // Create address 1762 - const addr = await bgWindow.evaluate(async () => { 1763 - return await (window as any).app.datastore.addAddress('https://untag-test.example.com', { 1764 - title: 'Untag Test' 1765 - }); 1766 - }); 1767 - expect(addr.success).toBe(true); 1768 - 1769 - // Create and link tag 1770 - const tag = await bgWindow.evaluate(async () => { 1771 - return await (window as any).app.datastore.getOrCreateTag('untag-test-tag'); 1772 - }); 1773 - expect(tag.success).toBe(true); 1774 - 1775 - await bgWindow.evaluate(async ({ addressId, tagId }) => { 1776 - return await (window as any).app.datastore.tagAddress(addressId, tagId); 1777 - }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 1778 - 1779 - // Verify tag is linked 1780 - let addressTags = await bgWindow.evaluate(async (addressId: string) => { 1781 - return await (window as any).app.datastore.getAddressTags(addressId); 1782 - }, addr.data.id); 1783 - expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(true); 1784 - 1785 - // Remove tag 1786 - const untag = await bgWindow.evaluate(async ({ addressId, tagId }) => { 1787 - return await (window as any).app.datastore.untagAddress(addressId, tagId); 1788 - }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 1789 - expect(untag.success).toBe(true); 1790 - 1791 - // Verify tag is removed 1792 - addressTags = await bgWindow.evaluate(async (addressId: string) => { 1793 - return await (window as any).app.datastore.getAddressTags(addressId); 1794 - }, addr.data.id); 1795 - expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(false); 1796 - }); 1797 - 1798 - test('getUntaggedAddresses returns addresses without tags', async () => { 1799 - // Use unique URI to avoid conflicts 1800 - // Note: datastore normalizes URLs (adds trailing slash) 1801 - const timestamp = Date.now(); 1802 - const testUri = `https://untagged-test-${timestamp}.example.com/`; 1803 - 1804 - // Create address without tagging it 1805 - const addr = await bgWindow.evaluate(async (uri: string) => { 1806 - return await (window as any).app.datastore.addAddress(uri, { 1807 - title: 'Untagged Test' 1808 - }); 1809 - }, testUri); 1810 - expect(addr.success).toBe(true); 1811 - expect(addr.data?.id).toBeTruthy(); 1812 - 1813 - // Query untagged addresses 1814 - const untagged = await bgWindow.evaluate(async () => { 1815 - return await (window as any).app.datastore.getUntaggedAddresses(); 1816 - }); 1817 - expect(untagged.success).toBe(true); 1818 - expect(untagged.data.some((a: any) => a.uri === testUri)).toBe(true); 1819 - 1820 - // Tag the address with unique tag name 1821 - const tag = await bgWindow.evaluate(async (ts: number) => { 1822 - return await (window as any).app.datastore.getOrCreateTag('now-tagged-' + ts); 1823 - }, timestamp); 1824 - expect(tag.success).toBe(true); 1825 - expect(tag.data?.tag?.id).toBeTruthy(); 1826 - 1827 - await bgWindow.evaluate(async ({ addressId, tagId }) => { 1828 - return await (window as any).app.datastore.tagAddress(addressId, tagId); 1829 - }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 1830 - 1831 - // Verify it's no longer in untagged list 1832 - const untaggedAfter = await bgWindow.evaluate(async () => { 1833 - return await (window as any).app.datastore.getUntaggedAddresses(); 1834 - }); 1835 - expect(untaggedAfter.data.some((a: any) => a.uri === testUri)).toBe(false); 1836 - }); 1837 - }); 1838 - 1839 - // ============================================================================ 1840 - // Command Execution Tests (uses shared app) 1841 - // Tests the full command execution path through pubsub: 1842 - // cmd:execute:<name> -> extension handler -> result via resultTopic 1843 - // ============================================================================ 1844 - 1845 - test.describe('Command Execution @desktop', () => { 1846 - let app: DesktopApp; 1847 - let bgWindow: Page; 1848 - let pageWindowId: number | null = null; 1849 - const testPageUrl = `https://cmd-exec-test-${Date.now()}.example.com/`; 1850 - 1851 - test.beforeAll(async () => { 1852 - ({ app, bgWindow } = await createPerDescribeApp('cmd-exec')); 1853 - 1854 - // Open a page window so tag commands have an "active window" to work with 1855 - const openResult = await bgWindow.evaluate(async (url: string) => { 1856 - return await (window as any).app.window.open(url, { 1857 - width: 800, 1858 - height: 600, 1859 - key: 'cmd-exec-test-page' 1860 - }); 1861 - }, testPageUrl); 1862 - 1863 - if (openResult.success && openResult.id) { 1864 - pageWindowId = openResult.id; 1865 - } 1866 - 1867 - // Give the page window time to load page.js, complete api.initialize(), 1868 - // and subscribe to tag pubsub events. Without this wait, the first tag 1869 - // command fires before page.js's subscribe is installed → missed event. 1870 - await sleep(2000); 1871 - }); 1872 - 1873 - test.afterAll(async () => { 1874 - // Close the page window we opened 1875 - if (pageWindowId && bgWindow && !bgWindow.isClosed()) { 1876 - try { 1877 - await bgWindow.evaluate(async (id: number) => { 1878 - return await (window as any).app.window.close(id); 1879 - }, pageWindowId); 1880 - } catch { /* app may already be closing */ } 1881 - } 1882 - if (app) await app.close(); 1883 - }); 1884 - 1885 - test('tag command with # prefixed tags stores tags without prefix', async () => { 1886 - const timestamp = Date.now(); 1887 - const tag1 = `testfoo${timestamp}`; 1888 - const tag2 = `testbar${timestamp}`; 1889 - 1890 - // Execute the tag command through pubsub 1891 - const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 1892 - const api = (window as any).app; 1893 - return new Promise((resolve) => { 1894 - const resultTopic = `cmd:execute:${args.name}:result`; 1895 - api.subscribe(resultTopic, (result: any) => { 1896 - resolve(result); 1897 - }, api.scopes.GLOBAL); 1898 - 1899 - api.publish(`cmd:execute:${args.name}`, { 1900 - search: args.search, 1901 - params: [], 1902 - expectResult: true, 1903 - resultTopic 1904 - }, api.scopes.GLOBAL); 1905 - 1906 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 1907 - }); 1908 - }, { name: 'tag', search: `#${tag1} #${tag2}` }); 1909 - 1910 - expect((result as any).success).toBe(true); 1911 - 1912 - // Verify tags are stored WITHOUT the # prefix 1913 - const added = (result as any).added || []; 1914 - expect(added).toContain(tag1); 1915 - expect(added).toContain(tag2); 1916 - // Ensure no # prefix leaked through 1917 - expect(added.some((t: string) => t.startsWith('#'))).toBe(false); 1918 - 1919 - // Verify via datastore: find items tagged with tag1 (tag-centric check, 1920 - // since getActiveWindow() may return a different window than testPageUrl) 1921 - const itemCheck = await bgWindow.evaluate(async (tagName: string) => { 1922 - const api = (window as any).app; 1923 - const tagResult = await api.datastore.getOrCreateTag(tagName); 1924 - if (!tagResult.success) return { found: false }; 1925 - const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 1926 - return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 }; 1927 - }, tag1); 1928 - 1929 - expect(itemCheck.found).toBe(true); 1930 - }); 1931 - 1932 - test('tag command without # prefix works the same way', async () => { 1933 - const timestamp = Date.now(); 1934 - const tagName = `testbaz${timestamp}`; 1935 - 1936 - const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 1937 - const api = (window as any).app; 1938 - return new Promise((resolve) => { 1939 - const resultTopic = `cmd:execute:${args.name}:result`; 1940 - api.subscribe(resultTopic, (result: any) => { 1941 - resolve(result); 1942 - }, api.scopes.GLOBAL); 1943 - 1944 - api.publish(`cmd:execute:${args.name}`, { 1945 - search: args.search, 1946 - params: [], 1947 - expectResult: true, 1948 - resultTopic 1949 - }, api.scopes.GLOBAL); 1950 - 1951 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 1952 - }); 1953 - }, { name: 'tag', search: tagName }); 1954 - 1955 - expect((result as any).success).toBe(true); 1956 - const added = (result as any).added || []; 1957 - expect(added).toContain(tagName); 1958 - }); 1959 - 1960 - test('tag command creates item if none exists', async () => { 1961 - const timestamp = Date.now(); 1962 - // Open a new page window with a URL that has no item yet 1963 - const newUrl = `https://cmd-exec-new-item-${timestamp}.example.com/`; 1964 - const tagName = `newtag${timestamp}`; 1965 - 1966 - // Close the shared page window so the new window becomes the "active" one 1967 - // (getActiveWindow returns the first non-internal window) 1968 - if (pageWindowId) { 1969 - await bgWindow.evaluate(async (id: number) => { 1970 - return await (window as any).app.window.close(id); 1971 - }, pageWindowId); 1972 - pageWindowId = null; 1973 - await sleep(200); 1974 - } 1975 - 1976 - const openResult = await bgWindow.evaluate(async (url: string) => { 1977 - return await (window as any).app.window.open(url, { 1978 - width: 800, 1979 - height: 600, 1980 - key: `cmd-exec-new-item-${Date.now()}` 1981 - }); 1982 - }, newUrl); 1983 - expect(openResult.success).toBe(true); 1984 - const newWindowId = openResult.id; 1985 - 1986 - // Give the window time to register 1987 - await sleep(500); 1988 - 1989 - // Execute tag command — should create item and tag it 1990 - const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 1991 - const api = (window as any).app; 1992 - return new Promise((resolve) => { 1993 - const resultTopic = `cmd:execute:${args.name}:result`; 1994 - api.subscribe(resultTopic, (result: any) => { 1995 - resolve(result); 1996 - }, api.scopes.GLOBAL); 1997 - 1998 - api.publish(`cmd:execute:${args.name}`, { 1999 - search: args.search, 2000 - params: [], 2001 - expectResult: true, 2002 - resultTopic 2003 - }, api.scopes.GLOBAL); 2004 - 2005 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2006 - }); 2007 - }, { name: 'tag', search: `#${tagName}` }); 2008 - 2009 - expect((result as any).success).toBe(true); 2010 - expect((result as any).added).toContain(tagName); 2011 - 2012 - // Verify an item was created and tagged (tag-centric check, 2013 - // since getActiveWindow() may not return newUrl if other windows exist) 2014 - const itemCheck = await bgWindow.evaluate(async (tag: string) => { 2015 - const api = (window as any).app; 2016 - const tagResult = await api.datastore.getOrCreateTag(tag); 2017 - if (!tagResult.success) return { found: false }; 2018 - const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 2019 - return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 }; 2020 - }, tagName); 2021 - 2022 - expect(itemCheck.found).toBe(true); 2023 - 2024 - // Reopen a shared page window for remaining tests 2025 - const reopenResult = await bgWindow.evaluate(async (url: string) => { 2026 - return await (window as any).app.window.open(url, { 2027 - width: 800, 2028 - height: 600, 2029 - key: 'cmd-exec-test-page' 2030 - }); 2031 - }, testPageUrl); 2032 - if (reopenResult.success && reopenResult.id) { 2033 - pageWindowId = reopenResult.id; 2034 - } 2035 - await sleep(300); 2036 - 2037 - // Clean up the test window 2038 - if (newWindowId) { 2039 - await bgWindow.evaluate(async (id: number) => { 2040 - return await (window as any).app.window.close(id); 2041 - }, newWindowId); 2042 - } 2043 - }); 2044 - 2045 - test('untag command removes tags from item', async () => { 2046 - const timestamp = Date.now(); 2047 - const tagName = `untagme${timestamp}`; 2048 - 2049 - // First, tag the item via command execution 2050 - const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 2051 - const api = (window as any).app; 2052 - return new Promise((resolve) => { 2053 - const resultTopic = `cmd:execute:${args.name}:result`; 2054 - api.subscribe(resultTopic, (result: any) => { 2055 - resolve(result); 2056 - }, api.scopes.GLOBAL); 2057 - 2058 - api.publish(`cmd:execute:${args.name}`, { 2059 - search: args.search, 2060 - params: [], 2061 - expectResult: true, 2062 - resultTopic 2063 - }, api.scopes.GLOBAL); 2064 - 2065 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2066 - }); 2067 - }, { name: 'tag', search: `#${tagName}` }); 2068 - 2069 - expect((tagResult as any).success).toBe(true); 2070 - expect((tagResult as any).added).toContain(tagName); 2071 - 2072 - // Verify tag exists (tag-centric check) 2073 - const beforeCheck = await bgWindow.evaluate(async (tag: string) => { 2074 - const api = (window as any).app; 2075 - const tagResult = await api.datastore.getOrCreateTag(tag); 2076 - if (!tagResult.success) return { found: false }; 2077 - const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 2078 - return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 }; 2079 - }, tagName); 2080 - 2081 - expect(beforeCheck.found).toBe(true); 2082 - 2083 - // Now untag via the untag command 2084 - const untagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 2085 - const api = (window as any).app; 2086 - return new Promise((resolve) => { 2087 - const resultTopic = `cmd:execute:${args.name}:result`; 2088 - api.subscribe(resultTopic, (result: any) => { 2089 - resolve(result); 2090 - }, api.scopes.GLOBAL); 2091 - 2092 - api.publish(`cmd:execute:${args.name}`, { 2093 - search: args.search, 2094 - params: [], 2095 - expectResult: true, 2096 - resultTopic 2097 - }, api.scopes.GLOBAL); 2098 - 2099 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2100 - }); 2101 - }, { name: 'untag', search: `#${tagName}` }); 2102 - 2103 - expect((untagResult as any).success).toBe(true); 2104 - 2105 - // Verify tag is removed (no items with this tag anymore) 2106 - const afterCheck = await bgWindow.evaluate(async (tag: string) => { 2107 - const api = (window as any).app; 2108 - const tagResult = await api.datastore.getOrCreateTag(tag); 2109 - if (!tagResult.success) return { removed: false }; 2110 - const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id); 2111 - return { removed: !tagged.data || tagged.data.length === 0 }; 2112 - }, tagName); 2113 - 2114 - expect(afterCheck.removed).toBe(true); 2115 - }); 2116 - 2117 - test('tags page widget updates dynamically when tag is added via command', async () => { 2118 - const timestamp = Date.now(); 2119 - const setupTag = `setupdyn${timestamp}`; 2120 - const dynamicTag = `testdynamic${timestamp}`; 2121 - // Per-test unique URL + key. Prevents cross-test window / item reuse in 2122 - // the shared-app full-suite context (where the describe's `pageWindowId` 2123 - // may have accumulated state from earlier tests or been closed/reopened 2124 - // with stale subscribers). Isolating this test to its own page window is 2125 - // the robust fix for the full-suite ordering flake — the other tests in 2126 - // this describe don't query #tags-list, so they tolerate the shared 2127 - // window; only this one is sensitive to page.js subscriber state. 2128 - const dynamicUrl = `https://cmd-exec-dyn-${timestamp}.example.com/`; 2129 - const dynamicKey = `cmd-exec-dyn-${timestamp}`; 2130 - 2131 - // Close the shared page window so getActiveWindow() picks our fresh one 2132 - // (matches the pattern in "tag command creates item if none exists"). 2133 - const hadSharedWindow = pageWindowId !== null; 2134 - if (pageWindowId) { 2135 - await bgWindow.evaluate(async (id: number) => { 2136 - return await (window as any).app.window.close(id); 2137 - }, pageWindowId); 2138 - pageWindowId = null; 2139 - await sleep(200); 2140 - } 2141 - 2142 - // Open our isolated page window 2143 - const openResult = await bgWindow.evaluate(async (args: { url: string; key: string }) => { 2144 - return await (window as any).app.window.open(args.url, { 2145 - width: 800, 2146 - height: 600, 2147 - key: args.key 2148 - }); 2149 - }, { url: dynamicUrl, key: dynamicKey }); 2150 - expect(openResult.success).toBe(true); 2151 - const testWindowId = openResult.id; 2152 - 2153 - // Wait for page.js to initialize and subscribe to pubsub 2154 - // (same 2s wait as in beforeAll — matches page.js init timing) 2155 - await sleep(2000); 2156 - 2157 - // Helper to execute a tag command and wait for the result 2158 - const executeTag = async (tag: string) => { 2159 - return bgWindow.evaluate(async (args: { name: string; search: string }) => { 2160 - const api = (window as any).app; 2161 - return new Promise((resolve) => { 2162 - const resultTopic = `cmd:execute:${args.name}:result`; 2163 - api.subscribe(resultTopic, (result: any) => { 2164 - resolve(result); 2165 - }, api.scopes.GLOBAL); 2166 - api.publish(`cmd:execute:${args.name}`, { 2167 - search: args.search, 2168 - params: [], 2169 - expectResult: true, 2170 - resultTopic 2171 - }, api.scopes.GLOBAL); 2172 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2173 - }); 2174 - }, { name: 'tag', search: tag }); 2175 - }; 2176 - 2177 - try { 2178 - // Grab the page window handle first so we can gate on page.js readiness 2179 - // BEFORE firing the setup tag. Under full-suite load the 2s sleep above 2180 - // is not enough — the tag command can otherwise publish `tag:item-added` 2181 - // before page.js has run its top-level `api.subscribe('tag:item-added', 2182 - // ...)`. tile-preload's `subscribeImpl` attaches the underlying 2183 - // `ipcRenderer.on('pubsub:tag:item-added')` listener synchronously from 2184 - // page.js module evaluation, so gating on `__pageModuleReady` (the 2185 - // sentinel flipped at the very bottom of page.js) guarantees the 2186 - // listener is live. Electron's `webContents.send` is fire-and-forget — 2187 - // a pubsub event that arrives before the listener is attached is 2188 - // silently dropped, which is the root cause of the full-suite flake. 2189 - const pageWindow = await app.getWindow(dynamicKey, 10000); 2190 - expect(pageWindow).toBeTruthy(); 2191 - await pageWindow.waitForFunction( 2192 - () => (window as unknown as { __pageModuleReady?: boolean }).__pageModuleReady === true, 2193 - null, 2194 - { timeout: 10000 } 2195 - ); 2196 - 2197 - // First tag establishes the item in the datastore and triggers the page's 2198 - // resolveItemId fallback, setting currentItemId for subsequent events 2199 - const setupResult = await executeTag(setupTag); 2200 - expect((setupResult as any).success).toBe(true); 2201 - 2202 - // Wait for page.js to initialize and for the setup tag to appear 2203 - // (proves the reactive update path works and currentItemId is set) 2204 - await pageWindow.waitForFunction( 2205 - (expected: string) => { 2206 - const list = document.getElementById('tags-list'); 2207 - if (!list) return false; 2208 - const names = Array.from(list.querySelectorAll('.tag-name')) 2209 - .map(el => el.textContent); 2210 - return names.includes(expected); 2211 - }, 2212 - setupTag, 2213 - { timeout: 10000 } 2214 - ); 2215 - 2216 - // Record tag count after setup 2217 - const tagCountBefore = await pageWindow.evaluate(() => { 2218 - const list = document.getElementById('tags-list'); 2219 - return list ? list.querySelectorAll('.tag-btn').length : 0; 2220 - }); 2221 - 2222 - // Now add a second tag — this should update the widget reactively 2223 - // because currentItemId is already set from the first tag 2224 - const result = await executeTag(dynamicTag); 2225 - expect((result as any).success).toBe(true); 2226 - expect((result as any).added).toContain(dynamicTag); 2227 - 2228 - // Verify the tags widget updates dynamically 2229 - await pageWindow.waitForFunction( 2230 - (expectedTag: string) => { 2231 - const list = document.getElementById('tags-list'); 2232 - if (!list) return false; 2233 - const tagNames = Array.from(list.querySelectorAll('.tag-name')) 2234 - .map(el => el.textContent); 2235 - return tagNames.includes(expectedTag); 2236 - }, 2237 - dynamicTag, 2238 - { timeout: 10000 } 2239 - ); 2240 - 2241 - // Tag count increased 2242 - const tagCountAfter = await pageWindow.evaluate(() => { 2243 - const list = document.getElementById('tags-list'); 2244 - return list ? list.querySelectorAll('.tag-btn').length : 0; 2245 - }); 2246 - expect(tagCountAfter).toBeGreaterThan(tagCountBefore); 2247 - } finally { 2248 - // Close our isolated page window 2249 - if (testWindowId) { 2250 - await bgWindow.evaluate(async (id: number) => { 2251 - return await (window as any).app.window.close(id); 2252 - }, testWindowId); 2253 - } 2254 - 2255 - // Reopen the shared page window so the remaining tests in this describe 2256 - // (and the afterAll cleanup) have the state they expect. 2257 - if (hadSharedWindow) { 2258 - const reopenResult = await bgWindow.evaluate(async (url: string) => { 2259 - return await (window as any).app.window.open(url, { 2260 - width: 800, 2261 - height: 600, 2262 - key: 'cmd-exec-test-page' 2263 - }); 2264 - }, testPageUrl); 2265 - if (reopenResult.success && reopenResult.id) { 2266 - pageWindowId = reopenResult.id; 2267 - } 2268 - await sleep(300); 2269 - } 2270 - } 2271 - }); 2272 - 2273 - test('tag command with no args returns current tags', async () => { 2274 - const timestamp = Date.now(); 2275 - const tagName = `showme${timestamp}`; 2276 - 2277 - // First add a tag so there's something to show 2278 - const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 2279 - const api = (window as any).app; 2280 - return new Promise((resolve) => { 2281 - const resultTopic = `cmd:execute:${args.name}:result`; 2282 - api.subscribe(resultTopic, (result: any) => { 2283 - resolve(result); 2284 - }, api.scopes.GLOBAL); 2285 - 2286 - api.publish(`cmd:execute:${args.name}`, { 2287 - search: args.search, 2288 - params: [], 2289 - expectResult: true, 2290 - resultTopic 2291 - }, api.scopes.GLOBAL); 2292 - 2293 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2294 - }); 2295 - }, { name: 'tag', search: tagName }); 2296 - 2297 - expect((tagResult as any).success).toBe(true); 2298 - 2299 - // Now execute tag with no search args — should return current tags 2300 - const result = await bgWindow.evaluate(async (args: { name: string }) => { 2301 - const api = (window as any).app; 2302 - return new Promise((resolve) => { 2303 - const resultTopic = `cmd:execute:${args.name}:result`; 2304 - api.subscribe(resultTopic, (result: any) => { 2305 - resolve(result); 2306 - }, api.scopes.GLOBAL); 2307 - 2308 - api.publish(`cmd:execute:${args.name}`, { 2309 - search: '', 2310 - params: [], 2311 - expectResult: true, 2312 - resultTopic 2313 - }, api.scopes.GLOBAL); 2314 - 2315 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2316 - }); 2317 - }, { name: 'tag' }); 2318 - 2319 - expect((result as any).success).toBe(true); 2320 - // Should return tags array for the active window's URL 2321 - expect(Array.isArray((result as any).tags)).toBe(true); 2322 - // The tag we just added should be in the list 2323 - const tagNames = (result as any).tags.map((t: any) => t.name); 2324 - expect(tagNames).toContain(tagName); 2325 - }); 2326 - 2327 - test('tagset command creates tagset item with tags stripped of #', async () => { 2328 - const timestamp = Date.now(); 2329 - const tag1 = `setx${timestamp}`; 2330 - const tag2 = `sety${timestamp}`; 2331 - 2332 - // Execute tagset command with # prefixed tags, comma separated 2333 - const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => { 2334 - const api = (window as any).app; 2335 - return new Promise((resolve) => { 2336 - const resultTopic = `cmd:execute:${args.name}:result`; 2337 - api.subscribe(resultTopic, (result: any) => { 2338 - resolve(result); 2339 - }, api.scopes.GLOBAL); 2340 - 2341 - api.publish(`cmd:execute:${args.name}`, { 2342 - search: args.search, 2343 - params: [], 2344 - expectResult: true, 2345 - resultTopic 2346 - }, api.scopes.GLOBAL); 2347 - 2348 - setTimeout(() => resolve({ error: 'timeout' }), 10000); 2349 - }); 2350 - }, { name: 'tagset', search: `#${tag1}, #${tag2}` }); 2351 - 2352 - expect((result as any).success).toBe(true); 2353 - expect((result as any).message).toContain(tag1); 2354 - expect((result as any).message).toContain(tag2); 2355 - 2356 - // Verify the tagset item was created in the datastore 2357 - const tagsetCheck = await bgWindow.evaluate(async (args: { tag1: string; tag2: string }) => { 2358 - const api = (window as any).app; 2359 - // Query for tagset items 2360 - const queryResult = await api.datastore.queryItems({ type: 'tagset', limit: 50 }); 2361 - if (!queryResult.success) return { found: false }; 2362 - 2363 - // Find our tagset by content (tags joined with ", ") 2364 - const tagset = queryResult.data.find((item: any) => 2365 - item.content.includes(args.tag1) && item.content.includes(args.tag2) 2366 - ); 2367 - if (!tagset) return { found: false }; 2368 - 2369 - // Get the tags on the tagset item 2370 - const tagsResult = await api.datastore.getItemTags(tagset.id); 2371 - return { 2372 - found: true, 2373 - itemId: tagset.id, 2374 - content: tagset.content, 2375 - tags: tagsResult.data?.map((t: any) => t.name) || [] 2376 - }; 2377 - }, { tag1, tag2 }); 2378 - 2379 - expect(tagsetCheck.found).toBe(true); 2380 - // Tags should be stored without # prefix 2381 - expect(tagsetCheck.tags).toContain(tag1); 2382 - expect(tagsetCheck.tags).toContain(tag2); 2383 - // The content field should contain the normalized tag names 2384 - expect(tagsetCheck.content).toContain(tag1); 2385 - expect(tagsetCheck.content).toContain(tag2); 2386 - // Should also have the from:cmd tag 2387 - expect(tagsetCheck.tags).toContain('from:cmd'); 2388 - }); 2389 - }); 2390 - 2391 - // ============================================================================ 2392 - // Tag Events Tests (uses shared app) 2393 - // ============================================================================ 2394 - 2395 - test.describe('Tag Events @desktop', () => { 2396 - let app: DesktopApp; 2397 - let bgWindow: Page; 2398 - 2399 - test.beforeAll(async () => { 2400 - ({ app, bgWindow } = await createPerDescribeApp('tag-events')); 2401 - }); 2402 - 2403 - test.afterAll(async () => { 2404 - if (app) await app.close(); 2405 - }); 2406 - 2407 - test('tag:created is emitted when new tag is created', async () => { 2408 - const timestamp = Date.now(); 2409 - const tagName = `event-test-tag-${timestamp}`; 2410 - 2411 - const result = await bgWindow.evaluate(async (name: string) => { 2412 - const api = (window as any).app; 2413 - 2414 - return new Promise((resolve) => { 2415 - const timeout = setTimeout(() => { 2416 - resolve({ received: false }); 2417 - }, 5000); 2418 - 2419 - api.subscribe('tag:created', (msg: any) => { 2420 - if (msg.tagName === name) { 2421 - clearTimeout(timeout); 2422 - resolve({ 2423 - received: true, 2424 - tagId: msg.tagId, 2425 - tagName: msg.tagName 2426 - }); 2427 - } 2428 - }, api.scopes.GLOBAL); 2429 - 2430 - // Create new tag to trigger the event 2431 - api.datastore.getOrCreateTag(name); 2432 - }); 2433 - }, tagName); 2434 - 2435 - expect((result as any).received).toBe(true); 2436 - expect((result as any).tagName).toBe(tagName); 2437 - expect((result as any).tagId).toBeTruthy(); 2438 - }); 2439 - 2440 - test('tag:item-added is emitted when item is tagged', async () => { 2441 - const timestamp = Date.now(); 2442 - const tagName = `item-added-event-tag-${timestamp}`; 2443 - 2444 - const result = await bgWindow.evaluate(async (name: string) => { 2445 - const api = (window as any).app; 2446 - 2447 - // First create an item and a tag 2448 - const itemResult = await api.datastore.addItem('url', { 2449 - content: `https://tag-event-test-${Date.now()}.example.com`, 2450 - metadata: JSON.stringify({ title: 'Tag Event Test Item' }) 2451 - }); 2452 - if (!itemResult.success) { 2453 - return { received: false, error: 'failed to create item' }; 2454 - } 2455 - const itemId = itemResult.data.id; 2456 - 2457 - const tagResult = await api.datastore.getOrCreateTag(name); 2458 - if (!tagResult.success) { 2459 - return { received: false, error: 'failed to create tag' }; 2460 - } 2461 - const tagId = tagResult.data.tag.id; 2462 - 2463 - return new Promise((resolve) => { 2464 - const timeout = setTimeout(() => { 2465 - resolve({ received: false, error: 'timeout' }); 2466 - }, 5000); 2467 - 2468 - api.subscribe('tag:item-added', (msg: any) => { 2469 - if (msg.itemId === itemId && msg.tagId === tagId) { 2470 - clearTimeout(timeout); 2471 - resolve({ 2472 - received: true, 2473 - tagId: msg.tagId, 2474 - tagName: msg.tagName, 2475 - itemId: msg.itemId, 2476 - itemType: msg.itemType 2477 - }); 2478 - } 2479 - }, api.scopes.GLOBAL); 2480 - 2481 - // Tag the item to trigger the event 2482 - api.datastore.tagItem(itemId, tagId); 2483 - }); 2484 - }, tagName); 2485 - 2486 - expect((result as any).received).toBe(true); 2487 - expect((result as any).tagName).toBe(tagName); 2488 - expect((result as any).tagId).toBeTruthy(); 2489 - expect((result as any).itemId).toBeTruthy(); 2490 - expect((result as any).itemType).toBe('url'); 2491 - }); 2492 - 2493 - test('tag:item-removed is emitted when item is untagged', async () => { 2494 - const timestamp = Date.now(); 2495 - const tagName = `item-removed-event-tag-${timestamp}`; 2496 - 2497 - const result = await bgWindow.evaluate(async (name: string) => { 2498 - const api = (window as any).app; 2499 - 2500 - // Create an item 2501 - const itemResult = await api.datastore.addItem('url', { 2502 - content: `https://untag-event-test-${Date.now()}.example.com`, 2503 - metadata: JSON.stringify({ title: 'Untag Event Test Item' }) 2504 - }); 2505 - if (!itemResult.success) { 2506 - return { received: false, error: 'failed to create item' }; 2507 - } 2508 - const itemId = itemResult.data.id; 2509 - 2510 - // Create a tag 2511 - const tagResult = await api.datastore.getOrCreateTag(name); 2512 - if (!tagResult.success) { 2513 - return { received: false, error: 'failed to create tag' }; 2514 - } 2515 - const tagId = tagResult.data.tag.id; 2516 - 2517 - // Tag the item first 2518 - await api.datastore.tagItem(itemId, tagId); 2519 - 2520 - return new Promise((resolve) => { 2521 - const timeout = setTimeout(() => { 2522 - resolve({ received: false, error: 'timeout' }); 2523 - }, 5000); 2524 - 2525 - api.subscribe('tag:item-removed', (msg: any) => { 2526 - if (msg.itemId === itemId && msg.tagId === tagId) { 2527 - clearTimeout(timeout); 2528 - resolve({ 2529 - received: true, 2530 - tagId: msg.tagId, 2531 - tagName: msg.tagName, 2532 - itemId: msg.itemId 2533 - }); 2534 - } 2535 - }, api.scopes.GLOBAL); 2536 - 2537 - // Untag the item to trigger the event 2538 - api.datastore.untagItem(itemId, tagId); 2539 - }); 2540 - }, tagName); 2541 - 2542 - expect((result as any).received).toBe(true); 2543 - expect((result as any).tagName).toBe(tagName); 2544 - expect((result as any).tagId).toBeTruthy(); 2545 - expect((result as any).itemId).toBeTruthy(); 2546 - }); 2547 - 2548 - test('tag:item-added is NOT emitted for duplicate tag', async () => { 2549 - const timestamp = Date.now(); 2550 - const tagName = `duplicate-tag-event-${timestamp}`; 2551 - 2552 - const result = await bgWindow.evaluate(async (name: string) => { 2553 - const api = (window as any).app; 2554 - 2555 - // Create an item 2556 - const itemResult = await api.datastore.addItem('url', { 2557 - content: `https://duplicate-tag-test-${Date.now()}.example.com`, 2558 - metadata: JSON.stringify({ title: 'Duplicate Tag Test Item' }) 2559 - }); 2560 - if (!itemResult.success) { 2561 - return { received: false, error: 'failed to create item' }; 2562 - } 2563 - const itemId = itemResult.data.id; 2564 - 2565 - // Create a tag 2566 - const tagResult = await api.datastore.getOrCreateTag(name); 2567 - if (!tagResult.success) { 2568 - return { received: false, error: 'failed to create tag' }; 2569 - } 2570 - const tagId = tagResult.data.tag.id; 2571 - 2572 - // Tag the item for the first time (this should emit an event but we don't care) 2573 - await api.datastore.tagItem(itemId, tagId); 2574 - 2575 - // Wait a bit to ensure the first event has been processed 2576 - await new Promise(r => setTimeout(r, 100)); 2577 - 2578 - return new Promise((resolve) => { 2579 - // Use a short timeout since we expect NO event 2580 - const timeout = setTimeout(() => { 2581 - resolve({ received: false }); // This is the expected outcome 2582 - }, 1000); 2583 - 2584 - api.subscribe('tag:item-added', (msg: any) => { 2585 - if (msg.itemId === itemId && msg.tagId === tagId) { 2586 - clearTimeout(timeout); 2587 - resolve({ received: true }); // This would be unexpected 2588 - } 2589 - }, api.scopes.GLOBAL); 2590 - 2591 - // Try to tag the same item with the same tag again 2592 - api.datastore.tagItem(itemId, tagId); 2593 - }); 2594 - }, tagName); 2595 - 2596 - // We expect NO event to be received for duplicate tagging 2597 - expect((result as any).received).toBe(false); 2598 - }); 2599 - }); 2600 - 2601 - // ============================================================================ 2602 - // Item Events Tests (uses shared app) 2603 - // ============================================================================ 2604 - 2605 - test.describe('Item Events @desktop', () => { 2606 - let app: DesktopApp; 2607 - let bgWindow: Page; 2608 - 2609 - test.beforeAll(async () => { 2610 - ({ app, bgWindow } = await createPerDescribeApp('item-events')); 2611 - }); 2612 - 2613 - test.afterAll(async () => { 2614 - if (app) await app.close(); 2615 - }); 2616 - 2617 - test('item:created is emitted when item is added', async () => { 2618 - const timestamp = Date.now(); 2619 - const testUrl = `https://item-created-event-${timestamp}.example.com`; 2620 - 2621 - const result = await bgWindow.evaluate(async (url: string) => { 2622 - const api = (window as any).app; 2623 - 2624 - return new Promise((resolve) => { 2625 - const timeout = setTimeout(() => { 2626 - resolve({ received: false, error: 'timeout' }); 2627 - }, 5000); 2628 - 2629 - api.subscribe('item:created', (msg: any) => { 2630 - if (msg.content === url) { 2631 - clearTimeout(timeout); 2632 - resolve({ 2633 - received: true, 2634 - itemId: msg.itemId, 2635 - itemType: msg.itemType, 2636 - content: msg.content 2637 - }); 2638 - } 2639 - }, api.scopes.GLOBAL); 2640 - 2641 - // Create item to trigger the event 2642 - api.datastore.addItem('url', { 2643 - content: url, 2644 - metadata: JSON.stringify({ title: 'Item Created Event Test' }) 2645 - }); 2646 - }); 2647 - }, testUrl); 2648 - 2649 - expect((result as any).received).toBe(true); 2650 - expect((result as any).itemId).toBeTruthy(); 2651 - expect((result as any).itemType).toBe('url'); 2652 - expect((result as any).content).toBe(testUrl); 2653 - }); 2654 - 2655 - test('item:updated is emitted when item is updated', async () => { 2656 - const timestamp = Date.now(); 2657 - 2658 - const result = await bgWindow.evaluate(async (ts: number) => { 2659 - const api = (window as any).app; 2660 - 2661 - // First create an item 2662 - const itemResult = await api.datastore.addItem('url', { 2663 - content: `https://item-updated-event-${ts}.example.com`, 2664 - metadata: JSON.stringify({ title: 'Item Updated Event Test' }) 2665 - }); 2666 - if (!itemResult.success) { 2667 - return { received: false, error: 'failed to create item' }; 2668 - } 2669 - const itemId = itemResult.data.id; 2670 - 2671 - return new Promise((resolve) => { 2672 - const timeout = setTimeout(() => { 2673 - resolve({ received: false, error: 'timeout' }); 2674 - }, 5000); 2675 - 2676 - api.subscribe('item:updated', (msg: any) => { 2677 - if (msg.itemId === itemId) { 2678 - clearTimeout(timeout); 2679 - resolve({ 2680 - received: true, 2681 - itemId: msg.itemId, 2682 - itemType: msg.itemType 2683 - }); 2684 - } 2685 - }, api.scopes.GLOBAL); 2686 - 2687 - // Update item to trigger the event 2688 - api.datastore.updateItem(itemId, { 2689 - content: `https://item-updated-event-${ts}-modified.example.com` 2690 - }); 2691 - }); 2692 - }, timestamp); 2693 - 2694 - expect((result as any).received).toBe(true); 2695 - expect((result as any).itemId).toBeTruthy(); 2696 - expect((result as any).itemType).toBe('url'); 2697 - }); 2698 - 2699 - test('item:deleted is emitted when item is deleted', async () => { 2700 - const timestamp = Date.now(); 2701 - 2702 - const result = await bgWindow.evaluate(async (ts: number) => { 2703 - const api = (window as any).app; 2704 - 2705 - // First create an item 2706 - const itemResult = await api.datastore.addItem('url', { 2707 - content: `https://item-deleted-event-${ts}.example.com`, 2708 - metadata: JSON.stringify({ title: 'Item Deleted Event Test' }) 2709 - }); 2710 - if (!itemResult.success) { 2711 - return { received: false, error: 'failed to create item' }; 2712 - } 2713 - const itemId = itemResult.data.id; 2714 - 2715 - return new Promise((resolve) => { 2716 - const timeout = setTimeout(() => { 2717 - resolve({ received: false, error: 'timeout' }); 2718 - }, 5000); 2719 - 2720 - api.subscribe('item:deleted', (msg: any) => { 2721 - if (msg.itemId === itemId) { 2722 - clearTimeout(timeout); 2723 - resolve({ 2724 - received: true, 2725 - itemId: msg.itemId, 2726 - itemType: msg.itemType 2727 - }); 2728 - } 2729 - }, api.scopes.GLOBAL); 2730 - 2731 - // Delete item to trigger the event 2732 - api.datastore.deleteItem(itemId); 2733 - }); 2734 - }, timestamp); 2735 - 2736 - expect((result as any).received).toBe(true); 2737 - expect((result as any).itemId).toBeTruthy(); 2738 - expect((result as any).itemType).toBe('url'); 2739 - }); 2740 - }); 2741 - 2742 - // ============================================================================ 2743 - // Groups View Tests (uses shared app) 2744 - // ============================================================================ 2745 - 2746 - test.describe('Groups View @desktop', () => { 2747 - let app: DesktopApp; 2748 - let bgWindow: Page; 2749 - 2750 - test.beforeAll(async () => { 2751 - ({ app, bgWindow } = await createPerDescribeApp('groups-view')); 2752 - }); 2753 - 2754 - test.afterAll(async () => { 2755 - if (app) await app.close(); 2756 - }); 2757 - 2758 - test('empty groups are not shown in groups list', async () => { 2759 - // Create an empty tag (group with no items) and promote it 2760 - const emptyTag = await bgWindow.evaluate(async () => { 2761 - const result = await (window as any).app.datastore.getOrCreateTag('empty-group-test'); 2762 - if (result.success) { 2763 - const tag = result.data.tag; 2764 - await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) }); 2765 - } 2766 - return result; 2767 - }); 2768 - expect(emptyTag.success).toBe(true); 2769 - 2770 - // Create a tag with an item and promote it 2771 - const nonEmptyTag = await bgWindow.evaluate(async () => { 2772 - const result = await (window as any).app.datastore.getOrCreateTag('non-empty-group-test'); 2773 - if (result.success) { 2774 - const tag = result.data.tag; 2775 - await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) }); 2776 - } 2777 - return result; 2778 - }); 2779 - expect(nonEmptyTag.success).toBe(true); 2780 - 2781 - const item = await bgWindow.evaluate(async () => { 2782 - return await (window as any).app.datastore.addItem('url', { 2783 - content: 'https://non-empty-group-addr.example.com', 2784 - metadata: JSON.stringify({ title: 'Non Empty Group Address' }) 2785 - }); 2786 - }); 2787 - expect(item.success).toBe(true); 2788 - 2789 - await bgWindow.evaluate(async ({ itemId, tagId }) => { 2790 - return await (window as any).app.datastore.tagItem(itemId, tagId); 2791 - }, { itemId: item.data.id, tagId: nonEmptyTag.data.tag.id }); 2792 - 2793 - // Open groups home 2794 - const groupsResult = await bgWindow.evaluate(async () => { 2795 - return await (window as any).app.window.open('peek://groups/home.html', { 2796 - width: 800, 2797 - height: 600 2798 - }); 2799 - }); 2800 - expect(groupsResult.success).toBe(true); 2801 - 2802 - // Find the groups window (getWindow polls) 2803 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 2804 - expect(groupsWindow).toBeTruthy(); 2805 - await groupsWindow.waitForLoadState('domcontentloaded'); 2806 - 2807 - // Wait for group cards to render (async data loading + rendering) 2808 - await groupsWindow.waitForSelector(`peek-card.group-card[data-tag-id="${nonEmptyTag.data.tag.id}"]`, { timeout: 10000 }); 2809 - 2810 - // Get all group card tag IDs 2811 - const groupCards = await groupsWindow.$$eval('peek-card.group-card', (cards: any[]) => 2812 - cards.map(c => c.dataset.tagId) 2813 - ); 2814 - 2815 - // Non-empty group should be shown 2816 - expect(groupCards.includes(String(nonEmptyTag.data.tag.id))).toBe(true); 2817 - 2818 - // Empty groups ARE shown (so newly created groups appear immediately) 2819 - expect(groupCards.includes(String(emptyTag.data.tag.id))).toBe(true); 2820 - 2821 - // Clean up 2822 - if (groupsResult.id) { 2823 - try { 2824 - await bgWindow.evaluate(async (id: number) => { 2825 - return await (window as any).app.window.close(id); 2826 - }, groupsResult.id); 2827 - } catch { 2828 - // Window may already be closed 2829 - } 2830 - } 2831 - }); 2832 - 2833 - test('Untagged group shows when there are untagged items', async () => { 2834 - // Create an untagged URL item 2835 - const testUrl = 'https://untagged-for-groups-view.example.com/'; 2836 - const item = await bgWindow.evaluate(async (url: string) => { 2837 - return await (window as any).app.datastore.addItem('url', { 2838 - content: url, 2839 - metadata: JSON.stringify({ title: 'Untagged For Groups View' }) 2840 - }); 2841 - }, testUrl); 2842 - expect(item.success).toBe(true); 2843 - 2844 - // Verify the item exists and has no tags 2845 - const itemTags = await bgWindow.evaluate(async (itemId: string) => { 2846 - return await (window as any).app.datastore.getItemTags(itemId); 2847 - }, item.data.id); 2848 - expect(itemTags.success).toBe(true); 2849 - expect(itemTags.data.length).toBe(0); 2850 - 2851 - // Open groups home 2852 - const groupsResult = await bgWindow.evaluate(async () => { 2853 - return await (window as any).app.window.open('peek://groups/home.html', { 2854 - width: 800, 2855 - height: 600, 2856 - key: 'groups-untagged-test' 2857 - }); 2858 - }); 2859 - expect(groupsResult.success).toBe(true); 2860 - 2861 - // Find the groups window (getWindow polls) 2862 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 2863 - expect(groupsWindow).toBeTruthy(); 2864 - await groupsWindow.waitForLoadState('domcontentloaded'); 2865 - 2866 - // Wait for cards to render 2867 - await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 2868 - 2869 - const untaggedCard = await groupsWindow.waitForSelector('peek-card.group-card[data-tag-id="__untagged__"]', { timeout: 5000 }).catch(() => null); 2870 - expect(untaggedCard).toBeTruthy(); 2871 - 2872 - // Verify it shows the special-group class 2873 - const hasSpecialClass = await untaggedCard!.evaluate((el: HTMLElement) => 2874 - el.classList.contains('special-group') 2875 - ); 2876 - expect(hasSpecialClass).toBe(true); 2877 - 2878 - // Clean up 2879 - if (groupsResult.id) { 2880 - try { 2881 - await bgWindow.evaluate(async (id: number) => { 2882 - return await (window as any).app.window.close(id); 2883 - }, groupsResult.id); 2884 - } catch { 2885 - // Window may already be closed 2886 - } 2887 - } 2888 - }); 2889 - }); 2890 - 2891 - // ============================================================================ 2892 - // Extension Lifecycle Tests 2893 - // ============================================================================ 2894 - 2895 - test.describe('Extension Lifecycle @desktop', () => { 2896 - let app: DesktopApp; 2897 - let bgWindow: Page; 2898 - 2899 - const EXAMPLE_EXT_PATH = path.join(ROOT, 'features', 'example'); 2900 - 2901 - test.beforeAll(async () => { 2902 - app = await launchDesktopApp('test-ext-lifecycle'); 2903 - bgWindow = await app.getBackgroundWindow(); 2904 - }); 2905 - 2906 - test.afterAll(async () => { 2907 - if (app) await app.close(); 2908 - }); 2909 - 2910 - test('validate extension folder', async () => { 2911 - const result = await bgWindow.evaluate(async (extPath: string) => { 2912 - return await (window as any).app.extensions.validateFolder(extPath); 2913 - }, EXAMPLE_EXT_PATH); 2914 - 2915 - expect(result.success).toBe(true); 2916 - expect(result.data).toBeTruthy(); 2917 - expect(result.data.manifest).toBeTruthy(); 2918 - expect(result.data.manifest.id || result.data.manifest.shortname || result.data.manifest.name).toBeTruthy(); 2919 - }); 2920 - 2921 - test('add extension', async () => { 2922 - // First validate to get manifest 2923 - const validateResult = await bgWindow.evaluate(async (extPath: string) => { 2924 - return await (window as any).app.extensions.validateFolder(extPath); 2925 - }, EXAMPLE_EXT_PATH); 2926 - 2927 - const manifest = validateResult.data.manifest; 2928 - 2929 - // Add the extension 2930 - const addResult = await bgWindow.evaluate(async ({ extPath, manifest }) => { 2931 - return await (window as any).app.extensions.add(extPath, manifest, false); 2932 - }, { extPath: EXAMPLE_EXT_PATH, manifest }); 2933 - 2934 - expect(addResult.success).toBe(true); 2935 - expect(addResult.data).toBeTruthy(); 2936 - expect(addResult.data.id).toBeTruthy(); 2937 - }); 2938 - 2939 - test('list extensions includes added extension', async () => { 2940 - const result = await bgWindow.evaluate(async () => { 2941 - return await (window as any).app.extensions.getAll(); 2942 - }); 2943 - 2944 - expect(result.success).toBe(true); 2945 - expect(Array.isArray(result.data)).toBe(true); 2946 - 2947 - // Find the example extension 2948 - const exampleExt = result.data.find((ext: any) => 2949 - ext.id === 'example' || ext.path?.includes('example') 2950 - ); 2951 - expect(exampleExt).toBeTruthy(); 2952 - }); 2953 - 2954 - test('update extension (enable/disable)', async () => { 2955 - // Enable the extension 2956 - const enableResult = await bgWindow.evaluate(async () => { 2957 - return await (window as any).app.extensions.update('example', { enabled: true }); 2958 - }); 2959 - expect(enableResult.success).toBe(true); 2960 - 2961 - // Verify it's enabled (accept both boolean true and integer 1) 2962 - const getResult1 = await bgWindow.evaluate(async () => { 2963 - return await (window as any).app.extensions.get('example'); 2964 - }); 2965 - expect(getResult1.success).toBe(true); 2966 - expect(getResult1.data.enabled === true || getResult1.data.enabled === 1).toBe(true); 2967 - 2968 - // Disable it 2969 - const disableResult = await bgWindow.evaluate(async () => { 2970 - return await (window as any).app.extensions.update('example', { enabled: false }); 2971 - }); 2972 - expect(disableResult.success).toBe(true); 2973 - 2974 - // Verify it's disabled 2975 - const getResult2 = await bgWindow.evaluate(async () => { 2976 - return await (window as any).app.extensions.get('example'); 2977 - }); 2978 - expect(getResult2.success).toBe(true); 2979 - expect(getResult2.data.enabled === false || getResult2.data.enabled === 0).toBe(true); 2980 - }); 2981 - 2982 - test('remove extension', async () => { 2983 - const removeResult = await bgWindow.evaluate(async () => { 2984 - return await (window as any).app.extensions.remove('example'); 2985 - }); 2986 - expect(removeResult.success).toBe(true); 2987 - 2988 - // Verify it's removed 2989 - const getResult = await bgWindow.evaluate(async () => { 2990 - return await (window as any).app.extensions.get('example'); 2991 - }); 2992 - expect(getResult.success).toBe(false); 2993 - 2994 - // Verify it's not in list 2995 - const listResult = await bgWindow.evaluate(async () => { 2996 - return await (window as any).app.extensions.getAll(); 2997 - }); 2998 - expect(listResult.success).toBe(true); 2999 - const exampleExt = listResult.data.find((ext: any) => ext.id === 'example'); 3000 - expect(exampleExt).toBeFalsy(); 3001 - }); 3002 - }); 3003 - 3004 - // ============================================================================ 3005 - // Command Chaining Tests (uses shared app) 3006 - // ============================================================================ 3007 - 3008 - test.describe('Command Chaining @desktop', () => { 3009 - let app: DesktopApp; 3010 - let bgWindow: Page; 3011 - 3012 - test.beforeAll(async () => { 3013 - ({ app, bgWindow } = await createPerDescribeApp('cmd-chain')); 3014 - }); 3015 - 3016 - test.afterAll(async () => { 3017 - if (app) await app.close(); 3018 - }); 3019 - 3020 - test('cmd panel loads with chain state initialized', async () => { 3021 - // Open cmd panel to verify it loads correctly with chain support 3022 - const openResult = await bgWindow.evaluate(async () => { 3023 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3024 - modal: true, 3025 - width: 600, 3026 - height: 300, 3027 - frame: false, 3028 - transparent: true, 3029 - alwaysOnTop: true, 3030 - center: true 3031 - }); 3032 - }); 3033 - expect(openResult.success).toBe(true); 3034 - 3035 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3036 - expect(cmdWindow).toBeTruthy(); 3037 - 3038 - // Verify state object has chain properties 3039 - const hasChainState = await cmdWindow.evaluate(() => { 3040 - // Access state through the module scope would require exposing it 3041 - // Instead verify the UI elements that depend on chain state exist 3042 - const chainIndicator = document.getElementById('chain-indicator'); 3043 - const previewContainer = document.getElementById('preview-container'); 3044 - return chainIndicator !== null && previewContainer !== null; 3045 - }); 3046 - expect(hasChainState).toBe(true); 3047 - 3048 - // Close the window 3049 - if (openResult.id) { 3050 - await bgWindow.evaluate(async (id: number) => { 3051 - return await (window as any).app.window.close(id); 3052 - }, openResult.id); 3053 - } 3054 - }); 3055 - 3056 - test('MIME type matching works correctly', async () => { 3057 - // Test MIME matching logic in panel context 3058 - const openResult = await bgWindow.evaluate(async () => { 3059 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3060 - modal: true, 3061 - width: 600, 3062 - height: 50, 3063 - frame: false, 3064 - transparent: true, 3065 - alwaysOnTop: true, 3066 - center: true 3067 - }); 3068 - }); 3069 - expect(openResult.success).toBe(true); 3070 - 3071 - // Find the cmd window 3072 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3073 - expect(cmdWindow).toBeTruthy(); 3074 - 3075 - // Test MIME type matching function (if exposed, or test via behavior) 3076 - // The panel.js has mimeTypeMatches function - we test the expected behavior 3077 - 3078 - // Test exact match: 'application/json' matches 'application/json' 3079 - const exactMatch = await cmdWindow.evaluate(() => { 3080 - // We can't directly call the function, but we can verify commands filter correctly 3081 - // This is more of an integration test 3082 - return true; 3083 - }); 3084 - expect(exactMatch).toBe(true); 3085 - 3086 - // Close the window 3087 - if (openResult.id) { 3088 - await bgWindow.evaluate(async (id: number) => { 3089 - return await (window as any).app.window.close(id); 3090 - }, openResult.id); 3091 - } 3092 - }); 3093 - 3094 - test('cmd panel input works correctly', async () => { 3095 - // Open cmd panel 3096 - const openResult = await bgWindow.evaluate(async () => { 3097 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3098 - modal: true, 3099 - width: 600, 3100 - height: 400, 3101 - frame: false, 3102 - transparent: true, 3103 - alwaysOnTop: true, 3104 - center: true 3105 - }); 3106 - }); 3107 - expect(openResult.success).toBe(true); 3108 - 3109 - // Find the cmd window 3110 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3111 - expect(cmdWindow).toBeTruthy(); 3112 - 3113 - // Wait for input to be ready 3114 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3115 - 3116 - // Verify input is focusable and can receive text 3117 - await cmdWindow.fill('input', 'test'); 3118 - const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value); 3119 - expect(inputValue).toBe('test'); 3120 - 3121 - // Close the window 3122 - if (openResult.id) { 3123 - await bgWindow.evaluate(async (id: number) => { 3124 - return await (window as any).app.window.close(id); 3125 - }, openResult.id); 3126 - } 3127 - }); 3128 - 3129 - test('panel has chain indicator, preview, and execution state elements', async () => { 3130 - // Open cmd panel 3131 - const openResult = await bgWindow.evaluate(async () => { 3132 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3133 - modal: true, 3134 - width: 600, 3135 - height: 300, 3136 - frame: false, 3137 - transparent: true, 3138 - alwaysOnTop: true, 3139 - center: true 3140 - }); 3141 - }); 3142 - expect(openResult.success).toBe(true); 3143 - 3144 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3145 - expect(cmdWindow).toBeTruthy(); 3146 - 3147 - // Check chain indicator element exists 3148 - const chainIndicator = await cmdWindow.$('#chain-indicator'); 3149 - expect(chainIndicator).toBeTruthy(); 3150 - 3151 - // Check preview container exists 3152 - const previewContainer = await cmdWindow.$('#preview-container'); 3153 - expect(previewContainer).toBeTruthy(); 3154 - 3155 - // Check execution state element exists 3156 - const executionState = await cmdWindow.$('#execution-state'); 3157 - expect(executionState).toBeTruthy(); 3158 - 3159 - // Verify chain indicator is initially hidden (no 'visible' class) 3160 - const chainVisible = await cmdWindow.$eval('#chain-indicator', (el: HTMLElement) => el.classList.contains('visible')); 3161 - expect(chainVisible).toBe(false); 3162 - 3163 - // Verify preview is initially hidden (no 'visible' class) 3164 - const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible')); 3165 - expect(previewVisible).toBe(false); 3166 - 3167 - // Verify execution state is initially hidden (no 'visible' class) 3168 - const execVisible = await cmdWindow.$eval('#execution-state', (el: HTMLElement) => el.classList.contains('visible')); 3169 - expect(execVisible).toBe(false); 3170 - 3171 - // Verify results is initially hidden (no 'visible' class) 3172 - const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible')); 3173 - expect(resultsVisible).toBe(false); 3174 - 3175 - // Close the window 3176 - if (openResult.id) { 3177 - await bgWindow.evaluate(async (id: number) => { 3178 - return await (window as any).app.window.close(id); 3179 - }, openResult.id); 3180 - } 3181 - }); 3182 - 3183 - test('list urls command produces array output and enters output selection mode', async () => { 3184 - // Open cmd panel 3185 - const openResult = await bgWindow.evaluate(async () => { 3186 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3187 - modal: true, 3188 - width: 600, 3189 - height: 400, 3190 - frame: false, 3191 - transparent: true, 3192 - alwaysOnTop: true, 3193 - center: true 3194 - }); 3195 - }); 3196 - expect(openResult.success).toBe(true); 3197 - 3198 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3199 - expect(cmdWindow).toBeTruthy(); 3200 - 3201 - // Wait for input to be ready and commands to be loaded 3202 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3203 - await waitForPanelCommandsLoaded(cmdWindow); 3204 - 3205 - // Type 'list urls' command 3206 - await cmdWindow.fill('input', 'list urls'); 3207 - 3208 - // Press down arrow to show results 3209 - await cmdWindow.press('input', 'ArrowDown'); 3210 - await waitForClass(cmdWindow, '#results', 'visible'); 3211 - 3212 - // Verify results are visible 3213 - const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible')); 3214 - expect(resultsVisible).toBe(true); 3215 - 3216 - // Press Enter to execute 3217 - await cmdWindow.press('input', 'Enter'); 3218 - await waitForResultsWithContent(cmdWindow); 3219 - 3220 - // After list urls executes, we should be in output selection mode 3221 - // Results should show the items from the list urls output 3222 - const hasResults = await cmdWindow.$eval('#results', (el: HTMLElement) => { 3223 - return el.classList.contains('visible') && el.children.length > 0; 3224 - }); 3225 - expect(hasResults).toBe(true); 3226 - 3227 - // Preview should show the selected item 3228 - const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible')); 3229 - expect(previewVisible).toBe(true); 3230 - 3231 - // Close the window 3232 - if (openResult.id) { 3233 - await bgWindow.evaluate(async (id: number) => { 3234 - return await (window as any).app.window.close(id); 3235 - }, openResult.id); 3236 - } 3237 - }); 3238 - 3239 - test('selecting output item enters chain mode with filtered commands', async () => { 3240 - // Architectural contract (see docs/cmd-chain-architecture.md): 3241 - // `list urls` → OUTPUT_SELECTION → Enter on a row → CHAIN_MODE. 3242 - // CHAIN_MODE is NEVER reached direct-from-EXECUTING; the user always 3243 - // sees their rows first so they can pick which one to chain against. 3244 - await waitForCommand(bgWindow, 'csv', 10000); 3245 - 3246 - // Seed a couple of urls so list urls returns a selectable list 3247 - await bgWindow.evaluate(async () => { 3248 - const api = (window as any).app; 3249 - await api.datastore.addItem('url', { url: 'https://example.com/chain-a', title: 'chain-a' }); 3250 - await api.datastore.addItem('url', { url: 'https://example.com/chain-b', title: 'chain-b' }); 3251 - }); 3252 - 3253 - const openResult = await bgWindow.evaluate(async () => { 3254 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3255 - modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 3256 - }); 3257 - }); 3258 - expect(openResult.success).toBe(true); 3259 - 3260 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3261 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3262 - await waitForPanelCommandsLoaded(cmdWindow); 3263 - 3264 - await cmdWindow.fill('input', 'list urls'); 3265 - await cmdWindow.press('input', 'Enter'); 3266 - 3267 - // Step 1: OUTPUT_SELECTION entered first (not CHAIN_MODE) 3268 - await cmdWindow.waitForFunction( 3269 - () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 3270 - null, { timeout: 5000 } 3271 - ); 3272 - 3273 - // Step 2: Enter on the selected row → CHAIN_MODE 3274 - await cmdWindow.press('input', 'Enter'); 3275 - await cmdWindow.waitForFunction( 3276 - () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 3277 - null, { timeout: 5000 } 3278 - ); 3279 - 3280 - // Step 3: CHAIN_MODE suggestions include csv (accepts application/json) 3281 - const suggestions = await cmdWindow.evaluate(() => (window as any)._cmdState?.matches || []); 3282 - expect(suggestions).toContain('csv'); 3283 - 3284 - if (openResult.id) { 3285 - await bgWindow.evaluate(async (id: number) => { 3286 - return await (window as any).app.window.close(id); 3287 - }, openResult.id); 3288 - } 3289 - }); 3290 - 3291 - test('csv command converts JSON to CSV format', async () => { 3292 - // list urls → OUTPUT_SELECTION → select row → CHAIN_MODE → csv → text/csv 3293 - await waitForCommand(bgWindow, 'csv', 10000); 3294 - 3295 - await bgWindow.evaluate(async () => { 3296 - const api = (window as any).app; 3297 - await api.datastore.addItem('url', { url: 'https://example.com/csv-a', title: 'csv-a' }); 3298 - await api.datastore.addItem('url', { url: 'https://example.com/csv-b', title: 'csv-b' }); 3299 - }); 3300 - 3301 - const openResult = await bgWindow.evaluate(async () => { 3302 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3303 - modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 3304 - }); 3305 - }); 3306 - expect(openResult.success).toBe(true); 3307 - 3308 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3309 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3310 - await waitForPanelCommandsLoaded(cmdWindow); 3311 - 3312 - await cmdWindow.fill('input', 'list urls'); 3313 - await cmdWindow.press('input', 'Enter'); 3314 - 3315 - // OUTPUT_SELECTION first 3316 - await cmdWindow.waitForFunction( 3317 - () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 3318 - null, { timeout: 5000 } 3319 - ); 3320 - 3321 - // Enter a row → CHAIN_MODE 3322 - await cmdWindow.press('input', 'Enter'); 3323 - await cmdWindow.waitForFunction( 3324 - () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 3325 - null, { timeout: 5000 } 3326 - ); 3327 - 3328 - // Type csv to filter matches, then ArrowDown+Enter to execute 3329 - await cmdWindow.fill('input', 'csv'); 3330 - await cmdWindow.waitForFunction( 3331 - () => ((window as any)._cmdState?.matches || []).includes('csv'), 3332 - null, { timeout: 5000 } 3333 - ); 3334 - await cmdWindow.press('input', 'ArrowDown'); 3335 - await cmdWindow.press('input', 'Enter'); 3336 - 3337 - // csv produces text/csv — wait for state to leave EXECUTING (csv is lazy 3338 - // so first invoke loads the tile; proxy has 30s timeout, bucket covers). 3339 - // If the panel closes mid-poll, we accept — that IS a terminal state. 3340 - const postExecState = await waitForCmdNotExecuting(cmdWindow, 35000); 3341 - expect(postExecState).not.toBe('TIMEOUT'); 3342 - 3343 - // After csv execution, either chainContext has text/csv (chain continues) 3344 - // or the panel transitioned to CLOSING (terminal csv output). Both are valid 3345 - // terminal states — we only fail if csv's result never arrived at all. 3346 - // If the panel already closed, we can't read chainContext — that's fine, 3347 - // closed itself is a valid terminal. 3348 - let final: { state: string | undefined; mimeType: string | undefined }; 3349 - if (postExecState === 'CLOSED') { 3350 - final = { state: 'CLOSING', mimeType: undefined }; 3351 - } else { 3352 - try { 3353 - final = await cmdWindow.evaluate(() => { 3354 - const s = (window as any)._cmdState; 3355 - return { state: s?.currentState, mimeType: s?.chainContext?.mimeType }; 3356 - }); 3357 - } catch { 3358 - // Page closed between waitForCmdNotExecuting and this evaluate — fine. 3359 - final = { state: 'CLOSING', mimeType: undefined }; 3360 - } 3361 - } 3362 - 3363 - if (final.state === 'CHAIN_MODE') { 3364 - expect(final.mimeType).toBe('text/csv'); 3365 - } else { 3366 - // csv is lazy-loaded; first invoke may exceed the proxy's 30s timeout 3367 - // in slow CI. ERROR is also acceptable — this test verifies the chain 3368 - // plumbing reaches csv, not lazy-tile performance. 3369 - expect(['CLOSING', 'IDLE', 'OUTPUT_SELECTION', 'ERROR']).toContain(final.state); 3370 - } 3371 - 3372 - if (openResult.id) { 3373 - try { 3374 - await bgWindow.evaluate(async (id: number) => { 3375 - return await (window as any).app.window.close(id); 3376 - }, openResult.id); 3377 - } catch { /* may already be closed */ } 3378 - } 3379 - }); 3380 - 3381 - test('escape exits chain mode before closing panel', async () => { 3382 - // Canonical ESC layering: ESC in CHAIN_MODE exits chain (back to 3383 - // OUTPUT_SELECTION or IDLE depending on stack), does NOT close the panel. 3384 - await waitForCommand(bgWindow, 'csv', 10000); 3385 - 3386 - await bgWindow.evaluate(async () => { 3387 - const api = (window as any).app; 3388 - await api.datastore.addItem('url', { url: 'https://example.com/esc-a', title: 'esc-a' }); 3389 - await api.datastore.addItem('url', { url: 'https://example.com/esc-b', title: 'esc-b' }); 3390 - }); 3391 - 3392 - const openResult = await bgWindow.evaluate(async () => { 3393 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3394 - modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 3395 - }); 3396 - }); 3397 - expect(openResult.success).toBe(true); 3398 - 3399 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3400 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3401 - await waitForPanelCommandsLoaded(cmdWindow); 3402 - 3403 - await cmdWindow.fill('input', 'list urls'); 3404 - await cmdWindow.press('input', 'Enter'); 3405 - await cmdWindow.waitForFunction( 3406 - () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 3407 - null, { timeout: 5000 } 3408 - ); 3409 - await cmdWindow.press('input', 'Enter'); 3410 - await cmdWindow.waitForFunction( 3411 - () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 3412 - null, { timeout: 5000 } 3413 - ); 3414 - 3415 - // ESC in CHAIN_MODE exits the chain — state changes away from CHAIN_MODE 3416 - // (target is OUTPUT_SELECTION, TYPING, or IDLE per implementation), but 3417 - // NOT CLOSING — the panel remains open. 3418 - await cmdWindow.press('input', 'Escape'); 3419 - await cmdWindow.waitForFunction( 3420 - () => { 3421 - const s = (window as any)._cmdState?.currentState; 3422 - return s !== 'CHAIN_MODE' && s !== 'CLOSING'; 3423 - }, 3424 - null, { timeout: 5000 } 3425 - ); 3426 - 3427 - const inputExists = await cmdWindow.$('input'); 3428 - expect(inputExists).toBeTruthy(); 3429 - 3430 - if (openResult.id) { 3431 - await bgWindow.evaluate(async (id: number) => { 3432 - return await (window as any).app.window.close(id); 3433 - }, openResult.id); 3434 - } 3435 - }); 3436 - 3437 - test('arrow navigation in output selection mode', async () => { 3438 - // Seed multiple urls so navigation has more than one row 3439 - await bgWindow.evaluate(async () => { 3440 - const api = (window as any).app; 3441 - await api.datastore.addItem('url', { url: 'https://example.com/nav-a', title: 'nav-a' }); 3442 - await api.datastore.addItem('url', { url: 'https://example.com/nav-b', title: 'nav-b' }); 3443 - }); 3444 - 3445 - const openResult = await bgWindow.evaluate(async () => { 3446 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3447 - modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 3448 - }); 3449 - }); 3450 - expect(openResult.success).toBe(true); 3451 - 3452 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3453 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3454 - await waitForPanelCommandsLoaded(cmdWindow); 3455 - 3456 - await cmdWindow.fill('input', 'list urls'); 3457 - await cmdWindow.press('input', 'Enter'); 3458 - 3459 - // OUTPUT_SELECTION with at least 2 rows 3460 - await cmdWindow.waitForFunction( 3461 - () => { 3462 - const s = (window as any)._cmdState; 3463 - return s?.currentState === 'OUTPUT_SELECTION' && (s?.outputItems?.length ?? 0) > 1; 3464 - }, 3465 - null, { timeout: 5000 } 3466 - ); 3467 - 3468 - // First item is selected (outputItemIndex starts at 0) 3469 - const initialIndex = await cmdWindow.evaluate(() => (window as any)._cmdState?.outputItemIndex); 3470 - expect(initialIndex).toBe(0); 3471 - 3472 - // Arrow down → index 1 3473 - await cmdWindow.press('input', 'ArrowDown'); 3474 - await cmdWindow.waitForFunction( 3475 - () => (window as any)._cmdState?.outputItemIndex === 1, 3476 - null, { timeout: 2000 } 3477 - ); 3478 - 3479 - // Arrow up → back to 0 3480 - await cmdWindow.press('input', 'ArrowUp'); 3481 - await cmdWindow.waitForFunction( 3482 - () => (window as any)._cmdState?.outputItemIndex === 0, 3483 - null, { timeout: 2000 } 3484 - ); 3485 - 3486 - if (openResult.id) { 3487 - await bgWindow.evaluate(async (id: number) => { 3488 - return await (window as any).app.window.close(id); 3489 - }, openResult.id); 3490 - } 3491 - }); 3492 - }); 3493 - 3494 - 3495 - // ============================================================================ 3496 - // Edit Command Param Mode Tests (uses shared app) 3497 - // ============================================================================ 3498 - 3499 - test.describe('Edit Command Param Mode @desktop', () => { 3500 - let app: DesktopApp; 3501 - let bgWindow: Page; 3502 - 3503 - test.beforeAll(async () => { 3504 - ({ app, bgWindow } = await createPerDescribeApp('edit-param')); 3505 - }); 3506 - 3507 - test.afterAll(async () => { 3508 - if (app) await app.close(); 3509 - }); 3510 - 3511 - test('Tab in param mode fills text, does NOT execute', async () => { 3512 - // Create a test note so param mode has suggestions 3513 - const createResult = await bgWindow.evaluate(async () => { 3514 - return await (window as any).app.datastore.addItem('text', { 3515 - content: '# Tab Test Note\nThis is a note for testing Tab in param mode.' 3516 - }); 3517 - }); 3518 - expect(createResult.success).toBe(true); 3519 - const noteId = createResult.data.id; 3520 - 3521 - // Open cmd panel 3522 - const openResult = await bgWindow.evaluate(async () => { 3523 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3524 - modal: true, 3525 - width: 600, 3526 - height: 400, 3527 - frame: false, 3528 - transparent: true, 3529 - alwaysOnTop: true, 3530 - center: true 3531 - }); 3532 - }); 3533 - expect(openResult.success).toBe(true); 3534 - 3535 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3536 - expect(cmdWindow).toBeTruthy(); 3537 - 3538 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3539 - await waitForPanelCommandsLoaded(cmdWindow); 3540 - 3541 - // Type 'edit ' (with space) to commit to the edit command and enter param mode 3542 - // (Tab would cycle to 'editor' since both match 'edit') 3543 - await cmdWindow.fill('input', 'edit '); 3544 - 3545 - // Wait for param mode to activate 3546 - await cmdWindow.waitForFunction(() => { 3547 - const s = (window as any)._cmdState; 3548 - return s.paramMode === true && s.paramCommand === 'edit'; 3549 - }, { timeout: 5000 }); 3550 - 3551 - // Wait for param suggestions to load (items query) 3552 - await cmdWindow.waitForFunction(() => { 3553 - return (window as any)._cmdState.paramSuggestions.length > 0; 3554 - }, { timeout: 10000 }); 3555 - 3556 - // Press Tab on a suggestion - should fill text, NOT execute 3557 - await cmdWindow.keyboard.press('Tab'); 3558 - 3559 - // Verify param mode is still active (Tab fills, doesn't execute) 3560 - const stillParamMode = await cmdWindow.evaluate(() => { 3561 - return (window as any)._cmdState.paramMode === true; 3562 - }); 3563 - expect(stillParamMode).toBe(true); 3564 - 3565 - // Verify input text was updated with the suggestion value 3566 - const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value); 3567 - expect(inputValue.startsWith('edit ')).toBe(true); 3568 - expect(inputValue.length).toBeGreaterThan('edit '.length); 3569 - 3570 - // Close the cmd window 3571 - if (openResult.id) { 3572 - await bgWindow.evaluate(async (id: number) => { 3573 - return await (window as any).app.window.close(id); 3574 - }, openResult.id); 3575 - } 3576 - 3577 - // Clean up test note 3578 - await bgWindow.evaluate(async (id: string) => { 3579 - return await (window as any).app.datastore.deleteItem(id); 3580 - }, noteId); 3581 - }); 3582 - 3583 - test('Enter in param mode executes with correct itemId', async () => { 3584 - // Create a test note 3585 - const createResult = await bgWindow.evaluate(async () => { 3586 - return await (window as any).app.datastore.addItem('text', { 3587 - content: '# Enter Test Note\nThis is a note for testing Enter in param mode.' 3588 - }); 3589 - }); 3590 - expect(createResult.success).toBe(true); 3591 - const noteId = createResult.data.id; 3592 - 3593 - // Set up a listener for editor:open events BEFORE opening cmd panel 3594 - await bgWindow.evaluate(async () => { 3595 - (window as any).__editorOpenEvents = []; 3596 - (window as any).app.subscribe('editor:open', (data: any) => { 3597 - (window as any).__editorOpenEvents.push(data); 3598 - }, { scope: 'global' }); 3599 - }); 3600 - 3601 - // Open cmd panel 3602 - const openResult = await bgWindow.evaluate(async () => { 3603 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3604 - modal: true, 3605 - width: 600, 3606 - height: 400, 3607 - frame: false, 3608 - transparent: true, 3609 - alwaysOnTop: true, 3610 - center: true 3611 - }); 3612 - }); 3613 - expect(openResult.success).toBe(true); 3614 - 3615 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3616 - expect(cmdWindow).toBeTruthy(); 3617 - 3618 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3619 - await waitForPanelCommandsLoaded(cmdWindow); 3620 - 3621 - // Type 'edit ' (with space) to commit to the edit command and enter param mode 3622 - await cmdWindow.fill('input', 'edit '); 3623 - 3624 - // Wait for param mode and suggestions to load 3625 - await cmdWindow.waitForFunction(() => { 3626 - const s = (window as any)._cmdState; 3627 - return s.paramMode === true && s.paramCommand === 'edit' && s.paramSuggestions.length > 0; 3628 - }, { timeout: 10000 }); 3629 - 3630 - // Find the index of our test note in suggestions 3631 - const testNoteIndex = await cmdWindow.evaluate((targetId: string) => { 3632 - const suggestions = (window as any)._cmdState.paramSuggestions; 3633 - return suggestions.findIndex((s: any) => s._item && s._item.id === targetId); 3634 - }, noteId); 3635 - 3636 - // Navigate to the test note if needed 3637 - if (testNoteIndex > 0) { 3638 - for (let i = 0; i < testNoteIndex; i++) { 3639 - await cmdWindow.keyboard.press('ArrowDown'); 3640 - } 3641 - } 3642 - 3643 - // Press Enter to execute with the selected item 3644 - await cmdWindow.keyboard.press('Enter'); 3645 - 3646 - // Wait for editor:open event to be published 3647 - await bgWindow.waitForFunction(() => { 3648 - return (window as any).__editorOpenEvents && (window as any).__editorOpenEvents.length > 0; 3649 - }, { timeout: 10000 }); 3650 - 3651 - // Verify editor:open was published with the correct itemId 3652 - const editorEvents = await bgWindow.evaluate(() => { 3653 - return (window as any).__editorOpenEvents; 3654 - }); 3655 - expect(editorEvents.length).toBeGreaterThan(0); 3656 - const lastEvent = editorEvents[editorEvents.length - 1]; 3657 - expect(lastEvent.itemId).toBe(noteId); 3658 - 3659 - // Close cmd window if still open 3660 - if (openResult.id) { 3661 - await bgWindow.evaluate(async (id: number) => { 3662 - try { return await (window as any).app.window.close(id); } catch(e) { /* may already be closed */ } 3663 - }, openResult.id); 3664 - } 3665 - 3666 - // Clean up 3667 - await bgWindow.evaluate(async (id: string) => { 3668 - delete (window as any).__editorOpenEvents; 3669 - return await (window as any).app.datastore.deleteItem(id); 3670 - }, noteId); 3671 - }); 3672 - 3673 - test('Tab in command mode completes name, does not execute', async () => { 3674 - // Open cmd panel 3675 - const openResult = await bgWindow.evaluate(async () => { 3676 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3677 - modal: true, 3678 - width: 600, 3679 - height: 400, 3680 - frame: false, 3681 - transparent: true, 3682 - alwaysOnTop: true, 3683 - center: true 3684 - }); 3685 - }); 3686 - expect(openResult.success).toBe(true); 3687 - 3688 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3689 - expect(cmdWindow).toBeTruthy(); 3690 - 3691 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3692 - await waitForPanelCommandsLoaded(cmdWindow); 3693 - 3694 - // Type partial command name 'edi' 3695 - await cmdWindow.fill('input', 'edi'); 3696 - await cmdWindow.keyboard.press('ArrowDown'); 3697 - await waitForCommandResults(cmdWindow, 1, 10000); 3698 - 3699 - // Press Tab - should complete command name, not execute 3700 - await cmdWindow.keyboard.press('Tab'); 3701 - 3702 - // Verify input is now 'edit' (completed from 'edi') 3703 - const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value); 3704 - expect(inputValue.toLowerCase().startsWith('edit')).toBe(true); 3705 - 3706 - // Verify the panel is still open and responsive (no command was executed) 3707 - const panelStillOpen = await cmdWindow.evaluate(() => { 3708 - return document.getElementById('command-input') !== null; 3709 - }); 3710 - expect(panelStillOpen).toBe(true); 3711 - 3712 - // Close the cmd window 3713 - if (openResult.id) { 3714 - await bgWindow.evaluate(async (id: number) => { 3715 - return await (window as any).app.window.close(id); 3716 - }, openResult.id); 3717 - } 3718 - }); 3719 - }); 3720 - 3721 - // ============================================================================ 3722 - // Theme Tests (uses shared app) 3723 - // ============================================================================ 3724 - 3725 - test.describe('Themes @desktop', () => { 3726 - let app: DesktopApp; 3727 - let bgWindow: Page; 3728 - 3729 - test.beforeAll(async () => { 3730 - ({ app, bgWindow } = await createPerDescribeApp('themes')); 3731 - }); 3732 - 3733 - test.afterAll(async () => { 3734 - if (app) await app.close(); 3735 - }); 3736 - 3737 - test('theme API is available', async () => { 3738 - const hasThemeApi = await bgWindow.evaluate(() => { 3739 - const api = (window as any).app; 3740 - return !!(api.theme && api.theme.get && api.theme.setTheme && api.theme.getAll); 3741 - }); 3742 - expect(hasThemeApi).toBe(true); 3743 - }); 3744 - 3745 - test('get current theme state', async () => { 3746 - const themeState = await bgWindow.evaluate(async () => { 3747 - return await (window as any).app.theme.get(); 3748 - }); 3749 - 3750 - expect(themeState).toBeTruthy(); 3751 - expect(themeState.themeId).toBeTruthy(); 3752 - expect(themeState.colorScheme).toBeTruthy(); 3753 - expect(['system', 'light', 'dark']).toContain(themeState.colorScheme); 3754 - expect(typeof themeState.isDark).toBe('boolean'); 3755 - expect(['light', 'dark']).toContain(themeState.effectiveScheme); 3756 - }); 3757 - 3758 - test('list available themes', async () => { 3759 - const result = await bgWindow.evaluate(async () => { 3760 - return await (window as any).app.theme.getAll(); 3761 - }); 3762 - 3763 - expect(result.success).toBe(true); 3764 - expect(Array.isArray(result.data)).toBe(true); 3765 - expect(result.data.length).toBeGreaterThanOrEqual(2); // basic and peek 3766 - 3767 - // Verify built-in themes exist 3768 - const themeIds = result.data.map((t: any) => t.id); 3769 - expect(themeIds).toContain('basic'); 3770 - expect(themeIds).toContain('peek'); 3771 - 3772 - // Verify theme structure 3773 - for (const theme of result.data) { 3774 - expect(theme.id).toBeTruthy(); 3775 - expect(theme.name).toBeTruthy(); 3776 - expect(theme.version).toBeTruthy(); 3777 - } 3778 - }); 3779 - 3780 - test('switch themes', async () => { 3781 - // Get initial theme 3782 - const initialState = await bgWindow.evaluate(async () => { 3783 - return await (window as any).app.theme.get(); 3784 - }); 3785 - 3786 - // Switch to a different theme 3787 - const targetTheme = initialState.themeId === 'basic' ? 'peek' : 'basic'; 3788 - const switchResult = await bgWindow.evaluate(async (themeId: string) => { 3789 - return await (window as any).app.theme.setTheme(themeId); 3790 - }, targetTheme); 3791 - 3792 - expect(switchResult.success).toBe(true); 3793 - expect(switchResult.themeId).toBe(targetTheme); 3794 - 3795 - // Verify theme changed 3796 - const newState = await bgWindow.evaluate(async () => { 3797 - return await (window as any).app.theme.get(); 3798 - }); 3799 - expect(newState.themeId).toBe(targetTheme); 3800 - 3801 - // Switch back to original 3802 - await bgWindow.evaluate(async (themeId: string) => { 3803 - return await (window as any).app.theme.setTheme(themeId); 3804 - }, initialState.themeId); 3805 - }); 3806 - 3807 - test('switch color scheme', async () => { 3808 - // Get initial state 3809 - const initialState = await bgWindow.evaluate(async () => { 3810 - return await (window as any).app.theme.get(); 3811 - }); 3812 - 3813 - // Switch to light mode 3814 - const lightResult = await bgWindow.evaluate(async () => { 3815 - return await (window as any).app.theme.setColorScheme('light'); 3816 - }); 3817 - expect(lightResult.success).toBe(true); 3818 - expect(lightResult.colorScheme).toBe('light'); 3819 - 3820 - // Verify it changed 3821 - let state = await bgWindow.evaluate(async () => { 3822 - return await (window as any).app.theme.get(); 3823 - }); 3824 - expect(state.colorScheme).toBe('light'); 3825 - expect(state.effectiveScheme).toBe('light'); 3826 - 3827 - // Switch to dark mode 3828 - const darkResult = await bgWindow.evaluate(async () => { 3829 - return await (window as any).app.theme.setColorScheme('dark'); 3830 - }); 3831 - expect(darkResult.success).toBe(true); 3832 - expect(darkResult.colorScheme).toBe('dark'); 3833 - 3834 - state = await bgWindow.evaluate(async () => { 3835 - return await (window as any).app.theme.get(); 3836 - }); 3837 - expect(state.colorScheme).toBe('dark'); 3838 - expect(state.effectiveScheme).toBe('dark'); 3839 - 3840 - // Switch back to system 3841 - const systemResult = await bgWindow.evaluate(async () => { 3842 - return await (window as any).app.theme.setColorScheme('system'); 3843 - }); 3844 - expect(systemResult.success).toBe(true); 3845 - expect(systemResult.colorScheme).toBe('system'); 3846 - 3847 - // Restore original color scheme 3848 - await bgWindow.evaluate(async (scheme: string) => { 3849 - return await (window as any).app.theme.setColorScheme(scheme); 3850 - }, initialState.colorScheme); 3851 - }); 3852 - 3853 - test('invalid theme returns error', async () => { 3854 - const result = await bgWindow.evaluate(async () => { 3855 - return await (window as any).app.theme.setTheme('nonexistent-theme'); 3856 - }); 3857 - 3858 - expect(result.success).toBe(false); 3859 - expect(result.error).toBeTruthy(); 3860 - }); 3861 - 3862 - test('invalid color scheme returns error', async () => { 3863 - const result = await bgWindow.evaluate(async () => { 3864 - return await (window as any).app.theme.setColorScheme('invalid'); 3865 - }); 3866 - 3867 - expect(result.success).toBe(false); 3868 - expect(result.error).toBeTruthy(); 3869 - }); 3870 - }); 3871 - 3872 - // ============================================================================ 3873 - // Command Registration Performance Tests (uses shared app) 3874 - // ============================================================================ 3875 - 3876 - test.describe('Command Registration Performance @desktop', () => { 3877 - let app: DesktopApp; 3878 - let bgWindow: Page; 3879 - 3880 - test.beforeAll(async () => { 3881 - ({ app, bgWindow } = await createPerDescribeApp('cmd-perf')); 3882 - // Wait for cmd extension to be fully ready before running performance tests 3883 - await waitForExtensionsReady(bgWindow, 15000); 3884 - }); 3885 - 3886 - test.afterAll(async () => { 3887 - if (app) await app.close(); 3888 - }); 3889 - 3890 - test('cmd:register-batch is handled by cmd extension', async () => { 3891 - // Test that batch registration works by sending a batch and verifying commands appear 3892 - const result = await bgWindow.evaluate(async () => { 3893 - const api = (window as any).app; 3894 - 3895 - // Send a batch of test commands 3896 - api.publish('cmd:register-batch', { 3897 - commands: [ 3898 - { name: 'test-batch-cmd-1', description: 'Test batch command 1', source: 'test' }, 3899 - { name: 'test-batch-cmd-2', description: 'Test batch command 2', source: 'test' }, 3900 - { name: 'test-batch-cmd-3', description: 'Test batch command 3', source: 'test' } 3901 - ] 3902 - }, api.scopes.GLOBAL); 3903 - 3904 - // Poll for commands to appear (deterministic retry instead of fixed timeout) 3905 - const maxAttempts = 20; 3906 - const pollInterval = 100; 3907 - 3908 - for (let attempt = 0; attempt < maxAttempts; attempt++) { 3909 - await new Promise(r => setTimeout(r, pollInterval)); 3910 - 3911 - const commands = await new Promise<any[]>((resolve) => { 3912 - const unsub = api.subscribe('cmd:query-commands-response', (msg: any) => { 3913 - unsub?.(); 3914 - resolve(msg.commands || []); 3915 - }, api.scopes.GLOBAL); 3916 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 3917 - setTimeout(() => resolve([]), 500); 3918 - }); 3919 - 3920 - const batchCmds = commands.filter((c: any) => c.name.startsWith('test-batch-cmd-')); 3921 - if (batchCmds.length === 3) { 3922 - return { 3923 - totalCommands: commands.length, 3924 - batchCommandsFound: batchCmds.length, 3925 - batchCommandNames: batchCmds.map((c: any) => c.name) 3926 - }; 3927 - } 3928 - } 3929 - 3930 - return { totalCommands: 0, batchCommandsFound: 0, batchCommandNames: [] }; 3931 - }); 3932 - 3933 - expect(result.batchCommandsFound).toBe(3); 3934 - expect(result.batchCommandNames).toContain('test-batch-cmd-1'); 3935 - expect(result.batchCommandNames).toContain('test-batch-cmd-2'); 3936 - expect(result.batchCommandNames).toContain('test-batch-cmd-3'); 3937 - }); 3938 - 3939 - }); 3940 - 3941 - // ============================================================================ 3942 - // Startup Phase Events Tests (uses shared app) 3943 - // ============================================================================ 3944 - 3945 - test.describe('Startup Phase Events @desktop', () => { 3946 - let app: DesktopApp; 3947 - let bgWindow: Page; 3948 - 3949 - test.beforeAll(async () => { 3950 - ({ app, bgWindow } = await createPerDescribeApp('startup-phase')); 3951 - // Wait for extensions to be fully ready 3952 - await waitForExtensionsReady(bgWindow); 3953 - }); 3954 - 3955 - test.afterAll(async () => { 3956 - if (app) await app.close(); 3957 - }); 3958 - 3959 - test('ext:startup:phase events are available for subscription', async () => { 3960 - // Test that extensions can subscribe to startup phase events 3961 - // Since app is already started, we test that the subscription mechanism works 3962 - const result = await bgWindow.evaluate(async () => { 3963 - const api = (window as any).app; 3964 - let received = false; 3965 - 3966 - // Subscribe to startup phase events 3967 - api.subscribe('ext:startup:phase', (msg: any) => { 3968 - received = true; 3969 - }, api.scopes.GLOBAL); 3970 - 3971 - // The subscription should be set up without error 3972 - return { subscriptionCreated: true }; 3973 - }); 3974 - 3975 - expect(result.subscriptionCreated).toBe(true); 3976 - }); 3977 - 3978 - test('ext:all-loaded event was published during startup', async () => { 3979 - // Verify that the ext:all-loaded event was published by checking extensions are running 3980 - const result = await bgWindow.evaluate(async () => { 3981 - const api = (window as any).app; 3982 - 3983 - // Get running extensions - if they're running, ext:all-loaded was published 3984 - const extResult = await api.extensions.list(); 3985 - const extensions = extResult.data || []; 3986 - return { 3987 - success: extResult.success, 3988 - extensionCount: extensions.length, 3989 - hasCmd: extensions.some((e: any) => e.id === 'cmd'), 3990 - hasGroups: extensions.some((e: any) => e.id === 'groups') 3991 - }; 3992 - }); 3993 - 3994 - expect(result.success).toBe(true); 3995 - expect(result.extensionCount).toBeGreaterThan(0); 3996 - expect(result.hasCmd).toBe(true); 3997 - }); 3998 - 3999 - test('cmd extension loads before other extensions can register commands', async () => { 4000 - // Verify that cmd is running and accepting commands (which means it loaded first) 4001 - // Use inline retry approach that works reliably 4002 - const result = await bgWindow.evaluate(async () => { 4003 - const api = (window as any).app; 4004 - 4005 - const queryCommands = () => new Promise((resolve) => { 4006 - api.subscribe('cmd:query-commands-response', (msg: any) => { 4007 - resolve(msg.commands || []); 4008 - }, api.scopes.GLOBAL); 4009 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 4010 - setTimeout(() => resolve([]), 1000); 4011 - }); 4012 - 4013 - // Retry a few times to allow extensions to finish loading 4014 - for (let i = 0; i < 5; i++) { 4015 - const cmds = await queryCommands() as any[]; 4016 - if (cmds.some((c: any) => c.name === 'example:gallery')) { 4017 - return cmds; 4018 - } 4019 - await new Promise(r => setTimeout(r, 500)); 4020 - } 4021 - return await queryCommands(); 4022 - }); 4023 - 4024 - expect(Array.isArray(result)).toBe(true); 4025 - expect(result.length).toBeGreaterThan(0); 4026 - // gallery command from example extension should be registered 4027 - const hasGalleryCommand = result.some((c: any) => c.name === 'example:gallery'); 4028 - expect(hasGalleryCommand).toBe(true); 4029 - }); 4030 - 4031 - test('cmd extension is always running (cannot be disabled)', async () => { 4032 - // cmd is required infrastructure - verify it's always in the running extensions list 4033 - const result = await bgWindow.evaluate(async () => { 4034 - const api = (window as any).app; 4035 - const runningExts = await api.extensions.list(); 4036 - return { 4037 - success: runningExts.success, 4038 - extensions: runningExts.data || [], 4039 - cmdRunning: runningExts.data?.some((ext: any) => ext.id === 'cmd'), 4040 - cmdStatus: runningExts.data?.find((ext: any) => ext.id === 'cmd')?.status 4041 - }; 4042 - }); 4043 - 4044 - expect(result.success).toBe(true); 4045 - expect(result.cmdRunning).toBe(true); 4046 - expect(result.cmdStatus).toBe('running'); 4047 - }); 4048 - }); 4049 - 4050 - // ============================================================================ 4051 - // Hybrid Extension Mode Tests (uses shared app) 4052 - // ============================================================================ 4053 - 4054 - test.describe('Hybrid Extension Mode @desktop', () => { 4055 - let app: DesktopApp; 4056 - let bgWindow: Page; 4057 - 4058 - test.beforeAll(async () => { 4059 - ({ app, bgWindow } = await createPerDescribeApp('hybrid-mode')); 4060 - }); 4061 - 4062 - test.afterAll(async () => { 4063 - if (app) await app.close(); 4064 - }); 4065 - 4066 - test('v2 background tile windows exist as separate BrowserWindows', async () => { 4067 - // V2 background tiles (peeks, slides) launch as separate hidden BrowserWindows 4068 - // at peek://{id}/background.html — NOT as iframes in the extension host. 4069 - const peeksWin = await waitForWindow( 4070 - () => app.windows(), 4071 - 'peek://peeks/background.html', 4072 - 15000 4073 - ); 4074 - expect(peeksWin).toBeDefined(); 4075 - 4076 - const slidesWin = await waitForWindow( 4077 - () => app.windows(), 4078 - 'peek://slides/background.html', 4079 - 15000 4080 - ); 4081 - expect(slidesWin).toBeDefined(); 4082 - }); 4083 - 4084 - test('api.extensions.reload() reloads external extension', async () => { 4085 - // Reload the example extension (external v2 tile — lazy). reload() re-reads 4086 - // the manifest, revokes any existing token, and relaunches the tile if it 4087 - // was loaded. For a lazy tile that hasn't been invoked yet, reload is a 4088 - // no-op on the tile side but still succeeds (manifest re-read). 4089 - const reloadResult = await bgWindow.evaluate(async () => { 4090 - return await (window as any).app.extensions.reload('example'); 4091 - }); 4092 - 4093 - expect(reloadResult.success).toBe(true); 4094 - expect(reloadResult.data?.id).toBe('example'); 4095 - }); 4096 - 4097 - test('api.extensions.reload() fails for consolidated extensions', async () => { 4098 - // Consolidated extensions (like cmd, groups) cannot be reloaded 4099 - const reloadResult = await bgWindow.evaluate(async () => { 4100 - return await (window as any).app.extensions.reload('cmd'); 4101 - }); 4102 - 4103 - expect(reloadResult.success).toBe(false); 4104 - expect(reloadResult.error).toContain('Failed to reload'); 4105 - }); 4106 - 4107 - test('commands work from both consolidated and external extensions', async () => { 4108 - // Wait a bit for extensions to initialize and register commands 4109 - await sleep(1000); 4110 - 4111 - // Query commands - should include commands from all extensions 4112 - const result = await bgWindow.evaluate(async () => { 4113 - const api = (window as any).app; 4114 - 4115 - return new Promise((resolve) => { 4116 - const timeout = setTimeout(() => { 4117 - resolve({ success: false, commandCount: 0 }); 4118 - }, 10000); 4119 - 4120 - api.subscribe('cmd:query-commands-response', (msg: any) => { 4121 - clearTimeout(timeout); 4122 - resolve({ 4123 - success: true, 4124 - commandCount: msg.commands?.length || 0, 4125 - // example:gallery comes from external 'example' extension 4126 - hasGalleryCommand: msg.commands?.some((c: any) => c.name === 'example:gallery'), 4127 - // settings comes from core (via consolidated cmd) 4128 - hasSettingsCommand: msg.commands?.some((c: any) => c.name === 'settings') 4129 - }); 4130 - }, api.scopes.GLOBAL); 4131 - 4132 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 4133 - }); 4134 - }); 4135 - 4136 - expect(result.success).toBe(true); 4137 - expect(result.commandCount).toBeGreaterThan(0); 4138 - // example:gallery proves external extension commands work 4139 - expect(result.hasGalleryCommand).toBe(true); 4140 - // settings proves consolidated extension commands work 4141 - expect(result.hasSettingsCommand).toBe(true); 4142 - }); 4143 - 4144 - test('pubsub works between consolidated and external extensions', async () => { 4145 - // Test pubsub routing between extensions in different modes 4146 - // cmd (consolidated) receives query, responds to core 4147 - const result = await bgWindow.evaluate(async () => { 4148 - const api = (window as any).app; 4149 - 4150 - return new Promise((resolve) => { 4151 - const timeout = setTimeout(() => { 4152 - resolve({ received: false, commandCount: 0 }); 4153 - }, 5000); 4154 - 4155 - api.subscribe('cmd:query-commands-response', (msg: any) => { 4156 - clearTimeout(timeout); 4157 - resolve({ 4158 - received: true, 4159 - commandCount: msg.commands?.length || 0 4160 - }); 4161 - }, api.scopes.GLOBAL); 4162 - 4163 - api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 4164 - }); 4165 - }); 4166 - 4167 - expect(result.received).toBe(true); 4168 - expect(result.commandCount).toBeGreaterThan(0); 4169 - }); 4170 - 4171 - test('correct window count for hybrid mode', async () => { 4172 - // After v2-tile migration: 4173 - // - 1 core background window (peek://app/background.html) 4174 - // - Multiple v2 eager-background tile windows (peeks, slides, entities, … 4175 - // served from peek://{id}/background.html) 4176 - // - Lazy v2 tiles (including 'example') do NOT load at startup; they 4177 - // only launch at peek://{id}/background.html on first command invoke. 4178 - // - Plus any UI windows (settings, etc.) 4179 - const windows = app.windows(); 4180 - 4181 - const coreBgWindows = windows.filter(w => w.url().includes('peek://app/background.html')); 4182 - expect(coreBgWindows.length).toBe(1); 4183 - 4184 - // Eager v2 tile background windows exist; at least a couple expected 4185 - // (peeks, slides were the canonical ones in v2 migration tests). 4186 - const v2TileBgWindows = windows.filter(w => /peek:\/\/[a-z-]+\/background\.html/.test(w.url())); 4187 - expect(v2TileBgWindows.length).toBeGreaterThan(0); 4188 - 4189 - // Lazy 'example' tile shouldn't have a window unless it was already 4190 - // invoked in a previous test. Don't assert presence or absence — this 4191 - // test is about the core/v2 shape, not example specifically. 4192 - }); 4193 - }); 4194 - 4195 - // ============================================================================ 4196 - // Extension Settings in Hybrid Mode Tests 4197 - // ============================================================================ 4198 - 4199 - test.describe('Extension Settings in Hybrid Mode @desktop', () => { 4200 - // Tests 1 and 2 share an instance (defaults check first, then modify) 4201 - // Test 3 (restart test) uses its own instances 4202 - let settingsApp: DesktopApp; 4203 - let settingsBgWindow: Page; 4204 - 4205 - test.beforeAll(async () => { 4206 - // Launch fresh app for settings tests (tests 1 and 2 share this) 4207 - settingsApp = await launchDesktopApp(`test-settings-hybrid-${Date.now()}`); 4208 - settingsBgWindow = await settingsApp.getBackgroundWindow(); 4209 - await waitForExtensionsReady(settingsBgWindow, 15000); 4210 - }); 4211 - 4212 - test.afterAll(async () => { 4213 - if (settingsApp) await settingsApp.close(); 4214 - }); 4215 - 4216 - // This test runs FIRST - checks defaults before any modifications 4217 - test('extension falls back to defaults when no custom settings exist', async () => { 4218 - // This test verifies that when no custom settings exist in the datastore, 4219 - // extensions correctly use their default settings 4220 - 4221 - // Query cmd's current settings 4222 - const result = await settingsBgWindow.evaluate(async () => { 4223 - const api = (window as any).app; 4224 - const defaultShortcut = 'Option+Space'; // cmd's default 4225 - 4226 - return new Promise((resolve) => { 4227 - const timeout = setTimeout(() => { 4228 - resolve({ success: false, error: 'timeout' }); 4229 - }, 5000); 4230 - 4231 - api.subscribe('cmd:settings-changed', (msg: any) => { 4232 - clearTimeout(timeout); 4233 - resolve({ 4234 - success: true, 4235 - shortcutKey: msg?.prefs?.shortcutKey, 4236 - isDefault: msg?.prefs?.shortcutKey === defaultShortcut 4237 - }); 4238 - }, api.scopes.GLOBAL); 4239 - 4240 - // Poke cmd to report its settings - use the default to not change it 4241 - api.publish('cmd:settings-update', { 4242 - data: { prefs: { shortcutKey: defaultShortcut } } 4243 - }, api.scopes.GLOBAL); 4244 - }); 4245 - }); 4246 - 4247 - expect(result.success).toBe(true); 4248 - expect(result.isDefault).toBe(true); 4249 - expect(result.shortcutKey).toBe('Option+Space'); 4250 - }); 4251 - 4252 - // This test runs SECOND - can modify settings since defaults already checked 4253 - test('hybrid mode extensions can access settings via api.settings.get()', async () => { 4254 - // This test verifies that extensions running at peek://{extId}/... URLs 4255 - // (hybrid mode) can successfully use the settings API 4256 - // 4257 - // The preload must correctly detect these URLs as extensions and return 4258 - // the proper extension ID for settings lookups 4259 - // 4260 - // We test this by updating settings via pubsub and verifying: 4261 - // 1. cmd receives the update (which requires api.settings.get() to have worked during init) 4262 - // 2. cmd persists the settings (which requires api.settings.set() to work) 4263 - 4264 - // Custom shortcut to test with 4265 - const customShortcut = 'Option+Shift+T'; 4266 - 4267 - // Update cmd settings via pubsub 4268 - const updateResult = await settingsBgWindow.evaluate(async (shortcut) => { 4269 - const api = (window as any).app; 4270 - 4271 - return new Promise((resolve) => { 4272 - const timeout = setTimeout(() => { 4273 - resolve({ success: false, error: 'timeout waiting for settings change' }); 4274 - }, 5000); 4275 - 4276 - // Subscribe to settings changed notification from cmd 4277 - api.subscribe('cmd:settings-changed', (msg: any) => { 4278 - clearTimeout(timeout); 4279 - resolve({ 4280 - success: true, 4281 - receivedShortcut: msg?.prefs?.shortcutKey, 4282 - matchesExpected: msg?.prefs?.shortcutKey === shortcut 4283 - }); 4284 - }, api.scopes.GLOBAL); 4285 - 4286 - // Update cmd settings via pubsub (this is how Settings UI does it) 4287 - api.publish('cmd:settings-update', { 4288 - data: { prefs: { shortcutKey: shortcut } } 4289 - }, api.scopes.GLOBAL); 4290 - }); 4291 - }, customShortcut); 4292 - 4293 - expect(updateResult.success).toBe(true); 4294 - expect(updateResult.matchesExpected).toBe(true); 4295 - expect(updateResult.receivedShortcut).toBe(customShortcut); 4296 - 4297 - // Wait a moment for persistence to complete 4298 - await sleep(200); 4299 - 4300 - // Now verify the settings were persisted to datastore 4301 - // Note: extension-settings-set stores with id format ${extId}_${key} 4302 - const persistResult = await settingsBgWindow.evaluate(async (expectedShortcut) => { 4303 - const api = (window as any).app; 4304 - const stored = await api.datastore.getRow('feature_settings', 'cmd_prefs'); 4305 - 4306 - if (!stored.success || !stored.data?.value) { 4307 - return { success: false, error: 'No stored settings found', stored }; 4308 - } 4309 - 4310 - const parsed = JSON.parse(stored.data.value); 4311 - return { 4312 - success: true, 4313 - persistedShortcut: parsed.shortcutKey, 4314 - wasPersisted: parsed.shortcutKey === expectedShortcut 4315 - }; 4316 - }, customShortcut); 4317 - 4318 - expect(persistResult.success).toBe(true); 4319 - expect(persistResult.wasPersisted).toBe(true); 4320 - expect(persistResult.persistedShortcut).toBe(customShortcut); 4321 - }); 4322 - 4323 - // This test needs restart - uses its own isolated instances 4324 - test('extension loads custom settings instead of defaults on startup', async () => { 4325 - // This test verifies that when custom settings exist in the datastore, 4326 - // extensions load those settings instead of their defaults 4327 - // 4328 - // We set up custom settings, close the app, relaunch with the same profile, 4329 - // and verify the extension loaded the custom settings on init 4330 - 4331 - // Use a fixed profile name so we can relaunch with the same settings 4332 - const profileName = `test-custom-settings-${Date.now()}`; 4333 - 4334 - // First, launch app to set up custom settings in the datastore 4335 - const setupApp = await launchDesktopApp(profileName); 4336 - const setupWindow = await setupApp.getBackgroundWindow(); 4337 - 4338 - const customShortcut = 'Option+Ctrl+P'; 4339 - 4340 - // Store custom settings (using format ${extId}_${key} to match extension-settings-set handler) 4341 - const saveResult = await setupWindow.evaluate(async (shortcut) => { 4342 - const api = (window as any).app; 4343 - return await api.datastore.setRow('feature_settings', 'cmd_prefs', { 4344 - featureId: 'cmd', 4345 - key: 'prefs', 4346 - value: JSON.stringify({ shortcutKey: shortcut }), 4347 - updatedAt: Date.now() 4348 - }); 4349 - }, customShortcut); 4350 - 4351 - expect(saveResult.success).toBe(true); 4352 - 4353 - // Close and relaunch - extensions should load custom settings on init 4354 - await setupApp.close(); 4355 - 4356 - // Small delay to ensure clean shutdown 4357 - await sleep(500); 4358 - 4359 - // Relaunch with SAME profile to pick up saved settings 4360 - const testApp = await launchDesktopApp(profileName); 4361 - 4362 - try { 4363 - const testWindow = await testApp.getBackgroundWindow(); 4364 - 4365 - // Wait for extensions to be fully initialized using proper wait helper 4366 - await waitForExtensionsReady(testWindow, 15000); 4367 - 4368 - // Verify cmd loaded the custom settings on startup 4369 - // We update settings with the same value and verify it was already set 4370 - const result = await testWindow.evaluate(async (expectedShortcut) => { 4371 - const api = (window as any).app; 4372 - 4373 - return new Promise((resolve) => { 4374 - const timeout = setTimeout(() => { 4375 - resolve({ success: false, error: 'timeout' }); 4376 - }, 10000); 4377 - 4378 - api.subscribe('cmd:settings-changed', (msg: any) => { 4379 - clearTimeout(timeout); 4380 - resolve({ 4381 - success: true, 4382 - shortcutKey: msg?.prefs?.shortcutKey, 4383 - matchesCustom: msg?.prefs?.shortcutKey === expectedShortcut, 4384 - // Check if it's NOT the default (Option+Space) 4385 - isNotDefault: msg?.prefs?.shortcutKey !== 'Option+Space' 4386 - }); 4387 - }, api.scopes.GLOBAL); 4388 - 4389 - // Poke cmd to report its current settings (update with same value) 4390 - api.publish('cmd:settings-update', { 4391 - data: { prefs: { shortcutKey: expectedShortcut } } 4392 - }, api.scopes.GLOBAL); 4393 - }); 4394 - }, customShortcut); 4395 - 4396 - expect(result.success).toBe(true); 4397 - expect(result.isNotDefault).toBe(true); 4398 - expect(result.matchesCustom).toBe(true); 4399 - expect(result.shortcutKey).toBe(customShortcut); 4400 - 4401 - } finally { 4402 - await testApp.close(); 4403 - } 4404 - }); 4405 - }); 4406 - 4407 - // ============================================================================ 4408 - // Window Targeting Tests 4409 - // ============================================================================ 4410 - // Tests for the window focus tracking system that enables per-window commands 4411 - // like "theme dark here" to target the correct window. 4412 - // 4413 - // Key behavior: Modal windows (like cmd palette) should NOT update the 4414 - // "last focused visible window" tracker, so commands target the window 4415 - // the user was looking at before opening the palette. 4416 - 4417 - test.describe('Window Targeting @desktop', () => { 4418 - let app: DesktopApp; 4419 - let bgWindow: Page; 4420 - 4421 - test.beforeAll(async () => { 4422 - ({ app, bgWindow } = await createPerDescribeApp('window-targeting')); 4423 - await sleep(500); // Wait for app to stabilize 4424 - }); 4425 - 4426 - test.afterAll(async () => { 4427 - if (app) await app.close(); 4428 - }); 4429 - 4430 - test('setWindowColorScheme returns success with windowId', async () => { 4431 - // Test that setWindowColorScheme works and returns expected data 4432 - const result = await bgWindow.evaluate(async () => { 4433 - const api = (window as any).app; 4434 - 4435 - // Open a test window (non-modal) to have a valid target 4436 - const winResult = await api.window.open('peek://app/settings/settings.html', { 4437 - width: 400, 4438 - height: 300, 4439 - modal: false, 4440 - key: 'test-theme-window-1' 4441 - }); 4442 - 4443 - if (!winResult.success) { 4444 - return { success: false, error: 'Failed to open window' }; 4445 - } 4446 - 4447 - // Wait for window to be ready and focused 4448 - await new Promise(r => setTimeout(r, 300)); 4449 - 4450 - // Call setWindowColorScheme 4451 - const themeResult = await api.theme.setWindowColorScheme('dark'); 4452 - 4453 - // Clean up 4454 - try { 4455 - await api.window.close(winResult.id); 4456 - } catch (e) { 4457 - // Ignore close errors 4458 - } 4459 - 4460 - return { 4461 - success: themeResult.success, 4462 - windowId: themeResult.windowId, 4463 - colorScheme: themeResult.colorScheme, 4464 - error: themeResult.error 4465 - }; 4466 - }); 4467 - 4468 - expect(result.success).toBe(true); 4469 - expect(result.colorScheme).toBe('dark'); 4470 - expect(typeof result.windowId).toBe('number'); 4471 - }); 4472 - 4473 - test('modal window does not become theme target', async () => { 4474 - // This test verifies that opening a modal window after a non-modal window 4475 - // still allows setWindowColorScheme to target the non-modal window 4476 - const result = await bgWindow.evaluate(async () => { 4477 - const api = (window as any).app; 4478 - 4479 - // Open a non-modal window first 4480 - const nonModalResult = await api.window.open('peek://app/settings/settings.html', { 4481 - width: 400, 4482 - height: 300, 4483 - modal: false, 4484 - key: 'test-nonmodal-target' 4485 - }); 4486 - 4487 - if (!nonModalResult.success) { 4488 - return { success: false, error: 'Failed to open non-modal window' }; 4489 - } 4490 - 4491 - // Wait for it to focus 4492 - await new Promise(r => setTimeout(r, 300)); 4493 - 4494 - // Now open a modal window (simulating cmd palette behavior) 4495 - const modalResult = await api.window.open('peek://app/settings/settings.html', { 4496 - width: 300, 4497 - height: 200, 4498 - modal: true, 4499 - key: 'test-modal-overlay' 4500 - }); 4501 - 4502 - if (!modalResult.success) { 4503 - // Clean up non-modal 4504 - try { await api.window.close(nonModalResult.id); } catch (e) {} 4505 - return { success: false, error: 'Failed to open modal window' }; 4506 - } 4507 - 4508 - // Wait a bit for modal to be ready 4509 - await new Promise(r => setTimeout(r, 200)); 4510 - 4511 - // Now call setWindowColorScheme - should still target the NON-MODAL window 4512 - const themeResult = await api.theme.setWindowColorScheme('light'); 4513 - 4514 - // Clean up both windows 4515 - try { await api.window.close(modalResult.id); } catch (e) {} 4516 - try { await api.window.close(nonModalResult.id); } catch (e) {} 4517 - 4518 - return { 4519 - success: themeResult.success, 4520 - targetedWindowId: themeResult.windowId, 4521 - nonModalWindowId: nonModalResult.id, 4522 - modalWindowId: modalResult.id, 4523 - // Key assertion: the targeted window should be the non-modal one 4524 - targetedNonModal: themeResult.windowId === nonModalResult.id 4525 - }; 4526 - }); 4527 - 4528 - expect(result.success).toBe(true); 4529 - // The theme command should have targeted the non-modal window, not the modal 4530 - expect(result.targetedNonModal).toBe(true); 4531 - }); 4532 - 4533 - test('setWindowColorScheme with global resets override', async () => { 4534 - // Test the 'global' value which should reset window-specific override 4535 - const result = await bgWindow.evaluate(async () => { 4536 - const api = (window as any).app; 4537 - 4538 - // Open a test window 4539 - const winResult = await api.window.open('peek://app/settings/settings.html', { 4540 - width: 400, 4541 - height: 300, 4542 - modal: false, 4543 - key: 'test-theme-reset-window' 4544 - }); 4545 - 4546 - if (!winResult.success) { 4547 - return { success: false, error: 'Failed to open window' }; 4548 - } 4549 - 4550 - await new Promise(r => setTimeout(r, 300)); 4551 - 4552 - // Set to dark first 4553 - const darkResult = await api.theme.setWindowColorScheme('dark'); 4554 - 4555 - // Then reset to global 4556 - const globalResult = await api.theme.setWindowColorScheme('global'); 4557 - 4558 - // Clean up 4559 - try { await api.window.close(winResult.id); } catch (e) {} 4560 - 4561 - return { 4562 - darkSuccess: darkResult.success, 4563 - globalSuccess: globalResult.success, 4564 - globalColorScheme: globalResult.colorScheme 4565 - }; 4566 - }); 4567 - 4568 - expect(result.darkSuccess).toBe(true); 4569 - expect(result.globalSuccess).toBe(true); 4570 - expect(result.globalColorScheme).toBe('global'); 4571 - }); 4572 - }); 4573 - 4574 - // ============================================================================ 4575 - // Backup Tests (uses shared app) 4576 - // ============================================================================ 4577 - 4578 - test.describe('Backup @desktop', () => { 4579 - let app: DesktopApp; 4580 - let bgWindow: Page; 4581 - 4582 - test.beforeAll(async () => { 4583 - ({ app, bgWindow } = await createPerDescribeApp('backup')); 4584 - }); 4585 - 4586 - test.afterAll(async () => { 4587 - if (app) await app.close(); 4588 - }); 4589 - 4590 - test('backup-get-config returns config object', async () => { 4591 - const result = await bgWindow.evaluate(async () => { 4592 - return await (window as any).app.backup.getConfig(); 4593 - }); 4594 - 4595 - expect(result.success).toBe(true); 4596 - expect(result.data).toBeDefined(); 4597 - expect(typeof result.data.enabled).toBe('boolean'); 4598 - expect(typeof result.data.backupDir).toBe('string'); 4599 - expect(typeof result.data.retentionCount).toBe('number'); 4600 - expect(typeof result.data.lastBackupTime).toBe('number'); 4601 - }); 4602 - 4603 - test('backup is disabled when backupDir is not configured', async () => { 4604 - const result = await bgWindow.evaluate(async () => { 4605 - return await (window as any).app.backup.getConfig(); 4606 - }); 4607 - 4608 - expect(result.success).toBe(true); 4609 - // By default, backupDir should be empty and backups disabled 4610 - expect(result.data.backupDir).toBe(''); 4611 - expect(result.data.enabled).toBe(false); 4612 - }); 4613 - 4614 - test('backup-create returns error when not configured', async () => { 4615 - const result = await bgWindow.evaluate(async () => { 4616 - return await (window as any).app.backup.create(); 4617 - }); 4618 - 4619 - expect(result.success).toBe(false); 4620 - expect(result.error).toContain('not configured'); 4621 - }); 4622 - 4623 - test('backup-list returns empty when not configured', async () => { 4624 - const result = await bgWindow.evaluate(async () => { 4625 - return await (window as any).app.backup.list(); 4626 - }); 4627 - 4628 - expect(result.success).toBe(true); 4629 - expect(result.data.backups).toEqual([]); 4630 - expect(result.data.backupDir).toBe(''); 4631 - }); 4632 - 4633 - test('backup works when backupDir is configured', async () => { 4634 - // Create temp directory for test backups 4635 - const os = await import('os'); 4636 - const pathModule = await import('path'); 4637 - const fs = await import('fs'); 4638 - 4639 - const tempBackupDir = pathModule.default.join(os.default.tmpdir(), `peek-backup-test-${Date.now()}`); 4640 - fs.default.mkdirSync(tempBackupDir, { recursive: true }); 4641 - 4642 - try { 4643 - // Store the current prefs and configure backup 4644 - const setupResult = await bgWindow.evaluate(async (backupDir: string) => { 4645 - const api = (window as any).app; 4646 - 4647 - // Get current prefs 4648 - const prefsResult = await api.datastore.getTable('feature_settings'); 4649 - const corePrefsRow = Object.values(prefsResult.data || {}).find( 4650 - (r: any) => r.featureId === 'core' && r.key === 'prefs' 4651 - ) as any; 4652 - const originalPrefs = corePrefsRow ? JSON.parse(corePrefsRow.value) : {}; 4653 - 4654 - // Set backupDir in core prefs 4655 - const newPrefs = { ...originalPrefs, backupDir }; 4656 - await api.datastore.setRow('feature_settings', 'core:prefs', { 4657 - featureId: 'core', 4658 - key: 'prefs', 4659 - value: JSON.stringify(newPrefs), 4660 - updatedAt: Date.now() 4661 - }); 4662 - 4663 - return { originalPrefs }; 4664 - }, tempBackupDir); 4665 - 4666 - // Verify config reflects the change 4667 - const configResult = await bgWindow.evaluate(async () => { 4668 - return await (window as any).app.backup.getConfig(); 4669 - }); 4670 - expect(configResult.success).toBe(true); 4671 - expect(configResult.data.backupDir).toBe(tempBackupDir); 4672 - expect(configResult.data.enabled).toBe(true); 4673 - 4674 - // Create a backup 4675 - const backupResult = await bgWindow.evaluate(async () => { 4676 - return await (window as any).app.backup.create(); 4677 - }); 4678 - expect(backupResult.success).toBe(true); 4679 - expect(backupResult.path).toBeTruthy(); 4680 - expect(backupResult.path.endsWith('.zip')).toBe(true); 4681 - 4682 - // Verify the file exists 4683 - expect(fs.default.existsSync(backupResult.path)).toBe(true); 4684 - 4685 - // List backups - should have one 4686 - const listResult = await bgWindow.evaluate(async () => { 4687 - return await (window as any).app.backup.list(); 4688 - }); 4689 - expect(listResult.success).toBe(true); 4690 - expect(listResult.data.backups.length).toBe(1); 4691 - 4692 - // Restore original prefs 4693 - await bgWindow.evaluate(async (originalPrefs: Record<string, unknown>) => { 4694 - const api = (window as any).app; 4695 - await api.datastore.setRow('feature_settings', 'core:prefs', { 4696 - featureId: 'core', 4697 - key: 'prefs', 4698 - value: JSON.stringify(originalPrefs), 4699 - updatedAt: Date.now() 4700 - }); 4701 - }, setupResult.originalPrefs); 4702 - } finally { 4703 - // Clean up temp directory 4704 - try { 4705 - fs.default.rmSync(tempBackupDir, { recursive: true, force: true }); 4706 - } catch (e) { 4707 - // Ignore cleanup errors 4708 - } 4709 - } 4710 - }); 4711 - }); 4712 - 4713 - // ============================================================================ 4714 - // IZUI Behavior Tests (uses shared app) 4715 - // ============================================================================ 4716 - 4717 - test.describe('IZUI Behavior @desktop', () => { 4718 - let app: DesktopApp; 4719 - let bgWindow: Page; 4720 - 4721 - test.beforeAll(async () => { 4722 - ({ app, bgWindow } = await createPerDescribeApp('izui-behavior')); 4723 - }); 4724 - 4725 - test.afterAll(async () => { 4726 - if (app) await app.close(); 4727 - }); 4728 - 4729 - test('parentWindowId is set when opened from a content window', async () => { 4730 - 4731 - // Step 1: Open a content window (groups home) from the background window. 4732 - // Since background.html is an internal URL, parentWindowId should be null. 4733 - const groupsResult = await bgWindow.evaluate(async () => { 4734 - return await (window as any).app.window.open('peek://groups/home.html', { 4735 - width: 600, 4736 - height: 400 4737 - }); 4738 - }); 4739 - expect(groupsResult.success).toBe(true); 4740 - const groupsWindowId = groupsResult.id; 4741 - 4742 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 4743 - expect(groupsWindow).toBeTruthy(); 4744 - await groupsWindow.waitForLoadState('domcontentloaded'); 4745 - 4746 - // Verify the groups window itself has parentWindowId=null (opened from background) 4747 - const groupsListBefore = await bgWindow.evaluate(async (wid: number) => { 4748 - const result = await (window as any).app.window.list({ includeInternal: true }); 4749 - if (!result.success) return null; 4750 - return result.windows.find((w: any) => w.id === wid); 4751 - }, groupsWindowId); 4752 - expect(groupsListBefore).toBeTruthy(); 4753 - expect(groupsListBefore.params.parentWindowId).toBeNull(); 4754 - 4755 - // Step 2: Open a child window FROM the groups content window. 4756 - // Since groups/home.html is a real content window (not background/extension-host), 4757 - // the child should get parentWindowId set to the groups window's ID. 4758 - const childResult = await groupsWindow.evaluate(async () => { 4759 - return await (window as any).app.window.open('https://child-parent-test.example.com', { 4760 - width: 400, 4761 - height: 300 4762 - }); 4763 - }); 4764 - expect(childResult.success).toBe(true); 4765 - const childWindowId = childResult.id; 4766 - 4767 - // Verify child window has parentWindowId set to the groups window 4768 - const childInfo = await bgWindow.evaluate(async (wid: number) => { 4769 - const result = await (window as any).app.window.list({ includeInternal: true }); 4770 - if (!result.success) return null; 4771 - return result.windows.find((w: any) => w.id === wid); 4772 - }, childWindowId); 4773 - expect(childInfo).toBeTruthy(); 4774 - expect(childInfo.params.parentWindowId).toBe(groupsWindowId); 4775 - 4776 - // Clean up 4777 - for (const id of [childWindowId, groupsWindowId]) { 4778 - if (id) { 4779 - try { 4780 - await bgWindow.evaluate(async (wid: number) => { 4781 - return await (window as any).app.window.close(wid); 4782 - }, id); 4783 - } catch { /* window may already be closed */ } 4784 - } 4785 - } 4786 - }); 4787 - 4788 - test('onEscape registers callback without changing backend escapeMode (role-based)', async () => { 4789 - 4790 - // Open a plain window with no escapeMode set (defaults to 'auto') 4791 - const result = await bgWindow.evaluate(async () => { 4792 - return await (window as any).app.window.open('about:blank', { 4793 - width: 400, 4794 - height: 300 4795 - }); 4796 - }); 4797 - expect(result.success).toBe(true); 4798 - const windowId = result.id; 4799 - 4800 - const contentWindow = await app.getWindow('about:blank', 5000); 4801 - expect(contentWindow).toBeTruthy(); 4802 - 4803 - // Get escapeMode before registering handler 4804 - const infoBefore = await bgWindow.evaluate(async (wid: number) => { 4805 - const listResult = await (window as any).app.window.list({ includeInternal: true }); 4806 - if (!listResult.success) return null; 4807 - return listResult.windows.find((w: any) => w.id === wid); 4808 - }, windowId); 4809 - expect(infoBefore).toBeTruthy(); 4810 - const escapeModeBefore = infoBefore.params.escapeMode; 4811 - 4812 - // Call api.escape.onEscape() — this should NOT change escapeMode on the backend 4813 - // (self-declaration removed; role determines behavior now) 4814 - await contentWindow.evaluate(() => { 4815 - (window as any).app.escape.onEscape(() => ({ handled: false })); 4816 - }); 4817 - 4818 - // Small delay to ensure any async IPC would have completed 4819 - await new Promise(r => setTimeout(r, 300)); 4820 - 4821 - // Verify escapeMode was NOT changed by onEscape registration 4822 - const infoAfter = await bgWindow.evaluate(async (wid: number) => { 4823 - const listResult = await (window as any).app.window.list({ includeInternal: true }); 4824 - if (!listResult.success) return null; 4825 - return listResult.windows.find((w: any) => w.id === wid); 4826 - }, windowId); 4827 - expect(infoAfter).toBeTruthy(); 4828 - expect(infoAfter.params.escapeMode).toBe(escapeModeBefore); 4829 - 4830 - // Verify the callback IS registered and responds via escape trigger 4831 - const triggerResult = await contentWindow.evaluate(async () => { 4832 - return await (window as any).app.escape.trigger(); 4833 - }); 4834 - expect(triggerResult).toEqual({ handled: false }); 4835 - 4836 - // Clean up 4837 - if (windowId) { 4838 - try { 4839 - await bgWindow.evaluate(async (id: number) => { 4840 - return await (window as any).app.window.close(id); 4841 - }, windowId); 4842 - } catch { /* window may already be closed */ } 4843 - } 4844 - }); 4845 - 4846 - test('izui-close-self closes the window', async () => { 4847 - 4848 - // Open a content window 4849 - const result = await bgWindow.evaluate(async () => { 4850 - return await (window as any).app.window.open('peek://groups/home.html', { 4851 - width: 400, 4852 - height: 300, 4853 - escapeMode: 'navigate' 4854 - }); 4855 - }); 4856 - expect(result.success).toBe(true); 4857 - const windowId = result.id; 4858 - 4859 - const contentWindow = await app.getWindow('groups/home.html', 5000); 4860 - expect(contentWindow).toBeTruthy(); 4861 - await contentWindow.waitForLoadState('domcontentloaded'); 4862 - 4863 - // Close the window via the IPC path (tile:window:close). Fire-and-forget 4864 - // — the IPC send doesn't block on the actual close. 4865 - await bgWindow.evaluate(async (wid: number) => { 4866 - await (window as any).app.window.close(wid); 4867 - }, windowId); 4868 - 4869 - // Poll the window list until the closed window drops out. 5s is plenty 4870 - // for a close — if it hasn't dropped by then, the close path is broken. 4871 - await bgWindow.waitForFunction( 4872 - async (wid: number) => { 4873 - const listResult = await (window as any).app.window.list({ includeInternal: true }); 4874 - if (!listResult.success) return false; 4875 - return !listResult.windows.some((w: any) => w.id === wid); 4876 - }, 4877 - windowId, 4878 - { timeout: 5000 } 4879 - ); 4880 - }); 4881 - 4882 - test('item:created fires from trackWindowLoad when opening external URL', async () => { 4883 - const timestamp = Date.now(); 4884 - const testUrl = `https://track-window-load-${timestamp}.example.com`; 4885 - 4886 - // Subscribe to item:created and then open a URL window 4887 - const result = await bgWindow.evaluate(async (url: string) => { 4888 - const api = (window as any).app; 4889 - 4890 - return new Promise((resolve) => { 4891 - const timeout = setTimeout(() => { 4892 - resolve({ received: false, error: 'timeout' }); 4893 - }, 10000); 4894 - 4895 - api.subscribe('item:created', (msg: any) => { 4896 - if (msg.content === url) { 4897 - clearTimeout(timeout); 4898 - resolve({ 4899 - received: true, 4900 - itemId: msg.itemId, 4901 - itemType: msg.itemType, 4902 - content: msg.content 4903 - }); 4904 - } 4905 - }, api.scopes.GLOBAL); 4906 - 4907 - // Open a window with an external URL - this triggers trackWindowLoad 4908 - // which emits item:created if the URL is new 4909 - api.window.open(url, { 4910 - width: 400, 4911 - height: 300 4912 - }); 4913 - }); 4914 - }, testUrl); 4915 - 4916 - expect((result as any).received).toBe(true); 4917 - expect((result as any).itemId).toBeTruthy(); 4918 - expect((result as any).itemType).toBe('url'); 4919 - expect((result as any).content).toBe(testUrl); 4920 - 4921 - // Clean up - close the opened window 4922 - const windowList = await bgWindow.evaluate(async () => { 4923 - return await (window as any).app.window.list(); 4924 - }); 4925 - if (windowList.success) { 4926 - for (const w of windowList.windows) { 4927 - if (w.url?.includes('track-window-load')) { 4928 - try { 4929 - await bgWindow.evaluate(async (id: number) => { 4930 - return await (window as any).app.window.close(id); 4931 - }, w.id); 4932 - } catch { /* ignore */ } 4933 - } 4934 - } 4935 - } 4936 - }); 4937 - 4938 - test('ESC debouncing: two rapid presses trigger only one handler call', async () => { 4939 - 4940 - // Open a groups window with navigate escape mode 4941 - const result = await bgWindow.evaluate(async () => { 4942 - return await (window as any).app.window.open('peek://groups/home.html', { 4943 - width: 400, 4944 - height: 300, 4945 - escapeMode: 'navigate' 4946 - }); 4947 - }); 4948 - expect(result.success).toBe(true); 4949 - const windowId = result.id; 4950 - 4951 - const groupsWindow = await app.getWindow('groups/home.html', 5000); 4952 - expect(groupsWindow).toBeTruthy(); 4953 - await groupsWindow.waitForLoadState('domcontentloaded'); 4954 - await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 4955 - 4956 - // Navigate into a group to have a deep state (so ESC navigates back) 4957 - // First create a group with items 4958 - const tagResult = await bgWindow.evaluate(async () => { 4959 - return await (window as any).app.datastore.getOrCreateTag('esc-debounce-test'); 4960 - }); 4961 - expect(tagResult.success).toBe(true); 4962 - const tagId = tagResult.data?.tag?.id; 4963 - 4964 - const item = await bgWindow.evaluate(async () => { 4965 - return await (window as any).app.datastore.addItem('url', { 4966 - content: 'https://esc-debounce-test.example.com', 4967 - metadata: JSON.stringify({ title: 'ESC Debounce Test' }) 4968 - }); 4969 - }); 4970 - expect(item.success).toBe(true); 4971 - 4972 - if (tagId && item.data?.id) { 4973 - await bgWindow.evaluate(async ({ itemId, tagId }) => { 4974 - return await (window as any).app.datastore.tagItem(itemId, tagId); 4975 - }, { itemId: item.data.id, tagId }); 4976 - } 4977 - 4978 - // Refresh the groups view to pick up the new data 4979 - // Navigate into the group to have a deep state. 4980 - // Use a locator (auto-retrying) instead of elementHandle because the groups 4981 - // view re-renders after tag:item-added pubsub events, detaching any handle. 4982 - const groupLocator = groupsWindow.locator('peek-card.group-card').first(); 4983 - try { 4984 - await groupLocator.waitFor({ state: 'visible', timeout: 5000 }); 4985 - await groupLocator.click({ timeout: 5000 }); 4986 - await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 }); 4987 - } catch { 4988 - // If no group cards render (e.g., visibility filtering), skip the deep 4989 - // navigation — the ESC debounce behavior is independent of depth. 4990 - } 4991 - 4992 - // Track how many times the escape handler is invoked by wrapping it 4993 - // Use evaluate to set up a counter in the renderer 4994 - await groupsWindow.evaluate(() => { 4995 - (window as any).__escCallCount = 0; 4996 - const origHandler = (window as any)._escapeCallback; 4997 - if (origHandler) { 4998 - (window as any)._origEscapeCallback = origHandler; 4999 - // The preload stores the callback as _escapeCallback, but it's in a closure. 5000 - // Instead, we'll use api.escape.onEscape to wrap the handler. 5001 - (window as any).app.escape.onEscape(async () => { 5002 - (window as any).__escCallCount++; 5003 - return origHandler(); 5004 - }); 5005 - } 5006 - }); 5007 - 5008 - // Send two ESC key presses in rapid succession (< 200ms apart) 5009 - // The debouncing in windows.ts should filter the second one 5010 - await groupsWindow.keyboard.press('Escape'); 5011 - // Immediately press again - well within the 200ms debounce window 5012 - await groupsWindow.keyboard.press('Escape'); 5013 - 5014 - // Wait a moment for any handlers to complete 5015 - await sleep(500); 5016 - 5017 - // Check call count - due to debouncing, only 0 or 1 calls should have gone through 5018 - // Note: Playwright keyboard.press sends both keyDown and keyUp, and the ESC handler 5019 - // fires on keyDown via before-input-event. The debounce ensures rapid presses are collapsed. 5020 - const callCount = await groupsWindow.evaluate(() => (window as any).__escCallCount); 5021 - 5022 - // The debounce should ensure at most 1 handler call for 2 rapid presses 5023 - expect(callCount).toBeLessThanOrEqual(1); 5024 - 5025 - // Clean up 5026 - if (windowId) { 5027 - try { 5028 - await bgWindow.evaluate(async (id: number) => { 5029 - return await (window as any).app.window.close(id); 5030 - }, windowId); 5031 - } catch { /* window may already be closed */ } 5032 - } 5033 - }); 5034 - }); 5035 - 5036 - // ============================================================================ 5037 - // Shortcut Roundtrip Tests 5038 - // 5039 - // Tests the full IPC roundtrip for shortcut registration and callback firing. 5040 - // The flow: renderer registers shortcut via IPC -> main stores callback with ev.reply -> 5041 - // shortcut fires -> callback calls ev.reply(replyTopic) -> renderer ipcRenderer.on fires cb. 5042 - // 5043 - // Since Playwright keyboard.press does NOT reliably trigger Electron's before-input-event, 5044 - // we trigger the shortcut callback by calling handleLocalShortcut from the main process 5045 - // via evaluateMain with a synthetic input event. 5046 - // ============================================================================ 5047 - 5048 - test.describe('Shortcut Roundtrip @desktop', () => { 5049 - let app: DesktopApp; 5050 - let bgWindow: Page; 5051 - 5052 - test.beforeAll(async () => { 5053 - ({ app, bgWindow } = await createPerDescribeApp('shortcut-roundtrip')); 5054 - }); 5055 - 5056 - test.afterAll(async () => { 5057 - if (app) await app.close(); 5058 - }); 5059 - 5060 - test('local shortcut from background window roundtrip', async () => { 5061 - // Register a local shortcut from bgWindow, trigger it via handleLocalShortcut 5062 - // in the main process, verify callback fires in the renderer. 5063 - // This tests the basic ev.reply roundtrip for a normal BrowserWindow WebContents. 5064 - 5065 - // Register a local shortcut from the bgWindow 5066 - await bgWindow.evaluate(() => { 5067 - (window as any).__shortcutFired = false; 5068 - (window as any).app.shortcuts.register('Alt+F7', () => { 5069 - (window as any).__shortcutFired = true; 5070 - }); 5071 - }); 5072 - 5073 - // Wait for IPC registration to propagate to main process 5074 - await sleep(300); 5075 - 5076 - // Trigger the shortcut from the main process by calling handleLocalShortcut 5077 - // with a synthetic input event matching Alt+F7. 5078 - // NOTE: handleLocalShortcut invokes the stored callback synchronously, which 5079 - // in turn calls ev.reply() to send an IPC message back to the renderer. The 5080 - // ev.reply is an async side-effect that can cause Playwright to see the main 5081 - // process "evaluate" context as destroyed if we return the raw result. To 5082 - // avoid this flakiness, wrap the call in setImmediate + return via a 5083 - // pre-computed flag so the evaluate settles cleanly before IPC fans out. 5084 - const handled = await app.evaluateMain!(({ app }) => { 5085 - try { 5086 - const { handleLocalShortcut } = (globalThis as any).__peek_test; 5087 - const result = handleLocalShortcut({ 5088 - type: 'keyDown', 5089 - alt: true, 5090 - shift: false, 5091 - meta: false, 5092 - control: false, 5093 - code: 'F7' 5094 - }); 5095 - return !!result; 5096 - } catch (e: any) { 5097 - return 'peek_test-failed: ' + e.message; 5098 - } 5099 - }).catch((err: any) => { 5100 - // Playwright sometimes reports "Execution context was destroyed" when the 5101 - // shortcut callback fans out async IPC (ev.reply) as a side-effect of the 5102 - // evaluate. The shortcut still fires — the waitForFunction below will 5103 - // confirm it. Treat this as a soft success. 5104 - if (/context was destroyed/i.test(err?.message || '')) return true; 5105 - throw err; 5106 - }); 5107 - 5108 - // handleLocalShortcut should return true (shortcut was found and callback invoked) 5109 - expect(handled).toBe(true); 5110 - 5111 - // Wait for the reply to reach the renderer and trigger the callback 5112 - await bgWindow.waitForFunction( 5113 - () => (window as any).__shortcutFired === true, 5114 - { timeout: 5000 } 5115 - ); 5116 - 5117 - const fired = await bgWindow.evaluate(() => (window as any).__shortcutFired); 5118 - expect(fired).toBe(true); 5119 - 5120 - // Clean up 5121 - await bgWindow.evaluate(() => { 5122 - (window as any).app.shortcuts.unregister('Alt+F7'); 5123 - delete (window as any).__shortcutFired; 5124 - }); 5125 - }); 5126 - 5127 - }); 5128 - 5129 - // ============================================================================ 5130 - // Scripts Extension Tests (uses shared app) 5131 - // ============================================================================ 5132 - 5133 - test.describe('Scripts Extension @desktop', () => { 5134 - let app: DesktopApp; 5135 - let bgWindow: Page; 5136 - 5137 - test.beforeAll(async () => { 5138 - ({ app, bgWindow } = await createPerDescribeApp('scripts')); 5139 - }); 5140 - 5141 - test.afterAll(async () => { 5142 - if (app) await app.close(); 5143 - }); 5144 - 5145 - test('create, save, and execute script', async () => { 5146 - // Wait for scripts extension to be ready 5147 - await waitForExtensionsReady(bgWindow, 15000); 5148 - 5149 - // Create a new script directly via datastore 5150 - const scriptId = await bgWindow.evaluate(async () => { 5151 - const api = (window as any).app; 5152 - const scriptId = `script_test_${Date.now()}`; 5153 - 5154 - // Get current settings from datastore 5155 - const settingsTable = await api.datastore.getTable('feature_settings'); 5156 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5157 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5158 - 5159 - // Add new script 5160 - const newScript = { 5161 - id: scriptId, 5162 - name: 'Test Script', 5163 - description: 'A test script', 5164 - code: 'const h1 = document.querySelector("h1"); return { title: h1?.textContent || "No h1 found" };', 5165 - matchPatterns: ['https://example.com/*'], 5166 - excludePatterns: [], 5167 - runAt: 'document-end', 5168 - enabled: true, 5169 - createdAt: Date.now(), 5170 - updatedAt: Date.now(), 5171 - lastExecutedAt: null 5172 - }; 5173 - 5174 - scripts.push(newScript); 5175 - 5176 - // Save back to datastore 5177 - await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5178 - featureId: 'scripts', 5179 - key: 'scripts', 5180 - value: JSON.stringify(scripts), 5181 - updatedAt: Date.now() 5182 - }); 5183 - 5184 - return scriptId; 5185 - }); 5186 - 5187 - expect(scriptId).toBeTruthy(); 5188 - 5189 - // Verify script was saved 5190 - const savedScript = await bgWindow.evaluate(async (scriptId) => { 5191 - const api = (window as any).app; 5192 - const settingsTable = await api.datastore.getTable('feature_settings'); 5193 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5194 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5195 - return scripts.find((s: any) => s.id === scriptId); 5196 - }, scriptId); 5197 - 5198 - expect(savedScript).toBeTruthy(); 5199 - expect(savedScript.name).toBe('Test Script'); 5200 - 5201 - // Execute script - test executor directly 5202 - const executeResult = await bgWindow.evaluate(async (scriptId) => { 5203 - const api = (window as any).app; 5204 - const { scriptExecutor } = await import('peek://scripts/script-executor.js'); 5205 - 5206 - // Get the script from datastore 5207 - const settingsTable = await api.datastore.getTable('feature_settings'); 5208 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5209 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5210 - const script = scripts.find((s: any) => s.id === scriptId); 5211 - 5212 - if (!script) { 5213 - return { success: false, error: 'Script not found' }; 5214 - } 5215 - 5216 - // Execute directly 5217 - const result = await scriptExecutor.executeScript(script, { 5218 - url: 'https://example.com/test', 5219 - pageDOM: document, 5220 - pageWindow: window 5221 - }); 5222 - 5223 - return { success: true, data: result }; 5224 - }, scriptId); 5225 - 5226 - expect(executeResult).toHaveProperty('success', true); 5227 - expect((executeResult as any).data.status).toBe('success'); 5228 - 5229 - // Clean up - delete script 5230 - await bgWindow.evaluate(async (scriptId) => { 5231 - const api = (window as any).app; 5232 - const settingsTable = await api.datastore.getTable('feature_settings'); 5233 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5234 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5235 - const filtered = scripts.filter((s: any) => s.id !== scriptId); 5236 - await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5237 - featureId: 'scripts', 5238 - key: 'scripts', 5239 - value: JSON.stringify(filtered), 5240 - updatedAt: Date.now() 5241 - }); 5242 - }, scriptId); 5243 - }); 5244 - 5245 - test('script pattern matching works', async () => { 5246 - // Test pattern matching directly 5247 - const patternTests = await bgWindow.evaluate(async () => { 5248 - // Import the script executor module 5249 - const { ScriptExecutor } = await import('peek://scripts/script-executor.js'); 5250 - const executor = new ScriptExecutor(); 5251 - 5252 - return { 5253 - exactMatch: executor.matchPattern('https://example.com/*', 'https://example.com/page'), 5254 - noMatch: executor.matchPattern('https://example.com/*', 'https://other.com/page'), 5255 - wildcardProtocol: executor.matchPattern('*://example.com/*', 'https://example.com/page'), 5256 - wildcardAll: executor.matchPattern('*', 'https://anything.com/page') 5257 - }; 5258 - }); 5259 - 5260 - expect(patternTests.exactMatch).toBe(true); 5261 - expect(patternTests.noMatch).toBe(false); 5262 - expect(patternTests.wildcardProtocol).toBe(true); 5263 - expect(patternTests.wildcardAll).toBe(true); 5264 - }); 5265 - 5266 - test('script timeout protection works', async () => { 5267 - // Create a script that runs forever 5268 - const scriptId = await bgWindow.evaluate(async () => { 5269 - const api = (window as any).app; 5270 - const scriptId = `script_timeout_test_${Date.now()}`; 5271 - 5272 - // Get current settings from datastore 5273 - const settingsTable = await api.datastore.getTable('feature_settings'); 5274 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5275 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5276 - 5277 - // Add timeout test script 5278 - const newScript = { 5279 - id: scriptId, 5280 - name: 'Timeout Test', 5281 - code: 'while(true) {}', // Infinite loop 5282 - matchPatterns: ['*'], 5283 - excludePatterns: [], 5284 - runAt: 'document-end', 5285 - enabled: true, 5286 - createdAt: Date.now(), 5287 - updatedAt: Date.now(), 5288 - lastExecutedAt: null 5289 - }; 5290 - 5291 - scripts.push(newScript); 5292 - await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5293 - featureId: 'scripts', 5294 - key: 'scripts', 5295 - value: JSON.stringify(scripts), 5296 - updatedAt: Date.now() 5297 - }); 5298 - 5299 - return scriptId; 5300 - }); 5301 - 5302 - // Execute with short timeout - test executor directly 5303 - const executeResult = await bgWindow.evaluate(async (scriptId) => { 5304 - const api = (window as any).app; 5305 - const { scriptExecutor } = await import('peek://scripts/script-executor.js'); 5306 - 5307 - // Get the script from datastore 5308 - const settingsTable = await api.datastore.getTable('feature_settings'); 5309 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5310 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5311 - const script = scripts.find((s: any) => s.id === scriptId); 5312 - 5313 - if (!script) { 5314 - return { success: false, error: 'Script not found' }; 5315 - } 5316 - 5317 - // Execute directly with timeout 5318 - const result = await scriptExecutor.executeScript(script, { 5319 - url: 'https://example.com', 5320 - pageDOM: document, 5321 - pageWindow: window, 5322 - timeout: 100 // 100ms timeout 5323 - }); 5324 - 5325 - return { success: true, data: result }; 5326 - }, scriptId); 5327 - 5328 - expect(executeResult).toHaveProperty('success', true); 5329 - expect((executeResult as any).data.status).toBe('error'); 5330 - expect((executeResult as any).data.error).toContain('timeout'); 5331 - 5332 - // Clean up 5333 - await bgWindow.evaluate(async (scriptId) => { 5334 - const api = (window as any).app; 5335 - const settingsTable = await api.datastore.getTable('feature_settings'); 5336 - const scriptsRow = settingsTable.data?.['scripts:scripts']; 5337 - const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5338 - const filtered = scripts.filter((s: any) => s.id !== scriptId); 5339 - await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5340 - featureId: 'scripts', 5341 - key: 'scripts', 5342 - value: JSON.stringify(filtered), 5343 - updatedAt: Date.now() 5344 - }); 5345 - }, scriptId); 5346 - }); 5347 - });
+109
tests/desktop/startup-events.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { waitForExtensionsReady } from '../helpers/window-utils'; 5 + 6 + test.describe('Startup Phase Events @desktop', () => { 7 + let app: DesktopApp; 8 + let bgWindow: Page; 9 + 10 + test.beforeAll(async () => { 11 + ({ app, bgWindow } = await createPerDescribeApp('startup-phase')); 12 + // Wait for extensions to be fully ready 13 + await waitForExtensionsReady(bgWindow); 14 + }); 15 + 16 + test.afterAll(async () => { 17 + if (app) await app.close(); 18 + }); 19 + 20 + test('ext:startup:phase events are available for subscription', async () => { 21 + // Test that extensions can subscribe to startup phase events 22 + // Since app is already started, we test that the subscription mechanism works 23 + const result = await bgWindow.evaluate(async () => { 24 + const api = (window as any).app; 25 + let received = false; 26 + 27 + // Subscribe to startup phase events 28 + api.subscribe('ext:startup:phase', (msg: any) => { 29 + received = true; 30 + }, api.scopes.GLOBAL); 31 + 32 + // The subscription should be set up without error 33 + return { subscriptionCreated: true }; 34 + }); 35 + 36 + expect(result.subscriptionCreated).toBe(true); 37 + }); 38 + 39 + test('ext:all-loaded event was published during startup', async () => { 40 + // Verify that the ext:all-loaded event was published by checking extensions are running 41 + const result = await bgWindow.evaluate(async () => { 42 + const api = (window as any).app; 43 + 44 + // Get running extensions - if they're running, ext:all-loaded was published 45 + const extResult = await api.extensions.list(); 46 + const extensions = extResult.data || []; 47 + return { 48 + success: extResult.success, 49 + extensionCount: extensions.length, 50 + hasCmd: extensions.some((e: any) => e.id === 'cmd'), 51 + hasGroups: extensions.some((e: any) => e.id === 'groups') 52 + }; 53 + }); 54 + 55 + expect(result.success).toBe(true); 56 + expect(result.extensionCount).toBeGreaterThan(0); 57 + expect(result.hasCmd).toBe(true); 58 + }); 59 + 60 + test('cmd extension loads before other extensions can register commands', async () => { 61 + // Verify that cmd is running and accepting commands (which means it loaded first) 62 + // Use inline retry approach that works reliably 63 + const result = await bgWindow.evaluate(async () => { 64 + const api = (window as any).app; 65 + 66 + const queryCommands = () => new Promise((resolve) => { 67 + api.subscribe('cmd:query-commands-response', (msg: any) => { 68 + resolve(msg.commands || []); 69 + }, api.scopes.GLOBAL); 70 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 71 + setTimeout(() => resolve([]), 1000); 72 + }); 73 + 74 + // Retry a few times to allow extensions to finish loading 75 + for (let i = 0; i < 5; i++) { 76 + const cmds = await queryCommands() as any[]; 77 + if (cmds.some((c: any) => c.name === 'example:gallery')) { 78 + return cmds; 79 + } 80 + await new Promise(r => setTimeout(r, 500)); 81 + } 82 + return await queryCommands(); 83 + }); 84 + 85 + expect(Array.isArray(result)).toBe(true); 86 + expect(result.length).toBeGreaterThan(0); 87 + // gallery command from example extension should be registered 88 + const hasGalleryCommand = result.some((c: any) => c.name === 'example:gallery'); 89 + expect(hasGalleryCommand).toBe(true); 90 + }); 91 + 92 + test('cmd extension is always running (cannot be disabled)', async () => { 93 + // cmd is required infrastructure - verify it's always in the running extensions list 94 + const result = await bgWindow.evaluate(async () => { 95 + const api = (window as any).app; 96 + const runningExts = await api.extensions.list(); 97 + return { 98 + success: runningExts.success, 99 + extensions: runningExts.data || [], 100 + cmdRunning: runningExts.data?.some((ext: any) => ext.id === 'cmd'), 101 + cmdStatus: runningExts.data?.find((ext: any) => ext.id === 'cmd')?.status 102 + }; 103 + }); 104 + 105 + expect(result.success).toBe(true); 106 + expect(result.cmdRunning).toBe(true); 107 + expect(result.cmdStatus).toBe('running'); 108 + }); 109 + });
+212
tests/desktop/tag-command.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Tag Command @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('tag-cmd')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('creates address if not exists when tagging', async () => { 18 + // This tests the bug fix: addResult.data.id instead of addResult.id 19 + // Use unique URI to avoid conflicts with other tests 20 + // Note: datastore normalizes URLs (adds trailing slash) 21 + const timestamp = Date.now(); 22 + const testUri = `https://tag-test-new-address-${timestamp}.example.com/`; 23 + 24 + // Create tag with unique name 25 + const tagResult = await bgWindow.evaluate(async (ts: number) => { 26 + return await (window as any).app.datastore.getOrCreateTag('test-new-addr-tag-' + ts); 27 + }, timestamp); 28 + expect(tagResult.success).toBe(true); 29 + const tagId = tagResult.data?.tag?.id; 30 + expect(tagId).toBeTruthy(); 31 + 32 + // Create address 33 + const addResult = await bgWindow.evaluate(async (uri: string) => { 34 + return await (window as any).app.datastore.addAddress(uri, { title: 'New Tagged Address' }); 35 + }, testUri); 36 + expect(addResult.success).toBe(true); 37 + // Bug fix verification: data.id is the correct path 38 + expect(addResult.data?.id).toBeTruthy(); 39 + 40 + // Tag the address using the correct id path 41 + const linkResult = await bgWindow.evaluate(async ({ addressId, tagId }) => { 42 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 43 + }, { addressId: addResult.data.id, tagId }); 44 + expect(linkResult.success).toBe(true); 45 + 46 + // Verify address is tagged 47 + const taggedAddresses = await bgWindow.evaluate(async (tId: string) => { 48 + return await (window as any).app.datastore.getAddressesByTag(tId); 49 + }, tagId); 50 + expect(taggedAddresses.success).toBe(true); 51 + expect(taggedAddresses.data.some((a: any) => a.uri === testUri)).toBe(true); 52 + }); 53 + 54 + test('getOrCreateTag returns tag in data.tag', async () => { 55 + // This tests the bug fix: tagResult.data.tag.id instead of tagResult.data.id 56 + const tagName = 'test-nested-tag-response'; 57 + 58 + const result = await bgWindow.evaluate(async (name: string) => { 59 + return await (window as any).app.datastore.getOrCreateTag(name); 60 + }, tagName); 61 + 62 + expect(result.success).toBe(true); 63 + // Bug fix verification: tag is nested in data.tag 64 + expect(result.data?.tag).toBeTruthy(); 65 + expect(result.data?.tag?.id).toBeTruthy(); 66 + expect(result.data?.tag?.name).toBe(tagName); 67 + expect(typeof result.data?.created).toBe('boolean'); 68 + }); 69 + 70 + test('tagAddress links tag to address correctly', async () => { 71 + // Create address 72 + const addr = await bgWindow.evaluate(async () => { 73 + return await (window as any).app.datastore.addAddress('https://tag-link-test.example.com', { 74 + title: 'Tag Link Test' 75 + }); 76 + }); 77 + expect(addr.success).toBe(true); 78 + 79 + // Create tag 80 + const tag = await bgWindow.evaluate(async () => { 81 + return await (window as any).app.datastore.getOrCreateTag('link-test-tag'); 82 + }); 83 + expect(tag.success).toBe(true); 84 + 85 + // Link them 86 + const link = await bgWindow.evaluate(async ({ addressId, tagId }) => { 87 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 88 + }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 89 + expect(link.success).toBe(true); 90 + 91 + // Verify link exists 92 + const addressTags = await bgWindow.evaluate(async (addressId: string) => { 93 + return await (window as any).app.datastore.getAddressTags(addressId); 94 + }, addr.data.id); 95 + expect(addressTags.success).toBe(true); 96 + expect(addressTags.data.some((t: any) => t.name === 'link-test-tag')).toBe(true); 97 + }); 98 + 99 + test('multiple tags can be added to same address', async () => { 100 + // Create address 101 + const addr = await bgWindow.evaluate(async () => { 102 + return await (window as any).app.datastore.addAddress('https://multi-tag-test.example.com', { 103 + title: 'Multi Tag Test' 104 + }); 105 + }); 106 + expect(addr.success).toBe(true); 107 + 108 + // Create and link multiple tags 109 + const tagNames = ['multi-tag-1', 'multi-tag-2', 'multi-tag-3']; 110 + 111 + for (const tagName of tagNames) { 112 + const tag = await bgWindow.evaluate(async (name: string) => { 113 + return await (window as any).app.datastore.getOrCreateTag(name); 114 + }, tagName); 115 + expect(tag.success).toBe(true); 116 + 117 + const link = await bgWindow.evaluate(async ({ addressId, tagId }) => { 118 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 119 + }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 120 + expect(link.success).toBe(true); 121 + } 122 + 123 + // Verify all tags are linked 124 + const addressTags = await bgWindow.evaluate(async (addressId: string) => { 125 + return await (window as any).app.datastore.getAddressTags(addressId); 126 + }, addr.data.id); 127 + expect(addressTags.success).toBe(true); 128 + expect(addressTags.data.length).toBeGreaterThanOrEqual(3); 129 + 130 + for (const tagName of tagNames) { 131 + expect(addressTags.data.some((t: any) => t.name === tagName)).toBe(true); 132 + } 133 + }); 134 + 135 + test('untagAddress removes tag from address', async () => { 136 + // Create address 137 + const addr = await bgWindow.evaluate(async () => { 138 + return await (window as any).app.datastore.addAddress('https://untag-test.example.com', { 139 + title: 'Untag Test' 140 + }); 141 + }); 142 + expect(addr.success).toBe(true); 143 + 144 + // Create and link tag 145 + const tag = await bgWindow.evaluate(async () => { 146 + return await (window as any).app.datastore.getOrCreateTag('untag-test-tag'); 147 + }); 148 + expect(tag.success).toBe(true); 149 + 150 + await bgWindow.evaluate(async ({ addressId, tagId }) => { 151 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 152 + }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 153 + 154 + // Verify tag is linked 155 + let addressTags = await bgWindow.evaluate(async (addressId: string) => { 156 + return await (window as any).app.datastore.getAddressTags(addressId); 157 + }, addr.data.id); 158 + expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(true); 159 + 160 + // Remove tag 161 + const untag = await bgWindow.evaluate(async ({ addressId, tagId }) => { 162 + return await (window as any).app.datastore.untagAddress(addressId, tagId); 163 + }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 164 + expect(untag.success).toBe(true); 165 + 166 + // Verify tag is removed 167 + addressTags = await bgWindow.evaluate(async (addressId: string) => { 168 + return await (window as any).app.datastore.getAddressTags(addressId); 169 + }, addr.data.id); 170 + expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(false); 171 + }); 172 + 173 + test('getUntaggedAddresses returns addresses without tags', async () => { 174 + // Use unique URI to avoid conflicts 175 + // Note: datastore normalizes URLs (adds trailing slash) 176 + const timestamp = Date.now(); 177 + const testUri = `https://untagged-test-${timestamp}.example.com/`; 178 + 179 + // Create address without tagging it 180 + const addr = await bgWindow.evaluate(async (uri: string) => { 181 + return await (window as any).app.datastore.addAddress(uri, { 182 + title: 'Untagged Test' 183 + }); 184 + }, testUri); 185 + expect(addr.success).toBe(true); 186 + expect(addr.data?.id).toBeTruthy(); 187 + 188 + // Query untagged addresses 189 + const untagged = await bgWindow.evaluate(async () => { 190 + return await (window as any).app.datastore.getUntaggedAddresses(); 191 + }); 192 + expect(untagged.success).toBe(true); 193 + expect(untagged.data.some((a: any) => a.uri === testUri)).toBe(true); 194 + 195 + // Tag the address with unique tag name 196 + const tag = await bgWindow.evaluate(async (ts: number) => { 197 + return await (window as any).app.datastore.getOrCreateTag('now-tagged-' + ts); 198 + }, timestamp); 199 + expect(tag.success).toBe(true); 200 + expect(tag.data?.tag?.id).toBeTruthy(); 201 + 202 + await bgWindow.evaluate(async ({ addressId, tagId }) => { 203 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 204 + }, { addressId: addr.data.id, tagId: tag.data.tag.id }); 205 + 206 + // Verify it's no longer in untagged list 207 + const untaggedAfter = await bgWindow.evaluate(async () => { 208 + return await (window as any).app.datastore.getUntaggedAddresses(); 209 + }); 210 + expect(untaggedAfter.data.some((a: any) => a.uri === testUri)).toBe(false); 211 + }); 212 + });
+209
tests/desktop/tag-events.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Tag Events @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('tag-events')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('tag:created is emitted when new tag is created', async () => { 18 + const timestamp = Date.now(); 19 + const tagName = `event-test-tag-${timestamp}`; 20 + 21 + const result = await bgWindow.evaluate(async (name: string) => { 22 + const api = (window as any).app; 23 + 24 + return new Promise((resolve) => { 25 + const timeout = setTimeout(() => { 26 + resolve({ received: false }); 27 + }, 5000); 28 + 29 + api.subscribe('tag:created', (msg: any) => { 30 + if (msg.tagName === name) { 31 + clearTimeout(timeout); 32 + resolve({ 33 + received: true, 34 + tagId: msg.tagId, 35 + tagName: msg.tagName 36 + }); 37 + } 38 + }, api.scopes.GLOBAL); 39 + 40 + // Create new tag to trigger the event 41 + api.datastore.getOrCreateTag(name); 42 + }); 43 + }, tagName); 44 + 45 + expect((result as any).received).toBe(true); 46 + expect((result as any).tagName).toBe(tagName); 47 + expect((result as any).tagId).toBeTruthy(); 48 + }); 49 + 50 + test('tag:item-added is emitted when item is tagged', async () => { 51 + const timestamp = Date.now(); 52 + const tagName = `item-added-event-tag-${timestamp}`; 53 + 54 + const result = await bgWindow.evaluate(async (name: string) => { 55 + const api = (window as any).app; 56 + 57 + // First create an item and a tag 58 + const itemResult = await api.datastore.addItem('url', { 59 + content: `https://tag-event-test-${Date.now()}.example.com`, 60 + metadata: JSON.stringify({ title: 'Tag Event Test Item' }) 61 + }); 62 + if (!itemResult.success) { 63 + return { received: false, error: 'failed to create item' }; 64 + } 65 + const itemId = itemResult.data.id; 66 + 67 + const tagResult = await api.datastore.getOrCreateTag(name); 68 + if (!tagResult.success) { 69 + return { received: false, error: 'failed to create tag' }; 70 + } 71 + const tagId = tagResult.data.tag.id; 72 + 73 + return new Promise((resolve) => { 74 + const timeout = setTimeout(() => { 75 + resolve({ received: false, error: 'timeout' }); 76 + }, 5000); 77 + 78 + api.subscribe('tag:item-added', (msg: any) => { 79 + if (msg.itemId === itemId && msg.tagId === tagId) { 80 + clearTimeout(timeout); 81 + resolve({ 82 + received: true, 83 + tagId: msg.tagId, 84 + tagName: msg.tagName, 85 + itemId: msg.itemId, 86 + itemType: msg.itemType 87 + }); 88 + } 89 + }, api.scopes.GLOBAL); 90 + 91 + // Tag the item to trigger the event 92 + api.datastore.tagItem(itemId, tagId); 93 + }); 94 + }, tagName); 95 + 96 + expect((result as any).received).toBe(true); 97 + expect((result as any).tagName).toBe(tagName); 98 + expect((result as any).tagId).toBeTruthy(); 99 + expect((result as any).itemId).toBeTruthy(); 100 + expect((result as any).itemType).toBe('url'); 101 + }); 102 + 103 + test('tag:item-removed is emitted when item is untagged', async () => { 104 + const timestamp = Date.now(); 105 + const tagName = `item-removed-event-tag-${timestamp}`; 106 + 107 + const result = await bgWindow.evaluate(async (name: string) => { 108 + const api = (window as any).app; 109 + 110 + // Create an item 111 + const itemResult = await api.datastore.addItem('url', { 112 + content: `https://untag-event-test-${Date.now()}.example.com`, 113 + metadata: JSON.stringify({ title: 'Untag Event Test Item' }) 114 + }); 115 + if (!itemResult.success) { 116 + return { received: false, error: 'failed to create item' }; 117 + } 118 + const itemId = itemResult.data.id; 119 + 120 + // Create a tag 121 + const tagResult = await api.datastore.getOrCreateTag(name); 122 + if (!tagResult.success) { 123 + return { received: false, error: 'failed to create tag' }; 124 + } 125 + const tagId = tagResult.data.tag.id; 126 + 127 + // Tag the item first 128 + await api.datastore.tagItem(itemId, tagId); 129 + 130 + return new Promise((resolve) => { 131 + const timeout = setTimeout(() => { 132 + resolve({ received: false, error: 'timeout' }); 133 + }, 5000); 134 + 135 + api.subscribe('tag:item-removed', (msg: any) => { 136 + if (msg.itemId === itemId && msg.tagId === tagId) { 137 + clearTimeout(timeout); 138 + resolve({ 139 + received: true, 140 + tagId: msg.tagId, 141 + tagName: msg.tagName, 142 + itemId: msg.itemId 143 + }); 144 + } 145 + }, api.scopes.GLOBAL); 146 + 147 + // Untag the item to trigger the event 148 + api.datastore.untagItem(itemId, tagId); 149 + }); 150 + }, tagName); 151 + 152 + expect((result as any).received).toBe(true); 153 + expect((result as any).tagName).toBe(tagName); 154 + expect((result as any).tagId).toBeTruthy(); 155 + expect((result as any).itemId).toBeTruthy(); 156 + }); 157 + 158 + test('tag:item-added is NOT emitted for duplicate tag', async () => { 159 + const timestamp = Date.now(); 160 + const tagName = `duplicate-tag-event-${timestamp}`; 161 + 162 + const result = await bgWindow.evaluate(async (name: string) => { 163 + const api = (window as any).app; 164 + 165 + // Create an item 166 + const itemResult = await api.datastore.addItem('url', { 167 + content: `https://duplicate-tag-test-${Date.now()}.example.com`, 168 + metadata: JSON.stringify({ title: 'Duplicate Tag Test Item' }) 169 + }); 170 + if (!itemResult.success) { 171 + return { received: false, error: 'failed to create item' }; 172 + } 173 + const itemId = itemResult.data.id; 174 + 175 + // Create a tag 176 + const tagResult = await api.datastore.getOrCreateTag(name); 177 + if (!tagResult.success) { 178 + return { received: false, error: 'failed to create tag' }; 179 + } 180 + const tagId = tagResult.data.tag.id; 181 + 182 + // Tag the item for the first time (this should emit an event but we don't care) 183 + await api.datastore.tagItem(itemId, tagId); 184 + 185 + // Wait a bit to ensure the first event has been processed 186 + await new Promise(r => setTimeout(r, 100)); 187 + 188 + return new Promise((resolve) => { 189 + // Use a short timeout since we expect NO event 190 + const timeout = setTimeout(() => { 191 + resolve({ received: false }); // This is the expected outcome 192 + }, 1000); 193 + 194 + api.subscribe('tag:item-added', (msg: any) => { 195 + if (msg.itemId === itemId && msg.tagId === tagId) { 196 + clearTimeout(timeout); 197 + resolve({ received: true }); // This would be unexpected 198 + } 199 + }, api.scopes.GLOBAL); 200 + 201 + // Try to tag the same item with the same tag again 202 + api.datastore.tagItem(itemId, tagId); 203 + }); 204 + }, tagName); 205 + 206 + // We expect NO event to be received for duplicate tagging 207 + expect((result as any).received).toBe(false); 208 + }); 209 + });
+150
tests/desktop/themes.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + test.describe('Themes @desktop', () => { 6 + let app: DesktopApp; 7 + let bgWindow: Page; 8 + 9 + test.beforeAll(async () => { 10 + ({ app, bgWindow } = await createPerDescribeApp('themes')); 11 + }); 12 + 13 + test.afterAll(async () => { 14 + if (app) await app.close(); 15 + }); 16 + 17 + test('theme API is available', async () => { 18 + const hasThemeApi = await bgWindow.evaluate(() => { 19 + const api = (window as any).app; 20 + return !!(api.theme && api.theme.get && api.theme.setTheme && api.theme.getAll); 21 + }); 22 + expect(hasThemeApi).toBe(true); 23 + }); 24 + 25 + test('get current theme state', async () => { 26 + const themeState = await bgWindow.evaluate(async () => { 27 + return await (window as any).app.theme.get(); 28 + }); 29 + 30 + expect(themeState).toBeTruthy(); 31 + expect(themeState.themeId).toBeTruthy(); 32 + expect(themeState.colorScheme).toBeTruthy(); 33 + expect(['system', 'light', 'dark']).toContain(themeState.colorScheme); 34 + expect(typeof themeState.isDark).toBe('boolean'); 35 + expect(['light', 'dark']).toContain(themeState.effectiveScheme); 36 + }); 37 + 38 + test('list available themes', async () => { 39 + const result = await bgWindow.evaluate(async () => { 40 + return await (window as any).app.theme.getAll(); 41 + }); 42 + 43 + expect(result.success).toBe(true); 44 + expect(Array.isArray(result.data)).toBe(true); 45 + expect(result.data.length).toBeGreaterThanOrEqual(2); // basic and peek 46 + 47 + // Verify built-in themes exist 48 + const themeIds = result.data.map((t: any) => t.id); 49 + expect(themeIds).toContain('basic'); 50 + expect(themeIds).toContain('peek'); 51 + 52 + // Verify theme structure 53 + for (const theme of result.data) { 54 + expect(theme.id).toBeTruthy(); 55 + expect(theme.name).toBeTruthy(); 56 + expect(theme.version).toBeTruthy(); 57 + } 58 + }); 59 + 60 + test('switch themes', async () => { 61 + // Get initial theme 62 + const initialState = await bgWindow.evaluate(async () => { 63 + return await (window as any).app.theme.get(); 64 + }); 65 + 66 + // Switch to a different theme 67 + const targetTheme = initialState.themeId === 'basic' ? 'peek' : 'basic'; 68 + const switchResult = await bgWindow.evaluate(async (themeId: string) => { 69 + return await (window as any).app.theme.setTheme(themeId); 70 + }, targetTheme); 71 + 72 + expect(switchResult.success).toBe(true); 73 + expect(switchResult.themeId).toBe(targetTheme); 74 + 75 + // Verify theme changed 76 + const newState = await bgWindow.evaluate(async () => { 77 + return await (window as any).app.theme.get(); 78 + }); 79 + expect(newState.themeId).toBe(targetTheme); 80 + 81 + // Switch back to original 82 + await bgWindow.evaluate(async (themeId: string) => { 83 + return await (window as any).app.theme.setTheme(themeId); 84 + }, initialState.themeId); 85 + }); 86 + 87 + test('switch color scheme', async () => { 88 + // Get initial state 89 + const initialState = await bgWindow.evaluate(async () => { 90 + return await (window as any).app.theme.get(); 91 + }); 92 + 93 + // Switch to light mode 94 + const lightResult = await bgWindow.evaluate(async () => { 95 + return await (window as any).app.theme.setColorScheme('light'); 96 + }); 97 + expect(lightResult.success).toBe(true); 98 + expect(lightResult.colorScheme).toBe('light'); 99 + 100 + // Verify it changed 101 + let state = await bgWindow.evaluate(async () => { 102 + return await (window as any).app.theme.get(); 103 + }); 104 + expect(state.colorScheme).toBe('light'); 105 + expect(state.effectiveScheme).toBe('light'); 106 + 107 + // Switch to dark mode 108 + const darkResult = await bgWindow.evaluate(async () => { 109 + return await (window as any).app.theme.setColorScheme('dark'); 110 + }); 111 + expect(darkResult.success).toBe(true); 112 + expect(darkResult.colorScheme).toBe('dark'); 113 + 114 + state = await bgWindow.evaluate(async () => { 115 + return await (window as any).app.theme.get(); 116 + }); 117 + expect(state.colorScheme).toBe('dark'); 118 + expect(state.effectiveScheme).toBe('dark'); 119 + 120 + // Switch back to system 121 + const systemResult = await bgWindow.evaluate(async () => { 122 + return await (window as any).app.theme.setColorScheme('system'); 123 + }); 124 + expect(systemResult.success).toBe(true); 125 + expect(systemResult.colorScheme).toBe('system'); 126 + 127 + // Restore original color scheme 128 + await bgWindow.evaluate(async (scheme: string) => { 129 + return await (window as any).app.theme.setColorScheme(scheme); 130 + }, initialState.colorScheme); 131 + }); 132 + 133 + test('invalid theme returns error', async () => { 134 + const result = await bgWindow.evaluate(async () => { 135 + return await (window as any).app.theme.setTheme('nonexistent-theme'); 136 + }); 137 + 138 + expect(result.success).toBe(false); 139 + expect(result.error).toBeTruthy(); 140 + }); 141 + 142 + test('invalid color scheme returns error', async () => { 143 + const result = await bgWindow.evaluate(async () => { 144 + return await (window as any).app.theme.setColorScheme('invalid'); 145 + }); 146 + 147 + expect(result.success).toBe(false); 148 + expect(result.error).toBeTruthy(); 149 + }); 150 + });
+171
tests/desktop/window-targeting.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + import { sleep } from '../helpers/window-utils'; 5 + 6 + // ============================================================================ 7 + // Window Targeting Tests 8 + // ============================================================================ 9 + // Tests for the window focus tracking system that enables per-window commands 10 + // like "theme dark here" to target the correct window. 11 + // 12 + // Key behavior: Modal windows (like cmd palette) should NOT update the 13 + // "last focused visible window" tracker, so commands target the window 14 + // the user was looking at before opening the palette. 15 + 16 + test.describe('Window Targeting @desktop', () => { 17 + let app: DesktopApp; 18 + let bgWindow: Page; 19 + 20 + test.beforeAll(async () => { 21 + ({ app, bgWindow } = await createPerDescribeApp('window-targeting')); 22 + await sleep(500); // Wait for app to stabilize 23 + }); 24 + 25 + test.afterAll(async () => { 26 + if (app) await app.close(); 27 + }); 28 + 29 + test('setWindowColorScheme returns success with windowId', async () => { 30 + // Test that setWindowColorScheme works and returns expected data 31 + const result = await bgWindow.evaluate(async () => { 32 + const api = (window as any).app; 33 + 34 + // Open a test window (non-modal) to have a valid target 35 + const winResult = await api.window.open('peek://app/settings/settings.html', { 36 + width: 400, 37 + height: 300, 38 + modal: false, 39 + key: 'test-theme-window-1' 40 + }); 41 + 42 + if (!winResult.success) { 43 + return { success: false, error: 'Failed to open window' }; 44 + } 45 + 46 + // Wait for window to be ready and focused 47 + await new Promise(r => setTimeout(r, 300)); 48 + 49 + // Call setWindowColorScheme 50 + const themeResult = await api.theme.setWindowColorScheme('dark'); 51 + 52 + // Clean up 53 + try { 54 + await api.window.close(winResult.id); 55 + } catch (e) { 56 + // Ignore close errors 57 + } 58 + 59 + return { 60 + success: themeResult.success, 61 + windowId: themeResult.windowId, 62 + colorScheme: themeResult.colorScheme, 63 + error: themeResult.error 64 + }; 65 + }); 66 + 67 + expect(result.success).toBe(true); 68 + expect(result.colorScheme).toBe('dark'); 69 + expect(typeof result.windowId).toBe('number'); 70 + }); 71 + 72 + test('modal window does not become theme target', async () => { 73 + // This test verifies that opening a modal window after a non-modal window 74 + // still allows setWindowColorScheme to target the non-modal window 75 + const result = await bgWindow.evaluate(async () => { 76 + const api = (window as any).app; 77 + 78 + // Open a non-modal window first 79 + const nonModalResult = await api.window.open('peek://app/settings/settings.html', { 80 + width: 400, 81 + height: 300, 82 + modal: false, 83 + key: 'test-nonmodal-target' 84 + }); 85 + 86 + if (!nonModalResult.success) { 87 + return { success: false, error: 'Failed to open non-modal window' }; 88 + } 89 + 90 + // Wait for it to focus 91 + await new Promise(r => setTimeout(r, 300)); 92 + 93 + // Now open a modal window (simulating cmd palette behavior) 94 + const modalResult = await api.window.open('peek://app/settings/settings.html', { 95 + width: 300, 96 + height: 200, 97 + modal: true, 98 + key: 'test-modal-overlay' 99 + }); 100 + 101 + if (!modalResult.success) { 102 + // Clean up non-modal 103 + try { await api.window.close(nonModalResult.id); } catch (e) {} 104 + return { success: false, error: 'Failed to open modal window' }; 105 + } 106 + 107 + // Wait a bit for modal to be ready 108 + await new Promise(r => setTimeout(r, 200)); 109 + 110 + // Now call setWindowColorScheme - should still target the NON-MODAL window 111 + const themeResult = await api.theme.setWindowColorScheme('light'); 112 + 113 + // Clean up both windows 114 + try { await api.window.close(modalResult.id); } catch (e) {} 115 + try { await api.window.close(nonModalResult.id); } catch (e) {} 116 + 117 + return { 118 + success: themeResult.success, 119 + targetedWindowId: themeResult.windowId, 120 + nonModalWindowId: nonModalResult.id, 121 + modalWindowId: modalResult.id, 122 + // Key assertion: the targeted window should be the non-modal one 123 + targetedNonModal: themeResult.windowId === nonModalResult.id 124 + }; 125 + }); 126 + 127 + expect(result.success).toBe(true); 128 + // The theme command should have targeted the non-modal window, not the modal 129 + expect(result.targetedNonModal).toBe(true); 130 + }); 131 + 132 + test('setWindowColorScheme with global resets override', async () => { 133 + // Test the 'global' value which should reset window-specific override 134 + const result = await bgWindow.evaluate(async () => { 135 + const api = (window as any).app; 136 + 137 + // Open a test window 138 + const winResult = await api.window.open('peek://app/settings/settings.html', { 139 + width: 400, 140 + height: 300, 141 + modal: false, 142 + key: 'test-theme-reset-window' 143 + }); 144 + 145 + if (!winResult.success) { 146 + return { success: false, error: 'Failed to open window' }; 147 + } 148 + 149 + await new Promise(r => setTimeout(r, 300)); 150 + 151 + // Set to dark first 152 + const darkResult = await api.theme.setWindowColorScheme('dark'); 153 + 154 + // Then reset to global 155 + const globalResult = await api.theme.setWindowColorScheme('global'); 156 + 157 + // Clean up 158 + try { await api.window.close(winResult.id); } catch (e) {} 159 + 160 + return { 161 + darkSuccess: darkResult.success, 162 + globalSuccess: globalResult.success, 163 + globalColorScheme: globalResult.colorScheme 164 + }; 165 + }); 166 + 167 + expect(result.darkSuccess).toBe(true); 168 + expect(result.globalSuccess).toBe(true); 169 + expect(result.globalColorScheme).toBe('global'); 170 + }); 171 + });
+26
tests/helpers/test-app.ts
··· 1 + /** 2 + * Shared per-describe app factory for Playwright smoke tests. 3 + * 4 + * Each describe block calls createPerDescribeApp(label) in its beforeAll to 5 + * launch a fresh Electron instance with an isolated profile. The profile name 6 + * MUST start with "test" so that isTestProfile() in backend/electron/config.ts 7 + * skips the single-instance lock — without that prefix, parallel Playwright 8 + * workers would all contend for the same machine-wide lock and only one 9 + * Electron launch would succeed. 10 + */ 11 + 12 + import { DesktopApp, launchDesktopApp } from '../fixtures/desktop-app'; 13 + import { Page } from '@playwright/test'; 14 + import { waitForExtensionsReady } from './window-utils'; 15 + 16 + export async function createPerDescribeApp(label: string): Promise<{ app: DesktopApp; bgWindow: Page }> { 17 + // Profile MUST start with "test" — `isTestProfile()` in backend/electron/config.ts 18 + // keys on that prefix to skip the single-instance lock. Without it, parallel 19 + // Playwright workers would all contend for the same machine-wide lock and 20 + // only one Electron launch would succeed. 21 + const profile = `test-smoke-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`; 22 + const app = await launchDesktopApp(profile); 23 + const bgWindow = await app.getBackgroundWindow(); 24 + await waitForExtensionsReady(bgWindow); 25 + return { app, bgWindow }; 26 + }