mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-27 03:59:59 +02:00
a861c00cfa
* feat: Layer C stealth — chrome.*, Notification, per-install hardware, toString Proxy (gbrowser T1+T3+D6)
Three additions stacked into the existing applyStealth() init script
to close the visible automation tells that today push GBrowser users
into Google's /sorry/index captcha and similar:
T1 — Strip Playwright's automation default args:
--enable-automation (kills "Chrome is being
controlled" infobar)
--disable-popup-blocking, --disable-component-update,
--disable-default-apps (Patchright's list — each
is a documented tell)
Now centralized in STEALTH_IGNORE_DEFAULT_ARGS export, used by BOTH
launchHeaded() and handoff() (the headless → headed re-launch path).
D6 — Drop "GStackBrowser" UA branding suffix:
Real Chrome's UA ends `Safari/537.36`, not `Safari/537.36 GStackBrowser`.
The branded suffix was a high-entropy classifier for any vendor that
grep'd UA for known automation/test-browser strings. Branding still
lives in the wrapper .app name + Dock icon + tray — does not need
to leak via the UA string for the product to be "GBrowser." Resolves
the "looks like Chrome but identifies as GStackBrowser" contradiction
codex review #18 flagged.
T3 — Layer C init-script additions in stealth.ts:
1. Function.prototype.toString Proxy (must run first). Wraps every
patched getter / function in a WeakSet so they report
`function NAME() { [native code] }` at every recursion depth,
defeating the depth-3+ integrity check
(fn.toString.toString.toString().includes('[native code]')).
2. window.chrome.runtime / chrome.app / chrome.csi / chrome.loadTimes
restoration with full enum shape (OnInstalledReason, PlatformArch,
PlatformOs, etc.) + method bodies. Real Chrome ships these; their
absence is universally checked. Vendor research (gbrowser plan
deep-dive on Cloudflare + DataDome) confirmed both vendors probe
this shape directly.
3. Notification.permission aligned to 'default'. The existing inline
addInitScript already spoofs permissions.query({name:'notifications'})
to return 'prompt' — Notification.permission being 'denied' while
Permissions returns 'prompt' is a cross-source inconsistency that
detectors flag specifically.
4. Per-install hardware values via GSTACK_HW_CONCURRENCY /
GSTACK_DEVICE_MEMORY env vars (set by gbd's host_profile.go from
system_profiler + sysctl). Reporting real host values within the
Chrome shape avoids the cross-user GBrowser fingerprint cluster
that hardcoded defaults would create. Codex review #10 flagged
hardcoding as creating contradictions across Apple Silicon / Intel
/ UA-CH architecture.
5. Selenium 25-global cleanup + PhantomJS + NightmareJS + Watir +
Playwright (__pwInitScripts, __playwright__binding__) static-name
deletion. The inline block continues to handle the dynamic
cdc_/__webdriver/__selenium/__driver prefixes.
D7 (codex correction) kept: still do NOT fake navigator.plugins or
navigator.languages. Synthesizing those triggers MORE consistency
flags from modern fingerprinters than letting Chromium surface them
natively.
Test coverage:
- 15 new tests in stealth-layer-c.test.ts covering: launch-flag
exports, script structure, toString-Proxy installs first, every
spoof present, hardware values interpolated from input (not
hardcoded), Selenium global cleanup spot-check, no GStackBrowser
leak in stealth payload, backwards-compat exports preserved.
- All 8 existing stealth-webdriver tests still pass.
- All 2 existing browser-manager-unit tests still pass.
For GBrowser specifically: this is the gstack-side half of Phase 1 / T1
+ T3 + D6 in the anti-detection plan. The gbrowser repo's submodule
pointer bump will land alongside this.
* feat: buildGStackLaunchArgs — Pack 1 cmdline-switch construction for gbrowser
New stealth.ts export that turns the GSTACK_* env vars (already populated
by gbrowser's gbd from host_profile.go) into the --gstack-* cmdline
switches the Pack 1 Chromium patches read at WebGL getParameter,
NavigatorUA::userAgentData, NavigatorConcurrentHardware::hardwareConcurrency,
and NavigatorDeviceMemory::deviceMemory time.
Wired into all three launchArgs sites: launch() (headless), launchHeaded()
(real product path), and handoff() (headless → headed re-launch).
Mapping:
GSTACK_GPU_VENDOR → --gstack-gpu-vendor
GSTACK_GPU_RENDERER → --gstack-gpu-renderer
GSTACK_PLATFORM → --gstack-ua-platform (with mapping:
MacARM/MacIntel → macOS, Win32 → Windows,
Linux x86_64 → Linux)
GSTACK_GPU_CHIPSET → --gstack-ua-model
GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency
GSTACK_DEVICE_MEMORY → --gstack-device-memory
Each switch is emitted only when its env var is non-empty — empty
values fall through to the patch's "no override" path, which returns
the real Chromium native value. Safe to ship on Chromium builds
without the Pack 1 patches applied (zero behavior change).
The patches themselves live in the gbrowser repo at chromium/patches/
{webgl-vendor-spoof,ua-client-hints-stealth,worker-navigator-stealth}.patch.
Both halves (gstack arg construction + gbrowser C++ patches) must
land + Chromium rebuild before the spoof reaches the WebGL/UA-CH/
hardware accessors. Currently dormant until then.
Tests (browse/test/stealth-layer-c.test.ts):
7 new buildGStackLaunchArgs cases — empty env, all-populated, partial,
platform mapping (MacARM/MacIntel/Win32/Linux), unrecognized platform
fallthrough, vendor-with-spaces escape-safety.
All 32 stealth/browser-manager tests pass.
For GBrowser specifically: gstack-side half of the Pack 1 flag plumbing.
gbrowser repo will bump the submodule pointer to this commit, then re-run
bun run test/anti-bot/evidence-run.ts to verify creepjs's "33% headless"
score drops after Pack 1 + Chromium rebuild.
* feat: buildGStackLaunchArgs adds --gstack-suppress-prepare-stack-trace
Pack 2 / B11 flag plumbing for the new
error-preparestacktrace-stealth.patch in gbrowser/chromium/patches/.
Always emit --gstack-suppress-prepare-stack-trace unless the caller
explicitly sets GSTACK_CDP_STEALTH=off in the environment. Off by
default in patch behavior (no-op without the C++ patch), so this is
safe on stock Playwright Chromium too.
Closes the Cloudflare canary trick where a page sets
Error.prepareStackTrace and watches for it to fire during CDP
serialization of a logged Error object.
Tests:
All 33 stealth/browser-manager tests pass. New cases:
- GSTACK_CDP_STEALTH=off disables suppression
- empty env still emits the always-on flag (count=1)
- all-populated env now emits 7 flags (was 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(browse): enable Chromium sandbox on headed launchPersistentContext
Mirrors v1.40.0.1 from main lineage (PR #1617). Cherry-picked onto
gbrowser-anti-detection so the GBrowser submodule can consume the fix
without waiting for main to merge.
Playwright auto-adds --no-sandbox whenever chromiumSandbox !== true
(playwright-core/lib/server/chromium/chromium.js:291-292). The headless
chromium.launch() site set the option; the two headed sites
(launchHeaded() and handoff()) did not. Every headed launch on macOS
and Linux showed Chromium's yellow "unsupported command-line flag:
--no-sandbox" infobar.
shouldEnableChromiumSandbox() centralizes the Win32 / CI / CONTAINER /
root heuristic that previously lived only in the headless path's
explicit --no-sandbox push at :225. All three launch sites now use the
helper, and six unit tests pin the policy across darwin, linux, win32,
CI, CONTAINER, and root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.40.0.2 fix(browse): Cmd+Q on managed Chromium stops triggering supervisor respawn
Three browser.on('disconnected') handlers in browse/src/browser-manager.ts
(launch, launchHeaded, handoff) each exited with a non-zero code on every
disconnect, regardless of cause. Process supervisors that consume our exit
code (gbrowser's gbd HealthMonitor in cmd/gbd/health.go) treated user
Cmd+Q identical to a Chromium crash and respawned with exponential
backoff, so the visible browser kept reappearing after the user closed it.
Add resolveDisconnectCause(browser) that reads the underlying ChildProcess
exitCode + signalCode (waiting up to 1s for the exit event if the
disconnected event fired first). Exit code 0 + no signal = clean user
quit; anything else = crash, signal-kill, or OOM.
Wire the resolver into all three disconnect handlers:
- launch() (headless): clean → exit 0, crash → exit 1 (was always 1)
- launchHeaded() (headed): clean → exit 0, crash → exit 2 (was always 2)
onDisconnect() cleanup callback still runs in both cases.
- handoff() (re-launch): same as launch() via the helper.
Preserve the per-path crash codes (1 vs 2) so any supervisor that
differentiated headed vs headless crashes keeps working.
Seven new unit tests in browse-manager-unit.test.ts cover the resolver
across already-exited, signal-killed (SIGSEGV / SIGKILL), async exits,
and null-browser inputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(browse): apply stealth on every launch path + share automation-artifact cleanup
handoff() built cmdline args but never called applyStealth, so a handed-off
browser had no JS stealth (no webdriver mask, no chrome.* shape, no toString
proxy). And the cdc_/Permissions cleanup shim lived inline in launchHeaded()
only, so headless launch() reported Notification.permission='default' without
the matching permissions.query='prompt' answer — the exact cross-source
inconsistency the shim exists to prevent.
Move the cleanup into AUTOMATION_ARTIFACT_CLEANUP_SCRIPT inside applyStealth so
all three launch paths (launch, launchHeaded, handoff) get identical stealth,
and call applyStealth(newContext) in handoff() before restoreState() navigates.
A static tripwire in browser-manager-unit.test.ts fails CI if any launch path
drops the applyStealth call again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(browse): make --gstack-suppress-prepare-stack-trace opt-in, not default-on
buildGStackLaunchArgs() pushed the flag unless GSTACK_CDP_STEALTH=off, i.e.
on-by-default — contradicting its own comment ("off by default, only for
gbrowser builds"). The switch is read by a C++ patch that only exists in
gbrowser; on stock Playwright Chromium it is an unknown switch.
Flip to opt-in: emit only when GSTACK_CDP_STEALTH is on/1/true. gbd opts in by
exporting GSTACK_CDP_STEALTH=on; stock installs leave it unset so the flag
never reaches a Chromium that wouldn't understand it. Comment now matches code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(browse): correct stale stealth comments
The file-level stealth.ts docstring claimed "we DON'T fake navigator.plugins"
while the same file now ships EXTENDED_STEALTH_SCRIPT, which does fake plugins
when GSTACK_STEALTH=extended. Clarify that Layer C (the always-on default)
doesn't fake plugins and the opt-in extended mode does, as the documented
"actively lies, may break sites" escape hatch.
Also fix the launch()/launchHeaded() comments that said "mask navigator.webdriver
only" — applyStealth (Layer C) also restores window.chrome.*, aligns
Notification.permission, and sets per-install hardware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(browse): runtime + extended-mode coverage for the stealth blend
The stealth tests were all static string-shape assertions; nothing executed
the script in a real page. Add real-Chromium runtime checks via applyStealth +
page.evaluate:
- Layer C runtime: window.chrome.* rich shape, Notification.permission='default'
paired with permissions.query notifications='prompt' (guards the shim now
running on every path), and patched getters reporting [native code].
- Per-install hardware: navigator.hardwareConcurrency/deviceMemory reflect the
GSTACK_* env profile.
- Extended-mode blend: navigator.plugins is faked when GSTACK_STEALTH=extended,
Layer C still wins window.chrome.runtime, and navigator.webdriver stays false
(own-prop getter survives extended's prototype delete).
- Persistent-context (launchHeaded/handoff) parity now uses a page created
AFTER applyStealth — the old test checked pages()[0], which predates the init
script, so webdriver was false only via the launch arg, not Layer C.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(browse): handoff() + launchHeaded() spread the shared STEALTH_LAUNCH_ARGS
handoff() built its launch args from only ['--hide-crash-restore-bubble',
...buildGStackLaunchArgs()], omitting STEALTH_LAUNCH_ARGS — so a handed-off
browser kept the --disable-blink-features=AutomationControlled tell that
launch() and launchHeaded() strip. launchHeaded() also hardcoded the flag as a
literal. Both now spread the shared constant, so the AutomationControlled flag
lives in one place across all three launch paths.
Tripwires: STEALTH_LAUNCH_ARGS spread into >= 3 sites (no inline literal) and
STEALTH_IGNORE_DEFAULT_ARGS wired into both persistent-context paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(browse): drop dead HostProfile.platform, export test internals
HostProfile.platform was set by readHostProfile but never read by
buildStealthScript — the platform spoof is owned by the UA-CH cmdline switch in
buildGStackLaunchArgs (which reads GSTACK_PLATFORM directly). Remove the dead
field. Export readHostProfile and AUTOMATION_ARTIFACT_CLEANUP_SCRIPT so their
clamp/shape invariants can be unit-tested. Correct the stale "25 Selenium
globals" count comment and note the extended cdc_ scan is redundant-but-retained
for standalone use.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(browse): cover readHostProfile clamp, toString depth-3, chrome.* calls
Pre-landing review coverage gaps:
- readHostProfile clamps 0/negative/NaN/missing env to 8 (a deviceMemory=0 or
NaN would be a glaring bot tell) — now asserted.
- toString proxy survives the depth-3 recursion trick
(fn.toString.toString.toString().includes('[native code]')), the headline
claim that was only tested at depth-1.
- chrome.csi() and chrome.loadTimes() are invoked (not just typeof-checked) and
runtime.connect() throws the native-shaped "No matching signature" error.
- AUTOMATION_ARTIFACT_CLEANUP_SCRIPT static shape (cdc_/__webdriver strip +
notifications->prompt) as a hermetic backup for the live-Chromium pairing test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(browse): recreateContext() re-applies stealth (closes 4th un-stealth path)
useragent and viewport --scale route through recreateContext(), which rebuilds
the BrowserContext via newContext() — a fresh context with no init scripts. It
never called applyStealth, so a routine useragent/viewport-scale command
silently dropped webdriver masking, window.chrome.* shape, hardware spoof, and
the cdc/Permissions cleanup on every restored page. Caught by the cross-model
adversarial review (Codex) after the Claude pass and eng review missed it.
Both the main and fallback paths now call applyStealth before any page is
created. The launch-path tripwire is raised to >= 4 sites and now asserts the
recreateContext() body specifically, so the regression class can't recur.
Also documents the load-bearing trust assumption on buildGStackLaunchArgs /
readHostProfile (GSTACK_* must be gbd-sourced, never page/remote data — the
injection-safety argument depends on it) and the notifications-permission
spoof tradeoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.58.3.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: sync browser stealth docs to Layer C (v1.58.3.0)
BROWSER.md "Stealth scope" still described the default as navigator.webdriver
masking only; Layer C is now the always-on default across all four
context-creation paths. Update the stealth-scope prose, the "What GStack
Browser means" blurb (stock-Chrome UA, no GStackBrowser suffix, captchas can
still get through at the CDP layer), the stealth.ts source-map line, and the
env-vars table (GSTACK_STEALTH, GSTACK_CDP_STEALTH, GSTACK_GPU_*, GSTACK_PLATFORM,
GSTACK_HW_CONCURRENCY/GSTACK_DEVICE_MEMORY + the explicit --gstack-* switches and
ignoreDefaultArgs stripping). Correct the stale "narrows to navigator.webdriver
masking only" premise on the open CDP-patch TODO (the TODO itself stays open —
the CDP-protocol layer is still unaddressed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
/**
|
|
* stealth.ts Layer C additions (T3 + D6 for GBrowser anti-detection):
|
|
* verifies the build-time scaffolding without requiring a live browser.
|
|
*
|
|
* Live-browser verification of these spoofs in actual page contexts is
|
|
* covered by the gbrowser-side `test/anti-bot.test.sh` (Phase 1 / T7)
|
|
* which loads the probe page through the built GBrowser app post-bundle.
|
|
* These tests only exercise the JS script builder + the static export
|
|
* shapes — fast, hermetic, no chromium launch.
|
|
*/
|
|
import { describe, test, expect, afterEach } from 'bun:test';
|
|
import {
|
|
buildStealthScript,
|
|
buildGStackLaunchArgs,
|
|
readHostProfile,
|
|
AUTOMATION_ARTIFACT_CLEANUP_SCRIPT,
|
|
WEBDRIVER_MASK_SCRIPT,
|
|
STEALTH_LAUNCH_ARGS,
|
|
STEALTH_IGNORE_DEFAULT_ARGS,
|
|
} from '../src/stealth';
|
|
|
|
describe('STEALTH_IGNORE_DEFAULT_ARGS — T1', () => {
|
|
test('includes --enable-automation (kills infobar)', () => {
|
|
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--enable-automation');
|
|
});
|
|
test('includes the 4 Patchright-recommended adds', () => {
|
|
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-popup-blocking');
|
|
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-update');
|
|
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-default-apps');
|
|
});
|
|
test('preserves the original extension-loading blockers', () => {
|
|
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-extensions');
|
|
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-extensions-with-background-pages');
|
|
});
|
|
});
|
|
|
|
describe('buildStealthScript — T3 Layer C', () => {
|
|
const hw = { platform: 'MacARM', hwConcurrency: 16, deviceMemory: 8 };
|
|
|
|
test('builds a self-invoking function (atomic injection)', () => {
|
|
const s = buildStealthScript(hw);
|
|
expect(s.trim().startsWith('(() => {')).toBe(true);
|
|
expect(s.trim().endsWith('})();')).toBe(true);
|
|
});
|
|
|
|
test('installs the Function.prototype.toString Proxy FIRST', () => {
|
|
const s = buildStealthScript(hw);
|
|
const proxyIdx = s.indexOf('new Proxy(nativeToString');
|
|
const webdriverIdx = s.indexOf("'webdriver'");
|
|
expect(proxyIdx).toBeGreaterThan(0);
|
|
expect(webdriverIdx).toBeGreaterThan(proxyIdx);
|
|
});
|
|
|
|
test('navigator.webdriver getter returns false', () => {
|
|
const s = buildStealthScript(hw);
|
|
expect(s).toMatch(/Object\.defineProperty\(navigator, 'webdriver'/);
|
|
expect(s).toMatch(/return false/);
|
|
});
|
|
|
|
test('window.chrome.runtime ships full enum shape', () => {
|
|
const s = buildStealthScript(hw);
|
|
expect(s).toContain('OnInstalledReason');
|
|
expect(s).toContain('PlatformArch');
|
|
expect(s).toContain('PlatformOs');
|
|
expect(s).toContain('RequestUpdateCheckStatus');
|
|
// sendMessage / connect must throw native-shaped errors
|
|
expect(s).toContain('runtime.connect');
|
|
expect(s).toContain('runtime.sendMessage');
|
|
});
|
|
|
|
test('chrome.csi and chrome.loadTimes provide method bodies', () => {
|
|
const s = buildStealthScript(hw);
|
|
expect(s).toContain('chrome.csi = markNative(function csi()');
|
|
expect(s).toContain('chrome.loadTimes = markNative(function loadTimes()');
|
|
// loadTimes shape must include wasFetchedViaSpdy/connectionInfo —
|
|
// those are what real Chrome's loadTimes() returns on HTTP/2 sites.
|
|
expect(s).toContain('wasFetchedViaSpdy');
|
|
expect(s).toContain('connectionInfo');
|
|
});
|
|
|
|
test('Notification.permission aligned to default', () => {
|
|
const s = buildStealthScript(hw);
|
|
expect(s).toMatch(/Notification, 'permission'/);
|
|
expect(s).toMatch(/return 'default'/);
|
|
});
|
|
|
|
test('hardware values interpolated from host profile (NOT hardcoded)', () => {
|
|
const s = buildStealthScript({ platform: 'MacARM', hwConcurrency: 12, deviceMemory: 4 });
|
|
expect(s).toContain('return 12');
|
|
expect(s).toContain('return 4');
|
|
expect(s).not.toMatch(/return 8;.*hardwareConcurrency/);
|
|
});
|
|
|
|
test('cleans up Selenium + Playwright + Phantom + Nightmare globals', () => {
|
|
const s = buildStealthScript(hw);
|
|
// Spot-check a few from each category
|
|
expect(s).toContain('__webdriver_evaluate'); // Selenium
|
|
expect(s).toContain('domAutomationController'); // Chrome Driver classic
|
|
expect(s).toContain('__pwInitScripts'); // Playwright
|
|
expect(s).toContain('callPhantom'); // PhantomJS
|
|
expect(s).toContain('__nightmare'); // NightmareJS
|
|
expect(s).toContain('_Selenium_IDE_Recorder'); // Selenium IDE
|
|
});
|
|
|
|
test('uses markNative wrapper for every patched function', () => {
|
|
const s = buildStealthScript(hw);
|
|
// Every getter (hardwareConcurrency, deviceMemory, webdriver, Notification.permission)
|
|
// should be wrapped through markNative so the toString Proxy covers it.
|
|
const markNativeMatches = s.match(/markNative\(/g) || [];
|
|
// At least 8 markNative wrappings (webdriver, csi, loadTimes, connect, sendMessage,
|
|
// notification permission, hwConcurrency, deviceMemory)
|
|
expect(markNativeMatches.length).toBeGreaterThanOrEqual(7);
|
|
});
|
|
|
|
test('script does not include "GStackBrowser" branding string', () => {
|
|
const s = buildStealthScript(hw);
|
|
// D6: dropped from UA, must not leak in via stealth payload either.
|
|
expect(s).not.toContain('GStackBrowser');
|
|
});
|
|
});
|
|
|
|
describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => {
|
|
// Helper: clear all GSTACK_* env, run test body, restore env.
|
|
function withEnv(env: Record<string, string | undefined>, body: () => void) {
|
|
const saved: Record<string, string | undefined> = {};
|
|
for (const k of Object.keys(process.env)) {
|
|
if (k.startsWith('GSTACK_')) {
|
|
saved[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
}
|
|
for (const [k, v] of Object.entries(env)) {
|
|
if (v !== undefined) process.env[k] = v;
|
|
}
|
|
try {
|
|
body();
|
|
} finally {
|
|
for (const k of Object.keys(process.env)) {
|
|
if (k.startsWith('GSTACK_')) delete process.env[k];
|
|
}
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v !== undefined) process.env[k] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
test('empty env produces no switches (suppress-stack is opt-in)', () => {
|
|
withEnv({}, () => {
|
|
// All switches are opt-in: the six per-install flags fall through
|
|
// (nothing in env), and the Pack 2 / B11 suppression flag is only
|
|
// emitted when GSTACK_CDP_STEALTH is on/1/true. Empty env → [].
|
|
expect(buildGStackLaunchArgs()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test('all env values populated (incl. CDP stealth opt-in) → all 7 switches emitted', () => {
|
|
withEnv({
|
|
GSTACK_GPU_VENDOR: 'Apple Inc.',
|
|
GSTACK_GPU_RENDERER: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)',
|
|
GSTACK_PLATFORM: 'MacARM',
|
|
GSTACK_GPU_CHIPSET: 'Apple M4 Max',
|
|
GSTACK_HW_CONCURRENCY: '16',
|
|
GSTACK_DEVICE_MEMORY: '8',
|
|
GSTACK_CDP_STEALTH: 'on',
|
|
}, () => {
|
|
const args = buildGStackLaunchArgs();
|
|
expect(args).toContain('--gstack-gpu-vendor=Apple Inc.');
|
|
expect(args).toContain('--gstack-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)');
|
|
expect(args).toContain('--gstack-ua-platform=macOS');
|
|
expect(args).toContain('--gstack-ua-model=Apple M4 Max');
|
|
expect(args).toContain('--gstack-hw-concurrency=16');
|
|
expect(args).toContain('--gstack-device-memory=8');
|
|
expect(args).toContain('--gstack-suppress-prepare-stack-trace');
|
|
expect(args.length).toBe(7);
|
|
});
|
|
});
|
|
|
|
test('platform mapping: MacARM and MacIntel both → macOS', () => {
|
|
withEnv({ GSTACK_PLATFORM: 'MacARM' }, () => {
|
|
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS');
|
|
});
|
|
withEnv({ GSTACK_PLATFORM: 'MacIntel' }, () => {
|
|
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS');
|
|
});
|
|
});
|
|
|
|
test('platform mapping: Win32 → Windows, Linux x86_64 → Linux', () => {
|
|
withEnv({ GSTACK_PLATFORM: 'Win32' }, () => {
|
|
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Windows');
|
|
});
|
|
withEnv({ GSTACK_PLATFORM: 'Linux x86_64' }, () => {
|
|
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Linux');
|
|
});
|
|
});
|
|
|
|
test('partial env: only set switches that have values', () => {
|
|
withEnv({ GSTACK_HW_CONCURRENCY: '12' }, () => {
|
|
const args = buildGStackLaunchArgs();
|
|
// hw only — suppress-stack is opt-in and GSTACK_CDP_STEALTH is unset.
|
|
expect(args).toContain('--gstack-hw-concurrency=12');
|
|
expect(args).not.toContain('--gstack-suppress-prepare-stack-trace');
|
|
expect(args.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
test('prepare-stack-trace suppression is opt-in via GSTACK_CDP_STEALTH', () => {
|
|
// on/1/true enable it; off and unset omit it, so stock Playwright
|
|
// Chromium (no GSTACK_CDP_STEALTH) never receives the unknown switch.
|
|
for (const v of ['on', '1', 'true']) {
|
|
withEnv({ GSTACK_CDP_STEALTH: v }, () => {
|
|
expect(buildGStackLaunchArgs()).toContain('--gstack-suppress-prepare-stack-trace');
|
|
});
|
|
}
|
|
withEnv({ GSTACK_CDP_STEALTH: 'off' }, () => {
|
|
expect(buildGStackLaunchArgs()).not.toContain('--gstack-suppress-prepare-stack-trace');
|
|
});
|
|
withEnv({}, () => {
|
|
expect(buildGStackLaunchArgs()).not.toContain('--gstack-suppress-prepare-stack-trace');
|
|
});
|
|
});
|
|
|
|
test('unrecognized platform falls through without --gstack-ua-platform', () => {
|
|
withEnv({ GSTACK_PLATFORM: 'OS/2' }, () => {
|
|
const args = buildGStackLaunchArgs();
|
|
expect(args.some(a => a.startsWith('--gstack-ua-platform='))).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('GPU vendor with spaces survives intact (no quote/escape regression)', () => {
|
|
withEnv({ GSTACK_GPU_VENDOR: 'NVIDIA Corporation' }, () => {
|
|
const args = buildGStackLaunchArgs();
|
|
expect(args).toContain('--gstack-gpu-vendor=NVIDIA Corporation');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('backwards-compat exports', () => {
|
|
test('WEBDRIVER_MASK_SCRIPT still exported', () => {
|
|
expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'");
|
|
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
|
|
});
|
|
test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => {
|
|
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
|
|
});
|
|
});
|
|
|
|
describe('readHostProfile — clamp/fallback', () => {
|
|
let savedHw: string | undefined;
|
|
let savedMem: string | undefined;
|
|
afterEach(() => {
|
|
if (savedHw === undefined) delete process.env.GSTACK_HW_CONCURRENCY;
|
|
else process.env.GSTACK_HW_CONCURRENCY = savedHw;
|
|
if (savedMem === undefined) delete process.env.GSTACK_DEVICE_MEMORY;
|
|
else process.env.GSTACK_DEVICE_MEMORY = savedMem;
|
|
});
|
|
function withHw(hw: string | undefined, mem: string | undefined): ReturnType<typeof readHostProfile> {
|
|
savedHw = process.env.GSTACK_HW_CONCURRENCY;
|
|
savedMem = process.env.GSTACK_DEVICE_MEMORY;
|
|
if (hw === undefined) delete process.env.GSTACK_HW_CONCURRENCY; else process.env.GSTACK_HW_CONCURRENCY = hw;
|
|
if (mem === undefined) delete process.env.GSTACK_DEVICE_MEMORY; else process.env.GSTACK_DEVICE_MEMORY = mem;
|
|
return readHostProfile();
|
|
}
|
|
|
|
test('valid env values pass through', () => {
|
|
expect(withHw('16', '8')).toEqual({ hwConcurrency: 16, deviceMemory: 8 });
|
|
});
|
|
|
|
test('missing env → default 8/8', () => {
|
|
expect(withHw(undefined, undefined)).toEqual({ hwConcurrency: 8, deviceMemory: 8 });
|
|
});
|
|
|
|
test('zero / negative / NaN / empty all clamp to 8 (never a 0 or NaN bot tell)', () => {
|
|
for (const bad of ['0', '-4', 'abc', '']) {
|
|
const p = withHw(bad, bad);
|
|
expect(p.hwConcurrency).toBe(8);
|
|
expect(p.deviceMemory).toBe(8);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('AUTOMATION_ARTIFACT_CLEANUP_SCRIPT — static shape', () => {
|
|
test('strips cdc_/__webdriver and maps notifications query to prompt', () => {
|
|
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("startsWith('cdc_')");
|
|
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("startsWith('__webdriver')");
|
|
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("name === 'notifications'");
|
|
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("state: 'prompt'");
|
|
});
|
|
});
|