@@ -491,6 +491,116 @@ describe('categorizeCloudBookmarks with locallyModifiedBookmarkIds', () => {
491491 } ) ;
492492} ) ;
493493
494+ describe ( 'Bookmark move: sibling index protection' , ( ) => {
495+ it ( 'should protect all siblings from cloud index override when one bookmark is moved' , ( ) => {
496+ // Scenario: User has bookmarks A(0), B(1), C(2), D(3) in a folder.
497+ // User drags A to the end: B(0), C(1), D(2), A(3).
498+ // Only A fires onMoved, but B, C, D all shifted indices.
499+ // All siblings must be in locallyModifiedBookmarkIds to prevent cloud from
500+ // reverting B, C, D to their old positions.
501+
502+ // Cloud still has OLD order
503+ const cloudBookmarks = [
504+ { url : 'https://a.com' , title : 'A' , folderPath : 'Bookmarks Bar' , index : 0 } ,
505+ { url : 'https://b.com' , title : 'B' , folderPath : 'Bookmarks Bar' , index : 1 } ,
506+ { url : 'https://c.com' , title : 'C' , folderPath : 'Bookmarks Bar' , index : 2 } ,
507+ { url : 'https://d.com' , title : 'D' , folderPath : 'Bookmarks Bar' , index : 3 } ,
508+ ] ;
509+
510+ // Local has NEW order (user moved A to end)
511+ const localBookmarks = [
512+ { id : 'bm-b' , url : 'https://b.com' , title : 'B' , folderPath : 'Bookmarks Bar' , index : 0 } ,
513+ { id : 'bm-c' , url : 'https://c.com' , title : 'C' , folderPath : 'Bookmarks Bar' , index : 1 } ,
514+ { id : 'bm-d' , url : 'https://d.com' , title : 'D' , folderPath : 'Bookmarks Bar' , index : 2 } ,
515+ { id : 'bm-a' , url : 'https://a.com' , title : 'A' , folderPath : 'Bookmarks Bar' , index : 3 } ,
516+ ] ;
517+
518+ // With the fix: ALL siblings are tracked as locally modified (not just A)
519+ const modifiedLocalIds = new Set ( [ 'bm-a' , 'bm-b' , 'bm-c' , 'bm-d' ] ) ;
520+
521+ const { toAdd, toUpdate, skippedByLocalModification } = categorizeCloudBookmarks (
522+ cloudBookmarks ,
523+ localBookmarks ,
524+ [ ] ,
525+ modifiedLocalIds
526+ ) ;
527+
528+ // Nothing should be added or updated — all are protected
529+ expect ( toAdd ) . toHaveLength ( 0 ) ;
530+ expect ( toUpdate ) . toHaveLength ( 0 ) ;
531+ // All 4 bookmarks should be skipped because they are locally modified
532+ expect ( skippedByLocalModification ) . toHaveLength ( 4 ) ;
533+ } ) ;
534+
535+ it ( 'should revert sibling order if only moved bookmark is tracked (demonstrates bug)' , ( ) => {
536+ // This test demonstrates what WOULD happen without the sibling tracking fix:
537+ // Only the explicitly moved bookmark is protected, siblings get overridden.
538+
539+ const cloudBookmarks = [
540+ { url : 'https://a.com' , title : 'A' , folderPath : 'Bookmarks Bar' , index : 0 } ,
541+ { url : 'https://b.com' , title : 'B' , folderPath : 'Bookmarks Bar' , index : 1 } ,
542+ { url : 'https://c.com' , title : 'C' , folderPath : 'Bookmarks Bar' , index : 2 } ,
543+ ] ;
544+
545+ const localBookmarks = [
546+ { id : 'bm-b' , url : 'https://b.com' , title : 'B' , folderPath : 'Bookmarks Bar' , index : 0 } ,
547+ { id : 'bm-c' , url : 'https://c.com' , title : 'C' , folderPath : 'Bookmarks Bar' , index : 1 } ,
548+ { id : 'bm-a' , url : 'https://a.com' , title : 'A' , folderPath : 'Bookmarks Bar' , index : 2 } ,
549+ ] ;
550+
551+ // BUG scenario: only the moved bookmark (A) is tracked
552+ const modifiedLocalIds = new Set ( [ 'bm-a' ] ) ;
553+
554+ const { toUpdate, skippedByLocalModification } = categorizeCloudBookmarks (
555+ cloudBookmarks ,
556+ localBookmarks ,
557+ [ ] ,
558+ modifiedLocalIds
559+ ) ;
560+
561+ // A is protected (correct)
562+ expect ( skippedByLocalModification ) . toContain ( 'https://a.com' ) ;
563+
564+ // B and C would get overridden with cloud indices (the bug!)
565+ // B: cloud index 1 vs local index 0 → needs update
566+ // C: cloud index 2 vs local index 1 → needs update
567+ expect ( toUpdate ) . toHaveLength ( 2 ) ;
568+ expect ( toUpdate [ 0 ] . cloud . url ) . toBe ( 'https://b.com' ) ;
569+ expect ( toUpdate [ 1 ] . cloud . url ) . toBe ( 'https://c.com' ) ;
570+ } ) ;
571+
572+ it ( 'should handle cross-folder move with siblings in both folders tracked' , ( ) => {
573+ // User moves bookmark from Folder1 to Folder2
574+ // Both source and destination folder siblings should be protected
575+
576+ const cloudBookmarks = [
577+ { url : 'https://stay1.com' , title : 'Stay1' , folderPath : 'Bookmarks Bar/Folder1' , index : 0 } ,
578+ { url : 'https://moved.com' , title : 'Moved' , folderPath : 'Bookmarks Bar/Folder1' , index : 1 } ,
579+ { url : 'https://stay2.com' , title : 'Stay2' , folderPath : 'Bookmarks Bar/Folder2' , index : 0 } ,
580+ ] ;
581+
582+ const localBookmarks = [
583+ { id : 'bm-stay1' , url : 'https://stay1.com' , title : 'Stay1' , folderPath : 'Bookmarks Bar/Folder1' , index : 0 } ,
584+ { id : 'bm-stay2' , url : 'https://stay2.com' , title : 'Stay2' , folderPath : 'Bookmarks Bar/Folder2' , index : 0 } ,
585+ { id : 'bm-moved' , url : 'https://moved.com' , title : 'Moved' , folderPath : 'Bookmarks Bar/Folder2' , index : 1 } ,
586+ ] ;
587+
588+ // All siblings in both folders are tracked
589+ const modifiedLocalIds = new Set ( [ 'bm-stay1' , 'bm-stay2' , 'bm-moved' ] ) ;
590+
591+ const { toAdd, toUpdate, skippedByLocalModification } = categorizeCloudBookmarks (
592+ cloudBookmarks ,
593+ localBookmarks ,
594+ [ ] ,
595+ modifiedLocalIds
596+ ) ;
597+
598+ expect ( toAdd ) . toHaveLength ( 0 ) ;
599+ expect ( toUpdate ) . toHaveLength ( 0 ) ;
600+ expect ( skippedByLocalModification ) . toHaveLength ( 3 ) ;
601+ } ) ;
602+ } ) ;
603+
494604describe ( 'Sync scenario: service worker restart' , ( ) => {
495605 it ( 'should preserve local changes across service worker restarts via persisted IDs' , async ( ) => {
496606 // Simulate: user adds/modifies bookmarks → service worker restarts → sync runs
0 commit comments