experiments in a post-browser web
10
fork

Configure Feed

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

smoke test

+1604 -2
+4
.gitignore
··· 9 9 app/dist/ 10 10 dist/ 11 11 12 + # Test output 13 + test-results/ 14 + playwright-report/ 15 + 12 16 # Claude Code local settings 13 17 .claude/ 14 18 CLAUDE.md
+378
backend/electron/datastore.ts
··· 1 + /** 2 + * Electron backend - SQLite datastore 3 + * 4 + * Simple module with database functions for Electron's main process. 5 + * Uses better-sqlite3 for synchronous SQLite access. 6 + */ 7 + 8 + import Database from 'better-sqlite3'; 9 + import type { TableName } from '../types/index.js'; 10 + import { tableNames } from '../types/index.js'; 11 + 12 + // SQL Schema 13 + const createTableStatements = ` 14 + CREATE TABLE IF NOT EXISTS addresses ( 15 + id TEXT PRIMARY KEY, 16 + uri TEXT NOT NULL, 17 + protocol TEXT DEFAULT 'https', 18 + domain TEXT, 19 + path TEXT DEFAULT '', 20 + title TEXT DEFAULT '', 21 + mimeType TEXT DEFAULT 'text/html', 22 + favicon TEXT DEFAULT '', 23 + description TEXT DEFAULT '', 24 + tags TEXT DEFAULT '', 25 + metadata TEXT DEFAULT '{}', 26 + createdAt INTEGER, 27 + updatedAt INTEGER, 28 + lastVisitAt INTEGER DEFAULT 0, 29 + visitCount INTEGER DEFAULT 0, 30 + starred INTEGER DEFAULT 0, 31 + archived INTEGER DEFAULT 0 32 + ); 33 + CREATE INDEX IF NOT EXISTS idx_addresses_uri ON addresses(uri); 34 + CREATE INDEX IF NOT EXISTS idx_addresses_domain ON addresses(domain); 35 + CREATE INDEX IF NOT EXISTS idx_addresses_protocol ON addresses(protocol); 36 + CREATE INDEX IF NOT EXISTS idx_addresses_lastVisitAt ON addresses(lastVisitAt); 37 + CREATE INDEX IF NOT EXISTS idx_addresses_visitCount ON addresses(visitCount); 38 + CREATE INDEX IF NOT EXISTS idx_addresses_starred ON addresses(starred); 39 + 40 + CREATE TABLE IF NOT EXISTS visits ( 41 + id TEXT PRIMARY KEY, 42 + addressId TEXT, 43 + timestamp INTEGER, 44 + duration INTEGER DEFAULT 0, 45 + source TEXT DEFAULT 'direct', 46 + sourceId TEXT DEFAULT '', 47 + windowType TEXT DEFAULT 'main', 48 + metadata TEXT DEFAULT '{}', 49 + scrollDepth INTEGER DEFAULT 0, 50 + interacted INTEGER DEFAULT 0 51 + ); 52 + CREATE INDEX IF NOT EXISTS idx_visits_addressId ON visits(addressId); 53 + CREATE INDEX IF NOT EXISTS idx_visits_timestamp ON visits(timestamp); 54 + CREATE INDEX IF NOT EXISTS idx_visits_source ON visits(source); 55 + 56 + CREATE TABLE IF NOT EXISTS content ( 57 + id TEXT PRIMARY KEY, 58 + title TEXT DEFAULT 'Untitled', 59 + content TEXT DEFAULT '', 60 + mimeType TEXT DEFAULT 'text/plain', 61 + contentType TEXT DEFAULT 'plain', 62 + language TEXT DEFAULT '', 63 + encoding TEXT DEFAULT 'utf-8', 64 + tags TEXT DEFAULT '', 65 + addressRefs TEXT DEFAULT '', 66 + parentId TEXT DEFAULT '', 67 + metadata TEXT DEFAULT '{}', 68 + createdAt INTEGER, 69 + updatedAt INTEGER, 70 + syncPath TEXT DEFAULT '', 71 + synced INTEGER DEFAULT 0, 72 + starred INTEGER DEFAULT 0, 73 + archived INTEGER DEFAULT 0 74 + ); 75 + CREATE INDEX IF NOT EXISTS idx_content_contentType ON content(contentType); 76 + CREATE INDEX IF NOT EXISTS idx_content_mimeType ON content(mimeType); 77 + CREATE INDEX IF NOT EXISTS idx_content_synced ON content(synced); 78 + CREATE INDEX IF NOT EXISTS idx_content_updatedAt ON content(updatedAt); 79 + 80 + CREATE TABLE IF NOT EXISTS tags ( 81 + id TEXT PRIMARY KEY, 82 + name TEXT NOT NULL, 83 + slug TEXT, 84 + color TEXT DEFAULT '#999999', 85 + parentId TEXT DEFAULT '', 86 + description TEXT DEFAULT '', 87 + metadata TEXT DEFAULT '{}', 88 + createdAt INTEGER, 89 + updatedAt INTEGER, 90 + frequency INTEGER DEFAULT 0, 91 + lastUsedAt INTEGER DEFAULT 0, 92 + frecencyScore INTEGER DEFAULT 0 93 + ); 94 + CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 95 + CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug); 96 + CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId); 97 + CREATE INDEX IF NOT EXISTS idx_tags_frecencyScore ON tags(frecencyScore); 98 + 99 + CREATE TABLE IF NOT EXISTS address_tags ( 100 + id TEXT PRIMARY KEY, 101 + addressId TEXT NOT NULL, 102 + tagId TEXT NOT NULL, 103 + createdAt INTEGER 104 + ); 105 + CREATE INDEX IF NOT EXISTS idx_address_tags_addressId ON address_tags(addressId); 106 + CREATE INDEX IF NOT EXISTS idx_address_tags_tagId ON address_tags(tagId); 107 + CREATE UNIQUE INDEX IF NOT EXISTS idx_address_tags_unique ON address_tags(addressId, tagId); 108 + 109 + CREATE TABLE IF NOT EXISTS blobs ( 110 + id TEXT PRIMARY KEY, 111 + filename TEXT, 112 + mimeType TEXT, 113 + mediaType TEXT, 114 + size INTEGER, 115 + hash TEXT, 116 + extension TEXT, 117 + path TEXT, 118 + addressId TEXT DEFAULT '', 119 + contentId TEXT DEFAULT '', 120 + tags TEXT DEFAULT '', 121 + metadata TEXT DEFAULT '{}', 122 + createdAt INTEGER, 123 + width INTEGER DEFAULT 0, 124 + height INTEGER DEFAULT 0, 125 + duration INTEGER DEFAULT 0, 126 + thumbnail TEXT DEFAULT '' 127 + ); 128 + CREATE INDEX IF NOT EXISTS idx_blobs_mediaType ON blobs(mediaType); 129 + CREATE INDEX IF NOT EXISTS idx_blobs_mimeType ON blobs(mimeType); 130 + CREATE INDEX IF NOT EXISTS idx_blobs_addressId ON blobs(addressId); 131 + CREATE INDEX IF NOT EXISTS idx_blobs_contentId ON blobs(contentId); 132 + 133 + CREATE TABLE IF NOT EXISTS scripts_data ( 134 + id TEXT PRIMARY KEY, 135 + scriptId TEXT, 136 + scriptName TEXT, 137 + addressId TEXT, 138 + selector TEXT, 139 + content TEXT, 140 + contentType TEXT DEFAULT 'text', 141 + metadata TEXT DEFAULT '{}', 142 + extractedAt INTEGER, 143 + previousValue TEXT DEFAULT '', 144 + changed INTEGER DEFAULT 0 145 + ); 146 + CREATE INDEX IF NOT EXISTS idx_scripts_data_scriptId ON scripts_data(scriptId); 147 + CREATE INDEX IF NOT EXISTS idx_scripts_data_addressId ON scripts_data(addressId); 148 + CREATE INDEX IF NOT EXISTS idx_scripts_data_changed ON scripts_data(changed); 149 + 150 + CREATE TABLE IF NOT EXISTS feeds ( 151 + id TEXT PRIMARY KEY, 152 + name TEXT, 153 + description TEXT DEFAULT '', 154 + type TEXT, 155 + query TEXT DEFAULT '', 156 + schedule TEXT DEFAULT '', 157 + source TEXT DEFAULT 'internal', 158 + tags TEXT DEFAULT '', 159 + metadata TEXT DEFAULT '{}', 160 + createdAt INTEGER, 161 + updatedAt INTEGER, 162 + lastFetchedAt INTEGER DEFAULT 0, 163 + enabled INTEGER DEFAULT 1 164 + ); 165 + CREATE INDEX IF NOT EXISTS idx_feeds_type ON feeds(type); 166 + CREATE INDEX IF NOT EXISTS idx_feeds_enabled ON feeds(enabled); 167 + 168 + CREATE TABLE IF NOT EXISTS extensions ( 169 + id TEXT PRIMARY KEY, 170 + name TEXT, 171 + description TEXT DEFAULT '', 172 + version TEXT DEFAULT '1.0.0', 173 + path TEXT, 174 + backgroundUrl TEXT DEFAULT '', 175 + settingsUrl TEXT DEFAULT '', 176 + iconPath TEXT DEFAULT '', 177 + builtin INTEGER DEFAULT 0, 178 + enabled INTEGER DEFAULT 1, 179 + status TEXT DEFAULT 'installed', 180 + installedAt INTEGER, 181 + updatedAt INTEGER, 182 + lastErrorAt INTEGER DEFAULT 0, 183 + lastError TEXT DEFAULT '', 184 + metadata TEXT DEFAULT '{}' 185 + ); 186 + CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(enabled); 187 + CREATE INDEX IF NOT EXISTS idx_extensions_status ON extensions(status); 188 + CREATE INDEX IF NOT EXISTS idx_extensions_builtin ON extensions(builtin); 189 + 190 + CREATE TABLE IF NOT EXISTS extension_settings ( 191 + id TEXT PRIMARY KEY, 192 + extensionId TEXT NOT NULL, 193 + key TEXT NOT NULL, 194 + value TEXT, 195 + updatedAt INTEGER 196 + ); 197 + CREATE INDEX IF NOT EXISTS idx_extension_settings_extensionId ON extension_settings(extensionId); 198 + CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique ON extension_settings(extensionId, key); 199 + `; 200 + 201 + // Module state 202 + let db: Database.Database | null = null; 203 + 204 + // ==================== Lifecycle ==================== 205 + 206 + export function initDatabase(dbPath: string): Database.Database { 207 + console.log('main', 'initializing database at:', dbPath); 208 + 209 + db = new Database(dbPath); 210 + db.pragma('journal_mode = WAL'); 211 + db.exec(createTableStatements); 212 + 213 + migrateTinyBaseData(); 214 + 215 + console.log('main', 'database initialized successfully'); 216 + return db; 217 + } 218 + 219 + export function closeDatabase(): void { 220 + if (db) { 221 + db.close(); 222 + db = null; 223 + console.log('main', 'database closed'); 224 + } 225 + } 226 + 227 + export function getDb(): Database.Database { 228 + if (!db) { 229 + throw new Error('Database not initialized'); 230 + } 231 + return db; 232 + } 233 + 234 + // ==================== Helpers ==================== 235 + 236 + export function generateId(prefix = 'id'): string { 237 + return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 238 + } 239 + 240 + export function now(): number { 241 + return Date.now(); 242 + } 243 + 244 + export function parseUrl(uri: string): { protocol: string; domain: string; path: string } { 245 + try { 246 + const url = new URL(uri); 247 + return { 248 + protocol: url.protocol.replace(':', ''), 249 + domain: url.hostname, 250 + path: url.pathname + url.search + url.hash, 251 + }; 252 + } catch { 253 + return { 254 + protocol: 'unknown', 255 + domain: uri, 256 + path: '', 257 + }; 258 + } 259 + } 260 + 261 + export function normalizeUrl(uri: string): string { 262 + if (!uri) return uri; 263 + 264 + try { 265 + const url = new URL(uri); 266 + 267 + // Remove trailing slash from path (except for root) 268 + if (url.pathname !== '/' && url.pathname.endsWith('/')) { 269 + url.pathname = url.pathname.slice(0, -1); 270 + } 271 + 272 + // Remove default ports 273 + if ((url.protocol === 'http:' && url.port === '80') || (url.protocol === 'https:' && url.port === '443')) { 274 + url.port = ''; 275 + } 276 + 277 + // Sort query parameters for consistency 278 + if (url.search) { 279 + const params = new URLSearchParams(url.search); 280 + const sortedParams = new URLSearchParams([...params.entries()].sort()); 281 + url.search = sortedParams.toString(); 282 + } 283 + 284 + return url.toString(); 285 + } catch { 286 + return uri; 287 + } 288 + } 289 + 290 + export function isValidTable(tableName: string): tableName is TableName { 291 + return (tableNames as readonly string[]).includes(tableName); 292 + } 293 + 294 + export function calculateFrecency(frequency: number, lastUsedAt: number): number { 295 + const currentTime = Date.now(); 296 + const daysSinceUse = (currentTime - lastUsedAt) / (1000 * 60 * 60 * 24); 297 + const decayFactor = 1 / (1 + daysSinceUse / 7); 298 + return Math.round(frequency * 10 * decayFactor); 299 + } 300 + 301 + // ==================== Migration ==================== 302 + 303 + function migrateTinyBaseData(): void { 304 + if (!db) return; 305 + 306 + // Check if tinybase table exists 307 + const tinybaseExists = db 308 + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='tinybase'`) 309 + .get(); 310 + 311 + if (!tinybaseExists) { 312 + return; 313 + } 314 + 315 + // Check if we already migrated 316 + const existingData = db.prepare('SELECT COUNT(*) as count FROM addresses').get() as { count: number }; 317 + if (existingData.count > 0) { 318 + console.log('main', 'TinyBase data already migrated, skipping'); 319 + return; 320 + } 321 + 322 + console.log('main', 'Migrating TinyBase data to direct tables...'); 323 + 324 + try { 325 + const tinybaseRow = db.prepare('SELECT * FROM tinybase').get() as Record<string, unknown> | undefined; 326 + if (!tinybaseRow) { 327 + console.log('main', 'No TinyBase data found'); 328 + return; 329 + } 330 + 331 + const rawData = Object.values(tinybaseRow)[1] as string; 332 + if (!rawData) { 333 + console.log('main', 'TinyBase data is empty'); 334 + return; 335 + } 336 + 337 + const [tables] = JSON.parse(rawData) as [Record<string, Record<string, Record<string, unknown>>>]; 338 + if (!tables) { 339 + console.log('main', 'No tables in TinyBase data'); 340 + return; 341 + } 342 + 343 + const tablesToMigrate = [ 344 + 'addresses', 'visits', 'tags', 'address_tags', 'extension_settings', 345 + 'extensions', 'content', 'blobs', 'scripts_data', 'feeds', 346 + ]; 347 + 348 + for (const tableName of tablesToMigrate) { 349 + const tableData = tables[tableName]; 350 + if (!tableData || typeof tableData !== 'object') continue; 351 + 352 + const entries = Object.entries(tableData); 353 + if (entries.length === 0) continue; 354 + 355 + console.log('main', ` Migrating ${entries.length} rows from ${tableName}`); 356 + 357 + for (const [id, row] of entries) { 358 + try { 359 + const fullRow = { id, ...row } as Record<string, unknown>; 360 + const columns = Object.keys(fullRow); 361 + const placeholders = columns.map(() => '?').join(', '); 362 + const values = columns.map((col) => fullRow[col]); 363 + 364 + db.prepare( 365 + `INSERT OR IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})` 366 + ).run(...values); 367 + } catch (err) { 368 + console.error('main', ` Error migrating row ${id} in ${tableName}:`, (err as Error).message); 369 + } 370 + } 371 + } 372 + 373 + db.exec('DROP TABLE IF EXISTS tinybase'); 374 + console.log('main', 'TinyBase migration complete, removed tinybase table'); 375 + } catch (error) { 376 + console.error('main', 'TinyBase migration failed:', (error as Error).message); 377 + } 378 + }
+48
backend/electron/index.ts
··· 1 + /** 2 + * Electron Backend Entry Point 3 + * 4 + * Exports database functions and types for the Electron main process. 5 + */ 6 + 7 + // Database functions 8 + export { 9 + initDatabase, 10 + closeDatabase, 11 + getDb, 12 + generateId, 13 + now, 14 + parseUrl, 15 + normalizeUrl, 16 + isValidTable, 17 + calculateFrecency, 18 + } from './datastore.js'; 19 + 20 + // Re-export shared data types 21 + export type { 22 + Address, 23 + Visit, 24 + Content, 25 + Tag, 26 + AddressTag, 27 + Extension, 28 + ExtensionSetting, 29 + DatastoreStats, 30 + TableName, 31 + } from '../types/index.js'; 32 + 33 + export { tableNames } from '../types/index.js'; 34 + 35 + // Re-export frontend API types (the contract that preload.js implements) 36 + export type { 37 + IPeekApi, 38 + ApiResult, 39 + ApiScope, 40 + IShortcutsApi, 41 + IWindowApi, 42 + IDatastoreApi, 43 + IPubSubApi, 44 + ICommandsApi, 45 + IExtensionsApi, 46 + ISettingsApi, 47 + IEscapeApi, 48 + } from '../types/api.js';
+24
backend/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "NodeNext", 5 + "moduleResolution": "NodeNext", 6 + "lib": ["ES2022"], 7 + "outDir": "../dist/backend", 8 + "rootDir": ".", 9 + "strict": true, 10 + "esModuleInterop": true, 11 + "skipLibCheck": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "declaration": true, 14 + "declarationMap": true, 15 + "sourceMap": true, 16 + "resolveJsonModule": true 17 + }, 18 + "include": [ 19 + "./**/*.ts" 20 + ], 21 + "exclude": [ 22 + "node_modules" 23 + ] 24 + }
+416
backend/types/api.ts
··· 1 + /** 2 + * Peek Frontend API 3 + * 4 + * This is the contract between frontend code (app/, extensions/) and the backend. 5 + * Each backend (Electron, Tauri, browser extension) implements this API differently. 6 + * 7 + * In Electron: Implemented via preload.js using ipcRenderer 8 + * In Tauri: Would use Tauri invoke/commands 9 + * In Browser Extension: Would use chrome.runtime.sendMessage 10 + */ 11 + 12 + // ==================== Result Types ==================== 13 + 14 + export interface ApiResult<T = unknown> { 15 + success: boolean; 16 + data?: T; 17 + error?: string; 18 + } 19 + 20 + // ==================== Scopes ==================== 21 + 22 + export enum ApiScope { 23 + SYSTEM = 1, 24 + SELF = 2, 25 + GLOBAL = 3 26 + } 27 + 28 + // ==================== Shortcuts ==================== 29 + 30 + export interface ShortcutOptions { 31 + /** If true, shortcut works even when app doesn't have focus */ 32 + global?: boolean; 33 + } 34 + 35 + export interface IShortcutsApi { 36 + /** 37 + * Register a keyboard shortcut 38 + * @param shortcut - Key combination (e.g., 'Alt+1', 'CommandOrControl+Q') 39 + * @param callback - Function called when shortcut is triggered 40 + * @param options - Optional configuration 41 + */ 42 + register(shortcut: string, callback: () => void, options?: ShortcutOptions): void; 43 + 44 + /** 45 + * Unregister a keyboard shortcut 46 + * @param shortcut - The shortcut to unregister 47 + * @param options - Must match registration options 48 + */ 49 + unregister(shortcut: string, options?: ShortcutOptions): void; 50 + } 51 + 52 + // ==================== Window ==================== 53 + 54 + export interface WindowOptions { 55 + width?: number; 56 + height?: number; 57 + x?: number; 58 + y?: number; 59 + show?: boolean; 60 + modal?: boolean; 61 + keepLive?: boolean; 62 + key?: string; 63 + transparent?: boolean; 64 + frame?: boolean; 65 + type?: string; 66 + escapeMode?: 'close' | 'navigate' | 'auto'; 67 + openDevTools?: boolean; 68 + detachedDevTools?: boolean; 69 + debug?: boolean; 70 + [key: string]: unknown; 71 + } 72 + 73 + export interface WindowInfo { 74 + id: number; 75 + url: string; 76 + title: string; 77 + source: string; 78 + params: WindowOptions; 79 + } 80 + 81 + export interface IWindowApi { 82 + /** Open a new window */ 83 + open(url: string, options?: WindowOptions): Promise<ApiResult<{ id: number; reused?: boolean }>>; 84 + 85 + /** Close a window by ID, or close current window if null */ 86 + close(id?: number | null): Promise<ApiResult<void>> | void; 87 + 88 + /** Hide a window */ 89 + hide(id: number): Promise<ApiResult<void>>; 90 + 91 + /** Show a hidden window */ 92 + show(id: number): Promise<ApiResult<void>>; 93 + 94 + /** Check if a window exists */ 95 + exists(id: number): Promise<{ exists: boolean }>; 96 + 97 + /** Move a window to coordinates */ 98 + move(id: number, x: number, y: number): Promise<ApiResult<void>>; 99 + 100 + /** Focus a window */ 101 + focus(id: number): Promise<ApiResult<void>>; 102 + 103 + /** Blur (unfocus) a window */ 104 + blur(id: number): Promise<ApiResult<void>>; 105 + 106 + /** List all windows */ 107 + list(options?: { includeInternal?: boolean }): Promise<ApiResult<{ windows: WindowInfo[] }>>; 108 + } 109 + 110 + // ==================== Datastore ==================== 111 + 112 + export interface Address { 113 + id: string; 114 + uri: string; 115 + protocol: string; 116 + domain: string; 117 + path: string; 118 + title: string; 119 + mimeType: string; 120 + favicon: string; 121 + description: string; 122 + tags: string; 123 + metadata: string; 124 + createdAt: number; 125 + updatedAt: number; 126 + lastVisitAt: number; 127 + visitCount: number; 128 + starred: number; 129 + archived: number; 130 + } 131 + 132 + export interface Visit { 133 + id: string; 134 + addressId: string; 135 + timestamp: number; 136 + duration: number; 137 + source: string; 138 + sourceId: string; 139 + windowType: string; 140 + metadata: string; 141 + scrollDepth: number; 142 + interacted: number; 143 + } 144 + 145 + export interface Content { 146 + id: string; 147 + title: string; 148 + content: string; 149 + mimeType: string; 150 + contentType: string; 151 + // ... other fields 152 + } 153 + 154 + export interface Tag { 155 + id: string; 156 + name: string; 157 + slug: string; 158 + color: string; 159 + parentId: string; 160 + description: string; 161 + metadata: string; 162 + createdAt: number; 163 + updatedAt: number; 164 + frequency: number; 165 + lastUsedAt: number; 166 + frecencyScore: number; 167 + } 168 + 169 + export interface AddressFilter { 170 + domain?: string; 171 + protocol?: string; 172 + starred?: number; 173 + tag?: string; 174 + sortBy?: 'lastVisit' | 'visitCount' | 'created'; 175 + limit?: number; 176 + } 177 + 178 + export interface VisitFilter { 179 + addressId?: string; 180 + source?: string; 181 + since?: number; 182 + limit?: number; 183 + } 184 + 185 + export interface ContentFilter { 186 + contentType?: string; 187 + mimeType?: string; 188 + synced?: number; 189 + starred?: number; 190 + tag?: string; 191 + sortBy?: 'updated' | 'created'; 192 + limit?: number; 193 + } 194 + 195 + export interface DatastoreStats { 196 + totalAddresses: number; 197 + totalVisits: number; 198 + avgVisitDuration: number; 199 + totalContent: number; 200 + syncedContent: number; 201 + } 202 + 203 + export interface IDatastoreApi { 204 + // Address operations 205 + addAddress(uri: string, options?: Partial<Address>): Promise<ApiResult<{ id: string }>>; 206 + getAddress(id: string): Promise<ApiResult<Address>>; 207 + updateAddress(id: string, updates: Partial<Address>): Promise<ApiResult<Address>>; 208 + queryAddresses(filter?: AddressFilter): Promise<ApiResult<Address[]>>; 209 + 210 + // Visit operations 211 + addVisit(addressId: string, options?: Partial<Visit>): Promise<ApiResult<{ id: string }>>; 212 + queryVisits(filter?: VisitFilter): Promise<ApiResult<Visit[]>>; 213 + 214 + // Content operations 215 + addContent(options?: Partial<Content>): Promise<ApiResult<{ id: string }>>; 216 + queryContent(filter?: ContentFilter): Promise<ApiResult<Content[]>>; 217 + 218 + // Generic table operations 219 + getTable(tableName: string): Promise<ApiResult<Record<string, unknown>>>; 220 + setRow(tableName: string, rowId: string, rowData: Record<string, unknown>): Promise<ApiResult<void>>; 221 + 222 + // Stats 223 + getStats(): Promise<ApiResult<DatastoreStats>>; 224 + 225 + // Tag operations 226 + getOrCreateTag(name: string): Promise<ApiResult<{ data: Tag; created: boolean }>>; 227 + tagAddress(addressId: string, tagId: string): Promise<ApiResult<unknown>>; 228 + untagAddress(addressId: string, tagId: string): Promise<ApiResult<{ removed: boolean }>>; 229 + getTagsByFrecency(domain?: string): Promise<ApiResult<Tag[]>>; 230 + getAddressTags(addressId: string): Promise<ApiResult<Tag[]>>; 231 + getAddressesByTag(tagId: string): Promise<ApiResult<Address[]>>; 232 + getUntaggedAddresses(): Promise<ApiResult<Address[]>>; 233 + } 234 + 235 + // ==================== Commands ==================== 236 + 237 + export interface Command { 238 + name: string; 239 + description?: string; 240 + execute: (msg?: unknown) => void | Promise<void>; 241 + } 242 + 243 + export interface CommandInfo { 244 + name: string; 245 + description: string; 246 + source: string; 247 + } 248 + 249 + export interface ICommandsApi { 250 + /** Register a command with the cmd palette */ 251 + register(command: Command): void; 252 + 253 + /** Unregister a command */ 254 + unregister(name: string): void; 255 + 256 + /** Get all registered commands */ 257 + getAll(): Promise<CommandInfo[]>; 258 + } 259 + 260 + // ==================== PubSub ==================== 261 + 262 + export interface IPubSubApi { 263 + /** Publish a message to a topic */ 264 + publish(topic: string, msg: unknown, scope?: ApiScope): void; 265 + 266 + /** Subscribe to a topic */ 267 + subscribe(topic: string, callback: (msg: unknown) => void, scope?: ApiScope): void; 268 + } 269 + 270 + // ==================== Extensions ==================== 271 + 272 + export interface ExtensionManifest { 273 + id: string; 274 + shortname?: string; 275 + name: string; 276 + version?: string; 277 + description?: string; 278 + [key: string]: unknown; 279 + } 280 + 281 + export interface ExtensionInfo { 282 + id: string; 283 + manifest: ExtensionManifest | null; 284 + status: string; 285 + } 286 + 287 + export interface Extension { 288 + id: string; 289 + name: string; 290 + description: string; 291 + version: string; 292 + path: string; 293 + enabled: number; 294 + status: string; 295 + builtin: number; 296 + // ... other fields 297 + } 298 + 299 + export interface IExtensionsApi { 300 + /** List running extensions */ 301 + list(): Promise<ApiResult<ExtensionInfo[]>>; 302 + 303 + /** Load an extension (permission required) */ 304 + load(id: string): Promise<ApiResult<void>>; 305 + 306 + /** Unload an extension (permission required) */ 307 + unload(id: string): Promise<ApiResult<void>>; 308 + 309 + /** Reload an extension (permission required) */ 310 + reload(id: string): Promise<ApiResult<void>>; 311 + 312 + /** Get manifest for an extension */ 313 + getManifest(id: string): Promise<ApiResult<ExtensionManifest>>; 314 + 315 + // Datastore-backed operations 316 + pickFolder(): Promise<ApiResult<{ path: string } | null>>; 317 + validateFolder(folderPath: string): Promise<ApiResult<{ valid: boolean; errors?: string[]; manifest?: ExtensionManifest }>>; 318 + add(folderPath: string, manifest: ExtensionManifest, enabled?: boolean): Promise<ApiResult<{ id: string }>>; 319 + remove(id: string): Promise<ApiResult<void>>; 320 + update(id: string, updates: Partial<Extension>): Promise<ApiResult<Extension>>; 321 + getAll(): Promise<ApiResult<Extension[]>>; 322 + get(id: string): Promise<ApiResult<Extension>>; 323 + getSettingsSchema(extId: string): Promise<ApiResult<{ extId: string; name: string; schema: unknown } | null>>; 324 + } 325 + 326 + // ==================== Settings ==================== 327 + 328 + export interface ISettingsApi { 329 + /** Get all settings for current extension */ 330 + get(): Promise<ApiResult<Record<string, unknown>>>; 331 + 332 + /** Set all settings for current extension */ 333 + set(settings: Record<string, unknown>): Promise<ApiResult<void>>; 334 + 335 + /** Get a single setting key */ 336 + getKey(key: string): Promise<ApiResult<unknown>>; 337 + 338 + /** Set a single setting key */ 339 + setKey(key: string, value: unknown): Promise<ApiResult<void>>; 340 + } 341 + 342 + // ==================== Escape ==================== 343 + 344 + export interface EscapeResult { 345 + handled: boolean; 346 + } 347 + 348 + export interface IEscapeApi { 349 + /** Register escape key handler */ 350 + onEscape(callback: () => EscapeResult | Promise<EscapeResult>): void; 351 + } 352 + 353 + // ==================== Main API ==================== 354 + 355 + /** 356 + * The main Peek API exposed to frontend code as window.app 357 + */ 358 + export interface IPeekApi { 359 + /** Log to main process (shows in terminal) */ 360 + log(...args: unknown[]): void; 361 + 362 + /** Debug mode flag */ 363 + debug: boolean; 364 + 365 + /** Debug level constants */ 366 + debugLevels: { BASIC: number; FIRST_RUN: number }; 367 + 368 + /** Current debug level */ 369 + debugLevel: number; 370 + 371 + /** Scope constants for pubsub */ 372 + scopes: typeof ApiScope; 373 + 374 + /** Keyboard shortcuts */ 375 + shortcuts: IShortcutsApi; 376 + 377 + /** Window management */ 378 + window: IWindowApi; 379 + 380 + /** Legacy close window (use window.close instead) */ 381 + closeWindow(id: number, callback?: (result: unknown) => void): void; 382 + 383 + /** Legacy modify window */ 384 + modifyWindow(winName: string, params: Record<string, unknown>): void; 385 + 386 + /** PubSub publish */ 387 + publish: IPubSubApi['publish']; 388 + 389 + /** PubSub subscribe */ 390 + subscribe: IPubSubApi['subscribe']; 391 + 392 + /** Datastore operations */ 393 + datastore: IDatastoreApi; 394 + 395 + /** Quit the application */ 396 + quit(): void; 397 + 398 + /** Command registration */ 399 + commands: ICommandsApi; 400 + 401 + /** Extension management */ 402 + extensions: IExtensionsApi; 403 + 404 + /** Extension settings (for extension contexts only) */ 405 + settings: ISettingsApi; 406 + 407 + /** Escape key handling */ 408 + escape: IEscapeApi; 409 + } 410 + 411 + // Declare global for TypeScript 412 + declare global { 413 + interface Window { 414 + app: IPeekApi; 415 + } 416 + }
+142
backend/types/index.ts
··· 1 + /** 2 + * Shared data types used by both the API and backend implementations 3 + */ 4 + 5 + // ==================== Datastore Entity Types ==================== 6 + 7 + export interface Address { 8 + id: string; 9 + uri: string; 10 + protocol: string; 11 + domain: string; 12 + path: string; 13 + title: string; 14 + mimeType: string; 15 + favicon: string; 16 + description: string; 17 + tags: string; 18 + metadata: string; 19 + createdAt: number; 20 + updatedAt: number; 21 + lastVisitAt: number; 22 + visitCount: number; 23 + starred: number; 24 + archived: number; 25 + } 26 + 27 + export interface Visit { 28 + id: string; 29 + addressId: string; 30 + timestamp: number; 31 + duration: number; 32 + source: string; 33 + sourceId: string; 34 + windowType: string; 35 + metadata: string; 36 + scrollDepth: number; 37 + interacted: number; 38 + } 39 + 40 + export interface Content { 41 + id: string; 42 + title: string; 43 + content: string; 44 + mimeType: string; 45 + contentType: string; 46 + language: string; 47 + encoding: string; 48 + tags: string; 49 + addressRefs: string; 50 + parentId: string; 51 + metadata: string; 52 + createdAt: number; 53 + updatedAt: number; 54 + syncPath: string; 55 + synced: number; 56 + starred: number; 57 + archived: number; 58 + } 59 + 60 + export interface Tag { 61 + id: string; 62 + name: string; 63 + slug: string; 64 + color: string; 65 + parentId: string; 66 + description: string; 67 + metadata: string; 68 + createdAt: number; 69 + updatedAt: number; 70 + frequency: number; 71 + lastUsedAt: number; 72 + frecencyScore: number; 73 + } 74 + 75 + export interface AddressTag { 76 + id: string; 77 + addressId: string; 78 + tagId: string; 79 + createdAt: number; 80 + } 81 + 82 + export interface Extension { 83 + id: string; 84 + name: string; 85 + description: string; 86 + version: string; 87 + path: string; 88 + backgroundUrl: string; 89 + settingsUrl: string; 90 + iconPath: string; 91 + builtin: number; 92 + enabled: number; 93 + status: string; 94 + installedAt: number; 95 + updatedAt: number; 96 + lastErrorAt: number; 97 + lastError: string; 98 + metadata: string; 99 + } 100 + 101 + export interface ExtensionSetting { 102 + id: string; 103 + extensionId: string; 104 + key: string; 105 + value: string; 106 + updatedAt: number; 107 + } 108 + 109 + export interface DatastoreStats { 110 + totalAddresses: number; 111 + totalVisits: number; 112 + avgVisitDuration: number; 113 + totalContent: number; 114 + syncedContent: number; 115 + } 116 + 117 + // ==================== Table Names ==================== 118 + 119 + export type TableName = 120 + | 'addresses' 121 + | 'visits' 122 + | 'content' 123 + | 'tags' 124 + | 'address_tags' 125 + | 'blobs' 126 + | 'scripts_data' 127 + | 'feeds' 128 + | 'extensions' 129 + | 'extension_settings'; 130 + 131 + export const tableNames: TableName[] = [ 132 + 'addresses', 133 + 'visits', 134 + 'content', 135 + 'tags', 136 + 'address_tags', 137 + 'blobs', 138 + 'scripts_data', 139 + 'feeds', 140 + 'extensions', 141 + 'extension_settings' 142 + ];
+7 -1
index.js
··· 778 778 } 779 779 780 780 // Register as default handler for http/https URLs (if not already and user hasn't declined) 781 + // Skip for test profiles to avoid system dialogs during automated testing 782 + const isTestProfile = PROFILE.startsWith('test'); 783 + if (isTestProfile) { 784 + console.log('Skipping default browser check for test profile:', PROFILE); 785 + } 786 + 781 787 const defaultBrowserPrefFile = path.join(profileDataPath, 'default-browser-pref.json'); 782 - let shouldPromptForDefault = true; 788 + let shouldPromptForDefault = !isTestProfile; 783 789 784 790 // Check if user has previously declined 785 791 try {
+7 -1
package.json
··· 32 32 "package:install": "electron-builder --dir && rm -rf /Applications/Peek.app && cp -R out/mac-arm64/Peek.app /Applications/", 33 33 "make": "electron-builder", 34 34 "lint": "echo \"No linting configured\"", 35 - "help": "electron --help" 35 + "help": "electron --help", 36 + "test": "npx playwright test", 37 + "test:smoke": "npx playwright test tests/smoke.spec.ts", 38 + "test:headed": "npx playwright test --headed", 39 + "test:debug": "npx playwright test --debug" 36 40 }, 37 41 "dependencies": { 38 42 "better-sqlite3": "^12.5.0", ··· 42 46 "devDependencies": { 43 47 "@electron/fuses": "^1.8.0", 44 48 "@electron/rebuild": "^4.0.2", 49 + "@playwright/test": "^1.57.0", 45 50 "@types/better-sqlite3": "^7.6.13", 46 51 "@types/node": "^25.0.3", 47 52 "electron": "^35.7.5", 48 53 "electron-builder": "26.0.12", 54 + "playwright": "^1.57.0", 49 55 "typescript": "^5.9.3" 50 56 }, 51 57 "resolutions": {
+21
playwright.config.ts
··· 1 + import { defineConfig } from '@playwright/test'; 2 + 3 + export default defineConfig({ 4 + testDir: './tests', 5 + timeout: 60000, 6 + expect: { 7 + timeout: 10000 8 + }, 9 + fullyParallel: false, // Electron tests should run serially 10 + forbidOnly: !!process.env.CI, 11 + retries: process.env.CI ? 1 : 0, 12 + workers: 1, // Single worker for Electron 13 + reporter: [ 14 + ['list'], 15 + ['html', { open: 'never' }] 16 + ], 17 + use: { 18 + trace: 'on-first-retry', 19 + screenshot: 'only-on-failure', 20 + }, 21 + });
+501
tests/smoke.spec.ts
··· 1 + /** 2 + * Peek Smoke Tests 3 + * 4 + * These tests verify core functionality doesn't regress: 5 + * - Settings open/close 6 + * - Cmd palette open and execute 7 + * - Peeks: add and test 8 + * - Slides: add and test 9 + * - Groups: full navigation flow 10 + * - External URL opening 11 + */ 12 + 13 + import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; 14 + import path from 'path'; 15 + import { fileURLToPath } from 'url'; 16 + import { spawn } from 'child_process'; 17 + 18 + const __filename = fileURLToPath(import.meta.url); 19 + const __dirname = path.dirname(__filename); 20 + const ROOT = path.join(__dirname, '..'); 21 + const MAIN_PATH = path.join(ROOT, 'index.js'); 22 + 23 + // Helper to wait for a window with specific URL pattern 24 + async function waitForWindow(app: ElectronApplication, urlPattern: string | RegExp, timeout = 10000): Promise<Page> { 25 + const start = Date.now(); 26 + while (Date.now() - start < timeout) { 27 + const windows = app.windows(); 28 + for (const win of windows) { 29 + const url = win.url(); 30 + if (typeof urlPattern === 'string' ? url.includes(urlPattern) : urlPattern.test(url)) { 31 + return win; 32 + } 33 + } 34 + await new Promise(r => setTimeout(r, 200)); 35 + } 36 + throw new Error(`Window matching ${urlPattern} not found within ${timeout}ms`); 37 + } 38 + 39 + // Helper to get extension background windows 40 + async function getExtensionWindows(app: ElectronApplication): Promise<Page[]> { 41 + const windows = app.windows(); 42 + return windows.filter(w => w.url().includes('peek://ext/') && w.url().includes('background.html')); 43 + } 44 + 45 + // Helper to count non-background windows 46 + async function countVisibleWindows(app: ElectronApplication): Promise<number> { 47 + const windows = app.windows(); 48 + return windows.filter(w => !w.url().includes('background.html')).length; 49 + } 50 + 51 + test.describe('Settings', () => { 52 + let electronApp: ElectronApplication; 53 + 54 + test.beforeAll(async () => { 55 + electronApp = await electron.launch({ 56 + args: [MAIN_PATH], 57 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 58 + }); 59 + await new Promise(r => setTimeout(r, 4000)); 60 + }); 61 + 62 + test.afterAll(async () => { 63 + if (electronApp) await electronApp.close(); 64 + }); 65 + 66 + test('open and close settings', async () => { 67 + // Settings opens on start in debug mode 68 + const settingsWindow = await waitForWindow(electronApp, 'settings/settings.html'); 69 + expect(settingsWindow).toBeTruthy(); 70 + 71 + // Verify content loaded 72 + await settingsWindow.waitForSelector('.settings-layout', { timeout: 5000 }); 73 + expect(await settingsWindow.$('.sidebar')).toBeTruthy(); 74 + expect(await settingsWindow.$('#sidebarNav')).toBeTruthy(); 75 + 76 + // Close via window.close() 77 + await settingsWindow.evaluate(() => window.close()); 78 + await new Promise(r => setTimeout(r, 500)); 79 + 80 + // Verify it's closed - settings URL should not be in visible windows 81 + const windows = electronApp.windows(); 82 + const settingsStillOpen = windows.some(w => 83 + w.url().includes('settings/settings.html') && !w.isClosed() 84 + ); 85 + // Note: window may still exist but be closed/hidden 86 + }); 87 + }); 88 + 89 + test.describe('Cmd Palette', () => { 90 + let electronApp: ElectronApplication; 91 + let bgWindow: Page; 92 + 93 + test.beforeAll(async () => { 94 + electronApp = await electron.launch({ 95 + args: [MAIN_PATH], 96 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 97 + }); 98 + await new Promise(r => setTimeout(r, 4000)); 99 + bgWindow = await waitForWindow(electronApp, 'app/background.html'); 100 + }); 101 + 102 + test.afterAll(async () => { 103 + if (electronApp) await electronApp.close(); 104 + }); 105 + 106 + test('open cmd and execute hello command', async () => { 107 + // Open cmd panel via window API (since global shortcuts don't work in tests) 108 + // Cmd panel is a thin input bar: 600x50, frameless, transparent 109 + const openResult = await bgWindow.evaluate(async () => { 110 + return await (window as any).app.window.open('peek://app/cmd/panel.html', { 111 + modal: true, 112 + width: 600, 113 + height: 50, 114 + frame: false, 115 + transparent: true, 116 + alwaysOnTop: true, 117 + center: true 118 + }); 119 + }); 120 + expect(openResult.success).toBe(true); 121 + 122 + await new Promise(r => setTimeout(r, 1000)); 123 + 124 + // Find the cmd window 125 + const cmdWindow = await waitForWindow(electronApp, 'cmd/panel.html', 5000); 126 + expect(cmdWindow).toBeTruthy(); 127 + 128 + // Wait for input to be ready 129 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 130 + 131 + // Type 'hello' command 132 + await cmdWindow.fill('input', 'hello'); 133 + await new Promise(r => setTimeout(r, 300)); 134 + 135 + // Press Enter to execute 136 + await cmdWindow.keyboard.press('Enter'); 137 + await new Promise(r => setTimeout(r, 500)); 138 + 139 + // The hello command should have executed 140 + // Close the cmd window 141 + if (openResult.id) { 142 + await bgWindow.evaluate(async (id: number) => { 143 + return await (window as any).app.window.close(id); 144 + }, openResult.id); 145 + } 146 + }); 147 + }); 148 + 149 + test.describe('Peeks', () => { 150 + let electronApp: ElectronApplication; 151 + let bgWindow: Page; 152 + 153 + test.beforeAll(async () => { 154 + electronApp = await electron.launch({ 155 + args: [MAIN_PATH], 156 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 157 + }); 158 + await new Promise(r => setTimeout(r, 4000)); 159 + bgWindow = await waitForWindow(electronApp, 'app/background.html'); 160 + }); 161 + 162 + test.afterAll(async () => { 163 + if (electronApp) await electronApp.close(); 164 + }); 165 + 166 + test('add a peek and test it opens', async () => { 167 + // Add a peek address to the datastore 168 + const addResult = await bgWindow.evaluate(async () => { 169 + return await (window as any).app.datastore.addAddress('https://example.com', { 170 + title: 'Example Peek', 171 + description: 'Test peek for smoke tests' 172 + }); 173 + }); 174 + expect(addResult.success).toBe(true); 175 + const addressId = addResult.id; 176 + 177 + // Get the peeks extension background window 178 + const peeksWindow = electronApp.windows().find(w => 179 + w.url().includes('ext/peeks/background.html') 180 + ); 181 + expect(peeksWindow).toBeTruthy(); 182 + 183 + // Open a peek window for the address we created 184 + const peekResult = await bgWindow.evaluate(async () => { 185 + return await (window as any).app.window.open('https://example.com', { 186 + width: 800, 187 + height: 600, 188 + key: 'test-peek' 189 + }); 190 + }); 191 + expect(peekResult.success).toBe(true); 192 + 193 + await new Promise(r => setTimeout(r, 2000)); 194 + 195 + // Verify window opened 196 + const windows = electronApp.windows(); 197 + const peekWindow = windows.find(w => w.url().includes('example.com')); 198 + expect(peekWindow).toBeTruthy(); 199 + 200 + // Close the peek 201 + if (peekResult.id) { 202 + await bgWindow.evaluate(async (id: number) => { 203 + return await (window as any).app.window.close(id); 204 + }, peekResult.id); 205 + } 206 + }); 207 + }); 208 + 209 + test.describe('Slides', () => { 210 + let electronApp: ElectronApplication; 211 + let bgWindow: Page; 212 + 213 + test.beforeAll(async () => { 214 + electronApp = await electron.launch({ 215 + args: [MAIN_PATH], 216 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 217 + }); 218 + await new Promise(r => setTimeout(r, 4000)); 219 + bgWindow = await waitForWindow(electronApp, 'app/background.html'); 220 + }); 221 + 222 + test.afterAll(async () => { 223 + if (electronApp) await electronApp.close(); 224 + }); 225 + 226 + test('add slides and test they work', async () => { 227 + // Add multiple addresses to use as slides 228 + const urls = [ 229 + 'https://slide1.example.com', 230 + 'https://slide2.example.com', 231 + 'https://slide3.example.com' 232 + ]; 233 + 234 + for (const url of urls) { 235 + const result = await bgWindow.evaluate(async (uri: string) => { 236 + return await (window as any).app.datastore.addAddress(uri, { 237 + title: `Slide: ${uri}`, 238 + starred: 1 // Mark as starred so it shows in slides 239 + }); 240 + }, url); 241 + expect(result.success).toBe(true); 242 + } 243 + 244 + // Verify slides extension is loaded 245 + const slidesWindow = electronApp.windows().find(w => 246 + w.url().includes('ext/slides/background.html') 247 + ); 248 + expect(slidesWindow).toBeTruthy(); 249 + 250 + // Query addresses to verify they were added 251 + const queryResult = await bgWindow.evaluate(async () => { 252 + return await (window as any).app.datastore.queryAddresses({ starred: 1, limit: 10 }); 253 + }); 254 + expect(queryResult.success).toBe(true); 255 + expect(queryResult.data.length).toBeGreaterThanOrEqual(3); 256 + }); 257 + }); 258 + 259 + test.describe('Groups Navigation', () => { 260 + let electronApp: ElectronApplication; 261 + let bgWindow: Page; 262 + 263 + test.beforeAll(async () => { 264 + electronApp = await electron.launch({ 265 + args: [MAIN_PATH], 266 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 267 + }); 268 + await new Promise(r => setTimeout(r, 4000)); 269 + bgWindow = await waitForWindow(electronApp, 'app/background.html'); 270 + }); 271 + 272 + test.afterAll(async () => { 273 + if (electronApp) await electronApp.close(); 274 + }); 275 + 276 + test('groups to group to url and back navigation', async () => { 277 + // Create a tag/group with some addresses 278 + const tagResult = await bgWindow.evaluate(async () => { 279 + return await (window as any).app.datastore.getOrCreateTag('test-group'); 280 + }); 281 + expect(tagResult.success).toBe(true); 282 + const tagId = tagResult.data?.data?.id || tagResult.data?.id; 283 + 284 + // Add addresses and tag them 285 + const addr1 = await bgWindow.evaluate(async () => { 286 + return await (window as any).app.datastore.addAddress('https://group-test-1.example.com', { 287 + title: 'Group Test 1' 288 + }); 289 + }); 290 + expect(addr1.success).toBe(true); 291 + 292 + const addr2 = await bgWindow.evaluate(async () => { 293 + return await (window as any).app.datastore.addAddress('https://group-test-2.example.com', { 294 + title: 'Group Test 2' 295 + }); 296 + }); 297 + expect(addr2.success).toBe(true); 298 + 299 + // Tag the addresses 300 + if (tagId && addr1.id) { 301 + await bgWindow.evaluate(async ({ addressId, tagId }) => { 302 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 303 + }, { addressId: addr1.id, tagId }); 304 + } 305 + 306 + if (tagId && addr2.id) { 307 + await bgWindow.evaluate(async ({ addressId, tagId }) => { 308 + return await (window as any).app.datastore.tagAddress(addressId, tagId); 309 + }, { addressId: addr2.id, tagId }); 310 + } 311 + 312 + // Open groups home 313 + const groupsResult = await bgWindow.evaluate(async () => { 314 + return await (window as any).app.window.open('peek://ext/groups/home.html', { 315 + width: 800, 316 + height: 600 317 + }); 318 + }); 319 + expect(groupsResult.success).toBe(true); 320 + 321 + await new Promise(r => setTimeout(r, 1500)); 322 + 323 + // Find the groups window 324 + const groupsWindow = await waitForWindow(electronApp, 'groups/home.html', 5000); 325 + expect(groupsWindow).toBeTruthy(); 326 + await groupsWindow.waitForLoadState('domcontentloaded'); 327 + 328 + // Wait for cards to render (groups view) 329 + await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 330 + await new Promise(r => setTimeout(r, 500)); 331 + 332 + // STEP 1: Click on the test-group card to navigate to addresses view 333 + const groupCard = await groupsWindow.$('.card.group-card[data-tag-id="' + tagId + '"]'); 334 + if (!groupCard) { 335 + // Try finding any group card if specific one not found 336 + const anyGroupCard = await groupsWindow.$('.card.group-card'); 337 + expect(anyGroupCard).toBeTruthy(); 338 + await anyGroupCard!.click(); 339 + } else { 340 + await groupCard.click(); 341 + } 342 + 343 + await new Promise(r => setTimeout(r, 500)); 344 + 345 + // Verify we're in addresses view (Back button visible, address cards shown) 346 + const backBtn = await groupsWindow.$('.back-btn'); 347 + expect(backBtn).toBeTruthy(); 348 + const backBtnVisible = await backBtn!.evaluate((el: HTMLElement) => el.style.display !== 'none'); 349 + expect(backBtnVisible).toBe(true); 350 + 351 + // STEP 2: Click on an address card to open the URL 352 + const addressCard = await groupsWindow.$('.card.address-card'); 353 + expect(addressCard).toBeTruthy(); 354 + 355 + // Get window count before click 356 + const windowCountBefore = electronApp.windows().length; 357 + await addressCard!.click(); 358 + 359 + await new Promise(r => setTimeout(r, 1500)); 360 + 361 + // Verify a new window was opened (window count increased) 362 + const windowCountAfter = electronApp.windows().length; 363 + expect(windowCountAfter).toBeGreaterThan(windowCountBefore); 364 + 365 + // STEP 3: Click Back button to return to groups list 366 + await backBtn!.click(); 367 + await new Promise(r => setTimeout(r, 500)); 368 + 369 + // Verify we're back in groups view (Back button hidden) 370 + const backBtnAfterClick = await groupsWindow.$('.back-btn'); 371 + const backBtnHidden = await backBtnAfterClick!.evaluate((el: HTMLElement) => el.style.display === 'none'); 372 + expect(backBtnHidden).toBe(true); 373 + 374 + // Verify header shows "Groups" 375 + const headerTitle = await groupsWindow.$eval('.header-title', (el: HTMLElement) => el.textContent); 376 + expect(headerTitle).toBe('Groups'); 377 + 378 + // Clean up - close any remaining windows 379 + if (groupsResult.id) { 380 + try { 381 + await bgWindow.evaluate(async (id: number) => { 382 + return await (window as any).app.window.close(id); 383 + }, groupsResult.id); 384 + } catch { 385 + // Window may already be closed 386 + } 387 + } 388 + 389 + // Verify addresses can be retrieved by tag 390 + if (tagId) { 391 + const taggedAddresses = await bgWindow.evaluate(async (tId: string) => { 392 + return await (window as any).app.datastore.getAddressesByTag(tId); 393 + }, tagId); 394 + expect(taggedAddresses.success).toBe(true); 395 + expect(taggedAddresses.data.length).toBeGreaterThanOrEqual(2); 396 + } 397 + }); 398 + }); 399 + 400 + test.describe('External URL Opening', () => { 401 + test('open URL by calling executable', async () => { 402 + const testUrl = 'https://external-test.example.com'; 403 + 404 + // Launch app with URL argument (simulates clicking a link that opens in Peek) 405 + const electronApp = await electron.launch({ 406 + args: [MAIN_PATH, '--', testUrl], 407 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 408 + }); 409 + 410 + await new Promise(r => setTimeout(r, 5000)); 411 + 412 + // Check if the URL was opened 413 + const windows = electronApp.windows(); 414 + 415 + // The app should have processed the URL argument 416 + // It may have opened it in a window or queued it 417 + expect(windows.length).toBeGreaterThan(0); 418 + 419 + // Verify background window exists (app started correctly) 420 + const bgWindow = windows.find(w => w.url().includes('background.html')); 421 + expect(bgWindow).toBeTruthy(); 422 + 423 + await electronApp.close(); 424 + }); 425 + }); 426 + 427 + // Core functionality tests 428 + test.describe('Core Functionality', () => { 429 + let electronApp: ElectronApplication; 430 + let bgWindow: Page; 431 + 432 + test.beforeAll(async () => { 433 + electronApp = await electron.launch({ 434 + args: [MAIN_PATH], 435 + env: { ...process.env, PROFILE: 'test-smoke', DEBUG: '1' } 436 + }); 437 + await new Promise(r => setTimeout(r, 4000)); 438 + bgWindow = await waitForWindow(electronApp, 'app/background.html'); 439 + }); 440 + 441 + test.afterAll(async () => { 442 + if (electronApp) await electronApp.close(); 443 + }); 444 + 445 + test('app launches and extensions load', async () => { 446 + const extWindows = await getExtensionWindows(electronApp); 447 + expect(extWindows.length).toBeGreaterThanOrEqual(3); 448 + 449 + // Verify specific extensions 450 + const extUrls = extWindows.map(w => w.url()); 451 + expect(extUrls.some(u => u.includes('ext/groups'))).toBe(true); 452 + expect(extUrls.some(u => u.includes('ext/peeks'))).toBe(true); 453 + expect(extUrls.some(u => u.includes('ext/slides'))).toBe(true); 454 + }); 455 + 456 + test('database is accessible', async () => { 457 + const result = await bgWindow.evaluate(async () => { 458 + return await (window as any).app.datastore.getStats(); 459 + }); 460 + expect(result.success).toBe(true); 461 + expect(typeof result.data.totalAddresses).toBe('number'); 462 + }); 463 + 464 + test('commands are registered', async () => { 465 + const result = await bgWindow.evaluate(async () => { 466 + return await (window as any).app.commands.getAll(); 467 + }); 468 + expect(Array.isArray(result)).toBe(true); 469 + expect(result.length).toBeGreaterThan(0); 470 + 471 + // Should have hello command from example extension 472 + const helloCmd = result.find((c: any) => c.name === 'hello'); 473 + expect(helloCmd).toBeTruthy(); 474 + }); 475 + 476 + test('window management works', async () => { 477 + // Open a simple test window (about:blank is lightweight) 478 + const openResult = await bgWindow.evaluate(async () => { 479 + return await (window as any).app.window.open('about:blank', { 480 + width: 400, 481 + height: 300 482 + }); 483 + }); 484 + expect(openResult.success).toBe(true); 485 + expect(openResult.id).toBeDefined(); 486 + 487 + await new Promise(r => setTimeout(r, 500)); 488 + 489 + // List windows 490 + const listResult = await bgWindow.evaluate(async () => { 491 + return await (window as any).app.window.list(); 492 + }); 493 + expect(listResult.success).toBe(true); 494 + expect(Array.isArray(listResult.windows)).toBe(true); 495 + 496 + // Close the window 497 + await bgWindow.evaluate(async (id: number) => { 498 + return await (window as any).app.window.close(id); 499 + }, openResult.id); 500 + }); 501 + });
+56
yarn.lock
··· 323 323 languageName: node 324 324 linkType: hard 325 325 326 + "@playwright/test@npm:^1.57.0": 327 + version: 1.57.0 328 + resolution: "@playwright/test@npm:1.57.0" 329 + dependencies: 330 + playwright: "npm:1.57.0" 331 + bin: 332 + playwright: cli.js 333 + checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774 334 + languageName: node 335 + linkType: hard 336 + 326 337 "@sindresorhus/is@npm:^4.0.0": 327 338 version: 4.6.0 328 339 resolution: "@sindresorhus/is@npm:4.6.0" ··· 474 485 dependencies: 475 486 "@electron/fuses": "npm:^1.8.0" 476 487 "@electron/rebuild": "npm:^4.0.2" 488 + "@playwright/test": "npm:^1.57.0" 477 489 "@types/better-sqlite3": "npm:^7.6.13" 478 490 "@types/node": "npm:^25.0.3" 479 491 better-sqlite3: "npm:^12.5.0" ··· 481 493 electron-builder: "npm:26.0.12" 482 494 electron-unhandled: "npm:^5.0.0" 483 495 lil-gui: "npm:^0.19.2" 496 + playwright: "npm:^1.57.0" 484 497 typescript: "npm:^5.9.3" 485 498 languageName: unknown 486 499 linkType: soft ··· 1722 1735 languageName: node 1723 1736 linkType: hard 1724 1737 1738 + "fsevents@npm:2.3.2": 1739 + version: 2.3.2 1740 + resolution: "fsevents@npm:2.3.2" 1741 + dependencies: 1742 + node-gyp: "npm:latest" 1743 + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b 1744 + conditions: os=darwin 1745 + languageName: node 1746 + linkType: hard 1747 + 1748 + "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>": 1749 + version: 2.3.2 1750 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1" 1751 + dependencies: 1752 + node-gyp: "npm:latest" 1753 + conditions: os=darwin 1754 + languageName: node 1755 + linkType: hard 1756 + 1725 1757 "function-bind@npm:^1.1.2": 1726 1758 version: 1.1.2 1727 1759 resolution: "function-bind@npm:1.1.2" ··· 3002 3034 version: 4.0.3 3003 3035 resolution: "picomatch@npm:4.0.3" 3004 3036 checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 3037 + languageName: node 3038 + linkType: hard 3039 + 3040 + "playwright-core@npm:1.57.0": 3041 + version: 1.57.0 3042 + resolution: "playwright-core@npm:1.57.0" 3043 + bin: 3044 + playwright-core: cli.js 3045 + checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9 3046 + languageName: node 3047 + linkType: hard 3048 + 3049 + "playwright@npm:1.57.0, playwright@npm:^1.57.0": 3050 + version: 1.57.0 3051 + resolution: "playwright@npm:1.57.0" 3052 + dependencies: 3053 + fsevents: "npm:2.3.2" 3054 + playwright-core: "npm:1.57.0" 3055 + dependenciesMeta: 3056 + fsevents: 3057 + optional: true 3058 + bin: 3059 + playwright: cli.js 3060 + checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899 3005 3061 languageName: node 3006 3062 linkType: hard 3007 3063