···7575 // --user-data-dir (see tests/fixtures/desktop-app.ts). The single-instance
7676 // lock is bypassed when the profile starts with "test" (see
7777 // `isTestProfile()` in backend/electron/config.ts), so parallel workers
7878- // don't collide on the lock. Default is 1 (both local and CI) for
7979- // reliability: the parallelism payoff is minor while smoke.spec.ts is a
8080- // single-file bottleneck, and concurrent Electron launches surface
8181- // occasional OS-level races. Set `PEEK_TEST_WORKERS=N` to opt in to
8282- // parallelism once smoke.spec.ts has been split into smaller files.
7878+ // don't collide on the lock. Default is 4 since smoke.spec.ts was split
7979+ // into one-describe-per-file (~44 files total), giving real parallelism
8080+ // across spec files. Override with `PEEK_TEST_WORKERS=1` to debug or
8181+ // on machines where 4 concurrent Electrons is too much.
8382 //
8483 // fullyParallel: false — tests within a file run serially in one worker,
8585- // different files distribute across workers. We tried fullyParallel: true
8686- // but OS-level cross-Electron focus/timing races under 4 parallel workers
8787- // surfaced in too many scattered tests. Current choice trades raw
8888- // parallelism for stability; smoke.spec.ts (the big file, ~218 tests)
8989- // bottlenecks on one worker but the rest of the suite distributes freely.
9090- // To recover more parallelism, the right move is to split smoke.spec.ts
9191- // into smaller files rather than re-enable fullyParallel.
8484+ // different files distribute across workers. fullyParallel: true exposed
8585+ // OS-level cross-Electron focus/timing races; also conflicts with the
8686+ // intra-test ordering some describes rely on (e.g. Extension Lifecycle:
8787+ // add → update → remove). File-level parallelism is enough.
8888+ //
8989+ // Cmd-panel-keyboard-flow tests live in tests/desktop-serial/ (separate
9090+ // project) because they hit OS-level modal focus/blur that misbehaves
9191+ // when N Electron instances are competing. `yarn test:electron` runs
9292+ // the desktop project at PEEK_TEST_WORKERS, then runs desktop-serial
9393+ // afterward at --workers=1.
9294 fullyParallel: false,
9393- workers: parseInt(process.env.PEEK_TEST_WORKERS || '1'),
9595+ workers: parseInt(process.env.PEEK_TEST_WORKERS || '4'),
94969597 // CI settings
9698 forbidOnly: !!process.env.CI,
+137
tests/desktop/backup.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import fs from 'fs';
55+import os from 'os';
66+import path from 'path';
77+88+test.describe('Backup @desktop', () => {
99+ let app: DesktopApp;
1010+ let bgWindow: Page;
1111+1212+ test.beforeAll(async () => {
1313+ ({ app, bgWindow } = await createPerDescribeApp('backup'));
1414+ });
1515+1616+ test.afterAll(async () => {
1717+ if (app) await app.close();
1818+ });
1919+2020+ test('backup-get-config returns config object', async () => {
2121+ const result = await bgWindow.evaluate(async () => {
2222+ return await (window as any).app.backup.getConfig();
2323+ });
2424+2525+ expect(result.success).toBe(true);
2626+ expect(result.data).toBeDefined();
2727+ expect(typeof result.data.enabled).toBe('boolean');
2828+ expect(typeof result.data.backupDir).toBe('string');
2929+ expect(typeof result.data.retentionCount).toBe('number');
3030+ expect(typeof result.data.lastBackupTime).toBe('number');
3131+ });
3232+3333+ test('backup is disabled when backupDir is not configured', async () => {
3434+ const result = await bgWindow.evaluate(async () => {
3535+ return await (window as any).app.backup.getConfig();
3636+ });
3737+3838+ expect(result.success).toBe(true);
3939+ // By default, backupDir should be empty and backups disabled
4040+ expect(result.data.backupDir).toBe('');
4141+ expect(result.data.enabled).toBe(false);
4242+ });
4343+4444+ test('backup-create returns error when not configured', async () => {
4545+ const result = await bgWindow.evaluate(async () => {
4646+ return await (window as any).app.backup.create();
4747+ });
4848+4949+ expect(result.success).toBe(false);
5050+ expect(result.error).toContain('not configured');
5151+ });
5252+5353+ test('backup-list returns empty when not configured', async () => {
5454+ const result = await bgWindow.evaluate(async () => {
5555+ return await (window as any).app.backup.list();
5656+ });
5757+5858+ expect(result.success).toBe(true);
5959+ expect(result.data.backups).toEqual([]);
6060+ expect(result.data.backupDir).toBe('');
6161+ });
6262+6363+ test('backup works when backupDir is configured', async () => {
6464+ // Create temp directory for test backups
6565+ const tempBackupDir = path.join(os.tmpdir(), `peek-backup-test-${Date.now()}`);
6666+ fs.mkdirSync(tempBackupDir, { recursive: true });
6767+6868+ try {
6969+ // Store the current prefs and configure backup
7070+ const setupResult = await bgWindow.evaluate(async (backupDir: string) => {
7171+ const api = (window as any).app;
7272+7373+ // Get current prefs
7474+ const prefsResult = await api.datastore.getTable('feature_settings');
7575+ const corePrefsRow = Object.values(prefsResult.data || {}).find(
7676+ (r: any) => r.featureId === 'core' && r.key === 'prefs'
7777+ ) as any;
7878+ const originalPrefs = corePrefsRow ? JSON.parse(corePrefsRow.value) : {};
7979+8080+ // Set backupDir in core prefs
8181+ const newPrefs = { ...originalPrefs, backupDir };
8282+ await api.datastore.setRow('feature_settings', 'core:prefs', {
8383+ featureId: 'core',
8484+ key: 'prefs',
8585+ value: JSON.stringify(newPrefs),
8686+ updatedAt: Date.now()
8787+ });
8888+8989+ return { originalPrefs };
9090+ }, tempBackupDir);
9191+9292+ // Verify config reflects the change
9393+ const configResult = await bgWindow.evaluate(async () => {
9494+ return await (window as any).app.backup.getConfig();
9595+ });
9696+ expect(configResult.success).toBe(true);
9797+ expect(configResult.data.backupDir).toBe(tempBackupDir);
9898+ expect(configResult.data.enabled).toBe(true);
9999+100100+ // Create a backup
101101+ const backupResult = await bgWindow.evaluate(async () => {
102102+ return await (window as any).app.backup.create();
103103+ });
104104+ expect(backupResult.success).toBe(true);
105105+ expect(backupResult.path).toBeTruthy();
106106+ expect(backupResult.path.endsWith('.zip')).toBe(true);
107107+108108+ // Verify the file exists
109109+ expect(fs.existsSync(backupResult.path)).toBe(true);
110110+111111+ // List backups - should have one
112112+ const listResult = await bgWindow.evaluate(async () => {
113113+ return await (window as any).app.backup.list();
114114+ });
115115+ expect(listResult.success).toBe(true);
116116+ expect(listResult.data.backups.length).toBe(1);
117117+118118+ // Restore original prefs
119119+ await bgWindow.evaluate(async (originalPrefs: Record<string, unknown>) => {
120120+ const api = (window as any).app;
121121+ await api.datastore.setRow('feature_settings', 'core:prefs', {
122122+ featureId: 'core',
123123+ key: 'prefs',
124124+ value: JSON.stringify(originalPrefs),
125125+ updatedAt: Date.now()
126126+ });
127127+ }, setupResult.originalPrefs);
128128+ } finally {
129129+ // Clean up temp directory
130130+ try {
131131+ fs.rmSync(tempBackupDir, { recursive: true, force: true });
132132+ } catch (e) {
133133+ // Ignore cleanup errors
134134+ }
135135+ }
136136+ });
137137+});
+166
tests/desktop/cmd-palette.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { waitForExtensionsReady, waitForCommandResults, waitForResultsWithContent, waitForPanelCommandsLoaded } from '../helpers/window-utils';
55+66+test.describe('Cmd Palette @desktop', () => {
77+ let app: DesktopApp;
88+ let bgWindow: Page;
99+1010+ test.beforeAll(async () => {
1111+ ({ app, bgWindow } = await createPerDescribeApp('cmd-palette'));
1212+ });
1313+1414+ test.afterAll(async () => {
1515+ if (app) await app.close();
1616+ });
1717+1818+ test('open cmd and execute gallery command', async () => {
1919+ // Wait for cmd extension to be ready (critical for packaged mode where startup is slower)
2020+ await waitForExtensionsReady(bgWindow, 15000);
2121+2222+ // Open cmd panel via window API
2323+ const openResult = await bgWindow.evaluate(async () => {
2424+ return await (window as any).app.window.open('peek://cmd/panel.html', {
2525+ modal: true,
2626+ width: 600,
2727+ height: 50,
2828+ frame: false,
2929+ transparent: true,
3030+ alwaysOnTop: true,
3131+ center: true
3232+ });
3333+ });
3434+ expect(openResult.success).toBe(true);
3535+3636+ // Find the cmd window (getWindow already polls until found)
3737+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
3838+ expect(cmdWindow).toBeTruthy();
3939+4040+ // Wait for input to be ready and commands to be loaded
4141+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
4242+ await waitForPanelCommandsLoaded(cmdWindow, 10000);
4343+4444+ // Type a built-in command first to verify
4545+ // Built-in commands (like 'settings') load faster than extension commands
4646+ await cmdWindow.fill('input', 'settings');
4747+ // Press ArrowDown to show results (panel requires this to display dropdown)
4848+ await cmdWindow.keyboard.press('ArrowDown');
4949+ await waitForCommandResults(cmdWindow, 1, 10000); // Longer timeout for initial load
5050+5151+ // Now search for the extension command
5252+ await cmdWindow.fill('input', 'example:gallery');
5353+ await cmdWindow.keyboard.press('ArrowDown');
5454+ await waitForCommandResults(cmdWindow, 1, 10000);
5555+5656+ // Press Enter to execute
5757+ await cmdWindow.keyboard.press('Enter');
5858+5959+ // Close the cmd window
6060+ if (openResult.id) {
6161+ await bgWindow.evaluate(async (id: number) => {
6262+ return await (window as any).app.window.close(id);
6363+ }, openResult.id);
6464+ }
6565+ });
6666+6767+ test('edit command Tab-completion shows autocomplete and opens editor', async () => {
6868+ await waitForExtensionsReady(bgWindow, 15000);
6969+7070+ // Create a test note so the edit command has something to autocomplete
7171+ const addResult = await bgWindow.evaluate(async () => {
7272+ return await (window as any).app.datastore.addItem('text', {
7373+ content: '# Edit Tab Test Note\nThis is a note for testing edit tab-completion.'
7474+ });
7575+ });
7676+ expect(addResult.success).toBe(true);
7777+ const noteId = addResult.data?.id;
7878+7979+ // Set up editor:open event capture BEFORE opening the cmd panel
8080+ await bgWindow.evaluate(() => {
8181+ (window as any).__editorOpenCaptured = [];
8282+ (window as any).__editorOpenUnsub = (window as any).app.subscribe('editor:open', (data: any) => {
8383+ (window as any).__editorOpenCaptured.push(data);
8484+ }, (window as any).app.scopes.GLOBAL);
8585+ });
8686+8787+ // Open cmd panel
8888+ const openResult = await bgWindow.evaluate(async () => {
8989+ return await (window as any).app.window.open('peek://cmd/panel.html', {
9090+ modal: true,
9191+ width: 600,
9292+ height: 50,
9393+ frame: false,
9494+ transparent: true,
9595+ alwaysOnTop: true,
9696+ center: true
9797+ });
9898+ });
9999+ expect(openResult.success).toBe(true);
100100+101101+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
102102+ expect(cmdWindow).toBeTruthy();
103103+104104+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
105105+ await waitForPanelCommandsLoaded(cmdWindow, 10000);
106106+107107+ // Type "edit " (with space) to commit to the edit command and enter param mode
108108+ // (Tab would cycle to 'editor' since both "edit" and "editor" match)
109109+ await cmdWindow.fill('input', 'edit ');
110110+111111+ // Wait for param mode and suggestions to populate
112112+ await cmdWindow.waitForFunction(
113113+ () => {
114114+ const state = (window as any)._cmdState;
115115+ return state && state.paramMode === true && state.paramCommand === 'edit'
116116+ && state.paramSuggestions && state.paramSuggestions.length > 0;
117117+ },
118118+ undefined,
119119+ { timeout: 10000 }
120120+ );
121121+122122+ // Verify results are visible with suggestion items
123123+ await waitForResultsWithContent(cmdWindow, 5000);
124124+125125+ // Press Enter to accept the first suggestion
126126+ // This executes the edit command, publishes editor:open, and closes the panel
127127+ await cmdWindow.keyboard.press('Enter');
128128+129129+ // Verify editor:open was published by polling the captured events
130130+ await bgWindow.waitForFunction(() => {
131131+ return (window as any).__editorOpenCaptured && (window as any).__editorOpenCaptured.length > 0;
132132+ }, undefined, { timeout: 10000 });
133133+134134+ const editorOpenData = await bgWindow.evaluate(() => {
135135+ return (window as any).__editorOpenCaptured[0];
136136+ });
137137+ expect(editorOpenData).toBeTruthy();
138138+139139+ // Clean up event listener
140140+ await bgWindow.evaluate(() => {
141141+ if ((window as any).__editorOpenUnsub) {
142142+ (window as any).__editorOpenUnsub();
143143+ }
144144+ delete (window as any).__editorOpenCaptured;
145145+ delete (window as any).__editorOpenUnsub;
146146+ });
147147+148148+ // Close cmd window if still open
149149+ try {
150150+ if (openResult.id) {
151151+ await bgWindow.evaluate(async (id: number) => {
152152+ return await (window as any).app.window.close(id);
153153+ }, openResult.id);
154154+ }
155155+ } catch {
156156+ // Panel may have already closed via shutdown()
157157+ }
158158+159159+ // Clean up the test note
160160+ if (noteId) {
161161+ await bgWindow.evaluate(async (id: string) => {
162162+ return await (window as any).app.datastore.deleteItem(id);
163163+ }, noteId);
164164+ }
165165+ });
166166+});
+476
tests/desktop/command-chaining.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { waitForPanelCommandsLoaded, waitForClass, waitForResultsWithContent, waitForCommand } from '../helpers/window-utils';
55+66+test.describe('Command Chaining @desktop', () => {
77+ let app: DesktopApp;
88+ let bgWindow: Page;
99+1010+ test.beforeAll(async () => {
1111+ ({ app, bgWindow } = await createPerDescribeApp('cmd-chain'));
1212+ });
1313+1414+ test.afterAll(async () => {
1515+ if (app) await app.close();
1616+ });
1717+1818+ test('cmd panel loads with chain state initialized', async () => {
1919+ // Open cmd panel to verify it loads correctly with chain support
2020+ const openResult = await bgWindow.evaluate(async () => {
2121+ return await (window as any).app.window.open('peek://cmd/panel.html', {
2222+ modal: true,
2323+ width: 600,
2424+ height: 300,
2525+ frame: false,
2626+ transparent: true,
2727+ alwaysOnTop: true,
2828+ center: true
2929+ });
3030+ });
3131+ expect(openResult.success).toBe(true);
3232+3333+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
3434+ expect(cmdWindow).toBeTruthy();
3535+3636+ // Verify state object has chain properties
3737+ const hasChainState = await cmdWindow.evaluate(() => {
3838+ // Access state through the module scope would require exposing it
3939+ // Instead verify the UI elements that depend on chain state exist
4040+ const chainIndicator = document.getElementById('chain-indicator');
4141+ const previewContainer = document.getElementById('preview-container');
4242+ return chainIndicator !== null && previewContainer !== null;
4343+ });
4444+ expect(hasChainState).toBe(true);
4545+4646+ // Close the window
4747+ if (openResult.id) {
4848+ await bgWindow.evaluate(async (id: number) => {
4949+ return await (window as any).app.window.close(id);
5050+ }, openResult.id);
5151+ }
5252+ });
5353+5454+ test('MIME type matching works correctly', async () => {
5555+ // Test MIME matching logic in panel context
5656+ const openResult = await bgWindow.evaluate(async () => {
5757+ return await (window as any).app.window.open('peek://cmd/panel.html', {
5858+ modal: true,
5959+ width: 600,
6060+ height: 50,
6161+ frame: false,
6262+ transparent: true,
6363+ alwaysOnTop: true,
6464+ center: true
6565+ });
6666+ });
6767+ expect(openResult.success).toBe(true);
6868+6969+ // Find the cmd window
7070+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
7171+ expect(cmdWindow).toBeTruthy();
7272+7373+ // Test MIME type matching function (if exposed, or test via behavior)
7474+ // The panel.js has mimeTypeMatches function - we test the expected behavior
7575+7676+ // Test exact match: 'application/json' matches 'application/json'
7777+ const exactMatch = await cmdWindow.evaluate(() => {
7878+ // We can't directly call the function, but we can verify commands filter correctly
7979+ // This is more of an integration test
8080+ return true;
8181+ });
8282+ expect(exactMatch).toBe(true);
8383+8484+ // Close the window
8585+ if (openResult.id) {
8686+ await bgWindow.evaluate(async (id: number) => {
8787+ return await (window as any).app.window.close(id);
8888+ }, openResult.id);
8989+ }
9090+ });
9191+9292+ test('cmd panel input works correctly', async () => {
9393+ // Open cmd panel
9494+ const openResult = await bgWindow.evaluate(async () => {
9595+ return await (window as any).app.window.open('peek://cmd/panel.html', {
9696+ modal: true,
9797+ width: 600,
9898+ height: 400,
9999+ frame: false,
100100+ transparent: true,
101101+ alwaysOnTop: true,
102102+ center: true
103103+ });
104104+ });
105105+ expect(openResult.success).toBe(true);
106106+107107+ // Find the cmd window
108108+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
109109+ expect(cmdWindow).toBeTruthy();
110110+111111+ // Wait for input to be ready
112112+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
113113+114114+ // Verify input is focusable and can receive text
115115+ await cmdWindow.fill('input', 'test');
116116+ const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value);
117117+ expect(inputValue).toBe('test');
118118+119119+ // Close the window
120120+ if (openResult.id) {
121121+ await bgWindow.evaluate(async (id: number) => {
122122+ return await (window as any).app.window.close(id);
123123+ }, openResult.id);
124124+ }
125125+ });
126126+127127+ test('panel has chain indicator, preview, and execution state elements', async () => {
128128+ // Open cmd panel
129129+ const openResult = await bgWindow.evaluate(async () => {
130130+ return await (window as any).app.window.open('peek://cmd/panel.html', {
131131+ modal: true,
132132+ width: 600,
133133+ height: 300,
134134+ frame: false,
135135+ transparent: true,
136136+ alwaysOnTop: true,
137137+ center: true
138138+ });
139139+ });
140140+ expect(openResult.success).toBe(true);
141141+142142+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
143143+ expect(cmdWindow).toBeTruthy();
144144+145145+ // Check chain indicator element exists
146146+ const chainIndicator = await cmdWindow.$('#chain-indicator');
147147+ expect(chainIndicator).toBeTruthy();
148148+149149+ // Check preview container exists
150150+ const previewContainer = await cmdWindow.$('#preview-container');
151151+ expect(previewContainer).toBeTruthy();
152152+153153+ // Check execution state element exists
154154+ const executionState = await cmdWindow.$('#execution-state');
155155+ expect(executionState).toBeTruthy();
156156+157157+ // Verify chain indicator is initially hidden (no 'visible' class)
158158+ const chainVisible = await cmdWindow.$eval('#chain-indicator', (el: HTMLElement) => el.classList.contains('visible'));
159159+ expect(chainVisible).toBe(false);
160160+161161+ // Verify preview is initially hidden (no 'visible' class)
162162+ const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible'));
163163+ expect(previewVisible).toBe(false);
164164+165165+ // Verify execution state is initially hidden (no 'visible' class)
166166+ const execVisible = await cmdWindow.$eval('#execution-state', (el: HTMLElement) => el.classList.contains('visible'));
167167+ expect(execVisible).toBe(false);
168168+169169+ // Verify results is initially hidden (no 'visible' class)
170170+ const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible'));
171171+ expect(resultsVisible).toBe(false);
172172+173173+ // Close the window
174174+ if (openResult.id) {
175175+ await bgWindow.evaluate(async (id: number) => {
176176+ return await (window as any).app.window.close(id);
177177+ }, openResult.id);
178178+ }
179179+ });
180180+181181+ test('list urls command produces array output and enters output selection mode', async () => {
182182+ // Open cmd panel
183183+ const openResult = await bgWindow.evaluate(async () => {
184184+ return await (window as any).app.window.open('peek://cmd/panel.html', {
185185+ modal: true,
186186+ width: 600,
187187+ height: 400,
188188+ frame: false,
189189+ transparent: true,
190190+ alwaysOnTop: true,
191191+ center: true
192192+ });
193193+ });
194194+ expect(openResult.success).toBe(true);
195195+196196+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
197197+ expect(cmdWindow).toBeTruthy();
198198+199199+ // Wait for input to be ready and commands to be loaded
200200+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
201201+ await waitForPanelCommandsLoaded(cmdWindow);
202202+203203+ // Type 'list urls' command
204204+ await cmdWindow.fill('input', 'list urls');
205205+206206+ // Press down arrow to show results
207207+ await cmdWindow.press('input', 'ArrowDown');
208208+ await waitForClass(cmdWindow, '#results', 'visible');
209209+210210+ // Verify results are visible
211211+ const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible'));
212212+ expect(resultsVisible).toBe(true);
213213+214214+ // Press Enter to execute
215215+ await cmdWindow.press('input', 'Enter');
216216+ await waitForResultsWithContent(cmdWindow);
217217+218218+ // After list urls executes, we should be in output selection mode
219219+ // Results should show the items from the list urls output
220220+ const hasResults = await cmdWindow.$eval('#results', (el: HTMLElement) => {
221221+ return el.classList.contains('visible') && el.children.length > 0;
222222+ });
223223+ expect(hasResults).toBe(true);
224224+225225+ // Preview should show the selected item
226226+ const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible'));
227227+ expect(previewVisible).toBe(true);
228228+229229+ // Close the window
230230+ if (openResult.id) {
231231+ await bgWindow.evaluate(async (id: number) => {
232232+ return await (window as any).app.window.close(id);
233233+ }, openResult.id);
234234+ }
235235+ });
236236+237237+ test('selecting output item enters chain mode with filtered commands', async () => {
238238+ // Architectural contract (see docs/cmd-chain-architecture.md):
239239+ // `list urls` → OUTPUT_SELECTION → Enter on a row → CHAIN_MODE.
240240+ // CHAIN_MODE is NEVER reached direct-from-EXECUTING; the user always
241241+ // sees their rows first so they can pick which one to chain against.
242242+ await waitForCommand(bgWindow, 'csv', 10000);
243243+244244+ // Seed a couple of urls so list urls returns a selectable list
245245+ await bgWindow.evaluate(async () => {
246246+ const api = (window as any).app;
247247+ await api.datastore.addItem('url', { url: 'https://example.com/chain-a', title: 'chain-a' });
248248+ await api.datastore.addItem('url', { url: 'https://example.com/chain-b', title: 'chain-b' });
249249+ });
250250+251251+ const openResult = await bgWindow.evaluate(async () => {
252252+ return await (window as any).app.window.open('peek://cmd/panel.html', {
253253+ modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
254254+ });
255255+ });
256256+ expect(openResult.success).toBe(true);
257257+258258+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
259259+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
260260+ await waitForPanelCommandsLoaded(cmdWindow);
261261+262262+ await cmdWindow.fill('input', 'list urls');
263263+ await cmdWindow.press('input', 'Enter');
264264+265265+ // Step 1: OUTPUT_SELECTION entered first (not CHAIN_MODE)
266266+ await cmdWindow.waitForFunction(
267267+ () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION',
268268+ null, { timeout: 5000 }
269269+ );
270270+271271+ // Step 2: Enter on the selected row → CHAIN_MODE
272272+ await cmdWindow.press('input', 'Enter');
273273+ await cmdWindow.waitForFunction(
274274+ () => (window as any)._cmdState?.currentState === 'CHAIN_MODE',
275275+ null, { timeout: 5000 }
276276+ );
277277+278278+ // Step 3: CHAIN_MODE suggestions include csv (accepts application/json)
279279+ const suggestions = await cmdWindow.evaluate(() => (window as any)._cmdState?.matches || []);
280280+ expect(suggestions).toContain('csv');
281281+282282+ if (openResult.id) {
283283+ await bgWindow.evaluate(async (id: number) => {
284284+ return await (window as any).app.window.close(id);
285285+ }, openResult.id);
286286+ }
287287+ });
288288+289289+ test('csv command converts JSON to CSV format', async () => {
290290+ // list urls → OUTPUT_SELECTION → select row → CHAIN_MODE → csv → text/csv
291291+ await waitForCommand(bgWindow, 'csv', 10000);
292292+293293+ await bgWindow.evaluate(async () => {
294294+ const api = (window as any).app;
295295+ await api.datastore.addItem('url', { url: 'https://example.com/csv-a', title: 'csv-a' });
296296+ await api.datastore.addItem('url', { url: 'https://example.com/csv-b', title: 'csv-b' });
297297+ });
298298+299299+ const openResult = await bgWindow.evaluate(async () => {
300300+ return await (window as any).app.window.open('peek://cmd/panel.html', {
301301+ modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
302302+ });
303303+ });
304304+ expect(openResult.success).toBe(true);
305305+306306+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
307307+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
308308+ await waitForPanelCommandsLoaded(cmdWindow);
309309+310310+ await cmdWindow.fill('input', 'list urls');
311311+ await cmdWindow.press('input', 'Enter');
312312+313313+ // OUTPUT_SELECTION first
314314+ await cmdWindow.waitForFunction(
315315+ () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION',
316316+ null, { timeout: 5000 }
317317+ );
318318+319319+ // Enter a row → CHAIN_MODE
320320+ await cmdWindow.press('input', 'Enter');
321321+ await cmdWindow.waitForFunction(
322322+ () => (window as any)._cmdState?.currentState === 'CHAIN_MODE',
323323+ null, { timeout: 5000 }
324324+ );
325325+326326+ // Type csv to filter matches, then ArrowDown+Enter to execute
327327+ await cmdWindow.fill('input', 'csv');
328328+ await cmdWindow.waitForFunction(
329329+ () => ((window as any)._cmdState?.matches || []).includes('csv'),
330330+ null, { timeout: 5000 }
331331+ );
332332+ await cmdWindow.press('input', 'ArrowDown');
333333+ await cmdWindow.press('input', 'Enter');
334334+335335+ // csv produces text/csv — wait for state to leave EXECUTING (csv is lazy
336336+ // so first invoke loads the tile; proxy has 30s timeout, bucket covers)
337337+ await cmdWindow.waitForFunction(
338338+ () => (window as any)._cmdState?.currentState !== 'EXECUTING',
339339+ null, { timeout: 35000 }
340340+ );
341341+342342+ // After csv execution, either chainContext has text/csv (chain continues)
343343+ // or the panel transitioned to CLOSING (terminal csv output). Both are valid
344344+ // terminal states — we only fail if csv's result never arrived at all.
345345+ const final = await cmdWindow.evaluate(() => {
346346+ const s = (window as any)._cmdState;
347347+ return { state: s?.currentState, mimeType: s?.chainContext?.mimeType };
348348+ });
349349+ if (final.state === 'CHAIN_MODE') {
350350+ expect(final.mimeType).toBe('text/csv');
351351+ } else {
352352+ // csv is lazy-loaded; first invoke may exceed the proxy's 30s timeout
353353+ // in slow CI. ERROR is also acceptable — this test verifies the chain
354354+ // plumbing reaches csv, not lazy-tile performance.
355355+ expect(['CLOSING', 'IDLE', 'OUTPUT_SELECTION', 'ERROR']).toContain(final.state);
356356+ }
357357+358358+ if (openResult.id) {
359359+ await bgWindow.evaluate(async (id: number) => {
360360+ return await (window as any).app.window.close(id);
361361+ }, openResult.id);
362362+ }
363363+ });
364364+365365+ test('escape exits chain mode before closing panel', async () => {
366366+ // Canonical ESC layering: ESC in CHAIN_MODE exits chain (back to
367367+ // OUTPUT_SELECTION or IDLE depending on stack), does NOT close the panel.
368368+ await waitForCommand(bgWindow, 'csv', 10000);
369369+370370+ await bgWindow.evaluate(async () => {
371371+ const api = (window as any).app;
372372+ await api.datastore.addItem('url', { url: 'https://example.com/esc-a', title: 'esc-a' });
373373+ await api.datastore.addItem('url', { url: 'https://example.com/esc-b', title: 'esc-b' });
374374+ });
375375+376376+ const openResult = await bgWindow.evaluate(async () => {
377377+ return await (window as any).app.window.open('peek://cmd/panel.html', {
378378+ modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
379379+ });
380380+ });
381381+ expect(openResult.success).toBe(true);
382382+383383+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
384384+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
385385+ await waitForPanelCommandsLoaded(cmdWindow);
386386+387387+ await cmdWindow.fill('input', 'list urls');
388388+ await cmdWindow.press('input', 'Enter');
389389+ await cmdWindow.waitForFunction(
390390+ () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION',
391391+ null, { timeout: 5000 }
392392+ );
393393+ await cmdWindow.press('input', 'Enter');
394394+ await cmdWindow.waitForFunction(
395395+ () => (window as any)._cmdState?.currentState === 'CHAIN_MODE',
396396+ null, { timeout: 5000 }
397397+ );
398398+399399+ // ESC in CHAIN_MODE exits the chain — state changes away from CHAIN_MODE
400400+ // (target is OUTPUT_SELECTION, TYPING, or IDLE per implementation), but
401401+ // NOT CLOSING — the panel remains open.
402402+ await cmdWindow.press('input', 'Escape');
403403+ await cmdWindow.waitForFunction(
404404+ () => {
405405+ const s = (window as any)._cmdState?.currentState;
406406+ return s !== 'CHAIN_MODE' && s !== 'CLOSING';
407407+ },
408408+ null, { timeout: 5000 }
409409+ );
410410+411411+ const inputExists = await cmdWindow.$('input');
412412+ expect(inputExists).toBeTruthy();
413413+414414+ if (openResult.id) {
415415+ await bgWindow.evaluate(async (id: number) => {
416416+ return await (window as any).app.window.close(id);
417417+ }, openResult.id);
418418+ }
419419+ });
420420+421421+ test('arrow navigation in output selection mode', async () => {
422422+ // Seed multiple urls so navigation has more than one row
423423+ await bgWindow.evaluate(async () => {
424424+ const api = (window as any).app;
425425+ await api.datastore.addItem('url', { url: 'https://example.com/nav-a', title: 'nav-a' });
426426+ await api.datastore.addItem('url', { url: 'https://example.com/nav-b', title: 'nav-b' });
427427+ });
428428+429429+ const openResult = await bgWindow.evaluate(async () => {
430430+ return await (window as any).app.window.open('peek://cmd/panel.html', {
431431+ modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
432432+ });
433433+ });
434434+ expect(openResult.success).toBe(true);
435435+436436+ const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
437437+ await cmdWindow.waitForSelector('input', { timeout: 5000 });
438438+ await waitForPanelCommandsLoaded(cmdWindow);
439439+440440+ await cmdWindow.fill('input', 'list urls');
441441+ await cmdWindow.press('input', 'Enter');
442442+443443+ // OUTPUT_SELECTION with at least 2 rows
444444+ await cmdWindow.waitForFunction(
445445+ () => {
446446+ const s = (window as any)._cmdState;
447447+ return s?.currentState === 'OUTPUT_SELECTION' && (s?.outputItems?.length ?? 0) > 1;
448448+ },
449449+ null, { timeout: 5000 }
450450+ );
451451+452452+ // First item is selected (outputItemIndex starts at 0)
453453+ const initialIndex = await cmdWindow.evaluate(() => (window as any)._cmdState?.outputItemIndex);
454454+ expect(initialIndex).toBe(0);
455455+456456+ // Arrow down → index 1
457457+ await cmdWindow.press('input', 'ArrowDown');
458458+ await cmdWindow.waitForFunction(
459459+ () => (window as any)._cmdState?.outputItemIndex === 1,
460460+ null, { timeout: 2000 }
461461+ );
462462+463463+ // Arrow up → back to 0
464464+ await cmdWindow.press('input', 'ArrowUp');
465465+ await cmdWindow.waitForFunction(
466466+ () => (window as any)._cmdState?.outputItemIndex === 0,
467467+ null, { timeout: 2000 }
468468+ );
469469+470470+ if (openResult.id) {
471471+ await bgWindow.evaluate(async (id: number) => {
472472+ return await (window as any).app.window.close(id);
473473+ }, openResult.id);
474474+ }
475475+ });
476476+});
+556
tests/desktop/command-execution.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { sleep } from '../helpers/window-utils';
55+66+// ============================================================================
77+// Command Execution Tests (uses shared app)
88+// Tests the full command execution path through pubsub:
99+// cmd:execute:<name> -> extension handler -> result via resultTopic
1010+// ============================================================================
1111+1212+test.describe('Command Execution @desktop', () => {
1313+ let app: DesktopApp;
1414+ let bgWindow: Page;
1515+ let pageWindowId: number | null = null;
1616+ const testPageUrl = `https://cmd-exec-test-${Date.now()}.example.com/`;
1717+1818+ test.beforeAll(async () => {
1919+ ({ app, bgWindow } = await createPerDescribeApp('cmd-exec'));
2020+2121+ // Open a page window so tag commands have an "active window" to work with
2222+ const openResult = await bgWindow.evaluate(async (url: string) => {
2323+ return await (window as any).app.window.open(url, {
2424+ width: 800,
2525+ height: 600,
2626+ key: 'cmd-exec-test-page'
2727+ });
2828+ }, testPageUrl);
2929+3030+ if (openResult.success && openResult.id) {
3131+ pageWindowId = openResult.id;
3232+ }
3333+3434+ // Give the page window time to load page.js, complete api.initialize(),
3535+ // and subscribe to tag pubsub events. Without this wait, the first tag
3636+ // command fires before page.js's subscribe is installed → missed event.
3737+ await sleep(2000);
3838+ });
3939+4040+ test.afterAll(async () => {
4141+ // Close the page window we opened
4242+ if (pageWindowId && bgWindow && !bgWindow.isClosed()) {
4343+ try {
4444+ await bgWindow.evaluate(async (id: number) => {
4545+ return await (window as any).app.window.close(id);
4646+ }, pageWindowId);
4747+ } catch { /* app may already be closing */ }
4848+ }
4949+ if (app) await app.close();
5050+ });
5151+5252+ test('tag command with # prefixed tags stores tags without prefix', async () => {
5353+ const timestamp = Date.now();
5454+ const tag1 = `testfoo${timestamp}`;
5555+ const tag2 = `testbar${timestamp}`;
5656+5757+ // Execute the tag command through pubsub
5858+ const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
5959+ const api = (window as any).app;
6060+ return new Promise((resolve) => {
6161+ const resultTopic = `cmd:execute:${args.name}:result`;
6262+ api.subscribe(resultTopic, (result: any) => {
6363+ resolve(result);
6464+ }, api.scopes.GLOBAL);
6565+6666+ api.publish(`cmd:execute:${args.name}`, {
6767+ search: args.search,
6868+ params: [],
6969+ expectResult: true,
7070+ resultTopic
7171+ }, api.scopes.GLOBAL);
7272+7373+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
7474+ });
7575+ }, { name: 'tag', search: `#${tag1} #${tag2}` });
7676+7777+ expect((result as any).success).toBe(true);
7878+7979+ // Verify tags are stored WITHOUT the # prefix
8080+ const added = (result as any).added || [];
8181+ expect(added).toContain(tag1);
8282+ expect(added).toContain(tag2);
8383+ // Ensure no # prefix leaked through
8484+ expect(added.some((t: string) => t.startsWith('#'))).toBe(false);
8585+8686+ // Verify via datastore: find items tagged with tag1 (tag-centric check,
8787+ // since getActiveWindow() may return a different window than testPageUrl)
8888+ const itemCheck = await bgWindow.evaluate(async (tagName: string) => {
8989+ const api = (window as any).app;
9090+ const tagResult = await api.datastore.getOrCreateTag(tagName);
9191+ if (!tagResult.success) return { found: false };
9292+ const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
9393+ return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 };
9494+ }, tag1);
9595+9696+ expect(itemCheck.found).toBe(true);
9797+ });
9898+9999+ test('tag command without # prefix works the same way', async () => {
100100+ const timestamp = Date.now();
101101+ const tagName = `testbaz${timestamp}`;
102102+103103+ const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
104104+ const api = (window as any).app;
105105+ return new Promise((resolve) => {
106106+ const resultTopic = `cmd:execute:${args.name}:result`;
107107+ api.subscribe(resultTopic, (result: any) => {
108108+ resolve(result);
109109+ }, api.scopes.GLOBAL);
110110+111111+ api.publish(`cmd:execute:${args.name}`, {
112112+ search: args.search,
113113+ params: [],
114114+ expectResult: true,
115115+ resultTopic
116116+ }, api.scopes.GLOBAL);
117117+118118+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
119119+ });
120120+ }, { name: 'tag', search: tagName });
121121+122122+ expect((result as any).success).toBe(true);
123123+ const added = (result as any).added || [];
124124+ expect(added).toContain(tagName);
125125+ });
126126+127127+ test('tag command creates item if none exists', async () => {
128128+ const timestamp = Date.now();
129129+ // Open a new page window with a URL that has no item yet
130130+ const newUrl = `https://cmd-exec-new-item-${timestamp}.example.com/`;
131131+ const tagName = `newtag${timestamp}`;
132132+133133+ // Close the shared page window so the new window becomes the "active" one
134134+ // (getActiveWindow returns the first non-internal window)
135135+ if (pageWindowId) {
136136+ await bgWindow.evaluate(async (id: number) => {
137137+ return await (window as any).app.window.close(id);
138138+ }, pageWindowId);
139139+ pageWindowId = null;
140140+ await sleep(200);
141141+ }
142142+143143+ const openResult = await bgWindow.evaluate(async (url: string) => {
144144+ return await (window as any).app.window.open(url, {
145145+ width: 800,
146146+ height: 600,
147147+ key: `cmd-exec-new-item-${Date.now()}`
148148+ });
149149+ }, newUrl);
150150+ expect(openResult.success).toBe(true);
151151+ const newWindowId = openResult.id;
152152+153153+ // Give the window time to register
154154+ await sleep(500);
155155+156156+ // Execute tag command — should create item and tag it
157157+ const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
158158+ const api = (window as any).app;
159159+ return new Promise((resolve) => {
160160+ const resultTopic = `cmd:execute:${args.name}:result`;
161161+ api.subscribe(resultTopic, (result: any) => {
162162+ resolve(result);
163163+ }, api.scopes.GLOBAL);
164164+165165+ api.publish(`cmd:execute:${args.name}`, {
166166+ search: args.search,
167167+ params: [],
168168+ expectResult: true,
169169+ resultTopic
170170+ }, api.scopes.GLOBAL);
171171+172172+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
173173+ });
174174+ }, { name: 'tag', search: `#${tagName}` });
175175+176176+ expect((result as any).success).toBe(true);
177177+ expect((result as any).added).toContain(tagName);
178178+179179+ // Verify an item was created and tagged (tag-centric check,
180180+ // since getActiveWindow() may not return newUrl if other windows exist)
181181+ const itemCheck = await bgWindow.evaluate(async (tag: string) => {
182182+ const api = (window as any).app;
183183+ const tagResult = await api.datastore.getOrCreateTag(tag);
184184+ if (!tagResult.success) return { found: false };
185185+ const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
186186+ return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 };
187187+ }, tagName);
188188+189189+ expect(itemCheck.found).toBe(true);
190190+191191+ // Reopen a shared page window for remaining tests
192192+ const reopenResult = await bgWindow.evaluate(async (url: string) => {
193193+ return await (window as any).app.window.open(url, {
194194+ width: 800,
195195+ height: 600,
196196+ key: 'cmd-exec-test-page'
197197+ });
198198+ }, testPageUrl);
199199+ if (reopenResult.success && reopenResult.id) {
200200+ pageWindowId = reopenResult.id;
201201+ }
202202+ await sleep(300);
203203+204204+ // Clean up the test window
205205+ if (newWindowId) {
206206+ await bgWindow.evaluate(async (id: number) => {
207207+ return await (window as any).app.window.close(id);
208208+ }, newWindowId);
209209+ }
210210+ });
211211+212212+ test('untag command removes tags from item', async () => {
213213+ const timestamp = Date.now();
214214+ const tagName = `untagme${timestamp}`;
215215+216216+ // First, tag the item via command execution
217217+ const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
218218+ const api = (window as any).app;
219219+ return new Promise((resolve) => {
220220+ const resultTopic = `cmd:execute:${args.name}:result`;
221221+ api.subscribe(resultTopic, (result: any) => {
222222+ resolve(result);
223223+ }, api.scopes.GLOBAL);
224224+225225+ api.publish(`cmd:execute:${args.name}`, {
226226+ search: args.search,
227227+ params: [],
228228+ expectResult: true,
229229+ resultTopic
230230+ }, api.scopes.GLOBAL);
231231+232232+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
233233+ });
234234+ }, { name: 'tag', search: `#${tagName}` });
235235+236236+ expect((tagResult as any).success).toBe(true);
237237+ expect((tagResult as any).added).toContain(tagName);
238238+239239+ // Verify tag exists (tag-centric check)
240240+ const beforeCheck = await bgWindow.evaluate(async (tag: string) => {
241241+ const api = (window as any).app;
242242+ const tagResult = await api.datastore.getOrCreateTag(tag);
243243+ if (!tagResult.success) return { found: false };
244244+ const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
245245+ return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 };
246246+ }, tagName);
247247+248248+ expect(beforeCheck.found).toBe(true);
249249+250250+ // Now untag via the untag command
251251+ const untagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
252252+ const api = (window as any).app;
253253+ return new Promise((resolve) => {
254254+ const resultTopic = `cmd:execute:${args.name}:result`;
255255+ api.subscribe(resultTopic, (result: any) => {
256256+ resolve(result);
257257+ }, api.scopes.GLOBAL);
258258+259259+ api.publish(`cmd:execute:${args.name}`, {
260260+ search: args.search,
261261+ params: [],
262262+ expectResult: true,
263263+ resultTopic
264264+ }, api.scopes.GLOBAL);
265265+266266+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
267267+ });
268268+ }, { name: 'untag', search: `#${tagName}` });
269269+270270+ expect((untagResult as any).success).toBe(true);
271271+272272+ // Verify tag is removed (no items with this tag anymore)
273273+ const afterCheck = await bgWindow.evaluate(async (tag: string) => {
274274+ const api = (window as any).app;
275275+ const tagResult = await api.datastore.getOrCreateTag(tag);
276276+ if (!tagResult.success) return { removed: false };
277277+ const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
278278+ return { removed: !tagged.data || tagged.data.length === 0 };
279279+ }, tagName);
280280+281281+ expect(afterCheck.removed).toBe(true);
282282+ });
283283+284284+ test('tags page widget updates dynamically when tag is added via command', async () => {
285285+ const timestamp = Date.now();
286286+ const setupTag = `setupdyn${timestamp}`;
287287+ const dynamicTag = `testdynamic${timestamp}`;
288288+ // Per-test unique URL + key. Prevents cross-test window / item reuse in
289289+ // the shared-app full-suite context (where the describe's `pageWindowId`
290290+ // may have accumulated state from earlier tests or been closed/reopened
291291+ // with stale subscribers). Isolating this test to its own page window is
292292+ // the robust fix for the full-suite ordering flake — the other tests in
293293+ // this describe don't query #tags-list, so they tolerate the shared
294294+ // window; only this one is sensitive to page.js subscriber state.
295295+ const dynamicUrl = `https://cmd-exec-dyn-${timestamp}.example.com/`;
296296+ const dynamicKey = `cmd-exec-dyn-${timestamp}`;
297297+298298+ // Close the shared page window so getActiveWindow() picks our fresh one
299299+ // (matches the pattern in "tag command creates item if none exists").
300300+ const hadSharedWindow = pageWindowId !== null;
301301+ if (pageWindowId) {
302302+ await bgWindow.evaluate(async (id: number) => {
303303+ return await (window as any).app.window.close(id);
304304+ }, pageWindowId);
305305+ pageWindowId = null;
306306+ await sleep(200);
307307+ }
308308+309309+ // Open our isolated page window
310310+ const openResult = await bgWindow.evaluate(async (args: { url: string; key: string }) => {
311311+ return await (window as any).app.window.open(args.url, {
312312+ width: 800,
313313+ height: 600,
314314+ key: args.key
315315+ });
316316+ }, { url: dynamicUrl, key: dynamicKey });
317317+ expect(openResult.success).toBe(true);
318318+ const testWindowId = openResult.id;
319319+320320+ // Wait for page.js to initialize and subscribe to pubsub
321321+ // (same 2s wait as in beforeAll — matches page.js init timing)
322322+ await sleep(2000);
323323+324324+ // Helper to execute a tag command and wait for the result
325325+ const executeTag = async (tag: string) => {
326326+ return bgWindow.evaluate(async (args: { name: string; search: string }) => {
327327+ const api = (window as any).app;
328328+ return new Promise((resolve) => {
329329+ const resultTopic = `cmd:execute:${args.name}:result`;
330330+ api.subscribe(resultTopic, (result: any) => {
331331+ resolve(result);
332332+ }, api.scopes.GLOBAL);
333333+ api.publish(`cmd:execute:${args.name}`, {
334334+ search: args.search,
335335+ params: [],
336336+ expectResult: true,
337337+ resultTopic
338338+ }, api.scopes.GLOBAL);
339339+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
340340+ });
341341+ }, { name: 'tag', search: tag });
342342+ };
343343+344344+ try {
345345+ // Grab the page window handle first so we can gate on page.js readiness
346346+ // BEFORE firing the setup tag. Under full-suite load the 2s sleep above
347347+ // is not enough — the tag command can otherwise publish `tag:item-added`
348348+ // before page.js has run its top-level `api.subscribe('tag:item-added',
349349+ // ...)`. tile-preload's `subscribeImpl` attaches the underlying
350350+ // `ipcRenderer.on('pubsub:tag:item-added')` listener synchronously from
351351+ // page.js module evaluation, so gating on `__pageModuleReady` (the
352352+ // sentinel flipped at the very bottom of page.js) guarantees the
353353+ // listener is live. Electron's `webContents.send` is fire-and-forget —
354354+ // a pubsub event that arrives before the listener is attached is
355355+ // silently dropped, which is the root cause of the full-suite flake.
356356+ const pageWindow = await app.getWindow(dynamicKey, 10000);
357357+ expect(pageWindow).toBeTruthy();
358358+ await pageWindow.waitForFunction(
359359+ () => (window as unknown as { __pageModuleReady?: boolean }).__pageModuleReady === true,
360360+ null,
361361+ { timeout: 10000 }
362362+ );
363363+364364+ // First tag establishes the item in the datastore and triggers the page's
365365+ // resolveItemId fallback, setting currentItemId for subsequent events
366366+ const setupResult = await executeTag(setupTag);
367367+ expect((setupResult as any).success).toBe(true);
368368+369369+ // Wait for page.js to initialize and for the setup tag to appear
370370+ // (proves the reactive update path works and currentItemId is set)
371371+ await pageWindow.waitForFunction(
372372+ (expected: string) => {
373373+ const list = document.getElementById('tags-list');
374374+ if (!list) return false;
375375+ const names = Array.from(list.querySelectorAll('.tag-name'))
376376+ .map(el => el.textContent);
377377+ return names.includes(expected);
378378+ },
379379+ setupTag,
380380+ { timeout: 10000 }
381381+ );
382382+383383+ // Record tag count after setup
384384+ const tagCountBefore = await pageWindow.evaluate(() => {
385385+ const list = document.getElementById('tags-list');
386386+ return list ? list.querySelectorAll('.tag-btn').length : 0;
387387+ });
388388+389389+ // Now add a second tag — this should update the widget reactively
390390+ // because currentItemId is already set from the first tag
391391+ const result = await executeTag(dynamicTag);
392392+ expect((result as any).success).toBe(true);
393393+ expect((result as any).added).toContain(dynamicTag);
394394+395395+ // Verify the tags widget updates dynamically
396396+ await pageWindow.waitForFunction(
397397+ (expectedTag: string) => {
398398+ const list = document.getElementById('tags-list');
399399+ if (!list) return false;
400400+ const tagNames = Array.from(list.querySelectorAll('.tag-name'))
401401+ .map(el => el.textContent);
402402+ return tagNames.includes(expectedTag);
403403+ },
404404+ dynamicTag,
405405+ { timeout: 10000 }
406406+ );
407407+408408+ // Tag count increased
409409+ const tagCountAfter = await pageWindow.evaluate(() => {
410410+ const list = document.getElementById('tags-list');
411411+ return list ? list.querySelectorAll('.tag-btn').length : 0;
412412+ });
413413+ expect(tagCountAfter).toBeGreaterThan(tagCountBefore);
414414+ } finally {
415415+ // Close our isolated page window
416416+ if (testWindowId) {
417417+ await bgWindow.evaluate(async (id: number) => {
418418+ return await (window as any).app.window.close(id);
419419+ }, testWindowId);
420420+ }
421421+422422+ // Reopen the shared page window so the remaining tests in this describe
423423+ // (and the afterAll cleanup) have the state they expect.
424424+ if (hadSharedWindow) {
425425+ const reopenResult = await bgWindow.evaluate(async (url: string) => {
426426+ return await (window as any).app.window.open(url, {
427427+ width: 800,
428428+ height: 600,
429429+ key: 'cmd-exec-test-page'
430430+ });
431431+ }, testPageUrl);
432432+ if (reopenResult.success && reopenResult.id) {
433433+ pageWindowId = reopenResult.id;
434434+ }
435435+ await sleep(300);
436436+ }
437437+ }
438438+ });
439439+440440+ test('tag command with no args returns current tags', async () => {
441441+ const timestamp = Date.now();
442442+ const tagName = `showme${timestamp}`;
443443+444444+ // First add a tag so there's something to show
445445+ const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
446446+ const api = (window as any).app;
447447+ return new Promise((resolve) => {
448448+ const resultTopic = `cmd:execute:${args.name}:result`;
449449+ api.subscribe(resultTopic, (result: any) => {
450450+ resolve(result);
451451+ }, api.scopes.GLOBAL);
452452+453453+ api.publish(`cmd:execute:${args.name}`, {
454454+ search: args.search,
455455+ params: [],
456456+ expectResult: true,
457457+ resultTopic
458458+ }, api.scopes.GLOBAL);
459459+460460+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
461461+ });
462462+ }, { name: 'tag', search: tagName });
463463+464464+ expect((tagResult as any).success).toBe(true);
465465+466466+ // Now execute tag with no search args — should return current tags
467467+ const result = await bgWindow.evaluate(async (args: { name: string }) => {
468468+ const api = (window as any).app;
469469+ return new Promise((resolve) => {
470470+ const resultTopic = `cmd:execute:${args.name}:result`;
471471+ api.subscribe(resultTopic, (result: any) => {
472472+ resolve(result);
473473+ }, api.scopes.GLOBAL);
474474+475475+ api.publish(`cmd:execute:${args.name}`, {
476476+ search: '',
477477+ params: [],
478478+ expectResult: true,
479479+ resultTopic
480480+ }, api.scopes.GLOBAL);
481481+482482+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
483483+ });
484484+ }, { name: 'tag' });
485485+486486+ expect((result as any).success).toBe(true);
487487+ // Should return tags array for the active window's URL
488488+ expect(Array.isArray((result as any).tags)).toBe(true);
489489+ // The tag we just added should be in the list
490490+ const tagNames = (result as any).tags.map((t: any) => t.name);
491491+ expect(tagNames).toContain(tagName);
492492+ });
493493+494494+ test('tagset command creates tagset item with tags stripped of #', async () => {
495495+ const timestamp = Date.now();
496496+ const tag1 = `setx${timestamp}`;
497497+ const tag2 = `sety${timestamp}`;
498498+499499+ // Execute tagset command with # prefixed tags, comma separated
500500+ const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
501501+ const api = (window as any).app;
502502+ return new Promise((resolve) => {
503503+ const resultTopic = `cmd:execute:${args.name}:result`;
504504+ api.subscribe(resultTopic, (result: any) => {
505505+ resolve(result);
506506+ }, api.scopes.GLOBAL);
507507+508508+ api.publish(`cmd:execute:${args.name}`, {
509509+ search: args.search,
510510+ params: [],
511511+ expectResult: true,
512512+ resultTopic
513513+ }, api.scopes.GLOBAL);
514514+515515+ setTimeout(() => resolve({ error: 'timeout' }), 10000);
516516+ });
517517+ }, { name: 'tagset', search: `#${tag1}, #${tag2}` });
518518+519519+ expect((result as any).success).toBe(true);
520520+ expect((result as any).message).toContain(tag1);
521521+ expect((result as any).message).toContain(tag2);
522522+523523+ // Verify the tagset item was created in the datastore
524524+ const tagsetCheck = await bgWindow.evaluate(async (args: { tag1: string; tag2: string }) => {
525525+ const api = (window as any).app;
526526+ // Query for tagset items
527527+ const queryResult = await api.datastore.queryItems({ type: 'tagset', limit: 50 });
528528+ if (!queryResult.success) return { found: false };
529529+530530+ // Find our tagset by content (tags joined with ", ")
531531+ const tagset = queryResult.data.find((item: any) =>
532532+ item.content.includes(args.tag1) && item.content.includes(args.tag2)
533533+ );
534534+ if (!tagset) return { found: false };
535535+536536+ // Get the tags on the tagset item
537537+ const tagsResult = await api.datastore.getItemTags(tagset.id);
538538+ return {
539539+ found: true,
540540+ itemId: tagset.id,
541541+ content: tagset.content,
542542+ tags: tagsResult.data?.map((t: any) => t.name) || []
543543+ };
544544+ }, { tag1, tag2 });
545545+546546+ expect(tagsetCheck.found).toBe(true);
547547+ // Tags should be stored without # prefix
548548+ expect(tagsetCheck.tags).toContain(tag1);
549549+ expect(tagsetCheck.tags).toContain(tag2);
550550+ // The content field should contain the normalized tag names
551551+ expect(tagsetCheck.content).toContain(tag1);
552552+ expect(tagsetCheck.content).toContain(tag2);
553553+ // Should also have the from:cmd tag
554554+ expect(tagsetCheck.tags).toContain('from:cmd');
555555+ });
556556+});
+69
tests/desktop/command-registration.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { waitForExtensionsReady } from '../helpers/window-utils';
55+66+test.describe('Command Registration Performance @desktop', () => {
77+ let app: DesktopApp;
88+ let bgWindow: Page;
99+1010+ test.beforeAll(async () => {
1111+ ({ app, bgWindow } = await createPerDescribeApp('cmd-perf'));
1212+ // Wait for cmd extension to be fully ready before running performance tests
1313+ await waitForExtensionsReady(bgWindow, 15000);
1414+ });
1515+1616+ test.afterAll(async () => {
1717+ if (app) await app.close();
1818+ });
1919+2020+ test('cmd:register-batch is handled by cmd extension', async () => {
2121+ // Test that batch registration works by sending a batch and verifying commands appear
2222+ const result = await bgWindow.evaluate(async () => {
2323+ const api = (window as any).app;
2424+2525+ // Send a batch of test commands
2626+ api.publish('cmd:register-batch', {
2727+ commands: [
2828+ { name: 'test-batch-cmd-1', description: 'Test batch command 1', source: 'test' },
2929+ { name: 'test-batch-cmd-2', description: 'Test batch command 2', source: 'test' },
3030+ { name: 'test-batch-cmd-3', description: 'Test batch command 3', source: 'test' }
3131+ ]
3232+ }, api.scopes.GLOBAL);
3333+3434+ // Poll for commands to appear (deterministic retry instead of fixed timeout)
3535+ const maxAttempts = 20;
3636+ const pollInterval = 100;
3737+3838+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3939+ await new Promise(r => setTimeout(r, pollInterval));
4040+4141+ const commands = await new Promise<any[]>((resolve) => {
4242+ const unsub = api.subscribe('cmd:query-commands-response', (msg: any) => {
4343+ unsub?.();
4444+ resolve(msg.commands || []);
4545+ }, api.scopes.GLOBAL);
4646+ api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
4747+ setTimeout(() => resolve([]), 500);
4848+ });
4949+5050+ const batchCmds = commands.filter((c: any) => c.name.startsWith('test-batch-cmd-'));
5151+ if (batchCmds.length === 3) {
5252+ return {
5353+ totalCommands: commands.length,
5454+ batchCommandsFound: batchCmds.length,
5555+ batchCommandNames: batchCmds.map((c: any) => c.name)
5656+ };
5757+ }
5858+ }
5959+6060+ return { totalCommands: 0, batchCommandsFound: 0, batchCommandNames: [] };
6161+ });
6262+6363+ expect(result.batchCommandsFound).toBe(3);
6464+ expect(result.batchCommandNames).toContain('test-batch-cmd-1');
6565+ expect(result.batchCommandNames).toContain('test-batch-cmd-2');
6666+ expect(result.batchCommandNames).toContain('test-batch-cmd-3');
6767+ });
6868+6969+});
+166
tests/desktop/core.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { waitForWindow, waitForCommand } from '../helpers/window-utils';
55+66+test.describe('Core Functionality @desktop', () => {
77+ let app: DesktopApp;
88+ let bgWindow: Page;
99+1010+ test.beforeAll(async () => {
1111+ ({ app, bgWindow } = await createPerDescribeApp('core'));
1212+ });
1313+1414+ test.afterAll(async () => {
1515+ if (app) await app.close();
1616+ });
1717+1818+ test('app launches and extensions load', async () => {
1919+ // After v2 tile migration:
2020+ // - V2 features load as separate background BrowserWindows (peek://{id}/background.html)
2121+ // - Eager v2 features (e.g. entities, peeks, slides) launch at startup;
2222+ // lazy v2 features (e.g. example) launch on first command/event
2323+2424+ // Check that at least one eager v2 background tile window exists.
2525+ // peeks and slides are eager v2 background tiles that launch at startup.
2626+ const v2BgWindow = await waitForWindow(
2727+ () => app.windows(),
2828+ 'peek://peeks/background.html',
2929+ 15000
3030+ );
3131+ expect(v2BgWindow).toBeDefined();
3232+ });
3333+3434+ test('database is accessible', async () => {
3535+ const result = await bgWindow.evaluate(async () => {
3636+ return await (window as any).app.datastore.getStats();
3737+ });
3838+ expect(result.success).toBe(true);
3939+ expect(typeof result.data.totalAddresses).toBe('number');
4040+ });
4141+4242+ test('commands are registered', async () => {
4343+ // Commands are now owned by the cmd extension via pubsub
4444+ // Query via cmd:query-commands topic with retry for extension loading
4545+ const result = await bgWindow.evaluate(async () => {
4646+ const api = (window as any).app;
4747+4848+ const queryCommands = () => new Promise((resolve) => {
4949+ api.subscribe('cmd:query-commands-response', (msg: any) => {
5050+ resolve(msg.commands || []);
5151+ }, api.scopes.GLOBAL);
5252+ api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
5353+ setTimeout(() => resolve([]), 1000);
5454+ });
5555+5656+ // Retry a few times to allow extensions to finish loading
5757+ for (let i = 0; i < 5; i++) {
5858+ const cmds = await queryCommands() as any[];
5959+ if (cmds.some((c: any) => c.name === 'example:gallery')) {
6060+ return cmds;
6161+ }
6262+ await new Promise(r => setTimeout(r, 500));
6363+ }
6464+ return await queryCommands();
6565+ });
6666+ expect(Array.isArray(result)).toBe(true);
6767+ expect(result.length).toBeGreaterThan(0);
6868+6969+ // Should have gallery command from example extension
7070+ const galleryCmd = result.find((c: any) => c.name === 'example:gallery');
7171+ expect(galleryCmd).toBeTruthy();
7272+ });
7373+7474+ test('quit and restart commands are registered', async () => {
7575+ // quit/restart are registered asynchronously during app boot (app/index.js).
7676+ // Poll via waitForCommand before querying details to avoid startup-race flake.
7777+ await waitForCommand(bgWindow, 'quit', 10000);
7878+ await waitForCommand(bgWindow, 'restart', 10000);
7979+8080+ // Query commands via cmd extension to verify descriptions
8181+ const result = await bgWindow.evaluate(async () => {
8282+ const api = (window as any).app;
8383+8484+ return new Promise((resolve) => {
8585+ api.subscribe('cmd:query-commands-response', (msg: any) => {
8686+ const commands = msg.commands || [];
8787+ resolve({
8888+ hasQuit: commands.some((c: any) => c.name === 'quit'),
8989+ hasRestart: commands.some((c: any) => c.name === 'restart'),
9090+ quitCmd: commands.find((c: any) => c.name === 'quit'),
9191+ restartCmd: commands.find((c: any) => c.name === 'restart')
9292+ });
9393+ }, api.scopes.GLOBAL);
9494+ api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
9595+ setTimeout(() => resolve({ hasQuit: false, hasRestart: false }), 2000);
9696+ });
9797+ });
9898+9999+ expect(result.hasQuit).toBe(true);
100100+ expect(result.hasRestart).toBe(true);
101101+ expect(result.quitCmd?.description).toBe('Quit the application');
102102+ expect(result.restartCmd?.description).toBe('Restart the application');
103103+ });
104104+105105+ test('reload extension command is registered', async () => {
106106+ const result = await bgWindow.evaluate(async () => {
107107+ const api = (window as any).app;
108108+109109+ return new Promise((resolve) => {
110110+ api.subscribe('cmd:query-commands-response', (msg: any) => {
111111+ const commands = msg.commands || [];
112112+ const reloadCmd = commands.find((c: any) => c.name === 'reload extension');
113113+ resolve({
114114+ hasReloadExtension: !!reloadCmd,
115115+ description: reloadCmd?.description
116116+ });
117117+ }, api.scopes.GLOBAL);
118118+ api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
119119+ setTimeout(() => resolve({ hasReloadExtension: false }), 2000);
120120+ });
121121+ });
122122+123123+ expect(result.hasReloadExtension).toBe(true);
124124+ expect(result.description).toBe('Reload an external extension by ID');
125125+ });
126126+127127+ test('api.quit and api.restart functions exist', async () => {
128128+ const result = await bgWindow.evaluate(() => {
129129+ const api = (window as any).app;
130130+ return {
131131+ hasQuit: typeof api.quit === 'function',
132132+ hasRestart: typeof api.restart === 'function'
133133+ };
134134+ });
135135+136136+ expect(result.hasQuit).toBe(true);
137137+ expect(result.hasRestart).toBe(true);
138138+ });
139139+140140+ test('window management works', async () => {
141141+ // Open a test window
142142+ const openResult = await bgWindow.evaluate(async () => {
143143+ return await (window as any).app.window.open('about:blank', {
144144+ width: 400,
145145+ height: 300
146146+ });
147147+ });
148148+ expect(openResult.success).toBe(true);
149149+ expect(openResult.id).toBeDefined();
150150+151151+ // Wait for window to open
152152+ await app.getWindow('about:blank', 5000);
153153+154154+ // List windows
155155+ const listResult = await bgWindow.evaluate(async () => {
156156+ return await (window as any).app.window.list();
157157+ });
158158+ expect(listResult.success).toBe(true);
159159+ expect(Array.isArray(listResult.windows)).toBe(true);
160160+161161+ // Close the window
162162+ await bgWindow.evaluate(async (id: number) => {
163163+ return await (window as any).app.window.close(id);
164164+ }, openResult.id);
165165+ });
166166+});
+33
tests/desktop/cross-origin-fetch.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+55+test.describe('Cross-Origin Fetch @desktop', () => {
66+ let app: DesktopApp;
77+ let bgWindow: Page;
88+99+ test.beforeAll(async () => {
1010+ ({ app, bgWindow } = await createPerDescribeApp('cors'));
1111+ });
1212+1313+ test.afterAll(async () => {
1414+ if (app) await app.close();
1515+ });
1616+1717+ test('peek:// pages can fetch from https:// origins', async () => {
1818+ // peek:// scheme has corsEnabled: false, so fetch() to external origins should work.
1919+ // If corsEnabled were true, this would throw "Failed to fetch" due to CORS.
2020+ const result = await bgWindow.evaluate(async () => {
2121+ try {
2222+ const res = await fetch('https://public.api.bsky.app/xrpc/_health');
2323+ return { ok: res.ok, status: res.status, error: null };
2424+ } catch (err: any) {
2525+ return { ok: false, status: 0, error: err.message };
2626+ }
2727+ });
2828+2929+ expect(result.error).toBeNull();
3030+ expect(result.ok).toBe(true);
3131+ expect(result.status).toBe(200);
3232+ });
3333+});
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+55+test.describe('Settings @desktop', () => {
66+ let app: DesktopApp;
77+ let bgWindow: Page;
88+99+ test.beforeAll(async () => {
1010+ ({ app, bgWindow } = await createPerDescribeApp('settings'));
1111+ });
1212+1313+ test.afterAll(async () => {
1414+ if (app) await app.close();
1515+ });
1616+1717+ test('open and close settings', async () => {
1818+ // Settings opens on start in debug mode
1919+ const settingsWindow = await app.getWindow('settings/settings.html');
2020+ expect(settingsWindow).toBeTruthy();
2121+2222+ // Verify content loaded
2323+ await settingsWindow.waitForSelector('.settings-layout', { timeout: 5000 });
2424+ expect(await settingsWindow.$('.sidebar')).toBeTruthy();
2525+ expect(await settingsWindow.$('#sidebarNav')).toBeTruthy();
2626+2727+ // Close via window.close()
2828+ await settingsWindow.evaluate(() => window.close());
2929+ });
3030+});
+97
tests/desktop/shortcut-roundtrip.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { sleep } from '../helpers/window-utils';
55+66+// ============================================================================
77+// Shortcut Roundtrip Tests
88+//
99+// Tests the full IPC roundtrip for shortcut registration and callback firing.
1010+// The flow: renderer registers shortcut via IPC -> main stores callback with ev.reply ->
1111+// shortcut fires -> callback calls ev.reply(replyTopic) -> renderer ipcRenderer.on fires cb.
1212+//
1313+// Since Playwright keyboard.press does NOT reliably trigger Electron's before-input-event,
1414+// we trigger the shortcut callback by calling handleLocalShortcut from the main process
1515+// via evaluateMain with a synthetic input event.
1616+// ============================================================================
1717+1818+test.describe('Shortcut Roundtrip @desktop', () => {
1919+ let app: DesktopApp;
2020+ let bgWindow: Page;
2121+2222+ test.beforeAll(async () => {
2323+ ({ app, bgWindow } = await createPerDescribeApp('shortcut-roundtrip'));
2424+ });
2525+2626+ test.afterAll(async () => {
2727+ if (app) await app.close();
2828+ });
2929+3030+ test('local shortcut from background window roundtrip', async () => {
3131+ // Register a local shortcut from bgWindow, trigger it via handleLocalShortcut
3232+ // in the main process, verify callback fires in the renderer.
3333+ // This tests the basic ev.reply roundtrip for a normal BrowserWindow WebContents.
3434+3535+ // Register a local shortcut from the bgWindow
3636+ await bgWindow.evaluate(() => {
3737+ (window as any).__shortcutFired = false;
3838+ (window as any).app.shortcuts.register('Alt+F7', () => {
3939+ (window as any).__shortcutFired = true;
4040+ });
4141+ });
4242+4343+ // Wait for IPC registration to propagate to main process
4444+ await sleep(300);
4545+4646+ // Trigger the shortcut from the main process by calling handleLocalShortcut
4747+ // with a synthetic input event matching Alt+F7.
4848+ // NOTE: handleLocalShortcut invokes the stored callback synchronously, which
4949+ // in turn calls ev.reply() to send an IPC message back to the renderer. The
5050+ // ev.reply is an async side-effect that can cause Playwright to see the main
5151+ // process "evaluate" context as destroyed if we return the raw result. To
5252+ // avoid this flakiness, wrap the call in setImmediate + return via a
5353+ // pre-computed flag so the evaluate settles cleanly before IPC fans out.
5454+ const handled = await app.evaluateMain!(({ app }) => {
5555+ try {
5656+ const { handleLocalShortcut } = (globalThis as any).__peek_test;
5757+ const result = handleLocalShortcut({
5858+ type: 'keyDown',
5959+ alt: true,
6060+ shift: false,
6161+ meta: false,
6262+ control: false,
6363+ code: 'F7'
6464+ });
6565+ return !!result;
6666+ } catch (e: any) {
6767+ return 'peek_test-failed: ' + e.message;
6868+ }
6969+ }).catch((err: any) => {
7070+ // Playwright sometimes reports "Execution context was destroyed" when the
7171+ // shortcut callback fans out async IPC (ev.reply) as a side-effect of the
7272+ // evaluate. The shortcut still fires — the waitForFunction below will
7373+ // confirm it. Treat this as a soft success.
7474+ if (/context was destroyed/i.test(err?.message || '')) return true;
7575+ throw err;
7676+ });
7777+7878+ // handleLocalShortcut should return true (shortcut was found and callback invoked)
7979+ expect(handled).toBe(true);
8080+8181+ // Wait for the reply to reach the renderer and trigger the callback
8282+ await bgWindow.waitForFunction(
8383+ () => (window as any).__shortcutFired === true,
8484+ { timeout: 5000 }
8585+ );
8686+8787+ const fired = await bgWindow.evaluate(() => (window as any).__shortcutFired);
8888+ expect(fired).toBe(true);
8989+9090+ // Clean up
9191+ await bgWindow.evaluate(() => {
9292+ (window as any).app.shortcuts.unregister('Alt+F7');
9393+ delete (window as any).__shortcutFired;
9494+ });
9595+ });
9696+9797+});
+49
tests/desktop/slides.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+55+test.describe('Slides @desktop', () => {
66+ let app: DesktopApp;
77+ let bgWindow: Page;
88+99+ test.beforeAll(async () => {
1010+ ({ app, bgWindow } = await createPerDescribeApp('slides'));
1111+ });
1212+1313+ test.afterAll(async () => {
1414+ if (app) await app.close();
1515+ });
1616+1717+ test('add slides and test they work', async () => {
1818+ // Add multiple addresses to use as slides
1919+ const urls = [
2020+ 'https://slide1.example.com',
2121+ 'https://slide2.example.com',
2222+ 'https://slide3.example.com'
2323+ ];
2424+2525+ for (const url of urls) {
2626+ const result = await bgWindow.evaluate(async (uri: string) => {
2727+ return await (window as any).app.datastore.addAddress(uri, {
2828+ title: `Slide: ${uri}`,
2929+ starred: 1
3030+ });
3131+ }, url);
3232+ expect(result.success).toBe(true);
3333+ }
3434+3535+ // Verify slides extension is loaded (hybrid mode: may be iframe or separate window)
3636+ const runningExts = await bgWindow.evaluate(async () => {
3737+ return await (window as any).app.extensions.list();
3838+ });
3939+ const slidesRunning = runningExts.data?.some((ext: any) => ext.id === 'slides');
4040+ expect(slidesRunning).toBe(true);
4141+4242+ // Query addresses to verify they were added
4343+ const queryResult = await bgWindow.evaluate(async () => {
4444+ return await (window as any).app.datastore.queryAddresses({ starred: 1, limit: 10 });
4545+ });
4646+ expect(queryResult.success).toBe(true);
4747+ expect(queryResult.data.length).toBeGreaterThanOrEqual(3);
4848+ });
4949+});
-5347
tests/desktop/smoke.spec.ts
···11-/**
22- * Peek Desktop Smoke Tests
33- *
44- * Cross-backend tests that run against both Electron and Tauri.
55- * Uses the desktopApp fixture for backend abstraction.
66- *
77- * Run with:
88- * BACKEND=electron yarn test:desktop
99- * BACKEND=tauri yarn test:desktop
1010- */
1111-1212-import { test, expect, DesktopApp, launchDesktopApp } from '../fixtures/desktop-app';
1313-import { Page } from '@playwright/test';
1414-import path from 'path';
1515-import { fileURLToPath } from 'url';
1616-import { waitForCommandResults, waitForWindowCount, waitForVisible, waitForClass, waitForResultsWithContent, waitForSelectionChange, sleep, waitForWindow, waitForExtensionsReady, queryCommandsWithRetry, waitForAppReady, waitForCommand, waitForPanelCommandsLoaded, waitForCmdNotExecuting } from '../helpers/window-utils';
1717-1818-const __filename = fileURLToPath(import.meta.url);
1919-const __dirname = path.dirname(__filename);
2020-const ROOT = path.join(__dirname, '../..');
2121-2222-// ============================================================================
2323-// PER-DESCRIBE APP INSTANCES
2424-// Each describe block launches its own Electron instance so that window leaks,
2525-// stale lastFocusedVisibleWindowId, and datastore pollution cannot cross
2626-// describe boundaries. launchDesktopApp() is called in each describe's
2727-// beforeAll and the app is closed in afterAll.
2828-// ============================================================================
2929-3030-/**
3131- * Launch a fresh app + bgWindow for a single describe block.
3232- * Call from test.beforeAll; close result.app in test.afterAll.
3333- */
3434-async function createPerDescribeApp(label: string): Promise<{ app: DesktopApp; bgWindow: Page }> {
3535- // Profile MUST start with "test" — `isTestProfile()` in backend/electron/config.ts
3636- // keys on that prefix to skip the single-instance lock. Without it, parallel
3737- // Playwright workers would all contend for the same machine-wide lock and
3838- // only one Electron launch would succeed.
3939- const profile = `test-smoke-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
4040- const app = await launchDesktopApp(profile);
4141- const bgWindow = await app.getBackgroundWindow();
4242- await waitForExtensionsReady(bgWindow);
4343- return { app, bgWindow };
4444-}
4545-4646-// ============================================================================
4747-// Settings Tests
4848-// ============================================================================
4949-5050-test.describe('Settings @desktop', () => {
5151- let app: DesktopApp;
5252- let bgWindow: Page;
5353-5454- test.beforeAll(async () => {
5555- ({ app, bgWindow } = await createPerDescribeApp('settings'));
5656- });
5757-5858- test.afterAll(async () => {
5959- if (app) await app.close();
6060- });
6161-6262- test('open and close settings', async () => {
6363- // Settings opens on start in debug mode
6464- const settingsWindow = await app.getWindow('settings/settings.html');
6565- expect(settingsWindow).toBeTruthy();
6666-6767- // Verify content loaded
6868- await settingsWindow.waitForSelector('.settings-layout', { timeout: 5000 });
6969- expect(await settingsWindow.$('.sidebar')).toBeTruthy();
7070- expect(await settingsWindow.$('#sidebarNav')).toBeTruthy();
7171-7272- // Close via window.close()
7373- await settingsWindow.evaluate(() => window.close());
7474- });
7575-});
7676-7777-// ============================================================================
7878-// Cross-Origin Fetch Tests
7979-// ============================================================================
8080-8181-test.describe('Cross-Origin Fetch @desktop', () => {
8282- let app: DesktopApp;
8383- let bgWindow: Page;
8484-8585- test.beforeAll(async () => {
8686- ({ app, bgWindow } = await createPerDescribeApp('cors'));
8787- });
8888-8989- test.afterAll(async () => {
9090- if (app) await app.close();
9191- });
9292-9393- test('peek:// pages can fetch from https:// origins', async () => {
9494- // peek:// scheme has corsEnabled: false, so fetch() to external origins should work.
9595- // If corsEnabled were true, this would throw "Failed to fetch" due to CORS.
9696- const result = await bgWindow.evaluate(async () => {
9797- try {
9898- const res = await fetch('https://public.api.bsky.app/xrpc/_health');
9999- return { ok: res.ok, status: res.status, error: null };
100100- } catch (err: any) {
101101- return { ok: false, status: 0, error: err.message };
102102- }
103103- });
104104-105105- expect(result.error).toBeNull();
106106- expect(result.ok).toBe(true);
107107- expect(result.status).toBe(200);
108108- });
109109-});
110110-111111-// ============================================================================
112112-// Command Palette Tests
113113-// ============================================================================
114114-115115-test.describe('Cmd Palette @desktop', () => {
116116- let app: DesktopApp;
117117- let bgWindow: Page;
118118-119119- test.beforeAll(async () => {
120120- ({ app, bgWindow } = await createPerDescribeApp('cmd-palette'));
121121- });
122122-123123- test.afterAll(async () => {
124124- if (app) await app.close();
125125- });
126126-127127- test('open cmd and execute gallery command', async () => {
128128- // Wait for cmd extension to be ready (critical for packaged mode where startup is slower)
129129- await waitForExtensionsReady(bgWindow, 15000);
130130-131131- // Open cmd panel via window API
132132- const openResult = await bgWindow.evaluate(async () => {
133133- return await (window as any).app.window.open('peek://cmd/panel.html', {
134134- modal: true,
135135- width: 600,
136136- height: 50,
137137- frame: false,
138138- transparent: true,
139139- alwaysOnTop: true,
140140- center: true
141141- });
142142- });
143143- expect(openResult.success).toBe(true);
144144-145145- // Find the cmd window (getWindow already polls until found)
146146- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
147147- expect(cmdWindow).toBeTruthy();
148148-149149- // Wait for input to be ready and commands to be loaded
150150- await cmdWindow.waitForSelector('input', { timeout: 5000 });
151151- await waitForPanelCommandsLoaded(cmdWindow, 10000);
152152-153153- // Type a built-in command first to verify
154154- // Built-in commands (like 'settings') load faster than extension commands
155155- await cmdWindow.fill('input', 'settings');
156156- // Press ArrowDown to show results (panel requires this to display dropdown)
157157- await cmdWindow.keyboard.press('ArrowDown');
158158- await waitForCommandResults(cmdWindow, 1, 10000); // Longer timeout for initial load
159159-160160- // Now search for the extension command
161161- await cmdWindow.fill('input', 'example:gallery');
162162- await cmdWindow.keyboard.press('ArrowDown');
163163- await waitForCommandResults(cmdWindow, 1, 10000);
164164-165165- // Press Enter to execute
166166- await cmdWindow.keyboard.press('Enter');
167167-168168- // Close the cmd window
169169- if (openResult.id) {
170170- await bgWindow.evaluate(async (id: number) => {
171171- return await (window as any).app.window.close(id);
172172- }, openResult.id);
173173- }
174174- });
175175-176176- test('edit command Tab-completion shows autocomplete and opens editor', async () => {
177177- await waitForExtensionsReady(bgWindow, 15000);
178178-179179- // Create a test note so the edit command has something to autocomplete
180180- const addResult = await bgWindow.evaluate(async () => {
181181- return await (window as any).app.datastore.addItem('text', {
182182- content: '# Edit Tab Test Note\nThis is a note for testing edit tab-completion.'
183183- });
184184- });
185185- expect(addResult.success).toBe(true);
186186- const noteId = addResult.data?.id;
187187-188188- // Set up editor:open event capture BEFORE opening the cmd panel
189189- await bgWindow.evaluate(() => {
190190- (window as any).__editorOpenCaptured = [];
191191- (window as any).__editorOpenUnsub = (window as any).app.subscribe('editor:open', (data: any) => {
192192- (window as any).__editorOpenCaptured.push(data);
193193- }, (window as any).app.scopes.GLOBAL);
194194- });
195195-196196- // Open cmd panel
197197- const openResult = await bgWindow.evaluate(async () => {
198198- return await (window as any).app.window.open('peek://cmd/panel.html', {
199199- modal: true,
200200- width: 600,
201201- height: 50,
202202- frame: false,
203203- transparent: true,
204204- alwaysOnTop: true,
205205- center: true
206206- });
207207- });
208208- expect(openResult.success).toBe(true);
209209-210210- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
211211- expect(cmdWindow).toBeTruthy();
212212-213213- await cmdWindow.waitForSelector('input', { timeout: 5000 });
214214- await waitForPanelCommandsLoaded(cmdWindow, 10000);
215215-216216- // Type "edit " (with space) to commit to the edit command and enter param mode
217217- // (Tab would cycle to 'editor' since both "edit" and "editor" match)
218218- await cmdWindow.fill('input', 'edit ');
219219-220220- // Wait for param mode and suggestions to populate
221221- await cmdWindow.waitForFunction(
222222- () => {
223223- const state = (window as any)._cmdState;
224224- return state && state.paramMode === true && state.paramCommand === 'edit'
225225- && state.paramSuggestions && state.paramSuggestions.length > 0;
226226- },
227227- undefined,
228228- { timeout: 10000 }
229229- );
230230-231231- // Verify results are visible with suggestion items
232232- await waitForResultsWithContent(cmdWindow, 5000);
233233-234234- // Press Enter to accept the first suggestion
235235- // This executes the edit command, publishes editor:open, and closes the panel
236236- await cmdWindow.keyboard.press('Enter');
237237-238238- // Verify editor:open was published by polling the captured events
239239- await bgWindow.waitForFunction(() => {
240240- return (window as any).__editorOpenCaptured && (window as any).__editorOpenCaptured.length > 0;
241241- }, undefined, { timeout: 10000 });
242242-243243- const editorOpenData = await bgWindow.evaluate(() => {
244244- return (window as any).__editorOpenCaptured[0];
245245- });
246246- expect(editorOpenData).toBeTruthy();
247247-248248- // Clean up event listener
249249- await bgWindow.evaluate(() => {
250250- if ((window as any).__editorOpenUnsub) {
251251- (window as any).__editorOpenUnsub();
252252- }
253253- delete (window as any).__editorOpenCaptured;
254254- delete (window as any).__editorOpenUnsub;
255255- });
256256-257257- // Close cmd window if still open
258258- try {
259259- if (openResult.id) {
260260- await bgWindow.evaluate(async (id: number) => {
261261- return await (window as any).app.window.close(id);
262262- }, openResult.id);
263263- }
264264- } catch {
265265- // Panel may have already closed via shutdown()
266266- }
267267-268268- // Clean up the test note
269269- if (noteId) {
270270- await bgWindow.evaluate(async (id: string) => {
271271- return await (window as any).app.datastore.deleteItem(id);
272272- }, noteId);
273273- }
274274- });
275275-});
276276-277277-// ============================================================================
278278-// Peeks Tests (uses shared app)
279279-// ============================================================================
280280-281281-test.describe('Peeks @desktop', () => {
282282- let app: DesktopApp;
283283- let bgWindow: Page;
284284-285285- test.beforeAll(async () => {
286286- ({ app, bgWindow } = await createPerDescribeApp('peeks'));
287287- });
288288-289289- test.afterAll(async () => {
290290- if (app) await app.close();
291291- });
292292-293293- test('add a peek and test it opens', async () => {
294294- // Add a peek address to the datastore
295295- const addResult = await bgWindow.evaluate(async () => {
296296- return await (window as any).app.datastore.addAddress('https://example.com', {
297297- title: 'Example Peek',
298298- description: 'Test peek for smoke tests'
299299- });
300300- });
301301- expect(addResult.success).toBe(true);
302302-303303- // Verify peeks extension is loaded (hybrid mode: may be iframe or separate window)
304304- const runningExts = await bgWindow.evaluate(async () => {
305305- return await (window as any).app.extensions.list();
306306- });
307307- const peeksRunning = runningExts.data?.some((ext: any) => ext.id === 'peeks');
308308- expect(peeksRunning).toBe(true);
309309-310310- // Open a peek window for the address we created
311311- const peekResult = await bgWindow.evaluate(async () => {
312312- return await (window as any).app.window.open('https://example.com', {
313313- width: 800,
314314- height: 600,
315315- key: 'test-peek'
316316- });
317317- });
318318- expect(peekResult.success).toBe(true);
319319-320320- // Wait for window to open (getWindow polls)
321321- const peekWindow = await app.getWindow('example.com', 5000);
322322- expect(peekWindow).toBeTruthy();
323323-324324- // Close the peek
325325- if (peekResult.id) {
326326- await bgWindow.evaluate(async (id: number) => {
327327- return await (window as any).app.window.close(id);
328328- }, peekResult.id);
329329- }
330330- });
331331-});
332332-333333-// ============================================================================
334334-// Slides Tests (uses shared app)
335335-// ============================================================================
336336-337337-test.describe('Slides @desktop', () => {
338338- let app: DesktopApp;
339339- let bgWindow: Page;
340340-341341- test.beforeAll(async () => {
342342- ({ app, bgWindow } = await createPerDescribeApp('slides'));
343343- });
344344-345345- test.afterAll(async () => {
346346- if (app) await app.close();
347347- });
348348-349349- test('add slides and test they work', async () => {
350350- // Add multiple addresses to use as slides
351351- const urls = [
352352- 'https://slide1.example.com',
353353- 'https://slide2.example.com',
354354- 'https://slide3.example.com'
355355- ];
356356-357357- for (const url of urls) {
358358- const result = await bgWindow.evaluate(async (uri: string) => {
359359- return await (window as any).app.datastore.addAddress(uri, {
360360- title: `Slide: ${uri}`,
361361- starred: 1
362362- });
363363- }, url);
364364- expect(result.success).toBe(true);
365365- }
366366-367367- // Verify slides extension is loaded (hybrid mode: may be iframe or separate window)
368368- const runningExts = await bgWindow.evaluate(async () => {
369369- return await (window as any).app.extensions.list();
370370- });
371371- const slidesRunning = runningExts.data?.some((ext: any) => ext.id === 'slides');
372372- expect(slidesRunning).toBe(true);
373373-374374- // Query addresses to verify they were added
375375- const queryResult = await bgWindow.evaluate(async () => {
376376- return await (window as any).app.datastore.queryAddresses({ starred: 1, limit: 10 });
377377- });
378378- expect(queryResult.success).toBe(true);
379379- expect(queryResult.data.length).toBeGreaterThanOrEqual(3);
380380- });
381381-});
382382-383383-// ============================================================================
384384-// Groups Navigation Tests (uses shared app)
385385-// ============================================================================
386386-387387-test.describe('Groups Navigation @desktop', () => {
388388- let app: DesktopApp;
389389- let bgWindow: Page;
390390-391391- test.beforeAll(async () => {
392392- ({ app, bgWindow } = await createPerDescribeApp('groups-nav'));
393393- });
394394-395395- test.afterAll(async () => {
396396- if (app) await app.close();
397397- });
398398-399399- test('groups to group to url and back navigation', async () => {
400400- // Create a tag/group with some items and promote it to a group
401401- const tagResult = await bgWindow.evaluate(async () => {
402402- const result = await (window as any).app.datastore.getOrCreateTag('test-group');
403403- if (result.success) {
404404- const tag = result.data.tag;
405405- let meta = {};
406406- try { meta = tag.metadata ? JSON.parse(tag.metadata) : {}; } catch {}
407407- meta.isGroup = true;
408408- await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify(meta) });
409409- }
410410- return result;
411411- });
412412- expect(tagResult.success).toBe(true);
413413- const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id;
414414-415415- // Add URL items and tag them
416416- const item1 = await bgWindow.evaluate(async () => {
417417- return await (window as any).app.datastore.addItem('url', {
418418- content: 'https://group-test-1.example.com',
419419- metadata: JSON.stringify({ title: 'Group Test 1' })
420420- });
421421- });
422422- expect(item1.success).toBe(true);
423423-424424- const item2 = await bgWindow.evaluate(async () => {
425425- return await (window as any).app.datastore.addItem('url', {
426426- content: 'https://group-test-2.example.com',
427427- metadata: JSON.stringify({ title: 'Group Test 2' })
428428- });
429429- });
430430- expect(item2.success).toBe(true);
431431-432432- // Tag the items
433433- if (tagId && item1.data?.id) {
434434- await bgWindow.evaluate(async ({ itemId, tagId }) => {
435435- return await (window as any).app.datastore.tagItem(itemId, tagId);
436436- }, { itemId: item1.data.id, tagId });
437437- }
438438-439439- if (tagId && item2.data?.id) {
440440- await bgWindow.evaluate(async ({ itemId, tagId }) => {
441441- return await (window as any).app.datastore.tagItem(itemId, tagId);
442442- }, { itemId: item2.data.id, tagId });
443443- }
444444-445445- // Open groups home
446446- const groupsResult = await bgWindow.evaluate(async () => {
447447- return await (window as any).app.window.open('peek://groups/home.html', {
448448- width: 800,
449449- height: 600
450450- });
451451- });
452452- expect(groupsResult.success).toBe(true);
453453-454454- // Find the groups window (getWindow polls)
455455- const groupsWindow = await app.getWindow('groups/home.html', 5000);
456456- expect(groupsWindow).toBeTruthy();
457457- await groupsWindow.waitForLoadState('domcontentloaded');
458458-459459- // Wait for cards to render
460460- await groupsWindow.waitForSelector('.cards', { timeout: 5000 });
461461-462462- // Click on the test-group card
463463- const groupCard = await groupsWindow.$('peek-card.group-card[data-tag-id="' + tagId + '"]');
464464- if (!groupCard) {
465465- const anyGroupCard = await groupsWindow.$('peek-card.group-card');
466466- expect(anyGroupCard).toBeTruthy();
467467- await anyGroupCard!.click();
468468- } else {
469469- await groupCard.click();
470470- }
471471-472472- // Wait for navigation to addresses view (address cards appear)
473473- await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 });
474474-475475- // Verify we're in addresses view by checking search placeholder
476476- const placeholderInGroup = await groupsWindow.evaluate(() => {
477477- const searchInput = document.querySelector('peek-input.search-input') as any;
478478- return searchInput ? searchInput.placeholder : null;
479479- });
480480- expect(placeholderInGroup).toContain('Search in');
481481-482482- // Click on an address card
483483- const addressCard = await groupsWindow.$('peek-card.address-card');
484484- expect(addressCard).toBeTruthy();
485485-486486- const windowCountBefore = app.windows().length;
487487- await addressCard!.click();
488488-489489- // Wait for new window to open
490490- await waitForWindowCount(() => app.windows(), windowCountBefore + 1, 5000);
491491-492492- // Verify a new window was opened
493493- const windowCountAfter = app.windows().length;
494494- expect(windowCountAfter).toBeGreaterThan(windowCountBefore);
495495-496496- // Navigate back to groups view
497497- // Note: Playwright's keyboard.press('Escape') doesn't reliably trigger
498498- // Electron's before-input-event handler, so we call the navigation function directly
499499- await groupsWindow.evaluate(async () => {
500500- const showGroups = (window as any).showGroups;
501501- if (showGroups) {
502502- await showGroups();
503503- }
504504- });
505505-506506- // Small delay for async operations
507507- await new Promise(resolve => setTimeout(resolve, 100));
508508-509509- // Wait for groups view (group cards appear, address cards disappear)
510510- await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 });
511511-512512- // Verify we're back in groups view by checking search placeholder
513513- const placeholderInGroups = await groupsWindow.evaluate(() => {
514514- const searchInput = document.querySelector('peek-input.search-input') as any;
515515- return searchInput ? searchInput.placeholder : null;
516516- });
517517- expect(placeholderInGroups).toBe('Search groups...');
518518-519519- // Clean up
520520- if (groupsResult.id) {
521521- try {
522522- await bgWindow.evaluate(async (id: number) => {
523523- return await (window as any).app.window.close(id);
524524- }, groupsResult.id);
525525- } catch {
526526- // Window may already be closed
527527- }
528528- }
529529-530530- // Verify items can be retrieved by tag
531531- if (tagId) {
532532- const taggedItems = await bgWindow.evaluate(async (tId: string) => {
533533- return await (window as any).app.datastore.getItemsByTag(tId);
534534- }, tagId);
535535- expect(taggedItems.success).toBe(true);
536536- expect(taggedItems.data.length).toBeGreaterThanOrEqual(2);
537537- }
538538- });
539539-});
540540-541541-// ============================================================================
542542-// IZUI Escape Protocol Tests (uses shared app)
543543-// ============================================================================
544544-545545-test.describe('IZUI Escape Protocol @desktop', () => {
546546- let app: DesktopApp;
547547- let bgWindow: Page;
548548-549549- test.beforeAll(async () => {
550550- ({ app, bgWindow } = await createPerDescribeApp('izui-escape'));
551551- });
552552-553553- test.afterAll(async () => {
554554- if (app) await app.close();
555555- });
556556-557557- test('navigate mode: escape navigates internally before requesting close', async () => {
558558-559559- // Create a group with items so we can navigate into it
560560- const tagResult = await bgWindow.evaluate(async () => {
561561- return await (window as any).app.datastore.getOrCreateTag('izui-esc-test');
562562- });
563563- expect(tagResult.success).toBe(true);
564564- const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id;
565565-566566- const item = await bgWindow.evaluate(async () => {
567567- return await (window as any).app.datastore.addItem('url', {
568568- content: 'https://izui-esc-test.example.com',
569569- metadata: JSON.stringify({ title: 'IZUI ESC Test' })
570570- });
571571- });
572572- expect(item.success).toBe(true);
573573-574574- if (tagId && item.data?.id) {
575575- await bgWindow.evaluate(async ({ itemId, tagId }) => {
576576- return await (window as any).app.datastore.tagItem(itemId, tagId);
577577- }, { itemId: item.data.id, tagId });
578578- }
579579-580580- // Open groups window (background.js uses escapeMode: 'navigate')
581581- const groupsResult = await bgWindow.evaluate(async () => {
582582- return await (window as any).app.window.open('peek://groups/home.html', {
583583- width: 800,
584584- height: 600,
585585- escapeMode: 'navigate'
586586- });
587587- });
588588- expect(groupsResult.success).toBe(true);
589589-590590- const groupsWindow = await app.getWindow('groups/home.html', 5000);
591591- expect(groupsWindow).toBeTruthy();
592592- await groupsWindow.waitForLoadState('domcontentloaded');
593593- await groupsWindow.waitForSelector('.cards', { timeout: 5000 });
594594-595595- // Navigate to addresses view by clicking a group
596596- const groupCard = await groupsWindow.$('peek-card.group-card[data-tag-id="' + tagId + '"]');
597597- if (groupCard) {
598598- await groupCard.click();
599599- } else {
600600- const anyCard = await groupsWindow.$('peek-card.group-card');
601601- expect(anyCard).toBeTruthy();
602602- await anyCard!.click();
603603- }
604604- await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 });
605605-606606- // Verify we're in addresses view
607607- const viewBefore = await groupsWindow.evaluate(() => (window as any)._groupsState.view);
608608- expect(viewBefore).toBe('addresses');
609609-610610- // Trigger escape via the IZUI chain - should navigate back to groups (handled: true)
611611- const escResult1 = await groupsWindow.evaluate(async () => {
612612- return await (window as any).app.escape.trigger();
613613- });
614614- expect(escResult1.handled).toBe(true);
615615-616616- // Wait for navigation to complete — waitForSelector is the deterministic signal
617617- // that showGroups() has rendered (the setTimeout(0) in handleEscape fires and
618618- // DOM updates before group-card is inserted into the DOM).
619619- await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 });
620620-621621- // Verify we're back in groups view
622622- const viewAfter = await groupsWindow.evaluate(() => (window as any)._groupsState.view);
623623- expect(viewAfter).toBe('groups');
624624-625625- // Trigger escape again at root - renderer returns { handled: false } at root
626626- // Backend handles close policy (child/transient windows close, active root stays)
627627- const escResult2 = await groupsWindow.evaluate(async () => {
628628- return await (window as any).app.escape.trigger();
629629- });
630630- // trigger() calls the handler directly - at root, groups returns { handled: false }
631631- expect(escResult2.handled).toBe(false);
632632-633633- // Clean up - trigger() doesn't go through backend ESC path, so window is still open
634634- if (groupsResult.id) {
635635- try {
636636- await bgWindow.evaluate(async (id: number) => {
637637- return await (window as any).app.window.close(id);
638638- }, groupsResult.id);
639639- } catch {
640640- // Window may already be closed
641641- }
642642- }
643643- });
644644-645645- test('peek-card: Enter key activates card via card-click event', async () => {
646646-647647- // Create a group with an item
648648- const tagResult = await bgWindow.evaluate(async () => {
649649- return await (window as any).app.datastore.getOrCreateTag('enter-key-test');
650650- });
651651- expect(tagResult.success).toBe(true);
652652- const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id;
653653-654654- const item = await bgWindow.evaluate(async () => {
655655- return await (window as any).app.datastore.addItem('url', {
656656- content: 'https://enter-key-test.example.com',
657657- metadata: JSON.stringify({ title: 'Enter Key Test' })
658658- });
659659- });
660660- expect(item.success).toBe(true);
661661-662662- if (tagId && item.data?.id) {
663663- await bgWindow.evaluate(async ({ itemId, tagId }) => {
664664- return await (window as any).app.datastore.tagItem(itemId, tagId);
665665- }, { itemId: item.data.id, tagId });
666666- }
667667-668668- // Open groups window
669669- const groupsResult = await bgWindow.evaluate(async () => {
670670- return await (window as any).app.window.open('peek://groups/home.html', {
671671- role: 'workspace',
672672- width: 800,
673673- height: 600
674674- });
675675- });
676676- expect(groupsResult.success).toBe(true);
677677-678678- const groupsWindow = await app.getWindow('groups/home.html', 5000);
679679- expect(groupsWindow).toBeTruthy();
680680- await groupsWindow.waitForLoadState('domcontentloaded');
681681- await groupsWindow.waitForSelector('peek-card.group-card', { timeout: 5000 });
682682-683683- // Verify we're in groups view
684684- const viewBefore = await groupsWindow.evaluate(() => (window as any)._groupsState.view);
685685- expect(viewBefore).toBe('groups');
686686-687687- // Programmatically activate the first card (simulates Enter key path)
688688- const activated = await groupsWindow.evaluate(async () => {
689689- const card = document.querySelector('peek-card.group-card') as any;
690690- if (!card) return false;
691691- card.click();
692692- return true;
693693- });
694694- expect(activated).toBe(true);
695695-696696- // Should navigate to addresses view
697697- await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 });
698698- const viewAfter = await groupsWindow.evaluate(() => (window as any)._groupsState.view);
699699- expect(viewAfter).toBe('addresses');
700700-701701- // Clean up
702702- if (groupsResult.id) {
703703- try {
704704- await bgWindow.evaluate(async (id: number) => {
705705- return await (window as any).app.window.close(id);
706706- }, groupsResult.id);
707707- } catch {
708708- // Window may already be closed
709709- }
710710- }
711711- });
712712-713713- test('active mode: ESC at root does NOT close window', async () => {
714714-715715- // Open groups window with role: 'workspace' (like the real groups extension does)
716716- // In headless/test mode, appFocused defaults to true → session is 'active'
717717- const groupsResult = await bgWindow.evaluate(async () => {
718718- return await (window as any).app.window.open('peek://groups/home.html', {
719719- role: 'workspace',
720720- width: 400,
721721- height: 300,
722722- });
723723- });
724724- expect(groupsResult.success).toBe(true);
725725- const groupsWindow = await app.getWindow('groups/home.html', 5000);
726726- expect(groupsWindow).toBeTruthy();
727727- await groupsWindow.waitForLoadState('domcontentloaded');
728728- await groupsWindow.waitForSelector('.cards', { timeout: 5000 });
729729-730730- // Verify the window's role is 'workspace' and session is 'active'
731731- const izuiState = await bgWindow.evaluate(async () => {
732732- return await (window as any).app.izui.getState();
733733- });
734734- expect(izuiState).toBe('active');
735735-736736- // Press ESC via keyboard — goes through full backend path:
737737- // before-input-event → handleEscapeForWindow → askRendererToHandleEscape →
738738- // renderer returns { handled: false } at root → escPolicy('active', 'workspace') → 'nothing'
739739- await groupsWindow.keyboard.press('Escape');
740740-741741- // Wait for the async ESC handling to complete
742742- await new Promise(resolve => setTimeout(resolve, 600));
743743-744744- // Verify window is still alive — if escPolicy is wrong, the window would be closed
745745- const stillAlive = await groupsWindow.evaluate(() => true).catch(() => false);
746746- expect(stillAlive).toBe(true);
747747-748748- // Also verify the view is still at root (groups list)
749749- const view = await groupsWindow.evaluate(() => (window as any)._groupsState?.view);
750750- expect(view).toBe('groups');
751751-752752- // Clean up
753753- if (groupsResult.id) {
754754- try {
755755- await bgWindow.evaluate(async (wid: number) => {
756756- return await (window as any).app.window.close(wid);
757757- }, groupsResult.id);
758758- } catch {
759759- // Window may already be closed
760760- }
761761- }
762762- });
763763-764764- test('active mode: ESC on child-content window does NOT close it (regression)', async () => {
765765-766766- // First open a workspace window (like groups) to establish an active session
767767- const workspaceResult = await bgWindow.evaluate(async () => {
768768- return await (window as any).app.window.open('peek://groups/home.html', {
769769- role: 'workspace',
770770- width: 400,
771771- height: 300,
772772- });
773773- });
774774- expect(workspaceResult.success).toBe(true);
775775- const workspaceWindow = await app.getWindow('groups/home.html', 5000);
776776- expect(workspaceWindow).toBeTruthy();
777777- await workspaceWindow.waitForLoadState('domcontentloaded');
778778-779779- // Verify session is active
780780- const izuiState = await bgWindow.evaluate(async () => {
781781- return await (window as any).app.izui.getState();
782782- });
783783- expect(izuiState).toBe('active');
784784-785785- // Now open a child-content window (simulates opening a web page from groups)
786786- // Using the workspace window as the opener gives it child-content role
787787- const contentResult = await workspaceWindow.evaluate(async () => {
788788- return await (window as any).app.window.open('peek://search/home.html', {
789789- role: 'child-content',
790790- width: 400,
791791- height: 300,
792792- });
793793- });
794794- expect(contentResult.success).toBe(true);
795795- const contentWindow = await app.getWindow('search/home.html', 5000);
796796- expect(contentWindow).toBeTruthy();
797797- await contentWindow.waitForLoadState('domcontentloaded');
798798-799799- // Trigger escape via the renderer callback directly — search/home.html has no
800800- // onEscape handler so trigger() returns { handled: false } immediately.
801801- // The regression: child-content would be closed on ESC before the escPolicy fix.
802802- // The backend policy (escPolicy('active','child-content') === 'nothing') is a
803803- // pure function tested in izui-state.test.ts. Here we verify the renderer path
804804- // doesn't close the window.
805805- const escResult = await contentWindow.evaluate(async () => {
806806- return await (window as any).app.escape.trigger();
807807- });
808808- expect(escResult).toEqual({ handled: false });
809809-810810- // Verify child-content window is still alive — if the window were incorrectly
811811- // closed on ESC, this evaluate() call would throw/return false.
812812- const stillAlive = await contentWindow.evaluate(() => true).catch(() => false);
813813- expect(stillAlive).toBe(true);
814814-815815- // Clean up
816816- for (const id of [contentResult.id, workspaceResult.id]) {
817817- if (id) {
818818- try {
819819- await bgWindow.evaluate(async (wid: number) => {
820820- return await (window as any).app.window.close(wid);
821821- }, id);
822822- } catch {
823823- // Window may already be closed
824824- }
825825- }
826826- }
827827- });
828828-829829- test('navigate mode: timeout does not close window', async () => {
830830-831831- // Open a window with navigate escape mode but NO escape handler registered
832832- // This simulates what happens when a window hasn't finished loading its IZUI
833833- const result = await bgWindow.evaluate(async () => {
834834- return await (window as any).app.window.open('peek://groups/home.html', {
835835- width: 400,
836836- height: 300,
837837- escapeMode: 'navigate'
838838- });
839839- });
840840- expect(result.success).toBe(true);
841841-842842- const testWindow = await app.getWindow('groups/home.html', 5000);
843843- expect(testWindow).toBeTruthy();
844844- await testWindow.waitForLoadState('domcontentloaded');
845845-846846- // The groups extension registers an escape handler via api.escape.onEscape.
847847- // At root (groups view), handler returns { handled: false }.
848848- // Backend handles close policy via navigate mode.
849849- await testWindow.waitForSelector('.cards', { timeout: 5000 });
850850- const escResult = await testWindow.evaluate(async () => {
851851- return await (window as any).app.escape.trigger();
852852- });
853853- // trigger() calls handler directly - at root, groups returns { handled: false }
854854- expect(escResult.handled).toBe(false);
855855-856856- // trigger() doesn't go through backend ESC path, window is still open
857857- // Clean up
858858- if (result.id) {
859859- try {
860860- await bgWindow.evaluate(async (id: number) => {
861861- return await (window as any).app.window.close(id);
862862- }, result.id);
863863- } catch {
864864- // Window may already be closed
865865- }
866866- }
867867- });
868868-});
869869-870870-// ============================================================================
871871-// External URL Opening Tests (uses shared app)
872872-// ============================================================================
873873-874874-test.describe('External URL Opening @desktop', () => {
875875- let app: DesktopApp;
876876- let bgWindow: Page;
877877-878878- test.beforeAll(async () => {
879879- ({ app, bgWindow } = await createPerDescribeApp('external-url'));
880880- });
881881-882882- test.afterAll(async () => {
883883- if (app) await app.close();
884884- });
885885-886886- test('open URL by calling executable', async () => {
887887- // Verify app is ready with background window
888888- expect(bgWindow).toBeTruthy();
889889- // Ensure the API is ready
890890- await waitForAppReady(bgWindow);
891891- });
892892-893893- test('cmd panel detects and opens domain without protocol (youtube.com)', async () => {
894894- await waitForExtensionsReady(bgWindow, 15000);
895895-896896- // Open cmd panel
897897- const openResult = await bgWindow.evaluate(async () => {
898898- return await (window as any).app.window.open('peek://cmd/panel.html', {
899899- modal: true,
900900- width: 600,
901901- height: 50,
902902- frame: false,
903903- transparent: true,
904904- alwaysOnTop: true,
905905- center: true
906906- });
907907- });
908908- expect(openResult.success).toBe(true);
909909-910910- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
911911- await cmdWindow.waitForSelector('input', { timeout: 5000 });
912912-913913- // Type a domain without protocol
914914- await cmdWindow.fill('input', 'example.com');
915915- await cmdWindow.keyboard.press('Enter');
916916-917917- // Wait for window to open
918918- await sleep(1000);
919919-920920- // Verify URL was opened (check window list for the URL)
921921- const windowList = await bgWindow.evaluate(async () => {
922922- return await (window as any).app.window.list();
923923- });
924924-925925- expect(windowList.success).toBe(true);
926926- // URL should be wrapped in page loader with https:// protocol
927927- const exampleWindow = windowList.windows?.find((w: any) =>
928928- w.url && (w.url.includes('https://example.com') || w.url.includes('https%3A%2F%2Fexample.com'))
929929- );
930930- expect(exampleWindow).toBeTruthy();
931931-932932- // Clean up
933933- if (exampleWindow) {
934934- await bgWindow.evaluate(async (id: number) => {
935935- await (window as any).app.window.close(id);
936936- }, exampleWindow.id);
937937- }
938938- });
939939-940940- test('cmd panel opens URL with http protocol', async () => {
941941- await waitForExtensionsReady(bgWindow, 15000);
942942-943943- // Open cmd panel
944944- const openResult = await bgWindow.evaluate(async () => {
945945- return await (window as any).app.window.open('peek://cmd/panel.html', {
946946- modal: true,
947947- width: 600,
948948- height: 50,
949949- frame: false,
950950- transparent: true,
951951- alwaysOnTop: true,
952952- center: true
953953- });
954954- });
955955- expect(openResult.success).toBe(true);
956956-957957- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
958958- await cmdWindow.waitForSelector('input', { timeout: 5000 });
959959-960960- // Type URL with http protocol
961961- await cmdWindow.fill('input', 'http://example.com');
962962- await cmdWindow.keyboard.press('Enter');
963963-964964- // Wait for window to open
965965- await sleep(1000);
966966-967967- // Verify URL was opened (should preserve http://)
968968- const windowList = await bgWindow.evaluate(async () => {
969969- return await (window as any).app.window.list();
970970- });
971971-972972- expect(windowList.success).toBe(true);
973973- const httpWindow = windowList.windows?.find((w: any) =>
974974- w.url && (w.url.includes('http://example.com') || w.url.includes('http%3A%2F%2Fexample.com'))
975975- );
976976- expect(httpWindow).toBeTruthy();
977977-978978- // Clean up
979979- if (httpWindow) {
980980- await bgWindow.evaluate(async (id: number) => {
981981- await (window as any).app.window.close(id);
982982- }, httpWindow.id);
983983- }
984984- });
985985-986986- test('cmd panel opens URL with https protocol', async () => {
987987- await waitForExtensionsReady(bgWindow, 15000);
988988-989989- // Open cmd panel
990990- const openResult = await bgWindow.evaluate(async () => {
991991- return await (window as any).app.window.open('peek://cmd/panel.html', {
992992- modal: true,
993993- width: 600,
994994- height: 50,
995995- frame: false,
996996- transparent: true,
997997- alwaysOnTop: true,
998998- center: true
999999- });
10001000- });
10011001- expect(openResult.success).toBe(true);
10021002-10031003- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
10041004- await cmdWindow.waitForSelector('input', { timeout: 5000 });
10051005-10061006- // Type URL with https protocol
10071007- await cmdWindow.fill('input', 'https://example.com');
10081008- await cmdWindow.keyboard.press('Enter');
10091009-10101010- // Wait for window to open
10111011- await sleep(1000);
10121012-10131013- // Verify URL was opened
10141014- const windowList = await bgWindow.evaluate(async () => {
10151015- return await (window as any).app.window.list();
10161016- });
10171017-10181018- expect(windowList.success).toBe(true);
10191019- const httpsWindow = windowList.windows?.find((w: any) =>
10201020- w.url && (w.url.includes('https://example.com') || w.url.includes('https%3A%2F%2Fexample.com'))
10211021- );
10221022- expect(httpsWindow).toBeTruthy();
10231023-10241024- // Clean up
10251025- if (httpsWindow) {
10261026- await bgWindow.evaluate(async (id: number) => {
10271027- await (window as any).app.window.close(id);
10281028- }, httpsWindow.id);
10291029- }
10301030- });
10311031-10321032- test('cmd panel opens localhost URLs', async () => {
10331033- await waitForExtensionsReady(bgWindow, 15000);
10341034-10351035- // Open cmd panel
10361036- const openResult = await bgWindow.evaluate(async () => {
10371037- return await (window as any).app.window.open('peek://cmd/panel.html', {
10381038- modal: true,
10391039- width: 600,
10401040- height: 50,
10411041- frame: false,
10421042- transparent: true,
10431043- alwaysOnTop: true,
10441044- center: true
10451045- });
10461046- });
10471047- expect(openResult.success).toBe(true);
10481048-10491049- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
10501050- await cmdWindow.waitForSelector('input', { timeout: 5000 });
10511051-10521052- // Type localhost with port
10531053- await cmdWindow.fill('input', 'localhost:3000');
10541054- await cmdWindow.keyboard.press('Enter');
10551055-10561056- // Wait for window to open
10571057- await sleep(1000);
10581058-10591059- // Verify URL was opened (normalized to https://localhost:3000)
10601060- const windowList = await bgWindow.evaluate(async () => {
10611061- return await (window as any).app.window.list();
10621062- });
10631063-10641064- expect(windowList.success).toBe(true);
10651065- const localhostWindow = windowList.windows?.find((w: any) =>
10661066- w.url && (w.url.includes('localhost:3000') || w.url.includes('localhost%3A3000'))
10671067- );
10681068- expect(localhostWindow).toBeTruthy();
10691069-10701070- // Clean up
10711071- if (localhostWindow) {
10721072- await bgWindow.evaluate(async (id: number) => {
10731073- await (window as any).app.window.close(id);
10741074- }, localhostWindow.id);
10751075- }
10761076- });
10771077-10781078- test('cmd panel ignores non-URL non-command text on Enter', async () => {
10791079- await waitForExtensionsReady(bgWindow, 15000);
10801080-10811081- // Snapshot window list before
10821082- const beforeList = await bgWindow.evaluate(async () => {
10831083- return await (window as any).app.window.list();
10841084- });
10851085- const beforeCount = beforeList.windows?.length || 0;
10861086-10871087- // Open cmd panel
10881088- const openResult = await bgWindow.evaluate(async () => {
10891089- return await (window as any).app.window.open('peek://cmd/panel.html', {
10901090- modal: true,
10911091- width: 600,
10921092- height: 50,
10931093- frame: false,
10941094- transparent: true,
10951095- alwaysOnTop: true,
10961096- center: true
10971097- });
10981098- });
10991099- expect(openResult.success).toBe(true);
11001100-11011101- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
11021102- await cmdWindow.waitForSelector('input', { timeout: 5000 });
11031103-11041104- // Type non-URL text (no dots, no protocol) — not a command either
11051105- await cmdWindow.fill('input', 'notaurl');
11061106- await cmdWindow.keyboard.press('Enter');
11071107-11081108- // Wait briefly to confirm nothing happens
11091109- await sleep(500);
11101110-11111111- // Verify no new windows were opened (non-URL text is ignored, not routed anywhere)
11121112- const afterList = await bgWindow.evaluate(async () => {
11131113- return await (window as any).app.window.list();
11141114- });
11151115-11161116- // Should NOT be opened as a direct URL
11171117- const directUrlWindow = afterList.windows?.find((w: any) =>
11181118- w.url === 'http://notaurl' || w.url === 'https://notaurl'
11191119- );
11201120- expect(directUrlWindow).toBeFalsy();
11211121-11221122- // Should NOT be opened as a web search either (no fallback)
11231123- const searchWindow = afterList.windows?.find((w: any) =>
11241124- w.url && w.url.includes('notaurl')
11251125- );
11261126- expect(searchWindow).toBeFalsy();
11271127-11281128- // Close cmd panel if still open
11291129- if (openResult.id) {
11301130- await bgWindow.evaluate(async (id: number) => {
11311131- try {
11321132- await (window as any).app.window.close(id);
11331133- } catch (e) {
11341134- // Already closed
11351135- }
11361136- }, openResult.id);
11371137- }
11381138- });
11391139-11401140- test('external URL handler opens URL on first click (simulates OS open-url event)', async () => {
11411141- // This test simulates clicking a URL from an external app (like clicking a link
11421142- // in another app when Peek is set as default browser).
11431143- // Tests the fix for: first click focuses app but doesn't open URL, second click works.
11441144-11451145- await waitForExtensionsReady(bgWindow, 15000);
11461146-11471147- // Get initial window count
11481148- const initialList = await bgWindow.evaluate(async () => {
11491149- return await (window as any).app.window.list();
11501150- });
11511151- const initialCount = initialList.windows?.length || 0;
11521152-11531153- // Simulate external URL open event (what happens when clicking URL from another app)
11541154- // This bypasses the normal window.open flow and tests the handleExternalUrl path
11551155- const testUrl = 'https://example.com/external-test';
11561156-11571157- // Trigger external:open-url event directly (simulates what handleExternalUrl does)
11581158- await bgWindow.evaluate(async (url: string) => {
11591159- const api = (window as any).app;
11601160- // Publish the same event that handleExternalUrl publishes — core
11611161- // renderer subscribes on GLOBAL scope, so publish with GLOBAL explicitly
11621162- // (default is SELF in tile-preload).
11631163- await api.publish('external:open-url', {
11641164- url,
11651165- trackingSource: 'external',
11661166- trackingSourceId: 'os',
11671167- timestamp: Date.now()
11681168- }, api.scopes.GLOBAL);
11691169- }, testUrl);
11701170-11711171- // Wait for window to be created (give it time to process the event)
11721172- await sleep(500);
11731173-11741174- // Verify URL was opened
11751175- const finalList = await bgWindow.evaluate(async () => {
11761176- return await (window as any).app.window.list();
11771177- });
11781178-11791179- expect(finalList.success).toBe(true);
11801180- const finalCount = finalList.windows?.length || 0;
11811181-11821182- // Should have created a new window
11831183- expect(finalCount).toBeGreaterThan(initialCount);
11841184-11851185- // Find the window with our test URL
11861186- const externalWindow = finalList.windows?.find((w: any) =>
11871187- w.url && (w.url.includes(testUrl) || w.url.includes(encodeURIComponent(testUrl)))
11881188- );
11891189- expect(externalWindow).toBeTruthy();
11901190-11911191- // Clean up
11921192- if (externalWindow) {
11931193- await bgWindow.evaluate(async (id: number) => {
11941194- await (window as any).app.window.close(id);
11951195- }, externalWindow.id);
11961196- }
11971197- });
11981198-11991199- test('handleExternalUrl from main process opens URL correctly', async () => {
12001200- // This tests the REAL external URL path — calling handleExternalUrl from
12011201- // the main process, which is what happens when macOS sends an open-url event
12021202- // or when Peek receives a second-instance signal with a URL argument.
12031203- // The previous test simulates via pubsub from the renderer; this test
12041204- // exercises the full main-process -> pubsub -> renderer -> window-open flow.
12051205-12061206- await waitForExtensionsReady(bgWindow, 15000);
12071207-12081208- // Get initial window count (include internal to see ALL windows)
12091209- const initialList = await bgWindow.evaluate(async () => {
12101210- return await (window as any).app.window.list();
12111211- });
12121212- const initialCount = initialList.windows?.length || 0;
12131213-12141214- const testUrl = 'https://example.com/main-process-external-test';
12151215-12161216- // Call handleExternalUrl from the main process (simulates real OS open-url)
12171217- const mainResult = await app.evaluateMain!(({ app }) => {
12181218- const { handleExternalUrl } = (globalThis as any).__peek_test;
12191219- if (!handleExternalUrl) return { error: 'handleExternalUrl not found on __peek_test' };
12201220- handleExternalUrl('https://example.com/main-process-external-test', 'os');
12211221- return { success: true };
12221222- });
12231223-12241224- // Verify main process call succeeded
12251225- expect((mainResult as any).error).toBeUndefined();
12261226-12271227- // Wait for window with the specific URL to appear (deterministic — no count-based checks)
12281228- let externalWindow: any = null;
12291229- const deadline = Date.now() + 5000;
12301230- while (Date.now() < deadline) {
12311231- const list = await bgWindow.evaluate(async () => {
12321232- return await (window as any).app.window.list();
12331233- });
12341234- externalWindow = list.windows?.find((w: any) =>
12351235- w.url && (w.url.includes('main-process-external-test') || w.url.includes(encodeURIComponent('main-process-external-test')))
12361236- );
12371237- if (externalWindow) break;
12381238- await sleep(100);
12391239- }
12401240-12411241- expect(externalWindow).toBeTruthy();
12421242-12431243- // Clean up
12441244- if (externalWindow) {
12451245- await bgWindow.evaluate(async (id: number) => {
12461246- await (window as any).app.window.close(id);
12471247- }, externalWindow.id);
12481248- }
12491249- });
12501250-});
12511251-12521252-// ============================================================================
12531253-// Data Persistence Tests (consolidated - single restart for all persistence checks)
12541254-// ============================================================================
12551255-12561256-test.describe('Data Persistence @desktop', () => {
12571257- test('all data persists across restart (peeks, slides, addresses, tags, theme)', async () => {
12581258- const PROFILE = 'test-all-persist-' + Date.now();
12591259-12601260- // ========== PHASE 1: Set up all data ==========
12611261- let app = await launchDesktopApp(PROFILE);
12621262- let bgWindow = await app.getBackgroundWindow();
12631263-12641264- // --- Peeks and Slides settings ---
12651265- const testPeeks = [
12661266- { title: 'Test Peek 1', uri: 'https://test-peek-1.example.com', shortcut: 'Option+1' },
12671267- { title: 'Test Peek 2', uri: 'https://test-peek-2.example.com', shortcut: 'Option+2' },
12681268- { title: 'Custom Peek', uri: 'https://custom-peek.example.com', shortcut: 'Option+3' }
12691269- ];
12701270-12711271- const testSlides = [
12721272- { title: 'Test Slide 1', uri: 'https://test-slide-1.example.com', position: 'right', size: 400 },
12731273- { title: 'Test Slide 2', uri: 'https://test-slide-2.example.com', position: 'bottom', size: 300 }
12741274- ];
12751275-12761276- // Save peeks items
12771277- const savePeeksResult = await bgWindow.evaluate(async (items) => {
12781278- const api = (window as any).app;
12791279- return await api.datastore.setRow('feature_settings', 'peeks:items', {
12801280- featureId: 'peeks',
12811281- key: 'items',
12821282- value: JSON.stringify(items),
12831283- updatedAt: Date.now()
12841284- });
12851285- }, testPeeks);
12861286- expect(savePeeksResult.success).toBe(true);
12871287-12881288- // Save slides items
12891289- const saveSlidesResult = await bgWindow.evaluate(async (items) => {
12901290- const api = (window as any).app;
12911291- return await api.datastore.setRow('feature_settings', 'slides:items', {
12921292- featureId: 'slides',
12931293- key: 'items',
12941294- value: JSON.stringify(items),
12951295- updatedAt: Date.now()
12961296- });
12971297- }, testSlides);
12981298- expect(saveSlidesResult.success).toBe(true);
12991299-13001300- // Save prefs
13011301- const savePeeksPrefs = await bgWindow.evaluate(async () => {
13021302- const api = (window as any).app;
13031303- return await api.datastore.setRow('feature_settings', 'peeks:prefs', {
13041304- featureId: 'peeks',
13051305- key: 'prefs',
13061306- value: JSON.stringify({ shortcutKeyPrefix: 'Option+' }),
13071307- updatedAt: Date.now()
13081308- });
13091309- });
13101310- expect(savePeeksPrefs.success).toBe(true);
13111311-13121312- const saveSlidesPrefs = await bgWindow.evaluate(async () => {
13131313- const api = (window as any).app;
13141314- return await api.datastore.setRow('feature_settings', 'slides:prefs', {
13151315- featureId: 'slides',
13161316- key: 'prefs',
13171317- value: JSON.stringify({ defaultPosition: 'right', defaultSize: 350 }),
13181318- updatedAt: Date.now()
13191319- });
13201320- });
13211321- expect(saveSlidesPrefs.success).toBe(true);
13221322-13231323- // --- Addresses and Tags ---
13241324- const addr1 = await bgWindow.evaluate(async () => {
13251325- return await (window as any).app.datastore.addAddress('https://persist-test-1.example.com', {
13261326- title: 'Persist Test 1',
13271327- starred: 1
13281328- });
13291329- });
13301330- expect(addr1.success).toBe(true);
13311331-13321332- const addr2 = await bgWindow.evaluate(async () => {
13331333- return await (window as any).app.datastore.addAddress('https://persist-test-2.example.com', {
13341334- title: 'Persist Test 2'
13351335- });
13361336- });
13371337- expect(addr2.success).toBe(true);
13381338-13391339- const tagResult = await bgWindow.evaluate(async () => {
13401340- return await (window as any).app.datastore.getOrCreateTag('persist-tag');
13411341- });
13421342- expect(tagResult.success).toBe(true);
13431343- const tagId = tagResult.data?.tag?.id || tagResult.data?.id;
13441344-13451345- if (tagId && addr1.data?.id) {
13461346- await bgWindow.evaluate(async ({ addressId, tagId }) => {
13471347- return await (window as any).app.datastore.tagAddress(addressId, tagId);
13481348- }, { addressId: addr1.data.id, tagId });
13491349- }
13501350-13511351- // --- Theme ---
13521352- const setThemeResult = await bgWindow.evaluate(async () => {
13531353- return await (window as any).app.theme.setTheme('peek');
13541354- });
13551355- expect(setThemeResult.success).toBe(true);
13561356-13571357- // Verify theme is set before restart
13581358- const themeState1 = await bgWindow.evaluate(async () => {
13591359- return await (window as any).app.theme.get();
13601360- });
13611361- expect(themeState1.themeId).toBe('peek');
13621362-13631363- // Ensure data is flushed before closing
13641364- await sleep(500);
13651365- await app.close();
13661366-13671367- // Wait for app to fully shut down
13681368- await sleep(1000);
13691369-13701370- // ========== PHASE 2: Verify all data persisted ==========
13711371- app = await launchDesktopApp(PROFILE);
13721372- bgWindow = await app.getBackgroundWindow();
13731373- await waitForExtensionsReady(bgWindow);
13741374-13751375- // --- Verify Peeks and Slides ---
13761376- const persistedSettings = await bgWindow.evaluate(async () => {
13771377- const api = (window as any).app;
13781378- return await api.datastore.getTable('feature_settings');
13791379- });
13801380- expect(persistedSettings.success).toBe(true);
13811381-13821382- const settingsData = persistedSettings.data as Record<string, any>;
13831383-13841384- // Peeks items
13851385- const peeksItems = settingsData['peeks:items'];
13861386- expect(peeksItems).toBeTruthy();
13871387- expect(peeksItems.featureId).toBe('peeks');
13881388- const parsedPeeks = JSON.parse(peeksItems.value);
13891389- expect(parsedPeeks.length).toBe(3);
13901390- expect(parsedPeeks[0].title).toBe('Test Peek 1');
13911391-13921392- // Slides items
13931393- const slidesItems = settingsData['slides:items'];
13941394- expect(slidesItems).toBeTruthy();
13951395- const parsedSlides = JSON.parse(slidesItems.value);
13961396- expect(parsedSlides.length).toBe(2);
13971397-13981398- // Peeks prefs
13991399- const peeksPrefs = settingsData['peeks:prefs'];
14001400- expect(peeksPrefs).toBeTruthy();
14011401- const parsedPeeksPrefs = JSON.parse(peeksPrefs.value);
14021402- expect(parsedPeeksPrefs.shortcutKeyPrefix).toBe('Option+');
14031403-14041404- // --- Verify Items (addresses are now stored as URL items) ---
14051405- const itemsResult = await bgWindow.evaluate(async () => {
14061406- return await (window as any).app.datastore.queryItems({ type: 'url' });
14071407- });
14081408- expect(itemsResult.success).toBe(true);
14091409-14101410- const items = itemsResult.data;
14111411- expect(items.length).toBeGreaterThanOrEqual(2);
14121412-14131413- const persistedItem1 = items.find((a: any) =>
14141414- a.content === 'https://persist-test-1.example.com/' ||
14151415- a.content?.includes('persist-test-1')
14161416- );
14171417- expect(persistedItem1).toBeTruthy();
14181418- expect(persistedItem1.title).toBe('Persist Test 1');
14191419-14201420- const tagsResult = await bgWindow.evaluate(async () => {
14211421- return await (window as any).app.datastore.getTagsByFrecency(10);
14221422- });
14231423- expect(tagsResult.success).toBe(true);
14241424- const persistTag = tagsResult.data.find((t: any) => t.name === 'persist-tag');
14251425- expect(persistTag).toBeTruthy();
14261426-14271427- // --- Verify Theme ---
14281428- const themeState2 = await bgWindow.evaluate(async () => {
14291429- return await (window as any).app.theme.get();
14301430- });
14311431- expect(themeState2.themeId).toBe('peek');
14321432-14331433- // Open settings window to verify the theme CSS is loaded correctly
14341434- await bgWindow.evaluate(async () => {
14351435- return await (window as any).app.window.open('peek://app/settings/settings.html', {
14361436- width: 800, height: 600
14371437- });
14381438- });
14391439-14401440- const settingsWin = await app.getWindow('settings/settings.html', 5000);
14411441- expect(settingsWin).toBeTruthy();
14421442-14431443- // Check that the theme CSS loaded (non-empty value for --theme-font-sans,
14441444- // which the peek theme defines in variables.css). Fallback would yield an
14451445- // empty string from getPropertyValue. Theme uses system sans proportional;
14461446- // --theme-font-mono is the one with ServerMono.
14471447- const fontVar = await settingsWin.evaluate(() => {
14481448- return getComputedStyle(document.documentElement).getPropertyValue('--theme-font-sans');
14491449- });
14501450- expect(fontVar.trim().length).toBeGreaterThan(0);
14511451- const monoVar = await settingsWin.evaluate(() => {
14521452- return getComputedStyle(document.documentElement).getPropertyValue('--theme-font-mono');
14531453- });
14541454- expect(monoVar).toContain('ServerMono');
14551455-14561456- await app.close();
14571457- });
14581458-});
14591459-14601460-// ============================================================================
14611461-// Core Functionality Tests (uses shared app)
14621462-// ============================================================================
14631463-14641464-test.describe('Core Functionality @desktop', () => {
14651465- let app: DesktopApp;
14661466- let bgWindow: Page;
14671467-14681468- test.beforeAll(async () => {
14691469- ({ app, bgWindow } = await createPerDescribeApp('core'));
14701470- });
14711471-14721472- test.afterAll(async () => {
14731473- if (app) await app.close();
14741474- });
14751475-14761476- test('app launches and extensions load', async () => {
14771477- // After v2 tile migration:
14781478- // - V2 features load as separate background BrowserWindows (peek://{id}/background.html)
14791479- // - Eager v2 features (e.g. entities, peeks, slides) launch at startup;
14801480- // lazy v2 features (e.g. example) launch on first command/event
14811481-14821482- // Check that at least one eager v2 background tile window exists.
14831483- // peeks and slides are eager v2 background tiles that launch at startup.
14841484- const v2BgWindow = await waitForWindow(
14851485- () => app.windows(),
14861486- 'peek://peeks/background.html',
14871487- 15000
14881488- );
14891489- expect(v2BgWindow).toBeDefined();
14901490- });
14911491-14921492- test('database is accessible', async () => {
14931493- const result = await bgWindow.evaluate(async () => {
14941494- return await (window as any).app.datastore.getStats();
14951495- });
14961496- expect(result.success).toBe(true);
14971497- expect(typeof result.data.totalAddresses).toBe('number');
14981498- });
14991499-15001500- test('commands are registered', async () => {
15011501- // Commands are now owned by the cmd extension via pubsub
15021502- // Query via cmd:query-commands topic with retry for extension loading
15031503- const result = await bgWindow.evaluate(async () => {
15041504- const api = (window as any).app;
15051505-15061506- const queryCommands = () => new Promise((resolve) => {
15071507- api.subscribe('cmd:query-commands-response', (msg: any) => {
15081508- resolve(msg.commands || []);
15091509- }, api.scopes.GLOBAL);
15101510- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
15111511- setTimeout(() => resolve([]), 1000);
15121512- });
15131513-15141514- // Retry a few times to allow extensions to finish loading
15151515- for (let i = 0; i < 5; i++) {
15161516- const cmds = await queryCommands() as any[];
15171517- if (cmds.some((c: any) => c.name === 'example:gallery')) {
15181518- return cmds;
15191519- }
15201520- await new Promise(r => setTimeout(r, 500));
15211521- }
15221522- return await queryCommands();
15231523- });
15241524- expect(Array.isArray(result)).toBe(true);
15251525- expect(result.length).toBeGreaterThan(0);
15261526-15271527- // Should have gallery command from example extension
15281528- const galleryCmd = result.find((c: any) => c.name === 'example:gallery');
15291529- expect(galleryCmd).toBeTruthy();
15301530- });
15311531-15321532- test('quit and restart commands are registered', async () => {
15331533- // quit/restart are registered asynchronously during app boot (app/index.js).
15341534- // Poll via waitForCommand before querying details to avoid startup-race flake.
15351535- await waitForCommand(bgWindow, 'quit', 10000);
15361536- await waitForCommand(bgWindow, 'restart', 10000);
15371537-15381538- // Query commands via cmd extension to verify descriptions
15391539- const result = await bgWindow.evaluate(async () => {
15401540- const api = (window as any).app;
15411541-15421542- return new Promise((resolve) => {
15431543- api.subscribe('cmd:query-commands-response', (msg: any) => {
15441544- const commands = msg.commands || [];
15451545- resolve({
15461546- hasQuit: commands.some((c: any) => c.name === 'quit'),
15471547- hasRestart: commands.some((c: any) => c.name === 'restart'),
15481548- quitCmd: commands.find((c: any) => c.name === 'quit'),
15491549- restartCmd: commands.find((c: any) => c.name === 'restart')
15501550- });
15511551- }, api.scopes.GLOBAL);
15521552- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
15531553- setTimeout(() => resolve({ hasQuit: false, hasRestart: false }), 2000);
15541554- });
15551555- });
15561556-15571557- expect(result.hasQuit).toBe(true);
15581558- expect(result.hasRestart).toBe(true);
15591559- expect(result.quitCmd?.description).toBe('Quit the application');
15601560- expect(result.restartCmd?.description).toBe('Restart the application');
15611561- });
15621562-15631563- test('reload extension command is registered', async () => {
15641564- const result = await bgWindow.evaluate(async () => {
15651565- const api = (window as any).app;
15661566-15671567- return new Promise((resolve) => {
15681568- api.subscribe('cmd:query-commands-response', (msg: any) => {
15691569- const commands = msg.commands || [];
15701570- const reloadCmd = commands.find((c: any) => c.name === 'reload extension');
15711571- resolve({
15721572- hasReloadExtension: !!reloadCmd,
15731573- description: reloadCmd?.description
15741574- });
15751575- }, api.scopes.GLOBAL);
15761576- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
15771577- setTimeout(() => resolve({ hasReloadExtension: false }), 2000);
15781578- });
15791579- });
15801580-15811581- expect(result.hasReloadExtension).toBe(true);
15821582- expect(result.description).toBe('Reload an external extension by ID');
15831583- });
15841584-15851585- test('api.quit and api.restart functions exist', async () => {
15861586- const result = await bgWindow.evaluate(() => {
15871587- const api = (window as any).app;
15881588- return {
15891589- hasQuit: typeof api.quit === 'function',
15901590- hasRestart: typeof api.restart === 'function'
15911591- };
15921592- });
15931593-15941594- expect(result.hasQuit).toBe(true);
15951595- expect(result.hasRestart).toBe(true);
15961596- });
15971597-15981598- test('window management works', async () => {
15991599- // Open a test window
16001600- const openResult = await bgWindow.evaluate(async () => {
16011601- return await (window as any).app.window.open('about:blank', {
16021602- width: 400,
16031603- height: 300
16041604- });
16051605- });
16061606- expect(openResult.success).toBe(true);
16071607- expect(openResult.id).toBeDefined();
16081608-16091609- // Wait for window to open
16101610- await app.getWindow('about:blank', 5000);
16111611-16121612- // List windows
16131613- const listResult = await bgWindow.evaluate(async () => {
16141614- return await (window as any).app.window.list();
16151615- });
16161616- expect(listResult.success).toBe(true);
16171617- expect(Array.isArray(listResult.windows)).toBe(true);
16181618-16191619- // Close the window
16201620- await bgWindow.evaluate(async (id: number) => {
16211621- return await (window as any).app.window.close(id);
16221622- }, openResult.id);
16231623- });
16241624-});
16251625-16261626-// ============================================================================
16271627-// Tag Command Tests (uses shared app)
16281628-// ============================================================================
16291629-16301630-test.describe('Tag Command @desktop', () => {
16311631- let app: DesktopApp;
16321632- let bgWindow: Page;
16331633-16341634- test.beforeAll(async () => {
16351635- ({ app, bgWindow } = await createPerDescribeApp('tag-cmd'));
16361636- });
16371637-16381638- test.afterAll(async () => {
16391639- if (app) await app.close();
16401640- });
16411641-16421642- test('creates address if not exists when tagging', async () => {
16431643- // This tests the bug fix: addResult.data.id instead of addResult.id
16441644- // Use unique URI to avoid conflicts with other tests
16451645- // Note: datastore normalizes URLs (adds trailing slash)
16461646- const timestamp = Date.now();
16471647- const testUri = `https://tag-test-new-address-${timestamp}.example.com/`;
16481648-16491649- // Create tag with unique name
16501650- const tagResult = await bgWindow.evaluate(async (ts: number) => {
16511651- return await (window as any).app.datastore.getOrCreateTag('test-new-addr-tag-' + ts);
16521652- }, timestamp);
16531653- expect(tagResult.success).toBe(true);
16541654- const tagId = tagResult.data?.tag?.id;
16551655- expect(tagId).toBeTruthy();
16561656-16571657- // Create address
16581658- const addResult = await bgWindow.evaluate(async (uri: string) => {
16591659- return await (window as any).app.datastore.addAddress(uri, { title: 'New Tagged Address' });
16601660- }, testUri);
16611661- expect(addResult.success).toBe(true);
16621662- // Bug fix verification: data.id is the correct path
16631663- expect(addResult.data?.id).toBeTruthy();
16641664-16651665- // Tag the address using the correct id path
16661666- const linkResult = await bgWindow.evaluate(async ({ addressId, tagId }) => {
16671667- return await (window as any).app.datastore.tagAddress(addressId, tagId);
16681668- }, { addressId: addResult.data.id, tagId });
16691669- expect(linkResult.success).toBe(true);
16701670-16711671- // Verify address is tagged
16721672- const taggedAddresses = await bgWindow.evaluate(async (tId: string) => {
16731673- return await (window as any).app.datastore.getAddressesByTag(tId);
16741674- }, tagId);
16751675- expect(taggedAddresses.success).toBe(true);
16761676- expect(taggedAddresses.data.some((a: any) => a.uri === testUri)).toBe(true);
16771677- });
16781678-16791679- test('getOrCreateTag returns tag in data.tag', async () => {
16801680- // This tests the bug fix: tagResult.data.tag.id instead of tagResult.data.id
16811681- const tagName = 'test-nested-tag-response';
16821682-16831683- const result = await bgWindow.evaluate(async (name: string) => {
16841684- return await (window as any).app.datastore.getOrCreateTag(name);
16851685- }, tagName);
16861686-16871687- expect(result.success).toBe(true);
16881688- // Bug fix verification: tag is nested in data.tag
16891689- expect(result.data?.tag).toBeTruthy();
16901690- expect(result.data?.tag?.id).toBeTruthy();
16911691- expect(result.data?.tag?.name).toBe(tagName);
16921692- expect(typeof result.data?.created).toBe('boolean');
16931693- });
16941694-16951695- test('tagAddress links tag to address correctly', async () => {
16961696- // Create address
16971697- const addr = await bgWindow.evaluate(async () => {
16981698- return await (window as any).app.datastore.addAddress('https://tag-link-test.example.com', {
16991699- title: 'Tag Link Test'
17001700- });
17011701- });
17021702- expect(addr.success).toBe(true);
17031703-17041704- // Create tag
17051705- const tag = await bgWindow.evaluate(async () => {
17061706- return await (window as any).app.datastore.getOrCreateTag('link-test-tag');
17071707- });
17081708- expect(tag.success).toBe(true);
17091709-17101710- // Link them
17111711- const link = await bgWindow.evaluate(async ({ addressId, tagId }) => {
17121712- return await (window as any).app.datastore.tagAddress(addressId, tagId);
17131713- }, { addressId: addr.data.id, tagId: tag.data.tag.id });
17141714- expect(link.success).toBe(true);
17151715-17161716- // Verify link exists
17171717- const addressTags = await bgWindow.evaluate(async (addressId: string) => {
17181718- return await (window as any).app.datastore.getAddressTags(addressId);
17191719- }, addr.data.id);
17201720- expect(addressTags.success).toBe(true);
17211721- expect(addressTags.data.some((t: any) => t.name === 'link-test-tag')).toBe(true);
17221722- });
17231723-17241724- test('multiple tags can be added to same address', async () => {
17251725- // Create address
17261726- const addr = await bgWindow.evaluate(async () => {
17271727- return await (window as any).app.datastore.addAddress('https://multi-tag-test.example.com', {
17281728- title: 'Multi Tag Test'
17291729- });
17301730- });
17311731- expect(addr.success).toBe(true);
17321732-17331733- // Create and link multiple tags
17341734- const tagNames = ['multi-tag-1', 'multi-tag-2', 'multi-tag-3'];
17351735-17361736- for (const tagName of tagNames) {
17371737- const tag = await bgWindow.evaluate(async (name: string) => {
17381738- return await (window as any).app.datastore.getOrCreateTag(name);
17391739- }, tagName);
17401740- expect(tag.success).toBe(true);
17411741-17421742- const link = await bgWindow.evaluate(async ({ addressId, tagId }) => {
17431743- return await (window as any).app.datastore.tagAddress(addressId, tagId);
17441744- }, { addressId: addr.data.id, tagId: tag.data.tag.id });
17451745- expect(link.success).toBe(true);
17461746- }
17471747-17481748- // Verify all tags are linked
17491749- const addressTags = await bgWindow.evaluate(async (addressId: string) => {
17501750- return await (window as any).app.datastore.getAddressTags(addressId);
17511751- }, addr.data.id);
17521752- expect(addressTags.success).toBe(true);
17531753- expect(addressTags.data.length).toBeGreaterThanOrEqual(3);
17541754-17551755- for (const tagName of tagNames) {
17561756- expect(addressTags.data.some((t: any) => t.name === tagName)).toBe(true);
17571757- }
17581758- });
17591759-17601760- test('untagAddress removes tag from address', async () => {
17611761- // Create address
17621762- const addr = await bgWindow.evaluate(async () => {
17631763- return await (window as any).app.datastore.addAddress('https://untag-test.example.com', {
17641764- title: 'Untag Test'
17651765- });
17661766- });
17671767- expect(addr.success).toBe(true);
17681768-17691769- // Create and link tag
17701770- const tag = await bgWindow.evaluate(async () => {
17711771- return await (window as any).app.datastore.getOrCreateTag('untag-test-tag');
17721772- });
17731773- expect(tag.success).toBe(true);
17741774-17751775- await bgWindow.evaluate(async ({ addressId, tagId }) => {
17761776- return await (window as any).app.datastore.tagAddress(addressId, tagId);
17771777- }, { addressId: addr.data.id, tagId: tag.data.tag.id });
17781778-17791779- // Verify tag is linked
17801780- let addressTags = await bgWindow.evaluate(async (addressId: string) => {
17811781- return await (window as any).app.datastore.getAddressTags(addressId);
17821782- }, addr.data.id);
17831783- expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(true);
17841784-17851785- // Remove tag
17861786- const untag = await bgWindow.evaluate(async ({ addressId, tagId }) => {
17871787- return await (window as any).app.datastore.untagAddress(addressId, tagId);
17881788- }, { addressId: addr.data.id, tagId: tag.data.tag.id });
17891789- expect(untag.success).toBe(true);
17901790-17911791- // Verify tag is removed
17921792- addressTags = await bgWindow.evaluate(async (addressId: string) => {
17931793- return await (window as any).app.datastore.getAddressTags(addressId);
17941794- }, addr.data.id);
17951795- expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(false);
17961796- });
17971797-17981798- test('getUntaggedAddresses returns addresses without tags', async () => {
17991799- // Use unique URI to avoid conflicts
18001800- // Note: datastore normalizes URLs (adds trailing slash)
18011801- const timestamp = Date.now();
18021802- const testUri = `https://untagged-test-${timestamp}.example.com/`;
18031803-18041804- // Create address without tagging it
18051805- const addr = await bgWindow.evaluate(async (uri: string) => {
18061806- return await (window as any).app.datastore.addAddress(uri, {
18071807- title: 'Untagged Test'
18081808- });
18091809- }, testUri);
18101810- expect(addr.success).toBe(true);
18111811- expect(addr.data?.id).toBeTruthy();
18121812-18131813- // Query untagged addresses
18141814- const untagged = await bgWindow.evaluate(async () => {
18151815- return await (window as any).app.datastore.getUntaggedAddresses();
18161816- });
18171817- expect(untagged.success).toBe(true);
18181818- expect(untagged.data.some((a: any) => a.uri === testUri)).toBe(true);
18191819-18201820- // Tag the address with unique tag name
18211821- const tag = await bgWindow.evaluate(async (ts: number) => {
18221822- return await (window as any).app.datastore.getOrCreateTag('now-tagged-' + ts);
18231823- }, timestamp);
18241824- expect(tag.success).toBe(true);
18251825- expect(tag.data?.tag?.id).toBeTruthy();
18261826-18271827- await bgWindow.evaluate(async ({ addressId, tagId }) => {
18281828- return await (window as any).app.datastore.tagAddress(addressId, tagId);
18291829- }, { addressId: addr.data.id, tagId: tag.data.tag.id });
18301830-18311831- // Verify it's no longer in untagged list
18321832- const untaggedAfter = await bgWindow.evaluate(async () => {
18331833- return await (window as any).app.datastore.getUntaggedAddresses();
18341834- });
18351835- expect(untaggedAfter.data.some((a: any) => a.uri === testUri)).toBe(false);
18361836- });
18371837-});
18381838-18391839-// ============================================================================
18401840-// Command Execution Tests (uses shared app)
18411841-// Tests the full command execution path through pubsub:
18421842-// cmd:execute:<name> -> extension handler -> result via resultTopic
18431843-// ============================================================================
18441844-18451845-test.describe('Command Execution @desktop', () => {
18461846- let app: DesktopApp;
18471847- let bgWindow: Page;
18481848- let pageWindowId: number | null = null;
18491849- const testPageUrl = `https://cmd-exec-test-${Date.now()}.example.com/`;
18501850-18511851- test.beforeAll(async () => {
18521852- ({ app, bgWindow } = await createPerDescribeApp('cmd-exec'));
18531853-18541854- // Open a page window so tag commands have an "active window" to work with
18551855- const openResult = await bgWindow.evaluate(async (url: string) => {
18561856- return await (window as any).app.window.open(url, {
18571857- width: 800,
18581858- height: 600,
18591859- key: 'cmd-exec-test-page'
18601860- });
18611861- }, testPageUrl);
18621862-18631863- if (openResult.success && openResult.id) {
18641864- pageWindowId = openResult.id;
18651865- }
18661866-18671867- // Give the page window time to load page.js, complete api.initialize(),
18681868- // and subscribe to tag pubsub events. Without this wait, the first tag
18691869- // command fires before page.js's subscribe is installed → missed event.
18701870- await sleep(2000);
18711871- });
18721872-18731873- test.afterAll(async () => {
18741874- // Close the page window we opened
18751875- if (pageWindowId && bgWindow && !bgWindow.isClosed()) {
18761876- try {
18771877- await bgWindow.evaluate(async (id: number) => {
18781878- return await (window as any).app.window.close(id);
18791879- }, pageWindowId);
18801880- } catch { /* app may already be closing */ }
18811881- }
18821882- if (app) await app.close();
18831883- });
18841884-18851885- test('tag command with # prefixed tags stores tags without prefix', async () => {
18861886- const timestamp = Date.now();
18871887- const tag1 = `testfoo${timestamp}`;
18881888- const tag2 = `testbar${timestamp}`;
18891889-18901890- // Execute the tag command through pubsub
18911891- const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
18921892- const api = (window as any).app;
18931893- return new Promise((resolve) => {
18941894- const resultTopic = `cmd:execute:${args.name}:result`;
18951895- api.subscribe(resultTopic, (result: any) => {
18961896- resolve(result);
18971897- }, api.scopes.GLOBAL);
18981898-18991899- api.publish(`cmd:execute:${args.name}`, {
19001900- search: args.search,
19011901- params: [],
19021902- expectResult: true,
19031903- resultTopic
19041904- }, api.scopes.GLOBAL);
19051905-19061906- setTimeout(() => resolve({ error: 'timeout' }), 10000);
19071907- });
19081908- }, { name: 'tag', search: `#${tag1} #${tag2}` });
19091909-19101910- expect((result as any).success).toBe(true);
19111911-19121912- // Verify tags are stored WITHOUT the # prefix
19131913- const added = (result as any).added || [];
19141914- expect(added).toContain(tag1);
19151915- expect(added).toContain(tag2);
19161916- // Ensure no # prefix leaked through
19171917- expect(added.some((t: string) => t.startsWith('#'))).toBe(false);
19181918-19191919- // Verify via datastore: find items tagged with tag1 (tag-centric check,
19201920- // since getActiveWindow() may return a different window than testPageUrl)
19211921- const itemCheck = await bgWindow.evaluate(async (tagName: string) => {
19221922- const api = (window as any).app;
19231923- const tagResult = await api.datastore.getOrCreateTag(tagName);
19241924- if (!tagResult.success) return { found: false };
19251925- const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
19261926- return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 };
19271927- }, tag1);
19281928-19291929- expect(itemCheck.found).toBe(true);
19301930- });
19311931-19321932- test('tag command without # prefix works the same way', async () => {
19331933- const timestamp = Date.now();
19341934- const tagName = `testbaz${timestamp}`;
19351935-19361936- const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
19371937- const api = (window as any).app;
19381938- return new Promise((resolve) => {
19391939- const resultTopic = `cmd:execute:${args.name}:result`;
19401940- api.subscribe(resultTopic, (result: any) => {
19411941- resolve(result);
19421942- }, api.scopes.GLOBAL);
19431943-19441944- api.publish(`cmd:execute:${args.name}`, {
19451945- search: args.search,
19461946- params: [],
19471947- expectResult: true,
19481948- resultTopic
19491949- }, api.scopes.GLOBAL);
19501950-19511951- setTimeout(() => resolve({ error: 'timeout' }), 10000);
19521952- });
19531953- }, { name: 'tag', search: tagName });
19541954-19551955- expect((result as any).success).toBe(true);
19561956- const added = (result as any).added || [];
19571957- expect(added).toContain(tagName);
19581958- });
19591959-19601960- test('tag command creates item if none exists', async () => {
19611961- const timestamp = Date.now();
19621962- // Open a new page window with a URL that has no item yet
19631963- const newUrl = `https://cmd-exec-new-item-${timestamp}.example.com/`;
19641964- const tagName = `newtag${timestamp}`;
19651965-19661966- // Close the shared page window so the new window becomes the "active" one
19671967- // (getActiveWindow returns the first non-internal window)
19681968- if (pageWindowId) {
19691969- await bgWindow.evaluate(async (id: number) => {
19701970- return await (window as any).app.window.close(id);
19711971- }, pageWindowId);
19721972- pageWindowId = null;
19731973- await sleep(200);
19741974- }
19751975-19761976- const openResult = await bgWindow.evaluate(async (url: string) => {
19771977- return await (window as any).app.window.open(url, {
19781978- width: 800,
19791979- height: 600,
19801980- key: `cmd-exec-new-item-${Date.now()}`
19811981- });
19821982- }, newUrl);
19831983- expect(openResult.success).toBe(true);
19841984- const newWindowId = openResult.id;
19851985-19861986- // Give the window time to register
19871987- await sleep(500);
19881988-19891989- // Execute tag command — should create item and tag it
19901990- const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
19911991- const api = (window as any).app;
19921992- return new Promise((resolve) => {
19931993- const resultTopic = `cmd:execute:${args.name}:result`;
19941994- api.subscribe(resultTopic, (result: any) => {
19951995- resolve(result);
19961996- }, api.scopes.GLOBAL);
19971997-19981998- api.publish(`cmd:execute:${args.name}`, {
19991999- search: args.search,
20002000- params: [],
20012001- expectResult: true,
20022002- resultTopic
20032003- }, api.scopes.GLOBAL);
20042004-20052005- setTimeout(() => resolve({ error: 'timeout' }), 10000);
20062006- });
20072007- }, { name: 'tag', search: `#${tagName}` });
20082008-20092009- expect((result as any).success).toBe(true);
20102010- expect((result as any).added).toContain(tagName);
20112011-20122012- // Verify an item was created and tagged (tag-centric check,
20132013- // since getActiveWindow() may not return newUrl if other windows exist)
20142014- const itemCheck = await bgWindow.evaluate(async (tag: string) => {
20152015- const api = (window as any).app;
20162016- const tagResult = await api.datastore.getOrCreateTag(tag);
20172017- if (!tagResult.success) return { found: false };
20182018- const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
20192019- return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 };
20202020- }, tagName);
20212021-20222022- expect(itemCheck.found).toBe(true);
20232023-20242024- // Reopen a shared page window for remaining tests
20252025- const reopenResult = await bgWindow.evaluate(async (url: string) => {
20262026- return await (window as any).app.window.open(url, {
20272027- width: 800,
20282028- height: 600,
20292029- key: 'cmd-exec-test-page'
20302030- });
20312031- }, testPageUrl);
20322032- if (reopenResult.success && reopenResult.id) {
20332033- pageWindowId = reopenResult.id;
20342034- }
20352035- await sleep(300);
20362036-20372037- // Clean up the test window
20382038- if (newWindowId) {
20392039- await bgWindow.evaluate(async (id: number) => {
20402040- return await (window as any).app.window.close(id);
20412041- }, newWindowId);
20422042- }
20432043- });
20442044-20452045- test('untag command removes tags from item', async () => {
20462046- const timestamp = Date.now();
20472047- const tagName = `untagme${timestamp}`;
20482048-20492049- // First, tag the item via command execution
20502050- const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
20512051- const api = (window as any).app;
20522052- return new Promise((resolve) => {
20532053- const resultTopic = `cmd:execute:${args.name}:result`;
20542054- api.subscribe(resultTopic, (result: any) => {
20552055- resolve(result);
20562056- }, api.scopes.GLOBAL);
20572057-20582058- api.publish(`cmd:execute:${args.name}`, {
20592059- search: args.search,
20602060- params: [],
20612061- expectResult: true,
20622062- resultTopic
20632063- }, api.scopes.GLOBAL);
20642064-20652065- setTimeout(() => resolve({ error: 'timeout' }), 10000);
20662066- });
20672067- }, { name: 'tag', search: `#${tagName}` });
20682068-20692069- expect((tagResult as any).success).toBe(true);
20702070- expect((tagResult as any).added).toContain(tagName);
20712071-20722072- // Verify tag exists (tag-centric check)
20732073- const beforeCheck = await bgWindow.evaluate(async (tag: string) => {
20742074- const api = (window as any).app;
20752075- const tagResult = await api.datastore.getOrCreateTag(tag);
20762076- if (!tagResult.success) return { found: false };
20772077- const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
20782078- return { found: tagged.success && tagged.data?.length > 0, count: tagged.data?.length || 0 };
20792079- }, tagName);
20802080-20812081- expect(beforeCheck.found).toBe(true);
20822082-20832083- // Now untag via the untag command
20842084- const untagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
20852085- const api = (window as any).app;
20862086- return new Promise((resolve) => {
20872087- const resultTopic = `cmd:execute:${args.name}:result`;
20882088- api.subscribe(resultTopic, (result: any) => {
20892089- resolve(result);
20902090- }, api.scopes.GLOBAL);
20912091-20922092- api.publish(`cmd:execute:${args.name}`, {
20932093- search: args.search,
20942094- params: [],
20952095- expectResult: true,
20962096- resultTopic
20972097- }, api.scopes.GLOBAL);
20982098-20992099- setTimeout(() => resolve({ error: 'timeout' }), 10000);
21002100- });
21012101- }, { name: 'untag', search: `#${tagName}` });
21022102-21032103- expect((untagResult as any).success).toBe(true);
21042104-21052105- // Verify tag is removed (no items with this tag anymore)
21062106- const afterCheck = await bgWindow.evaluate(async (tag: string) => {
21072107- const api = (window as any).app;
21082108- const tagResult = await api.datastore.getOrCreateTag(tag);
21092109- if (!tagResult.success) return { removed: false };
21102110- const tagged = await api.datastore.getItemsByTag(tagResult.data.tag.id);
21112111- return { removed: !tagged.data || tagged.data.length === 0 };
21122112- }, tagName);
21132113-21142114- expect(afterCheck.removed).toBe(true);
21152115- });
21162116-21172117- test('tags page widget updates dynamically when tag is added via command', async () => {
21182118- const timestamp = Date.now();
21192119- const setupTag = `setupdyn${timestamp}`;
21202120- const dynamicTag = `testdynamic${timestamp}`;
21212121- // Per-test unique URL + key. Prevents cross-test window / item reuse in
21222122- // the shared-app full-suite context (where the describe's `pageWindowId`
21232123- // may have accumulated state from earlier tests or been closed/reopened
21242124- // with stale subscribers). Isolating this test to its own page window is
21252125- // the robust fix for the full-suite ordering flake — the other tests in
21262126- // this describe don't query #tags-list, so they tolerate the shared
21272127- // window; only this one is sensitive to page.js subscriber state.
21282128- const dynamicUrl = `https://cmd-exec-dyn-${timestamp}.example.com/`;
21292129- const dynamicKey = `cmd-exec-dyn-${timestamp}`;
21302130-21312131- // Close the shared page window so getActiveWindow() picks our fresh one
21322132- // (matches the pattern in "tag command creates item if none exists").
21332133- const hadSharedWindow = pageWindowId !== null;
21342134- if (pageWindowId) {
21352135- await bgWindow.evaluate(async (id: number) => {
21362136- return await (window as any).app.window.close(id);
21372137- }, pageWindowId);
21382138- pageWindowId = null;
21392139- await sleep(200);
21402140- }
21412141-21422142- // Open our isolated page window
21432143- const openResult = await bgWindow.evaluate(async (args: { url: string; key: string }) => {
21442144- return await (window as any).app.window.open(args.url, {
21452145- width: 800,
21462146- height: 600,
21472147- key: args.key
21482148- });
21492149- }, { url: dynamicUrl, key: dynamicKey });
21502150- expect(openResult.success).toBe(true);
21512151- const testWindowId = openResult.id;
21522152-21532153- // Wait for page.js to initialize and subscribe to pubsub
21542154- // (same 2s wait as in beforeAll — matches page.js init timing)
21552155- await sleep(2000);
21562156-21572157- // Helper to execute a tag command and wait for the result
21582158- const executeTag = async (tag: string) => {
21592159- return bgWindow.evaluate(async (args: { name: string; search: string }) => {
21602160- const api = (window as any).app;
21612161- return new Promise((resolve) => {
21622162- const resultTopic = `cmd:execute:${args.name}:result`;
21632163- api.subscribe(resultTopic, (result: any) => {
21642164- resolve(result);
21652165- }, api.scopes.GLOBAL);
21662166- api.publish(`cmd:execute:${args.name}`, {
21672167- search: args.search,
21682168- params: [],
21692169- expectResult: true,
21702170- resultTopic
21712171- }, api.scopes.GLOBAL);
21722172- setTimeout(() => resolve({ error: 'timeout' }), 10000);
21732173- });
21742174- }, { name: 'tag', search: tag });
21752175- };
21762176-21772177- try {
21782178- // Grab the page window handle first so we can gate on page.js readiness
21792179- // BEFORE firing the setup tag. Under full-suite load the 2s sleep above
21802180- // is not enough — the tag command can otherwise publish `tag:item-added`
21812181- // before page.js has run its top-level `api.subscribe('tag:item-added',
21822182- // ...)`. tile-preload's `subscribeImpl` attaches the underlying
21832183- // `ipcRenderer.on('pubsub:tag:item-added')` listener synchronously from
21842184- // page.js module evaluation, so gating on `__pageModuleReady` (the
21852185- // sentinel flipped at the very bottom of page.js) guarantees the
21862186- // listener is live. Electron's `webContents.send` is fire-and-forget —
21872187- // a pubsub event that arrives before the listener is attached is
21882188- // silently dropped, which is the root cause of the full-suite flake.
21892189- const pageWindow = await app.getWindow(dynamicKey, 10000);
21902190- expect(pageWindow).toBeTruthy();
21912191- await pageWindow.waitForFunction(
21922192- () => (window as unknown as { __pageModuleReady?: boolean }).__pageModuleReady === true,
21932193- null,
21942194- { timeout: 10000 }
21952195- );
21962196-21972197- // First tag establishes the item in the datastore and triggers the page's
21982198- // resolveItemId fallback, setting currentItemId for subsequent events
21992199- const setupResult = await executeTag(setupTag);
22002200- expect((setupResult as any).success).toBe(true);
22012201-22022202- // Wait for page.js to initialize and for the setup tag to appear
22032203- // (proves the reactive update path works and currentItemId is set)
22042204- await pageWindow.waitForFunction(
22052205- (expected: string) => {
22062206- const list = document.getElementById('tags-list');
22072207- if (!list) return false;
22082208- const names = Array.from(list.querySelectorAll('.tag-name'))
22092209- .map(el => el.textContent);
22102210- return names.includes(expected);
22112211- },
22122212- setupTag,
22132213- { timeout: 10000 }
22142214- );
22152215-22162216- // Record tag count after setup
22172217- const tagCountBefore = await pageWindow.evaluate(() => {
22182218- const list = document.getElementById('tags-list');
22192219- return list ? list.querySelectorAll('.tag-btn').length : 0;
22202220- });
22212221-22222222- // Now add a second tag — this should update the widget reactively
22232223- // because currentItemId is already set from the first tag
22242224- const result = await executeTag(dynamicTag);
22252225- expect((result as any).success).toBe(true);
22262226- expect((result as any).added).toContain(dynamicTag);
22272227-22282228- // Verify the tags widget updates dynamically
22292229- await pageWindow.waitForFunction(
22302230- (expectedTag: string) => {
22312231- const list = document.getElementById('tags-list');
22322232- if (!list) return false;
22332233- const tagNames = Array.from(list.querySelectorAll('.tag-name'))
22342234- .map(el => el.textContent);
22352235- return tagNames.includes(expectedTag);
22362236- },
22372237- dynamicTag,
22382238- { timeout: 10000 }
22392239- );
22402240-22412241- // Tag count increased
22422242- const tagCountAfter = await pageWindow.evaluate(() => {
22432243- const list = document.getElementById('tags-list');
22442244- return list ? list.querySelectorAll('.tag-btn').length : 0;
22452245- });
22462246- expect(tagCountAfter).toBeGreaterThan(tagCountBefore);
22472247- } finally {
22482248- // Close our isolated page window
22492249- if (testWindowId) {
22502250- await bgWindow.evaluate(async (id: number) => {
22512251- return await (window as any).app.window.close(id);
22522252- }, testWindowId);
22532253- }
22542254-22552255- // Reopen the shared page window so the remaining tests in this describe
22562256- // (and the afterAll cleanup) have the state they expect.
22572257- if (hadSharedWindow) {
22582258- const reopenResult = await bgWindow.evaluate(async (url: string) => {
22592259- return await (window as any).app.window.open(url, {
22602260- width: 800,
22612261- height: 600,
22622262- key: 'cmd-exec-test-page'
22632263- });
22642264- }, testPageUrl);
22652265- if (reopenResult.success && reopenResult.id) {
22662266- pageWindowId = reopenResult.id;
22672267- }
22682268- await sleep(300);
22692269- }
22702270- }
22712271- });
22722272-22732273- test('tag command with no args returns current tags', async () => {
22742274- const timestamp = Date.now();
22752275- const tagName = `showme${timestamp}`;
22762276-22772277- // First add a tag so there's something to show
22782278- const tagResult = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
22792279- const api = (window as any).app;
22802280- return new Promise((resolve) => {
22812281- const resultTopic = `cmd:execute:${args.name}:result`;
22822282- api.subscribe(resultTopic, (result: any) => {
22832283- resolve(result);
22842284- }, api.scopes.GLOBAL);
22852285-22862286- api.publish(`cmd:execute:${args.name}`, {
22872287- search: args.search,
22882288- params: [],
22892289- expectResult: true,
22902290- resultTopic
22912291- }, api.scopes.GLOBAL);
22922292-22932293- setTimeout(() => resolve({ error: 'timeout' }), 10000);
22942294- });
22952295- }, { name: 'tag', search: tagName });
22962296-22972297- expect((tagResult as any).success).toBe(true);
22982298-22992299- // Now execute tag with no search args — should return current tags
23002300- const result = await bgWindow.evaluate(async (args: { name: string }) => {
23012301- const api = (window as any).app;
23022302- return new Promise((resolve) => {
23032303- const resultTopic = `cmd:execute:${args.name}:result`;
23042304- api.subscribe(resultTopic, (result: any) => {
23052305- resolve(result);
23062306- }, api.scopes.GLOBAL);
23072307-23082308- api.publish(`cmd:execute:${args.name}`, {
23092309- search: '',
23102310- params: [],
23112311- expectResult: true,
23122312- resultTopic
23132313- }, api.scopes.GLOBAL);
23142314-23152315- setTimeout(() => resolve({ error: 'timeout' }), 10000);
23162316- });
23172317- }, { name: 'tag' });
23182318-23192319- expect((result as any).success).toBe(true);
23202320- // Should return tags array for the active window's URL
23212321- expect(Array.isArray((result as any).tags)).toBe(true);
23222322- // The tag we just added should be in the list
23232323- const tagNames = (result as any).tags.map((t: any) => t.name);
23242324- expect(tagNames).toContain(tagName);
23252325- });
23262326-23272327- test('tagset command creates tagset item with tags stripped of #', async () => {
23282328- const timestamp = Date.now();
23292329- const tag1 = `setx${timestamp}`;
23302330- const tag2 = `sety${timestamp}`;
23312331-23322332- // Execute tagset command with # prefixed tags, comma separated
23332333- const result = await bgWindow.evaluate(async (args: { name: string; search: string }) => {
23342334- const api = (window as any).app;
23352335- return new Promise((resolve) => {
23362336- const resultTopic = `cmd:execute:${args.name}:result`;
23372337- api.subscribe(resultTopic, (result: any) => {
23382338- resolve(result);
23392339- }, api.scopes.GLOBAL);
23402340-23412341- api.publish(`cmd:execute:${args.name}`, {
23422342- search: args.search,
23432343- params: [],
23442344- expectResult: true,
23452345- resultTopic
23462346- }, api.scopes.GLOBAL);
23472347-23482348- setTimeout(() => resolve({ error: 'timeout' }), 10000);
23492349- });
23502350- }, { name: 'tagset', search: `#${tag1}, #${tag2}` });
23512351-23522352- expect((result as any).success).toBe(true);
23532353- expect((result as any).message).toContain(tag1);
23542354- expect((result as any).message).toContain(tag2);
23552355-23562356- // Verify the tagset item was created in the datastore
23572357- const tagsetCheck = await bgWindow.evaluate(async (args: { tag1: string; tag2: string }) => {
23582358- const api = (window as any).app;
23592359- // Query for tagset items
23602360- const queryResult = await api.datastore.queryItems({ type: 'tagset', limit: 50 });
23612361- if (!queryResult.success) return { found: false };
23622362-23632363- // Find our tagset by content (tags joined with ", ")
23642364- const tagset = queryResult.data.find((item: any) =>
23652365- item.content.includes(args.tag1) && item.content.includes(args.tag2)
23662366- );
23672367- if (!tagset) return { found: false };
23682368-23692369- // Get the tags on the tagset item
23702370- const tagsResult = await api.datastore.getItemTags(tagset.id);
23712371- return {
23722372- found: true,
23732373- itemId: tagset.id,
23742374- content: tagset.content,
23752375- tags: tagsResult.data?.map((t: any) => t.name) || []
23762376- };
23772377- }, { tag1, tag2 });
23782378-23792379- expect(tagsetCheck.found).toBe(true);
23802380- // Tags should be stored without # prefix
23812381- expect(tagsetCheck.tags).toContain(tag1);
23822382- expect(tagsetCheck.tags).toContain(tag2);
23832383- // The content field should contain the normalized tag names
23842384- expect(tagsetCheck.content).toContain(tag1);
23852385- expect(tagsetCheck.content).toContain(tag2);
23862386- // Should also have the from:cmd tag
23872387- expect(tagsetCheck.tags).toContain('from:cmd');
23882388- });
23892389-});
23902390-23912391-// ============================================================================
23922392-// Tag Events Tests (uses shared app)
23932393-// ============================================================================
23942394-23952395-test.describe('Tag Events @desktop', () => {
23962396- let app: DesktopApp;
23972397- let bgWindow: Page;
23982398-23992399- test.beforeAll(async () => {
24002400- ({ app, bgWindow } = await createPerDescribeApp('tag-events'));
24012401- });
24022402-24032403- test.afterAll(async () => {
24042404- if (app) await app.close();
24052405- });
24062406-24072407- test('tag:created is emitted when new tag is created', async () => {
24082408- const timestamp = Date.now();
24092409- const tagName = `event-test-tag-${timestamp}`;
24102410-24112411- const result = await bgWindow.evaluate(async (name: string) => {
24122412- const api = (window as any).app;
24132413-24142414- return new Promise((resolve) => {
24152415- const timeout = setTimeout(() => {
24162416- resolve({ received: false });
24172417- }, 5000);
24182418-24192419- api.subscribe('tag:created', (msg: any) => {
24202420- if (msg.tagName === name) {
24212421- clearTimeout(timeout);
24222422- resolve({
24232423- received: true,
24242424- tagId: msg.tagId,
24252425- tagName: msg.tagName
24262426- });
24272427- }
24282428- }, api.scopes.GLOBAL);
24292429-24302430- // Create new tag to trigger the event
24312431- api.datastore.getOrCreateTag(name);
24322432- });
24332433- }, tagName);
24342434-24352435- expect((result as any).received).toBe(true);
24362436- expect((result as any).tagName).toBe(tagName);
24372437- expect((result as any).tagId).toBeTruthy();
24382438- });
24392439-24402440- test('tag:item-added is emitted when item is tagged', async () => {
24412441- const timestamp = Date.now();
24422442- const tagName = `item-added-event-tag-${timestamp}`;
24432443-24442444- const result = await bgWindow.evaluate(async (name: string) => {
24452445- const api = (window as any).app;
24462446-24472447- // First create an item and a tag
24482448- const itemResult = await api.datastore.addItem('url', {
24492449- content: `https://tag-event-test-${Date.now()}.example.com`,
24502450- metadata: JSON.stringify({ title: 'Tag Event Test Item' })
24512451- });
24522452- if (!itemResult.success) {
24532453- return { received: false, error: 'failed to create item' };
24542454- }
24552455- const itemId = itemResult.data.id;
24562456-24572457- const tagResult = await api.datastore.getOrCreateTag(name);
24582458- if (!tagResult.success) {
24592459- return { received: false, error: 'failed to create tag' };
24602460- }
24612461- const tagId = tagResult.data.tag.id;
24622462-24632463- return new Promise((resolve) => {
24642464- const timeout = setTimeout(() => {
24652465- resolve({ received: false, error: 'timeout' });
24662466- }, 5000);
24672467-24682468- api.subscribe('tag:item-added', (msg: any) => {
24692469- if (msg.itemId === itemId && msg.tagId === tagId) {
24702470- clearTimeout(timeout);
24712471- resolve({
24722472- received: true,
24732473- tagId: msg.tagId,
24742474- tagName: msg.tagName,
24752475- itemId: msg.itemId,
24762476- itemType: msg.itemType
24772477- });
24782478- }
24792479- }, api.scopes.GLOBAL);
24802480-24812481- // Tag the item to trigger the event
24822482- api.datastore.tagItem(itemId, tagId);
24832483- });
24842484- }, tagName);
24852485-24862486- expect((result as any).received).toBe(true);
24872487- expect((result as any).tagName).toBe(tagName);
24882488- expect((result as any).tagId).toBeTruthy();
24892489- expect((result as any).itemId).toBeTruthy();
24902490- expect((result as any).itemType).toBe('url');
24912491- });
24922492-24932493- test('tag:item-removed is emitted when item is untagged', async () => {
24942494- const timestamp = Date.now();
24952495- const tagName = `item-removed-event-tag-${timestamp}`;
24962496-24972497- const result = await bgWindow.evaluate(async (name: string) => {
24982498- const api = (window as any).app;
24992499-25002500- // Create an item
25012501- const itemResult = await api.datastore.addItem('url', {
25022502- content: `https://untag-event-test-${Date.now()}.example.com`,
25032503- metadata: JSON.stringify({ title: 'Untag Event Test Item' })
25042504- });
25052505- if (!itemResult.success) {
25062506- return { received: false, error: 'failed to create item' };
25072507- }
25082508- const itemId = itemResult.data.id;
25092509-25102510- // Create a tag
25112511- const tagResult = await api.datastore.getOrCreateTag(name);
25122512- if (!tagResult.success) {
25132513- return { received: false, error: 'failed to create tag' };
25142514- }
25152515- const tagId = tagResult.data.tag.id;
25162516-25172517- // Tag the item first
25182518- await api.datastore.tagItem(itemId, tagId);
25192519-25202520- return new Promise((resolve) => {
25212521- const timeout = setTimeout(() => {
25222522- resolve({ received: false, error: 'timeout' });
25232523- }, 5000);
25242524-25252525- api.subscribe('tag:item-removed', (msg: any) => {
25262526- if (msg.itemId === itemId && msg.tagId === tagId) {
25272527- clearTimeout(timeout);
25282528- resolve({
25292529- received: true,
25302530- tagId: msg.tagId,
25312531- tagName: msg.tagName,
25322532- itemId: msg.itemId
25332533- });
25342534- }
25352535- }, api.scopes.GLOBAL);
25362536-25372537- // Untag the item to trigger the event
25382538- api.datastore.untagItem(itemId, tagId);
25392539- });
25402540- }, tagName);
25412541-25422542- expect((result as any).received).toBe(true);
25432543- expect((result as any).tagName).toBe(tagName);
25442544- expect((result as any).tagId).toBeTruthy();
25452545- expect((result as any).itemId).toBeTruthy();
25462546- });
25472547-25482548- test('tag:item-added is NOT emitted for duplicate tag', async () => {
25492549- const timestamp = Date.now();
25502550- const tagName = `duplicate-tag-event-${timestamp}`;
25512551-25522552- const result = await bgWindow.evaluate(async (name: string) => {
25532553- const api = (window as any).app;
25542554-25552555- // Create an item
25562556- const itemResult = await api.datastore.addItem('url', {
25572557- content: `https://duplicate-tag-test-${Date.now()}.example.com`,
25582558- metadata: JSON.stringify({ title: 'Duplicate Tag Test Item' })
25592559- });
25602560- if (!itemResult.success) {
25612561- return { received: false, error: 'failed to create item' };
25622562- }
25632563- const itemId = itemResult.data.id;
25642564-25652565- // Create a tag
25662566- const tagResult = await api.datastore.getOrCreateTag(name);
25672567- if (!tagResult.success) {
25682568- return { received: false, error: 'failed to create tag' };
25692569- }
25702570- const tagId = tagResult.data.tag.id;
25712571-25722572- // Tag the item for the first time (this should emit an event but we don't care)
25732573- await api.datastore.tagItem(itemId, tagId);
25742574-25752575- // Wait a bit to ensure the first event has been processed
25762576- await new Promise(r => setTimeout(r, 100));
25772577-25782578- return new Promise((resolve) => {
25792579- // Use a short timeout since we expect NO event
25802580- const timeout = setTimeout(() => {
25812581- resolve({ received: false }); // This is the expected outcome
25822582- }, 1000);
25832583-25842584- api.subscribe('tag:item-added', (msg: any) => {
25852585- if (msg.itemId === itemId && msg.tagId === tagId) {
25862586- clearTimeout(timeout);
25872587- resolve({ received: true }); // This would be unexpected
25882588- }
25892589- }, api.scopes.GLOBAL);
25902590-25912591- // Try to tag the same item with the same tag again
25922592- api.datastore.tagItem(itemId, tagId);
25932593- });
25942594- }, tagName);
25952595-25962596- // We expect NO event to be received for duplicate tagging
25972597- expect((result as any).received).toBe(false);
25982598- });
25992599-});
26002600-26012601-// ============================================================================
26022602-// Item Events Tests (uses shared app)
26032603-// ============================================================================
26042604-26052605-test.describe('Item Events @desktop', () => {
26062606- let app: DesktopApp;
26072607- let bgWindow: Page;
26082608-26092609- test.beforeAll(async () => {
26102610- ({ app, bgWindow } = await createPerDescribeApp('item-events'));
26112611- });
26122612-26132613- test.afterAll(async () => {
26142614- if (app) await app.close();
26152615- });
26162616-26172617- test('item:created is emitted when item is added', async () => {
26182618- const timestamp = Date.now();
26192619- const testUrl = `https://item-created-event-${timestamp}.example.com`;
26202620-26212621- const result = await bgWindow.evaluate(async (url: string) => {
26222622- const api = (window as any).app;
26232623-26242624- return new Promise((resolve) => {
26252625- const timeout = setTimeout(() => {
26262626- resolve({ received: false, error: 'timeout' });
26272627- }, 5000);
26282628-26292629- api.subscribe('item:created', (msg: any) => {
26302630- if (msg.content === url) {
26312631- clearTimeout(timeout);
26322632- resolve({
26332633- received: true,
26342634- itemId: msg.itemId,
26352635- itemType: msg.itemType,
26362636- content: msg.content
26372637- });
26382638- }
26392639- }, api.scopes.GLOBAL);
26402640-26412641- // Create item to trigger the event
26422642- api.datastore.addItem('url', {
26432643- content: url,
26442644- metadata: JSON.stringify({ title: 'Item Created Event Test' })
26452645- });
26462646- });
26472647- }, testUrl);
26482648-26492649- expect((result as any).received).toBe(true);
26502650- expect((result as any).itemId).toBeTruthy();
26512651- expect((result as any).itemType).toBe('url');
26522652- expect((result as any).content).toBe(testUrl);
26532653- });
26542654-26552655- test('item:updated is emitted when item is updated', async () => {
26562656- const timestamp = Date.now();
26572657-26582658- const result = await bgWindow.evaluate(async (ts: number) => {
26592659- const api = (window as any).app;
26602660-26612661- // First create an item
26622662- const itemResult = await api.datastore.addItem('url', {
26632663- content: `https://item-updated-event-${ts}.example.com`,
26642664- metadata: JSON.stringify({ title: 'Item Updated Event Test' })
26652665- });
26662666- if (!itemResult.success) {
26672667- return { received: false, error: 'failed to create item' };
26682668- }
26692669- const itemId = itemResult.data.id;
26702670-26712671- return new Promise((resolve) => {
26722672- const timeout = setTimeout(() => {
26732673- resolve({ received: false, error: 'timeout' });
26742674- }, 5000);
26752675-26762676- api.subscribe('item:updated', (msg: any) => {
26772677- if (msg.itemId === itemId) {
26782678- clearTimeout(timeout);
26792679- resolve({
26802680- received: true,
26812681- itemId: msg.itemId,
26822682- itemType: msg.itemType
26832683- });
26842684- }
26852685- }, api.scopes.GLOBAL);
26862686-26872687- // Update item to trigger the event
26882688- api.datastore.updateItem(itemId, {
26892689- content: `https://item-updated-event-${ts}-modified.example.com`
26902690- });
26912691- });
26922692- }, timestamp);
26932693-26942694- expect((result as any).received).toBe(true);
26952695- expect((result as any).itemId).toBeTruthy();
26962696- expect((result as any).itemType).toBe('url');
26972697- });
26982698-26992699- test('item:deleted is emitted when item is deleted', async () => {
27002700- const timestamp = Date.now();
27012701-27022702- const result = await bgWindow.evaluate(async (ts: number) => {
27032703- const api = (window as any).app;
27042704-27052705- // First create an item
27062706- const itemResult = await api.datastore.addItem('url', {
27072707- content: `https://item-deleted-event-${ts}.example.com`,
27082708- metadata: JSON.stringify({ title: 'Item Deleted Event Test' })
27092709- });
27102710- if (!itemResult.success) {
27112711- return { received: false, error: 'failed to create item' };
27122712- }
27132713- const itemId = itemResult.data.id;
27142714-27152715- return new Promise((resolve) => {
27162716- const timeout = setTimeout(() => {
27172717- resolve({ received: false, error: 'timeout' });
27182718- }, 5000);
27192719-27202720- api.subscribe('item:deleted', (msg: any) => {
27212721- if (msg.itemId === itemId) {
27222722- clearTimeout(timeout);
27232723- resolve({
27242724- received: true,
27252725- itemId: msg.itemId,
27262726- itemType: msg.itemType
27272727- });
27282728- }
27292729- }, api.scopes.GLOBAL);
27302730-27312731- // Delete item to trigger the event
27322732- api.datastore.deleteItem(itemId);
27332733- });
27342734- }, timestamp);
27352735-27362736- expect((result as any).received).toBe(true);
27372737- expect((result as any).itemId).toBeTruthy();
27382738- expect((result as any).itemType).toBe('url');
27392739- });
27402740-});
27412741-27422742-// ============================================================================
27432743-// Groups View Tests (uses shared app)
27442744-// ============================================================================
27452745-27462746-test.describe('Groups View @desktop', () => {
27472747- let app: DesktopApp;
27482748- let bgWindow: Page;
27492749-27502750- test.beforeAll(async () => {
27512751- ({ app, bgWindow } = await createPerDescribeApp('groups-view'));
27522752- });
27532753-27542754- test.afterAll(async () => {
27552755- if (app) await app.close();
27562756- });
27572757-27582758- test('empty groups are not shown in groups list', async () => {
27592759- // Create an empty tag (group with no items) and promote it
27602760- const emptyTag = await bgWindow.evaluate(async () => {
27612761- const result = await (window as any).app.datastore.getOrCreateTag('empty-group-test');
27622762- if (result.success) {
27632763- const tag = result.data.tag;
27642764- await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) });
27652765- }
27662766- return result;
27672767- });
27682768- expect(emptyTag.success).toBe(true);
27692769-27702770- // Create a tag with an item and promote it
27712771- const nonEmptyTag = await bgWindow.evaluate(async () => {
27722772- const result = await (window as any).app.datastore.getOrCreateTag('non-empty-group-test');
27732773- if (result.success) {
27742774- const tag = result.data.tag;
27752775- await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) });
27762776- }
27772777- return result;
27782778- });
27792779- expect(nonEmptyTag.success).toBe(true);
27802780-27812781- const item = await bgWindow.evaluate(async () => {
27822782- return await (window as any).app.datastore.addItem('url', {
27832783- content: 'https://non-empty-group-addr.example.com',
27842784- metadata: JSON.stringify({ title: 'Non Empty Group Address' })
27852785- });
27862786- });
27872787- expect(item.success).toBe(true);
27882788-27892789- await bgWindow.evaluate(async ({ itemId, tagId }) => {
27902790- return await (window as any).app.datastore.tagItem(itemId, tagId);
27912791- }, { itemId: item.data.id, tagId: nonEmptyTag.data.tag.id });
27922792-27932793- // Open groups home
27942794- const groupsResult = await bgWindow.evaluate(async () => {
27952795- return await (window as any).app.window.open('peek://groups/home.html', {
27962796- width: 800,
27972797- height: 600
27982798- });
27992799- });
28002800- expect(groupsResult.success).toBe(true);
28012801-28022802- // Find the groups window (getWindow polls)
28032803- const groupsWindow = await app.getWindow('groups/home.html', 5000);
28042804- expect(groupsWindow).toBeTruthy();
28052805- await groupsWindow.waitForLoadState('domcontentloaded');
28062806-28072807- // Wait for group cards to render (async data loading + rendering)
28082808- await groupsWindow.waitForSelector(`peek-card.group-card[data-tag-id="${nonEmptyTag.data.tag.id}"]`, { timeout: 10000 });
28092809-28102810- // Get all group card tag IDs
28112811- const groupCards = await groupsWindow.$$eval('peek-card.group-card', (cards: any[]) =>
28122812- cards.map(c => c.dataset.tagId)
28132813- );
28142814-28152815- // Non-empty group should be shown
28162816- expect(groupCards.includes(String(nonEmptyTag.data.tag.id))).toBe(true);
28172817-28182818- // Empty groups ARE shown (so newly created groups appear immediately)
28192819- expect(groupCards.includes(String(emptyTag.data.tag.id))).toBe(true);
28202820-28212821- // Clean up
28222822- if (groupsResult.id) {
28232823- try {
28242824- await bgWindow.evaluate(async (id: number) => {
28252825- return await (window as any).app.window.close(id);
28262826- }, groupsResult.id);
28272827- } catch {
28282828- // Window may already be closed
28292829- }
28302830- }
28312831- });
28322832-28332833- test('Untagged group shows when there are untagged items', async () => {
28342834- // Create an untagged URL item
28352835- const testUrl = 'https://untagged-for-groups-view.example.com/';
28362836- const item = await bgWindow.evaluate(async (url: string) => {
28372837- return await (window as any).app.datastore.addItem('url', {
28382838- content: url,
28392839- metadata: JSON.stringify({ title: 'Untagged For Groups View' })
28402840- });
28412841- }, testUrl);
28422842- expect(item.success).toBe(true);
28432843-28442844- // Verify the item exists and has no tags
28452845- const itemTags = await bgWindow.evaluate(async (itemId: string) => {
28462846- return await (window as any).app.datastore.getItemTags(itemId);
28472847- }, item.data.id);
28482848- expect(itemTags.success).toBe(true);
28492849- expect(itemTags.data.length).toBe(0);
28502850-28512851- // Open groups home
28522852- const groupsResult = await bgWindow.evaluate(async () => {
28532853- return await (window as any).app.window.open('peek://groups/home.html', {
28542854- width: 800,
28552855- height: 600,
28562856- key: 'groups-untagged-test'
28572857- });
28582858- });
28592859- expect(groupsResult.success).toBe(true);
28602860-28612861- // Find the groups window (getWindow polls)
28622862- const groupsWindow = await app.getWindow('groups/home.html', 5000);
28632863- expect(groupsWindow).toBeTruthy();
28642864- await groupsWindow.waitForLoadState('domcontentloaded');
28652865-28662866- // Wait for cards to render
28672867- await groupsWindow.waitForSelector('.cards', { timeout: 5000 });
28682868-28692869- const untaggedCard = await groupsWindow.waitForSelector('peek-card.group-card[data-tag-id="__untagged__"]', { timeout: 5000 }).catch(() => null);
28702870- expect(untaggedCard).toBeTruthy();
28712871-28722872- // Verify it shows the special-group class
28732873- const hasSpecialClass = await untaggedCard!.evaluate((el: HTMLElement) =>
28742874- el.classList.contains('special-group')
28752875- );
28762876- expect(hasSpecialClass).toBe(true);
28772877-28782878- // Clean up
28792879- if (groupsResult.id) {
28802880- try {
28812881- await bgWindow.evaluate(async (id: number) => {
28822882- return await (window as any).app.window.close(id);
28832883- }, groupsResult.id);
28842884- } catch {
28852885- // Window may already be closed
28862886- }
28872887- }
28882888- });
28892889-});
28902890-28912891-// ============================================================================
28922892-// Extension Lifecycle Tests
28932893-// ============================================================================
28942894-28952895-test.describe('Extension Lifecycle @desktop', () => {
28962896- let app: DesktopApp;
28972897- let bgWindow: Page;
28982898-28992899- const EXAMPLE_EXT_PATH = path.join(ROOT, 'features', 'example');
29002900-29012901- test.beforeAll(async () => {
29022902- app = await launchDesktopApp('test-ext-lifecycle');
29032903- bgWindow = await app.getBackgroundWindow();
29042904- });
29052905-29062906- test.afterAll(async () => {
29072907- if (app) await app.close();
29082908- });
29092909-29102910- test('validate extension folder', async () => {
29112911- const result = await bgWindow.evaluate(async (extPath: string) => {
29122912- return await (window as any).app.extensions.validateFolder(extPath);
29132913- }, EXAMPLE_EXT_PATH);
29142914-29152915- expect(result.success).toBe(true);
29162916- expect(result.data).toBeTruthy();
29172917- expect(result.data.manifest).toBeTruthy();
29182918- expect(result.data.manifest.id || result.data.manifest.shortname || result.data.manifest.name).toBeTruthy();
29192919- });
29202920-29212921- test('add extension', async () => {
29222922- // First validate to get manifest
29232923- const validateResult = await bgWindow.evaluate(async (extPath: string) => {
29242924- return await (window as any).app.extensions.validateFolder(extPath);
29252925- }, EXAMPLE_EXT_PATH);
29262926-29272927- const manifest = validateResult.data.manifest;
29282928-29292929- // Add the extension
29302930- const addResult = await bgWindow.evaluate(async ({ extPath, manifest }) => {
29312931- return await (window as any).app.extensions.add(extPath, manifest, false);
29322932- }, { extPath: EXAMPLE_EXT_PATH, manifest });
29332933-29342934- expect(addResult.success).toBe(true);
29352935- expect(addResult.data).toBeTruthy();
29362936- expect(addResult.data.id).toBeTruthy();
29372937- });
29382938-29392939- test('list extensions includes added extension', async () => {
29402940- const result = await bgWindow.evaluate(async () => {
29412941- return await (window as any).app.extensions.getAll();
29422942- });
29432943-29442944- expect(result.success).toBe(true);
29452945- expect(Array.isArray(result.data)).toBe(true);
29462946-29472947- // Find the example extension
29482948- const exampleExt = result.data.find((ext: any) =>
29492949- ext.id === 'example' || ext.path?.includes('example')
29502950- );
29512951- expect(exampleExt).toBeTruthy();
29522952- });
29532953-29542954- test('update extension (enable/disable)', async () => {
29552955- // Enable the extension
29562956- const enableResult = await bgWindow.evaluate(async () => {
29572957- return await (window as any).app.extensions.update('example', { enabled: true });
29582958- });
29592959- expect(enableResult.success).toBe(true);
29602960-29612961- // Verify it's enabled (accept both boolean true and integer 1)
29622962- const getResult1 = await bgWindow.evaluate(async () => {
29632963- return await (window as any).app.extensions.get('example');
29642964- });
29652965- expect(getResult1.success).toBe(true);
29662966- expect(getResult1.data.enabled === true || getResult1.data.enabled === 1).toBe(true);
29672967-29682968- // Disable it
29692969- const disableResult = await bgWindow.evaluate(async () => {
29702970- return await (window as any).app.extensions.update('example', { enabled: false });
29712971- });
29722972- expect(disableResult.success).toBe(true);
29732973-29742974- // Verify it's disabled
29752975- const getResult2 = await bgWindow.evaluate(async () => {
29762976- return await (window as any).app.extensions.get('example');
29772977- });
29782978- expect(getResult2.success).toBe(true);
29792979- expect(getResult2.data.enabled === false || getResult2.data.enabled === 0).toBe(true);
29802980- });
29812981-29822982- test('remove extension', async () => {
29832983- const removeResult = await bgWindow.evaluate(async () => {
29842984- return await (window as any).app.extensions.remove('example');
29852985- });
29862986- expect(removeResult.success).toBe(true);
29872987-29882988- // Verify it's removed
29892989- const getResult = await bgWindow.evaluate(async () => {
29902990- return await (window as any).app.extensions.get('example');
29912991- });
29922992- expect(getResult.success).toBe(false);
29932993-29942994- // Verify it's not in list
29952995- const listResult = await bgWindow.evaluate(async () => {
29962996- return await (window as any).app.extensions.getAll();
29972997- });
29982998- expect(listResult.success).toBe(true);
29992999- const exampleExt = listResult.data.find((ext: any) => ext.id === 'example');
30003000- expect(exampleExt).toBeFalsy();
30013001- });
30023002-});
30033003-30043004-// ============================================================================
30053005-// Command Chaining Tests (uses shared app)
30063006-// ============================================================================
30073007-30083008-test.describe('Command Chaining @desktop', () => {
30093009- let app: DesktopApp;
30103010- let bgWindow: Page;
30113011-30123012- test.beforeAll(async () => {
30133013- ({ app, bgWindow } = await createPerDescribeApp('cmd-chain'));
30143014- });
30153015-30163016- test.afterAll(async () => {
30173017- if (app) await app.close();
30183018- });
30193019-30203020- test('cmd panel loads with chain state initialized', async () => {
30213021- // Open cmd panel to verify it loads correctly with chain support
30223022- const openResult = await bgWindow.evaluate(async () => {
30233023- return await (window as any).app.window.open('peek://cmd/panel.html', {
30243024- modal: true,
30253025- width: 600,
30263026- height: 300,
30273027- frame: false,
30283028- transparent: true,
30293029- alwaysOnTop: true,
30303030- center: true
30313031- });
30323032- });
30333033- expect(openResult.success).toBe(true);
30343034-30353035- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
30363036- expect(cmdWindow).toBeTruthy();
30373037-30383038- // Verify state object has chain properties
30393039- const hasChainState = await cmdWindow.evaluate(() => {
30403040- // Access state through the module scope would require exposing it
30413041- // Instead verify the UI elements that depend on chain state exist
30423042- const chainIndicator = document.getElementById('chain-indicator');
30433043- const previewContainer = document.getElementById('preview-container');
30443044- return chainIndicator !== null && previewContainer !== null;
30453045- });
30463046- expect(hasChainState).toBe(true);
30473047-30483048- // Close the window
30493049- if (openResult.id) {
30503050- await bgWindow.evaluate(async (id: number) => {
30513051- return await (window as any).app.window.close(id);
30523052- }, openResult.id);
30533053- }
30543054- });
30553055-30563056- test('MIME type matching works correctly', async () => {
30573057- // Test MIME matching logic in panel context
30583058- const openResult = await bgWindow.evaluate(async () => {
30593059- return await (window as any).app.window.open('peek://cmd/panel.html', {
30603060- modal: true,
30613061- width: 600,
30623062- height: 50,
30633063- frame: false,
30643064- transparent: true,
30653065- alwaysOnTop: true,
30663066- center: true
30673067- });
30683068- });
30693069- expect(openResult.success).toBe(true);
30703070-30713071- // Find the cmd window
30723072- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
30733073- expect(cmdWindow).toBeTruthy();
30743074-30753075- // Test MIME type matching function (if exposed, or test via behavior)
30763076- // The panel.js has mimeTypeMatches function - we test the expected behavior
30773077-30783078- // Test exact match: 'application/json' matches 'application/json'
30793079- const exactMatch = await cmdWindow.evaluate(() => {
30803080- // We can't directly call the function, but we can verify commands filter correctly
30813081- // This is more of an integration test
30823082- return true;
30833083- });
30843084- expect(exactMatch).toBe(true);
30853085-30863086- // Close the window
30873087- if (openResult.id) {
30883088- await bgWindow.evaluate(async (id: number) => {
30893089- return await (window as any).app.window.close(id);
30903090- }, openResult.id);
30913091- }
30923092- });
30933093-30943094- test('cmd panel input works correctly', async () => {
30953095- // Open cmd panel
30963096- const openResult = await bgWindow.evaluate(async () => {
30973097- return await (window as any).app.window.open('peek://cmd/panel.html', {
30983098- modal: true,
30993099- width: 600,
31003100- height: 400,
31013101- frame: false,
31023102- transparent: true,
31033103- alwaysOnTop: true,
31043104- center: true
31053105- });
31063106- });
31073107- expect(openResult.success).toBe(true);
31083108-31093109- // Find the cmd window
31103110- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
31113111- expect(cmdWindow).toBeTruthy();
31123112-31133113- // Wait for input to be ready
31143114- await cmdWindow.waitForSelector('input', { timeout: 5000 });
31153115-31163116- // Verify input is focusable and can receive text
31173117- await cmdWindow.fill('input', 'test');
31183118- const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value);
31193119- expect(inputValue).toBe('test');
31203120-31213121- // Close the window
31223122- if (openResult.id) {
31233123- await bgWindow.evaluate(async (id: number) => {
31243124- return await (window as any).app.window.close(id);
31253125- }, openResult.id);
31263126- }
31273127- });
31283128-31293129- test('panel has chain indicator, preview, and execution state elements', async () => {
31303130- // Open cmd panel
31313131- const openResult = await bgWindow.evaluate(async () => {
31323132- return await (window as any).app.window.open('peek://cmd/panel.html', {
31333133- modal: true,
31343134- width: 600,
31353135- height: 300,
31363136- frame: false,
31373137- transparent: true,
31383138- alwaysOnTop: true,
31393139- center: true
31403140- });
31413141- });
31423142- expect(openResult.success).toBe(true);
31433143-31443144- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
31453145- expect(cmdWindow).toBeTruthy();
31463146-31473147- // Check chain indicator element exists
31483148- const chainIndicator = await cmdWindow.$('#chain-indicator');
31493149- expect(chainIndicator).toBeTruthy();
31503150-31513151- // Check preview container exists
31523152- const previewContainer = await cmdWindow.$('#preview-container');
31533153- expect(previewContainer).toBeTruthy();
31543154-31553155- // Check execution state element exists
31563156- const executionState = await cmdWindow.$('#execution-state');
31573157- expect(executionState).toBeTruthy();
31583158-31593159- // Verify chain indicator is initially hidden (no 'visible' class)
31603160- const chainVisible = await cmdWindow.$eval('#chain-indicator', (el: HTMLElement) => el.classList.contains('visible'));
31613161- expect(chainVisible).toBe(false);
31623162-31633163- // Verify preview is initially hidden (no 'visible' class)
31643164- const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible'));
31653165- expect(previewVisible).toBe(false);
31663166-31673167- // Verify execution state is initially hidden (no 'visible' class)
31683168- const execVisible = await cmdWindow.$eval('#execution-state', (el: HTMLElement) => el.classList.contains('visible'));
31693169- expect(execVisible).toBe(false);
31703170-31713171- // Verify results is initially hidden (no 'visible' class)
31723172- const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible'));
31733173- expect(resultsVisible).toBe(false);
31743174-31753175- // Close the window
31763176- if (openResult.id) {
31773177- await bgWindow.evaluate(async (id: number) => {
31783178- return await (window as any).app.window.close(id);
31793179- }, openResult.id);
31803180- }
31813181- });
31823182-31833183- test('list urls command produces array output and enters output selection mode', async () => {
31843184- // Open cmd panel
31853185- const openResult = await bgWindow.evaluate(async () => {
31863186- return await (window as any).app.window.open('peek://cmd/panel.html', {
31873187- modal: true,
31883188- width: 600,
31893189- height: 400,
31903190- frame: false,
31913191- transparent: true,
31923192- alwaysOnTop: true,
31933193- center: true
31943194- });
31953195- });
31963196- expect(openResult.success).toBe(true);
31973197-31983198- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
31993199- expect(cmdWindow).toBeTruthy();
32003200-32013201- // Wait for input to be ready and commands to be loaded
32023202- await cmdWindow.waitForSelector('input', { timeout: 5000 });
32033203- await waitForPanelCommandsLoaded(cmdWindow);
32043204-32053205- // Type 'list urls' command
32063206- await cmdWindow.fill('input', 'list urls');
32073207-32083208- // Press down arrow to show results
32093209- await cmdWindow.press('input', 'ArrowDown');
32103210- await waitForClass(cmdWindow, '#results', 'visible');
32113211-32123212- // Verify results are visible
32133213- const resultsVisible = await cmdWindow.$eval('#results', (el: HTMLElement) => el.classList.contains('visible'));
32143214- expect(resultsVisible).toBe(true);
32153215-32163216- // Press Enter to execute
32173217- await cmdWindow.press('input', 'Enter');
32183218- await waitForResultsWithContent(cmdWindow);
32193219-32203220- // After list urls executes, we should be in output selection mode
32213221- // Results should show the items from the list urls output
32223222- const hasResults = await cmdWindow.$eval('#results', (el: HTMLElement) => {
32233223- return el.classList.contains('visible') && el.children.length > 0;
32243224- });
32253225- expect(hasResults).toBe(true);
32263226-32273227- // Preview should show the selected item
32283228- const previewVisible = await cmdWindow.$eval('#preview-container', (el: HTMLElement) => el.classList.contains('visible'));
32293229- expect(previewVisible).toBe(true);
32303230-32313231- // Close the window
32323232- if (openResult.id) {
32333233- await bgWindow.evaluate(async (id: number) => {
32343234- return await (window as any).app.window.close(id);
32353235- }, openResult.id);
32363236- }
32373237- });
32383238-32393239- test('selecting output item enters chain mode with filtered commands', async () => {
32403240- // Architectural contract (see docs/cmd-chain-architecture.md):
32413241- // `list urls` → OUTPUT_SELECTION → Enter on a row → CHAIN_MODE.
32423242- // CHAIN_MODE is NEVER reached direct-from-EXECUTING; the user always
32433243- // sees their rows first so they can pick which one to chain against.
32443244- await waitForCommand(bgWindow, 'csv', 10000);
32453245-32463246- // Seed a couple of urls so list urls returns a selectable list
32473247- await bgWindow.evaluate(async () => {
32483248- const api = (window as any).app;
32493249- await api.datastore.addItem('url', { url: 'https://example.com/chain-a', title: 'chain-a' });
32503250- await api.datastore.addItem('url', { url: 'https://example.com/chain-b', title: 'chain-b' });
32513251- });
32523252-32533253- const openResult = await bgWindow.evaluate(async () => {
32543254- return await (window as any).app.window.open('peek://cmd/panel.html', {
32553255- modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
32563256- });
32573257- });
32583258- expect(openResult.success).toBe(true);
32593259-32603260- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
32613261- await cmdWindow.waitForSelector('input', { timeout: 5000 });
32623262- await waitForPanelCommandsLoaded(cmdWindow);
32633263-32643264- await cmdWindow.fill('input', 'list urls');
32653265- await cmdWindow.press('input', 'Enter');
32663266-32673267- // Step 1: OUTPUT_SELECTION entered first (not CHAIN_MODE)
32683268- await cmdWindow.waitForFunction(
32693269- () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION',
32703270- null, { timeout: 5000 }
32713271- );
32723272-32733273- // Step 2: Enter on the selected row → CHAIN_MODE
32743274- await cmdWindow.press('input', 'Enter');
32753275- await cmdWindow.waitForFunction(
32763276- () => (window as any)._cmdState?.currentState === 'CHAIN_MODE',
32773277- null, { timeout: 5000 }
32783278- );
32793279-32803280- // Step 3: CHAIN_MODE suggestions include csv (accepts application/json)
32813281- const suggestions = await cmdWindow.evaluate(() => (window as any)._cmdState?.matches || []);
32823282- expect(suggestions).toContain('csv');
32833283-32843284- if (openResult.id) {
32853285- await bgWindow.evaluate(async (id: number) => {
32863286- return await (window as any).app.window.close(id);
32873287- }, openResult.id);
32883288- }
32893289- });
32903290-32913291- test('csv command converts JSON to CSV format', async () => {
32923292- // list urls → OUTPUT_SELECTION → select row → CHAIN_MODE → csv → text/csv
32933293- await waitForCommand(bgWindow, 'csv', 10000);
32943294-32953295- await bgWindow.evaluate(async () => {
32963296- const api = (window as any).app;
32973297- await api.datastore.addItem('url', { url: 'https://example.com/csv-a', title: 'csv-a' });
32983298- await api.datastore.addItem('url', { url: 'https://example.com/csv-b', title: 'csv-b' });
32993299- });
33003300-33013301- const openResult = await bgWindow.evaluate(async () => {
33023302- return await (window as any).app.window.open('peek://cmd/panel.html', {
33033303- modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
33043304- });
33053305- });
33063306- expect(openResult.success).toBe(true);
33073307-33083308- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
33093309- await cmdWindow.waitForSelector('input', { timeout: 5000 });
33103310- await waitForPanelCommandsLoaded(cmdWindow);
33113311-33123312- await cmdWindow.fill('input', 'list urls');
33133313- await cmdWindow.press('input', 'Enter');
33143314-33153315- // OUTPUT_SELECTION first
33163316- await cmdWindow.waitForFunction(
33173317- () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION',
33183318- null, { timeout: 5000 }
33193319- );
33203320-33213321- // Enter a row → CHAIN_MODE
33223322- await cmdWindow.press('input', 'Enter');
33233323- await cmdWindow.waitForFunction(
33243324- () => (window as any)._cmdState?.currentState === 'CHAIN_MODE',
33253325- null, { timeout: 5000 }
33263326- );
33273327-33283328- // Type csv to filter matches, then ArrowDown+Enter to execute
33293329- await cmdWindow.fill('input', 'csv');
33303330- await cmdWindow.waitForFunction(
33313331- () => ((window as any)._cmdState?.matches || []).includes('csv'),
33323332- null, { timeout: 5000 }
33333333- );
33343334- await cmdWindow.press('input', 'ArrowDown');
33353335- await cmdWindow.press('input', 'Enter');
33363336-33373337- // csv produces text/csv — wait for state to leave EXECUTING (csv is lazy
33383338- // so first invoke loads the tile; proxy has 30s timeout, bucket covers).
33393339- // If the panel closes mid-poll, we accept — that IS a terminal state.
33403340- const postExecState = await waitForCmdNotExecuting(cmdWindow, 35000);
33413341- expect(postExecState).not.toBe('TIMEOUT');
33423342-33433343- // After csv execution, either chainContext has text/csv (chain continues)
33443344- // or the panel transitioned to CLOSING (terminal csv output). Both are valid
33453345- // terminal states — we only fail if csv's result never arrived at all.
33463346- // If the panel already closed, we can't read chainContext — that's fine,
33473347- // closed itself is a valid terminal.
33483348- let final: { state: string | undefined; mimeType: string | undefined };
33493349- if (postExecState === 'CLOSED') {
33503350- final = { state: 'CLOSING', mimeType: undefined };
33513351- } else {
33523352- try {
33533353- final = await cmdWindow.evaluate(() => {
33543354- const s = (window as any)._cmdState;
33553355- return { state: s?.currentState, mimeType: s?.chainContext?.mimeType };
33563356- });
33573357- } catch {
33583358- // Page closed between waitForCmdNotExecuting and this evaluate — fine.
33593359- final = { state: 'CLOSING', mimeType: undefined };
33603360- }
33613361- }
33623362-33633363- if (final.state === 'CHAIN_MODE') {
33643364- expect(final.mimeType).toBe('text/csv');
33653365- } else {
33663366- // csv is lazy-loaded; first invoke may exceed the proxy's 30s timeout
33673367- // in slow CI. ERROR is also acceptable — this test verifies the chain
33683368- // plumbing reaches csv, not lazy-tile performance.
33693369- expect(['CLOSING', 'IDLE', 'OUTPUT_SELECTION', 'ERROR']).toContain(final.state);
33703370- }
33713371-33723372- if (openResult.id) {
33733373- try {
33743374- await bgWindow.evaluate(async (id: number) => {
33753375- return await (window as any).app.window.close(id);
33763376- }, openResult.id);
33773377- } catch { /* may already be closed */ }
33783378- }
33793379- });
33803380-33813381- test('escape exits chain mode before closing panel', async () => {
33823382- // Canonical ESC layering: ESC in CHAIN_MODE exits chain (back to
33833383- // OUTPUT_SELECTION or IDLE depending on stack), does NOT close the panel.
33843384- await waitForCommand(bgWindow, 'csv', 10000);
33853385-33863386- await bgWindow.evaluate(async () => {
33873387- const api = (window as any).app;
33883388- await api.datastore.addItem('url', { url: 'https://example.com/esc-a', title: 'esc-a' });
33893389- await api.datastore.addItem('url', { url: 'https://example.com/esc-b', title: 'esc-b' });
33903390- });
33913391-33923392- const openResult = await bgWindow.evaluate(async () => {
33933393- return await (window as any).app.window.open('peek://cmd/panel.html', {
33943394- modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
33953395- });
33963396- });
33973397- expect(openResult.success).toBe(true);
33983398-33993399- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
34003400- await cmdWindow.waitForSelector('input', { timeout: 5000 });
34013401- await waitForPanelCommandsLoaded(cmdWindow);
34023402-34033403- await cmdWindow.fill('input', 'list urls');
34043404- await cmdWindow.press('input', 'Enter');
34053405- await cmdWindow.waitForFunction(
34063406- () => (window as any)._cmdState?.currentState === 'OUTPUT_SELECTION',
34073407- null, { timeout: 5000 }
34083408- );
34093409- await cmdWindow.press('input', 'Enter');
34103410- await cmdWindow.waitForFunction(
34113411- () => (window as any)._cmdState?.currentState === 'CHAIN_MODE',
34123412- null, { timeout: 5000 }
34133413- );
34143414-34153415- // ESC in CHAIN_MODE exits the chain — state changes away from CHAIN_MODE
34163416- // (target is OUTPUT_SELECTION, TYPING, or IDLE per implementation), but
34173417- // NOT CLOSING — the panel remains open.
34183418- await cmdWindow.press('input', 'Escape');
34193419- await cmdWindow.waitForFunction(
34203420- () => {
34213421- const s = (window as any)._cmdState?.currentState;
34223422- return s !== 'CHAIN_MODE' && s !== 'CLOSING';
34233423- },
34243424- null, { timeout: 5000 }
34253425- );
34263426-34273427- const inputExists = await cmdWindow.$('input');
34283428- expect(inputExists).toBeTruthy();
34293429-34303430- if (openResult.id) {
34313431- await bgWindow.evaluate(async (id: number) => {
34323432- return await (window as any).app.window.close(id);
34333433- }, openResult.id);
34343434- }
34353435- });
34363436-34373437- test('arrow navigation in output selection mode', async () => {
34383438- // Seed multiple urls so navigation has more than one row
34393439- await bgWindow.evaluate(async () => {
34403440- const api = (window as any).app;
34413441- await api.datastore.addItem('url', { url: 'https://example.com/nav-a', title: 'nav-a' });
34423442- await api.datastore.addItem('url', { url: 'https://example.com/nav-b', title: 'nav-b' });
34433443- });
34443444-34453445- const openResult = await bgWindow.evaluate(async () => {
34463446- return await (window as any).app.window.open('peek://cmd/panel.html', {
34473447- modal: true, width: 600, height: 400, frame: false, transparent: true, alwaysOnTop: true, center: true,
34483448- });
34493449- });
34503450- expect(openResult.success).toBe(true);
34513451-34523452- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
34533453- await cmdWindow.waitForSelector('input', { timeout: 5000 });
34543454- await waitForPanelCommandsLoaded(cmdWindow);
34553455-34563456- await cmdWindow.fill('input', 'list urls');
34573457- await cmdWindow.press('input', 'Enter');
34583458-34593459- // OUTPUT_SELECTION with at least 2 rows
34603460- await cmdWindow.waitForFunction(
34613461- () => {
34623462- const s = (window as any)._cmdState;
34633463- return s?.currentState === 'OUTPUT_SELECTION' && (s?.outputItems?.length ?? 0) > 1;
34643464- },
34653465- null, { timeout: 5000 }
34663466- );
34673467-34683468- // First item is selected (outputItemIndex starts at 0)
34693469- const initialIndex = await cmdWindow.evaluate(() => (window as any)._cmdState?.outputItemIndex);
34703470- expect(initialIndex).toBe(0);
34713471-34723472- // Arrow down → index 1
34733473- await cmdWindow.press('input', 'ArrowDown');
34743474- await cmdWindow.waitForFunction(
34753475- () => (window as any)._cmdState?.outputItemIndex === 1,
34763476- null, { timeout: 2000 }
34773477- );
34783478-34793479- // Arrow up → back to 0
34803480- await cmdWindow.press('input', 'ArrowUp');
34813481- await cmdWindow.waitForFunction(
34823482- () => (window as any)._cmdState?.outputItemIndex === 0,
34833483- null, { timeout: 2000 }
34843484- );
34853485-34863486- if (openResult.id) {
34873487- await bgWindow.evaluate(async (id: number) => {
34883488- return await (window as any).app.window.close(id);
34893489- }, openResult.id);
34903490- }
34913491- });
34923492-});
34933493-34943494-34953495-// ============================================================================
34963496-// Edit Command Param Mode Tests (uses shared app)
34973497-// ============================================================================
34983498-34993499-test.describe('Edit Command Param Mode @desktop', () => {
35003500- let app: DesktopApp;
35013501- let bgWindow: Page;
35023502-35033503- test.beforeAll(async () => {
35043504- ({ app, bgWindow } = await createPerDescribeApp('edit-param'));
35053505- });
35063506-35073507- test.afterAll(async () => {
35083508- if (app) await app.close();
35093509- });
35103510-35113511- test('Tab in param mode fills text, does NOT execute', async () => {
35123512- // Create a test note so param mode has suggestions
35133513- const createResult = await bgWindow.evaluate(async () => {
35143514- return await (window as any).app.datastore.addItem('text', {
35153515- content: '# Tab Test Note\nThis is a note for testing Tab in param mode.'
35163516- });
35173517- });
35183518- expect(createResult.success).toBe(true);
35193519- const noteId = createResult.data.id;
35203520-35213521- // Open cmd panel
35223522- const openResult = await bgWindow.evaluate(async () => {
35233523- return await (window as any).app.window.open('peek://cmd/panel.html', {
35243524- modal: true,
35253525- width: 600,
35263526- height: 400,
35273527- frame: false,
35283528- transparent: true,
35293529- alwaysOnTop: true,
35303530- center: true
35313531- });
35323532- });
35333533- expect(openResult.success).toBe(true);
35343534-35353535- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
35363536- expect(cmdWindow).toBeTruthy();
35373537-35383538- await cmdWindow.waitForSelector('input', { timeout: 5000 });
35393539- await waitForPanelCommandsLoaded(cmdWindow);
35403540-35413541- // Type 'edit ' (with space) to commit to the edit command and enter param mode
35423542- // (Tab would cycle to 'editor' since both match 'edit')
35433543- await cmdWindow.fill('input', 'edit ');
35443544-35453545- // Wait for param mode to activate
35463546- await cmdWindow.waitForFunction(() => {
35473547- const s = (window as any)._cmdState;
35483548- return s.paramMode === true && s.paramCommand === 'edit';
35493549- }, { timeout: 5000 });
35503550-35513551- // Wait for param suggestions to load (items query)
35523552- await cmdWindow.waitForFunction(() => {
35533553- return (window as any)._cmdState.paramSuggestions.length > 0;
35543554- }, { timeout: 10000 });
35553555-35563556- // Press Tab on a suggestion - should fill text, NOT execute
35573557- await cmdWindow.keyboard.press('Tab');
35583558-35593559- // Verify param mode is still active (Tab fills, doesn't execute)
35603560- const stillParamMode = await cmdWindow.evaluate(() => {
35613561- return (window as any)._cmdState.paramMode === true;
35623562- });
35633563- expect(stillParamMode).toBe(true);
35643564-35653565- // Verify input text was updated with the suggestion value
35663566- const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value);
35673567- expect(inputValue.startsWith('edit ')).toBe(true);
35683568- expect(inputValue.length).toBeGreaterThan('edit '.length);
35693569-35703570- // Close the cmd window
35713571- if (openResult.id) {
35723572- await bgWindow.evaluate(async (id: number) => {
35733573- return await (window as any).app.window.close(id);
35743574- }, openResult.id);
35753575- }
35763576-35773577- // Clean up test note
35783578- await bgWindow.evaluate(async (id: string) => {
35793579- return await (window as any).app.datastore.deleteItem(id);
35803580- }, noteId);
35813581- });
35823582-35833583- test('Enter in param mode executes with correct itemId', async () => {
35843584- // Create a test note
35853585- const createResult = await bgWindow.evaluate(async () => {
35863586- return await (window as any).app.datastore.addItem('text', {
35873587- content: '# Enter Test Note\nThis is a note for testing Enter in param mode.'
35883588- });
35893589- });
35903590- expect(createResult.success).toBe(true);
35913591- const noteId = createResult.data.id;
35923592-35933593- // Set up a listener for editor:open events BEFORE opening cmd panel
35943594- await bgWindow.evaluate(async () => {
35953595- (window as any).__editorOpenEvents = [];
35963596- (window as any).app.subscribe('editor:open', (data: any) => {
35973597- (window as any).__editorOpenEvents.push(data);
35983598- }, { scope: 'global' });
35993599- });
36003600-36013601- // Open cmd panel
36023602- const openResult = await bgWindow.evaluate(async () => {
36033603- return await (window as any).app.window.open('peek://cmd/panel.html', {
36043604- modal: true,
36053605- width: 600,
36063606- height: 400,
36073607- frame: false,
36083608- transparent: true,
36093609- alwaysOnTop: true,
36103610- center: true
36113611- });
36123612- });
36133613- expect(openResult.success).toBe(true);
36143614-36153615- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
36163616- expect(cmdWindow).toBeTruthy();
36173617-36183618- await cmdWindow.waitForSelector('input', { timeout: 5000 });
36193619- await waitForPanelCommandsLoaded(cmdWindow);
36203620-36213621- // Type 'edit ' (with space) to commit to the edit command and enter param mode
36223622- await cmdWindow.fill('input', 'edit ');
36233623-36243624- // Wait for param mode and suggestions to load
36253625- await cmdWindow.waitForFunction(() => {
36263626- const s = (window as any)._cmdState;
36273627- return s.paramMode === true && s.paramCommand === 'edit' && s.paramSuggestions.length > 0;
36283628- }, { timeout: 10000 });
36293629-36303630- // Find the index of our test note in suggestions
36313631- const testNoteIndex = await cmdWindow.evaluate((targetId: string) => {
36323632- const suggestions = (window as any)._cmdState.paramSuggestions;
36333633- return suggestions.findIndex((s: any) => s._item && s._item.id === targetId);
36343634- }, noteId);
36353635-36363636- // Navigate to the test note if needed
36373637- if (testNoteIndex > 0) {
36383638- for (let i = 0; i < testNoteIndex; i++) {
36393639- await cmdWindow.keyboard.press('ArrowDown');
36403640- }
36413641- }
36423642-36433643- // Press Enter to execute with the selected item
36443644- await cmdWindow.keyboard.press('Enter');
36453645-36463646- // Wait for editor:open event to be published
36473647- await bgWindow.waitForFunction(() => {
36483648- return (window as any).__editorOpenEvents && (window as any).__editorOpenEvents.length > 0;
36493649- }, { timeout: 10000 });
36503650-36513651- // Verify editor:open was published with the correct itemId
36523652- const editorEvents = await bgWindow.evaluate(() => {
36533653- return (window as any).__editorOpenEvents;
36543654- });
36553655- expect(editorEvents.length).toBeGreaterThan(0);
36563656- const lastEvent = editorEvents[editorEvents.length - 1];
36573657- expect(lastEvent.itemId).toBe(noteId);
36583658-36593659- // Close cmd window if still open
36603660- if (openResult.id) {
36613661- await bgWindow.evaluate(async (id: number) => {
36623662- try { return await (window as any).app.window.close(id); } catch(e) { /* may already be closed */ }
36633663- }, openResult.id);
36643664- }
36653665-36663666- // Clean up
36673667- await bgWindow.evaluate(async (id: string) => {
36683668- delete (window as any).__editorOpenEvents;
36693669- return await (window as any).app.datastore.deleteItem(id);
36703670- }, noteId);
36713671- });
36723672-36733673- test('Tab in command mode completes name, does not execute', async () => {
36743674- // Open cmd panel
36753675- const openResult = await bgWindow.evaluate(async () => {
36763676- return await (window as any).app.window.open('peek://cmd/panel.html', {
36773677- modal: true,
36783678- width: 600,
36793679- height: 400,
36803680- frame: false,
36813681- transparent: true,
36823682- alwaysOnTop: true,
36833683- center: true
36843684- });
36853685- });
36863686- expect(openResult.success).toBe(true);
36873687-36883688- const cmdWindow = await app.getWindow('cmd/panel.html', 5000);
36893689- expect(cmdWindow).toBeTruthy();
36903690-36913691- await cmdWindow.waitForSelector('input', { timeout: 5000 });
36923692- await waitForPanelCommandsLoaded(cmdWindow);
36933693-36943694- // Type partial command name 'edi'
36953695- await cmdWindow.fill('input', 'edi');
36963696- await cmdWindow.keyboard.press('ArrowDown');
36973697- await waitForCommandResults(cmdWindow, 1, 10000);
36983698-36993699- // Press Tab - should complete command name, not execute
37003700- await cmdWindow.keyboard.press('Tab');
37013701-37023702- // Verify input is now 'edit' (completed from 'edi')
37033703- const inputValue = await cmdWindow.$eval('input', (el: HTMLInputElement) => el.value);
37043704- expect(inputValue.toLowerCase().startsWith('edit')).toBe(true);
37053705-37063706- // Verify the panel is still open and responsive (no command was executed)
37073707- const panelStillOpen = await cmdWindow.evaluate(() => {
37083708- return document.getElementById('command-input') !== null;
37093709- });
37103710- expect(panelStillOpen).toBe(true);
37113711-37123712- // Close the cmd window
37133713- if (openResult.id) {
37143714- await bgWindow.evaluate(async (id: number) => {
37153715- return await (window as any).app.window.close(id);
37163716- }, openResult.id);
37173717- }
37183718- });
37193719-});
37203720-37213721-// ============================================================================
37223722-// Theme Tests (uses shared app)
37233723-// ============================================================================
37243724-37253725-test.describe('Themes @desktop', () => {
37263726- let app: DesktopApp;
37273727- let bgWindow: Page;
37283728-37293729- test.beforeAll(async () => {
37303730- ({ app, bgWindow } = await createPerDescribeApp('themes'));
37313731- });
37323732-37333733- test.afterAll(async () => {
37343734- if (app) await app.close();
37353735- });
37363736-37373737- test('theme API is available', async () => {
37383738- const hasThemeApi = await bgWindow.evaluate(() => {
37393739- const api = (window as any).app;
37403740- return !!(api.theme && api.theme.get && api.theme.setTheme && api.theme.getAll);
37413741- });
37423742- expect(hasThemeApi).toBe(true);
37433743- });
37443744-37453745- test('get current theme state', async () => {
37463746- const themeState = await bgWindow.evaluate(async () => {
37473747- return await (window as any).app.theme.get();
37483748- });
37493749-37503750- expect(themeState).toBeTruthy();
37513751- expect(themeState.themeId).toBeTruthy();
37523752- expect(themeState.colorScheme).toBeTruthy();
37533753- expect(['system', 'light', 'dark']).toContain(themeState.colorScheme);
37543754- expect(typeof themeState.isDark).toBe('boolean');
37553755- expect(['light', 'dark']).toContain(themeState.effectiveScheme);
37563756- });
37573757-37583758- test('list available themes', async () => {
37593759- const result = await bgWindow.evaluate(async () => {
37603760- return await (window as any).app.theme.getAll();
37613761- });
37623762-37633763- expect(result.success).toBe(true);
37643764- expect(Array.isArray(result.data)).toBe(true);
37653765- expect(result.data.length).toBeGreaterThanOrEqual(2); // basic and peek
37663766-37673767- // Verify built-in themes exist
37683768- const themeIds = result.data.map((t: any) => t.id);
37693769- expect(themeIds).toContain('basic');
37703770- expect(themeIds).toContain('peek');
37713771-37723772- // Verify theme structure
37733773- for (const theme of result.data) {
37743774- expect(theme.id).toBeTruthy();
37753775- expect(theme.name).toBeTruthy();
37763776- expect(theme.version).toBeTruthy();
37773777- }
37783778- });
37793779-37803780- test('switch themes', async () => {
37813781- // Get initial theme
37823782- const initialState = await bgWindow.evaluate(async () => {
37833783- return await (window as any).app.theme.get();
37843784- });
37853785-37863786- // Switch to a different theme
37873787- const targetTheme = initialState.themeId === 'basic' ? 'peek' : 'basic';
37883788- const switchResult = await bgWindow.evaluate(async (themeId: string) => {
37893789- return await (window as any).app.theme.setTheme(themeId);
37903790- }, targetTheme);
37913791-37923792- expect(switchResult.success).toBe(true);
37933793- expect(switchResult.themeId).toBe(targetTheme);
37943794-37953795- // Verify theme changed
37963796- const newState = await bgWindow.evaluate(async () => {
37973797- return await (window as any).app.theme.get();
37983798- });
37993799- expect(newState.themeId).toBe(targetTheme);
38003800-38013801- // Switch back to original
38023802- await bgWindow.evaluate(async (themeId: string) => {
38033803- return await (window as any).app.theme.setTheme(themeId);
38043804- }, initialState.themeId);
38053805- });
38063806-38073807- test('switch color scheme', async () => {
38083808- // Get initial state
38093809- const initialState = await bgWindow.evaluate(async () => {
38103810- return await (window as any).app.theme.get();
38113811- });
38123812-38133813- // Switch to light mode
38143814- const lightResult = await bgWindow.evaluate(async () => {
38153815- return await (window as any).app.theme.setColorScheme('light');
38163816- });
38173817- expect(lightResult.success).toBe(true);
38183818- expect(lightResult.colorScheme).toBe('light');
38193819-38203820- // Verify it changed
38213821- let state = await bgWindow.evaluate(async () => {
38223822- return await (window as any).app.theme.get();
38233823- });
38243824- expect(state.colorScheme).toBe('light');
38253825- expect(state.effectiveScheme).toBe('light');
38263826-38273827- // Switch to dark mode
38283828- const darkResult = await bgWindow.evaluate(async () => {
38293829- return await (window as any).app.theme.setColorScheme('dark');
38303830- });
38313831- expect(darkResult.success).toBe(true);
38323832- expect(darkResult.colorScheme).toBe('dark');
38333833-38343834- state = await bgWindow.evaluate(async () => {
38353835- return await (window as any).app.theme.get();
38363836- });
38373837- expect(state.colorScheme).toBe('dark');
38383838- expect(state.effectiveScheme).toBe('dark');
38393839-38403840- // Switch back to system
38413841- const systemResult = await bgWindow.evaluate(async () => {
38423842- return await (window as any).app.theme.setColorScheme('system');
38433843- });
38443844- expect(systemResult.success).toBe(true);
38453845- expect(systemResult.colorScheme).toBe('system');
38463846-38473847- // Restore original color scheme
38483848- await bgWindow.evaluate(async (scheme: string) => {
38493849- return await (window as any).app.theme.setColorScheme(scheme);
38503850- }, initialState.colorScheme);
38513851- });
38523852-38533853- test('invalid theme returns error', async () => {
38543854- const result = await bgWindow.evaluate(async () => {
38553855- return await (window as any).app.theme.setTheme('nonexistent-theme');
38563856- });
38573857-38583858- expect(result.success).toBe(false);
38593859- expect(result.error).toBeTruthy();
38603860- });
38613861-38623862- test('invalid color scheme returns error', async () => {
38633863- const result = await bgWindow.evaluate(async () => {
38643864- return await (window as any).app.theme.setColorScheme('invalid');
38653865- });
38663866-38673867- expect(result.success).toBe(false);
38683868- expect(result.error).toBeTruthy();
38693869- });
38703870-});
38713871-38723872-// ============================================================================
38733873-// Command Registration Performance Tests (uses shared app)
38743874-// ============================================================================
38753875-38763876-test.describe('Command Registration Performance @desktop', () => {
38773877- let app: DesktopApp;
38783878- let bgWindow: Page;
38793879-38803880- test.beforeAll(async () => {
38813881- ({ app, bgWindow } = await createPerDescribeApp('cmd-perf'));
38823882- // Wait for cmd extension to be fully ready before running performance tests
38833883- await waitForExtensionsReady(bgWindow, 15000);
38843884- });
38853885-38863886- test.afterAll(async () => {
38873887- if (app) await app.close();
38883888- });
38893889-38903890- test('cmd:register-batch is handled by cmd extension', async () => {
38913891- // Test that batch registration works by sending a batch and verifying commands appear
38923892- const result = await bgWindow.evaluate(async () => {
38933893- const api = (window as any).app;
38943894-38953895- // Send a batch of test commands
38963896- api.publish('cmd:register-batch', {
38973897- commands: [
38983898- { name: 'test-batch-cmd-1', description: 'Test batch command 1', source: 'test' },
38993899- { name: 'test-batch-cmd-2', description: 'Test batch command 2', source: 'test' },
39003900- { name: 'test-batch-cmd-3', description: 'Test batch command 3', source: 'test' }
39013901- ]
39023902- }, api.scopes.GLOBAL);
39033903-39043904- // Poll for commands to appear (deterministic retry instead of fixed timeout)
39053905- const maxAttempts = 20;
39063906- const pollInterval = 100;
39073907-39083908- for (let attempt = 0; attempt < maxAttempts; attempt++) {
39093909- await new Promise(r => setTimeout(r, pollInterval));
39103910-39113911- const commands = await new Promise<any[]>((resolve) => {
39123912- const unsub = api.subscribe('cmd:query-commands-response', (msg: any) => {
39133913- unsub?.();
39143914- resolve(msg.commands || []);
39153915- }, api.scopes.GLOBAL);
39163916- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
39173917- setTimeout(() => resolve([]), 500);
39183918- });
39193919-39203920- const batchCmds = commands.filter((c: any) => c.name.startsWith('test-batch-cmd-'));
39213921- if (batchCmds.length === 3) {
39223922- return {
39233923- totalCommands: commands.length,
39243924- batchCommandsFound: batchCmds.length,
39253925- batchCommandNames: batchCmds.map((c: any) => c.name)
39263926- };
39273927- }
39283928- }
39293929-39303930- return { totalCommands: 0, batchCommandsFound: 0, batchCommandNames: [] };
39313931- });
39323932-39333933- expect(result.batchCommandsFound).toBe(3);
39343934- expect(result.batchCommandNames).toContain('test-batch-cmd-1');
39353935- expect(result.batchCommandNames).toContain('test-batch-cmd-2');
39363936- expect(result.batchCommandNames).toContain('test-batch-cmd-3');
39373937- });
39383938-39393939-});
39403940-39413941-// ============================================================================
39423942-// Startup Phase Events Tests (uses shared app)
39433943-// ============================================================================
39443944-39453945-test.describe('Startup Phase Events @desktop', () => {
39463946- let app: DesktopApp;
39473947- let bgWindow: Page;
39483948-39493949- test.beforeAll(async () => {
39503950- ({ app, bgWindow } = await createPerDescribeApp('startup-phase'));
39513951- // Wait for extensions to be fully ready
39523952- await waitForExtensionsReady(bgWindow);
39533953- });
39543954-39553955- test.afterAll(async () => {
39563956- if (app) await app.close();
39573957- });
39583958-39593959- test('ext:startup:phase events are available for subscription', async () => {
39603960- // Test that extensions can subscribe to startup phase events
39613961- // Since app is already started, we test that the subscription mechanism works
39623962- const result = await bgWindow.evaluate(async () => {
39633963- const api = (window as any).app;
39643964- let received = false;
39653965-39663966- // Subscribe to startup phase events
39673967- api.subscribe('ext:startup:phase', (msg: any) => {
39683968- received = true;
39693969- }, api.scopes.GLOBAL);
39703970-39713971- // The subscription should be set up without error
39723972- return { subscriptionCreated: true };
39733973- });
39743974-39753975- expect(result.subscriptionCreated).toBe(true);
39763976- });
39773977-39783978- test('ext:all-loaded event was published during startup', async () => {
39793979- // Verify that the ext:all-loaded event was published by checking extensions are running
39803980- const result = await bgWindow.evaluate(async () => {
39813981- const api = (window as any).app;
39823982-39833983- // Get running extensions - if they're running, ext:all-loaded was published
39843984- const extResult = await api.extensions.list();
39853985- const extensions = extResult.data || [];
39863986- return {
39873987- success: extResult.success,
39883988- extensionCount: extensions.length,
39893989- hasCmd: extensions.some((e: any) => e.id === 'cmd'),
39903990- hasGroups: extensions.some((e: any) => e.id === 'groups')
39913991- };
39923992- });
39933993-39943994- expect(result.success).toBe(true);
39953995- expect(result.extensionCount).toBeGreaterThan(0);
39963996- expect(result.hasCmd).toBe(true);
39973997- });
39983998-39993999- test('cmd extension loads before other extensions can register commands', async () => {
40004000- // Verify that cmd is running and accepting commands (which means it loaded first)
40014001- // Use inline retry approach that works reliably
40024002- const result = await bgWindow.evaluate(async () => {
40034003- const api = (window as any).app;
40044004-40054005- const queryCommands = () => new Promise((resolve) => {
40064006- api.subscribe('cmd:query-commands-response', (msg: any) => {
40074007- resolve(msg.commands || []);
40084008- }, api.scopes.GLOBAL);
40094009- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
40104010- setTimeout(() => resolve([]), 1000);
40114011- });
40124012-40134013- // Retry a few times to allow extensions to finish loading
40144014- for (let i = 0; i < 5; i++) {
40154015- const cmds = await queryCommands() as any[];
40164016- if (cmds.some((c: any) => c.name === 'example:gallery')) {
40174017- return cmds;
40184018- }
40194019- await new Promise(r => setTimeout(r, 500));
40204020- }
40214021- return await queryCommands();
40224022- });
40234023-40244024- expect(Array.isArray(result)).toBe(true);
40254025- expect(result.length).toBeGreaterThan(0);
40264026- // gallery command from example extension should be registered
40274027- const hasGalleryCommand = result.some((c: any) => c.name === 'example:gallery');
40284028- expect(hasGalleryCommand).toBe(true);
40294029- });
40304030-40314031- test('cmd extension is always running (cannot be disabled)', async () => {
40324032- // cmd is required infrastructure - verify it's always in the running extensions list
40334033- const result = await bgWindow.evaluate(async () => {
40344034- const api = (window as any).app;
40354035- const runningExts = await api.extensions.list();
40364036- return {
40374037- success: runningExts.success,
40384038- extensions: runningExts.data || [],
40394039- cmdRunning: runningExts.data?.some((ext: any) => ext.id === 'cmd'),
40404040- cmdStatus: runningExts.data?.find((ext: any) => ext.id === 'cmd')?.status
40414041- };
40424042- });
40434043-40444044- expect(result.success).toBe(true);
40454045- expect(result.cmdRunning).toBe(true);
40464046- expect(result.cmdStatus).toBe('running');
40474047- });
40484048-});
40494049-40504050-// ============================================================================
40514051-// Hybrid Extension Mode Tests (uses shared app)
40524052-// ============================================================================
40534053-40544054-test.describe('Hybrid Extension Mode @desktop', () => {
40554055- let app: DesktopApp;
40564056- let bgWindow: Page;
40574057-40584058- test.beforeAll(async () => {
40594059- ({ app, bgWindow } = await createPerDescribeApp('hybrid-mode'));
40604060- });
40614061-40624062- test.afterAll(async () => {
40634063- if (app) await app.close();
40644064- });
40654065-40664066- test('v2 background tile windows exist as separate BrowserWindows', async () => {
40674067- // V2 background tiles (peeks, slides) launch as separate hidden BrowserWindows
40684068- // at peek://{id}/background.html — NOT as iframes in the extension host.
40694069- const peeksWin = await waitForWindow(
40704070- () => app.windows(),
40714071- 'peek://peeks/background.html',
40724072- 15000
40734073- );
40744074- expect(peeksWin).toBeDefined();
40754075-40764076- const slidesWin = await waitForWindow(
40774077- () => app.windows(),
40784078- 'peek://slides/background.html',
40794079- 15000
40804080- );
40814081- expect(slidesWin).toBeDefined();
40824082- });
40834083-40844084- test('api.extensions.reload() reloads external extension', async () => {
40854085- // Reload the example extension (external v2 tile — lazy). reload() re-reads
40864086- // the manifest, revokes any existing token, and relaunches the tile if it
40874087- // was loaded. For a lazy tile that hasn't been invoked yet, reload is a
40884088- // no-op on the tile side but still succeeds (manifest re-read).
40894089- const reloadResult = await bgWindow.evaluate(async () => {
40904090- return await (window as any).app.extensions.reload('example');
40914091- });
40924092-40934093- expect(reloadResult.success).toBe(true);
40944094- expect(reloadResult.data?.id).toBe('example');
40954095- });
40964096-40974097- test('api.extensions.reload() fails for consolidated extensions', async () => {
40984098- // Consolidated extensions (like cmd, groups) cannot be reloaded
40994099- const reloadResult = await bgWindow.evaluate(async () => {
41004100- return await (window as any).app.extensions.reload('cmd');
41014101- });
41024102-41034103- expect(reloadResult.success).toBe(false);
41044104- expect(reloadResult.error).toContain('Failed to reload');
41054105- });
41064106-41074107- test('commands work from both consolidated and external extensions', async () => {
41084108- // Wait a bit for extensions to initialize and register commands
41094109- await sleep(1000);
41104110-41114111- // Query commands - should include commands from all extensions
41124112- const result = await bgWindow.evaluate(async () => {
41134113- const api = (window as any).app;
41144114-41154115- return new Promise((resolve) => {
41164116- const timeout = setTimeout(() => {
41174117- resolve({ success: false, commandCount: 0 });
41184118- }, 10000);
41194119-41204120- api.subscribe('cmd:query-commands-response', (msg: any) => {
41214121- clearTimeout(timeout);
41224122- resolve({
41234123- success: true,
41244124- commandCount: msg.commands?.length || 0,
41254125- // example:gallery comes from external 'example' extension
41264126- hasGalleryCommand: msg.commands?.some((c: any) => c.name === 'example:gallery'),
41274127- // settings comes from core (via consolidated cmd)
41284128- hasSettingsCommand: msg.commands?.some((c: any) => c.name === 'settings')
41294129- });
41304130- }, api.scopes.GLOBAL);
41314131-41324132- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
41334133- });
41344134- });
41354135-41364136- expect(result.success).toBe(true);
41374137- expect(result.commandCount).toBeGreaterThan(0);
41384138- // example:gallery proves external extension commands work
41394139- expect(result.hasGalleryCommand).toBe(true);
41404140- // settings proves consolidated extension commands work
41414141- expect(result.hasSettingsCommand).toBe(true);
41424142- });
41434143-41444144- test('pubsub works between consolidated and external extensions', async () => {
41454145- // Test pubsub routing between extensions in different modes
41464146- // cmd (consolidated) receives query, responds to core
41474147- const result = await bgWindow.evaluate(async () => {
41484148- const api = (window as any).app;
41494149-41504150- return new Promise((resolve) => {
41514151- const timeout = setTimeout(() => {
41524152- resolve({ received: false, commandCount: 0 });
41534153- }, 5000);
41544154-41554155- api.subscribe('cmd:query-commands-response', (msg: any) => {
41564156- clearTimeout(timeout);
41574157- resolve({
41584158- received: true,
41594159- commandCount: msg.commands?.length || 0
41604160- });
41614161- }, api.scopes.GLOBAL);
41624162-41634163- api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
41644164- });
41654165- });
41664166-41674167- expect(result.received).toBe(true);
41684168- expect(result.commandCount).toBeGreaterThan(0);
41694169- });
41704170-41714171- test('correct window count for hybrid mode', async () => {
41724172- // After v2-tile migration:
41734173- // - 1 core background window (peek://app/background.html)
41744174- // - Multiple v2 eager-background tile windows (peeks, slides, entities, …
41754175- // served from peek://{id}/background.html)
41764176- // - Lazy v2 tiles (including 'example') do NOT load at startup; they
41774177- // only launch at peek://{id}/background.html on first command invoke.
41784178- // - Plus any UI windows (settings, etc.)
41794179- const windows = app.windows();
41804180-41814181- const coreBgWindows = windows.filter(w => w.url().includes('peek://app/background.html'));
41824182- expect(coreBgWindows.length).toBe(1);
41834183-41844184- // Eager v2 tile background windows exist; at least a couple expected
41854185- // (peeks, slides were the canonical ones in v2 migration tests).
41864186- const v2TileBgWindows = windows.filter(w => /peek:\/\/[a-z-]+\/background\.html/.test(w.url()));
41874187- expect(v2TileBgWindows.length).toBeGreaterThan(0);
41884188-41894189- // Lazy 'example' tile shouldn't have a window unless it was already
41904190- // invoked in a previous test. Don't assert presence or absence — this
41914191- // test is about the core/v2 shape, not example specifically.
41924192- });
41934193-});
41944194-41954195-// ============================================================================
41964196-// Extension Settings in Hybrid Mode Tests
41974197-// ============================================================================
41984198-41994199-test.describe('Extension Settings in Hybrid Mode @desktop', () => {
42004200- // Tests 1 and 2 share an instance (defaults check first, then modify)
42014201- // Test 3 (restart test) uses its own instances
42024202- let settingsApp: DesktopApp;
42034203- let settingsBgWindow: Page;
42044204-42054205- test.beforeAll(async () => {
42064206- // Launch fresh app for settings tests (tests 1 and 2 share this)
42074207- settingsApp = await launchDesktopApp(`test-settings-hybrid-${Date.now()}`);
42084208- settingsBgWindow = await settingsApp.getBackgroundWindow();
42094209- await waitForExtensionsReady(settingsBgWindow, 15000);
42104210- });
42114211-42124212- test.afterAll(async () => {
42134213- if (settingsApp) await settingsApp.close();
42144214- });
42154215-42164216- // This test runs FIRST - checks defaults before any modifications
42174217- test('extension falls back to defaults when no custom settings exist', async () => {
42184218- // This test verifies that when no custom settings exist in the datastore,
42194219- // extensions correctly use their default settings
42204220-42214221- // Query cmd's current settings
42224222- const result = await settingsBgWindow.evaluate(async () => {
42234223- const api = (window as any).app;
42244224- const defaultShortcut = 'Option+Space'; // cmd's default
42254225-42264226- return new Promise((resolve) => {
42274227- const timeout = setTimeout(() => {
42284228- resolve({ success: false, error: 'timeout' });
42294229- }, 5000);
42304230-42314231- api.subscribe('cmd:settings-changed', (msg: any) => {
42324232- clearTimeout(timeout);
42334233- resolve({
42344234- success: true,
42354235- shortcutKey: msg?.prefs?.shortcutKey,
42364236- isDefault: msg?.prefs?.shortcutKey === defaultShortcut
42374237- });
42384238- }, api.scopes.GLOBAL);
42394239-42404240- // Poke cmd to report its settings - use the default to not change it
42414241- api.publish('cmd:settings-update', {
42424242- data: { prefs: { shortcutKey: defaultShortcut } }
42434243- }, api.scopes.GLOBAL);
42444244- });
42454245- });
42464246-42474247- expect(result.success).toBe(true);
42484248- expect(result.isDefault).toBe(true);
42494249- expect(result.shortcutKey).toBe('Option+Space');
42504250- });
42514251-42524252- // This test runs SECOND - can modify settings since defaults already checked
42534253- test('hybrid mode extensions can access settings via api.settings.get()', async () => {
42544254- // This test verifies that extensions running at peek://{extId}/... URLs
42554255- // (hybrid mode) can successfully use the settings API
42564256- //
42574257- // The preload must correctly detect these URLs as extensions and return
42584258- // the proper extension ID for settings lookups
42594259- //
42604260- // We test this by updating settings via pubsub and verifying:
42614261- // 1. cmd receives the update (which requires api.settings.get() to have worked during init)
42624262- // 2. cmd persists the settings (which requires api.settings.set() to work)
42634263-42644264- // Custom shortcut to test with
42654265- const customShortcut = 'Option+Shift+T';
42664266-42674267- // Update cmd settings via pubsub
42684268- const updateResult = await settingsBgWindow.evaluate(async (shortcut) => {
42694269- const api = (window as any).app;
42704270-42714271- return new Promise((resolve) => {
42724272- const timeout = setTimeout(() => {
42734273- resolve({ success: false, error: 'timeout waiting for settings change' });
42744274- }, 5000);
42754275-42764276- // Subscribe to settings changed notification from cmd
42774277- api.subscribe('cmd:settings-changed', (msg: any) => {
42784278- clearTimeout(timeout);
42794279- resolve({
42804280- success: true,
42814281- receivedShortcut: msg?.prefs?.shortcutKey,
42824282- matchesExpected: msg?.prefs?.shortcutKey === shortcut
42834283- });
42844284- }, api.scopes.GLOBAL);
42854285-42864286- // Update cmd settings via pubsub (this is how Settings UI does it)
42874287- api.publish('cmd:settings-update', {
42884288- data: { prefs: { shortcutKey: shortcut } }
42894289- }, api.scopes.GLOBAL);
42904290- });
42914291- }, customShortcut);
42924292-42934293- expect(updateResult.success).toBe(true);
42944294- expect(updateResult.matchesExpected).toBe(true);
42954295- expect(updateResult.receivedShortcut).toBe(customShortcut);
42964296-42974297- // Wait a moment for persistence to complete
42984298- await sleep(200);
42994299-43004300- // Now verify the settings were persisted to datastore
43014301- // Note: extension-settings-set stores with id format ${extId}_${key}
43024302- const persistResult = await settingsBgWindow.evaluate(async (expectedShortcut) => {
43034303- const api = (window as any).app;
43044304- const stored = await api.datastore.getRow('feature_settings', 'cmd_prefs');
43054305-43064306- if (!stored.success || !stored.data?.value) {
43074307- return { success: false, error: 'No stored settings found', stored };
43084308- }
43094309-43104310- const parsed = JSON.parse(stored.data.value);
43114311- return {
43124312- success: true,
43134313- persistedShortcut: parsed.shortcutKey,
43144314- wasPersisted: parsed.shortcutKey === expectedShortcut
43154315- };
43164316- }, customShortcut);
43174317-43184318- expect(persistResult.success).toBe(true);
43194319- expect(persistResult.wasPersisted).toBe(true);
43204320- expect(persistResult.persistedShortcut).toBe(customShortcut);
43214321- });
43224322-43234323- // This test needs restart - uses its own isolated instances
43244324- test('extension loads custom settings instead of defaults on startup', async () => {
43254325- // This test verifies that when custom settings exist in the datastore,
43264326- // extensions load those settings instead of their defaults
43274327- //
43284328- // We set up custom settings, close the app, relaunch with the same profile,
43294329- // and verify the extension loaded the custom settings on init
43304330-43314331- // Use a fixed profile name so we can relaunch with the same settings
43324332- const profileName = `test-custom-settings-${Date.now()}`;
43334333-43344334- // First, launch app to set up custom settings in the datastore
43354335- const setupApp = await launchDesktopApp(profileName);
43364336- const setupWindow = await setupApp.getBackgroundWindow();
43374337-43384338- const customShortcut = 'Option+Ctrl+P';
43394339-43404340- // Store custom settings (using format ${extId}_${key} to match extension-settings-set handler)
43414341- const saveResult = await setupWindow.evaluate(async (shortcut) => {
43424342- const api = (window as any).app;
43434343- return await api.datastore.setRow('feature_settings', 'cmd_prefs', {
43444344- featureId: 'cmd',
43454345- key: 'prefs',
43464346- value: JSON.stringify({ shortcutKey: shortcut }),
43474347- updatedAt: Date.now()
43484348- });
43494349- }, customShortcut);
43504350-43514351- expect(saveResult.success).toBe(true);
43524352-43534353- // Close and relaunch - extensions should load custom settings on init
43544354- await setupApp.close();
43554355-43564356- // Small delay to ensure clean shutdown
43574357- await sleep(500);
43584358-43594359- // Relaunch with SAME profile to pick up saved settings
43604360- const testApp = await launchDesktopApp(profileName);
43614361-43624362- try {
43634363- const testWindow = await testApp.getBackgroundWindow();
43644364-43654365- // Wait for extensions to be fully initialized using proper wait helper
43664366- await waitForExtensionsReady(testWindow, 15000);
43674367-43684368- // Verify cmd loaded the custom settings on startup
43694369- // We update settings with the same value and verify it was already set
43704370- const result = await testWindow.evaluate(async (expectedShortcut) => {
43714371- const api = (window as any).app;
43724372-43734373- return new Promise((resolve) => {
43744374- const timeout = setTimeout(() => {
43754375- resolve({ success: false, error: 'timeout' });
43764376- }, 10000);
43774377-43784378- api.subscribe('cmd:settings-changed', (msg: any) => {
43794379- clearTimeout(timeout);
43804380- resolve({
43814381- success: true,
43824382- shortcutKey: msg?.prefs?.shortcutKey,
43834383- matchesCustom: msg?.prefs?.shortcutKey === expectedShortcut,
43844384- // Check if it's NOT the default (Option+Space)
43854385- isNotDefault: msg?.prefs?.shortcutKey !== 'Option+Space'
43864386- });
43874387- }, api.scopes.GLOBAL);
43884388-43894389- // Poke cmd to report its current settings (update with same value)
43904390- api.publish('cmd:settings-update', {
43914391- data: { prefs: { shortcutKey: expectedShortcut } }
43924392- }, api.scopes.GLOBAL);
43934393- });
43944394- }, customShortcut);
43954395-43964396- expect(result.success).toBe(true);
43974397- expect(result.isNotDefault).toBe(true);
43984398- expect(result.matchesCustom).toBe(true);
43994399- expect(result.shortcutKey).toBe(customShortcut);
44004400-44014401- } finally {
44024402- await testApp.close();
44034403- }
44044404- });
44054405-});
44064406-44074407-// ============================================================================
44084408-// Window Targeting Tests
44094409-// ============================================================================
44104410-// Tests for the window focus tracking system that enables per-window commands
44114411-// like "theme dark here" to target the correct window.
44124412-//
44134413-// Key behavior: Modal windows (like cmd palette) should NOT update the
44144414-// "last focused visible window" tracker, so commands target the window
44154415-// the user was looking at before opening the palette.
44164416-44174417-test.describe('Window Targeting @desktop', () => {
44184418- let app: DesktopApp;
44194419- let bgWindow: Page;
44204420-44214421- test.beforeAll(async () => {
44224422- ({ app, bgWindow } = await createPerDescribeApp('window-targeting'));
44234423- await sleep(500); // Wait for app to stabilize
44244424- });
44254425-44264426- test.afterAll(async () => {
44274427- if (app) await app.close();
44284428- });
44294429-44304430- test('setWindowColorScheme returns success with windowId', async () => {
44314431- // Test that setWindowColorScheme works and returns expected data
44324432- const result = await bgWindow.evaluate(async () => {
44334433- const api = (window as any).app;
44344434-44354435- // Open a test window (non-modal) to have a valid target
44364436- const winResult = await api.window.open('peek://app/settings/settings.html', {
44374437- width: 400,
44384438- height: 300,
44394439- modal: false,
44404440- key: 'test-theme-window-1'
44414441- });
44424442-44434443- if (!winResult.success) {
44444444- return { success: false, error: 'Failed to open window' };
44454445- }
44464446-44474447- // Wait for window to be ready and focused
44484448- await new Promise(r => setTimeout(r, 300));
44494449-44504450- // Call setWindowColorScheme
44514451- const themeResult = await api.theme.setWindowColorScheme('dark');
44524452-44534453- // Clean up
44544454- try {
44554455- await api.window.close(winResult.id);
44564456- } catch (e) {
44574457- // Ignore close errors
44584458- }
44594459-44604460- return {
44614461- success: themeResult.success,
44624462- windowId: themeResult.windowId,
44634463- colorScheme: themeResult.colorScheme,
44644464- error: themeResult.error
44654465- };
44664466- });
44674467-44684468- expect(result.success).toBe(true);
44694469- expect(result.colorScheme).toBe('dark');
44704470- expect(typeof result.windowId).toBe('number');
44714471- });
44724472-44734473- test('modal window does not become theme target', async () => {
44744474- // This test verifies that opening a modal window after a non-modal window
44754475- // still allows setWindowColorScheme to target the non-modal window
44764476- const result = await bgWindow.evaluate(async () => {
44774477- const api = (window as any).app;
44784478-44794479- // Open a non-modal window first
44804480- const nonModalResult = await api.window.open('peek://app/settings/settings.html', {
44814481- width: 400,
44824482- height: 300,
44834483- modal: false,
44844484- key: 'test-nonmodal-target'
44854485- });
44864486-44874487- if (!nonModalResult.success) {
44884488- return { success: false, error: 'Failed to open non-modal window' };
44894489- }
44904490-44914491- // Wait for it to focus
44924492- await new Promise(r => setTimeout(r, 300));
44934493-44944494- // Now open a modal window (simulating cmd palette behavior)
44954495- const modalResult = await api.window.open('peek://app/settings/settings.html', {
44964496- width: 300,
44974497- height: 200,
44984498- modal: true,
44994499- key: 'test-modal-overlay'
45004500- });
45014501-45024502- if (!modalResult.success) {
45034503- // Clean up non-modal
45044504- try { await api.window.close(nonModalResult.id); } catch (e) {}
45054505- return { success: false, error: 'Failed to open modal window' };
45064506- }
45074507-45084508- // Wait a bit for modal to be ready
45094509- await new Promise(r => setTimeout(r, 200));
45104510-45114511- // Now call setWindowColorScheme - should still target the NON-MODAL window
45124512- const themeResult = await api.theme.setWindowColorScheme('light');
45134513-45144514- // Clean up both windows
45154515- try { await api.window.close(modalResult.id); } catch (e) {}
45164516- try { await api.window.close(nonModalResult.id); } catch (e) {}
45174517-45184518- return {
45194519- success: themeResult.success,
45204520- targetedWindowId: themeResult.windowId,
45214521- nonModalWindowId: nonModalResult.id,
45224522- modalWindowId: modalResult.id,
45234523- // Key assertion: the targeted window should be the non-modal one
45244524- targetedNonModal: themeResult.windowId === nonModalResult.id
45254525- };
45264526- });
45274527-45284528- expect(result.success).toBe(true);
45294529- // The theme command should have targeted the non-modal window, not the modal
45304530- expect(result.targetedNonModal).toBe(true);
45314531- });
45324532-45334533- test('setWindowColorScheme with global resets override', async () => {
45344534- // Test the 'global' value which should reset window-specific override
45354535- const result = await bgWindow.evaluate(async () => {
45364536- const api = (window as any).app;
45374537-45384538- // Open a test window
45394539- const winResult = await api.window.open('peek://app/settings/settings.html', {
45404540- width: 400,
45414541- height: 300,
45424542- modal: false,
45434543- key: 'test-theme-reset-window'
45444544- });
45454545-45464546- if (!winResult.success) {
45474547- return { success: false, error: 'Failed to open window' };
45484548- }
45494549-45504550- await new Promise(r => setTimeout(r, 300));
45514551-45524552- // Set to dark first
45534553- const darkResult = await api.theme.setWindowColorScheme('dark');
45544554-45554555- // Then reset to global
45564556- const globalResult = await api.theme.setWindowColorScheme('global');
45574557-45584558- // Clean up
45594559- try { await api.window.close(winResult.id); } catch (e) {}
45604560-45614561- return {
45624562- darkSuccess: darkResult.success,
45634563- globalSuccess: globalResult.success,
45644564- globalColorScheme: globalResult.colorScheme
45654565- };
45664566- });
45674567-45684568- expect(result.darkSuccess).toBe(true);
45694569- expect(result.globalSuccess).toBe(true);
45704570- expect(result.globalColorScheme).toBe('global');
45714571- });
45724572-});
45734573-45744574-// ============================================================================
45754575-// Backup Tests (uses shared app)
45764576-// ============================================================================
45774577-45784578-test.describe('Backup @desktop', () => {
45794579- let app: DesktopApp;
45804580- let bgWindow: Page;
45814581-45824582- test.beforeAll(async () => {
45834583- ({ app, bgWindow } = await createPerDescribeApp('backup'));
45844584- });
45854585-45864586- test.afterAll(async () => {
45874587- if (app) await app.close();
45884588- });
45894589-45904590- test('backup-get-config returns config object', async () => {
45914591- const result = await bgWindow.evaluate(async () => {
45924592- return await (window as any).app.backup.getConfig();
45934593- });
45944594-45954595- expect(result.success).toBe(true);
45964596- expect(result.data).toBeDefined();
45974597- expect(typeof result.data.enabled).toBe('boolean');
45984598- expect(typeof result.data.backupDir).toBe('string');
45994599- expect(typeof result.data.retentionCount).toBe('number');
46004600- expect(typeof result.data.lastBackupTime).toBe('number');
46014601- });
46024602-46034603- test('backup is disabled when backupDir is not configured', async () => {
46044604- const result = await bgWindow.evaluate(async () => {
46054605- return await (window as any).app.backup.getConfig();
46064606- });
46074607-46084608- expect(result.success).toBe(true);
46094609- // By default, backupDir should be empty and backups disabled
46104610- expect(result.data.backupDir).toBe('');
46114611- expect(result.data.enabled).toBe(false);
46124612- });
46134613-46144614- test('backup-create returns error when not configured', async () => {
46154615- const result = await bgWindow.evaluate(async () => {
46164616- return await (window as any).app.backup.create();
46174617- });
46184618-46194619- expect(result.success).toBe(false);
46204620- expect(result.error).toContain('not configured');
46214621- });
46224622-46234623- test('backup-list returns empty when not configured', async () => {
46244624- const result = await bgWindow.evaluate(async () => {
46254625- return await (window as any).app.backup.list();
46264626- });
46274627-46284628- expect(result.success).toBe(true);
46294629- expect(result.data.backups).toEqual([]);
46304630- expect(result.data.backupDir).toBe('');
46314631- });
46324632-46334633- test('backup works when backupDir is configured', async () => {
46344634- // Create temp directory for test backups
46354635- const os = await import('os');
46364636- const pathModule = await import('path');
46374637- const fs = await import('fs');
46384638-46394639- const tempBackupDir = pathModule.default.join(os.default.tmpdir(), `peek-backup-test-${Date.now()}`);
46404640- fs.default.mkdirSync(tempBackupDir, { recursive: true });
46414641-46424642- try {
46434643- // Store the current prefs and configure backup
46444644- const setupResult = await bgWindow.evaluate(async (backupDir: string) => {
46454645- const api = (window as any).app;
46464646-46474647- // Get current prefs
46484648- const prefsResult = await api.datastore.getTable('feature_settings');
46494649- const corePrefsRow = Object.values(prefsResult.data || {}).find(
46504650- (r: any) => r.featureId === 'core' && r.key === 'prefs'
46514651- ) as any;
46524652- const originalPrefs = corePrefsRow ? JSON.parse(corePrefsRow.value) : {};
46534653-46544654- // Set backupDir in core prefs
46554655- const newPrefs = { ...originalPrefs, backupDir };
46564656- await api.datastore.setRow('feature_settings', 'core:prefs', {
46574657- featureId: 'core',
46584658- key: 'prefs',
46594659- value: JSON.stringify(newPrefs),
46604660- updatedAt: Date.now()
46614661- });
46624662-46634663- return { originalPrefs };
46644664- }, tempBackupDir);
46654665-46664666- // Verify config reflects the change
46674667- const configResult = await bgWindow.evaluate(async () => {
46684668- return await (window as any).app.backup.getConfig();
46694669- });
46704670- expect(configResult.success).toBe(true);
46714671- expect(configResult.data.backupDir).toBe(tempBackupDir);
46724672- expect(configResult.data.enabled).toBe(true);
46734673-46744674- // Create a backup
46754675- const backupResult = await bgWindow.evaluate(async () => {
46764676- return await (window as any).app.backup.create();
46774677- });
46784678- expect(backupResult.success).toBe(true);
46794679- expect(backupResult.path).toBeTruthy();
46804680- expect(backupResult.path.endsWith('.zip')).toBe(true);
46814681-46824682- // Verify the file exists
46834683- expect(fs.default.existsSync(backupResult.path)).toBe(true);
46844684-46854685- // List backups - should have one
46864686- const listResult = await bgWindow.evaluate(async () => {
46874687- return await (window as any).app.backup.list();
46884688- });
46894689- expect(listResult.success).toBe(true);
46904690- expect(listResult.data.backups.length).toBe(1);
46914691-46924692- // Restore original prefs
46934693- await bgWindow.evaluate(async (originalPrefs: Record<string, unknown>) => {
46944694- const api = (window as any).app;
46954695- await api.datastore.setRow('feature_settings', 'core:prefs', {
46964696- featureId: 'core',
46974697- key: 'prefs',
46984698- value: JSON.stringify(originalPrefs),
46994699- updatedAt: Date.now()
47004700- });
47014701- }, setupResult.originalPrefs);
47024702- } finally {
47034703- // Clean up temp directory
47044704- try {
47054705- fs.default.rmSync(tempBackupDir, { recursive: true, force: true });
47064706- } catch (e) {
47074707- // Ignore cleanup errors
47084708- }
47094709- }
47104710- });
47114711-});
47124712-47134713-// ============================================================================
47144714-// IZUI Behavior Tests (uses shared app)
47154715-// ============================================================================
47164716-47174717-test.describe('IZUI Behavior @desktop', () => {
47184718- let app: DesktopApp;
47194719- let bgWindow: Page;
47204720-47214721- test.beforeAll(async () => {
47224722- ({ app, bgWindow } = await createPerDescribeApp('izui-behavior'));
47234723- });
47244724-47254725- test.afterAll(async () => {
47264726- if (app) await app.close();
47274727- });
47284728-47294729- test('parentWindowId is set when opened from a content window', async () => {
47304730-47314731- // Step 1: Open a content window (groups home) from the background window.
47324732- // Since background.html is an internal URL, parentWindowId should be null.
47334733- const groupsResult = await bgWindow.evaluate(async () => {
47344734- return await (window as any).app.window.open('peek://groups/home.html', {
47354735- width: 600,
47364736- height: 400
47374737- });
47384738- });
47394739- expect(groupsResult.success).toBe(true);
47404740- const groupsWindowId = groupsResult.id;
47414741-47424742- const groupsWindow = await app.getWindow('groups/home.html', 5000);
47434743- expect(groupsWindow).toBeTruthy();
47444744- await groupsWindow.waitForLoadState('domcontentloaded');
47454745-47464746- // Verify the groups window itself has parentWindowId=null (opened from background)
47474747- const groupsListBefore = await bgWindow.evaluate(async (wid: number) => {
47484748- const result = await (window as any).app.window.list({ includeInternal: true });
47494749- if (!result.success) return null;
47504750- return result.windows.find((w: any) => w.id === wid);
47514751- }, groupsWindowId);
47524752- expect(groupsListBefore).toBeTruthy();
47534753- expect(groupsListBefore.params.parentWindowId).toBeNull();
47544754-47554755- // Step 2: Open a child window FROM the groups content window.
47564756- // Since groups/home.html is a real content window (not background/extension-host),
47574757- // the child should get parentWindowId set to the groups window's ID.
47584758- const childResult = await groupsWindow.evaluate(async () => {
47594759- return await (window as any).app.window.open('https://child-parent-test.example.com', {
47604760- width: 400,
47614761- height: 300
47624762- });
47634763- });
47644764- expect(childResult.success).toBe(true);
47654765- const childWindowId = childResult.id;
47664766-47674767- // Verify child window has parentWindowId set to the groups window
47684768- const childInfo = await bgWindow.evaluate(async (wid: number) => {
47694769- const result = await (window as any).app.window.list({ includeInternal: true });
47704770- if (!result.success) return null;
47714771- return result.windows.find((w: any) => w.id === wid);
47724772- }, childWindowId);
47734773- expect(childInfo).toBeTruthy();
47744774- expect(childInfo.params.parentWindowId).toBe(groupsWindowId);
47754775-47764776- // Clean up
47774777- for (const id of [childWindowId, groupsWindowId]) {
47784778- if (id) {
47794779- try {
47804780- await bgWindow.evaluate(async (wid: number) => {
47814781- return await (window as any).app.window.close(wid);
47824782- }, id);
47834783- } catch { /* window may already be closed */ }
47844784- }
47854785- }
47864786- });
47874787-47884788- test('onEscape registers callback without changing backend escapeMode (role-based)', async () => {
47894789-47904790- // Open a plain window with no escapeMode set (defaults to 'auto')
47914791- const result = await bgWindow.evaluate(async () => {
47924792- return await (window as any).app.window.open('about:blank', {
47934793- width: 400,
47944794- height: 300
47954795- });
47964796- });
47974797- expect(result.success).toBe(true);
47984798- const windowId = result.id;
47994799-48004800- const contentWindow = await app.getWindow('about:blank', 5000);
48014801- expect(contentWindow).toBeTruthy();
48024802-48034803- // Get escapeMode before registering handler
48044804- const infoBefore = await bgWindow.evaluate(async (wid: number) => {
48054805- const listResult = await (window as any).app.window.list({ includeInternal: true });
48064806- if (!listResult.success) return null;
48074807- return listResult.windows.find((w: any) => w.id === wid);
48084808- }, windowId);
48094809- expect(infoBefore).toBeTruthy();
48104810- const escapeModeBefore = infoBefore.params.escapeMode;
48114811-48124812- // Call api.escape.onEscape() — this should NOT change escapeMode on the backend
48134813- // (self-declaration removed; role determines behavior now)
48144814- await contentWindow.evaluate(() => {
48154815- (window as any).app.escape.onEscape(() => ({ handled: false }));
48164816- });
48174817-48184818- // Small delay to ensure any async IPC would have completed
48194819- await new Promise(r => setTimeout(r, 300));
48204820-48214821- // Verify escapeMode was NOT changed by onEscape registration
48224822- const infoAfter = await bgWindow.evaluate(async (wid: number) => {
48234823- const listResult = await (window as any).app.window.list({ includeInternal: true });
48244824- if (!listResult.success) return null;
48254825- return listResult.windows.find((w: any) => w.id === wid);
48264826- }, windowId);
48274827- expect(infoAfter).toBeTruthy();
48284828- expect(infoAfter.params.escapeMode).toBe(escapeModeBefore);
48294829-48304830- // Verify the callback IS registered and responds via escape trigger
48314831- const triggerResult = await contentWindow.evaluate(async () => {
48324832- return await (window as any).app.escape.trigger();
48334833- });
48344834- expect(triggerResult).toEqual({ handled: false });
48354835-48364836- // Clean up
48374837- if (windowId) {
48384838- try {
48394839- await bgWindow.evaluate(async (id: number) => {
48404840- return await (window as any).app.window.close(id);
48414841- }, windowId);
48424842- } catch { /* window may already be closed */ }
48434843- }
48444844- });
48454845-48464846- test('izui-close-self closes the window', async () => {
48474847-48484848- // Open a content window
48494849- const result = await bgWindow.evaluate(async () => {
48504850- return await (window as any).app.window.open('peek://groups/home.html', {
48514851- width: 400,
48524852- height: 300,
48534853- escapeMode: 'navigate'
48544854- });
48554855- });
48564856- expect(result.success).toBe(true);
48574857- const windowId = result.id;
48584858-48594859- const contentWindow = await app.getWindow('groups/home.html', 5000);
48604860- expect(contentWindow).toBeTruthy();
48614861- await contentWindow.waitForLoadState('domcontentloaded');
48624862-48634863- // Close the window via the IPC path (tile:window:close). Fire-and-forget
48644864- // — the IPC send doesn't block on the actual close.
48654865- await bgWindow.evaluate(async (wid: number) => {
48664866- await (window as any).app.window.close(wid);
48674867- }, windowId);
48684868-48694869- // Poll the window list until the closed window drops out. 5s is plenty
48704870- // for a close — if it hasn't dropped by then, the close path is broken.
48714871- await bgWindow.waitForFunction(
48724872- async (wid: number) => {
48734873- const listResult = await (window as any).app.window.list({ includeInternal: true });
48744874- if (!listResult.success) return false;
48754875- return !listResult.windows.some((w: any) => w.id === wid);
48764876- },
48774877- windowId,
48784878- { timeout: 5000 }
48794879- );
48804880- });
48814881-48824882- test('item:created fires from trackWindowLoad when opening external URL', async () => {
48834883- const timestamp = Date.now();
48844884- const testUrl = `https://track-window-load-${timestamp}.example.com`;
48854885-48864886- // Subscribe to item:created and then open a URL window
48874887- const result = await bgWindow.evaluate(async (url: string) => {
48884888- const api = (window as any).app;
48894889-48904890- return new Promise((resolve) => {
48914891- const timeout = setTimeout(() => {
48924892- resolve({ received: false, error: 'timeout' });
48934893- }, 10000);
48944894-48954895- api.subscribe('item:created', (msg: any) => {
48964896- if (msg.content === url) {
48974897- clearTimeout(timeout);
48984898- resolve({
48994899- received: true,
49004900- itemId: msg.itemId,
49014901- itemType: msg.itemType,
49024902- content: msg.content
49034903- });
49044904- }
49054905- }, api.scopes.GLOBAL);
49064906-49074907- // Open a window with an external URL - this triggers trackWindowLoad
49084908- // which emits item:created if the URL is new
49094909- api.window.open(url, {
49104910- width: 400,
49114911- height: 300
49124912- });
49134913- });
49144914- }, testUrl);
49154915-49164916- expect((result as any).received).toBe(true);
49174917- expect((result as any).itemId).toBeTruthy();
49184918- expect((result as any).itemType).toBe('url');
49194919- expect((result as any).content).toBe(testUrl);
49204920-49214921- // Clean up - close the opened window
49224922- const windowList = await bgWindow.evaluate(async () => {
49234923- return await (window as any).app.window.list();
49244924- });
49254925- if (windowList.success) {
49264926- for (const w of windowList.windows) {
49274927- if (w.url?.includes('track-window-load')) {
49284928- try {
49294929- await bgWindow.evaluate(async (id: number) => {
49304930- return await (window as any).app.window.close(id);
49314931- }, w.id);
49324932- } catch { /* ignore */ }
49334933- }
49344934- }
49354935- }
49364936- });
49374937-49384938- test('ESC debouncing: two rapid presses trigger only one handler call', async () => {
49394939-49404940- // Open a groups window with navigate escape mode
49414941- const result = await bgWindow.evaluate(async () => {
49424942- return await (window as any).app.window.open('peek://groups/home.html', {
49434943- width: 400,
49444944- height: 300,
49454945- escapeMode: 'navigate'
49464946- });
49474947- });
49484948- expect(result.success).toBe(true);
49494949- const windowId = result.id;
49504950-49514951- const groupsWindow = await app.getWindow('groups/home.html', 5000);
49524952- expect(groupsWindow).toBeTruthy();
49534953- await groupsWindow.waitForLoadState('domcontentloaded');
49544954- await groupsWindow.waitForSelector('.cards', { timeout: 5000 });
49554955-49564956- // Navigate into a group to have a deep state (so ESC navigates back)
49574957- // First create a group with items
49584958- const tagResult = await bgWindow.evaluate(async () => {
49594959- return await (window as any).app.datastore.getOrCreateTag('esc-debounce-test');
49604960- });
49614961- expect(tagResult.success).toBe(true);
49624962- const tagId = tagResult.data?.tag?.id;
49634963-49644964- const item = await bgWindow.evaluate(async () => {
49654965- return await (window as any).app.datastore.addItem('url', {
49664966- content: 'https://esc-debounce-test.example.com',
49674967- metadata: JSON.stringify({ title: 'ESC Debounce Test' })
49684968- });
49694969- });
49704970- expect(item.success).toBe(true);
49714971-49724972- if (tagId && item.data?.id) {
49734973- await bgWindow.evaluate(async ({ itemId, tagId }) => {
49744974- return await (window as any).app.datastore.tagItem(itemId, tagId);
49754975- }, { itemId: item.data.id, tagId });
49764976- }
49774977-49784978- // Refresh the groups view to pick up the new data
49794979- // Navigate into the group to have a deep state.
49804980- // Use a locator (auto-retrying) instead of elementHandle because the groups
49814981- // view re-renders after tag:item-added pubsub events, detaching any handle.
49824982- const groupLocator = groupsWindow.locator('peek-card.group-card').first();
49834983- try {
49844984- await groupLocator.waitFor({ state: 'visible', timeout: 5000 });
49854985- await groupLocator.click({ timeout: 5000 });
49864986- await groupsWindow.waitForSelector('peek-card.address-card', { timeout: 5000 });
49874987- } catch {
49884988- // If no group cards render (e.g., visibility filtering), skip the deep
49894989- // navigation — the ESC debounce behavior is independent of depth.
49904990- }
49914991-49924992- // Track how many times the escape handler is invoked by wrapping it
49934993- // Use evaluate to set up a counter in the renderer
49944994- await groupsWindow.evaluate(() => {
49954995- (window as any).__escCallCount = 0;
49964996- const origHandler = (window as any)._escapeCallback;
49974997- if (origHandler) {
49984998- (window as any)._origEscapeCallback = origHandler;
49994999- // The preload stores the callback as _escapeCallback, but it's in a closure.
50005000- // Instead, we'll use api.escape.onEscape to wrap the handler.
50015001- (window as any).app.escape.onEscape(async () => {
50025002- (window as any).__escCallCount++;
50035003- return origHandler();
50045004- });
50055005- }
50065006- });
50075007-50085008- // Send two ESC key presses in rapid succession (< 200ms apart)
50095009- // The debouncing in windows.ts should filter the second one
50105010- await groupsWindow.keyboard.press('Escape');
50115011- // Immediately press again - well within the 200ms debounce window
50125012- await groupsWindow.keyboard.press('Escape');
50135013-50145014- // Wait a moment for any handlers to complete
50155015- await sleep(500);
50165016-50175017- // Check call count - due to debouncing, only 0 or 1 calls should have gone through
50185018- // Note: Playwright keyboard.press sends both keyDown and keyUp, and the ESC handler
50195019- // fires on keyDown via before-input-event. The debounce ensures rapid presses are collapsed.
50205020- const callCount = await groupsWindow.evaluate(() => (window as any).__escCallCount);
50215021-50225022- // The debounce should ensure at most 1 handler call for 2 rapid presses
50235023- expect(callCount).toBeLessThanOrEqual(1);
50245024-50255025- // Clean up
50265026- if (windowId) {
50275027- try {
50285028- await bgWindow.evaluate(async (id: number) => {
50295029- return await (window as any).app.window.close(id);
50305030- }, windowId);
50315031- } catch { /* window may already be closed */ }
50325032- }
50335033- });
50345034-});
50355035-50365036-// ============================================================================
50375037-// Shortcut Roundtrip Tests
50385038-//
50395039-// Tests the full IPC roundtrip for shortcut registration and callback firing.
50405040-// The flow: renderer registers shortcut via IPC -> main stores callback with ev.reply ->
50415041-// shortcut fires -> callback calls ev.reply(replyTopic) -> renderer ipcRenderer.on fires cb.
50425042-//
50435043-// Since Playwright keyboard.press does NOT reliably trigger Electron's before-input-event,
50445044-// we trigger the shortcut callback by calling handleLocalShortcut from the main process
50455045-// via evaluateMain with a synthetic input event.
50465046-// ============================================================================
50475047-50485048-test.describe('Shortcut Roundtrip @desktop', () => {
50495049- let app: DesktopApp;
50505050- let bgWindow: Page;
50515051-50525052- test.beforeAll(async () => {
50535053- ({ app, bgWindow } = await createPerDescribeApp('shortcut-roundtrip'));
50545054- });
50555055-50565056- test.afterAll(async () => {
50575057- if (app) await app.close();
50585058- });
50595059-50605060- test('local shortcut from background window roundtrip', async () => {
50615061- // Register a local shortcut from bgWindow, trigger it via handleLocalShortcut
50625062- // in the main process, verify callback fires in the renderer.
50635063- // This tests the basic ev.reply roundtrip for a normal BrowserWindow WebContents.
50645064-50655065- // Register a local shortcut from the bgWindow
50665066- await bgWindow.evaluate(() => {
50675067- (window as any).__shortcutFired = false;
50685068- (window as any).app.shortcuts.register('Alt+F7', () => {
50695069- (window as any).__shortcutFired = true;
50705070- });
50715071- });
50725072-50735073- // Wait for IPC registration to propagate to main process
50745074- await sleep(300);
50755075-50765076- // Trigger the shortcut from the main process by calling handleLocalShortcut
50775077- // with a synthetic input event matching Alt+F7.
50785078- // NOTE: handleLocalShortcut invokes the stored callback synchronously, which
50795079- // in turn calls ev.reply() to send an IPC message back to the renderer. The
50805080- // ev.reply is an async side-effect that can cause Playwright to see the main
50815081- // process "evaluate" context as destroyed if we return the raw result. To
50825082- // avoid this flakiness, wrap the call in setImmediate + return via a
50835083- // pre-computed flag so the evaluate settles cleanly before IPC fans out.
50845084- const handled = await app.evaluateMain!(({ app }) => {
50855085- try {
50865086- const { handleLocalShortcut } = (globalThis as any).__peek_test;
50875087- const result = handleLocalShortcut({
50885088- type: 'keyDown',
50895089- alt: true,
50905090- shift: false,
50915091- meta: false,
50925092- control: false,
50935093- code: 'F7'
50945094- });
50955095- return !!result;
50965096- } catch (e: any) {
50975097- return 'peek_test-failed: ' + e.message;
50985098- }
50995099- }).catch((err: any) => {
51005100- // Playwright sometimes reports "Execution context was destroyed" when the
51015101- // shortcut callback fans out async IPC (ev.reply) as a side-effect of the
51025102- // evaluate. The shortcut still fires — the waitForFunction below will
51035103- // confirm it. Treat this as a soft success.
51045104- if (/context was destroyed/i.test(err?.message || '')) return true;
51055105- throw err;
51065106- });
51075107-51085108- // handleLocalShortcut should return true (shortcut was found and callback invoked)
51095109- expect(handled).toBe(true);
51105110-51115111- // Wait for the reply to reach the renderer and trigger the callback
51125112- await bgWindow.waitForFunction(
51135113- () => (window as any).__shortcutFired === true,
51145114- { timeout: 5000 }
51155115- );
51165116-51175117- const fired = await bgWindow.evaluate(() => (window as any).__shortcutFired);
51185118- expect(fired).toBe(true);
51195119-51205120- // Clean up
51215121- await bgWindow.evaluate(() => {
51225122- (window as any).app.shortcuts.unregister('Alt+F7');
51235123- delete (window as any).__shortcutFired;
51245124- });
51255125- });
51265126-51275127-});
51285128-51295129-// ============================================================================
51305130-// Scripts Extension Tests (uses shared app)
51315131-// ============================================================================
51325132-51335133-test.describe('Scripts Extension @desktop', () => {
51345134- let app: DesktopApp;
51355135- let bgWindow: Page;
51365136-51375137- test.beforeAll(async () => {
51385138- ({ app, bgWindow } = await createPerDescribeApp('scripts'));
51395139- });
51405140-51415141- test.afterAll(async () => {
51425142- if (app) await app.close();
51435143- });
51445144-51455145- test('create, save, and execute script', async () => {
51465146- // Wait for scripts extension to be ready
51475147- await waitForExtensionsReady(bgWindow, 15000);
51485148-51495149- // Create a new script directly via datastore
51505150- const scriptId = await bgWindow.evaluate(async () => {
51515151- const api = (window as any).app;
51525152- const scriptId = `script_test_${Date.now()}`;
51535153-51545154- // Get current settings from datastore
51555155- const settingsTable = await api.datastore.getTable('feature_settings');
51565156- const scriptsRow = settingsTable.data?.['scripts:scripts'];
51575157- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
51585158-51595159- // Add new script
51605160- const newScript = {
51615161- id: scriptId,
51625162- name: 'Test Script',
51635163- description: 'A test script',
51645164- code: 'const h1 = document.querySelector("h1"); return { title: h1?.textContent || "No h1 found" };',
51655165- matchPatterns: ['https://example.com/*'],
51665166- excludePatterns: [],
51675167- runAt: 'document-end',
51685168- enabled: true,
51695169- createdAt: Date.now(),
51705170- updatedAt: Date.now(),
51715171- lastExecutedAt: null
51725172- };
51735173-51745174- scripts.push(newScript);
51755175-51765176- // Save back to datastore
51775177- await api.datastore.setRow('feature_settings', 'scripts:scripts', {
51785178- featureId: 'scripts',
51795179- key: 'scripts',
51805180- value: JSON.stringify(scripts),
51815181- updatedAt: Date.now()
51825182- });
51835183-51845184- return scriptId;
51855185- });
51865186-51875187- expect(scriptId).toBeTruthy();
51885188-51895189- // Verify script was saved
51905190- const savedScript = await bgWindow.evaluate(async (scriptId) => {
51915191- const api = (window as any).app;
51925192- const settingsTable = await api.datastore.getTable('feature_settings');
51935193- const scriptsRow = settingsTable.data?.['scripts:scripts'];
51945194- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
51955195- return scripts.find((s: any) => s.id === scriptId);
51965196- }, scriptId);
51975197-51985198- expect(savedScript).toBeTruthy();
51995199- expect(savedScript.name).toBe('Test Script');
52005200-52015201- // Execute script - test executor directly
52025202- const executeResult = await bgWindow.evaluate(async (scriptId) => {
52035203- const api = (window as any).app;
52045204- const { scriptExecutor } = await import('peek://scripts/script-executor.js');
52055205-52065206- // Get the script from datastore
52075207- const settingsTable = await api.datastore.getTable('feature_settings');
52085208- const scriptsRow = settingsTable.data?.['scripts:scripts'];
52095209- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
52105210- const script = scripts.find((s: any) => s.id === scriptId);
52115211-52125212- if (!script) {
52135213- return { success: false, error: 'Script not found' };
52145214- }
52155215-52165216- // Execute directly
52175217- const result = await scriptExecutor.executeScript(script, {
52185218- url: 'https://example.com/test',
52195219- pageDOM: document,
52205220- pageWindow: window
52215221- });
52225222-52235223- return { success: true, data: result };
52245224- }, scriptId);
52255225-52265226- expect(executeResult).toHaveProperty('success', true);
52275227- expect((executeResult as any).data.status).toBe('success');
52285228-52295229- // Clean up - delete script
52305230- await bgWindow.evaluate(async (scriptId) => {
52315231- const api = (window as any).app;
52325232- const settingsTable = await api.datastore.getTable('feature_settings');
52335233- const scriptsRow = settingsTable.data?.['scripts:scripts'];
52345234- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
52355235- const filtered = scripts.filter((s: any) => s.id !== scriptId);
52365236- await api.datastore.setRow('feature_settings', 'scripts:scripts', {
52375237- featureId: 'scripts',
52385238- key: 'scripts',
52395239- value: JSON.stringify(filtered),
52405240- updatedAt: Date.now()
52415241- });
52425242- }, scriptId);
52435243- });
52445244-52455245- test('script pattern matching works', async () => {
52465246- // Test pattern matching directly
52475247- const patternTests = await bgWindow.evaluate(async () => {
52485248- // Import the script executor module
52495249- const { ScriptExecutor } = await import('peek://scripts/script-executor.js');
52505250- const executor = new ScriptExecutor();
52515251-52525252- return {
52535253- exactMatch: executor.matchPattern('https://example.com/*', 'https://example.com/page'),
52545254- noMatch: executor.matchPattern('https://example.com/*', 'https://other.com/page'),
52555255- wildcardProtocol: executor.matchPattern('*://example.com/*', 'https://example.com/page'),
52565256- wildcardAll: executor.matchPattern('*', 'https://anything.com/page')
52575257- };
52585258- });
52595259-52605260- expect(patternTests.exactMatch).toBe(true);
52615261- expect(patternTests.noMatch).toBe(false);
52625262- expect(patternTests.wildcardProtocol).toBe(true);
52635263- expect(patternTests.wildcardAll).toBe(true);
52645264- });
52655265-52665266- test('script timeout protection works', async () => {
52675267- // Create a script that runs forever
52685268- const scriptId = await bgWindow.evaluate(async () => {
52695269- const api = (window as any).app;
52705270- const scriptId = `script_timeout_test_${Date.now()}`;
52715271-52725272- // Get current settings from datastore
52735273- const settingsTable = await api.datastore.getTable('feature_settings');
52745274- const scriptsRow = settingsTable.data?.['scripts:scripts'];
52755275- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
52765276-52775277- // Add timeout test script
52785278- const newScript = {
52795279- id: scriptId,
52805280- name: 'Timeout Test',
52815281- code: 'while(true) {}', // Infinite loop
52825282- matchPatterns: ['*'],
52835283- excludePatterns: [],
52845284- runAt: 'document-end',
52855285- enabled: true,
52865286- createdAt: Date.now(),
52875287- updatedAt: Date.now(),
52885288- lastExecutedAt: null
52895289- };
52905290-52915291- scripts.push(newScript);
52925292- await api.datastore.setRow('feature_settings', 'scripts:scripts', {
52935293- featureId: 'scripts',
52945294- key: 'scripts',
52955295- value: JSON.stringify(scripts),
52965296- updatedAt: Date.now()
52975297- });
52985298-52995299- return scriptId;
53005300- });
53015301-53025302- // Execute with short timeout - test executor directly
53035303- const executeResult = await bgWindow.evaluate(async (scriptId) => {
53045304- const api = (window as any).app;
53055305- const { scriptExecutor } = await import('peek://scripts/script-executor.js');
53065306-53075307- // Get the script from datastore
53085308- const settingsTable = await api.datastore.getTable('feature_settings');
53095309- const scriptsRow = settingsTable.data?.['scripts:scripts'];
53105310- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
53115311- const script = scripts.find((s: any) => s.id === scriptId);
53125312-53135313- if (!script) {
53145314- return { success: false, error: 'Script not found' };
53155315- }
53165316-53175317- // Execute directly with timeout
53185318- const result = await scriptExecutor.executeScript(script, {
53195319- url: 'https://example.com',
53205320- pageDOM: document,
53215321- pageWindow: window,
53225322- timeout: 100 // 100ms timeout
53235323- });
53245324-53255325- return { success: true, data: result };
53265326- }, scriptId);
53275327-53285328- expect(executeResult).toHaveProperty('success', true);
53295329- expect((executeResult as any).data.status).toBe('error');
53305330- expect((executeResult as any).data.error).toContain('timeout');
53315331-53325332- // Clean up
53335333- await bgWindow.evaluate(async (scriptId) => {
53345334- const api = (window as any).app;
53355335- const settingsTable = await api.datastore.getTable('feature_settings');
53365336- const scriptsRow = settingsTable.data?.['scripts:scripts'];
53375337- const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : [];
53385338- const filtered = scripts.filter((s: any) => s.id !== scriptId);
53395339- await api.datastore.setRow('feature_settings', 'scripts:scripts', {
53405340- featureId: 'scripts',
53415341- key: 'scripts',
53425342- value: JSON.stringify(filtered),
53435343- updatedAt: Date.now()
53445344- });
53455345- }, scriptId);
53465346- });
53475347-});
+109
tests/desktop/startup-events.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { waitForExtensionsReady } from '../helpers/window-utils';
55+66+test.describe('Startup Phase Events @desktop', () => {
77+ let app: DesktopApp;
88+ let bgWindow: Page;
99+1010+ test.beforeAll(async () => {
1111+ ({ app, bgWindow } = await createPerDescribeApp('startup-phase'));
1212+ // Wait for extensions to be fully ready
1313+ await waitForExtensionsReady(bgWindow);
1414+ });
1515+1616+ test.afterAll(async () => {
1717+ if (app) await app.close();
1818+ });
1919+2020+ test('ext:startup:phase events are available for subscription', async () => {
2121+ // Test that extensions can subscribe to startup phase events
2222+ // Since app is already started, we test that the subscription mechanism works
2323+ const result = await bgWindow.evaluate(async () => {
2424+ const api = (window as any).app;
2525+ let received = false;
2626+2727+ // Subscribe to startup phase events
2828+ api.subscribe('ext:startup:phase', (msg: any) => {
2929+ received = true;
3030+ }, api.scopes.GLOBAL);
3131+3232+ // The subscription should be set up without error
3333+ return { subscriptionCreated: true };
3434+ });
3535+3636+ expect(result.subscriptionCreated).toBe(true);
3737+ });
3838+3939+ test('ext:all-loaded event was published during startup', async () => {
4040+ // Verify that the ext:all-loaded event was published by checking extensions are running
4141+ const result = await bgWindow.evaluate(async () => {
4242+ const api = (window as any).app;
4343+4444+ // Get running extensions - if they're running, ext:all-loaded was published
4545+ const extResult = await api.extensions.list();
4646+ const extensions = extResult.data || [];
4747+ return {
4848+ success: extResult.success,
4949+ extensionCount: extensions.length,
5050+ hasCmd: extensions.some((e: any) => e.id === 'cmd'),
5151+ hasGroups: extensions.some((e: any) => e.id === 'groups')
5252+ };
5353+ });
5454+5555+ expect(result.success).toBe(true);
5656+ expect(result.extensionCount).toBeGreaterThan(0);
5757+ expect(result.hasCmd).toBe(true);
5858+ });
5959+6060+ test('cmd extension loads before other extensions can register commands', async () => {
6161+ // Verify that cmd is running and accepting commands (which means it loaded first)
6262+ // Use inline retry approach that works reliably
6363+ const result = await bgWindow.evaluate(async () => {
6464+ const api = (window as any).app;
6565+6666+ const queryCommands = () => new Promise((resolve) => {
6767+ api.subscribe('cmd:query-commands-response', (msg: any) => {
6868+ resolve(msg.commands || []);
6969+ }, api.scopes.GLOBAL);
7070+ api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
7171+ setTimeout(() => resolve([]), 1000);
7272+ });
7373+7474+ // Retry a few times to allow extensions to finish loading
7575+ for (let i = 0; i < 5; i++) {
7676+ const cmds = await queryCommands() as any[];
7777+ if (cmds.some((c: any) => c.name === 'example:gallery')) {
7878+ return cmds;
7979+ }
8080+ await new Promise(r => setTimeout(r, 500));
8181+ }
8282+ return await queryCommands();
8383+ });
8484+8585+ expect(Array.isArray(result)).toBe(true);
8686+ expect(result.length).toBeGreaterThan(0);
8787+ // gallery command from example extension should be registered
8888+ const hasGalleryCommand = result.some((c: any) => c.name === 'example:gallery');
8989+ expect(hasGalleryCommand).toBe(true);
9090+ });
9191+9292+ test('cmd extension is always running (cannot be disabled)', async () => {
9393+ // cmd is required infrastructure - verify it's always in the running extensions list
9494+ const result = await bgWindow.evaluate(async () => {
9595+ const api = (window as any).app;
9696+ const runningExts = await api.extensions.list();
9797+ return {
9898+ success: runningExts.success,
9999+ extensions: runningExts.data || [],
100100+ cmdRunning: runningExts.data?.some((ext: any) => ext.id === 'cmd'),
101101+ cmdStatus: runningExts.data?.find((ext: any) => ext.id === 'cmd')?.status
102102+ };
103103+ });
104104+105105+ expect(result.success).toBe(true);
106106+ expect(result.cmdRunning).toBe(true);
107107+ expect(result.cmdStatus).toBe('running');
108108+ });
109109+});
+212
tests/desktop/tag-command.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+55+test.describe('Tag Command @desktop', () => {
66+ let app: DesktopApp;
77+ let bgWindow: Page;
88+99+ test.beforeAll(async () => {
1010+ ({ app, bgWindow } = await createPerDescribeApp('tag-cmd'));
1111+ });
1212+1313+ test.afterAll(async () => {
1414+ if (app) await app.close();
1515+ });
1616+1717+ test('creates address if not exists when tagging', async () => {
1818+ // This tests the bug fix: addResult.data.id instead of addResult.id
1919+ // Use unique URI to avoid conflicts with other tests
2020+ // Note: datastore normalizes URLs (adds trailing slash)
2121+ const timestamp = Date.now();
2222+ const testUri = `https://tag-test-new-address-${timestamp}.example.com/`;
2323+2424+ // Create tag with unique name
2525+ const tagResult = await bgWindow.evaluate(async (ts: number) => {
2626+ return await (window as any).app.datastore.getOrCreateTag('test-new-addr-tag-' + ts);
2727+ }, timestamp);
2828+ expect(tagResult.success).toBe(true);
2929+ const tagId = tagResult.data?.tag?.id;
3030+ expect(tagId).toBeTruthy();
3131+3232+ // Create address
3333+ const addResult = await bgWindow.evaluate(async (uri: string) => {
3434+ return await (window as any).app.datastore.addAddress(uri, { title: 'New Tagged Address' });
3535+ }, testUri);
3636+ expect(addResult.success).toBe(true);
3737+ // Bug fix verification: data.id is the correct path
3838+ expect(addResult.data?.id).toBeTruthy();
3939+4040+ // Tag the address using the correct id path
4141+ const linkResult = await bgWindow.evaluate(async ({ addressId, tagId }) => {
4242+ return await (window as any).app.datastore.tagAddress(addressId, tagId);
4343+ }, { addressId: addResult.data.id, tagId });
4444+ expect(linkResult.success).toBe(true);
4545+4646+ // Verify address is tagged
4747+ const taggedAddresses = await bgWindow.evaluate(async (tId: string) => {
4848+ return await (window as any).app.datastore.getAddressesByTag(tId);
4949+ }, tagId);
5050+ expect(taggedAddresses.success).toBe(true);
5151+ expect(taggedAddresses.data.some((a: any) => a.uri === testUri)).toBe(true);
5252+ });
5353+5454+ test('getOrCreateTag returns tag in data.tag', async () => {
5555+ // This tests the bug fix: tagResult.data.tag.id instead of tagResult.data.id
5656+ const tagName = 'test-nested-tag-response';
5757+5858+ const result = await bgWindow.evaluate(async (name: string) => {
5959+ return await (window as any).app.datastore.getOrCreateTag(name);
6060+ }, tagName);
6161+6262+ expect(result.success).toBe(true);
6363+ // Bug fix verification: tag is nested in data.tag
6464+ expect(result.data?.tag).toBeTruthy();
6565+ expect(result.data?.tag?.id).toBeTruthy();
6666+ expect(result.data?.tag?.name).toBe(tagName);
6767+ expect(typeof result.data?.created).toBe('boolean');
6868+ });
6969+7070+ test('tagAddress links tag to address correctly', async () => {
7171+ // Create address
7272+ const addr = await bgWindow.evaluate(async () => {
7373+ return await (window as any).app.datastore.addAddress('https://tag-link-test.example.com', {
7474+ title: 'Tag Link Test'
7575+ });
7676+ });
7777+ expect(addr.success).toBe(true);
7878+7979+ // Create tag
8080+ const tag = await bgWindow.evaluate(async () => {
8181+ return await (window as any).app.datastore.getOrCreateTag('link-test-tag');
8282+ });
8383+ expect(tag.success).toBe(true);
8484+8585+ // Link them
8686+ const link = await bgWindow.evaluate(async ({ addressId, tagId }) => {
8787+ return await (window as any).app.datastore.tagAddress(addressId, tagId);
8888+ }, { addressId: addr.data.id, tagId: tag.data.tag.id });
8989+ expect(link.success).toBe(true);
9090+9191+ // Verify link exists
9292+ const addressTags = await bgWindow.evaluate(async (addressId: string) => {
9393+ return await (window as any).app.datastore.getAddressTags(addressId);
9494+ }, addr.data.id);
9595+ expect(addressTags.success).toBe(true);
9696+ expect(addressTags.data.some((t: any) => t.name === 'link-test-tag')).toBe(true);
9797+ });
9898+9999+ test('multiple tags can be added to same address', async () => {
100100+ // Create address
101101+ const addr = await bgWindow.evaluate(async () => {
102102+ return await (window as any).app.datastore.addAddress('https://multi-tag-test.example.com', {
103103+ title: 'Multi Tag Test'
104104+ });
105105+ });
106106+ expect(addr.success).toBe(true);
107107+108108+ // Create and link multiple tags
109109+ const tagNames = ['multi-tag-1', 'multi-tag-2', 'multi-tag-3'];
110110+111111+ for (const tagName of tagNames) {
112112+ const tag = await bgWindow.evaluate(async (name: string) => {
113113+ return await (window as any).app.datastore.getOrCreateTag(name);
114114+ }, tagName);
115115+ expect(tag.success).toBe(true);
116116+117117+ const link = await bgWindow.evaluate(async ({ addressId, tagId }) => {
118118+ return await (window as any).app.datastore.tagAddress(addressId, tagId);
119119+ }, { addressId: addr.data.id, tagId: tag.data.tag.id });
120120+ expect(link.success).toBe(true);
121121+ }
122122+123123+ // Verify all tags are linked
124124+ const addressTags = await bgWindow.evaluate(async (addressId: string) => {
125125+ return await (window as any).app.datastore.getAddressTags(addressId);
126126+ }, addr.data.id);
127127+ expect(addressTags.success).toBe(true);
128128+ expect(addressTags.data.length).toBeGreaterThanOrEqual(3);
129129+130130+ for (const tagName of tagNames) {
131131+ expect(addressTags.data.some((t: any) => t.name === tagName)).toBe(true);
132132+ }
133133+ });
134134+135135+ test('untagAddress removes tag from address', async () => {
136136+ // Create address
137137+ const addr = await bgWindow.evaluate(async () => {
138138+ return await (window as any).app.datastore.addAddress('https://untag-test.example.com', {
139139+ title: 'Untag Test'
140140+ });
141141+ });
142142+ expect(addr.success).toBe(true);
143143+144144+ // Create and link tag
145145+ const tag = await bgWindow.evaluate(async () => {
146146+ return await (window as any).app.datastore.getOrCreateTag('untag-test-tag');
147147+ });
148148+ expect(tag.success).toBe(true);
149149+150150+ await bgWindow.evaluate(async ({ addressId, tagId }) => {
151151+ return await (window as any).app.datastore.tagAddress(addressId, tagId);
152152+ }, { addressId: addr.data.id, tagId: tag.data.tag.id });
153153+154154+ // Verify tag is linked
155155+ let addressTags = await bgWindow.evaluate(async (addressId: string) => {
156156+ return await (window as any).app.datastore.getAddressTags(addressId);
157157+ }, addr.data.id);
158158+ expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(true);
159159+160160+ // Remove tag
161161+ const untag = await bgWindow.evaluate(async ({ addressId, tagId }) => {
162162+ return await (window as any).app.datastore.untagAddress(addressId, tagId);
163163+ }, { addressId: addr.data.id, tagId: tag.data.tag.id });
164164+ expect(untag.success).toBe(true);
165165+166166+ // Verify tag is removed
167167+ addressTags = await bgWindow.evaluate(async (addressId: string) => {
168168+ return await (window as any).app.datastore.getAddressTags(addressId);
169169+ }, addr.data.id);
170170+ expect(addressTags.data.some((t: any) => t.name === 'untag-test-tag')).toBe(false);
171171+ });
172172+173173+ test('getUntaggedAddresses returns addresses without tags', async () => {
174174+ // Use unique URI to avoid conflicts
175175+ // Note: datastore normalizes URLs (adds trailing slash)
176176+ const timestamp = Date.now();
177177+ const testUri = `https://untagged-test-${timestamp}.example.com/`;
178178+179179+ // Create address without tagging it
180180+ const addr = await bgWindow.evaluate(async (uri: string) => {
181181+ return await (window as any).app.datastore.addAddress(uri, {
182182+ title: 'Untagged Test'
183183+ });
184184+ }, testUri);
185185+ expect(addr.success).toBe(true);
186186+ expect(addr.data?.id).toBeTruthy();
187187+188188+ // Query untagged addresses
189189+ const untagged = await bgWindow.evaluate(async () => {
190190+ return await (window as any).app.datastore.getUntaggedAddresses();
191191+ });
192192+ expect(untagged.success).toBe(true);
193193+ expect(untagged.data.some((a: any) => a.uri === testUri)).toBe(true);
194194+195195+ // Tag the address with unique tag name
196196+ const tag = await bgWindow.evaluate(async (ts: number) => {
197197+ return await (window as any).app.datastore.getOrCreateTag('now-tagged-' + ts);
198198+ }, timestamp);
199199+ expect(tag.success).toBe(true);
200200+ expect(tag.data?.tag?.id).toBeTruthy();
201201+202202+ await bgWindow.evaluate(async ({ addressId, tagId }) => {
203203+ return await (window as any).app.datastore.tagAddress(addressId, tagId);
204204+ }, { addressId: addr.data.id, tagId: tag.data.tag.id });
205205+206206+ // Verify it's no longer in untagged list
207207+ const untaggedAfter = await bgWindow.evaluate(async () => {
208208+ return await (window as any).app.datastore.getUntaggedAddresses();
209209+ });
210210+ expect(untaggedAfter.data.some((a: any) => a.uri === testUri)).toBe(false);
211211+ });
212212+});
+209
tests/desktop/tag-events.spec.ts
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+55+test.describe('Tag Events @desktop', () => {
66+ let app: DesktopApp;
77+ let bgWindow: Page;
88+99+ test.beforeAll(async () => {
1010+ ({ app, bgWindow } = await createPerDescribeApp('tag-events'));
1111+ });
1212+1313+ test.afterAll(async () => {
1414+ if (app) await app.close();
1515+ });
1616+1717+ test('tag:created is emitted when new tag is created', async () => {
1818+ const timestamp = Date.now();
1919+ const tagName = `event-test-tag-${timestamp}`;
2020+2121+ const result = await bgWindow.evaluate(async (name: string) => {
2222+ const api = (window as any).app;
2323+2424+ return new Promise((resolve) => {
2525+ const timeout = setTimeout(() => {
2626+ resolve({ received: false });
2727+ }, 5000);
2828+2929+ api.subscribe('tag:created', (msg: any) => {
3030+ if (msg.tagName === name) {
3131+ clearTimeout(timeout);
3232+ resolve({
3333+ received: true,
3434+ tagId: msg.tagId,
3535+ tagName: msg.tagName
3636+ });
3737+ }
3838+ }, api.scopes.GLOBAL);
3939+4040+ // Create new tag to trigger the event
4141+ api.datastore.getOrCreateTag(name);
4242+ });
4343+ }, tagName);
4444+4545+ expect((result as any).received).toBe(true);
4646+ expect((result as any).tagName).toBe(tagName);
4747+ expect((result as any).tagId).toBeTruthy();
4848+ });
4949+5050+ test('tag:item-added is emitted when item is tagged', async () => {
5151+ const timestamp = Date.now();
5252+ const tagName = `item-added-event-tag-${timestamp}`;
5353+5454+ const result = await bgWindow.evaluate(async (name: string) => {
5555+ const api = (window as any).app;
5656+5757+ // First create an item and a tag
5858+ const itemResult = await api.datastore.addItem('url', {
5959+ content: `https://tag-event-test-${Date.now()}.example.com`,
6060+ metadata: JSON.stringify({ title: 'Tag Event Test Item' })
6161+ });
6262+ if (!itemResult.success) {
6363+ return { received: false, error: 'failed to create item' };
6464+ }
6565+ const itemId = itemResult.data.id;
6666+6767+ const tagResult = await api.datastore.getOrCreateTag(name);
6868+ if (!tagResult.success) {
6969+ return { received: false, error: 'failed to create tag' };
7070+ }
7171+ const tagId = tagResult.data.tag.id;
7272+7373+ return new Promise((resolve) => {
7474+ const timeout = setTimeout(() => {
7575+ resolve({ received: false, error: 'timeout' });
7676+ }, 5000);
7777+7878+ api.subscribe('tag:item-added', (msg: any) => {
7979+ if (msg.itemId === itemId && msg.tagId === tagId) {
8080+ clearTimeout(timeout);
8181+ resolve({
8282+ received: true,
8383+ tagId: msg.tagId,
8484+ tagName: msg.tagName,
8585+ itemId: msg.itemId,
8686+ itemType: msg.itemType
8787+ });
8888+ }
8989+ }, api.scopes.GLOBAL);
9090+9191+ // Tag the item to trigger the event
9292+ api.datastore.tagItem(itemId, tagId);
9393+ });
9494+ }, tagName);
9595+9696+ expect((result as any).received).toBe(true);
9797+ expect((result as any).tagName).toBe(tagName);
9898+ expect((result as any).tagId).toBeTruthy();
9999+ expect((result as any).itemId).toBeTruthy();
100100+ expect((result as any).itemType).toBe('url');
101101+ });
102102+103103+ test('tag:item-removed is emitted when item is untagged', async () => {
104104+ const timestamp = Date.now();
105105+ const tagName = `item-removed-event-tag-${timestamp}`;
106106+107107+ const result = await bgWindow.evaluate(async (name: string) => {
108108+ const api = (window as any).app;
109109+110110+ // Create an item
111111+ const itemResult = await api.datastore.addItem('url', {
112112+ content: `https://untag-event-test-${Date.now()}.example.com`,
113113+ metadata: JSON.stringify({ title: 'Untag Event Test Item' })
114114+ });
115115+ if (!itemResult.success) {
116116+ return { received: false, error: 'failed to create item' };
117117+ }
118118+ const itemId = itemResult.data.id;
119119+120120+ // Create a tag
121121+ const tagResult = await api.datastore.getOrCreateTag(name);
122122+ if (!tagResult.success) {
123123+ return { received: false, error: 'failed to create tag' };
124124+ }
125125+ const tagId = tagResult.data.tag.id;
126126+127127+ // Tag the item first
128128+ await api.datastore.tagItem(itemId, tagId);
129129+130130+ return new Promise((resolve) => {
131131+ const timeout = setTimeout(() => {
132132+ resolve({ received: false, error: 'timeout' });
133133+ }, 5000);
134134+135135+ api.subscribe('tag:item-removed', (msg: any) => {
136136+ if (msg.itemId === itemId && msg.tagId === tagId) {
137137+ clearTimeout(timeout);
138138+ resolve({
139139+ received: true,
140140+ tagId: msg.tagId,
141141+ tagName: msg.tagName,
142142+ itemId: msg.itemId
143143+ });
144144+ }
145145+ }, api.scopes.GLOBAL);
146146+147147+ // Untag the item to trigger the event
148148+ api.datastore.untagItem(itemId, tagId);
149149+ });
150150+ }, tagName);
151151+152152+ expect((result as any).received).toBe(true);
153153+ expect((result as any).tagName).toBe(tagName);
154154+ expect((result as any).tagId).toBeTruthy();
155155+ expect((result as any).itemId).toBeTruthy();
156156+ });
157157+158158+ test('tag:item-added is NOT emitted for duplicate tag', async () => {
159159+ const timestamp = Date.now();
160160+ const tagName = `duplicate-tag-event-${timestamp}`;
161161+162162+ const result = await bgWindow.evaluate(async (name: string) => {
163163+ const api = (window as any).app;
164164+165165+ // Create an item
166166+ const itemResult = await api.datastore.addItem('url', {
167167+ content: `https://duplicate-tag-test-${Date.now()}.example.com`,
168168+ metadata: JSON.stringify({ title: 'Duplicate Tag Test Item' })
169169+ });
170170+ if (!itemResult.success) {
171171+ return { received: false, error: 'failed to create item' };
172172+ }
173173+ const itemId = itemResult.data.id;
174174+175175+ // Create a tag
176176+ const tagResult = await api.datastore.getOrCreateTag(name);
177177+ if (!tagResult.success) {
178178+ return { received: false, error: 'failed to create tag' };
179179+ }
180180+ const tagId = tagResult.data.tag.id;
181181+182182+ // Tag the item for the first time (this should emit an event but we don't care)
183183+ await api.datastore.tagItem(itemId, tagId);
184184+185185+ // Wait a bit to ensure the first event has been processed
186186+ await new Promise(r => setTimeout(r, 100));
187187+188188+ return new Promise((resolve) => {
189189+ // Use a short timeout since we expect NO event
190190+ const timeout = setTimeout(() => {
191191+ resolve({ received: false }); // This is the expected outcome
192192+ }, 1000);
193193+194194+ api.subscribe('tag:item-added', (msg: any) => {
195195+ if (msg.itemId === itemId && msg.tagId === tagId) {
196196+ clearTimeout(timeout);
197197+ resolve({ received: true }); // This would be unexpected
198198+ }
199199+ }, api.scopes.GLOBAL);
200200+201201+ // Try to tag the same item with the same tag again
202202+ api.datastore.tagItem(itemId, tagId);
203203+ });
204204+ }, tagName);
205205+206206+ // We expect NO event to be received for duplicate tagging
207207+ expect((result as any).received).toBe(false);
208208+ });
209209+});
···11+import { test, expect, DesktopApp } from '../fixtures/desktop-app';
22+import { Page } from '@playwright/test';
33+import { createPerDescribeApp } from '../helpers/test-app';
44+import { sleep } from '../helpers/window-utils';
55+66+// ============================================================================
77+// Window Targeting Tests
88+// ============================================================================
99+// Tests for the window focus tracking system that enables per-window commands
1010+// like "theme dark here" to target the correct window.
1111+//
1212+// Key behavior: Modal windows (like cmd palette) should NOT update the
1313+// "last focused visible window" tracker, so commands target the window
1414+// the user was looking at before opening the palette.
1515+1616+test.describe('Window Targeting @desktop', () => {
1717+ let app: DesktopApp;
1818+ let bgWindow: Page;
1919+2020+ test.beforeAll(async () => {
2121+ ({ app, bgWindow } = await createPerDescribeApp('window-targeting'));
2222+ await sleep(500); // Wait for app to stabilize
2323+ });
2424+2525+ test.afterAll(async () => {
2626+ if (app) await app.close();
2727+ });
2828+2929+ test('setWindowColorScheme returns success with windowId', async () => {
3030+ // Test that setWindowColorScheme works and returns expected data
3131+ const result = await bgWindow.evaluate(async () => {
3232+ const api = (window as any).app;
3333+3434+ // Open a test window (non-modal) to have a valid target
3535+ const winResult = await api.window.open('peek://app/settings/settings.html', {
3636+ width: 400,
3737+ height: 300,
3838+ modal: false,
3939+ key: 'test-theme-window-1'
4040+ });
4141+4242+ if (!winResult.success) {
4343+ return { success: false, error: 'Failed to open window' };
4444+ }
4545+4646+ // Wait for window to be ready and focused
4747+ await new Promise(r => setTimeout(r, 300));
4848+4949+ // Call setWindowColorScheme
5050+ const themeResult = await api.theme.setWindowColorScheme('dark');
5151+5252+ // Clean up
5353+ try {
5454+ await api.window.close(winResult.id);
5555+ } catch (e) {
5656+ // Ignore close errors
5757+ }
5858+5959+ return {
6060+ success: themeResult.success,
6161+ windowId: themeResult.windowId,
6262+ colorScheme: themeResult.colorScheme,
6363+ error: themeResult.error
6464+ };
6565+ });
6666+6767+ expect(result.success).toBe(true);
6868+ expect(result.colorScheme).toBe('dark');
6969+ expect(typeof result.windowId).toBe('number');
7070+ });
7171+7272+ test('modal window does not become theme target', async () => {
7373+ // This test verifies that opening a modal window after a non-modal window
7474+ // still allows setWindowColorScheme to target the non-modal window
7575+ const result = await bgWindow.evaluate(async () => {
7676+ const api = (window as any).app;
7777+7878+ // Open a non-modal window first
7979+ const nonModalResult = await api.window.open('peek://app/settings/settings.html', {
8080+ width: 400,
8181+ height: 300,
8282+ modal: false,
8383+ key: 'test-nonmodal-target'
8484+ });
8585+8686+ if (!nonModalResult.success) {
8787+ return { success: false, error: 'Failed to open non-modal window' };
8888+ }
8989+9090+ // Wait for it to focus
9191+ await new Promise(r => setTimeout(r, 300));
9292+9393+ // Now open a modal window (simulating cmd palette behavior)
9494+ const modalResult = await api.window.open('peek://app/settings/settings.html', {
9595+ width: 300,
9696+ height: 200,
9797+ modal: true,
9898+ key: 'test-modal-overlay'
9999+ });
100100+101101+ if (!modalResult.success) {
102102+ // Clean up non-modal
103103+ try { await api.window.close(nonModalResult.id); } catch (e) {}
104104+ return { success: false, error: 'Failed to open modal window' };
105105+ }
106106+107107+ // Wait a bit for modal to be ready
108108+ await new Promise(r => setTimeout(r, 200));
109109+110110+ // Now call setWindowColorScheme - should still target the NON-MODAL window
111111+ const themeResult = await api.theme.setWindowColorScheme('light');
112112+113113+ // Clean up both windows
114114+ try { await api.window.close(modalResult.id); } catch (e) {}
115115+ try { await api.window.close(nonModalResult.id); } catch (e) {}
116116+117117+ return {
118118+ success: themeResult.success,
119119+ targetedWindowId: themeResult.windowId,
120120+ nonModalWindowId: nonModalResult.id,
121121+ modalWindowId: modalResult.id,
122122+ // Key assertion: the targeted window should be the non-modal one
123123+ targetedNonModal: themeResult.windowId === nonModalResult.id
124124+ };
125125+ });
126126+127127+ expect(result.success).toBe(true);
128128+ // The theme command should have targeted the non-modal window, not the modal
129129+ expect(result.targetedNonModal).toBe(true);
130130+ });
131131+132132+ test('setWindowColorScheme with global resets override', async () => {
133133+ // Test the 'global' value which should reset window-specific override
134134+ const result = await bgWindow.evaluate(async () => {
135135+ const api = (window as any).app;
136136+137137+ // Open a test window
138138+ const winResult = await api.window.open('peek://app/settings/settings.html', {
139139+ width: 400,
140140+ height: 300,
141141+ modal: false,
142142+ key: 'test-theme-reset-window'
143143+ });
144144+145145+ if (!winResult.success) {
146146+ return { success: false, error: 'Failed to open window' };
147147+ }
148148+149149+ await new Promise(r => setTimeout(r, 300));
150150+151151+ // Set to dark first
152152+ const darkResult = await api.theme.setWindowColorScheme('dark');
153153+154154+ // Then reset to global
155155+ const globalResult = await api.theme.setWindowColorScheme('global');
156156+157157+ // Clean up
158158+ try { await api.window.close(winResult.id); } catch (e) {}
159159+160160+ return {
161161+ darkSuccess: darkResult.success,
162162+ globalSuccess: globalResult.success,
163163+ globalColorScheme: globalResult.colorScheme
164164+ };
165165+ });
166166+167167+ expect(result.darkSuccess).toBe(true);
168168+ expect(result.globalSuccess).toBe(true);
169169+ expect(result.globalColorScheme).toBe('global');
170170+ });
171171+});
+26
tests/helpers/test-app.ts
···11+/**
22+ * Shared per-describe app factory for Playwright smoke tests.
33+ *
44+ * Each describe block calls createPerDescribeApp(label) in its beforeAll to
55+ * launch a fresh Electron instance with an isolated profile. The profile name
66+ * MUST start with "test" so that isTestProfile() in backend/electron/config.ts
77+ * skips the single-instance lock — without that prefix, parallel Playwright
88+ * workers would all contend for the same machine-wide lock and only one
99+ * Electron launch would succeed.
1010+ */
1111+1212+import { DesktopApp, launchDesktopApp } from '../fixtures/desktop-app';
1313+import { Page } from '@playwright/test';
1414+import { waitForExtensionsReady } from './window-utils';
1515+1616+export async function createPerDescribeApp(label: string): Promise<{ app: DesktopApp; bgWindow: Page }> {
1717+ // Profile MUST start with "test" — `isTestProfile()` in backend/electron/config.ts
1818+ // keys on that prefix to skip the single-instance lock. Without it, parallel
1919+ // Playwright workers would all contend for the same machine-wide lock and
2020+ // only one Electron launch would succeed.
2121+ const profile = `test-smoke-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
2222+ const app = await launchDesktopApp(profile);
2323+ const bgWindow = await app.getBackgroundWindow();
2424+ await waitForExtensionsReady(bgWindow);
2525+ return { app, bgWindow };
2626+}