mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-23 02:00:00 +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>
306 lines
14 KiB
TypeScript
306 lines
14 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import { afterEach, beforeEach, describe, it, expect } from 'bun:test';
|
|
|
|
// ─── BrowserManager basic unit tests ─────────────────────────────
|
|
|
|
describe('BrowserManager defaults', () => {
|
|
it('getConnectionMode defaults to launched', async () => {
|
|
const { BrowserManager } = await import('../src/browser-manager');
|
|
const bm = new BrowserManager();
|
|
expect(bm.getConnectionMode()).toBe('launched');
|
|
});
|
|
|
|
it('getRefMap returns empty array initially', async () => {
|
|
const { BrowserManager } = await import('../src/browser-manager');
|
|
const bm = new BrowserManager();
|
|
expect(bm.getRefMap()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── shouldEnableChromiumSandbox ─────────────────────────────────
|
|
//
|
|
// Pinning this is what prevents the "--no-sandbox" yellow infobar from
|
|
// regressing on headed launches. Playwright auto-adds --no-sandbox when
|
|
// chromiumSandbox !== true (playwright-core chromium.js:291-292), so all
|
|
// three launch sites in browser-manager.ts must pass the policy this
|
|
// helper computes.
|
|
|
|
describe('shouldEnableChromiumSandbox', () => {
|
|
const origPlatform = process.platform;
|
|
const origCI = process.env.CI;
|
|
const origContainer = process.env.CONTAINER;
|
|
const origNoSandbox = process.env.GSTACK_CHROMIUM_NO_SANDBOX;
|
|
const origGetuid = process.getuid;
|
|
|
|
beforeEach(() => {
|
|
delete process.env.CI;
|
|
delete process.env.CONTAINER;
|
|
delete process.env.GSTACK_CHROMIUM_NO_SANDBOX;
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'platform', { value: origPlatform });
|
|
if (origCI === undefined) delete process.env.CI; else process.env.CI = origCI;
|
|
if (origContainer === undefined) delete process.env.CONTAINER; else process.env.CONTAINER = origContainer;
|
|
if (origNoSandbox === undefined) delete process.env.GSTACK_CHROMIUM_NO_SANDBOX; else process.env.GSTACK_CHROMIUM_NO_SANDBOX = origNoSandbox;
|
|
process.getuid = origGetuid;
|
|
});
|
|
|
|
function setPlatform(p: NodeJS.Platform) {
|
|
Object.defineProperty(process, 'platform', { value: p });
|
|
}
|
|
|
|
it('darwin, no CI/CONTAINER/root → true', async () => {
|
|
setPlatform('darwin');
|
|
process.getuid = (() => 501) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(true);
|
|
});
|
|
|
|
it('linux, no CI/CONTAINER/root → true', async () => {
|
|
setPlatform('linux');
|
|
process.getuid = (() => 1000) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(true);
|
|
});
|
|
|
|
it('win32 → false (sandbox fails in Bun→Node→Chromium chain)', async () => {
|
|
setPlatform('win32');
|
|
process.getuid = (() => 1000) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(false);
|
|
});
|
|
|
|
it('linux + CI=1 → false', async () => {
|
|
setPlatform('linux');
|
|
process.env.CI = '1';
|
|
process.getuid = (() => 1000) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(false);
|
|
});
|
|
|
|
it('linux + CONTAINER=1 → false', async () => {
|
|
setPlatform('linux');
|
|
process.env.CONTAINER = '1';
|
|
process.getuid = (() => 1000) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(false);
|
|
});
|
|
|
|
it('linux + root (uid 0) → false', async () => {
|
|
setPlatform('linux');
|
|
process.getuid = (() => 0) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(false);
|
|
});
|
|
|
|
// #1562 — Ubuntu/AppArmor opt-in override
|
|
it('linux + GSTACK_CHROMIUM_NO_SANDBOX=1 → false (Ubuntu/AppArmor opt-out)', async () => {
|
|
setPlatform('linux');
|
|
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '1';
|
|
process.getuid = (() => 1000) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(false);
|
|
});
|
|
|
|
it('darwin + GSTACK_CHROMIUM_NO_SANDBOX=1 → false (env override wins on any platform)', async () => {
|
|
setPlatform('darwin');
|
|
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '1';
|
|
process.getuid = (() => 501) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(false);
|
|
});
|
|
|
|
it('GSTACK_CHROMIUM_NO_SANDBOX=0 → does NOT trigger override (must be exactly "1")', async () => {
|
|
setPlatform('linux');
|
|
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '0';
|
|
process.getuid = (() => 1000) as typeof process.getuid;
|
|
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
|
expect(shouldEnableChromiumSandbox()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── resolveDisconnectCause ──────────────────────────────────────
|
|
//
|
|
// Pinning the clean-vs-crash distinction matters because gbd's
|
|
// HealthMonitor consumes our exit code (0 = don't restart, !=0 =
|
|
// restart). A regression here brings back the "Cmd+Q makes the browser
|
|
// keep coming back" UX bug.
|
|
|
|
function makeFakeBrowser(opts: {
|
|
exitCode: number | null;
|
|
signalCode: NodeJS.Signals | null;
|
|
/** ms before emitting 'exit'; default = already exited at construction */
|
|
exitDelay?: number;
|
|
}): { process(): { exitCode: number | null; signalCode: NodeJS.Signals | null; once: EventEmitter['once'] } } {
|
|
const ee = new EventEmitter();
|
|
const state = {
|
|
exitCode: opts.exitDelay != null ? null : opts.exitCode,
|
|
signalCode: opts.exitDelay != null ? null : opts.signalCode,
|
|
once: ee.once.bind(ee),
|
|
};
|
|
if (opts.exitDelay != null) {
|
|
setTimeout(() => {
|
|
state.exitCode = opts.exitCode;
|
|
state.signalCode = opts.signalCode;
|
|
ee.emit('exit', opts.exitCode, opts.signalCode);
|
|
}, opts.exitDelay);
|
|
}
|
|
return { process: () => state };
|
|
}
|
|
|
|
describe('resolveDisconnectCause', () => {
|
|
it('clean: process already exited with code 0', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
const fake = makeFakeBrowser({ exitCode: 0, signalCode: null });
|
|
expect(await resolveDisconnectCause(fake as never)).toBe('clean');
|
|
});
|
|
|
|
it('crash: non-zero exit code', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
const fake = makeFakeBrowser({ exitCode: 1, signalCode: null });
|
|
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
|
});
|
|
|
|
it('crash: SIGSEGV', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
const fake = makeFakeBrowser({ exitCode: null, signalCode: 'SIGSEGV' });
|
|
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
|
});
|
|
|
|
it('crash: SIGKILL', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
const fake = makeFakeBrowser({ exitCode: null, signalCode: 'SIGKILL' });
|
|
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
|
});
|
|
|
|
it('clean: process exits asynchronously with code 0 within timeout', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
const fake = makeFakeBrowser({ exitCode: 0, signalCode: null, exitDelay: 50 });
|
|
expect(await resolveDisconnectCause(fake as never)).toBe('clean');
|
|
});
|
|
|
|
it('crash: process exits asynchronously with non-zero code', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
const fake = makeFakeBrowser({ exitCode: 137, signalCode: null, exitDelay: 50 });
|
|
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
|
});
|
|
|
|
it('crash: null browser returns crash (defensive default)', async () => {
|
|
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
|
expect(await resolveDisconnectCause(null)).toBe('crash');
|
|
});
|
|
});
|
|
|
|
// ─── onDisconnect exit-code propagation (regression test) ──────────
|
|
//
|
|
// The contract: BrowserManager.onDisconnect is called with the resolved
|
|
// exit code (0 for clean Cmd+Q, 2 for crash). server.ts then forwards
|
|
// that code to activeShutdown(), which exits the process.
|
|
//
|
|
// Without this propagation, the headed-mode user-visible Cmd+Q respawn
|
|
// bug returns: server.ts hardcoded `activeShutdown?.(2)` ignores the
|
|
// resolved 0 and gbrowser's gbd HealthMonitor treats the clean quit as
|
|
// a crash, restarting the window.
|
|
describe('BrowserManager.onDisconnect exit-code propagation', () => {
|
|
it('signature accepts an optional exitCode argument', async () => {
|
|
const { BrowserManager } = await import('../src/browser-manager');
|
|
const bm = new BrowserManager();
|
|
const calls: Array<number | undefined> = [];
|
|
bm.onDisconnect = (code?: number) => { calls.push(code); };
|
|
bm.onDisconnect(0);
|
|
bm.onDisconnect(2);
|
|
bm.onDisconnect(undefined);
|
|
expect(calls).toEqual([0, 2, undefined]);
|
|
});
|
|
|
|
it('server.ts callback forwards exitCode when provided, falls back to 2', async () => {
|
|
// Mirror the production wiring in browse/src/server.ts so a refactor
|
|
// that drops the forward (e.g. reverting to `() => activeShutdown?.(2)`)
|
|
// fails CI before the user-visible bug returns.
|
|
const shutdownCalls: number[] = [];
|
|
const activeShutdown = (code: number) => { shutdownCalls.push(code); };
|
|
const onDisconnect = (code?: number) => activeShutdown(code ?? 2);
|
|
onDisconnect(0);
|
|
onDisconnect(2);
|
|
onDisconnect(undefined);
|
|
expect(shutdownCalls).toEqual([0, 2, 2]);
|
|
});
|
|
});
|
|
|
|
// ─── Stealth injected on EVERY launch path (regression tripwire) ───
|
|
//
|
|
// applyStealth must run on launch() (headless), launchHeaded(), AND
|
|
// handoff(). The blend of Layer C with extended mode left handoff() building
|
|
// cmdline args but never calling applyStealth, so a handed-off browser had
|
|
// no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
|
|
// This static check fails CI if any launch path drops the call again.
|
|
describe('stealth injected on every context-creation path', () => {
|
|
it('every context-creation path calls applyStealth (>= 4 call sites)', async () => {
|
|
const { readFileSync } = await import('node:fs');
|
|
const { join } = await import('node:path');
|
|
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
|
|
|
|
// Every path that builds a BrowserContext must apply stealth: launch()
|
|
// (headless), launchHeaded(), handoff(), and recreateContext() (the
|
|
// useragent / viewport --scale rebuild, main + fallback). A path that
|
|
// creates a context without applyStealth silently un-stealths its pages.
|
|
const callSites = src.match(/applyStealth\(/g) || [];
|
|
expect(callSites.length).toBeGreaterThanOrEqual(4);
|
|
|
|
// handoff() body specifically must call applyStealth, before the resume() JSDoc.
|
|
const handoffStart = src.indexOf('async handoff(');
|
|
expect(handoffStart).toBeGreaterThan(0);
|
|
const resumeAnchor = src.indexOf('Resume AI control after user handoff', handoffStart);
|
|
const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000);
|
|
expect(handoffBody).toContain('applyStealth(');
|
|
|
|
// recreateContext() body must call applyStealth too — useragent and
|
|
// viewport --scale route through it and would otherwise drop all stealth.
|
|
const recreateStart = src.indexOf('recreateContext');
|
|
expect(recreateStart).toBeGreaterThan(0);
|
|
// Find the method definition (not just the JSDoc/caller references).
|
|
const recreateDef = src.indexOf('async recreateContext(');
|
|
expect(recreateDef).toBeGreaterThan(0);
|
|
const setUaAnchor = src.indexOf('async setDeviceScaleFactor(', recreateDef);
|
|
const recreateBody = src.slice(recreateDef, setUaAnchor > 0 ? setUaAnchor : recreateDef + 6000);
|
|
expect(recreateBody).toContain('applyStealth(');
|
|
});
|
|
|
|
it('buildGStackLaunchArgs() is spread into all 3 launch sites', async () => {
|
|
// Same silent-drop regression class as applyStealth: a launch path that
|
|
// omits buildGStackLaunchArgs() loses the per-install GPU/UA/hardware
|
|
// cmdline spoof. launch(), launchHeaded(), and handoff() must all call it.
|
|
const { readFileSync } = await import('node:fs');
|
|
const { join } = await import('node:path');
|
|
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
|
|
const callSites = src.match(/buildGStackLaunchArgs\(\)/g) || [];
|
|
expect(callSites.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('STEALTH_LAUNCH_ARGS is spread into all 3 launch sites (no hardcoded literal)', async () => {
|
|
// The --disable-blink-features=AutomationControlled flag must come from the
|
|
// shared constant on launch(), launchHeaded(), AND handoff(). handoff()
|
|
// previously omitted it, leaving the AutomationControlled tell on the
|
|
// handed-off browser. No path may inline the literal instead.
|
|
const { readFileSync } = await import('node:fs');
|
|
const { join } = await import('node:path');
|
|
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
|
|
const spreads = src.match(/\.\.\.STEALTH_LAUNCH_ARGS/g) || [];
|
|
expect(spreads.length).toBeGreaterThanOrEqual(3);
|
|
// The literal must not be reintroduced in a launchArgs array (it belongs in
|
|
// the STEALTH_LAUNCH_ARGS constant in stealth.ts, not inline here).
|
|
expect(src).not.toContain("'--disable-blink-features=AutomationControlled'");
|
|
});
|
|
|
|
it('STEALTH_IGNORE_DEFAULT_ARGS is wired into both persistent-context paths', async () => {
|
|
// launchHeaded() and handoff() both launchPersistentContext and must strip
|
|
// Playwright's automation-tell defaults via the shared constant.
|
|
const { readFileSync } = await import('node:fs');
|
|
const { join } = await import('node:path');
|
|
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
|
|
const sites = src.match(/ignoreDefaultArgs:\s*STEALTH_IGNORE_DEFAULT_ARGS/g) || [];
|
|
expect(sites.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|