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 OS-fragile tests into non-parallel desktop-serial project

+405 -342
+6
docs/tasks.md
··· 29 29 30 30 --- 31 31 32 + ## Test infrastructure 33 + 34 + - [ ] **Move OS-integration-fragile tests to a non-parallel suite.** Under `fullyParallel: true` + `workers > 1`, tests that drive modal cmd panels with OS-level focus/blur dependencies are flaky. Confirmed examples: most of `tests/desktop/cmd-state-machine.spec.ts` (`Escape layering…`, `URL opening…`) and `smoke.spec.ts` Type-Specific Noun Commands describe (`new note with content saves to datastore`, `new note without content signals editor open`). Add a second Playwright project in `playwright.config.ts` matching `tests/desktop-serial/` with `workers: 1, fullyParallel: false`. Move the fragile tests. Also refine the `isTestProfile()` gate in `backend/electron/ipc.ts:2373` that currently disables modal close-on-blur in all test profiles — the serial suite may want to exercise that behavior. 35 + 36 + --- 37 + 32 38 ## Features 33 39 34 40 - [x] **Commands for browser-extension options pages.** Registered `<name> options` command per installed Chromium extension that declares `options_page`/`options_ui.page`.
+1 -1
package.json
··· 143 143 "test:unit:shortcuts": "./scripts/timed.sh sh -c 'yarn build && ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/shortcuts.test.js'", 144 144 "test:unit:datastore": "./scripts/timed.sh sh -c 'yarn build && ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/datastore.test.js'", 145 145 "test": "./scripts/timed.sh sh -c 'yarn build && yarn test:electron && yarn test:tauri'", 146 - "test:electron": "./scripts/timed.sh sh -c 'yarn check:native && yarn build && HEADLESS=1 BACKEND=electron npx playwright test tests/desktop/'", 146 + "test:electron": "./scripts/timed.sh sh -c 'yarn check:native && yarn build && HEADLESS=1 BACKEND=electron npx playwright test --project=desktop && HEADLESS=1 BACKEND=electron npx playwright test --project=desktop-serial --workers=1'", 147 147 "test:electron:x": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=1 BACKEND=electron npx playwright test tests/desktop/ -x'", 148 148 "test:tauri": "./scripts/timed.sh sh -c 'yarn test:tauri:frontend; yarn test:tauri:rust'", 149 149 "test:tauri:frontend": "./scripts/timed.sh sh -c 'HEADLESS=1 BACKEND=tauri npx playwright test tests/desktop/'",
+29 -2
playwright.config.ts
··· 32 32 testMatch: /desktop\/.*\.spec\.ts/, 33 33 }, 34 34 { 35 + // Tests that exercise OS-integration behavior (modal panel focus/blur, 36 + // keyboard flows driving the cmd panel) are fragile under fullyParallel + 37 + // workers>1. yarn test:electron runs this project as a SEPARATE 38 + // playwright invocation with --workers=1, after the parallel project 39 + // finishes, so no other Electron instances are competing. 40 + name: 'desktop-serial', 41 + testMatch: /desktop-serial\/.*\.spec\.ts/, 42 + fullyParallel: false, 43 + }, 44 + { 35 45 name: 'components', 36 46 testMatch: /components\/.*\.spec\.ts/, 37 47 use: { ··· 61 71 timeout: 10000 62 72 }, 63 73 64 - // Desktop app tests must run serially 74 + // Playwright workers — each runs in its own Electron with its own 75 + // --user-data-dir (see tests/fixtures/desktop-app.ts). The single-instance 76 + // lock is bypassed when the profile starts with "test" (see 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. 83 + // 84 + // 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. 65 92 fullyParallel: false, 66 - workers: 1, 93 + workers: parseInt(process.env.PEEK_TEST_WORKERS || '1'), 67 94 68 95 // CI settings 69 96 forbidOnly: !!process.env.CI,
+264
tests/desktop-serial/cmd-noun-commands.spec.ts
··· 1 + /** 2 + * Cmd panel noun-command tests (serial). 3 + * 4 + * Moved out of smoke.spec.ts because the cmd panel's modal window + keyboard 5 + * input flow (fill input → ArrowDown → press Enter) is fragile under 6 + * fullyParallel + workers>1. Concurrent Electron instances in other workers 7 + * appear to perturb OS-level focus/visibility enough that the modal panel 8 + * closes mid-test on press() calls. These tests exercise an OS-integration 9 + * behavior, so running them serially is the right boundary. 10 + */ 11 + 12 + import { test, expect, DesktopApp, launchDesktopApp } from '../fixtures/desktop-app'; 13 + import { Page } from '@playwright/test'; 14 + import { 15 + waitForPanelCommandsLoaded, 16 + waitForCmdNotExecuting, 17 + waitForCommand, 18 + waitForClass, 19 + waitForResultsWithContent, 20 + waitForExtensionsReady, 21 + } from '../helpers/window-utils'; 22 + 23 + async function createPerDescribeApp(label: string): Promise<{ app: DesktopApp; bgWindow: Page }> { 24 + const profile = `test-serial-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`; 25 + const app = await launchDesktopApp(profile); 26 + const bgWindow = await app.getBackgroundWindow(); 27 + await waitForExtensionsReady(bgWindow); 28 + return { app, bgWindow }; 29 + } 30 + 31 + test.describe('Type-Specific Noun Commands @desktop-serial', () => { 32 + let app: DesktopApp; 33 + let bgWindow: Page; 34 + 35 + test.beforeAll(async () => { 36 + ({ app, bgWindow } = await createPerDescribeApp('nouns')); 37 + }); 38 + 39 + test.afterAll(async () => { 40 + if (app) await app.close(); 41 + }); 42 + 43 + test('list notes command produces array output', async () => { 44 + const seedResult = await bgWindow.evaluate(async () => { 45 + return await (window as any).app.datastore.addItem('text', { 46 + content: 'Noun test note content' 47 + }); 48 + }); 49 + expect(seedResult.success).toBe(true); 50 + const seedId = seedResult.data.id; 51 + 52 + const openResult = await bgWindow.evaluate(async () => { 53 + return await (window as any).app.window.open('peek://cmd/panel.html', { 54 + modal: true, 55 + width: 600, 56 + height: 400, 57 + frame: false, 58 + transparent: true, 59 + alwaysOnTop: true, 60 + center: true 61 + }); 62 + }); 63 + expect(openResult.success).toBe(true); 64 + 65 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 66 + expect(cmdWindow).toBeTruthy(); 67 + 68 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 69 + await waitForPanelCommandsLoaded(cmdWindow); 70 + 71 + await cmdWindow.fill('input', 'list notes'); 72 + await cmdWindow.press('input', 'ArrowDown'); 73 + await waitForClass(cmdWindow, '#results', 'visible'); 74 + 75 + await cmdWindow.press('input', 'Enter'); 76 + await waitForResultsWithContent(cmdWindow); 77 + 78 + const hasResults = await cmdWindow.$eval('#results', (el: HTMLElement) => { 79 + return el.classList.contains('visible') && el.children.length > 0; 80 + }); 81 + expect(hasResults).toBe(true); 82 + 83 + if (openResult.id) { 84 + await bgWindow.evaluate(async (id: number) => { 85 + return await (window as any).app.window.close(id); 86 + }, openResult.id); 87 + } 88 + 89 + await bgWindow.evaluate(async (id: string) => { 90 + return await (window as any).app.datastore.deleteItem(id); 91 + }, seedId); 92 + }); 93 + 94 + test('list notes chains with csv', async () => { 95 + await waitForCommand(bgWindow, 'csv', 10000); 96 + 97 + const seedResult = await bgWindow.evaluate(async () => { 98 + return await (window as any).app.datastore.addItem('text', { 99 + content: 'CSV chain test note' 100 + }); 101 + }); 102 + expect(seedResult.success).toBe(true); 103 + const seedId = seedResult.data.id; 104 + 105 + const openResult = await bgWindow.evaluate(async () => { 106 + return await (window as any).app.window.open('peek://cmd/panel.html', { 107 + modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 108 + }); 109 + }); 110 + expect(openResult.success).toBe(true); 111 + 112 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 113 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 114 + await waitForPanelCommandsLoaded(cmdWindow); 115 + 116 + await cmdWindow.fill('input', 'list notes'); 117 + await cmdWindow.press('input', 'Enter'); 118 + 119 + await cmdWindow.waitForFunction( 120 + () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 121 + null, { timeout: 5000 } 122 + ); 123 + await cmdWindow.press('input', 'Enter'); 124 + await cmdWindow.waitForFunction( 125 + () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 126 + null, { timeout: 5000 } 127 + ); 128 + 129 + await cmdWindow.fill('input', 'csv'); 130 + await cmdWindow.waitForFunction( 131 + () => ((window as any)._cmdState?.matches || []).includes('csv'), 132 + null, { timeout: 5000 } 133 + ); 134 + await cmdWindow.press('input', 'ArrowDown'); 135 + await cmdWindow.press('input', 'Enter'); 136 + 137 + const postExecState = await waitForCmdNotExecuting(cmdWindow, 35000); 138 + expect(postExecState).not.toBe('TIMEOUT'); 139 + 140 + let final: { state: string | undefined; mimeType: string | undefined }; 141 + if (postExecState === 'CLOSED') { 142 + final = { state: 'CLOSING', mimeType: undefined }; 143 + } else { 144 + try { 145 + final = await cmdWindow.evaluate(() => { 146 + const s = (window as any)._cmdState; 147 + return { state: s?.currentState, mimeType: s?.chainContext?.mimeType }; 148 + }); 149 + } catch { 150 + final = { state: 'CLOSING', mimeType: undefined }; 151 + } 152 + } 153 + 154 + if (final.state === 'CHAIN_MODE') { 155 + expect(final.mimeType).toBe('text/csv'); 156 + } else { 157 + expect(['CLOSING', 'IDLE', 'OUTPUT_SELECTION', 'ERROR']).toContain(final.state); 158 + } 159 + 160 + if (openResult.id) { 161 + try { 162 + await bgWindow.evaluate(async (id: number) => { 163 + return await (window as any).app.window.close(id); 164 + }, openResult.id); 165 + } catch { /* may already be closed */ } 166 + } 167 + 168 + await bgWindow.evaluate(async (id: string) => { 169 + return await (window as any).app.datastore.deleteItem(id); 170 + }, seedId); 171 + }); 172 + 173 + test('new note with content saves to datastore', async () => { 174 + const uniqueContent = `New note test ${Date.now()}`; 175 + 176 + const openResult = await bgWindow.evaluate(async () => { 177 + return await (window as any).app.window.open('peek://cmd/panel.html', { 178 + modal: true, 179 + width: 600, 180 + height: 400, 181 + frame: false, 182 + transparent: true, 183 + alwaysOnTop: true, 184 + center: true 185 + }); 186 + }); 187 + expect(openResult.success).toBe(true); 188 + 189 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 190 + expect(cmdWindow).toBeTruthy(); 191 + 192 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 193 + await waitForPanelCommandsLoaded(cmdWindow); 194 + 195 + await cmdWindow.fill('input', `new note ${uniqueContent}`); 196 + await cmdWindow.press('input', 'ArrowDown'); 197 + await waitForClass(cmdWindow, '#results', 'visible'); 198 + 199 + await cmdWindow.press('input', 'Enter'); 200 + 201 + const postExecState = await waitForCmdNotExecuting(cmdWindow, 10000); 202 + expect(postExecState).not.toBe('TIMEOUT'); 203 + 204 + if (openResult.id) { 205 + try { 206 + await bgWindow.evaluate(async (id: number) => { 207 + return await (window as any).app.window.close(id); 208 + }, openResult.id); 209 + } catch { /* may already be closed */ } 210 + } 211 + 212 + const queryResult = await bgWindow.evaluate(async (content: string) => { 213 + const result = await (window as any).app.datastore.queryItems({ type: 'text' }); 214 + if (!result.success) return { found: false }; 215 + const match = result.data.find((item: any) => item.content && item.content.includes(content)); 216 + return { found: !!match, itemId: match?.id }; 217 + }, uniqueContent); 218 + 219 + expect(queryResult.found).toBe(true); 220 + 221 + if (queryResult.itemId) { 222 + await bgWindow.evaluate(async (id: string) => { 223 + return await (window as any).app.datastore.deleteItem(id); 224 + }, queryResult.itemId); 225 + } 226 + }); 227 + 228 + test('new note without content signals editor open', async () => { 229 + const openResult = await bgWindow.evaluate(async () => { 230 + return await (window as any).app.window.open('peek://cmd/panel.html', { 231 + modal: true, 232 + width: 600, 233 + height: 400, 234 + frame: false, 235 + transparent: true, 236 + alwaysOnTop: true, 237 + center: true 238 + }); 239 + }); 240 + expect(openResult.success).toBe(true); 241 + 242 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 243 + expect(cmdWindow).toBeTruthy(); 244 + 245 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 246 + await waitForPanelCommandsLoaded(cmdWindow); 247 + 248 + await cmdWindow.fill('input', 'new note'); 249 + await cmdWindow.press('input', 'ArrowDown'); 250 + await waitForClass(cmdWindow, '#results', 'visible'); 251 + 252 + await cmdWindow.press('input', 'Enter'); 253 + 254 + const finalState = await waitForCmdNotExecuting(cmdWindow, 10000); 255 + expect(finalState).not.toBe('TIMEOUT'); 256 + expect(finalState).not.toBe('ERROR'); 257 + 258 + if (openResult.id) { 259 + await bgWindow.evaluate(async (id: number) => { 260 + return await (window as any).app.window.close(id); 261 + }, openResult.id); 262 + } 263 + }); 264 + });
+25 -12
tests/desktop/cmd-state-machine.spec.ts tests/desktop-serial/cmd-state-machine.spec.ts
··· 75 75 } 76 76 77 77 /** 78 - * Helper: Wait for a specific state machine state 78 + * Helper: Wait for a specific state machine state. 79 + * 80 + * If the caller expects CLOSING, tolerate the panel actually closing — the 81 + * CLOSING transition triggers window.close(), and waitForFunction can race 82 + * with the close and throw "Target page, context or browser has been closed". 83 + * That's the success case for CLOSING. 79 84 */ 80 85 async function waitForState(cmdWindow: Page, expectedState: string, timeout = 5000) { 81 - await cmdWindow.waitForFunction( 82 - (state: string) => (window as any)._cmdState.currentState === state, 83 - expectedState, 84 - { timeout } 85 - ); 86 + try { 87 + await cmdWindow.waitForFunction( 88 + (state: string) => (window as any)._cmdState.currentState === state, 89 + expectedState, 90 + { timeout } 91 + ); 92 + } catch (err) { 93 + // Panel closed during the wait. For CLOSING (and other terminal states 94 + // where the window teardown is the expected behavior), accept that as 95 + // having reached the state. For non-terminal states, rethrow — a closed 96 + // panel while expecting TYPING/IDLE/PARAM_MODE is a real failure. 97 + const msg = err instanceof Error ? err.message : String(err); 98 + const isPageClosed = msg.includes('Target page') || msg.includes('context or browser has been closed'); 99 + if (isPageClosed && expectedState === 'CLOSING') return; 100 + throw err; 101 + } 86 102 } 87 103 88 104 // ============================================================================ ··· 413 429 // Enter should detect URL and close 414 430 await cmdWindow.keyboard.press('Enter'); 415 431 416 - // Should transition to CLOSING 417 - await cmdWindow.waitForFunction( 418 - () => (window as any)._cmdState.currentState === 'CLOSING', 419 - undefined, 420 - { timeout: 5000 } 421 - ); 432 + // Should transition to CLOSING (waitForState tolerates the panel 433 + // actually closing during the CLOSING transition). 434 + await waitForState(cmdWindow, 'CLOSING'); 422 435 } finally { 423 436 await closeCmdPanel(windowId); 424 437 }
+27 -281
tests/desktop/smoke.spec.ts
··· 13 13 import { Page } from '@playwright/test'; 14 14 import path from 'path'; 15 15 import { fileURLToPath } from 'url'; 16 - import { waitForCommandResults, waitForWindowCount, waitForVisible, waitForClass, waitForResultsWithContent, waitForSelectionChange, sleep, waitForWindow, waitForExtensionsReady, queryCommandsWithRetry, waitForAppReady, waitForCommand, waitForPanelCommandsLoaded } from '../helpers/window-utils'; 16 + import { waitForCommandResults, waitForWindowCount, waitForVisible, waitForClass, waitForResultsWithContent, waitForSelectionChange, sleep, waitForWindow, waitForExtensionsReady, queryCommandsWithRetry, waitForAppReady, waitForCommand, waitForPanelCommandsLoaded, waitForCmdNotExecuting } from '../helpers/window-utils'; 17 17 18 18 const __filename = fileURLToPath(import.meta.url); 19 19 const __dirname = path.dirname(__filename); ··· 3335 3335 await cmdWindow.press('input', 'Enter'); 3336 3336 3337 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 - await cmdWindow.waitForFunction( 3340 - () => (window as any)._cmdState?.currentState !== 'EXECUTING', 3341 - null, { timeout: 35000 } 3342 - ); 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'); 3343 3342 3344 3343 // After csv execution, either chainContext has text/csv (chain continues) 3345 3344 // or the panel transitioned to CLOSING (terminal csv output). Both are valid 3346 3345 // terminal states — we only fail if csv's result never arrived at all. 3347 - const final = await cmdWindow.evaluate(() => { 3348 - const s = (window as any)._cmdState; 3349 - return { state: s?.currentState, mimeType: s?.chainContext?.mimeType }; 3350 - }); 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 + 3351 3363 if (final.state === 'CHAIN_MODE') { 3352 3364 expect(final.mimeType).toBe('text/csv'); 3353 3365 } else { ··· 3358 3370 } 3359 3371 3360 3372 if (openResult.id) { 3361 - await bgWindow.evaluate(async (id: number) => { 3362 - return await (window as any).app.window.close(id); 3363 - }, 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 */ } 3364 3378 } 3365 3379 }); 3366 3380 ··· 3477 3491 }); 3478 3492 }); 3479 3493 3480 - // ============================================================================ 3481 - // Type-Specific Noun Commands Tests (uses shared app) 3482 - // ============================================================================ 3483 - 3484 - test.describe('Type-Specific Noun Commands @desktop', () => { 3485 - let app: DesktopApp; 3486 - let bgWindow: Page; 3487 - 3488 - test.beforeAll(async () => { 3489 - ({ app, bgWindow } = await createPerDescribeApp('nouns')); 3490 - }); 3491 - 3492 - test.afterAll(async () => { 3493 - if (app) await app.close(); 3494 - }); 3495 - 3496 - test('list notes command produces array output', async () => { 3497 - // Seed a text item so list notes has data 3498 - const seedResult = await bgWindow.evaluate(async () => { 3499 - return await (window as any).app.datastore.addItem('text', { 3500 - content: 'Noun test note content' 3501 - }); 3502 - }); 3503 - expect(seedResult.success).toBe(true); 3504 - const seedId = seedResult.data.id; 3505 - 3506 - // Open cmd panel 3507 - const openResult = await bgWindow.evaluate(async () => { 3508 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3509 - modal: true, 3510 - width: 600, 3511 - height: 400, 3512 - frame: false, 3513 - transparent: true, 3514 - alwaysOnTop: true, 3515 - center: true 3516 - }); 3517 - }); 3518 - expect(openResult.success).toBe(true); 3519 - 3520 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3521 - expect(cmdWindow).toBeTruthy(); 3522 - 3523 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3524 - await waitForPanelCommandsLoaded(cmdWindow); 3525 - 3526 - // Type 'list notes' command 3527 - await cmdWindow.fill('input', 'list notes'); 3528 - await cmdWindow.press('input', 'ArrowDown'); 3529 - await waitForClass(cmdWindow, '#results', 'visible'); 3530 - 3531 - // Execute 3532 - await cmdWindow.press('input', 'Enter'); 3533 - await waitForResultsWithContent(cmdWindow); 3534 - 3535 - // Should have results showing 3536 - const hasResults = await cmdWindow.$eval('#results', (el: HTMLElement) => { 3537 - return el.classList.contains('visible') && el.children.length > 0; 3538 - }); 3539 - expect(hasResults).toBe(true); 3540 - 3541 - // Close the window 3542 - if (openResult.id) { 3543 - await bgWindow.evaluate(async (id: number) => { 3544 - return await (window as any).app.window.close(id); 3545 - }, openResult.id); 3546 - } 3547 - 3548 - // Clean up seeded item 3549 - await bgWindow.evaluate(async (id: string) => { 3550 - return await (window as any).app.datastore.deleteItem(id); 3551 - }, seedId); 3552 - }); 3553 - 3554 - test('list notes chains with csv', async () => { 3555 - // list notes → OUTPUT_SELECTION → select row → CHAIN_MODE → csv → text/csv 3556 - await waitForCommand(bgWindow, 'csv', 10000); 3557 - 3558 - const seedResult = await bgWindow.evaluate(async () => { 3559 - return await (window as any).app.datastore.addItem('text', { 3560 - content: 'CSV chain test note' 3561 - }); 3562 - }); 3563 - expect(seedResult.success).toBe(true); 3564 - const seedId = seedResult.data.id; 3565 - 3566 - const openResult = await bgWindow.evaluate(async () => { 3567 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3568 - modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true, 3569 - }); 3570 - }); 3571 - expect(openResult.success).toBe(true); 3572 - 3573 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3574 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3575 - await waitForPanelCommandsLoaded(cmdWindow); 3576 - 3577 - await cmdWindow.fill('input', 'list notes'); 3578 - await cmdWindow.press('input', 'Enter'); 3579 - 3580 - // OUTPUT_SELECTION → Enter row → CHAIN_MODE 3581 - await cmdWindow.waitForFunction( 3582 - () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION', 3583 - null, { timeout: 5000 } 3584 - ); 3585 - await cmdWindow.press('input', 'Enter'); 3586 - await cmdWindow.waitForFunction( 3587 - () => (window as any)._cmdState?.currentState === 'CHAIN_MODE', 3588 - null, { timeout: 5000 } 3589 - ); 3590 - 3591 - // csv produces text/csv 3592 - await cmdWindow.fill('input', 'csv'); 3593 - await cmdWindow.waitForFunction( 3594 - () => ((window as any)._cmdState?.matches || []).includes('csv'), 3595 - null, { timeout: 5000 } 3596 - ); 3597 - await cmdWindow.press('input', 'ArrowDown'); 3598 - await cmdWindow.press('input', 'Enter'); 3599 - 3600 - // Wait out the csv lazy-tile-load + execute (proxy has 30s timeout) 3601 - await cmdWindow.waitForFunction( 3602 - () => (window as any)._cmdState?.currentState !== 'EXECUTING', 3603 - null, { timeout: 35000 } 3604 - ); 3605 - const final = await cmdWindow.evaluate(() => { 3606 - const s = (window as any)._cmdState; 3607 - return { state: s?.currentState, mimeType: s?.chainContext?.mimeType }; 3608 - }); 3609 - if (final.state === 'CHAIN_MODE') { 3610 - expect(final.mimeType).toBe('text/csv'); 3611 - } else { 3612 - // csv is lazy-loaded; first invoke may exceed the proxy's 30s timeout. 3613 - // ERROR is also acceptable — test verifies plumbing, not perf. 3614 - expect(['CLOSING', 'IDLE', 'OUTPUT_SELECTION', 'ERROR']).toContain(final.state); 3615 - } 3616 - 3617 - // Close the window 3618 - if (openResult.id) { 3619 - await bgWindow.evaluate(async (id: number) => { 3620 - return await (window as any).app.window.close(id); 3621 - }, openResult.id); 3622 - } 3623 - 3624 - // Clean up 3625 - await bgWindow.evaluate(async (id: string) => { 3626 - return await (window as any).app.datastore.deleteItem(id); 3627 - }, seedId); 3628 - }); 3629 - 3630 - test('new note with content saves to datastore', async () => { 3631 - const uniqueContent = `New note test ${Date.now()}`; 3632 - 3633 - // Open cmd panel 3634 - const openResult = await bgWindow.evaluate(async () => { 3635 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3636 - modal: true, 3637 - width: 600, 3638 - height: 400, 3639 - frame: false, 3640 - transparent: true, 3641 - alwaysOnTop: true, 3642 - center: true 3643 - }); 3644 - }); 3645 - expect(openResult.success).toBe(true); 3646 - 3647 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3648 - expect(cmdWindow).toBeTruthy(); 3649 - 3650 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3651 - await waitForPanelCommandsLoaded(cmdWindow); 3652 - 3653 - // Type 'new note' followed by content text 3654 - await cmdWindow.fill('input', `new note ${uniqueContent}`); 3655 - await cmdWindow.press('input', 'ArrowDown'); 3656 - await waitForClass(cmdWindow, '#results', 'visible'); 3657 - 3658 - // Execute 3659 - await cmdWindow.press('input', 'Enter'); 3660 - 3661 - // Wait for execution to finish (panel may close or show output) 3662 - await cmdWindow.waitForFunction(() => { 3663 - const s = (window as any)._cmdState; 3664 - return s.currentState !== 'EXECUTING'; 3665 - }, { timeout: 10000 }); 3666 - 3667 - // Close the window if still open 3668 - if (openResult.id) { 3669 - try { 3670 - await bgWindow.evaluate(async (id: number) => { 3671 - return await (window as any).app.window.close(id); 3672 - }, openResult.id); 3673 - } catch { /* may already be closed */ } 3674 - } 3675 - 3676 - // Verify the note was actually saved in the datastore 3677 - const queryResult = await bgWindow.evaluate(async (content: string) => { 3678 - const result = await (window as any).app.datastore.queryItems({ type: 'text' }); 3679 - if (!result.success) return { found: false }; 3680 - const match = result.data.find((item: any) => item.content && item.content.includes(content)); 3681 - return { found: !!match, itemId: match?.id }; 3682 - }, uniqueContent); 3683 - 3684 - expect(queryResult.found).toBe(true); 3685 - 3686 - // Clean up 3687 - if (queryResult.itemId) { 3688 - await bgWindow.evaluate(async (id: string) => { 3689 - return await (window as any).app.datastore.deleteItem(id); 3690 - }, queryResult.itemId); 3691 - } 3692 - }); 3693 - 3694 - test('new note without content signals editor open', async () => { 3695 - // Open cmd panel 3696 - const openResult = await bgWindow.evaluate(async () => { 3697 - return await (window as any).app.window.open('peek://cmd/panel.html', { 3698 - modal: true, 3699 - width: 600, 3700 - height: 400, 3701 - frame: false, 3702 - transparent: true, 3703 - alwaysOnTop: true, 3704 - center: true 3705 - }); 3706 - }); 3707 - expect(openResult.success).toBe(true); 3708 - 3709 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 3710 - expect(cmdWindow).toBeTruthy(); 3711 - 3712 - await cmdWindow.waitForSelector('input', { timeout: 5000 }); 3713 - await waitForPanelCommandsLoaded(cmdWindow); 3714 - 3715 - // Type just 'new note' with no additional content — enter param mode with space 3716 - await cmdWindow.fill('input', 'new note'); 3717 - await cmdWindow.press('input', 'ArrowDown'); 3718 - await waitForClass(cmdWindow, '#results', 'visible'); 3719 - 3720 - // Execute with no search text (just the command name) 3721 - await cmdWindow.press('input', 'Enter'); 3722 - 3723 - // The command returns mimeType 'new-item' which signals editor should open. 3724 - // This should complete execution without error — verify the panel doesn't show an error state. 3725 - await cmdWindow.waitForFunction(() => { 3726 - const s = (window as any)._cmdState; 3727 - // Should either be in chain mode, output selection, or back to idle after execution 3728 - return s.currentState !== 'EXECUTING'; 3729 - }, { timeout: 10000 }); 3730 - 3731 - const state = await cmdWindow.evaluate(() => { 3732 - return (window as any)._cmdState?.currentState; 3733 - }); 3734 - // Should not be in error state 3735 - expect(state).not.toBe('ERROR'); 3736 - 3737 - // Close the window 3738 - if (openResult.id) { 3739 - await bgWindow.evaluate(async (id: number) => { 3740 - return await (window as any).app.window.close(id); 3741 - }, openResult.id); 3742 - } 3743 - }); 3744 - 3745 - // TODO: search filtering test — requires cmd palette to pass extra text as ctx.search 3746 - // for direct commands (not noun-system commands). Skipped until search param passing is implemented. 3747 - }); 3748 3494 3749 3495 // ============================================================================ 3750 3496 // Edit Command Param Mode Tests (uses shared app)
+15 -45
tests/desktop/websearch-cmd.spec.ts tests/desktop-serial/websearch-cmd.spec.ts
··· 21 21 waitForExtensionsReady, 22 22 waitForCommandResults, 23 23 waitForPanelCommandsLoaded, 24 + waitForCmdNotExecuting, 24 25 sleep, 25 26 } from '../helpers/window-utils'; 26 27 ··· 71 72 // Panel may have already closed 72 73 } 73 74 } 75 + 74 76 75 77 // Ensure the websearch extension is loaded before our UI tests 76 78 async function ensureWebsearchLoaded(bgWindow: Page) { ··· 236 238 // Press Enter to execute the command through the UI proxy flow 237 239 await cmdWindow.keyboard.press('Enter'); 238 240 239 - // Wait for the command to complete (should NOT hang) 240 - // The cmd panel state machine transitions to EXECUTING on Enter, 241 - // then to IDLE/OUTPUT_SELECTION/CLOSING on completion. 242 - // If it stays in EXECUTING for more than 15s, the command hung. 243 - const executionResult = await cmdWindow.waitForFunction( 244 - () => { 245 - const state = (window as any)._cmdState; 246 - if (!state) return null; 247 - const currentState = state.currentState; 248 - // EXECUTING = the command is still running 249 - // Any other state means it completed (or errored) 250 - if (currentState !== 'EXECUTING') { 251 - return { completed: true, finalState: currentState }; 252 - } 253 - return null; // Keep waiting 254 - }, 255 - undefined, 256 - { timeout: 20000 } // 20s — well under the 30s proxy timeout 257 - ); 241 + // Wait for the command to complete (should NOT hang). 20s is well under 242 + // the 30s proxy timeout. Helper tolerates the panel closing mid-poll. 243 + const finalState = await waitForCmdNotExecuting(cmdWindow, 20000); 244 + console.log('Command completed with final state:', finalState); 258 245 259 - const result = await executionResult.jsonValue() as { completed: boolean; finalState: string }; 260 - console.log('Command completed with final state:', result.finalState); 261 - 262 - // The command should have completed, not timed out 263 - expect(result.completed).toBe(true); 264 - // It should NOT be in ERROR state (timeout would cause ERROR) 265 - expect(result.finalState).not.toBe('ERROR'); 246 + // Must have left EXECUTING — that's what "didn't hang" means. 247 + expect(finalState).not.toBe('TIMEOUT'); 248 + // ERROR means the proxy timed out / the command errored. 249 + expect(finalState).not.toBe('ERROR'); 266 250 267 251 } finally { 268 252 await closeCmdPanel(sharedBgWindow, windowId); ··· 280 264 await cmdWindow.fill('input', 'google test'); 281 265 await cmdWindow.keyboard.press('Enter'); 282 266 283 - // Wait for execution to complete 284 - const executionResult = await cmdWindow.waitForFunction( 285 - () => { 286 - const state = (window as any)._cmdState; 287 - if (!state) return null; 288 - const currentState = state.currentState; 289 - if (currentState !== 'EXECUTING') { 290 - return { completed: true, finalState: currentState }; 291 - } 292 - return null; 293 - }, 294 - undefined, 295 - { timeout: 20000 } 296 - ); 267 + // Wait for execution to complete (tolerates panel closing mid-poll) 268 + const finalState = await waitForCmdNotExecuting(cmdWindow, 20000); 269 + console.log('Google command completed with final state:', finalState); 297 270 298 - const result = await executionResult.jsonValue() as { completed: boolean; finalState: string }; 299 - console.log('Google command completed with final state:', result.finalState); 300 - 301 - expect(result.completed).toBe(true); 302 - expect(result.finalState).not.toBe('ERROR'); 271 + expect(finalState).not.toBe('TIMEOUT'); 272 + expect(finalState).not.toBe('ERROR'); 303 273 304 274 } finally { 305 275 await closeCmdPanel(sharedBgWindow, windowId);
+1 -1
tests/fixtures/desktop-app.ts
··· 652 652 return sharedAppPromise; 653 653 } 654 654 655 - sharedAppPromise = launchDesktopApp('shared-test-instance'); 655 + sharedAppPromise = launchDesktopApp('test-shared-instance'); 656 656 sharedApp = await sharedAppPromise; 657 657 sharedAppPromise = null; 658 658 return sharedApp;
+37
tests/helpers/window-utils.ts
··· 410 410 { timeout } 411 411 ); 412 412 } 413 + 414 + /** 415 + * Poll the cmd panel's state machine until it leaves EXECUTING. 416 + * 417 + * Returns the final state as a primitive string. If the panel closes 418 + * mid-poll — which is the normal outcome for commands that transition 419 + * EXECUTING → CLOSING → actual close (e.g. search, URL open) — return 420 + * 'CLOSED'. That counts as success: the only real failure mode is staying 421 + * in EXECUTING past the timeout. 422 + * 423 + * Avoids the race where `waitForFunction(...).jsonValue()` or a subsequent 424 + * `cmdWindow.evaluate(...)` fires against a page that has already closed, 425 + * throwing "Target page, context or browser has been closed". The tests 426 + * that used that pattern were flaky under parallel load (workers > 1, 427 + * fullyParallel: true). 428 + */ 429 + export async function waitForCmdNotExecuting( 430 + cmdWindow: Page, 431 + timeoutMs: number 432 + ): Promise<string> { 433 + const start = Date.now(); 434 + while (Date.now() - start < timeoutMs) { 435 + try { 436 + const state = await cmdWindow.evaluate(() => { 437 + const s = (window as any)._cmdState; 438 + return s?.currentState ?? null; 439 + }); 440 + if (state && state !== 'EXECUTING') return state; 441 + } catch { 442 + // Page closed — command completed (the panel's CLOSING transition 443 + // actually closes the window). Treat as success. 444 + return 'CLOSED'; 445 + } 446 + await sleep(50); 447 + } 448 + return 'TIMEOUT'; 449 + }