@@ -9,11 +9,157 @@ import {
99 fetchCompanyEvidence ,
1010 resolveWithNodeDns ,
1111 type CompanyFetchDependencies ,
12+ type CompanyPageDiagnostic ,
1213 type CompanyRequestInit ,
1314} from './company-fetch.js' ;
1415
1516const NOW = new Date ( '2026-09-01T12:00:00.000Z' ) ;
1617
18+ describe ( 'page diagnostics' , ( ) => {
19+ it . each ( [
20+ [ 403 , 'access_denied' ] ,
21+ [ 429 , 'rate_limited' ] ,
22+ [ 503 , 'http_error' ] ,
23+ ] as const ) (
24+ 'reports HTTP %s without response content' ,
25+ async ( status , outcome ) => {
26+ const diagnostics : CompanyPageDiagnostic [ ] = [ ] ;
27+ await expect (
28+ fetchCompanyEvidence ( 'example.com' , new AbortController ( ) . signal , {
29+ ...dependencies ( {
30+ fetch : async ( ) => new Response ( 'private body' , { status } ) ,
31+ } ) ,
32+ onDiagnostic : ( diagnostic ) => diagnostics . push ( diagnostic ) ,
33+ } )
34+ ) . resolves . toEqual ( [ ] ) ;
35+ expect ( diagnostics ) . toEqual (
36+ [ '/' , '/about' , '/pricing' ] . map ( ( requestedPath ) => ( {
37+ requestedPath,
38+ outcome,
39+ status,
40+ } ) )
41+ ) ;
42+ }
43+ ) ;
44+
45+ it ( 'records requested paths for redirected captures and isolates observer exceptions' , async ( ) => {
46+ const diagnostics : CompanyPageDiagnostic [ ] = [ ] ;
47+ const pages = await fetchCompanyEvidence (
48+ 'example.com' ,
49+ new AbortController ( ) . signal ,
50+ {
51+ ...dependencies ( {
52+ fetch : async ( url ) =>
53+ url . pathname === '/about'
54+ ? new Response ( null , {
55+ status : 302 ,
56+ headers : { location : '/company?token=private' } ,
57+ } )
58+ : okPage ( ) ,
59+ } ) ,
60+ onDiagnostic : ( diagnostic ) => {
61+ diagnostics . push ( diagnostic ) ;
62+ throw new Error ( 'observer' ) ;
63+ } ,
64+ }
65+ ) ;
66+ expect ( pages ) . toHaveLength ( 3 ) ;
67+ expect ( diagnostics . map ( ( d ) => d . requestedPath ) ) . toEqual ( [
68+ '/' ,
69+ '/about' ,
70+ '/pricing' ,
71+ ] ) ;
72+ expect (
73+ diagnostics . every (
74+ ( d ) =>
75+ d . outcome === 'captured' && d . status === 200 && ( d . bytes ?? 0 ) > 0
76+ )
77+ ) . toBe ( true ) ;
78+ expect ( JSON . stringify ( diagnostics ) ) . not . toContain ( 'private' ) ;
79+ } ) ;
80+
81+ it . each ( [
82+ 'missing_location' ,
83+ 'redirect_limit' ,
84+ 'redirect_rejected' ,
85+ 'security_rejected' ,
86+ 'page_too_large' ,
87+ 'transport_failure' ,
88+ 'timeout' ,
89+ ] as const ) ( 'classifies %s without weakening policy' , async ( outcome ) => {
90+ const diagnostics : CompanyPageDiagnostic [ ] = [ ] ;
91+ const ownTimeout = AbortSignal . abort ( new Error ( 'private timeout' ) ) ;
92+ const operation = fetchCompanyEvidence (
93+ 'example.com' ,
94+ new AbortController ( ) . signal ,
95+ {
96+ ...dependencies ( {
97+ ...( outcome === 'timeout'
98+ ? {
99+ createTimeoutSignal : ( ) => ( {
100+ signal : ownTimeout ,
101+ clear : ( ) => undefined ,
102+ } ) ,
103+ }
104+ : { } ) ,
105+ resolve : async ( ) => [
106+ outcome === 'security_rejected' ? '127.0.0.1' : '93.184.216.34' ,
107+ ] ,
108+ fetch : async ( ) => {
109+ if ( outcome === 'transport_failure' )
110+ throw new Error ( 'private transport' ) ;
111+ if ( outcome === 'page_too_large' )
112+ return new Response ( 'x' , {
113+ headers : { 'content-length' : '256001' } ,
114+ } ) ;
115+ return new Response ( null , {
116+ status : 302 ,
117+ headers :
118+ outcome === 'missing_location'
119+ ? { }
120+ : {
121+ location :
122+ outcome === 'redirect_rejected'
123+ ? 'https://other.example/?secret=private'
124+ : '/loop' ,
125+ } ,
126+ } ) ;
127+ } ,
128+ } ) ,
129+ onDiagnostic : ( diagnostic ) => {
130+ diagnostics . push ( diagnostic ) ;
131+ throw new Error ( 'observer' ) ;
132+ } ,
133+ }
134+ ) ;
135+ if ( outcome === 'security_rejected' || outcome === 'redirect_rejected' )
136+ await expect ( operation ) . rejects . toThrow ( / u n s a f e / iu) ;
137+ else await expect ( operation ) . resolves . toEqual ( [ ] ) ;
138+ expect ( diagnostics [ 0 ] ) . toMatchObject ( { requestedPath : '/' , outcome } ) ;
139+ expect ( JSON . stringify ( diagnostics ) ) . not . toContain ( 'private' ) ;
140+ } ) ;
141+
142+ it ( 'does not classify caller cancellation as a timeout or let an observer mask it' , async ( ) => {
143+ const controller = new AbortController ( ) ;
144+ const reason = new Error ( 'caller stopped' ) ;
145+ const observer = vi . fn ( ( ) => {
146+ throw new Error ( 'observer' ) ;
147+ } ) ;
148+ await expect (
149+ fetchCompanyEvidence ( 'example.com' , controller . signal , {
150+ ...dependencies ( {
151+ fetch : async ( ) => {
152+ controller . abort ( reason ) ;
153+ throw reason ;
154+ } ,
155+ } ) ,
156+ onDiagnostic : observer ,
157+ } )
158+ ) . rejects . toBe ( reason ) ;
159+ expect ( observer ) . not . toHaveBeenCalled ( ) ;
160+ } ) ;
161+ } ) ;
162+
17163function dependencies (
18164 overrides : Partial < CompanyFetchDependencies > = { }
19165) : CompanyFetchDependencies {
0 commit comments