@@ -16,36 +16,61 @@ public sealed class StealthIntegrationTests
1616 private const string SignalAttributeName = "data-stealth-signals" ;
1717 private const int NetworkIdleTimeoutMs = 10_000 ;
1818 private static readonly string ScreenshotDirectory = Path . Combine ( AppContext . BaseDirectory , "artifacts" , "screenshots" ) ;
19+ private static readonly string SignalDirectory = Path . Combine ( AppContext . BaseDirectory , "artifacts" , "signals" ) ;
1920
2021 [ Test ]
2122 public Task BrowserScan_Should_Not_Surface_BotSignals ( ) =>
22- RunStealthCheckAsync ( StealthTestSites . BrowserScan ) ;
23+ RunSiteCheckAsync ( StealthTestSites . BrowserScan , applyStealth : true ) ;
2324
2425 [ Test ]
2526 public Task SannySoft_Should_Not_Surface_BotSignals ( ) =>
26- RunStealthCheckAsync ( StealthTestSites . SannySoft ) ;
27+ RunSiteCheckAsync ( StealthTestSites . SannySoft , applyStealth : true ) ;
2728
2829 [ Test ]
2930 public Task Intoli_Should_Not_Surface_BotSignals ( ) =>
30- RunStealthCheckAsync ( StealthTestSites . Intoli ) ;
31+ RunSiteCheckAsync ( StealthTestSites . Intoli , applyStealth : true ) ;
3132
3233 [ Test ]
3334 public Task Fingerprint_Should_Not_Surface_BotSignals ( ) =>
34- RunStealthCheckAsync ( StealthTestSites . Fingerprint ) ;
35+ RunSiteCheckAsync ( StealthTestSites . Fingerprint , applyStealth : true ) ;
3536
3637 [ Test ]
3738 public Task AreYouHeadless_Should_Not_Surface_BotSignals ( ) =>
38- RunStealthCheckAsync ( StealthTestSites . AreYouHeadless ) ;
39+ RunSiteCheckAsync ( StealthTestSites . AreYouHeadless , applyStealth : true ) ;
3940
4041 [ Test ]
4142 public Task PixelScan_Should_Not_Surface_BotSignals ( ) =>
42- RunStealthCheckAsync ( StealthTestSites . PixelScan ) ;
43+ RunSiteCheckAsync ( StealthTestSites . PixelScan , applyStealth : true ) ;
44+
45+ [ Test ]
46+ public Task BrowserScan_Baseline_Should_Capture_Signals ( ) =>
47+ RunSiteCheckAsync ( StealthTestSites . BrowserScan , applyStealth : false ) ;
48+
49+ [ Test ]
50+ public Task SannySoft_Baseline_Should_Capture_Signals ( ) =>
51+ RunSiteCheckAsync ( StealthTestSites . SannySoft , applyStealth : false ) ;
52+
53+ [ Test ]
54+ public Task Intoli_Baseline_Should_Capture_Signals ( ) =>
55+ RunSiteCheckAsync ( StealthTestSites . Intoli , applyStealth : false ) ;
56+
57+ [ Test ]
58+ public Task Fingerprint_Baseline_Should_Capture_Signals ( ) =>
59+ RunSiteCheckAsync ( StealthTestSites . Fingerprint , applyStealth : false ) ;
60+
61+ [ Test ]
62+ public Task AreYouHeadless_Baseline_Should_Capture_Signals ( ) =>
63+ RunSiteCheckAsync ( StealthTestSites . AreYouHeadless , applyStealth : false ) ;
64+
65+ [ Test ]
66+ public Task PixelScan_Baseline_Should_Capture_Signals ( ) =>
67+ RunSiteCheckAsync ( StealthTestSites . PixelScan , applyStealth : false ) ;
4368
4469 [ Test ]
4570 [ GoogleSearchOnly ]
4671 public async Task GoogleSearch_Should_Find_ManagedCode ( )
4772 {
48- await WithStealthPageAsync ( async page =>
73+ await WithPageAsync ( applyStealth : true , async page =>
4974 {
5075 var label = "google_search" ;
5176 try
@@ -75,25 +100,31 @@ await WithStealthPageAsync(async page =>
75100 } ) ;
76101 }
77102
78- private static async Task RunStealthCheckAsync ( string url )
103+ private static async Task RunSiteCheckAsync ( string url , bool applyStealth )
79104 {
80- await WithStealthPageAsync ( async page =>
105+ await WithPageAsync ( applyStealth , async page =>
81106 {
82107 var label = GetScreenshotLabelFromUrl ( url ) ;
108+ var modeLabel = applyStealth ? "stealth" : "baseline" ;
83109 try
84110 {
85111 await NavigateWithRetriesAsync ( page , url ) ;
86112 await PrimePageAsync ( page ) ;
87- await AssertStealthSignalsAsync ( page ) ;
113+ var signals = await GetSignalsAsync ( page ) ;
114+ await PersistSignalsAsync ( $ "{ modeLabel } _{ label } ", url , modeLabel , signals ) ;
115+ if ( applyStealth )
116+ {
117+ await AssertStealthSignalsAsync ( signals ) ;
118+ }
88119 }
89120 finally
90121 {
91- await CaptureScreenshotAsync ( page , label ) ;
122+ await CaptureScreenshotAsync ( page , $ " { modeLabel } _ { label } " ) ;
92123 }
93124 } ) ;
94125 }
95126
96- private static async Task WithStealthPageAsync ( Func < IPage , Task > action )
127+ private static async Task WithPageAsync ( bool applyStealth , Func < IPage , Task > action )
97128 {
98129 await PlaywrightInstall . EnsureInstalledAsync ( ) ;
99130 using var playwright = await Microsoft . Playwright . Playwright . CreateAsync ( ) ;
@@ -105,10 +136,15 @@ private static async Task WithStealthPageAsync(Func<IPage, Task> action)
105136
106137 await using var context = await browser . NewContextAsync ( new BrowserNewContextOptions
107138 {
108- Locale = CultureInfo . CurrentCulture . Name
139+ Locale = CultureInfo . CurrentCulture . Name ,
140+ ViewportSize = new ViewportSize { Width = 1920 , Height = 1080 } ,
141+ DeviceScaleFactor = 2
109142 } ) ;
110143
111- await context . ApplyStealthAsync ( ) ;
144+ if ( applyStealth )
145+ {
146+ await context . ApplyStealthAsync ( ) ;
147+ }
112148 var page = await context . NewPageAsync ( ) ;
113149 await InstallSignalProbeAsync ( page ) ;
114150
@@ -144,7 +180,7 @@ private static async Task NavigateWithRetriesAsync(IPage page, string url)
144180 throw new InvalidOperationException ( $ "Navigation failed for { url } .", lastError ) ;
145181 }
146182
147- private static async Task AssertStealthSignalsAsync ( IPage page )
183+ private static async Task < StealthSignals > GetSignalsAsync ( IPage page )
148184 {
149185 await page . WaitForFunctionAsync ( $ "() => document.documentElement.hasAttribute('{ SignalAttributeName } ')", new PageWaitForFunctionOptions
150186 {
@@ -167,6 +203,11 @@ private static async Task AssertStealthSignalsAsync(IPage page)
167203 throw new InvalidOperationException ( "Stealth signal payload could not be parsed." ) ;
168204 }
169205
206+ return signals ;
207+ }
208+
209+ private static async Task AssertStealthSignalsAsync ( StealthSignals signals )
210+ {
170211 await Assert . That ( signals . WebDriver ) . IsFalse ( ) ;
171212 if ( signals . PluginCount <= 0 )
172213 {
@@ -293,20 +334,95 @@ private static async Task CaptureScreenshotAsync(IPage page, string label)
293334 Directory . CreateDirectory ( ScreenshotDirectory ) ;
294335 var timestamp = DateTime . UtcNow . ToString ( "yyyyMMdd_HHmmss_fff" , CultureInfo . InvariantCulture ) ;
295336 var safeLabel = SanitizeFileName ( label ) ;
296- var path = Path . Combine ( ScreenshotDirectory , $ "{ timestamp } _{ safeLabel } .png") ;
337+ var baseName = $ "{ timestamp } _{ safeLabel } ";
338+ var path = Path . Combine ( ScreenshotDirectory , $ "{ baseName } .png") ;
297339 await page . ScreenshotAsync ( new PageScreenshotOptions
298340 {
299341 Path = path ,
300342 FullPage = true
301343 } ) ;
302344 Console . WriteLine ( $ "Saved screenshot: { path } ") ;
345+
346+ await CaptureViewportSlicesAsync ( page , baseName ) ;
303347 }
304348 catch ( Exception ex ) when ( ex is IOException or PlaywrightException )
305349 {
306350 Console . WriteLine ( $ "Failed to capture screenshot: { ex . Message } ") ;
307351 }
308352 }
309353
354+ private static async Task PersistSignalsAsync ( string label , string url , string mode , StealthSignals signals )
355+ {
356+ try
357+ {
358+ Directory . CreateDirectory ( SignalDirectory ) ;
359+ var timestamp = DateTime . UtcNow . ToString ( "yyyyMMdd_HHmmss_fff" , CultureInfo . InvariantCulture ) ;
360+ var safeLabel = SanitizeFileName ( label ) ;
361+ var path = Path . Combine ( SignalDirectory , $ "{ timestamp } _{ safeLabel } .json") ;
362+ var payload = new SignalSnapshot
363+ {
364+ Url = url ,
365+ Mode = mode ,
366+ TimestampUtc = DateTimeOffset . UtcNow ,
367+ Signals = signals
368+ } ;
369+ var json = JsonSerializer . Serialize ( payload , new JsonSerializerOptions
370+ {
371+ WriteIndented = true
372+ } ) ;
373+ await File . WriteAllTextAsync ( path , json ) ;
374+ Console . WriteLine ( $ "Saved signals: { path } ") ;
375+ }
376+ catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
377+ {
378+ Console . WriteLine ( $ "Failed to persist signals: { ex . Message } ") ;
379+ }
380+ }
381+
382+ private static async Task CaptureViewportSlicesAsync ( IPage page , string baseName )
383+ {
384+ try
385+ {
386+ var metrics = await page . EvaluateAsync < ViewportMetrics > ( """
387+ () => ({
388+ scrollHeight: document.documentElement.scrollHeight,
389+ viewportHeight: window.innerHeight
390+ })
391+ """ ) ;
392+
393+ if ( metrics . ScrollHeight <= 0 || metrics . ViewportHeight <= 0 )
394+ {
395+ return ;
396+ }
397+
398+ var step = Math . Max ( 1 , metrics . ViewportHeight - 120 ) ;
399+ var index = 1 ;
400+ for ( var offset = 0 ; offset < metrics . ScrollHeight ; offset += step )
401+ {
402+ await page . EvaluateAsync ( "y => window.scrollTo(0, y)" , offset ) ;
403+ await page . WaitForTimeoutAsync ( 250 ) ;
404+
405+ var path = Path . Combine ( ScreenshotDirectory , $ "{ baseName } _slice_{ index : D2} .png") ;
406+ await page . ScreenshotAsync ( new PageScreenshotOptions
407+ {
408+ Path = path ,
409+ FullPage = false
410+ } ) ;
411+ Console . WriteLine ( $ "Saved screenshot: { path } ") ;
412+
413+ index ++ ;
414+ if ( offset + metrics . ViewportHeight >= metrics . ScrollHeight )
415+ {
416+ break ;
417+ }
418+ }
419+ }
420+ catch ( Exception ex ) when ( ex is IOException or PlaywrightException )
421+ {
422+ Console . WriteLine ( $ "Failed to capture viewport slices: { ex . Message } ") ;
423+ }
424+ }
425+
310426 private static string GetScreenshotLabelFromUrl ( string url )
311427 {
312428 if ( ! Uri . TryCreate ( url , UriKind . Absolute , out var uri ) )
@@ -352,6 +468,20 @@ private sealed class StealthSignals
352468 public string WebglRenderer { get ; set ; } = string . Empty ;
353469 }
354470
471+ private sealed class SignalSnapshot
472+ {
473+ public string Url { get ; set ; } = string . Empty ;
474+ public string Mode { get ; set ; } = string . Empty ;
475+ public DateTimeOffset TimestampUtc { get ; set ; }
476+ public StealthSignals Signals { get ; set ; } = new ( ) ;
477+ }
478+
479+ private sealed class ViewportMetrics
480+ {
481+ public int ScrollHeight { get ; set ; }
482+ public int ViewportHeight { get ; set ; }
483+ }
484+
355485 public sealed class GoogleSearchOnlyAttribute : SkipAttribute
356486 {
357487 public GoogleSearchOnlyAttribute ( )
0 commit comments