@@ -2028,3 +2028,167 @@ test("embedded pages without IDs follow next without loader calls", async () =>
20282028 ) ;
20292029 deepStrictEqual ( items . map ( ( item ) => item . id ?. href ) , [ a . id ?. href , b . id ?. href ] ) ;
20302030} ) ;
2031+
2032+ describe ( "context loader failures" , ( ) => {
2033+ const contextId = new URL ( "https://example.com/thread" ) ;
2034+ const parent = new Note ( { id : new URL ( "https://example.com/parent" ) } ) ;
2035+
2036+ for ( const synchronous of [ false , true ] ) {
2037+ const failure = synchronous ? "synchronous throw" : "promise rejection" ;
2038+ const fail = ( ) => {
2039+ const error = new Error ( "Context collection unavailable" ) ;
2040+ if ( synchronous ) throw error ;
2041+ return Promise . reject ( error ) ;
2042+ } ;
2043+
2044+ test ( `${ failure } allows reply-tree to yield an embedded parent` , async ( ) => {
2045+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : parent } ) ;
2046+ let requests = 0 ;
2047+ const items = await collect (
2048+ {
2049+ documentLoader : ( ) => {
2050+ requests ++ ;
2051+ return fail ( ) ;
2052+ } ,
2053+ } ,
2054+ seed ,
2055+ { strategies : [ "context-auto" , "reply-tree" ] , maxRequests : 1 } ,
2056+ ) ;
2057+ deepStrictEqual ( items . map ( ( item ) => item . object ) , [ parent ] ) ;
2058+ strictEqual ( items [ 0 ] . strategy , "reply-tree" ) ;
2059+ strictEqual ( requests , 1 ) ;
2060+ } ) ;
2061+
2062+ test ( `${ failure } finishes the default strategy without items` , async ( ) => {
2063+ const seed = new Note ( { contexts : [ contextId ] } ) ;
2064+ deepStrictEqual ( await collect ( { documentLoader : fail } , seed ) , [ ] ) ;
2065+ } ) ;
2066+
2067+ test ( `${ failure } consumes the request budget` , async ( ) => {
2068+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : parent . id } ) ;
2069+ const requests : string [ ] = [ ] ;
2070+ const items = await collect (
2071+ {
2072+ documentLoader : ( url ) => {
2073+ requests . push ( url . href ) ;
2074+ if ( url . href === contextId . href ) return fail ( ) ;
2075+ return Promise . resolve ( parent ) ;
2076+ } ,
2077+ } ,
2078+ seed ,
2079+ { strategies : [ "context-auto" , "reply-tree" ] , maxRequests : 1 } ,
2080+ ) ;
2081+ deepStrictEqual ( items , [ ] ) ;
2082+ deepStrictEqual ( requests , [ contextId . href ] ) ;
2083+ } ) ;
2084+
2085+ test ( `${ failure } propagates the cancellation reason` , async ( ) => {
2086+ const controller = new AbortController ( ) ;
2087+ const reason = new Error ( "Stop backfill" ) ;
2088+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : parent } ) ;
2089+ const yielded : unknown [ ] = [ ] ;
2090+ await rejects ( async ( ) => {
2091+ for await (
2092+ const item of backfill (
2093+ {
2094+ documentLoader : ( _url , options ) => {
2095+ strictEqual ( options ?. signal , controller . signal ) ;
2096+ controller . abort ( reason ) ;
2097+ return fail ( ) ;
2098+ } ,
2099+ } ,
2100+ seed ,
2101+ {
2102+ strategies : [ "context-auto" , "reply-tree" ] ,
2103+ signal : controller . signal ,
2104+ } ,
2105+ )
2106+ ) yielded . push ( item ) ;
2107+ } , ( error ) => error === reason ) ;
2108+ deepStrictEqual ( yielded , [ ] ) ;
2109+ } ) ;
2110+ }
2111+
2112+ test ( "a failed context load is not cached for later reply-tree loads" , async ( ) => {
2113+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : contextId } ) ;
2114+ let requests = 0 ;
2115+ const items = await collect (
2116+ {
2117+ documentLoader : ( url ) => {
2118+ strictEqual ( url . href , contextId . href ) ;
2119+ requests ++ ;
2120+ if ( requests === 1 ) {
2121+ return Promise . reject (
2122+ new Error ( "Temporary failure" ) ,
2123+ ) ;
2124+ }
2125+ return Promise . resolve ( parent ) ;
2126+ } ,
2127+ } ,
2128+ seed ,
2129+ { strategies : [ "context-auto" , "reply-tree" ] , maxRequests : 2 } ,
2130+ ) ;
2131+ deepStrictEqual ( items . map ( ( item ) => item . object . id ?. href ) , [
2132+ parent . id ?. href ,
2133+ ] ) ;
2134+ strictEqual ( requests , 2 ) ;
2135+ } ) ;
2136+
2137+ test ( "an exhausted budget still allows embedded reply-tree data" , async ( ) => {
2138+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : parent } ) ;
2139+ const items = await collect (
2140+ {
2141+ documentLoader : ( ) => {
2142+ throw new Error ( "No requests allowed" ) ;
2143+ } ,
2144+ } ,
2145+ seed ,
2146+ { strategies : [ "context-auto" , "reply-tree" ] , maxRequests : 0 } ,
2147+ ) ;
2148+ deepStrictEqual ( items . map ( ( item ) => item . object ) , [ parent ] ) ;
2149+ } ) ;
2150+
2151+ test ( "interval callback errors propagate before the loader is called" , async ( ) => {
2152+ const reason = new Error ( "Invalid interval configuration" ) ;
2153+ let requests = 0 ;
2154+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : parent } ) ;
2155+ await rejects (
2156+ collect (
2157+ {
2158+ documentLoader : ( ) => {
2159+ requests ++ ;
2160+ return Promise . resolve ( null ) ;
2161+ } ,
2162+ } ,
2163+ seed ,
2164+ {
2165+ strategies : [ "context-auto" , "reply-tree" ] ,
2166+ interval : ( ) => {
2167+ throw reason ;
2168+ } ,
2169+ } ,
2170+ ) ,
2171+ ( error ) => error === reason ,
2172+ ) ;
2173+ strictEqual ( requests , 0 ) ;
2174+ } ) ;
2175+
2176+ test ( "invalid interval strings still propagate" , async ( ) => {
2177+ let requests = 0 ;
2178+ const seed = new Note ( { contexts : [ contextId ] , replyTarget : parent } ) ;
2179+ await rejects ( collect (
2180+ {
2181+ documentLoader : ( ) => {
2182+ requests ++ ;
2183+ return Promise . resolve ( null ) ;
2184+ } ,
2185+ } ,
2186+ seed ,
2187+ {
2188+ strategies : [ "context-auto" , "reply-tree" ] ,
2189+ interval : "not a duration" ,
2190+ } ,
2191+ ) ) ;
2192+ strictEqual ( requests , 0 ) ;
2193+ } ) ;
2194+ } ) ;
0 commit comments