diff --git a/app/cmd/panel.js b/app/cmd/panel.js index 3b134099..33167303 100644 --- a/app/cmd/panel.js +++ b/app/cmd/panel.js @@ -523,8 +523,8 @@ function isModifier(e) { function getValidURL(str) { if (!str) return { valid: false }; - // Check if it starts with a valid protocol - const hasValidProtocol = /^(https?|ftp|file):\/\//.test(str); + // Check if it starts with a valid protocol (including peek:// for internal pages) + const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str); if (!hasValidProtocol) { // Check if it looks like a domain (e.g., "example.com" or "localhost") diff --git a/app/diagnostic.html b/app/diagnostic.html new file mode 100644 index 00000000..f8f51202 --- /dev/null +++ b/app/diagnostic.html @@ -0,0 +1,133 @@ + + + + Peek Diagnostic + + + +

Peek Diagnostic Tool

+

This page helps inspect localStorage and datastore contents.

+ +
+ + + + +
+ +
+ + + + diff --git a/backend/electron/entry.ts b/backend/electron/entry.ts index 5c56e2a4..717b71bc 100644 --- a/backend/electron/entry.ts +++ b/backend/electron/entry.ts @@ -60,8 +60,8 @@ unhandled({ } }); -// Get the root directory (two levels up from dist/backend/electron/) -const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..', '..'); +// Get the root directory - app.getAppPath() works in both dev and packaged modes +const ROOT_DIR = app.getAppPath(); const DEBUG = !!process.env.DEBUG; diff --git a/backend/electron/ipc.ts b/backend/electron/ipc.ts index 69df4ba3..0a591104 100644 --- a/backend/electron/ipc.ts +++ b/backend/electron/ipc.ts @@ -193,10 +193,11 @@ export function registerDatastoreHandlers(): void { ipcMain.handle('datastore-get-table', async (ev, data) => { try { - if (!isValidTable(data.table)) { - return { success: false, error: `Invalid table: ${data.table}` }; + const tableName = data.tableName || data.table; + if (!isValidTable(tableName)) { + return { success: false, error: `Invalid table: ${tableName}` }; } - const result = getTable(data.table); + const result = getTable(tableName); return { success: true, data: result }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -206,10 +207,13 @@ export function registerDatastoreHandlers(): void { ipcMain.handle('datastore-set-row', async (ev, data) => { try { - if (!isValidTable(data.table)) { - return { success: false, error: `Invalid table: ${data.table}` }; + const tableName = data.tableName || data.table; + const rowId = data.rowId || data.id; + const rowData = data.rowData || data.row; + if (!isValidTable(tableName)) { + return { success: false, error: `Invalid table: ${tableName}` }; } - const result = setRow(data.table, data.id, data.row); + const result = setRow(tableName, rowId, rowData); return { success: true, data: result }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/package.json b/package.json index 27d2806d..ab2a9252 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "smoke:visible": "./scripts/smoke-test.sh --visible", "test": "npx playwright test", "test:smoke": "npx playwright test tests/smoke.spec.ts", + "test:persistence": "npx playwright test tests/smoke.spec.ts --grep 'Data Persistence' --timeout=120000", "test:headed": "npx playwright test --headed", "test:debug": "npx playwright test --debug" }, diff --git a/scripts/debug-packaged.sh b/scripts/debug-packaged.sh new file mode 100755 index 00000000..9e2ca7ac --- /dev/null +++ b/scripts/debug-packaged.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Debug packaged app - runs the installed app with DEBUG output +# Usage: ./scripts/debug-packaged.sh [--visible] [--profile NAME] [duration_seconds] +# Example: ./scripts/debug-packaged.sh # 10 seconds, default profile +# Example: ./scripts/debug-packaged.sh --visible # 10 seconds, show UI +# Example: ./scripts/debug-packaged.sh --profile test # fresh test profile +# Example: ./scripts/debug-packaged.sh --visible --profile test 20 + +VISIBLE=0 +PROFILE="" + +while [[ "$1" == --* ]]; do + case "$1" in + --visible) + VISIBLE=1 + shift + ;; + --profile) + PROFILE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +DURATION=${1:-10} +LOGFILE="/tmp/peek-packaged-debug.log" + +cleanup() { + pkill -f "/Applications/Peek.app" 2>/dev/null || true +} + +trap cleanup EXIT + +# Kill any existing instances first +pkill -f "/Applications/Peek.app" 2>/dev/null || true +sleep 1 + +if [ -n "$PROFILE" ]; then + echo "Running packaged Peek.app with DEBUG for ${DURATION}s (profile: $PROFILE)..." +else + echo "Running packaged Peek.app with DEBUG for ${DURATION}s (default profile)..." +fi +echo "Log file: $LOGFILE" +echo "" + +if [ -n "$PROFILE" ]; then + PROFILE="$PROFILE" DEBUG=1 /Applications/Peek.app/Contents/MacOS/Peek > "$LOGFILE" 2>&1 & +else + DEBUG=1 /Applications/Peek.app/Contents/MacOS/Peek > "$LOGFILE" 2>&1 & +fi +PID=$! + +sleep "$DURATION" + +echo "=== Extension manifest loading ===" +grep -E "\[ext:win\].*Creating window" "$LOGFILE" || echo "(no extension window creation found)" +echo "" + +echo "=== Manifest details ===" +grep -E "manifest:" "$LOGFILE" | head -20 || echo "(no manifest details found)" +echo "" + +echo "=== Errors ===" +grep -iE "error|failed|cannot" "$LOGFILE" | head -20 || echo "(no errors found)" +echo "" + +echo "=== Full log available at: $LOGFILE ===" +echo "View with: cat $LOGFILE" diff --git a/scripts/test-dev.sh b/scripts/test-dev.sh new file mode 100755 index 00000000..73e8dca5 --- /dev/null +++ b/scripts/test-dev.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Test dev app - runs the dev app with a fresh profile +# Usage: ./scripts/test-dev.sh [--visible] [duration_seconds] +# Example: ./scripts/test-dev.sh # 8 seconds, headless +# Example: ./scripts/test-dev.sh --visible # 8 seconds, show UI +# Example: ./scripts/test-dev.sh --visible 15 + +VISIBLE=0 +if [ "$1" = "--visible" ]; then + VISIBLE=1 + shift +fi + +DURATION=${1:-8} +PROFILE="test-$$" +LOGFILE="/tmp/peek-dev-test.log" + +cleanup() { + pkill -f "/Users/dietrich/misc/peek/node_modules/electron" 2>/dev/null || true +} + +trap cleanup EXIT + +# Kill any existing dev instances +pkill -f "/Users/dietrich/misc/peek/node_modules/electron" 2>/dev/null || true +sleep 1 + +echo "Running dev Peek with test profile '$PROFILE' for ${DURATION}s..." +echo "Log file: $LOGFILE" +echo "" + +if [ "$VISIBLE" = "1" ]; then + PROFILE="$PROFILE" DEBUG=1 yarn start > "$LOGFILE" 2>&1 & +else + PROFILE="$PROFILE" DEBUG=1 PEEK_HEADLESS=1 yarn start > "$LOGFILE" 2>&1 & +fi +PID=$! + +sleep "$DURATION" + +echo "=== Errors ===" +grep -iE "error|failed|exception" "$LOGFILE" | grep -v "Autofill" | head -20 || echo "(no errors found)" +echo "" + +echo "=== Warnings ===" +grep -iE "warning|warn" "$LOGFILE" | head -10 || echo "(no warnings found)" +echo "" + +echo "=== Key events ===" +grep -E "onReady|Loading|loaded|register" "$LOGFILE" | head -20 +echo "" + +echo "=== Full log available at: $LOGFILE ===" diff --git a/scripts/test-persistence.sh b/scripts/test-persistence.sh new file mode 100755 index 00000000..38e9e1c3 --- /dev/null +++ b/scripts/test-persistence.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Run data persistence tests +# Usage: ./scripts/test-persistence.sh + +echo "Running data persistence tests..." +npx playwright test tests/smoke.spec.ts --grep "Data Persistence" --timeout=120000 + +echo "" +echo "Done. Test profiles created in ~/Library/Application Support/Peek/test-persistence-*" +echo "You can delete them with: rm -rf ~/Library/Application\\ Support/Peek/test-*" diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 80299035..6b8cb31b 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -18,7 +18,8 @@ import { spawn } from 'child_process'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.join(__dirname, '..'); -const MAIN_PATH = path.join(ROOT, 'index.js'); +// Pass the root directory - Electron will use package.json main field +const MAIN_PATH = ROOT; // Helper to wait for a window with specific URL pattern async function waitForWindow(app: ElectronApplication, urlPattern: string | RegExp, timeout = 10000): Promise { @@ -424,6 +425,224 @@ test.describe('External URL Opening', () => { }); }); +// Data Persistence Tests - verify user data survives app restart +test.describe('Data Persistence', () => { + const PERSISTENCE_PROFILE = 'test-persistence-' + Date.now(); + + test('peeks and slides settings persist across restart', async () => { + // PHASE 1: Launch app and add custom peeks/slides configuration + let electronApp = await electron.launch({ + args: [MAIN_PATH], + env: { ...process.env, PROFILE: PERSISTENCE_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' } + }); + await new Promise(r => setTimeout(r, 4000)); + + let bgWindow = await waitForWindow(electronApp, 'app/background.html'); + + // Add custom peek items to extension_settings + const testPeeks = [ + { title: 'Test Peek 1', uri: 'https://test-peek-1.example.com', shortcut: 'Option+1' }, + { title: 'Test Peek 2', uri: 'https://test-peek-2.example.com', shortcut: 'Option+2' }, + { title: 'Custom Peek', uri: 'https://custom-peek.example.com', shortcut: 'Option+3' } + ]; + + const testSlides = [ + { title: 'Test Slide 1', uri: 'https://test-slide-1.example.com', position: 'right', size: 400 }, + { title: 'Test Slide 2', uri: 'https://test-slide-2.example.com', position: 'bottom', size: 300 } + ]; + + // Save peeks items to extension_settings via datastore + const savePeeksResult = await bgWindow.evaluate(async (items) => { + const api = (window as any).app; + return await api.datastore.setRow('extension_settings', 'peeks:items', { + extensionId: 'peeks', + key: 'items', + value: JSON.stringify(items), + updatedAt: Date.now() + }); + }, testPeeks); + expect(savePeeksResult.success).toBe(true); + + // Save slides items to extension_settings + const saveSlidesResult = await bgWindow.evaluate(async (items) => { + const api = (window as any).app; + return await api.datastore.setRow('extension_settings', 'slides:items', { + extensionId: 'slides', + key: 'items', + value: JSON.stringify(items), + updatedAt: Date.now() + }); + }, testSlides); + expect(saveSlidesResult.success).toBe(true); + + // Also save custom prefs + const savePeeksPrefs = await bgWindow.evaluate(async () => { + const api = (window as any).app; + return await api.datastore.setRow('extension_settings', 'peeks:prefs', { + extensionId: 'peeks', + key: 'prefs', + value: JSON.stringify({ shortcutKeyPrefix: 'Option+' }), + updatedAt: Date.now() + }); + }); + expect(savePeeksPrefs.success).toBe(true); + + const saveSlidesPrefs = await bgWindow.evaluate(async () => { + const api = (window as any).app; + return await api.datastore.setRow('extension_settings', 'slides:prefs', { + extensionId: 'slides', + key: 'prefs', + value: JSON.stringify({ defaultPosition: 'right', defaultSize: 350 }), + updatedAt: Date.now() + }); + }); + expect(saveSlidesPrefs.success).toBe(true); + + // Verify data was saved + const verifyResult = await bgWindow.evaluate(async () => { + const api = (window as any).app; + return await api.datastore.getTable('extension_settings'); + }); + expect(verifyResult.success).toBe(true); + const savedRows = Object.values(verifyResult.data); + expect(savedRows.length).toBeGreaterThanOrEqual(4); + + // Close the app + await electronApp.close(); + await new Promise(r => setTimeout(r, 1000)); + + // PHASE 2: Relaunch with same profile and verify data persisted + electronApp = await electron.launch({ + args: [MAIN_PATH], + env: { ...process.env, PROFILE: PERSISTENCE_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' } + }); + await new Promise(r => setTimeout(r, 4000)); + + bgWindow = await waitForWindow(electronApp, 'app/background.html'); + + // Query extension_settings to verify persistence + const persistedResult = await bgWindow.evaluate(async () => { + const api = (window as any).app; + return await api.datastore.getTable('extension_settings'); + }); + expect(persistedResult.success).toBe(true); + + const persistedData = persistedResult.data as Record; + + // Verify peeks items persisted + const peeksItems = persistedData['peeks:items']; + expect(peeksItems).toBeTruthy(); + expect(peeksItems.extensionId).toBe('peeks'); + expect(peeksItems.key).toBe('items'); + const parsedPeeks = JSON.parse(peeksItems.value); + expect(parsedPeeks.length).toBe(3); + expect(parsedPeeks[0].title).toBe('Test Peek 1'); + expect(parsedPeeks[2].title).toBe('Custom Peek'); + + // Verify slides items persisted + const slidesItems = persistedData['slides:items']; + expect(slidesItems).toBeTruthy(); + expect(slidesItems.extensionId).toBe('slides'); + const parsedSlides = JSON.parse(slidesItems.value); + expect(parsedSlides.length).toBe(2); + expect(parsedSlides[0].position).toBe('right'); + expect(parsedSlides[1].position).toBe('bottom'); + + // Verify prefs persisted + const peeksPrefs = persistedData['peeks:prefs']; + expect(peeksPrefs).toBeTruthy(); + const parsedPeeksPrefs = JSON.parse(peeksPrefs.value); + expect(parsedPeeksPrefs.shortcutKeyPrefix).toBe('Option+'); + + const slidesPrefs = persistedData['slides:prefs']; + expect(slidesPrefs).toBeTruthy(); + const parsedSlidesPrefs = JSON.parse(slidesPrefs.value); + expect(parsedSlidesPrefs.defaultPosition).toBe('right'); + + await electronApp.close(); + }); + + test('addresses and tags persist across restart', async () => { + const ADDR_PROFILE = 'test-addr-persist-' + Date.now(); + + // PHASE 1: Add addresses and tags + let electronApp = await electron.launch({ + args: [MAIN_PATH], + env: { ...process.env, PROFILE: ADDR_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' } + }); + await new Promise(r => setTimeout(r, 4000)); + + let bgWindow = await waitForWindow(electronApp, 'app/background.html'); + + // Add addresses + const addr1 = await bgWindow.evaluate(async () => { + return await (window as any).app.datastore.addAddress('https://persist-test-1.example.com', { + title: 'Persist Test 1', + starred: 1 + }); + }); + expect(addr1.success).toBe(true); + + const addr2 = await bgWindow.evaluate(async () => { + return await (window as any).app.datastore.addAddress('https://persist-test-2.example.com', { + title: 'Persist Test 2' + }); + }); + expect(addr2.success).toBe(true); + + // Create a tag and tag the addresses + const tagResult = await bgWindow.evaluate(async () => { + return await (window as any).app.datastore.getOrCreateTag('persist-tag'); + }); + expect(tagResult.success).toBe(true); + const tagId = tagResult.data?.id; + + if (tagId && addr1.id) { + await bgWindow.evaluate(async ({ addressId, tagId }) => { + return await (window as any).app.datastore.tagAddress(addressId, tagId); + }, { addressId: addr1.id, tagId }); + } + + await electronApp.close(); + await new Promise(r => setTimeout(r, 1000)); + + // PHASE 2: Verify persistence + electronApp = await electron.launch({ + args: [MAIN_PATH], + env: { ...process.env, PROFILE: ADDR_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' } + }); + await new Promise(r => setTimeout(r, 4000)); + + bgWindow = await waitForWindow(electronApp, 'app/background.html'); + + // Query addresses - use getTable for more reliable results + const tableResult = await bgWindow.evaluate(async () => { + return await (window as any).app.datastore.getTable('addresses'); + }); + expect(tableResult.success).toBe(true); + + const addresses = Object.values(tableResult.data) as any[]; + expect(addresses.length).toBeGreaterThanOrEqual(2); + + const persistedAddr1 = addresses.find((a: any) => + a.uri === 'https://persist-test-1.example.com' || + a.uri?.includes('persist-test-1') + ); + expect(persistedAddr1).toBeTruthy(); + expect(persistedAddr1.title).toBe('Persist Test 1'); + + // Query tags + const tagsResult = await bgWindow.evaluate(async () => { + return await (window as any).app.datastore.getTagsByFrecency(10); + }); + expect(tagsResult.success).toBe(true); + const persistTag = tagsResult.data.find((t: any) => t.name === 'persist-tag'); + expect(persistTag).toBeTruthy(); + + await electronApp.close(); + }); +}); + // Core functionality tests test.describe('Core Functionality', () => { let electronApp: ElectronApplication;