Skip to content

Commit 5b7871b

Browse files
committed
more tests
1 parent 5fc3d92 commit 5b7871b

4 files changed

Lines changed: 198 additions & 23 deletions

File tree

src/Playwright.Stealth/Resources/js/navigator.permissions.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
1+
const getNotificationPermission = () => {
2+
try {
3+
if (typeof Notification !== 'undefined' && Notification.permission) {
4+
return Notification.permission
5+
}
6+
} catch (err) {
7+
}
8+
return 'default'
9+
}
10+
11+
const normalizePermission = (permission) => permission === 'denied' ? 'default' : permission
12+
13+
const normalizedPermission = normalizePermission(getNotificationPermission())
14+
15+
try {
16+
if (typeof Notification !== 'undefined') {
17+
const descriptor = Object.getOwnPropertyDescriptor(Notification, 'permission')
18+
if (descriptor && descriptor.configurable) {
19+
Object.defineProperty(Notification, 'permission', {
20+
get: () => normalizedPermission
21+
})
22+
}
23+
}
24+
} catch (err) {
25+
}
26+
127
const handler = {
228
apply: function (target, ctx, args) {
329
const param = (args || [])[0]
430

531
if (param && param.name && param.name === 'notifications') {
6-
const result = {state: Notification.permission}
32+
const state = normalizedPermission === 'default' ? 'prompt' : normalizedPermission
33+
const result = {state}
734
Object.setPrototypeOf(result, PermissionStatus.prototype)
835
return Promise.resolve(result)
936
}

src/Playwright.Stealth/Resources/js/navigator.userAgent.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
// replace Headless references in default useragent
22
try {
33
const current_ua = navigator.userAgent
4+
const current_app_version = navigator.appVersion
45
const proto = Object.getPrototypeOf(navigator)
5-
const descriptor = Object.getOwnPropertyDescriptor(proto, 'userAgent')
6+
const ua_descriptor = Object.getOwnPropertyDescriptor(proto, 'userAgent')
7+
const app_version_descriptor = Object.getOwnPropertyDescriptor(proto, 'appVersion')
8+
const patched_ua = opts.navigator_user_agent || current_ua.replace('HeadlessChrome/', 'Chrome/')
9+
const patched_app_version = opts.navigator_user_agent
10+
? opts.navigator_user_agent.replace('Mozilla/', '')
11+
: current_app_version.replace('HeadlessChrome/', 'Chrome/')
612

7-
if (descriptor && descriptor.configurable) {
13+
if (ua_descriptor && ua_descriptor.configurable) {
814
Object.defineProperty(proto, 'userAgent', {
9-
get: () => opts.navigator_user_agent || current_ua.replace('HeadlessChrome/', 'Chrome/')
15+
get: () => patched_ua
16+
})
17+
}
18+
19+
if (app_version_descriptor && app_version_descriptor.configurable) {
20+
Object.defineProperty(proto, 'appVersion', {
21+
get: () => patched_app_version
1022
})
1123
}
1224
} catch (err) {
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
'use strict'
22

33
try {
4-
if (!!window.outerWidth && !!window.outerHeight) {
4+
if (!window.outerWidth || !window.outerHeight) {
55
const windowFrame = 85 // probably OS and WM dependent
6-
window.outerWidth = window.innerWidth
7-
window.outerHeight = window.innerHeight + windowFrame
6+
Object.defineProperty(window, 'outerWidth', {
7+
get: () => window.innerWidth,
8+
configurable: true
9+
})
10+
Object.defineProperty(window, 'outerHeight', {
11+
get: () => window.innerHeight + windowFrame,
12+
configurable: true
13+
})
814
}
915
} catch (err) {
1016
}

tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs

Lines changed: 146 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)