@@ -10,6 +10,7 @@ afterEach(() => {
1010 delete globalThis . chrome ;
1111 delete globalThis . document ;
1212 delete globalThis . window ;
13+ delete globalThis . crypto ;
1314} ) ;
1415
1516class FakeElement {
@@ -76,6 +77,11 @@ class FakeElement {
7677
7778function installOptionsPage ( { feeds, lifecycle } = { } ) {
7879 const elements = new Map ( ) ;
80+ const store = {
81+ feeds : structuredClone ( feeds || { } ) ,
82+ lifecycle : structuredClone ( lifecycle || { } ) ,
83+ settings : { }
84+ } ;
7985 for ( const id of [
8086 "updateOnStartup" ,
8187 "globalInterval" ,
@@ -119,19 +125,21 @@ function installOptionsPage({ feeds, lifecycle } = {}) {
119125 i18n : {
120126 getMessage ( key , substitutions = [ ] ) {
121127 if ( key === "optionsExported" ) return `exported:${ substitutions [ 0 ] } ` ;
128+ if ( key === "optionsOPMLImported" ) return `imported:${ substitutions [ 0 ] } ` ;
122129 if ( key === "popupError" ) return `error:${ substitutions [ 0 ] } ` ;
123130 return key ;
124131 }
125132 } ,
126133 storage : {
127134 local : {
128135 async get ( keys ) {
129- return Object . fromEntries ( keys . map ( ( key ) => [
130- key ,
131- key === "feeds" ? feeds : key === "lifecycle" ? lifecycle : undefined
132- ] ) ) ;
136+ return Object . fromEntries ( keys . map ( ( key ) => [ key , structuredClone ( store [ key ] ) ] ) ) ;
133137 } ,
134- async set ( ) { }
138+ async set ( patch ) {
139+ for ( const [ key , value ] of Object . entries ( patch ) ) {
140+ store [ key ] = structuredClone ( value ) ;
141+ }
142+ }
135143 }
136144 } ,
137145 bookmarks : {
@@ -171,7 +179,16 @@ function installOptionsPage({ feeds, lifecycle } = {}) {
171179 }
172180 } ;
173181
174- return { elements, writes } ;
182+ Object . defineProperty ( globalThis , "crypto" , {
183+ configurable : true ,
184+ value : {
185+ randomUUID ( ) {
186+ return `feed-${ Object . keys ( store . feeds ) . length + 1 } ` ;
187+ }
188+ }
189+ } ) ;
190+
191+ return { elements, writes, store } ;
175192}
176193
177194test ( "options page exposes folder export button" , ( ) => {
@@ -205,6 +222,30 @@ test("options folder export button writes bookmark .url files and shows count",
205222 assert . equal ( elements . get ( "feedStatus" ) . textContent , "exported:1" ) ;
206223} ) ;
207224
225+ test ( "OPML import ignores duplicate feed URLs within the same file" , async ( ) => {
226+ const { elements, store } = installOptionsPage ( { feeds : { } } ) ;
227+ const opmlFile = {
228+ async text ( ) {
229+ return `<?xml version="1.0" encoding="UTF-8"?>
230+ <opml version="2.0">
231+ <body>
232+ <outline text="Alpha" xmlUrl="https://example.test/feed.xml" />
233+ <outline text="Alpha Duplicate" xmlUrl="https://example.test/feed.xml" />
234+ </body>
235+ </opml>` ;
236+ }
237+ } ;
238+
239+ await import ( `../ui/options.js?options-opml=${ Date . now ( ) } ` ) ;
240+
241+ const input = elements . get ( "opmlFileInput" ) ;
242+ await input . listeners . change ( { target : { files : [ opmlFile ] , value : "picked.opml" } } ) ;
243+
244+ assert . equal ( Object . keys ( store . feeds ) . length , 1 ) ;
245+ assert . equal ( store . feeds [ "feed-1" ] ?. url , "https://example.test/feed.xml" ) ;
246+ assert . equal ( elements . get ( "feedStatus" ) . textContent , "imported:1" ) ;
247+ } ) ;
248+
208249test ( "options page renders lifecycle diagnostics from storage" , async ( ) => {
209250 const { elements } = installOptionsPage ( {
210251 feeds : { } ,
0 commit comments