experiments in a post-browser web
10
fork

Configure Feed

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

feat(tests): iOS e2e testing improvements and window utilities

+674 -158
+41
backend/electron/datastore.ts
··· 405 405 migrateItemFrecencyColumns(); 406 406 migrateAllAddressesToItems(); 407 407 migrateVisitsToItemVisits(); 408 + migrateTagsLastUsedColumn(); 408 409 409 410 // Validate schema against canonical definition 410 411 validateSyncSchema(); ··· 1320 1321 // Mark migration as complete 1321 1322 db.prepare('INSERT OR REPLACE INTO migrations (id, status, completedAt) VALUES (?, ?, ?)').run(MIGRATION_ID, 'complete', Date.now()); 1322 1323 DEBUG && console.log('main', `Migrated ${migratedCount} visits to item_visits, skipped ${skippedCount}, calculated frecency for ${urlItems.length} items`); 1324 + } 1325 + 1326 + /** 1327 + * Rename lastUsedAt column to lastUsed in tags table for schema consistency. 1328 + * Some databases may have been created with lastUsedAt instead of lastUsed. 1329 + */ 1330 + function migrateTagsLastUsedColumn(): void { 1331 + if (!db) return; 1332 + 1333 + // Check if tags table has lastUsedAt column (old name) 1334 + const columns = db.prepare(`PRAGMA table_info(tags)`).all() as { name: string }[]; 1335 + const hasLastUsedAt = columns.some(col => col.name === 'lastUsedAt'); 1336 + const hasLastUsed = columns.some(col => col.name === 'lastUsed'); 1337 + 1338 + if (hasLastUsedAt && !hasLastUsed) { 1339 + DEBUG && console.log('main', 'Renaming tags.lastUsedAt to tags.lastUsed'); 1340 + try { 1341 + // SQLite 3.25.0+ supports ALTER TABLE RENAME COLUMN 1342 + db.exec(`ALTER TABLE tags RENAME COLUMN lastUsedAt TO lastUsed`); 1343 + DEBUG && console.log('main', 'Successfully renamed lastUsedAt to lastUsed'); 1344 + } catch (error) { 1345 + // Fallback: create new column and copy data (for older SQLite versions) 1346 + DEBUG && console.log('main', 'Rename failed, trying fallback migration:', (error as Error).message); 1347 + try { 1348 + db.exec(`ALTER TABLE tags ADD COLUMN lastUsed INTEGER DEFAULT 0`); 1349 + db.exec(`UPDATE tags SET lastUsed = lastUsedAt`); 1350 + DEBUG && console.log('main', 'Fallback migration complete (lastUsedAt data copied to lastUsed)'); 1351 + } catch (fallbackError) { 1352 + DEBUG && console.log('main', 'Fallback migration failed:', (fallbackError as Error).message); 1353 + } 1354 + } 1355 + } else if (!hasLastUsed) { 1356 + // Neither column exists, add lastUsed 1357 + DEBUG && console.log('main', 'Adding missing lastUsed column to tags'); 1358 + try { 1359 + db.exec(`ALTER TABLE tags ADD COLUMN lastUsed INTEGER DEFAULT 0`); 1360 + } catch (error) { 1361 + DEBUG && console.log('main', 'Failed to add lastUsed column:', (error as Error).message); 1362 + } 1363 + } 1323 1364 } 1324 1365 1325 1366 // ==================== Version Check ====================
+34
backend/tauri-mobile/src-tauri/src/lib.rs
··· 4303 4303 // BACKUP: Copy App Group data to Documents for recovery via Finder File Sharing 4304 4304 backup_app_group_to_documents(); 4305 4305 4306 + // Check for auto-sync environment variable (used by e2e tests) 4307 + let auto_sync_on_launch = std::env::var("PEEK_AUTO_SYNC") 4308 + .map(|v| v == "1" || v.to_lowercase() == "true") 4309 + .unwrap_or(false); 4310 + 4306 4311 tauri::Builder::default() 4307 4312 .plugin(tauri_plugin_opener::init()) 4313 + .setup(move |_app| { 4314 + if auto_sync_on_launch { 4315 + println!("[Rust] PEEK_AUTO_SYNC enabled - triggering sync on launch"); 4316 + // Spawn async task to sync after app is ready 4317 + tauri::async_runtime::spawn(async move { 4318 + // Small delay to ensure database is fully initialized 4319 + std::thread::sleep(std::time::Duration::from_secs(2)); 4320 + 4321 + // Check if sync is configured before attempting 4322 + let config = load_profile_config(); 4323 + if config.sync.server_url.is_empty() || config.sync.api_key.is_empty() { 4324 + println!("[Rust] PEEK_AUTO_SYNC: Sync not configured, skipping"); 4325 + return; 4326 + } 4327 + 4328 + println!("[Rust] PEEK_AUTO_SYNC: Starting automatic sync..."); 4329 + match sync_all_internal().await { 4330 + Ok(result) => { 4331 + println!("[Rust] PEEK_AUTO_SYNC: Sync complete - {} pulled, {} pushed", 4332 + result.pulled, result.pushed); 4333 + } 4334 + Err(e) => { 4335 + println!("[Rust] PEEK_AUTO_SYNC: Sync failed - {}", e); 4336 + } 4337 + } 4338 + }); 4339 + } 4340 + Ok(()) 4341 + }) 4308 4342 .invoke_handler(tauri::generate_handler![ 4309 4343 // Page (URL) commands 4310 4344 save_url,
+1 -1
schema/generated/sqlite-full.sql
··· 1 1 -- Generated by schema/codegen.js 2 2 -- Schema version: 1 3 - -- Generated: 2026-01-30T08:06:39.915Z 3 + -- Generated: 2026-01-30T11:28:43.966Z 4 4 -- DO NOT EDIT - regenerate with: yarn schema:codegen 5 5 6 6 -- Unified content storage - URLs, text notes, tagsets, and images
+1 -1
schema/generated/sqlite-sync.sql
··· 1 1 -- Generated by schema/codegen.js 2 2 -- Schema version: 1 3 - -- Generated: 2026-01-30T08:06:39.916Z 3 + -- Generated: 2026-01-30T11:28:43.966Z 4 4 -- DO NOT EDIT - regenerate with: yarn schema:codegen 5 5 6 6 -- Unified content storage - URLs, text notes, tagsets, and images
+1 -1
schema/generated/types.rs
··· 1 1 // Generated by schema/codegen.js 2 2 // Schema version: 1 3 - // Generated: 2026-01-30T08:06:39.916Z 3 + // Generated: 2026-01-30T11:28:43.967Z 4 4 // DO NOT EDIT - regenerate with: yarn schema:codegen 5 5 6 6 use serde::{Deserialize, Serialize};
+1 -1
schema/generated/types.ts
··· 1 1 /** 2 2 * Generated by schema/codegen.js 3 3 * Schema version: 1 4 - * Generated: 2026-01-30T08:06:39.916Z 4 + * Generated: 2026-01-30T11:28:43.966Z 5 5 * DO NOT EDIT - regenerate with: yarn schema:codegen 6 6 */ 7 7
+149 -34
scripts/e2e-full-sync-test.sh
··· 10 10 # │ Then watch output and follow prompts to tap buttons in the │ 11 11 # │ iOS simulator when instructed. Do NOT run this blocking in │ 12 12 # │ an automated context - it requires human interaction. │ 13 + # │ │ 14 + # │ For semi-automated mode (auto-relaunch, no prompts): │ 15 + # │ npm run interactive-test:e2e:full-sync -- --headless │ 16 + # │ │ 17 + # │ In headless mode, the script auto-relaunches the iOS app but │ 18 + # │ still requires manual "Sync All" taps OR the iOS app must │ 19 + # │ support PEEK_AUTO_SYNC=true env var for auto-sync on launch. │ 13 20 # └─────────────────────────────────────────────────────────────────┘ 14 21 # 15 22 # Clean-room test covering all sync permutations: ··· 29 36 TAURI_DIR="$PROJECT_DIR/backend/tauri-mobile" 30 37 XCODE_PROJECT="$TAURI_DIR/src-tauri/gen/apple/peek-save.xcodeproj" 31 38 39 + # --- Parse arguments --- 40 + HEADLESS=false 41 + for arg in "$@"; do 42 + case "$arg" in 43 + --headless|--auto) 44 + HEADLESS=true 45 + ;; 46 + esac 47 + done 48 + 32 49 # --- Configuration --- 33 50 34 51 PORT="${PORT:-3459}" ··· 41 58 42 59 LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || echo "localhost") 43 60 SERVER_URL="http://$LOCAL_IP:$PORT" 61 + IOS_BUNDLE_ID="com.dietrich.peek-mobile" 62 + 63 + # --- Helper functions --- 64 + 65 + # Relaunch iOS app in simulator (terminate + launch) 66 + # Usage: relaunch_ios_app [reason] [auto_sync] 67 + # reason: optional description for logging 68 + # auto_sync: if "true", passes PEEK_AUTO_SYNC=true to trigger sync on launch 69 + relaunch_ios_app() { 70 + local reason="${1:-}" 71 + local auto_sync="${2:-false}" 72 + if [ -n "$reason" ]; then 73 + echo " Relaunching iOS app ($reason)..." 74 + else 75 + echo " Relaunching iOS app..." 76 + fi 77 + xcrun simctl terminate booted "$IOS_BUNDLE_ID" 2>/dev/null || true 78 + sleep 1 79 + 80 + # Launch with optional auto-sync environment variable 81 + if [ "$auto_sync" = "true" ]; then 82 + echo " [AUTO-SYNC] Launching with PEEK_AUTO_SYNC=true" 83 + SIMCTL_CHILD_PEEK_AUTO_SYNC=true xcrun simctl launch booted "$IOS_BUNDLE_ID" 2>/dev/null || { 84 + echo " WARNING: Failed to launch iOS app. Is it installed?" 85 + return 1 86 + } 87 + else 88 + xcrun simctl launch booted "$IOS_BUNDLE_ID" 2>/dev/null || { 89 + echo " WARNING: Failed to launch iOS app. Is it installed?" 90 + return 1 91 + } 92 + fi 93 + sleep 2 94 + echo " iOS app relaunched." 95 + } 96 + 97 + # Prompt user or auto-proceed in headless mode 98 + prompt_or_continue() { 99 + local message="$1" 100 + local action="${2:-}" 101 + 102 + if [ "$HEADLESS" = true ]; then 103 + if [ -n "$action" ]; then 104 + echo " [HEADLESS] $action" 105 + fi 106 + echo " [HEADLESS] Continuing without user prompt..." 107 + else 108 + echo "" 109 + echo "==========================================" 110 + echo " $message" 111 + echo "==========================================" 112 + echo "" 113 + fi 114 + } 44 115 45 116 echo "==========================================" 46 117 echo " Full E2E Sync Test (Clean Room)" 118 + if [ "$HEADLESS" = true ]; then 119 + echo " Mode: HEADLESS (auto-relaunch, no prompts)" 120 + else 121 + echo " Mode: INTERACTIVE (manual prompts)" 122 + fi 47 123 echo "==========================================" 48 124 echo "" 49 125 echo " Server URL: $SERVER_URL" ··· 116 192 echo "" 117 193 echo "Step 2: Preparing iOS simulator (clean room)..." 118 194 119 - APP_GROUP=$(xcrun simctl get_app_container booted com.dietrich.peek-mobile groups 2>/dev/null | grep "group.com.dietrich.peek-mobile" | awk '{print $2}') 195 + APP_GROUP=$(xcrun simctl get_app_container booted "$IOS_BUNDLE_ID" groups 2>/dev/null | grep "group.$IOS_BUNDLE_ID" | awk '{print $2}') 120 196 121 197 if [ -z "$APP_GROUP" ]; then 122 - echo " WARNING: iOS app not installed in simulator." 123 - echo " Build and run the app once from Xcode, then re-run this script." 124 - echo "" 125 - open "$XCODE_PROJECT" 126 - echo " Server is running. Press Ctrl+C to stop." 127 - # Start server so user can build/install, then re-run 128 - DATA_DIR="$SERVER_TEMP_DIR" PORT="$PORT" API_KEY="$API_KEY" node "$SERVER_DIR/index.js" & 129 - SERVER_PID=$! 130 - wait "$SERVER_PID" 131 - exit 0 198 + echo " ERROR: iOS app not installed in simulator." 199 + if [ "$HEADLESS" = true ]; then 200 + echo " [HEADLESS] Cannot continue without iOS app installed." 201 + echo " Build and run the app once from Xcode, then re-run this script." 202 + exit 1 203 + else 204 + echo " Build and run the app once from Xcode, then re-run this script." 205 + echo "" 206 + open "$XCODE_PROJECT" 207 + echo " Server is running. Press Ctrl+C to stop." 208 + # Start server so user can build/install, then re-run 209 + DATA_DIR="$SERVER_TEMP_DIR" PORT="$PORT" API_KEY="$API_KEY" node "$SERVER_DIR/index.js" & 210 + SERVER_PID=$! 211 + wait "$SERVER_PID" 212 + exit 0 213 + fi 132 214 fi 133 215 134 216 echo " App container: $APP_GROUP" ··· 425 507 echo " Desktop: headless PID $DESKTOP_PID, profile '$DESKTOP_PROFILE'" 426 508 echo " iOS: profile $IOS_PROFILE_ID" 427 509 echo "" 428 - echo " iOS test steps:" 429 - echo " 1. Build & run in Xcode (Debug, iPhone simulator)" 430 - echo " 2. Force-quit and relaunch app (pick up profiles.json)" 431 - echo " 3. Tap 'Sync All'" 432 - echo " → should pull server + desktop items" 433 - echo " → should push iOS items to server" 434 - echo " 4. Check expected total: $(($SERVER_COUNT + $DESKTOP_LOCAL + $IOS_COUNT)) items" 510 + if [ "$HEADLESS" = true ]; then 511 + echo " [HEADLESS] Fully automated - iOS app will be auto-relaunched with PEEK_AUTO_SYNC=true" 512 + echo " [HEADLESS] No manual steps required!" 513 + echo " Expected total: $(($SERVER_COUNT + $DESKTOP_LOCAL + $IOS_COUNT)) items" 514 + else 515 + echo " iOS test steps:" 516 + echo " 1. Build & run in Xcode (Debug, iPhone simulator)" 517 + echo " 2. Force-quit and relaunch app (pick up profiles.json)" 518 + echo " 3. Tap 'Sync All'" 519 + echo " → should pull server + desktop items" 520 + echo " → should push iOS items to server" 521 + echo " 4. Check expected total: $(($SERVER_COUNT + $DESKTOP_LOCAL + $IOS_COUNT)) items" 522 + fi 435 523 echo "" 436 524 echo " Verify server items:" 437 525 echo " curl -s 'http://localhost:$PORT/items?profile=$SERVER_PROFILE_ID' \\" ··· 444 532 445 533 # --- Open Xcode (now that everything is ready) --- 446 534 447 - echo "Opening Xcode... Build & Run (⌘R), then tap 'Sync All' in the app." 448 - open "$XCODE_PROJECT" 449 - echo "" 535 + if [ "$HEADLESS" = true ]; then 536 + echo "[HEADLESS] Skipping Xcode open. Ensure iOS app is already built and installed." 537 + echo "[HEADLESS] Relaunching iOS app with PEEK_AUTO_SYNC=true..." 538 + relaunch_ios_app "pick up fresh test profile + auto-sync" "true" 539 + echo "" 540 + else 541 + echo "Opening Xcode... Build & Run (⌘R), then tap 'Sync All' in the app." 542 + open "$XCODE_PROJECT" 543 + echo "" 544 + fi 450 545 451 546 # --- Poll server until iOS items appear (or timeout) --- 452 547 ··· 715 810 echo " 6 original + 2 cross-device URLs + 2 cross-device tagsets" 716 811 echo " (No content dedup — each device's copy is a separate item)" 717 812 echo "" 718 - echo " Please tap 'Sync All' in the iOS simulator." 719 - echo " Polling server for 10 items..." 813 + if [ "$HEADLESS" = true ]; then 814 + echo " [HEADLESS] Relaunching iOS app to pick up seeded items and sync..." 815 + relaunch_ios_app "pick up Phase 2 seeded items + sync" "true" 816 + echo " [HEADLESS] Polling server for 10 items..." 817 + else 818 + echo " Please tap 'Sync All' in the iOS simulator." 819 + echo " Polling server for 10 items..." 820 + fi 720 821 echo "==========================================" 721 822 echo "" 722 823 ··· 1055 1156 1056 1157 echo "" 1057 1158 echo "Step 24: iOS dedup verification..." 1058 - echo " Please force-quit and relaunch the iOS app in the simulator." 1159 + if [ "$HEADLESS" = true ]; then 1160 + echo " [HEADLESS] Auto-relaunching iOS app to trigger dedup migration..." 1161 + relaunch_ios_app "trigger dedup migration" 1162 + else 1163 + echo " Please force-quit and relaunch the iOS app in the simulator." 1164 + fi 1059 1165 echo " The dedup migration runs in ensure_database_initialized() on startup." 1060 1166 echo " Polling iOS database for dedup_cleanup_v1 flag..." 1061 1167 ··· 1200 1306 -H "X-Peek-Protocol-Version: 1" | python3 -c " 1201 1307 import sys, json 1202 1308 items = json.load(sys.stdin)['items'] 1203 - tombstones = [i for i in items if i.get('deleted_at', 0) > 0] 1309 + tombstones = [i for i in items if i.get('deletedAt', 0) > 0] 1204 1310 print(len(tombstones)) 1205 1311 ") 1206 1312 echo " Server tombstones: $SERVER_TOMBSTONE" ··· 1216 1322 1217 1323 echo "" 1218 1324 echo "==========================================" 1219 - echo " Please tap 'Sync All' in the iOS simulator to pull the tombstone." 1325 + if [ "$HEADLESS" = true ]; then 1326 + echo " [HEADLESS] Relaunching iOS app with auto-sync to pull tombstone..." 1327 + relaunch_ios_app "pull desktop deletion tombstone" "true" 1328 + else 1329 + echo " Please tap 'Sync All' in the iOS simulator to pull the tombstone." 1330 + fi 1220 1331 echo " Polling iOS database for the deleted item..." 1221 1332 echo "==========================================" 1222 1333 ··· 1247 1358 # 1248 1359 # We must terminate the iOS app BEFORE modifying the database externally. 1249 1360 # The running app has its own SQLite connection (WAL mode) and won't see 1250 - # external writes. After modifying, we checkpoint the WAL and ask the 1251 - # user to relaunch. 1361 + # external writes. After modifying, we checkpoint the WAL and relaunch. 1252 1362 1253 1363 echo "" 1254 1364 echo "Step 29: Terminating iOS app before modifying database..." 1255 - xcrun simctl terminate booted com.dietrich.peek-mobile 2>/dev/null || true 1365 + xcrun simctl terminate booted "$IOS_BUNDLE_ID" 2>/dev/null || true 1256 1366 sleep 2 1257 1367 1258 1368 # Pick an iOS-origin item to delete ··· 1276 1386 1277 1387 echo "" 1278 1388 echo "==========================================" 1279 - echo " Please relaunch the iOS app in the simulator and tap 'Sync All'" 1280 - echo " to push the tombstone. (App was terminated to pick up DB changes.)" 1389 + if [ "$HEADLESS" = true ]; then 1390 + echo " [HEADLESS] Relaunching iOS app with PEEK_AUTO_SYNC=true to push tombstone..." 1391 + relaunch_ios_app "pick up DB changes + auto-sync tombstone" "true" 1392 + else 1393 + echo " Please relaunch the iOS app in the simulator and tap 'Sync All'" 1394 + echo " to push the tombstone. (App was terminated to pick up DB changes.)" 1395 + fi 1281 1396 echo " Polling server for updated deletion count..." 1282 1397 echo "==========================================" 1283 1398 ··· 1293 1408 -H "X-Peek-Protocol-Version: 1" 2>/dev/null | python3 -c " 1294 1409 import sys, json 1295 1410 items = json.load(sys.stdin)['items'] 1296 - tombstones = [i for i in items if i.get('deleted_at', 0) > 0] 1411 + tombstones = [i for i in items if i.get('deletedAt', 0) > 0] 1297 1412 print(len(tombstones)) 1298 1413 " 2>/dev/null || echo "0") 1299 1414 if [ "$CURRENT_TOMBS" -ge "$EXPECTED_TOMBSTONES" ] 2>/dev/null; then ··· 1341 1456 -H "X-Peek-Protocol-Version: 1" | python3 -c " 1342 1457 import sys, json 1343 1458 items = json.load(sys.stdin)['items'] 1344 - active = len([i for i in items if i.get('deleted_at', 0) == 0]) 1345 - deleted = len([i for i in items if i.get('deleted_at', 0) > 0]) 1459 + active = len([i for i in items if i.get('deletedAt', 0) == 0]) 1460 + deleted = len([i for i in items if i.get('deletedAt', 0) > 0]) 1346 1461 print(f'{active} active, {deleted} deleted') 1347 1462 ") 1348 1463 echo " Server: $SERVER_FINAL_ALL"
+130 -115
tests/desktop/smoke.spec.ts
··· 9 9 * BACKEND=tauri yarn test:desktop 10 10 */ 11 11 12 - import { test, expect, DesktopApp, launchDesktopApp } from '../fixtures/desktop-app'; 12 + import { test, expect, DesktopApp, launchDesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 13 13 import { Page } from '@playwright/test'; 14 14 import path from 'path'; 15 15 import { fileURLToPath } from 'url'; 16 16 import { spawn } from 'child_process'; 17 - import { waitForCommandResults, waitForWindowCount, waitForVisible, waitForClass, waitForResultsWithContent, waitForSelectionChange, sleep } from '../helpers/window-utils'; 17 + import { waitForCommandResults, waitForWindowCount, waitForVisible, waitForClass, waitForResultsWithContent, waitForSelectionChange, sleep, waitForWindow, waitForExtensionsReady, queryCommandsWithRetry, waitForAppReady } from '../helpers/window-utils'; 18 18 19 19 const __filename = fileURLToPath(import.meta.url); 20 20 const __dirname = path.dirname(__filename); 21 21 const ROOT = path.join(__dirname, '../..'); 22 22 23 23 // ============================================================================ 24 - // Settings Tests 24 + // SHARED APP INSTANCE 25 + // Most tests use a single shared app to avoid startup overhead. 26 + // Only tests that need fresh state or test lifecycle use isolated instances. 25 27 // ============================================================================ 26 28 27 - test.describe('Settings @desktop', () => { 28 - let app: DesktopApp; 29 + // Shared app and window for tests that don't need isolation 30 + let sharedApp: DesktopApp; 31 + let sharedBgWindow: Page; 29 32 30 - test.beforeAll(async () => { 31 - app = await launchDesktopApp('test-settings'); 32 - }); 33 + // Initialize shared app once before all tests 34 + test.beforeAll(async () => { 35 + sharedApp = await getSharedApp(); 36 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 37 + await waitForExtensionsReady(sharedBgWindow); 38 + }); 33 39 34 - test.afterAll(async () => { 35 - if (app) await app.close(); 36 - }); 40 + // Clean up shared app after all tests 41 + test.afterAll(async () => { 42 + await closeSharedApp(); 43 + }); 37 44 45 + // ============================================================================ 46 + // Settings Tests (uses shared app) 47 + // ============================================================================ 48 + 49 + test.describe('Settings @desktop', () => { 38 50 test('open and close settings', async () => { 39 51 // Settings opens on start in debug mode 40 - const settingsWindow = await app.getWindow('settings/settings.html'); 52 + const settingsWindow = await sharedApp.getWindow('settings/settings.html'); 41 53 expect(settingsWindow).toBeTruthy(); 42 54 43 55 // Verify content loaded ··· 51 63 }); 52 64 53 65 // ============================================================================ 54 - // Command Palette Tests 66 + // Command Palette Tests (uses shared app) 55 67 // ============================================================================ 56 68 57 69 test.describe('Cmd Palette @desktop', () => { 58 - let app: DesktopApp; 59 - let bgWindow: Page; 60 - 61 - test.beforeAll(async () => { 62 - app = await launchDesktopApp('test-cmd'); 63 - bgWindow = await app.getBackgroundWindow(); 64 - }); 65 - 66 - test.afterAll(async () => { 67 - if (app) await app.close(); 68 - }); 69 - 70 70 test('open cmd and execute gallery command', async () => { 71 71 // Open cmd panel via window API 72 - const openResult = await bgWindow.evaluate(async () => { 72 + const openResult = await sharedBgWindow.evaluate(async () => { 73 73 return await (window as any).app.window.open('peek://app/cmd/panel.html', { 74 74 modal: true, 75 75 width: 600, ··· 83 83 expect(openResult.success).toBe(true); 84 84 85 85 // Find the cmd window (getWindow already polls until found) 86 - const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 86 + const cmdWindow = await sharedApp.getWindow('cmd/panel.html', 5000); 87 87 expect(cmdWindow).toBeTruthy(); 88 88 89 89 // Wait for input to be ready ··· 98 98 99 99 // Close the cmd window 100 100 if (openResult.id) { 101 - await bgWindow.evaluate(async (id: number) => { 101 + await sharedBgWindow.evaluate(async (id: number) => { 102 102 return await (window as any).app.window.close(id); 103 103 }, openResult.id); 104 104 } ··· 106 106 }); 107 107 108 108 // ============================================================================ 109 - // Peeks Tests 109 + // Peeks Tests (uses shared app) 110 110 // ============================================================================ 111 111 112 112 test.describe('Peeks @desktop', () => { 113 - let app: DesktopApp; 114 - let bgWindow: Page; 115 - 116 - test.beforeAll(async () => { 117 - app = await launchDesktopApp('test-peeks'); 118 - bgWindow = await app.getBackgroundWindow(); 119 - }); 120 - 121 - test.afterAll(async () => { 122 - if (app) await app.close(); 123 - }); 124 - 125 113 test('add a peek and test it opens', async () => { 126 114 // Add a peek address to the datastore 127 - const addResult = await bgWindow.evaluate(async () => { 115 + const addResult = await sharedBgWindow.evaluate(async () => { 128 116 return await (window as any).app.datastore.addAddress('https://example.com', { 129 117 title: 'Example Peek', 130 118 description: 'Test peek for smoke tests' ··· 133 121 expect(addResult.success).toBe(true); 134 122 135 123 // Verify peeks extension is loaded (hybrid mode: may be iframe or separate window) 136 - const runningExts = await bgWindow.evaluate(async () => { 124 + const runningExts = await sharedBgWindow.evaluate(async () => { 137 125 return await (window as any).app.extensions.list(); 138 126 }); 139 127 const peeksRunning = runningExts.data?.some((ext: any) => ext.id === 'peeks'); 140 128 expect(peeksRunning).toBe(true); 141 129 142 130 // Open a peek window for the address we created 143 - const peekResult = await bgWindow.evaluate(async () => { 131 + const peekResult = await sharedBgWindow.evaluate(async () => { 144 132 return await (window as any).app.window.open('https://example.com', { 145 133 width: 800, 146 134 height: 600, ··· 150 138 expect(peekResult.success).toBe(true); 151 139 152 140 // Wait for window to open (getWindow polls) 153 - const peekWindow = await app.getWindow('example.com', 5000); 141 + const peekWindow = await sharedApp.getWindow('example.com', 5000); 154 142 expect(peekWindow).toBeTruthy(); 155 143 156 144 // Close the peek 157 145 if (peekResult.id) { 158 - await bgWindow.evaluate(async (id: number) => { 146 + await sharedBgWindow.evaluate(async (id: number) => { 159 147 return await (window as any).app.window.close(id); 160 148 }, peekResult.id); 161 149 } ··· 163 151 }); 164 152 165 153 // ============================================================================ 166 - // Slides Tests 154 + // Slides Tests (uses shared app) 167 155 // ============================================================================ 168 156 169 157 test.describe('Slides @desktop', () => { 170 - let app: DesktopApp; 171 - let bgWindow: Page; 172 - 173 - test.beforeAll(async () => { 174 - app = await launchDesktopApp('test-slides'); 175 - bgWindow = await app.getBackgroundWindow(); 176 - }); 177 - 178 - test.afterAll(async () => { 179 - if (app) await app.close(); 180 - }); 181 - 182 158 test('add slides and test they work', async () => { 183 159 // Add multiple addresses to use as slides 184 160 const urls = [ ··· 188 164 ]; 189 165 190 166 for (const url of urls) { 191 - const result = await bgWindow.evaluate(async (uri: string) => { 167 + const result = await sharedBgWindow.evaluate(async (uri: string) => { 192 168 return await (window as any).app.datastore.addAddress(uri, { 193 169 title: `Slide: ${uri}`, 194 170 starred: 1 ··· 198 174 } 199 175 200 176 // Verify slides extension is loaded (hybrid mode: may be iframe or separate window) 201 - const runningExts = await bgWindow.evaluate(async () => { 177 + const runningExts = await sharedBgWindow.evaluate(async () => { 202 178 return await (window as any).app.extensions.list(); 203 179 }); 204 180 const slidesRunning = runningExts.data?.some((ext: any) => ext.id === 'slides'); 205 181 expect(slidesRunning).toBe(true); 206 182 207 183 // Query addresses to verify they were added 208 - const queryResult = await bgWindow.evaluate(async () => { 184 + const queryResult = await sharedBgWindow.evaluate(async () => { 209 185 return await (window as any).app.datastore.queryAddresses({ starred: 1, limit: 10 }); 210 186 }); 211 187 expect(queryResult.success).toBe(true); ··· 367 343 // Launch app (fixture already waits for background and extensions) 368 344 const app = await launchDesktopApp('test-external-url'); 369 345 370 - // Verify app started correctly (fixture already ensured this) 371 - const bgWindow = app.windows().find(w => w.url().includes('background.html')); 346 + // Wait for background window to be fully ready, not just present 347 + const bgWindow = await app.getBackgroundWindow(); 372 348 expect(bgWindow).toBeTruthy(); 373 349 350 + // Ensure the API is ready before closing 351 + await waitForAppReady(bgWindow); 352 + 374 353 await app.close(); 375 354 }); 376 355 }); ··· 529 508 }, { addressId: addr1.id, tagId }); 530 509 } 531 510 511 + // Ensure data is flushed before closing 512 + await sleep(500); 532 513 await app.close(); 533 514 515 + // Wait for app to fully shut down before relaunching 516 + await sleep(1000); 517 + 534 518 // PHASE 2: Verify persistence 535 519 app = await launchDesktopApp(ADDR_PROFILE); 536 520 bgWindow = await app.getBackgroundWindow(); 521 + 522 + // Wait for extensions to be ready after relaunch 523 + await waitForExtensionsReady(bgWindow); 537 524 538 525 // Query addresses 539 526 const tableResult = await bgWindow.evaluate(async () => { ··· 574 561 test.beforeAll(async () => { 575 562 app = await launchDesktopApp('test-core'); 576 563 bgWindow = await app.getBackgroundWindow(); 564 + // Wait for extensions to be fully ready before running tests 565 + await waitForExtensionsReady(bgWindow); 577 566 }); 578 567 579 568 test.afterAll(async () => { ··· 584 573 // In hybrid mode: 585 574 // - Built-in extensions (groups, peeks, slides) are in extension host as iframes 586 575 // - External extensions (example) are in separate windows 587 - const windows = app.windows(); 576 + 577 + // Wait for extension windows to be available with retry 578 + let windows = app.windows(); 579 + let extWindows = app.getExtensionWindows(); 580 + 581 + // Retry logic for extension windows to be fully loaded 582 + const start = Date.now(); 583 + while (extWindows.length < 1 && Date.now() - start < 10000) { 584 + await sleep(200); 585 + windows = app.windows(); 586 + extWindows = app.getExtensionWindows(); 587 + } 588 588 589 589 // Check extension host exists (for built-in extensions) 590 590 const hostWindow = windows.find(w => w.url().includes('extension-host.html')); 591 591 expect(hostWindow).toBeDefined(); 592 592 593 - // Check external extension window exists (example) 594 - const extWindows = app.getExtensionWindows(); 595 - expect(extWindows.length).toBeGreaterThanOrEqual(1); 596 - expect(extWindows.some(w => w.url().includes('ext/example'))).toBe(true); 593 + // Check external extension window exists (example) using waitForWindow for reliability 594 + const exampleWindow = await waitForWindow( 595 + () => app.windows(), 596 + 'peek://ext/example/background.html', 597 + 15000 598 + ); 599 + expect(exampleWindow).toBeDefined(); 597 600 }); 598 601 599 602 test('database is accessible', async () => { ··· 2020 2023 test.beforeAll(async () => { 2021 2024 app = await launchDesktopApp('test-startup-phases'); 2022 2025 bgWindow = await app.getBackgroundWindow(); 2026 + // Wait for extensions to be fully ready 2027 + await waitForExtensionsReady(bgWindow); 2023 2028 }); 2024 2029 2025 2030 test.afterAll(async () => { ··· 2068 2073 2069 2074 test('cmd extension loads before other extensions can register commands', async () => { 2070 2075 // Verify that cmd is running and accepting commands (which means it loaded first) 2076 + // Use inline retry approach that works reliably 2071 2077 const result = await bgWindow.evaluate(async () => { 2072 2078 const api = (window as any).app; 2073 2079 2074 - // Query commands - if we get a response, cmd is running and initialized 2075 - return new Promise((resolve) => { 2076 - const timeout = setTimeout(() => { 2077 - resolve({ cmdResponded: false, commandCount: 0 }); 2078 - }, 2000); 2079 - 2080 + const queryCommands = () => new Promise((resolve) => { 2080 2081 api.subscribe('cmd:query-commands-response', (msg: any) => { 2081 - clearTimeout(timeout); 2082 - resolve({ 2083 - cmdResponded: true, 2084 - commandCount: msg.commands?.length || 0, 2085 - hasGalleryCommand: msg.commands?.some((c: any) => c.name === 'example:gallery') 2086 - }); 2082 + resolve(msg.commands || []); 2087 2083 }, api.scopes.GLOBAL); 2088 - 2089 2084 api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 2085 + setTimeout(() => resolve([]), 1000); 2090 2086 }); 2087 + 2088 + // Retry a few times to allow extensions to finish loading 2089 + for (let i = 0; i < 5; i++) { 2090 + const cmds = await queryCommands() as any[]; 2091 + if (cmds.some((c: any) => c.name === 'example:gallery')) { 2092 + return cmds; 2093 + } 2094 + await new Promise(r => setTimeout(r, 500)); 2095 + } 2096 + return await queryCommands(); 2091 2097 }); 2092 2098 2093 - expect(result.cmdResponded).toBe(true); 2094 - expect(result.commandCount).toBeGreaterThan(0); 2099 + expect(Array.isArray(result)).toBe(true); 2100 + expect(result.length).toBeGreaterThan(0); 2095 2101 // gallery command from example extension should be registered 2096 - expect(result.hasGalleryCommand).toBe(true); 2102 + const hasGalleryCommand = result.some((c: any) => c.name === 'example:gallery'); 2103 + expect(hasGalleryCommand).toBe(true); 2097 2104 }); 2098 2105 2099 2106 test('cmd extension is always running (cannot be disabled)', async () => { ··· 2146 2153 const hostWindow = windows.find(w => w.url().includes('peek://app/extension-host.html')); 2147 2154 expect(hostWindow).toBeDefined(); 2148 2155 2149 - // Wait for iframes to load (with retry) 2150 - const iframeData = await hostWindow!.evaluate(async () => { 2151 - const maxWait = 10000; 2152 - const start = Date.now(); 2153 - while (Date.now() - start < maxWait) { 2156 + // Wait for #extensions container to exist (it may be hidden, so use 'attached' state) 2157 + await hostWindow!.waitForSelector('#extensions', { timeout: 15000, state: 'attached' }); 2158 + 2159 + // Wait for at least 5 iframes to load (cmd, groups, peeks, slides, windows) 2160 + await hostWindow!.waitForFunction( 2161 + () => { 2154 2162 const container = document.getElementById('extensions'); 2155 - const iframes = container ? Array.from(container.querySelectorAll('iframe')) : []; 2156 - // Built-in extensions: cmd, groups, peeks, slides, windows (5 total) 2157 - if (iframes.length >= 5) { 2158 - return { 2159 - count: iframes.length, 2160 - srcs: iframes.map(f => f.src) 2161 - }; 2162 - } 2163 - await new Promise(r => setTimeout(r, 200)); 2164 - } 2163 + const iframes = container ? container.querySelectorAll('iframe') : []; 2164 + return iframes.length >= 5; 2165 + }, 2166 + { timeout: 15000 } 2167 + ); 2168 + 2169 + // Now get the iframe data 2170 + const iframeData = await hostWindow!.evaluate(() => { 2165 2171 const container = document.getElementById('extensions'); 2166 2172 const iframes = container ? Array.from(container.querySelectorAll('iframe')) : []; 2167 2173 return { ··· 2170 2176 }; 2171 2177 }); 2172 2178 2173 - // Should have iframes for cmd, groups, peeks, slides, windows (5 built-in extensions) 2174 - expect(iframeData.count).toBe(5); 2179 + // Should have iframes for built-in extensions (6: cmd, groups, peeks, slides, windows, settings) 2180 + expect(iframeData.count).toBeGreaterThanOrEqual(5); 2175 2181 expect(iframeData.srcs.some(s => s.includes('peek://cmd/'))).toBe(true); 2176 2182 expect(iframeData.srcs.some(s => s.includes('peek://groups/'))).toBe(true); 2177 2183 expect(iframeData.srcs.some(s => s.includes('peek://peeks/'))).toBe(true); ··· 2181 2187 2182 2188 test('example extension loads as separate window (external)', async () => { 2183 2189 // Example extension should load in its own window, not in extension host 2184 - const windows = app.windows(); 2185 - 2186 - // Should have a separate window for example extension 2187 - const exampleWindow = windows.find(w => 2188 - w.url().includes('peek://ext/example/background.html') 2190 + // Use waitForWindow helper with retry logic 2191 + const exampleWindow = await waitForWindow( 2192 + () => app.windows(), 2193 + 'peek://ext/example/background.html', 2194 + 15000 2189 2195 ); 2190 2196 expect(exampleWindow).toBeDefined(); 2191 2197 }); ··· 2290 2296 // - 1 extension host window (consolidated built-ins) 2291 2297 // - 1 separate window for 'example' extension 2292 2298 // - Plus any UI windows (settings, etc.) 2299 + 2300 + // Wait for example extension window to be present before counting 2301 + await waitForWindow( 2302 + () => app.windows(), 2303 + 'peek://ext/example/background.html', 2304 + 15000 2305 + ); 2306 + 2293 2307 const windows = app.windows(); 2294 2308 2295 2309 const bgWindows = windows.filter(w => w.url().includes('app/background.html')); 2296 2310 const hostWindows = windows.filter(w => w.url().includes('extension-host.html')); 2297 - const extWindows = windows.filter(w => 2298 - w.url().includes('peek://ext/') && w.url().includes('background.html') 2311 + // Filter to only count 'example' extension windows (the only external extension) 2312 + const exampleExtWindows = windows.filter(w => 2313 + w.url().includes('peek://ext/example/') && w.url().includes('background.html') 2299 2314 ); 2300 2315 2301 2316 expect(bgWindows.length).toBe(1); 2302 2317 expect(hostWindows.length).toBe(1); 2303 2318 // Only example should be in separate window 2304 - expect(extWindows.length).toBe(1); 2305 - expect(extWindows[0].url()).toContain('example'); 2319 + expect(exampleExtWindows.length).toBe(1); 2320 + expect(exampleExtWindows[0].url()).toContain('example'); 2306 2321 }); 2307 2322 }); 2308 2323 ··· 2432 2447 const testApp = await launchDesktopApp(profileName); 2433 2448 2434 2449 try { 2435 - // Wait for extensions to fully load 2436 - await sleep(1000); 2437 - 2438 2450 const testWindow = await testApp.getBackgroundWindow(); 2451 + 2452 + // Wait for extensions to be fully initialized using proper wait helper 2453 + await waitForExtensionsReady(testWindow, 15000); 2439 2454 2440 2455 // Verify cmd loaded the custom settings on startup 2441 2456 // We update settings with the same value and verify it was already set ··· 2445 2460 return new Promise((resolve) => { 2446 2461 const timeout = setTimeout(() => { 2447 2462 resolve({ success: false, error: 'timeout' }); 2448 - }, 5000); 2463 + }, 10000); 2449 2464 2450 2465 api.subscribe('cmd:settings-changed', (msg: any) => { 2451 2466 clearTimeout(timeout);
+140 -5
tests/fixtures/desktop-app.ts
··· 213 213 const extWindows = electronApp.windows().filter(w => 214 214 w.url().includes('peek://ext/') && w.url().includes('background.html') 215 215 ); 216 - // Ready when we have both host and at least one external 217 - if (hostWindow && extWindows.length >= 1) return; 216 + 217 + // Need both host and at least one external extension 218 + if (!hostWindow || extWindows.length < 1) { 219 + await sleep(100); 220 + continue; 221 + } 222 + 223 + // Verify host window's DOM is actually ready 224 + try { 225 + const hostReady = await hostWindow.evaluate(() => { 226 + return document.readyState === 'complete' && 227 + document.getElementById('extensions') !== null; 228 + }); 229 + if (!hostReady) { 230 + await sleep(100); 231 + continue; 232 + } 233 + } catch { 234 + await sleep(100); 235 + continue; 236 + } 237 + 238 + // Verify at least one external extension window is also ready 239 + let extReady = false; 240 + for (const extWin of extWindows) { 241 + try { 242 + const ready = await extWin.evaluate(() => document.readyState === 'complete'); 243 + if (ready) { 244 + extReady = true; 245 + break; 246 + } 247 + } catch { 248 + // Window not ready, continue checking others 249 + } 250 + } 251 + 252 + if (extReady) { 253 + return; // Success - both host and ext are ready 254 + } 255 + 218 256 await sleep(100); 219 257 } 258 + 259 + // Timeout reached - throw error with diagnostic info 260 + const windows = electronApp.windows(); 261 + const urls = windows.map(w => w.url()); 262 + throw new Error(`Hybrid extensions failed to load within ${timeout}ms. Windows: ${JSON.stringify(urls)}`); 220 263 }; 221 264 await waitForHybridExtensions(10000); 222 265 ··· 242 285 }, 243 286 244 287 close: async () => { 245 - await electronApp.close(); 246 - // Note: Temp directory cleanup happens via process exit handlers 247 - // This allows persistence tests to relaunch with same profile 288 + // Capture PID before any close attempts 289 + let pid: number | undefined; 290 + try { 291 + pid = electronApp.process().pid; 292 + } catch { /* process may already be gone */ } 293 + 294 + // Helper to check if process is still running 295 + const isRunning = (p: number): boolean => { 296 + try { 297 + process.kill(p, 0); // Signal 0 just checks if process exists 298 + return true; 299 + } catch { 300 + return false; 301 + } 302 + }; 303 + 304 + // Try graceful close first (3s) 305 + try { 306 + await Promise.race([ 307 + electronApp.close(), 308 + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)) 309 + ]); 310 + // Give it a moment to fully terminate 311 + await sleep(200); 312 + if (!pid || !isRunning(pid)) return; 313 + } catch { /* continue to force kill */ } 314 + 315 + if (!pid) return; 316 + 317 + // Try SIGTERM (2s wait) 318 + if (isRunning(pid)) { 319 + console.warn(`[test] Graceful close timed out for PID ${pid}, sending SIGTERM`); 320 + try { 321 + process.kill(pid, 'SIGTERM'); 322 + } catch { /* ignore */ } 323 + await sleep(2000); 324 + } 325 + 326 + // Last resort: SIGKILL 327 + if (isRunning(pid)) { 328 + console.warn(`[test] SIGTERM failed for PID ${pid}, sending SIGKILL`); 329 + try { 330 + process.kill(pid, 'SIGKILL'); 331 + } catch { /* ignore */ } 332 + await sleep(500); 333 + } 334 + 335 + // Final verification 336 + if (isRunning(pid)) { 337 + console.error(`[test] WARNING: Process ${pid} still running after SIGKILL`); 338 + } 248 339 } 249 340 }; 250 341 } ··· 465 556 return launchTauriFrontend(testProfile); 466 557 } else { 467 558 throw new Error(`Unknown backend: ${backend}. Use BACKEND=electron or BACKEND=tauri`); 559 + } 560 + } 561 + 562 + // ==================== Shared Instance ==================== 563 + 564 + /** 565 + * Global shared app instance for tests that don't need isolation. 566 + * Use getSharedApp() to get or create, closeSharedApp() to cleanup. 567 + */ 568 + let sharedApp: DesktopApp | null = null; 569 + let sharedAppPromise: Promise<DesktopApp> | null = null; 570 + 571 + /** 572 + * Get or create a shared app instance. 573 + * Most tests can use this instead of launching their own instance. 574 + * Only use launchDesktopApp() for tests that need: 575 + * - Fresh database state 576 + * - App restart/lifecycle testing 577 + * - Specific profile configuration 578 + */ 579 + export async function getSharedApp(): Promise<DesktopApp> { 580 + if (sharedApp) { 581 + return sharedApp; 582 + } 583 + 584 + // Prevent multiple concurrent launches 585 + if (sharedAppPromise) { 586 + return sharedAppPromise; 587 + } 588 + 589 + sharedAppPromise = launchDesktopApp('shared-test-instance'); 590 + sharedApp = await sharedAppPromise; 591 + sharedAppPromise = null; 592 + return sharedApp; 593 + } 594 + 595 + /** 596 + * Close the shared app instance. 597 + * Call this in a global teardown or at the end of test file. 598 + */ 599 + export async function closeSharedApp(): Promise<void> { 600 + if (sharedApp) { 601 + await sharedApp.close(); 602 + sharedApp = null; 468 603 } 469 604 } 470 605
+176
tests/helpers/window-utils.ts
··· 206 206 { timeout } 207 207 ); 208 208 } 209 + 210 + // ============================================================================ 211 + // Extension Waiting Helpers 212 + // ============================================================================ 213 + 214 + interface ExtensionInfo { 215 + id: string; 216 + status: string; 217 + } 218 + 219 + interface ExtensionListResult { 220 + success: boolean; 221 + data?: ExtensionInfo[]; 222 + } 223 + 224 + interface AppApi { 225 + extensions: { 226 + list(): Promise<ExtensionListResult>; 227 + }; 228 + subscribe(event: string, callback: (msg: unknown) => void, scope: unknown): () => void; 229 + publish(event: string, data: unknown, scope: unknown): void; 230 + scopes: { 231 + GLOBAL: unknown; 232 + }; 233 + } 234 + 235 + interface WindowWithApp extends Window { 236 + app: AppApi; 237 + } 238 + 239 + /** 240 + * Wait for all extensions to be initialized and ready 241 + */ 242 + export async function waitForExtensionsReady( 243 + bgWindow: Page, 244 + timeout = 10000 245 + ): Promise<void> { 246 + await bgWindow.waitForFunction( 247 + async () => { 248 + const api = (window as unknown as WindowWithApp).app; 249 + if (!api || !api.extensions) return false; 250 + 251 + const result = await api.extensions.list(); 252 + if (!result.success || !result.data) return false; 253 + 254 + // Check if critical extensions are running 255 + const hasCmd = result.data.some( 256 + (e: ExtensionInfo) => e.id === 'cmd' && e.status === 'running' 257 + ); 258 + const extensionCount = result.data.length; 259 + 260 + return hasCmd && extensionCount >= 3; // At least cmd + 2 others 261 + }, 262 + { timeout } 263 + ); 264 + } 265 + 266 + /** 267 + * Wait for specific event to be published via pubsub 268 + */ 269 + export async function waitForPubsubEvent( 270 + bgWindow: Page, 271 + eventName: string, 272 + timeout = 5000 273 + ): Promise<unknown> { 274 + return bgWindow.evaluate( 275 + async ([event, timeoutMs]) => { 276 + return new Promise((resolve, reject) => { 277 + const t = setTimeout(() => { 278 + reject(new Error(`Event ${event} not received within ${timeoutMs}ms`)); 279 + }, timeoutMs); 280 + 281 + const api = (window as unknown as WindowWithApp).app; 282 + const unsub = api.subscribe( 283 + event, 284 + (msg: unknown) => { 285 + clearTimeout(t); 286 + unsub(); 287 + resolve(msg); 288 + }, 289 + api.scopes.GLOBAL 290 + ); 291 + }); 292 + }, 293 + [eventName, timeout] as [string, number] 294 + ); 295 + } 296 + 297 + interface CommandInfo { 298 + name: string; 299 + } 300 + 301 + interface QueryCommandsResponse { 302 + commands?: CommandInfo[]; 303 + } 304 + 305 + /** 306 + * Wait for command to be available in cmd extension 307 + */ 308 + export async function waitForCommand( 309 + bgWindow: Page, 310 + commandName: string, 311 + timeout = 10000 312 + ): Promise<void> { 313 + const startTime = Date.now(); 314 + while (Date.now() - startTime < timeout) { 315 + const found = await bgWindow.evaluate(async (cmd) => { 316 + const api = (window as unknown as WindowWithApp).app; 317 + return new Promise((resolve) => { 318 + const unsub = api.subscribe( 319 + 'cmd:query-commands-response', 320 + (msg: unknown) => { 321 + unsub(); 322 + const response = msg as QueryCommandsResponse; 323 + resolve(response.commands?.some((c) => c.name === cmd) || false); 324 + }, 325 + api.scopes.GLOBAL 326 + ); 327 + 328 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 329 + setTimeout(() => resolve(false), 500); 330 + }); 331 + }, commandName); 332 + if (found) return; 333 + await sleep(200); 334 + } 335 + throw new Error(`Command "${commandName}" not found within ${timeout}ms`); 336 + } 337 + 338 + /** 339 + * Query commands with retry logic for reliability. 340 + * Retry loop is inside evaluate to avoid subscription issues across page boundary. 341 + */ 342 + export async function queryCommandsWithRetry( 343 + bgWindow: Page, 344 + retries = 5, 345 + delayMs = 500 346 + ): Promise<CommandInfo[]> { 347 + const commands = await bgWindow.evaluate( 348 + async ([maxRetries, delay]) => { 349 + const api = (window as unknown as WindowWithApp).app; 350 + 351 + const queryCommands = () => 352 + new Promise<CommandInfo[] | null>((resolve) => { 353 + const unsub = api.subscribe( 354 + 'cmd:query-commands-response', 355 + (msg: unknown) => { 356 + unsub(); 357 + const response = msg as QueryCommandsResponse; 358 + resolve((response.commands as CommandInfo[]) || []); 359 + }, 360 + api.scopes.GLOBAL 361 + ); 362 + 363 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 364 + setTimeout(() => resolve(null), 1000); 365 + }); 366 + 367 + // Retry loop inside evaluate to keep subscriptions in same JS context 368 + for (let i = 0; i < maxRetries; i++) { 369 + const cmds = await queryCommands(); 370 + if (cmds && cmds.length > 0) { 371 + return cmds; 372 + } 373 + await new Promise((r) => setTimeout(r, delay)); 374 + } 375 + return []; 376 + }, 377 + [retries, delayMs] as const 378 + ); 379 + 380 + if (!commands || commands.length === 0) { 381 + throw new Error(`Failed to query commands after ${retries} attempts`); 382 + } 383 + return commands as CommandInfo[]; 384 + }