From da72f257ff134a640cae92e687b915a4d6c69ba4 Mon Sep 17 00:00:00 2001 From: Master Preshy Date: Sat, 7 Mar 2026 13:57:27 +0100 Subject: [PATCH 1/5] fix: prevent deleted toolbar bookmarks from being reverted by auto-sync Race condition: when a user deletes a bookmark while a sync is running, performSync's storeTombstones() overwrites the tombstone created by the onRemoved listener, causing the follow-up sync to re-add the bookmark from cloud. Fix: re-read current tombstones from storage before writing to preserve any tombstones added concurrently during the sync. --- .../__tests__/toolbar-delete-revert.test.js | 1202 +++++++++++++++++ apps/extension/src/background/index.js | 20 +- 2 files changed, 1216 insertions(+), 6 deletions(-) create mode 100644 apps/extension/__tests__/toolbar-delete-revert.test.js diff --git a/apps/extension/__tests__/toolbar-delete-revert.test.js b/apps/extension/__tests__/toolbar-delete-revert.test.js new file mode 100644 index 0000000..e82aa94 --- /dev/null +++ b/apps/extension/__tests__/toolbar-delete-revert.test.js @@ -0,0 +1,1202 @@ +/** + * Tests for the bug: "Delete a bookmark in the bookmarks toolbar, wait for + * auto-sync to run, it is reverted." + * + * Scenario: + * 1. User has a toolbar bookmark that is already synced to cloud + * 2. User deletes the bookmark from the toolbar + * 3. onRemoved fires → tombstone is created locally + * 4. Auto-sync alarm fires → performSync() runs + * 5. Bug: the bookmark is re-added from cloud, reverting the deletion + * + * Root cause candidates tested here: + * A. categorizeCloudBookmarks fails to filter the deleted bookmark + * (tombstone date comparison issue, dateAdded format issue) + * B. Tombstone is lost between creation and sync (race condition + * where storeTombstones in performSync overwrites the tombstone + * added by onRemoved) + * C. The push to cloud doesn't remove the bookmark, so subsequent + * syncs keep re-adding it + * D. dateAdded on cloud is newer than the tombstone's deletedAt + * (caused by server normalizing dateAdded to Date.now() for + * falsy values) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ============================================================================= +// Functions extracted from background/index.js for direct testing +// ============================================================================= + +/** + * Normalize folder path for cross-browser comparison + * (from background/index.js ~line 405) + */ +function normalizeFolderPath(path) { + if (!path) return ''; + return path + .replace(/^Bookmarks Bar\/?/i, 'toolbar/') + .replace(/^Bookmarks Toolbar\/?/i, 'toolbar/') + .replace(/^Speed Dial\/?/i, 'toolbar/') + .replace(/^Favourites Bar\/?/i, 'toolbar/') + .replace(/^Favorites Bar\/?/i, 'toolbar/') + .replace(/^Other Bookmarks\/?/i, 'other/') + .replace(/^Unsorted Bookmarks\/?/i, 'other/') + .replace(/^Bookmarks Menu\/?/i, 'menu/') + .replace(/\/+$/, ''); +} + +/** + * Check if a bookmark needs to be updated based on cloud data + * (from background/index.js ~line 433) + */ +function bookmarkNeedsUpdate(cloudBm, localBm) { + if ((cloudBm.title ?? '') !== (localBm.title ?? '')) return true; + const cloudFolder = normalizeFolderPath(cloudBm.folderPath); + const localFolder = normalizeFolderPath(localBm.folderPath); + if (cloudFolder !== localFolder) return true; + if (cloudBm.index !== undefined && localBm.index !== undefined && cloudBm.index !== localBm.index) + return true; + return false; +} + +/** + * Categorize cloud bookmarks into those to add vs update vs skip + * (from background/index.js ~line 457) + * + * This is the critical function for this bug - it decides whether a + * cloud bookmark should be added to the local browser. + */ +function categorizeCloudBookmarks( + cloudBookmarks, + localBookmarks, + tombstones, + _locallyModifiedBookmarkIds = new Set() +) { + const localByUrl = new Map(localBookmarks.filter((b) => b.url).map((b) => [b.url, b])); + + const toAdd = []; + const toUpdate = []; + const skippedByTombstone = []; + + for (const cloudBm of cloudBookmarks) { + if (!cloudBm.url) continue; + + // Check tombstones - only add if bookmark is newer than tombstone + const tombstone = tombstones.find((t) => t.url === cloudBm.url); + if (tombstone) { + const rawDate = cloudBm.dateAdded; + const bookmarkDate = typeof rawDate === 'string' ? new Date(rawDate).getTime() : rawDate || 0; + const tombstoneDate = tombstone.deletedAt || 0; + if (isNaN(bookmarkDate) || bookmarkDate <= tombstoneDate) { + skippedByTombstone.push({ + bookmark: cloudBm, + tombstone, + reason: `dateAdded(${bookmarkDate}) <= deletedAt(${tombstoneDate})`, + }); + continue; + } + } + + const localBm = localByUrl.get(cloudBm.url); + if (!localBm) { + toAdd.push(cloudBm); + } else if (bookmarkNeedsUpdate(cloudBm, localBm)) { + if (_locallyModifiedBookmarkIds.has(localBm.id)) { + const cloudFolder = normalizeFolderPath(cloudBm.folderPath); + const localFolder = normalizeFolderPath(localBm.folderPath); + const titleChanged = (cloudBm.title ?? '') !== (localBm.title ?? ''); + const folderChanged = cloudFolder !== localFolder; + if (!titleChanged && !folderChanged) { + continue; + } + } + toUpdate.push({ cloud: cloudBm, local: localBm }); + } + } + + return { toAdd, toUpdate, skippedByTombstone }; +} + +/** + * Merge local and cloud tombstones, keeping the newest deletion time + * (from background/index.js ~line 2044) + */ +function mergeTombstonesLocal(localTombstones, cloudTombstones) { + const tombstoneMap = new Map(); + for (const t of localTombstones) { + tombstoneMap.set(t.url, t.deletedAt); + } + for (const t of cloudTombstones) { + const existing = tombstoneMap.get(t.url); + if (!existing || t.deletedAt > existing) { + tombstoneMap.set(t.url, t.deletedAt); + } + } + return Array.from(tombstoneMap.entries()).map(([url, deletedAt]) => ({ url, deletedAt })); +} + +/** + * Filter cloud tombstones to only include those that should be applied + * (from background/index.js ~line 343) + */ +function filterTombstonesToApply(cloudTombstones, localTombstones, lastSyncTime) { + if (!cloudTombstones || cloudTombstones.length === 0) return []; + if (!lastSyncTime) return []; + + const localTombstoneUrls = new Set(localTombstones.map((t) => t.url)); + + return cloudTombstones.filter((tombstone) => { + if (localTombstoneUrls.has(tombstone.url)) return true; + if (tombstone.deletedAt > lastSyncTime) return true; + return false; + }); +} + +/** + * Flatten a bookmark tree into a flat array + * (from background/index.js ~line 1456) + */ +function flattenBookmarkTree(tree) { + const items = []; + + function traverse(nodes, parentPath = '') { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const nodeIndex = node.index ?? i; + + if (node.url) { + items.push({ + type: 'bookmark', + id: node.id, + url: node.url, + title: node.title ?? '', + folderPath: parentPath, + dateAdded: node.dateAdded, + index: nodeIndex, + }); + } else if (node.children) { + const folderPath = node.title + ? parentPath + ? `${parentPath}/${node.title}` + : node.title + : parentPath; + if (node.title && parentPath) { + items.push({ + type: 'folder', + id: node.id, + title: node.title, + folderPath: parentPath, + dateAdded: node.dateAdded, + index: nodeIndex, + }); + } + traverse(node.children, folderPath); + } + } + } + + traverse(tree); + return items; +} + +/** + * Server-side: Apply tombstones to filter bookmarks (replace mode) + * (from apps/web/app/api/bookmarks/route.js ~line 905) + */ +function serverApplyTombstonesReplaceMode(bookmarks, tombstones) { + const tombstoneMap = new Map(tombstones.map((t) => [t.url, t.deletedAt || 0])); + return bookmarks.filter((b) => { + if (!b.url) return true; + const tombstoneDate = tombstoneMap.get(b.url); + if (tombstoneDate === undefined) return true; + const bookmarkDate = + typeof b.dateAdded === 'string' ? new Date(b.dateAdded).getTime() : b.dateAdded || 0; + return bookmarkDate > tombstoneDate; + }); +} + +/** + * Server-side: Merge tombstones with age-based cleanup + * (from apps/web/app/api/bookmarks/route.js ~line 375) + */ +function serverMergeTombstones(existingTombstones, incomingTombstones) { + const tombstoneMap = new Map(); + const existingArray = Array.isArray(existingTombstones) ? existingTombstones : []; + const incomingArray = Array.isArray(incomingTombstones) ? incomingTombstones : []; + + for (const tombstone of existingArray) { + if (tombstone && tombstone.url) { + tombstoneMap.set(tombstone.url, tombstone); + } + } + for (const incoming of incomingArray) { + if (!incoming || !incoming.url) continue; + const existing = tombstoneMap.get(incoming.url); + if (!existing || incoming.deletedAt > existing.deletedAt) { + tombstoneMap.set(incoming.url, incoming); + } + } + return Array.from(tombstoneMap.values()); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('Bug: Delete toolbar bookmark → auto-sync reverts it', () => { + // Timestamps for the scenario + const T_BOOKMARK_CREATED = 1700000000000; // When the bookmark was originally created + const T_LAST_SYNC = 1700100000000; // Last successful sync (before deletion) + const T_USER_DELETES = 1700200000000; // User deletes the bookmark + const T_AUTO_SYNC_FIRES = 1700205000000; // Auto-sync fires 5 seconds later + + // The bookmark that exists in both local browser and cloud + const toolbarBookmark = { + url: 'https://example.com', + title: 'Example Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + type: 'bookmark', + }; + + // Other bookmarks that should remain untouched + const otherBookmark = { + url: 'https://other.com', + title: 'Other Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 1, + type: 'bookmark', + }; + + describe('Core: categorizeCloudBookmarks must filter tombstoned bookmark', () => { + it('should NOT add cloud bookmark when local tombstone exists (dateAdded is number)', () => { + // After user deletes the bookmark: + // - Local bookmarks: only "Other Site" (deleted bookmark is gone) + // - Local tombstones: tombstone for "https://example.com" + // - Cloud bookmarks: still has BOTH bookmarks (not yet pushed) + // - Cloud tombstones: none + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + const cloudBookmarks = [toolbarBookmark, otherBookmark]; + const localTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + const cloudTombstones = []; + const mergedTombstones = mergeTombstonesLocal(localTombstones, cloudTombstones); + + const { toAdd, skippedByTombstone } = categorizeCloudBookmarks( + cloudBookmarks, + localBookmarks, + mergedTombstones + ); + + // The deleted bookmark must NOT be in toAdd + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + // It should be in skippedByTombstone + expect( + skippedByTombstone.find((s) => s.bookmark.url === 'https://example.com') + ).toBeDefined(); + // The other bookmark already exists locally, so nothing to add + expect(toAdd).toHaveLength(0); + }); + + it('should NOT add cloud bookmark when dateAdded is an ISO string', () => { + // Cloud might return dateAdded as an ISO string (from convertBrowserBookmarks) + const cloudBookmarkWithStringDate = { + ...toolbarBookmark, + dateAdded: new Date(T_BOOKMARK_CREATED).toISOString(), + }; + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + const mergedTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks( + [cloudBookmarkWithStringDate, otherBookmark], + localBookmarks, + mergedTombstones + ); + + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + + it('should NOT add cloud bookmark when dateAdded is 0 (falsy)', () => { + const cloudBookmarkNoDate = { ...toolbarBookmark, dateAdded: 0 }; + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + const mergedTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks( + [cloudBookmarkNoDate, otherBookmark], + localBookmarks, + mergedTombstones + ); + + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + + it('should NOT add cloud bookmark when dateAdded is undefined', () => { + const cloudBookmarkNoDate = { ...toolbarBookmark, dateAdded: undefined }; + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + const mergedTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks( + [cloudBookmarkNoDate, otherBookmark], + localBookmarks, + mergedTombstones + ); + + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + + it('should NOT add cloud bookmark when dateAdded is NaN (invalid string)', () => { + const cloudBookmarkBadDate = { ...toolbarBookmark, dateAdded: 'not-a-date' }; + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + const mergedTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks( + [cloudBookmarkBadDate, otherBookmark], + localBookmarks, + mergedTombstones + ); + + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + + it('CRITICAL: should NOT add when server set dateAdded to Date.now() during previous push', () => { + // The server normalizes: dateAdded = ... || Date.now() + // If dateAdded was somehow falsy during the last push, the server + // stores Date.now() as dateAdded. This could make the cloud bookmark's + // dateAdded be VERY CLOSE to the tombstone's deletedAt. + // + // Even in this worst case, the bookmark should not be re-added because + // the user deleted it AFTER the last sync (which set dateAdded). + + // Previous sync set dateAdded to Date.now() at push time (T_LAST_SYNC) + const cloudBookmarkServerDate = { + ...toolbarBookmark, + dateAdded: T_LAST_SYNC, // Set by server during last push + }; + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + // Tombstone created AFTER the last sync + const mergedTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks( + [cloudBookmarkServerDate, otherBookmark], + localBookmarks, + mergedTombstones + ); + + // T_LAST_SYNC < T_USER_DELETES, so tombstone wins + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + + it('EDGE CASE: should NOT add when dateAdded equals deletedAt exactly', () => { + const sameTime = T_USER_DELETES; + const cloudBookmarkSameDate = { ...toolbarBookmark, dateAdded: sameTime }; + + const localBookmarks = [{ ...otherBookmark, id: 'bm-2' }]; + const mergedTombstones = [{ url: 'https://example.com', deletedAt: sameTime }]; + + const { toAdd } = categorizeCloudBookmarks( + [cloudBookmarkSameDate, otherBookmark], + localBookmarks, + mergedTombstones + ); + + // When dates are equal, tombstone wins (bookmarkDate <= tombstoneDate) + expect(toAdd.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + }); + + describe('Full single-browser sync flow after deletion', () => { + it('should not re-add deleted toolbar bookmark during auto-sync', () => { + // === STATE BEFORE DELETION === + // Both local and cloud have the bookmark. Last sync was at T_LAST_SYNC. + + // === USER DELETES BOOKMARK === + // onRemoved fires → tombstone created + + // === AUTO-SYNC FIRES (performSync flow) === + + // Step 1: Get local bookmarks (deleted bookmark is GONE) + const localTree = [ + { + id: '0', + children: [ + { + id: '1', + title: 'Bookmarks Bar', + children: [ + { + id: 'bm-2', + url: 'https://other.com', + title: 'Other Site', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + }, + ], + }, + { id: '2', title: 'Other Bookmarks', children: [] }, + ], + }, + ]; + const localFlat = flattenBookmarkTree(localTree); + expect(localFlat.find((b) => b.url === 'https://example.com')).toBeUndefined(); + + // Step 1b: Get local tombstones + const localTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + // Step 2: Get cloud data (still has the bookmark, no tombstones yet) + const cloudBookmarks = [ + { + url: 'https://example.com', + title: 'Example Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + type: 'bookmark', + }, + { + url: 'https://other.com', + title: 'Other Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 1, + type: 'bookmark', + }, + ]; + const cloudTombstones = []; + + // Step 2.5: Get last sync time + const lastSyncTime = T_LAST_SYNC; + + // Step 3: Apply cloud tombstones to local (none to apply) + const tombstonesToApply = filterTombstonesToApply( + cloudTombstones, + localTombstones, + lastSyncTime + ); + expect(tombstonesToApply).toHaveLength(0); + + // Step 4: Merge tombstones + const mergedTombstones = mergeTombstonesLocal(localTombstones, cloudTombstones); + expect(mergedTombstones).toHaveLength(1); + expect(mergedTombstones[0].url).toBe('https://example.com'); + + // Step 5: Re-read local bookmarks (same as step 1 since nothing was applied) + const updatedLocalFlat = localFlat; + + // Step 6: Categorize cloud bookmarks + const { toAdd: newFromCloud, toUpdate: bookmarksToUpdate } = categorizeCloudBookmarks( + cloudBookmarks, + updatedLocalFlat, + mergedTombstones + ); + + // *** THIS IS THE CRITICAL ASSERTION *** + // The deleted bookmark must NOT be in newFromCloud + expect(newFromCloud).toHaveLength(0); + expect(newFromCloud.find((b) => b.url === 'https://example.com')).toBeUndefined(); + + // Step 7: No new bookmarks to add locally (good - deletion preserved) + + // Step 8: Final local state still doesn't have the deleted bookmark + const finalFlat = localFlat; // No changes were made to local + expect(finalFlat.find((b) => b.url === 'https://example.com')).toBeUndefined(); + expect(finalFlat.filter((b) => b.url).length).toBe(1); // Only "Other Site" + + // Step 9: Push to cloud + // The pushed data should NOT include the deleted bookmark + const pushedBookmarks = finalFlat; + expect(pushedBookmarks.find((b) => b.url === 'https://example.com')).toBeUndefined(); + + // Server applies tombstones to pushed data (safety net) + const serverFinal = serverApplyTombstonesReplaceMode(pushedBookmarks, mergedTombstones); + expect(serverFinal.find((b) => b.url === 'https://example.com')).toBeUndefined(); + + // Server merges tombstones + const serverTombstones = serverMergeTombstones(cloudTombstones, mergedTombstones); + expect(serverTombstones).toHaveLength(1); + expect(serverTombstones[0].url).toBe('https://example.com'); + }); + }); + + describe('Second sync after deletion should also not revert', () => { + it('should not re-add on subsequent syncs after successful push', () => { + // After the first sync post-deletion: + // - Cloud now has: [other.com] + tombstone for example.com + // - Local has: [other.com] + tombstone for example.com + // - Last sync time updated to T_AUTO_SYNC_FIRES + + const T_SECOND_SYNC = T_AUTO_SYNC_FIRES + 300000; // 5 min later + + const localFlat = [ + { + type: 'bookmark', + id: 'bm-2', + url: 'https://other.com', + title: 'Other Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + }, + ]; + + const cloudBookmarks = [ + { + url: 'https://other.com', + title: 'Other Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + type: 'bookmark', + }, + ]; + const cloudTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + const localTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + const lastSyncTime = T_AUTO_SYNC_FIRES; + + // Step 3: Apply cloud tombstones (tombstone matches local, would be applied + // but bookmark doesn't exist locally so nothing happens) + const tombstonesToApply = filterTombstonesToApply( + cloudTombstones, + localTombstones, + lastSyncTime + ); + // Tombstone passes filter because local has it too + expect(tombstonesToApply).toHaveLength(1); + + // Step 4: Merge tombstones + const mergedTombstones = mergeTombstonesLocal(localTombstones, cloudTombstones); + expect(mergedTombstones).toHaveLength(1); + + // Step 6: Categorize - cloud doesn't have the deleted bookmark + const { toAdd } = categorizeCloudBookmarks(cloudBookmarks, localFlat, mergedTombstones); + expect(toAdd).toHaveLength(0); + }); + }); + + describe('Tombstone race condition: storeTombstones during sync', () => { + it('OLD BUG: demonstrates tombstone loss without re-read fix', () => { + // This test demonstrates the race condition that CAUSED the bug. + // The fix (re-reading tombstones before writing) prevents this. + // + // Without the fix: + // 1. performSync starts, reads localTombstones = [] (no tombstones yet) + // 2. During sync, user deletes a bookmark + // 3. onRemoved fires, addTombstone writes tombstone to storage + // 4. performSync continues, calls storeTombstones(mergedTombstones) + // where mergedTombstones was computed from the OLD localTombstones (empty) + // 5. The tombstone from step 3 is OVERWRITTEN and lost! + // 6. Follow-up sync has no tombstone → bookmark is re-added from cloud + + const storage = {}; + + // Step 1: performSync reads tombstones (empty) + const tombstonesAtSyncStart = storage['marksyncr-tombstones'] || []; + expect(tombstonesAtSyncStart).toHaveLength(0); + + // Step 2-3: User deletes during sync, tombstone is written + storage['marksyncr-tombstones'] = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + // Step 4: performSync merges OLD tombstones with cloud (both empty) + const mergedTombstones = mergeTombstonesLocal(tombstonesAtSyncStart, []); + expect(mergedTombstones).toHaveLength(0); // No tombstones in merge! + + // WITHOUT FIX: blindly overwrite → tombstone lost + const unfixedStorage = [...mergedTombstones]; + expect(unfixedStorage).toHaveLength(0); + + // WITH FIX: re-read current storage before writing + const currentTombstones = storage['marksyncr-tombstones'] || []; + const safeMerged = mergeTombstonesLocal(currentTombstones, mergedTombstones); + storage['marksyncr-tombstones'] = safeMerged; + + // Tombstone is preserved with the fix! + expect(storage['marksyncr-tombstones']).toHaveLength(1); + expect(storage['marksyncr-tombstones'][0].url).toBe('https://example.com'); + }); + + it('FIX: re-reading tombstones before writing preserves concurrent tombstones', () => { + // This test verifies the fix: performSync re-reads tombstones from + // storage before writing, so concurrent tombstones are not lost. + + const storage = {}; + + // Step 1: performSync reads tombstones (empty) + const tombstonesAtSyncStart = storage['marksyncr-tombstones'] || []; + + // Step 2-3: User deletes during sync + storage['marksyncr-tombstones'] = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + // Step 4: Merge OLD tombstones with cloud + const mergedFromSync = mergeTombstonesLocal(tombstonesAtSyncStart, []); + + // FIX: Before writing, re-read current storage and merge + const currentTombstones = storage['marksyncr-tombstones'] || []; + const safelyMerged = mergeTombstonesLocal(currentTombstones, mergedFromSync); + + // Write the safely merged tombstones + storage['marksyncr-tombstones'] = safelyMerged; + + // Tombstone is preserved! + expect(storage['marksyncr-tombstones']).toHaveLength(1); + expect(storage['marksyncr-tombstones'][0].url).toBe('https://example.com'); + + // Follow-up sync correctly filters the cloud bookmark + const { toAdd } = categorizeCloudBookmarks( + [ + { + url: 'https://example.com', + title: 'Example', + dateAdded: T_BOOKMARK_CREATED, + folderPath: 'Bookmarks Bar', + index: 0, + }, + ], + [], + safelyMerged + ); + expect(toAdd).toHaveLength(0); + }); + + it('FIX: handles multiple concurrent deletions during sync', () => { + const storage = {}; + + // performSync starts with one existing tombstone + const existingTombstones = [{ url: 'https://old-delete.com', deletedAt: T_BOOKMARK_CREATED }]; + storage['marksyncr-tombstones'] = [...existingTombstones]; + + // performSync reads tombstones at start + const tombstonesAtSyncStart = storage['marksyncr-tombstones']; + + // User deletes two bookmarks during sync + storage['marksyncr-tombstones'] = [ + ...existingTombstones, + { url: 'https://example.com', deletedAt: T_USER_DELETES }, + { url: 'https://another.com', deletedAt: T_USER_DELETES + 100 }, + ]; + + // performSync merges its tombstones with cloud + const cloudTombstones = [{ url: 'https://cloud-delete.com', deletedAt: T_LAST_SYNC }]; + const mergedFromSync = mergeTombstonesLocal(tombstonesAtSyncStart, cloudTombstones); + + // FIX: re-read before writing + const currentTombstones = storage['marksyncr-tombstones'] || []; + const safelyMerged = mergeTombstonesLocal(currentTombstones, mergedFromSync); + storage['marksyncr-tombstones'] = safelyMerged; + + // ALL tombstones are preserved: old + 2 concurrent + cloud + const urls = safelyMerged.map((t) => t.url).sort(); + expect(urls).toEqual([ + 'https://another.com', + 'https://cloud-delete.com', + 'https://example.com', + 'https://old-delete.com', + ]); + }); + }); + + describe('Server-side: push must remove bookmark and store tombstone', () => { + it('should remove the deleted bookmark from cloud during push (replace mode)', () => { + // The extension pushes its local state (without the deleted bookmark) + // plus merged tombstones (with the deletion tombstone) + const pushedBookmarks = [ + { + url: 'https://other.com', + title: 'Other Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + type: 'bookmark', + }, + ]; + const pushedTombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + // Server's existing state (still has the bookmark) + const existingBookmarks = [ + { + url: 'https://example.com', + title: 'Example Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 0, + }, + { + url: 'https://other.com', + title: 'Other Site', + folderPath: 'Bookmarks Bar', + dateAdded: T_BOOKMARK_CREATED, + index: 1, + }, + ]; + const existingTombstones = []; + + // Server merges tombstones + const serverMerged = serverMergeTombstones(existingTombstones, pushedTombstones); + expect(serverMerged).toHaveLength(1); + + // Server applies tombstones to pushed bookmarks (replace mode) + const serverFinal = serverApplyTombstonesReplaceMode(pushedBookmarks, serverMerged); + + // The deleted bookmark should not be in the final result + expect(serverFinal.find((b) => b.url === 'https://example.com')).toBeUndefined(); + expect(serverFinal).toHaveLength(1); + expect(serverFinal[0].url).toBe('https://other.com'); + }); + }); + + describe('Firefox toolbar (different folderPath naming)', () => { + it('should handle Firefox "Bookmarks Toolbar" path correctly', () => { + const cloudBookmark = { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Toolbar', // Firefox naming + dateAdded: T_BOOKMARK_CREATED, + index: 0, + }; + + const localBookmarks = []; + const tombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks([cloudBookmark], localBookmarks, tombstones); + + // Should still be filtered by tombstone regardless of folder naming + expect(toAdd).toHaveLength(0); + }); + }); + + describe('Bookmark with dateAdded set by server Date.now() fallback', () => { + it('should handle when server set dateAdded to Date.now() (close to tombstone time)', () => { + // Worst case: the server's Date.now() fallback set dateAdded to a time + // that is very close to (but still before) the deletion. + + // Server normalized dateAdded during push at T_LAST_SYNC + const cloudBookmark = { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Bar', + dateAdded: T_LAST_SYNC, // Server's Date.now() at push time + index: 0, + }; + + const localBookmarks = []; + // User deleted 100 seconds after last sync + const tombstones = [{ url: 'https://example.com', deletedAt: T_LAST_SYNC + 100000 }]; + + const { toAdd } = categorizeCloudBookmarks([cloudBookmark], localBookmarks, tombstones); + + // dateAdded (T_LAST_SYNC) < deletedAt (T_LAST_SYNC + 100s) → tombstone wins + expect(toAdd).toHaveLength(0); + }); + + it('DANGEROUS: should handle when server Date.now() dateAdded is AFTER tombstone', () => { + // This is the dangerous scenario: if somehow the server's Date.now() + // for dateAdded ends up AFTER the tombstone's deletedAt. + // + // This could happen if: + // 1. Browser A pushes at T1 (server sets dateAdded = T1) + // 2. Browser B deletes at T0 (creates tombstone deletedAt = T0, where T0 < T1) + // 3. Browser B syncs - the tombstone is older than the cloud bookmark + // + // In this case, the cloud bookmark IS newer than the tombstone, + // so it SHOULD be added (the other browser intentionally has it). + // This is NOT the bug - this is correct behavior. + + const cloudBookmark = { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Bar', + dateAdded: T_USER_DELETES + 1000, // NEWER than tombstone + index: 0, + }; + + const localBookmarks = []; + const tombstones = [{ url: 'https://example.com', deletedAt: T_USER_DELETES }]; + + const { toAdd } = categorizeCloudBookmarks([cloudBookmark], localBookmarks, tombstones); + + // Bookmark is NEWER than tombstone → should be added (correct behavior) + expect(toAdd).toHaveLength(1); + }); + }); +}); + +describe('Full end-to-end sync simulation with mock browser APIs', () => { + let storage; + let bookmarkTree; + let nextId; + + /** + * Simulates browser.storage.local + */ + function createMockStorage() { + const data = {}; + return { + get: async (keys) => { + if (typeof keys === 'string') return { [keys]: data[keys] }; + if (Array.isArray(keys)) { + const result = {}; + for (const k of keys) { + if (k in data) result[k] = data[k]; + } + return result; + } + return { ...data }; + }, + set: async (items) => { + Object.assign(data, items); + }, + _data: data, + }; + } + + /** + * Simulates the entire performSync flow with mock state. + * Returns the final state after sync. + * + * @param {Object} opts - Sync options + * @param {Array} opts.currentStorageTombstones - Optional: tombstones currently in storage + * (may differ from localTombstones if onRemoved wrote during sync). + * When provided, simulates the fix: re-reading tombstones before writing. + */ + function simulatePerformSync({ + localBookmarkTree, + localTombstones, + cloudBookmarks, + cloudTombstones, + cloudChecksum, + lastSyncTime, + currentStorageTombstones, + }) { + // Step 1: Flatten local bookmarks + const localFlat = flattenBookmarkTree(localBookmarkTree); + + // Step 2: Cloud data (provided) + + // Step 3: Apply cloud tombstones to local + const tombstonesToApply = filterTombstonesToApply( + cloudTombstones, + localTombstones, + lastSyncTime + ); + // In real code, this would call browser.bookmarks.remove for each match + // For simulation, we just track what would be deleted + const localFlatAfterTombstones = localFlat.filter((b) => { + if (!b.url) return true; + return !tombstonesToApply.some((t) => t.url === b.url); + }); + + // Step 4: Merge tombstones + const mergedTombstones = mergeTombstonesLocal(localTombstones, cloudTombstones); + + // FIX: Re-read current tombstones from storage before writing + // This preserves tombstones created by onRemoved during the sync + const currentTombstones = currentStorageTombstones ?? localTombstones; + const safeMergedTombstones = mergeTombstonesLocal(currentTombstones, mergedTombstones); + + // Step 5: "Re-read" local bookmarks (after tombstone deletions) + const updatedLocalFlat = localFlatAfterTombstones; + + // Step 6: Categorize cloud bookmarks (uses safeMergedTombstones per fix) + const { toAdd: newFromCloud, toUpdate: bookmarksToUpdate } = categorizeCloudBookmarks( + cloudBookmarks, + updatedLocalFlat, + safeMergedTombstones + ); + + // Step 7: "Add" new cloud bookmarks to local + // In real code, this calls browser.bookmarks.create + const finalLocalFlat = [ + ...updatedLocalFlat, + ...newFromCloud.map((b, i) => ({ + ...b, + id: `cloud-added-${i}`, + })), + ]; + + // Step 8: Generate final state for push + const pushBookmarks = finalLocalFlat; + + // Step 9: Server processes the push + const serverMergedTombstones = serverMergeTombstones(cloudTombstones, safeMergedTombstones); + const serverFinalBookmarks = serverApplyTombstonesReplaceMode( + pushBookmarks, + serverMergedTombstones + ); + + return { + newFromCloud, + bookmarksToUpdate, + finalLocalFlat, + mergedTombstones: safeMergedTombstones, + serverFinalBookmarks, + serverMergedTombstones, + tombstonesToApply, + }; + } + + it('complete flow: delete toolbar bookmark, auto-sync, verify not reverted', () => { + const T_CREATED = 1700000000000; + const T_LAST_SYNC = 1700100000000; + const T_DELETED = 1700200000000; + + // Local state: bookmark already deleted, tombstone created + const localTree = [ + { + id: '0', + children: [ + { + id: '1', + title: 'Bookmarks Bar', + children: [ + { + id: 'bm-2', + url: 'https://keeper.com', + title: 'Keeper', + dateAdded: T_CREATED, + index: 0, + }, + // https://example.com is NOT here (user deleted it) + ], + }, + { id: '2', title: 'Other Bookmarks', children: [] }, + ], + }, + ]; + + const result = simulatePerformSync({ + localBookmarkTree: localTree, + localTombstones: [{ url: 'https://example.com', deletedAt: T_DELETED }], + cloudBookmarks: [ + { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 0, + type: 'bookmark', + }, + { + url: 'https://keeper.com', + title: 'Keeper', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 1, + type: 'bookmark', + }, + ], + cloudTombstones: [], + cloudChecksum: 'old-checksum', + lastSyncTime: T_LAST_SYNC, + }); + + // VERIFY: The deleted bookmark was NOT re-added + expect(result.newFromCloud).toHaveLength(0); + expect(result.finalLocalFlat.find((b) => b.url === 'https://example.com')).toBeUndefined(); + + // VERIFY: The deletion is reflected on the server after push + expect( + result.serverFinalBookmarks.find((b) => b.url === 'https://example.com') + ).toBeUndefined(); + + // VERIFY: The tombstone is stored on the server + expect( + result.serverMergedTombstones.find((t) => t.url === 'https://example.com') + ).toBeDefined(); + + // VERIFY: The other bookmark is preserved + expect(result.finalLocalFlat.find((b) => b.url === 'https://keeper.com')).toBeDefined(); + expect(result.serverFinalBookmarks.find((b) => b.url === 'https://keeper.com')).toBeDefined(); + }); + + it('complete flow: second sync after deletion still works', () => { + const T_CREATED = 1700000000000; + const T_DELETED = 1700200000000; + const T_FIRST_SYNC = 1700205000000; + + // After first sync, cloud no longer has the deleted bookmark + const localTree = [ + { + id: '0', + children: [ + { + id: '1', + title: 'Bookmarks Bar', + children: [ + { + id: 'bm-2', + url: 'https://keeper.com', + title: 'Keeper', + dateAdded: T_CREATED, + index: 0, + }, + ], + }, + { id: '2', title: 'Other Bookmarks', children: [] }, + ], + }, + ]; + + const result = simulatePerformSync({ + localBookmarkTree: localTree, + localTombstones: [{ url: 'https://example.com', deletedAt: T_DELETED }], + cloudBookmarks: [ + { + url: 'https://keeper.com', + title: 'Keeper', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 0, + type: 'bookmark', + }, + ], + cloudTombstones: [{ url: 'https://example.com', deletedAt: T_DELETED }], + cloudChecksum: 'synced-checksum', + lastSyncTime: T_FIRST_SYNC, + }); + + // No changes should be made + expect(result.newFromCloud).toHaveLength(0); + expect(result.finalLocalFlat.find((b) => b.url === 'https://example.com')).toBeUndefined(); + }); + + it('FIXED: deletion during ongoing sync preserved by re-read', () => { + const T_CREATED = 1700000000000; + const T_LAST_SYNC = 1700100000000; + const T_DELETE_DURING_SYNC = 1700200500000; + const T_SYNC_END = 1700201000000; + + // Scenario: Sync starts, user deletes during sync, sync completes, + // follow-up sync fires. + // + // With the fix: performSync re-reads tombstones from storage before writing, + // so the tombstone created by onRemoved during sync is preserved. + + // First sync: tombstones were empty when sync started, but user deletes + // during sync and onRemoved writes a tombstone to storage. + // The fix re-reads storage before writing, picking up the new tombstone. + const firstSyncResult = simulatePerformSync({ + localBookmarkTree: [ + { + id: '0', + children: [ + { + id: '1', + title: 'Bookmarks Bar', + children: [ + { + id: 'bm-1', + url: 'https://example.com', + title: 'Example', + dateAdded: T_CREATED, + index: 0, + }, + { + id: 'bm-2', + url: 'https://keeper.com', + title: 'Keeper', + dateAdded: T_CREATED, + index: 1, + }, + ], + }, + { id: '2', title: 'Other Bookmarks', children: [] }, + ], + }, + ], + localTombstones: [], // Empty at sync start (read before user deleted) + cloudBookmarks: [ + { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 0, + type: 'bookmark', + }, + { + url: 'https://keeper.com', + title: 'Keeper', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 1, + type: 'bookmark', + }, + ], + cloudTombstones: [], + cloudChecksum: 'same-checksum', + lastSyncTime: T_LAST_SYNC, + // FIX: currentStorageTombstones simulates re-reading storage which now + // has the tombstone that onRemoved wrote during the sync + currentStorageTombstones: [{ url: 'https://example.com', deletedAt: T_DELETE_DURING_SYNC }], + }); + + // With the fix, the tombstone is preserved in the merged result + expect( + firstSyncResult.mergedTombstones.find((t) => t.url === 'https://example.com') + ).toBeDefined(); + + // Follow-up sync: the local bookmark was deleted, tombstone is preserved + const followUpResult = simulatePerformSync({ + localBookmarkTree: [ + { + id: '0', + children: [ + { + id: '1', + title: 'Bookmarks Bar', + children: [ + { + id: 'bm-2', + url: 'https://keeper.com', + title: 'Keeper', + dateAdded: T_CREATED, + index: 0, + }, + ], + }, + { id: '2', title: 'Other Bookmarks', children: [] }, + ], + }, + ], + localTombstones: firstSyncResult.mergedTombstones, // Has the tombstone! + cloudBookmarks: [ + { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 0, + type: 'bookmark', + }, + { + url: 'https://keeper.com', + title: 'Keeper', + folderPath: 'Bookmarks Bar', + dateAdded: T_CREATED, + index: 1, + type: 'bookmark', + }, + ], + cloudTombstones: [], + cloudChecksum: 'same-checksum', + lastSyncTime: T_SYNC_END, + }); + + // FIXED: The deleted bookmark is NOT re-added + expect(followUpResult.newFromCloud).toHaveLength(0); + expect( + followUpResult.finalLocalFlat.find((b) => b.url === 'https://example.com') + ).toBeUndefined(); + + // The tombstone is pushed to cloud + expect( + followUpResult.serverMergedTombstones.find((t) => t.url === 'https://example.com') + ).toBeDefined(); + }); +}); diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js index 59fc002..7eae955 100644 --- a/apps/extension/src/background/index.js +++ b/apps/extension/src/background/index.js @@ -1652,9 +1652,17 @@ async function performSync(sourceId) { } // Step 4: Merge tombstones (keep the newest deletion time for each URL) + // IMPORTANT: Re-read current tombstones from storage before writing to avoid + // overwriting tombstones created by onRemoved during the sync. + // Race condition: if the user deletes a bookmark while sync is running, + // onRemoved writes a new tombstone to storage. If we blindly overwrite with + // mergedTombstones (computed from the old localTombstones read at step 1), + // the new tombstone is lost and the deletion gets reverted on the next sync. const mergedTombstones = mergeTombstonesLocal(localTombstones, cloudTombstones); - await storeTombstones(mergedTombstones); - console.log(`[MarkSyncr] Merged tombstones: ${mergedTombstones.length}`); + const currentTombstones = await getTombstones(); + const safeMergedTombstones = mergeTombstonesLocal(currentTombstones, mergedTombstones); + await storeTombstones(safeMergedTombstones); + console.log(`[MarkSyncr] Merged tombstones: ${safeMergedTombstones.length}`); // Step 5: Get updated local bookmarks after applying tombstones const updatedTree = await browser.bookmarks.getTree(); @@ -1668,7 +1676,7 @@ async function performSync(sourceId) { const { toAdd: newFromCloud, toUpdate: bookmarksToUpdate } = categorizeCloudBookmarks( cloudBookmarks, updatedLocalFlat, - mergedTombstones + safeMergedTombstones ); console.log(`[MarkSyncr] 🔍 New bookmarks from cloud: ${newFromCloud.length}`); @@ -1826,9 +1834,9 @@ async function performSync(sourceId) { let syncResult = null; if (hasLocalChangesToPush) { console.log( - `[MarkSyncr] Pushing ${mergedFlat.length} merged bookmarks and ${mergedTombstones.length} tombstones to cloud...` + `[MarkSyncr] Pushing ${mergedFlat.length} merged bookmarks and ${safeMergedTombstones.length} tombstones to cloud...` ); - syncResult = await syncBookmarksToCloud(mergedFlat, detectBrowser(), mergedTombstones); + syncResult = await syncBookmarksToCloud(mergedFlat, detectBrowser(), safeMergedTombstones); console.log('[MarkSyncr] Cloud sync result:', syncResult); } else { console.log( @@ -1860,7 +1868,7 @@ async function performSync(sourceId) { addedFromCloud: newFromCloud.length, deletedLocally, pushedToCloud: localAdditions.length, - tombstones: mergedTombstones.length, + tombstones: safeMergedTombstones.length, } ); console.log('[MarkSyncr] Version saved:', versionResult); From 8ccb9af6844b14713fffbd4d8f9a3a9f6365fb5b Mon Sep 17 00:00:00 2001 From: Master Preshy Date: Sat, 7 Mar 2026 14:13:14 +0100 Subject: [PATCH 2/5] test: add integration test calling real performSync with mocked browser APIs Add __test__ exports (VITEST-only, tree-shaken in production) to background/index.js so integration tests can import and call the real performSync, addTombstone, categorizeCloudBookmarks, etc. New test file: toolbar-delete-race-integration.test.js (10 tests) - Calls the REAL performSync with fully mocked browser.* and fetch APIs - Simulates the tombstone race condition: delays cloud GET, fires onRemoved mid-sync, verifies tombstone is preserved after sync - Verifies deleted bookmark is NOT re-added from cloud - Tests tombstone merge, categorization filtering, concurrent sync guard, and state management - All 10 tests pass; 527 total passing (was 517) --- .../toolbar-delete-race-integration.test.js | 801 ++++++++++++++++++ apps/extension/src/background/index.js | 44 + 2 files changed, 845 insertions(+) create mode 100644 apps/extension/__tests__/toolbar-delete-race-integration.test.js diff --git a/apps/extension/__tests__/toolbar-delete-race-integration.test.js b/apps/extension/__tests__/toolbar-delete-race-integration.test.js new file mode 100644 index 0000000..3e5852c --- /dev/null +++ b/apps/extension/__tests__/toolbar-delete-race-integration.test.js @@ -0,0 +1,801 @@ +/** + * Integration test: calls the REAL performSync from background/index.js + * with fully mocked browser.* and fetch APIs to verify the tombstone + * race-condition fix. + * + * Race condition under test: + * 1. performSync starts, reads localTombstones = [] (none yet) + * 2. During sync (between cloud GET and tombstone write), user deletes + * a toolbar bookmark → onRemoved fires → addTombstone() writes to storage + * 3. BUG (before fix): performSync calls storeTombstones(mergedTombstones) + * which overwrites the tombstone from step 2 → deletion reverted on next sync + * 4. FIX: performSync re-reads tombstones from storage before writing, + * preserving the concurrent tombstone. + * + * This test imports the REAL module code via __test__ exports (only available + * under VITEST=true) and validates the fix end-to-end. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// 1. Mock browser API (webextension-polyfill) – must be before module import +// --------------------------------------------------------------------------- + +// In-memory storage backing (survives across async ticks within a test) +let storageData = {}; + +// Captured listener callbacks — set by addListener mocks +const capturedListeners = { + onCreated: null, + onRemoved: null, + onChanged: null, + onMoved: null, + onAlarm: null, + onMessage: null, + onInstalled: null, + onStartup: null, +}; + +// Track bookmarks.create calls so we can assert the deleted bookmark is NOT re-added +let bookmarksCreated = []; + +// The mock bookmark tree returned by browser.bookmarks.getTree() +// This is mutable so we can update it mid-sync (e.g., after a deletion) +let mockBookmarkTree = []; + +const mockBrowser = { + storage: { + local: { + get: vi.fn(async (keys) => { + if (typeof keys === 'string') { + return { [keys]: storageData[keys] }; + } + if (Array.isArray(keys)) { + const result = {}; + for (const k of keys) result[k] = storageData[k]; + return result; + } + // Object with defaults + if (typeof keys === 'object' && keys !== null) { + const result = {}; + for (const k of Object.keys(keys)) { + result[k] = storageData[k] !== undefined ? storageData[k] : keys[k]; + } + return result; + } + return { ...storageData }; + }), + set: vi.fn(async (obj) => { + Object.assign(storageData, obj); + }), + remove: vi.fn(async (keys) => { + const arr = Array.isArray(keys) ? keys : [keys]; + for (const k of arr) delete storageData[k]; + }), + }, + }, + bookmarks: { + getTree: vi.fn(async () => mockBookmarkTree), + create: vi.fn(async (props) => { + const bm = { id: `created-${Date.now()}-${Math.random()}`, ...props }; + bookmarksCreated.push(bm); + return bm; + }), + remove: vi.fn(async () => {}), + update: vi.fn(async (id, changes) => ({ id, ...changes })), + move: vi.fn(async (id, dest) => ({ id, ...dest })), + getChildren: vi.fn(async () => []), + onCreated: { + addListener: vi.fn((cb) => { + capturedListeners.onCreated = cb; + }), + }, + onRemoved: { + addListener: vi.fn((cb) => { + capturedListeners.onRemoved = cb; + }), + }, + onChanged: { + addListener: vi.fn((cb) => { + capturedListeners.onChanged = cb; + }), + }, + onMoved: { + addListener: vi.fn((cb) => { + capturedListeners.onMoved = cb; + }), + }, + }, + alarms: { + clear: vi.fn(async () => true), + create: vi.fn(), + get: vi.fn(async () => null), + onAlarm: { + addListener: vi.fn((cb) => { + capturedListeners.onAlarm = cb; + }), + }, + }, + runtime: { + onMessage: { + addListener: vi.fn((cb) => { + capturedListeners.onMessage = cb; + }), + }, + onInstalled: { + addListener: vi.fn((cb) => { + capturedListeners.onInstalled = cb; + }), + }, + onStartup: { + addListener: vi.fn((cb) => { + capturedListeners.onStartup = cb; + }), + }, + id: 'test-extension-id', + }, +}; + +vi.mock('webextension-polyfill', () => ({ + default: mockBrowser, +})); + +// --------------------------------------------------------------------------- +// 2. Mock global.fetch — routes requests to handlers +// (the actual fetch mock is installed in beforeEach via installFetchRouter) +// --------------------------------------------------------------------------- +let fetchHandlers = {}; + +// Install initial router so initialize() on first import can use fetch +global.fetch = vi.fn(async (url, opts) => { + for (const [pattern, handler] of Object.entries(fetchHandlers)) { + if (url.includes(pattern)) { + return handler(url, opts); + } + } + return { ok: false, status: 404, json: async () => ({ error: 'Not found' }) }; +}); + +// --------------------------------------------------------------------------- +// 3. Mock navigator.userAgent for detectBrowser() +// --------------------------------------------------------------------------- +Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', + }, + writable: true, + configurable: true, +}); + +// --------------------------------------------------------------------------- +// 4. Mock crypto.subtle.digest for generateChecksum() +// --------------------------------------------------------------------------- +if (!global.crypto) { + global.crypto = {}; +} +if (!global.crypto.subtle) { + global.crypto.subtle = {}; +} +global.crypto.subtle.digest = vi.fn(async (_algo, data) => { + // Return a deterministic hash based on data content + // (doesn't need to be real SHA-256, just deterministic for the test) + let hash = 0; + const bytes = new Uint8Array(data); + for (let i = 0; i < bytes.length; i++) { + hash = (hash * 31 + bytes[i]) | 0; + } + const buf = new ArrayBuffer(32); + const view = new DataView(buf); + view.setInt32(0, hash); + view.setInt32(4, hash ^ 0x12345678); + view.setInt32(8, hash ^ 0x9abcdef0); + view.setInt32(12, hash ^ 0xdeadbeef); + return buf; +}); + +// --------------------------------------------------------------------------- +// 5. Mock TextEncoder (for generateChecksum in Node env) +// --------------------------------------------------------------------------- +if (!global.TextEncoder) { + global.TextEncoder = class { + encode(str) { + return new Uint8Array([...str].map((c) => c.charCodeAt(0))); + } + }; +} + +// --------------------------------------------------------------------------- +// 6. Import the real module (initialize() runs on import, registering listeners) +// --------------------------------------------------------------------------- +let __test__; + +// Helper to set up the fetch router (must be called after any mockReset) +function installFetchRouter() { + global.fetch = vi.fn(async (url, opts) => { + for (const [pattern, handler] of Object.entries(fetchHandlers)) { + if (url.includes(pattern)) { + return handler(url, opts); + } + } + // Default: 404 + return { + ok: false, + status: 404, + json: async () => ({ error: 'Not found' }), + }; + }); +} + +// Dynamic import so mocks are in place first +beforeEach(async () => { + // Reset state + storageData = {}; + bookmarksCreated = []; + mockBookmarkTree = []; + fetchHandlers = {}; + installFetchRouter(); + + // Import module (cached after first call — initialize() only runs once) + const mod = await import('../src/background/index.js'); + __test__ = mod.__test__; + + if (!__test__) { + throw new Error( + '__test__ exports not available — ensure VITEST env var is set (vitest does this automatically)' + ); + } + + // Reset module-level state (isSyncInProgress, consecutiveSyncFailures, etc.) + __test__.resetState(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// =========================================================================== +// Helper: build a typical Chrome bookmark tree +// =========================================================================== +function buildBookmarkTree(toolbarBookmarks = [], otherBookmarks = []) { + return [ + { + id: '0', + title: '', + children: [ + { + id: '1', + title: 'Bookmarks Bar', + children: toolbarBookmarks.map((bm, i) => ({ + id: bm.id || `tb-${i}`, + title: bm.title, + url: bm.url, + index: i, + dateAdded: bm.dateAdded || Date.now() - 86400000, + })), + }, + { + id: '2', + title: 'Other Bookmarks', + children: otherBookmarks.map((bm, i) => ({ + id: bm.id || `ob-${i}`, + title: bm.title, + url: bm.url, + index: i, + dateAdded: bm.dateAdded || Date.now() - 86400000, + })), + }, + ], + }, + ]; +} + +// =========================================================================== +// Helper: set up storage & fetch for a "normal" sync scenario +// =========================================================================== +function setupSyncScenario({ + localToolbarBookmarks = [], + localOtherBookmarks = [], + cloudBookmarks = [], + cloudTombstones = [], + cloudChecksum = 'cloud-checksum-abc', + session = { + access_token: 'test-token-valid', + access_token_expires_at: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now + extension_token: 'ext-token-valid', + }, +} = {}) { + // Set up storage: session, sources, selectedSource + storageData = { + session, + sources: [{ id: 'browser-bookmarks', connected: true }], + selectedSource: 'browser-bookmarks', + 'marksyncr-tombstones': [], + 'marksyncr-last-sync-time': Date.now() - 300000, // 5 minutes ago + 'marksyncr-locally-modified-ids': '[]', + }; + + // Set up bookmark tree + mockBookmarkTree = buildBookmarkTree(localToolbarBookmarks, localOtherBookmarks); + + // Set up fetch handlers + fetchHandlers = { + // Token validation (validateToken calls /api/auth/session) + '/api/auth/session': async () => ({ + ok: true, + json: async () => ({ user: { id: 'user-1' } }), + }), + // Token refresh via extension_token + '/api/auth/extension/refresh': async () => ({ + ok: true, + json: async () => ({ + session: { + access_token: 'refreshed-token', + access_token_expires_at: new Date(Date.now() + 3600000).toISOString(), + }, + }), + }), + // Device registration + '/api/devices': async () => ({ + ok: true, + json: async () => ({ device: { id: 'dev-1' } }), + }), + // GET /api/bookmarks — cloud data + '/api/bookmarks': async (_url, opts) => { + if (opts?.method === 'GET' || !opts?.method) { + return { + ok: true, + json: async () => ({ + bookmarks: cloudBookmarks, + tombstones: cloudTombstones, + checksum: cloudChecksum, + version: 1, + }), + }; + } + // POST /api/bookmarks — push to cloud + return { + ok: true, + json: async () => ({ + synced: 10, + total: 10, + checksum: 'new-checksum', + message: 'Synced', + }), + }; + }, + // POST /api/versions + '/api/versions': async () => ({ + ok: true, + json: async () => ({ version: { id: 'v1', version: 1 } }), + }), + }; +} + +// =========================================================================== +// Tests +// =========================================================================== + +describe('Integration: real performSync with mocked browser APIs', () => { + describe('basic sync flow', () => { + it('should complete a sync cycle successfully with the real performSync', async () => { + const toolbarBms = [ + { id: 'tb-1', title: 'Example', url: 'https://example.com' }, + { id: 'tb-2', title: 'Test', url: 'https://test.com' }, + ]; + + setupSyncScenario({ + localToolbarBookmarks: toolbarBms, + cloudBookmarks: [ + { + url: 'https://example.com', + title: 'Example', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + { + url: 'https://test.com', + title: 'Test', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + }); + + const result = await __test__.performSync(); + expect(result.success).toBe(true); + }); + + it('should call browser.bookmarks.getTree at least once', async () => { + setupSyncScenario({ + localToolbarBookmarks: [{ id: 'tb-1', title: 'A', url: 'https://a.com' }], + cloudBookmarks: [ + { + url: 'https://a.com', + title: 'A', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + }); + + await __test__.performSync(); + expect(mockBrowser.bookmarks.getTree).toHaveBeenCalled(); + }); + + it('should return error when no source is configured', async () => { + storageData = { session: { access_token: 'tok' } }; + mockBookmarkTree = buildBookmarkTree(); + + const result = await __test__.performSync(); + expect(result.success).toBe(false); + expect(result.error).toContain('No sync source'); + }); + }); + + describe('tombstone race condition (the actual bug)', () => { + it('should preserve tombstones created by onRemoved during sync', async () => { + // Setup: one bookmark in toolbar, same bookmark in cloud + const deletedUrl = 'https://deleted-during-sync.com'; + const keptUrl = 'https://kept.com'; + + const toolbarBms = [ + { id: 'tb-del', title: 'Will Delete', url: deletedUrl }, + { id: 'tb-keep', title: 'Keeper', url: keptUrl }, + ]; + + setupSyncScenario({ + localToolbarBookmarks: toolbarBms, + cloudBookmarks: [ + { + url: deletedUrl, + title: 'Will Delete', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + { + url: keptUrl, + title: 'Keeper', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + }); + + // Listeners were registered by initialize() on first import + const onRemovedCb = capturedListeners.onRemoved; + expect(onRemovedCb).toBeTruthy(); + + // Intercept the cloud GET to simulate a delay. + // During this delay, we fire onRemoved (user deletes a bookmark). + let cloudGetResolve; + const cloudGetPromise = new Promise((resolve) => { + cloudGetResolve = resolve; + }); + + fetchHandlers['/api/bookmarks'] = async (_url, opts) => { + if (opts?.method === 'GET' || !opts?.method) { + // Wait for the test to release us + await cloudGetPromise; + return { + ok: true, + json: async () => ({ + bookmarks: [ + { + url: deletedUrl, + title: 'Will Delete', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + { + url: keptUrl, + title: 'Keeper', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + tombstones: [], + checksum: 'cloud-check-1', + version: 1, + }), + }; + } + // POST: push to cloud + return { + ok: true, + json: async () => ({ + synced: 1, + total: 1, + checksum: 'cloud-check-2', + message: 'Synced', + }), + }; + }; + + // Start performSync (it will block on the cloud GET) + const syncPromise = __test__.performSync(); + + // Wait a tick to let performSync reach the cloud GET + await new Promise((r) => setTimeout(r, 50)); + + // Simulate user deleting the bookmark while sync is blocked + // This fires the REAL onRemoved handler which calls addTombstone() + await onRemovedCb('tb-del', { + node: { + id: 'tb-del', + title: 'Will Delete', + url: deletedUrl, + }, + }); + + // Verify tombstone was written to storage + const tombstonesAfterDelete = storageData['marksyncr-tombstones'] || []; + expect(tombstonesAfterDelete.some((t) => t.url === deletedUrl)).toBe(true); + + // Update the bookmark tree to reflect the deletion + // (the user already deleted the bookmark from the toolbar) + mockBookmarkTree = buildBookmarkTree([{ id: 'tb-keep', title: 'Keeper', url: keptUrl }], []); + + // Release the cloud GET so performSync continues + cloudGetResolve(); + + // Wait for sync to complete + const result = await syncPromise; + expect(result.success).toBe(true); + + // THE CRITICAL ASSERTION: the tombstone for the deleted URL must + // still be in storage after performSync completes. + // Before the fix, storeTombstones(mergedTombstones) would overwrite it. + const tombstonesAfterSync = storageData['marksyncr-tombstones'] || []; + const preservedTombstone = tombstonesAfterSync.find((t) => t.url === deletedUrl); + + expect(preservedTombstone).toBeTruthy(); + expect(preservedTombstone.url).toBe(deletedUrl); + expect(preservedTombstone.deletedAt).toBeGreaterThan(0); + }); + + it('should NOT re-add a deleted bookmark from cloud when tombstone is preserved', async () => { + const deletedUrl = 'https://deleted-bookmark.com'; + + setupSyncScenario({ + localToolbarBookmarks: [{ id: 'tb-del', title: 'Deleted One', url: deletedUrl }], + cloudBookmarks: [ + { + url: deletedUrl, + title: 'Deleted One', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + }); + + // Listeners were registered by initialize() on first import + const onRemovedCb = capturedListeners.onRemoved; + + // Intercept cloud GET with delay + let releaseCloudGet; + const cloudGetBlocked = new Promise((r) => { + releaseCloudGet = r; + }); + + fetchHandlers['/api/bookmarks'] = async (_url, opts) => { + if (opts?.method === 'GET' || !opts?.method) { + await cloudGetBlocked; + return { + ok: true, + json: async () => ({ + bookmarks: [ + { + url: deletedUrl, + title: 'Deleted One', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + tombstones: [], + checksum: 'c1', + version: 1, + }), + }; + } + return { + ok: true, + json: async () => ({ synced: 0, total: 0, checksum: 'c2', message: 'OK' }), + }; + }; + + const syncPromise = __test__.performSync(); + await new Promise((r) => setTimeout(r, 50)); + + // User deletes the bookmark mid-sync + await onRemovedCb('tb-del', { + node: { id: 'tb-del', title: 'Deleted One', url: deletedUrl }, + }); + + // Bookmark tree now has no bookmarks + mockBookmarkTree = buildBookmarkTree([], []); + + releaseCloudGet(); + const result = await syncPromise; + expect(result.success).toBe(true); + + // The deleted bookmark should NOT have been re-added by addCloudBookmarksToLocal + // because categorizeCloudBookmarks should filter it out via the preserved tombstone + const reAddedDeleted = bookmarksCreated.filter((bm) => bm.url === deletedUrl); + expect(reAddedDeleted.length).toBe(0); + }); + }); + + describe('tombstone merge correctness', () => { + it('should merge local and cloud tombstones using the real mergeTombstonesLocal', async () => { + const url1 = 'https://local-deleted.com'; + const url2 = 'https://cloud-deleted.com'; + const urlBoth = 'https://both-deleted.com'; + + // Use recent timestamps so tombstones pass the safeguard filter + // and don't get cleaned up by cleanupOldTombstones (30 day TTL) + const now = Date.now(); + const localTs1 = now - 60000; // 1 minute ago + const localTsBoth = now - 30000; // 30 seconds ago + const cloudTs2 = now - 45000; // 45 seconds ago + const cloudTsBoth = now - 10000; // 10 seconds ago (newer than local) + + setupSyncScenario({ + localToolbarBookmarks: [], + cloudBookmarks: [], + cloudTombstones: [ + { url: url2, deletedAt: cloudTs2 }, + { url: urlBoth, deletedAt: cloudTsBoth }, + ], + }); + + // Set local tombstones AFTER setupSyncScenario (which clears them) + storageData['marksyncr-tombstones'] = [ + { url: url1, deletedAt: localTs1 }, + { url: urlBoth, deletedAt: localTsBoth }, + ]; + + const result = await __test__.performSync(); + expect(result.success).toBe(true); + + const finalTombstones = storageData['marksyncr-tombstones'] || []; + + // Should have all three URLs + expect(finalTombstones.find((t) => t.url === url1)).toBeTruthy(); + expect(finalTombstones.find((t) => t.url === url2)).toBeTruthy(); + + // For the shared URL, cloud's newer timestamp should win + const bothEntry = finalTombstones.find((t) => t.url === urlBoth); + expect(bothEntry).toBeTruthy(); + expect(bothEntry.deletedAt).toBe(cloudTsBoth); + }); + }); + + describe('categorizeCloudBookmarks filters tombstoned URLs', () => { + it('should not add a cloud bookmark that has a tombstone', async () => { + const deletedUrl = 'https://already-tombstoned.com'; + + // Local has tombstone, no bookmark for this URL + storageData['marksyncr-tombstones'] = [{ url: deletedUrl, deletedAt: Date.now() }]; + + setupSyncScenario({ + localToolbarBookmarks: [], + cloudBookmarks: [ + { + url: deletedUrl, + title: 'Should Not Re-Add', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, // older than tombstone + }, + ], + }); + + // Restore tombstones (setupSyncScenario clears them) + storageData['marksyncr-tombstones'] = [{ url: deletedUrl, deletedAt: Date.now() }]; + + const result = await __test__.performSync(); + expect(result.success).toBe(true); + + // The tombstoned URL should not be created locally + const reAdded = bookmarksCreated.filter((bm) => bm.url === deletedUrl); + expect(reAdded.length).toBe(0); + }); + }); + + describe('concurrent sync guard', () => { + it('should reject a second sync while one is in progress', async () => { + setupSyncScenario({ + localToolbarBookmarks: [{ id: 'tb-1', title: 'A', url: 'https://a.com' }], + cloudBookmarks: [ + { + url: 'https://a.com', + title: 'A', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + }); + + // Block cloud GET to keep first sync running + let releaseFn; + const blocked = new Promise((r) => { + releaseFn = r; + }); + fetchHandlers['/api/bookmarks'] = async (_url, opts) => { + if (opts?.method === 'GET' || !opts?.method) { + await blocked; + return { + ok: true, + json: async () => ({ + bookmarks: [ + { + url: 'https://a.com', + title: 'A', + folderPath: 'Bookmarks Bar', + dateAdded: Date.now() - 86400000, + }, + ], + tombstones: [], + checksum: 'c1', + version: 1, + }), + }; + } + return { ok: true, json: async () => ({ synced: 1, checksum: 'c2', message: 'OK' }) }; + }; + + const sync1 = __test__.performSync(); + await new Promise((r) => setTimeout(r, 10)); + + // Second sync should be rejected + const sync2Result = await __test__.performSync(); + expect(sync2Result.success).toBe(false); + expect(sync2Result.error).toContain('already in progress'); + + releaseFn(); + const sync1Result = await sync1; + expect(sync1Result.success).toBe(true); + }); + }); + + describe('state management', () => { + it('should reset isSyncInProgress after sync completes (even on success)', async () => { + setupSyncScenario({ + localToolbarBookmarks: [], + cloudBookmarks: [], + }); + + await __test__.performSync(); + + const state = __test__.getState(); + expect(state.isSyncInProgress).toBe(false); + }); + + it('should reset isSyncInProgress after sync fails', async () => { + setupSyncScenario({}); + + // Force a failure by making token validation fail + fetchHandlers['/api/auth/validate'] = async () => ({ + ok: false, + status: 401, + json: async () => ({ valid: false }), + }); + // Also remove token refresh endpoint to prevent recovery + fetchHandlers['/api/auth/refresh'] = async () => ({ + ok: false, + status: 401, + json: async () => ({ error: 'refresh failed' }), + }); + // Remove session to trigger auth failure + storageData.session = null; + + const result = await __test__.performSync(); + // Sync should fail gracefully (no source / no auth) + expect(result.success).toBe(false); + + const state = __test__.getState(); + expect(state.isSyncInProgress).toBe(false); + }); + }); +}); diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js index 7eae955..fb7e846 100644 --- a/apps/extension/src/background/index.js +++ b/apps/extension/src/background/index.js @@ -3579,3 +3579,47 @@ console.log('[MarkSyncr] Event listeners registered'); // Initialize (async operations) initialize(); + +// ============================================================================= +// Test-only exports — these are used by integration tests to call real code +// with mocked browser APIs, rather than copying/reimplementing functions. +// Guarded behind VITEST so the production bundle tree-shakes them away. +// ============================================================================= +export const __test__ = import.meta.env?.VITEST + ? { + performSync, + getTombstones, + storeTombstones, + addTombstone, + removeTombstone, + categorizeCloudBookmarks, + flattenBookmarkTree, + mergeTombstonesLocal, + addCloudBookmarksToLocal, + applyTombstonesToLocal, + filterTombstonesToApply, + setupBookmarkListeners, + initialize, + // State accessors (module-level let variables are not directly exportable) + getState: () => ({ + isSyncInProgress, + isForcePullInProgress, + isSyncDrivenChange, + pendingSyncNeeded, + pendingSyncReasons, + locallyModifiedBookmarkIds, + consecutiveSyncFailures, + lastSyncError, + }), + resetState: () => { + isSyncInProgress = false; + isForcePullInProgress = false; + isSyncDrivenChange = false; + pendingSyncNeeded = false; + pendingSyncReasons = []; + locallyModifiedBookmarkIds = new Set(); + consecutiveSyncFailures = 0; + lastSyncError = null; + }, + } + : undefined; From 51d57da15f7205c131d39f4dde1f4e1749f67c32 Mon Sep 17 00:00:00 2001 From: "Mr. P" Date: Sat, 7 Mar 2026 14:34:51 +0100 Subject: [PATCH 3/5] Update apps/extension/__tests__/toolbar-delete-revert.test.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../__tests__/toolbar-delete-revert.test.js | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/apps/extension/__tests__/toolbar-delete-revert.test.js b/apps/extension/__tests__/toolbar-delete-revert.test.js index e82aa94..1239bcb 100644 --- a/apps/extension/__tests__/toolbar-delete-revert.test.js +++ b/apps/extension/__tests__/toolbar-delete-revert.test.js @@ -827,34 +827,6 @@ describe('Bug: Delete toolbar bookmark → auto-sync reverts it', () => { }); describe('Full end-to-end sync simulation with mock browser APIs', () => { - let storage; - let bookmarkTree; - let nextId; - - /** - * Simulates browser.storage.local - */ - function createMockStorage() { - const data = {}; - return { - get: async (keys) => { - if (typeof keys === 'string') return { [keys]: data[keys] }; - if (Array.isArray(keys)) { - const result = {}; - for (const k of keys) { - if (k in data) result[k] = data[k]; - } - return result; - } - return { ...data }; - }, - set: async (items) => { - Object.assign(data, items); - }, - _data: data, - }; - } - /** * Simulates the entire performSync flow with mock state. * Returns the final state after sync. From 00d3d372a1d3a622be0a5cb8b0d91cc83e9633dc Mon Sep 17 00:00:00 2001 From: Master Preshy Date: Sat, 7 Mar 2026 14:35:36 +0100 Subject: [PATCH 4/5] chore: add screenshots/ to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index beec7b1..0e8e02c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ coverage/ supabase/.branches/ supabase/.temp/ +# Screenshots +screenshots/ + # Misc *.local .cache/ From e31f363eaf0c93d455d0ea631f117d0649de46d3 Mon Sep 17 00:00:00 2001 From: Master Preshy Date: Sat, 7 Mar 2026 14:37:40 +0100 Subject: [PATCH 5/5] chore(release): v0.8.27 --- apps/extension/package.json | 2 +- apps/extension/src/manifest.chrome.json | 2 +- apps/extension/src/manifest.firefox.json | 2 +- apps/extension/src/manifest.safari.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/core/package.json | 2 +- packages/sources/package.json | 2 +- packages/types/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index 7ef8647..a916efa 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@marksyncr/extension", - "version": "0.8.26", + "version": "0.8.27", "private": true, "type": "module", "scripts": { diff --git a/apps/extension/src/manifest.chrome.json b/apps/extension/src/manifest.chrome.json index 46666fa..52ccdc2 100644 --- a/apps/extension/src/manifest.chrome.json +++ b/apps/extension/src/manifest.chrome.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "MarkSyncr", - "version": "0.8.26", + "version": "0.8.27", "description": "Sync your bookmarks across browsers using GitHub, Dropbox, Google Drive, or MarkSyncr Cloud", "icons": { "16": "icons/favicon-16.png", diff --git a/apps/extension/src/manifest.firefox.json b/apps/extension/src/manifest.firefox.json index 39ae042..4bbac5d 100644 --- a/apps/extension/src/manifest.firefox.json +++ b/apps/extension/src/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "MarkSyncr", - "version": "0.8.26", + "version": "0.8.27", "description": "Sync your bookmarks across browsers using GitHub, Dropbox, Google Drive, or MarkSyncr Cloud", "icons": { "16": "icons/favicon-16.png", diff --git a/apps/extension/src/manifest.safari.json b/apps/extension/src/manifest.safari.json index 9b0ff93..b0955b9 100644 --- a/apps/extension/src/manifest.safari.json +++ b/apps/extension/src/manifest.safari.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "MarkSyncr", - "version": "0.8.26", + "version": "0.8.27", "description": "Sync your bookmarks across browsers using GitHub, Dropbox, Google Drive, or MarkSyncr Cloud", "icons": { "16": "icons/favicon-16.png", diff --git a/apps/web/package.json b/apps/web/package.json index d818397..07c33b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@marksyncr/web", - "version": "0.8.26", + "version": "0.8.27", "private": true, "type": "module", "scripts": { diff --git a/package.json b/package.json index c317355..c09452e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marksyncr", - "version": "0.8.26", + "version": "0.8.27", "private": true, "description": "Cross-browser bookmark sync extension with cloud storage", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index e0d2384..c45e92c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@marksyncr/core", - "version": "0.8.26", + "version": "0.8.27", "private": true, "type": "module", "main": "./src/index.js", diff --git a/packages/sources/package.json b/packages/sources/package.json index 905bf1b..fe195a2 100644 --- a/packages/sources/package.json +++ b/packages/sources/package.json @@ -1,6 +1,6 @@ { "name": "@marksyncr/sources", - "version": "0.8.26", + "version": "0.8.27", "private": true, "type": "module", "main": "./src/index.js", diff --git a/packages/types/package.json b/packages/types/package.json index c6cd303..8c3f655 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@marksyncr/types", - "version": "0.8.26", + "version": "0.8.27", "private": true, "type": "module", "main": "./src/index.js",