33 */
44import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
55
6+ const { mockSleep } = vi . hoisted ( ( ) => ( {
7+ mockSleep : vi . fn ( ( ) => Promise . resolve ( ) ) ,
8+ } ) )
9+
610vi . mock ( '@sim/utils/helpers' , ( ) => ( {
7- sleep : vi . fn ( ( ) => Promise . resolve ( ) ) ,
11+ sleep : mockSleep ,
812} ) )
913
1014import {
@@ -19,6 +23,15 @@ import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'
1923
2024const SAMPLE_PAGE = DOCS_MANIFEST . find ( ( path ) => path === 'workflows/blocks/agent.mdx' )
2125
26+ function fetchResponse ( status : number , content = '' , headers : HeadersInit = { } ) {
27+ return {
28+ ok : status >= 200 && status < 300 ,
29+ status,
30+ headers : new Headers ( headers ) ,
31+ text : async ( ) => content ,
32+ }
33+ }
34+
2235describe ( 'docs corpus scoping' , ( ) => {
2336 it ( 'recognizes docs paths' , ( ) => {
2437 expect ( isDocsPath ( 'docs/workflows.mdx' ) ) . toBe ( true )
@@ -75,6 +88,8 @@ describe('readDocsPage', () => {
7588
7689 beforeEach ( ( ) => {
7790 fetchMock . mockReset ( )
91+ mockSleep . mockReset ( )
92+ mockSleep . mockResolvedValue ( undefined )
7893 vi . stubGlobal ( 'fetch' , fetchMock )
7994 } )
8095
@@ -84,7 +99,7 @@ describe('readDocsPage', () => {
8499
85100 it ( 'fetches the manifest path verbatim from the docs site' , async ( ) => {
86101 expect ( SAMPLE_PAGE ) . toBeDefined ( )
87- fetchMock . mockResolvedValue ( { ok : true , status : 200 , text : async ( ) => '# Agent\n\nbody' } )
102+ fetchMock . mockResolvedValue ( fetchResponse ( 200 , '# Agent\n\nbody' ) )
88103
89104 const page = await readDocsPage ( `docs/${ SAMPLE_PAGE } ` )
90105
@@ -104,7 +119,7 @@ describe('readDocsPage', () => {
104119 } )
105120
106121 it ( 'surfaces a docs-site outage as a retryable error after exhausting retries' , async ( ) => {
107- fetchMock . mockResolvedValue ( { ok : false , status : 502 , text : async ( ) => '' } )
122+ fetchMock . mockResolvedValue ( fetchResponse ( 502 ) )
108123 await expect ( readDocsPage ( `docs/${ SAMPLE_PAGE } ` ) ) . rejects . toThrow ( / c o u l d n o t b e r e a c h e d / )
109124 expect ( fetchMock ) . toHaveBeenCalledTimes ( 3 )
110125 } )
@@ -118,7 +133,7 @@ describe('readDocsPage', () => {
118133 it ( 'recovers when a transient failure clears on retry' , async ( ) => {
119134 fetchMock
120135 . mockRejectedValueOnce ( new Error ( 'socket hang up' ) )
121- . mockResolvedValue ( { ok : true , status : 200 , text : async ( ) => '# Agent\n\nbody' } )
136+ . mockResolvedValue ( fetchResponse ( 200 , '# Agent\n\nbody' ) )
122137
123138 const page = await readDocsPage ( `docs/${ SAMPLE_PAGE } ` )
124139
@@ -127,7 +142,7 @@ describe('readDocsPage', () => {
127142 } )
128143
129144 it ( 'reports a page the site no longer serves as permanent, without retrying' , async ( ) => {
130- fetchMock . mockResolvedValue ( { ok : false , status : 404 , text : async ( ) => '' } )
145+ fetchMock . mockResolvedValue ( fetchResponse ( 404 ) )
131146 const error = await readDocsPage ( `docs/${ SAMPLE_PAGE } ` ) . catch ( ( e ) => e )
132147 expect ( error ) . toBeInstanceOf ( DocsCorpusError )
133148 expect ( error . message ) . toMatch ( / d o e s n o t s e r v e i t / )
@@ -136,17 +151,50 @@ describe('readDocsPage', () => {
136151 expect ( fetchMock ) . toHaveBeenCalledOnce ( )
137152 } )
138153
139- it ( 'still treats 429 as retryable rather than permanent ' , async ( ) => {
140- fetchMock . mockResolvedValue ( { ok : false , status : 429 , text : async ( ) => ' ' } )
154+ it ( 'honors Retry-After while retrying a 429 response ' , async ( ) => {
155+ fetchMock . mockResolvedValue ( fetchResponse ( 429 , '' , { 'Retry-After' : '7 ' } ) )
141156 await expect ( readDocsPage ( `docs/${ SAMPLE_PAGE } ` ) ) . rejects . toThrow ( / c o u l d n o t b e r e a c h e d / )
142157 expect ( fetchMock ) . toHaveBeenCalledTimes ( 3 )
158+ expect ( mockSleep ) . toHaveBeenNthCalledWith ( 1 , 7_000 )
159+ expect ( mockSleep ) . toHaveBeenNthCalledWith ( 2 , 7_000 )
143160 } )
144161
145162 it ( 'treats 408 as retryable rather than a missing page' , async ( ) => {
146- fetchMock . mockResolvedValue ( { ok : false , status : 408 , text : async ( ) => '' } )
163+ fetchMock . mockResolvedValue ( fetchResponse ( 408 ) )
147164 await expect ( readDocsPage ( `docs/${ SAMPLE_PAGE } ` ) ) . rejects . toThrow ( / c o u l d n o t b e r e a c h e d / )
148165 expect ( fetchMock ) . toHaveBeenCalledTimes ( 3 )
149166 } )
167+
168+ it ( 'aborts an in-flight fetch without retrying' , async ( ) => {
169+ const controller = new AbortController ( )
170+ fetchMock . mockImplementation ( ( _url : string , init : RequestInit ) => {
171+ const signal = init . signal as AbortSignal
172+ return new Promise ( ( _resolve , reject ) => {
173+ signal . addEventListener ( 'abort' , ( ) => reject ( signal . reason ) , { once : true } )
174+ } )
175+ } )
176+
177+ const request = readDocsPage ( `docs/${ SAMPLE_PAGE } ` , controller . signal )
178+ await vi . waitFor ( ( ) => expect ( fetchMock ) . toHaveBeenCalledOnce ( ) )
179+ controller . abort ( new Error ( 'user stopped docs read' ) )
180+
181+ await expect ( request ) . rejects . toThrow ( 'user stopped docs read' )
182+ expect ( fetchMock ) . toHaveBeenCalledOnce ( )
183+ expect ( mockSleep ) . not . toHaveBeenCalled ( )
184+ } )
185+
186+ it ( 'aborts retry backoff before starting another fetch' , async ( ) => {
187+ const controller = new AbortController ( )
188+ fetchMock . mockResolvedValue ( fetchResponse ( 502 ) )
189+ mockSleep . mockImplementationOnce ( ( ) => new Promise < void > ( ( ) => { } ) )
190+
191+ const request = readDocsPage ( `docs/${ SAMPLE_PAGE } ` , controller . signal )
192+ await vi . waitFor ( ( ) => expect ( mockSleep ) . toHaveBeenCalledOnce ( ) )
193+ controller . abort ( new Error ( 'user stopped docs retry' ) )
194+
195+ await expect ( request ) . rejects . toThrow ( 'user stopped docs retry' )
196+ expect ( fetchMock ) . toHaveBeenCalledOnce ( )
197+ } )
150198} )
151199
152200describe ( 'grepDocs' , ( ) => {
@@ -163,11 +211,7 @@ describe('grepDocs', () => {
163211 } )
164212
165213 it ( 'greps exactly one page for a page path' , async ( ) => {
166- fetchMock . mockResolvedValue ( {
167- ok : true ,
168- status : 200 ,
169- text : async ( ) => 'intro line\nsystemPrompt matters\ntail' ,
170- } )
214+ fetchMock . mockResolvedValue ( fetchResponse ( 200 , 'intro line\nsystemPrompt matters\ntail' ) )
171215
172216 const matches = await grepDocs ( `docs/${ SAMPLE_PAGE } ` , 'systemPrompt' )
173217
0 commit comments