experiments in a post-browser web
10
fork

Configure Feed

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

feat(extension): add e2e sync tests and preserve original timestamps

- Add comprehensive e2e tests for browser extension sync:
- Browser imports (history, bookmarks, tabs) stay local (syncSource prevents push)
- User-added items (notes, URLs) sync bidirectionally
- Deduplication behavior
- Tags and metadata handling

- Preserve original timestamps as createdAt during imports:
- History: uses earliest visit time
- Bookmarks: uses dateAdded
- User items: use current time (default)

- DataEngine.addItem() now accepts optional createdAt parameter for imports

Tests: 129 passing (all extension tests)

+434 -9
+12 -5
backend/extension/bookmarks.js
··· 26 26 27 27 /** 28 28 * Walk the bookmark tree and collect all bookmark URLs (skip folders). 29 + * Captures dateAdded for preserving original creation time. 29 30 */ 30 31 function collectBookmarks(nodes) { 31 32 const urls = []; 32 33 for (const node of nodes) { 33 34 if (node.url) { 34 - urls.push({ url: node.url, title: node.title || '', id: node.id }); 35 + urls.push({ 36 + url: node.url, 37 + title: node.title || '', 38 + dateAdded: node.dateAdded || 0, 39 + }); 35 40 } 36 41 if (node.children) { 37 42 urls.push(...collectBookmarks(node.children)); ··· 58 63 /** 59 64 * Add a single bookmark as a Peek URL item with tag. 60 65 * If the URL already exists, tags the existing item with from:bookmark. 66 + * Uses dateAdded as createdAt to preserve original bookmark creation time. 61 67 * Returns true if a new item was imported, false if already existed. 62 68 */ 63 - async function addBookmarkItem(url, title, bookmarkId, existingUrlMap) { 69 + async function addBookmarkItem(url, title, dateAdded, existingUrlMap) { 64 70 const existing = existingUrlMap.get(url); 65 71 66 72 if (existing) { ··· 74 80 75 81 const result = await addItem('url', { 76 82 content: url, 77 - metadata: { title, bookmarkId }, 83 + metadata: { title, dateAdded }, 78 84 syncSource: 'bookmark', 85 + createdAt: dateAdded || undefined, 79 86 }); 80 87 81 88 if (result.success) { ··· 102 109 let skipped = 0; 103 110 104 111 for (const bm of bookmarks) { 105 - const added = await addBookmarkItem(bm.url, bm.title, bm.id, existingUrlMap); 112 + const added = await addBookmarkItem(bm.url, bm.title, bm.dateAdded, existingUrlMap); 106 113 if (added) imported++; 107 114 else skipped++; 108 115 } ··· 147 154 if (!bookmark.url) return; // folder, ignore 148 155 149 156 const existingUrlMap = await getExistingUrlMap(); 150 - await addBookmarkItem(bookmark.url, bookmark.title || '', id, existingUrlMap); 157 + await addBookmarkItem(bookmark.url, bookmark.title || '', bookmark.dateAdded || Date.now(), existingUrlMap); 151 158 }; 152 159 153 160 chrome.bookmarks.onCreated.addListener(onCreatedCallback);
+12 -1
backend/extension/history.js
··· 83 83 /** 84 84 * Add a new history item or update an existing one with fresh visit data. 85 85 * Also records item visits for frecency calculation. 86 + * Uses earliest visit time as createdAt to preserve original history date. 86 87 * Returns 'imported' | 'updated' | 'skipped'. 87 88 */ 88 89 async function addOrUpdateHistoryItem(url, historyItem, visits, existingUrlMap) { ··· 92 93 const existing = existingUrlMap.get(url); 93 94 let itemId; 94 95 96 + // Find earliest visit time for createdAt 97 + let earliestVisitTime = historyItem.lastVisitTime || 0; 98 + if (visits && visits.length > 0) { 99 + const visitTimes = visits.map(v => v.visitTime).filter(t => t > 0); 100 + if (visitTimes.length > 0) { 101 + earliestVisitTime = Math.min(...visitTimes); 102 + } 103 + } 104 + 95 105 if (existing) { 96 106 itemId = existing.id; 97 107 // Update existing item with fresh metadata ··· 102 112 await tagItem(existing.id, tagResult.data.tag.id); 103 113 } 104 114 } else { 105 - // Add new item 115 + // Add new item with earliest visit as createdAt 106 116 const result = await addItem('url', { 107 117 content: url, 108 118 metadata, 109 119 syncSource: 'history', 120 + createdAt: earliestVisitTime || undefined, 110 121 }); 111 122 112 123 if (result.success) {
+403
backend/extension/tests/sync-e2e.test.js
··· 1 + /** 2 + * E2E sync tests for browser extension 3 + * 4 + * Tests the complete sync flow: 5 + * - Browser imports (history, bookmarks, tabs) stay local (syncSource prevents push) 6 + * - User-added items (notes, URLs via popup) sync bidirectionally 7 + * - Original timestamps (dateAdded, earliest visit) preserved as createdAt 8 + */ 9 + 10 + import { describe, it, before, beforeEach, afterEach, after } from 'node:test'; 11 + import assert from 'node:assert/strict'; 12 + import { resetMocks, setMockHistoryItems, setMockVisitsByUrl, setBookmarkTree, getStorageData } from './helpers/mocks.js'; 13 + import { initialize, close, data, sync, setConfig } from '../engine.js'; 14 + import { ensureDefaultProfile, getCurrentProfile, enableSync } from '../profiles.js'; 15 + import { importAllHistory } from '../history.js'; 16 + import { importAllBookmarks } from '../bookmarks.js'; 17 + 18 + // Helper to build a mock Response 19 + function jsonResponse(body, status = 200, headers = {}) { 20 + return new Response(JSON.stringify(body), { 21 + status, 22 + headers: { 23 + 'Content-Type': 'application/json', 24 + 'X-Peek-Datastore-Version': '1', 25 + 'X-Peek-Protocol-Version': '1', 26 + ...headers, 27 + }, 28 + }); 29 + } 30 + 31 + describe('e2e sync', () => { 32 + let mockFetchHandler; 33 + let pushedItems; 34 + 35 + before(async () => { 36 + await resetMocks(); 37 + }); 38 + 39 + beforeEach(async () => { 40 + pushedItems = []; 41 + await resetMocks(); 42 + await initialize(); 43 + await ensureDefaultProfile(); 44 + 45 + // Configure sync for default profile 46 + const profile = (await getCurrentProfile()).data; 47 + await enableSync(profile.id, 'test-api-key', 'default'); 48 + await setConfig({ serverUrl: 'https://test-server.example.com', autoSync: false }); 49 + 50 + // Install custom fetch that tracks push requests 51 + mockFetchHandler = null; 52 + sync._fetch = async (url, opts) => { 53 + if (opts && opts.method === 'POST') { 54 + const body = JSON.parse(opts.body); 55 + pushedItems.push(body); 56 + return jsonResponse({ id: `server-${Date.now()}`, created: true }); 57 + } 58 + if (mockFetchHandler) return mockFetchHandler(url, opts); 59 + return jsonResponse({ items: [] }); 60 + }; 61 + }); 62 + 63 + afterEach(async () => { 64 + await close(); 65 + setMockHistoryItems([]); 66 + setMockVisitsByUrl({}); 67 + setBookmarkTree([]); 68 + }); 69 + 70 + // ==================== Browser Imports Stay Local ==================== 71 + 72 + describe('browser imports stay local (not pushed)', () => { 73 + it('history items with syncSource=history should not be pushed', async () => { 74 + setMockHistoryItems([ 75 + { url: 'https://history.example.com', title: 'History Page', lastVisitTime: 1000, visitCount: 5, typedCount: 2 }, 76 + ]); 77 + setMockVisitsByUrl({ 78 + 'https://history.example.com': [ 79 + { visitId: '1', visitTime: 1000, referringVisitId: '0', transition: 'typed' }, 80 + ], 81 + }); 82 + 83 + const importResult = await importAllHistory(); 84 + assert.equal(importResult.imported, 1); 85 + 86 + // Verify item has syncSource = 'history' 87 + const items = await data.queryItems({ type: 'url' }); 88 + assert.equal(items.length, 1); 89 + assert.equal(items[0].syncSource, 'history'); 90 + 91 + // Push should not include this item 92 + const pushResult = await sync.pushToServer(); 93 + assert.equal(pushResult.pushed, 0); 94 + assert.equal(pushedItems.length, 0); 95 + }); 96 + 97 + it('bookmark items with syncSource=bookmark should not be pushed', async () => { 98 + setBookmarkTree([ 99 + { 100 + id: '1', 101 + title: 'Bookmarks Bar', 102 + children: [ 103 + { id: '2', url: 'https://bookmark.example.com', title: 'Bookmarked Site', dateAdded: 1609459200000 }, 104 + ], 105 + }, 106 + ]); 107 + 108 + const importResult = await importAllBookmarks(); 109 + assert.equal(importResult.imported, 1); 110 + 111 + // Verify item has syncSource = 'bookmark' 112 + const items = await data.queryItems({ type: 'url' }); 113 + assert.equal(items.length, 1); 114 + assert.equal(items[0].syncSource, 'bookmark'); 115 + 116 + // Push should not include this item 117 + const pushResult = await sync.pushToServer(); 118 + assert.equal(pushResult.pushed, 0); 119 + assert.equal(pushedItems.length, 0); 120 + }); 121 + 122 + it('mixed browser imports and user items should only push user items', async () => { 123 + // Import history 124 + setMockHistoryItems([ 125 + { url: 'https://history.example.com', title: 'History', lastVisitTime: 1000, visitCount: 1, typedCount: 0 }, 126 + ]); 127 + setMockVisitsByUrl({ 128 + 'https://history.example.com': [{ visitId: '1', visitTime: 1000, referringVisitId: '0', transition: 'link' }], 129 + }); 130 + await importAllHistory(); 131 + 132 + // Import bookmarks 133 + setBookmarkTree([ 134 + { id: '1', children: [{ id: '2', url: 'https://bookmark.example.com', title: 'Bookmark', dateAdded: 1000 }] }, 135 + ]); 136 + await importAllBookmarks(); 137 + 138 + // Add user item (no syncSource) 139 + await data.addItem('text', { content: 'User note from popup' }); 140 + await data.addItem('url', { content: 'https://user-added.example.com' }); 141 + 142 + const items = await data.queryItems(); 143 + assert.equal(items.length, 4); 144 + 145 + // Push should only include user items (2 items without syncSource) 146 + const pushResult = await sync.pushToServer(); 147 + assert.equal(pushResult.pushed, 2); 148 + assert.equal(pushedItems.length, 2); 149 + 150 + const pushedContents = pushedItems.map(i => i.content); 151 + assert.ok(pushedContents.includes('User note from popup')); 152 + assert.ok(pushedContents.includes('https://user-added.example.com')); 153 + assert.ok(!pushedContents.includes('https://history.example.com')); 154 + assert.ok(!pushedContents.includes('https://bookmark.example.com')); 155 + }); 156 + }); 157 + 158 + // ==================== Timestamp Preservation ==================== 159 + 160 + describe('timestamp preservation', () => { 161 + it('history items should use earliest visit time as createdAt', async () => { 162 + const earliestVisit = 1609459200000; // 2021-01-01 163 + const laterVisit = 1640995200000; // 2022-01-01 164 + 165 + setMockHistoryItems([ 166 + { url: 'https://visited.example.com', title: 'Visited Page', lastVisitTime: laterVisit, visitCount: 2, typedCount: 1 }, 167 + ]); 168 + setMockVisitsByUrl({ 169 + 'https://visited.example.com': [ 170 + { visitId: '1', visitTime: earliestVisit, referringVisitId: '0', transition: 'typed' }, 171 + { visitId: '2', visitTime: laterVisit, referringVisitId: '0', transition: 'link' }, 172 + ], 173 + }); 174 + 175 + await importAllHistory(); 176 + 177 + const items = await data.queryItems({ type: 'url' }); 178 + assert.equal(items.length, 1); 179 + assert.equal(items[0].createdAt, earliestVisit); 180 + }); 181 + 182 + it('bookmark items should use dateAdded as createdAt', async () => { 183 + const bookmarkDate = 1577836800000; // 2020-01-01 184 + 185 + setBookmarkTree([ 186 + { 187 + id: '1', 188 + children: [ 189 + { id: '2', url: 'https://bookmarked.example.com', title: 'Old Bookmark', dateAdded: bookmarkDate }, 190 + ], 191 + }, 192 + ]); 193 + 194 + await importAllBookmarks(); 195 + 196 + const items = await data.queryItems({ type: 'url' }); 197 + assert.equal(items.length, 1); 198 + assert.equal(items[0].createdAt, bookmarkDate); 199 + }); 200 + 201 + it('user-added items should use current time as createdAt', async () => { 202 + const beforeAdd = Date.now(); 203 + await data.addItem('text', { content: 'Fresh note' }); 204 + const afterAdd = Date.now(); 205 + 206 + const items = await data.queryItems({ type: 'text' }); 207 + assert.equal(items.length, 1); 208 + assert.ok(items[0].createdAt >= beforeAdd); 209 + assert.ok(items[0].createdAt <= afterAdd); 210 + }); 211 + }); 212 + 213 + // ==================== User Items Sync Bidirectionally ==================== 214 + 215 + describe('user items sync bidirectionally', () => { 216 + it('user-added text notes should push to server', async () => { 217 + await data.addItem('text', { content: 'My quick note' }); 218 + 219 + const pushResult = await sync.pushToServer(); 220 + assert.equal(pushResult.pushed, 1); 221 + assert.equal(pushedItems.length, 1); 222 + assert.equal(pushedItems[0].content, 'My quick note'); 223 + assert.equal(pushedItems[0].type, 'text'); 224 + }); 225 + 226 + it('user-added URLs should push to server', async () => { 227 + await data.addItem('url', { content: 'https://saved-url.example.com' }); 228 + 229 + const pushResult = await sync.pushToServer(); 230 + assert.equal(pushResult.pushed, 1); 231 + assert.equal(pushedItems[0].content, 'https://saved-url.example.com'); 232 + assert.equal(pushedItems[0].type, 'url'); 233 + }); 234 + 235 + it('server items should pull to local', async () => { 236 + const serverItem = { 237 + id: 'server-item-1', 238 + type: 'text', 239 + content: 'Note from server', 240 + tags: [], 241 + metadata: null, 242 + createdAt: Date.now() - 10000, 243 + updatedAt: Date.now(), 244 + }; 245 + 246 + mockFetchHandler = async () => jsonResponse({ items: [serverItem] }); 247 + 248 + const pullResult = await sync.pullFromServer(); 249 + assert.equal(pullResult.pulled, 1); 250 + 251 + const items = await data.queryItems({ type: 'text' }); 252 + assert.equal(items.length, 1); 253 + assert.equal(items[0].content, 'Note from server'); 254 + assert.equal(items[0].syncId, 'server-item-1'); 255 + assert.equal(items[0].syncSource, 'server'); 256 + }); 257 + 258 + it('full sync should pull then push', async () => { 259 + // Add local item 260 + await data.addItem('text', { content: 'Local item to push' }); 261 + 262 + // Mock server with an item to pull 263 + const serverItem = { 264 + id: 'server-sync-item', 265 + type: 'url', 266 + content: 'https://from-server.example.com', 267 + tags: [], 268 + metadata: null, 269 + createdAt: Date.now() - 10000, 270 + updatedAt: Date.now(), 271 + }; 272 + 273 + mockFetchHandler = async () => jsonResponse({ items: [serverItem] }); 274 + 275 + const syncResult = await sync.syncAll(); 276 + assert.equal(syncResult.pulled, 1); 277 + assert.equal(syncResult.pushed, 1); 278 + 279 + const items = await data.queryItems(); 280 + assert.equal(items.length, 2); 281 + }); 282 + }); 283 + 284 + // ==================== Deduplication ==================== 285 + 286 + describe('deduplication', () => { 287 + it('history import should update existing URL items instead of duplicating', async () => { 288 + // First, add a URL item manually 289 + await data.addItem('url', { content: 'https://existing.example.com' }); 290 + 291 + // Now import history with same URL 292 + setMockHistoryItems([ 293 + { url: 'https://existing.example.com', title: 'Existing Page', lastVisitTime: 5000, visitCount: 10, typedCount: 3 }, 294 + ]); 295 + setMockVisitsByUrl({ 296 + 'https://existing.example.com': [{ visitId: '1', visitTime: 5000, referringVisitId: '0', transition: 'typed' }], 297 + }); 298 + 299 + const result = await importAllHistory(); 300 + assert.equal(result.updated, 1); 301 + assert.equal(result.imported, 0); 302 + 303 + // Should still be only one item 304 + const items = await data.queryItems({ type: 'url' }); 305 + const matching = items.filter(i => i.content === 'https://existing.example.com'); 306 + assert.equal(matching.length, 1); 307 + }); 308 + 309 + it('bookmark import should tag existing URL items instead of duplicating', async () => { 310 + // First, add a URL item manually 311 + await data.addItem('url', { content: 'https://existing.example.com' }); 312 + 313 + // Now import bookmark with same URL 314 + setBookmarkTree([ 315 + { id: '1', children: [{ id: '2', url: 'https://existing.example.com', title: 'Existing', dateAdded: 1000 }] }, 316 + ]); 317 + 318 + const result = await importAllBookmarks(); 319 + assert.equal(result.skipped, 1); 320 + assert.equal(result.imported, 0); 321 + 322 + // Should still be only one item 323 + const items = await data.queryItems({ type: 'url' }); 324 + const matching = items.filter(i => i.content === 'https://existing.example.com'); 325 + assert.equal(matching.length, 1); 326 + }); 327 + 328 + it('re-importing history should update metadata, not create duplicates', async () => { 329 + setMockHistoryItems([ 330 + { url: 'https://reimport.example.com', title: 'First Import', lastVisitTime: 1000, visitCount: 1, typedCount: 0 }, 331 + ]); 332 + setMockVisitsByUrl({ 333 + 'https://reimport.example.com': [{ visitId: '1', visitTime: 1000, referringVisitId: '0', transition: 'link' }], 334 + }); 335 + 336 + const first = await importAllHistory(); 337 + assert.equal(first.imported, 1); 338 + 339 + // Update mock data and reimport 340 + setMockHistoryItems([ 341 + { url: 'https://reimport.example.com', title: 'Updated Title', lastVisitTime: 5000, visitCount: 5, typedCount: 2 }, 342 + ]); 343 + setMockVisitsByUrl({ 344 + 'https://reimport.example.com': [ 345 + { visitId: '1', visitTime: 1000, referringVisitId: '0', transition: 'link' }, 346 + { visitId: '2', visitTime: 5000, referringVisitId: '0', transition: 'typed' }, 347 + ], 348 + }); 349 + 350 + const second = await importAllHistory(); 351 + assert.equal(second.imported, 0); 352 + assert.equal(second.updated, 1); 353 + 354 + // Verify only one item exists with updated metadata 355 + const items = await data.queryItems({ type: 'url' }); 356 + const matching = items.filter(i => i.content === 'https://reimport.example.com'); 357 + assert.equal(matching.length, 1); 358 + 359 + const meta = JSON.parse(matching[0].metadata); 360 + assert.equal(meta.title, 'Updated Title'); 361 + assert.equal(meta.visitCount, 5); 362 + }); 363 + }); 364 + 365 + // ==================== Tags ==================== 366 + 367 + describe('tags and metadata', () => { 368 + it('user items with tags should push tags to server', async () => { 369 + const { tag } = await data.getOrCreateTag('important'); 370 + const { id } = await data.addItem('text', { content: 'Tagged note' }); 371 + await data.tagItem(id, tag.id); 372 + 373 + await sync.pushToServer(); 374 + 375 + assert.equal(pushedItems.length, 1); 376 + assert.ok(pushedItems[0].tags.includes('important')); 377 + }); 378 + 379 + it('server items with tags should create local tags', async () => { 380 + const serverItem = { 381 + id: 'server-tagged', 382 + type: 'text', 383 + content: 'Server tagged item', 384 + tags: ['work', 'urgent'], 385 + metadata: null, 386 + createdAt: Date.now() - 10000, 387 + updatedAt: Date.now(), 388 + }; 389 + 390 + mockFetchHandler = async () => jsonResponse({ items: [serverItem] }); 391 + 392 + await sync.pullFromServer(); 393 + 394 + const items = await data.queryItems({ type: 'text' }); 395 + assert.equal(items.length, 1); 396 + 397 + const tags = await data.getItemTags(items[0].id); 398 + const tagNames = tags.map(t => t.name); 399 + assert.ok(tagNames.includes('work')); 400 + assert.ok(tagNames.includes('urgent')); 401 + }); 402 + }); 403 + });
+2
package.json
··· 123 123 "test:e2e:mobile": "LOCAL_IP=localhost ./scripts/e2e-full-sync-test.sh --headless --build", 124 124 "test:mobile": "cd backend/tauri-mobile && npm test", 125 125 "test:extension": "node --test backend/extension/tests/*.test.js", 126 + "test:extension:e2e": "node --test backend/extension/tests/sync-e2e.test.js", 127 + "test:extension:e2e:verbose": "VERBOSE=1 node --test backend/extension/tests/sync-e2e.test.js", 126 128 "extension:chrome": "backend/extension/scripts/launch-chrome.sh", 127 129 "extension:firefox": "web-ext run --source-dir backend/extension --firefox-profile /tmp/peek-firefox-profile --keep-profile-changes --no-reload", 128 130 "//-- Packaged Electron --//": "",
+5 -3
sync/data.js
··· 39 39 * @param {string|null} [options.metadata] - JSON string 40 40 * @param {string} [options.syncId] 41 41 * @param {string} [options.syncSource] 42 + * @param {number} [options.createdAt] - Override creation timestamp (for imports) 42 43 * @returns {Promise<{id: string}>} 43 44 */ 44 45 async addItem(type, options = {}) { 45 46 const id = generateId(); 46 - const timestamp = Date.now(); 47 + const now = Date.now(); 48 + const createdAt = options.createdAt || now; 47 49 48 50 let metadata = null; 49 51 if (options.metadata !== undefined && options.metadata !== null) { ··· 61 63 syncId: options.syncId || '', 62 64 syncSource: options.syncSource || '', 63 65 syncedAt: 0, 64 - createdAt: timestamp, 65 - updatedAt: timestamp, 66 + createdAt, 67 + updatedAt: now, 66 68 deletedAt: 0, 67 69 }); 68 70