diff --git a/.changeset/browser-environment-tracker.md b/.changeset/browser-environment-tracker.md new file mode 100644 index 00000000..eafddc52 --- /dev/null +++ b/.changeset/browser-environment-tracker.md @@ -0,0 +1,8 @@ +--- +'@bugsee/browser': minor +'@bugsee/webview': minor +'@bugsee/protocol': minor +'@bugsee/trackers': minor +--- + +Browser and WebView `events.system` gain `language_changed` (the page language changed) and `timezone_changed` (the time zone or UTC offset changed, so a DST shift counts). The Browser launch (not WebView, which keeps private-mode detection off) also gains `private_mode_detected`, from a private-browsing heuristic that is ON by default (opt-out): turn it off with `captureEnvironmentHints: false` (canonical option `com.bugsee.option.capture.environment-hints`, a non-boolean value is treated as off and reported). It reports only a `yes` answer, never as a certainty (constrained storage can false-positive), and an opted-out launch never forwards it even if another consumer enabled detection on the shared tracker. All of these come from the new per-window singleton `EnvironmentTracker` in `@bugsee/trackers/browser`, which can also observe environment hints (cookies, secure context, cross-origin isolation, service-worker control, automation flag, Do-Not-Track and GPC) and Permissions API states, read without ever prompting; unknown values are omitted. Those two stages are read only when a consumer sets `observeHints` / `queryPermissions`, and the browser launch does not, so it issues no `permissions.query()` call. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 63c0be83..e5ad93d8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -411,6 +411,7 @@ solid), in slices: - **TR1 (DONE, `feat/application-tracker`; design §15):** `PageLifecycleTracker` is now the domain **`ApplicationTracker`** (`packages/trackers/src/browser/application-tracker.ts`). Stages: `pagehide`, `visibility`, `freeze`, `resume`, plus the NEW aggregated `state` (`{state: 'foreground'|'background'}`, Android vocabulary, emitted when a visibility change CHANGES it) and `current()` (read live from the document, else the retained value; undefined when never known — an unreadable document emits no `state`, never a fabricated background). **Singleton per key** via `createTrackerRegistry`: `applicationTrackerFor(env, onError?)` keys by the document, else the window, else one shared key; a window-only env and a window+document env are DISTINCT trackers (pair a document with its own window); `resetApplicationTracker(env)` is exported from `@bugsee/trackers/browser` for internal/test use and is NOT re-exported by `@bugsee/browser`. Browser + webview launch and the events-source fallback obtain it through the registry. **Retention rule (tested):** DOM listeners attach on the first subscriber and detach on the last, the last known `state` is retained and recomputed silently from the document on re-activation, so the first subscriber after a gap is correct at once and the de-dup baseline is never stale. `AppStateProducer` now takes the tracker only (`on('state')` + `current()` for the initial value); trace/event names and wire shapes are unchanged. **Keying rule:** document, else window, else one module key (design §15.1 #5 will be amended from "one per window" to this). **Latest launch wins** through the registry `rebind`: a defined `onError` replaces the shared reporter (undefined keeps it); while IDLE the window/document are merged (a target the request omits is kept), while ACTIVE the binding is kept and a window/document mismatch (only a target the request supplies is compared; the error names which) is reported once. The shared sink is never released on stop: a relaunch with no `onError` keeps the previous launch's (accepted limitation). `resetApplicationTracker` is `@internal` test/teardown use: exported from `@bugsee/trackers/browser` but not re-exported by `@bugsee/browser` (`reset()` removes all subscribers). An unreadable document emits neither `visibility` nor `state` (no fabricated `process_background`/fps pause). **Stays for later:** `online`/`offline` (move to Connectivity, TR3) and `orientationchange` was a stage here until TR2: the UiTracker owns it now and it is removed from this tracker. - **TR3 (DONE, `feat/connectivity-tracker`):** the per-signal `ConnectionTracker` (and its test) is deleted; the domain **`ConnectivityTracker`** (`packages/trackers/src/browser/connectivity-tracker.ts`, `connectivityTrackerFor(env, onError)`, `resetConnectivityTracker`) is a per-window singleton via the registry (keyed on `env.window`, else `env.navigator`, else the env object) with `rebind` (latest `onError` wins; a defined `window`/`navigator` replaces ours, a partial env never drops them; a RUNNING tracker moves its listeners to a changed window/navigator and re-reads). It owns ALL connectivity listeners (window `online`/`offline` + `navigator.connection` `change`) and does NOT depend on the page-lifecycle tracker. Stages: `online` `{online}`, `networkInfo` `{type?, effectiveType?, downlink?, downlinkMax?, rtt?, saveData?}` and the aggregate `state` `{online, networkInfo?}`; activation emits a baseline of every stage. Fields are omitted when absent/invalid; zero/non-finite `downlink`/`downlinkMax`/`rtt` count as "no estimate" (Chromium reports 0 offline and rounds `rtt` to 25 ms, so a 0 rtt is an unmeasured one; the spec reports `downlinkMax` 0 for type `none`) and are omitted, `saveData` only when boolean. Every host read goes through `hostRead` (a hostile getter costs only its field; reported). The emitter's reporter and every DOM listener are guarded, so a throwing subscriber plus a throwing `onError` never escapes a dispatch or `on()`. A refused `addEventListener` is reported once and the listeners that did attach stay attached (no silent death; released on deactivation). A THROWING `navigator.onLine` getter is NOT online: `online` is omitted from the state (no `online` emission, no fabricated true; `ConnectionProducer` writes no row), unless the window `online`/`offline` event that triggered the read supplies the value (the event is evidence; a READABLE `onLine` always wins), and an absent `onLine` property still counts as online. `connectivityTrackerFor` reads `window`/`navigator` once per call (a flaky getter is reported once and the key matches the host it built); `resetConnectivityTracker` is best-effort for such envs (first of window/navigator/env that has a tracker). The `addEventListener` READ itself is guarded too (cross-origin proxies). `state.online` is therefore optional. **Retention:** there is no cached snapshot to lose: state lives in `navigator`, which the tracker keeps and READS LIVE (`current()` and a fresh baseline on each activation); only the listeners are released on the last unsubscribe. `ConnectionProducer` is rewired onto it with UNCHANGED trace name/wire (`connection`: `type`, `effective_type`, `link_downstream_kbps`, `rtt`, `save_data`); `downlinkMax` is added to the open trace data as `link_downstream_max_kbps` (the connection trace is a free-form object with no schema, so this is additive). Consequence: `online`/`offline` are now bound twice on a window while capturing (the lifecycle tracker still serves the events source; the connectivity tracker owns its own), until the Application tracker absorbs the events source. - **TR6 (DONE, `feat/storage-tracker`):** domain **`StorageTracker`** (`packages/trackers/src/browser/storage-tracker.ts`), singleton per window via `createTrackerRegistry` (`storageTrackerFor(env, onError?)`, `resetStorageTracker`; keyed window -> navigator -> env object, `rebind` as PowerTracker). Wraps `navigator.storage.estimate()` + `persisted()`; ONE stage `storageEstimate` `{usageBytes?, quotaBytes?, persisted?}`. This is an ORIGIN quota ESTIMATE (browsers fuzz/pad it), NOT device free space. No change event, so it polls sparsely: on activation, on `visibilitychange` -> visible, then a slow timer (default 5 min via an injectable scheduler; ticks while the page is hidden are skipped). A field that is non-finite/negative/non-number is omitted (never 0); `persisted` only when boolean; an estimate with no usable field emits nothing. A rejected/throwing `estimate()`/`persisted()` is reported via `onError` and costs only its own field (host getters through `hostRead`); launch is never failed. **Retention rule (tested):** the LAST estimate survives the last unsubscribe (`current()` answers at once; a failed poll never erases it); timer and visibility listener do not (released, re-armed on the next subscriber, which also polls afresh). A single epoch counter is bumped by every poll/stop/reset, so a stale in-flight promise (after stop/reset, or superseded by a newer poll) is inert. `rebind` keeps every defined seam (undefined never clobbers) and applies it from the NEXT activation; a tracker that is attached keeps the navigator/document/scheduler it armed (only `onError` changes at once). Visibility-triggered polls are throttled to one per 60 s (`VISIBILITY_MIN_GAP_MS`, injectable `now`); the slow timer is not. Poll failures are reported ONCE per failure streak (reset by a poll without failures, and by `reset()`), not on every tick. A PARTIAL poll (one of `estimate()`/`persisted()` failed) merges: the failed call's retained fields are kept, answered fields replace, and the estimate's age advances only when usage/quota were refreshed. `currentFresh()` returns the retained estimate only while younger than 2x the poll interval (`current()` still returns it regardless). `StorageProducer` (browser `trace-producers.ts`, built after the `sensors:false` gate like the other sensor producers, so a `systemMetricsSampler` replaces it) emits the NEW JS-specific trace `storage_estimate` `{usage, quota}` in integer MB (`bytesToMegabytes`, floored; a missing key is omitted, `persisted` is not a trace) - NOT the Android `disk_mem_*` group. It is held/de-duplicated (`emitIfChanged`) because the poll period (5 min) far exceeds the default 60 s recording window; a non-held value would almost never appear in a report. `StorageProducer.onStart` seeds only from `currentFresh()` (never a snapshot older than 2x the interval). **OWNER DECISION PENDING:** a held polled value cannot be retracted, so after failed polls the last known estimate keeps being re-recorded every `refreshMs`. Alternatives: plain `emit` (the value would rarely land in a 60 s window) or a provider retract/release mechanism (same open question as the TR4 time traces). Usage under 1 MiB floors to 0 MB (an honest floor, not a measurement of zero). Shared by the WebView launch via the browser producers. Launch-level test proves launch goes through `storageTrackerFor` (the window's singleton holds the estimate). +- **TR7 (DONE, `feat/environment-tracker`):** domain **`EnvironmentTracker`** (`packages/trackers/src/browser/environment-tracker.ts`), singleton per window via `createTrackerRegistry` (`environmentTrackerFor(env, onError?)`, `resetEnvironmentTracker`; keyed window -> navigator -> env object; `rebind` merges seams, never drops a defined one; a RUNNING tracker whose window/navigator/document/scheduler/interval changed detaches with the OLD seams, swaps in place, then re-attaches; `detectPrivateMode` turned on while running probes at once, turned off drops the result and any in-flight probe). Five stages: `language` `{language?, languages?, previous}` (`navigator.language(s)` + `languagechange`), `timezone` `{timeZone?, utcOffsetMinutes?, previous}` (no browser event, so re-read on a slow injectable timer, default 60 s with hidden-page ticks skipped, and on visibility -> visible; a DST shift is an offset change), `hints` `{cookiesEnabled?, secureContext?, crossOriginIsolated?, serviceWorkerControlled?, automation?, doNotTrack?, globalPrivacyControl?}`, `permissions` `{camera?, microphone?, geolocation?, notifications?, 'idle-detection'?: 'granted'|'denied'|'prompt'}` and `privateMode` `{private: 'yes'|'no'|'unknown', method?}`. `language`/`timezone` are CHANGE-ONLY (the first reading is a silent baseline held in `current()`, because they back `*_changed` events); `hints`/`permissions` are SNAPSHOT stages (first reading + every change); `privateMode` emits when its answer first arrives and when it changes. **Never prompts:** only `navigator.permissions.query()` is called (never requestPermission/getUserMedia/...), only the state STRING is kept, a PermissionStatus `change` refreshes it; a name the browser rejects with TypeError is omitted silently (Firefox does not know `camera`), other rejections are reported once per failure streak. **Private mode** is a HEURISTIC, never a certainty: one method `storage-quota` (`estimate().quota` under 120 MiB -> `yes`, at/over 1 GiB -> `no`, between or unreadable -> `unknown`; Chromium gives incognito a small fixed quota, Safari fuzzes). Omit-never-fabricate everywhere (empty/non-string language, NaN offset, non-boolean hints, unusable permission states are omitted; unknown DNT values omitted). Every host read goes through `hostRead` (a hostile getter costs only its field and is reported), a throwing `onError` never escapes. **Retention rule (tested):** the last language/time zone/hints/permissions/private-mode readings survive the last unsubscribe (and the change-only stages compare a restart against them); DOM listeners (languagechange, visibilitychange, PermissionStatus change), the timer and async work in flight do not (epoch tokens make a stale query/probe inert). **Events (`events.system`, `createBrowserSystemEventsSource`; the WebView launch shares the same source):** `language_changed` `{language?, languages?, previousLanguage?}`, `timezone_changed` `{timezone?, utcOffsetMinutes?, previousTimezone?, previousUtcOffsetMinutes?}`, `private_mode_detected` `{method?}` (ONLY for a `yes`; `no`/`unknown` never produce an event). Event names are free-form in `SystemEvent` (no registry/schema list exists to extend). **Launch option `captureEnvironmentHints`** (canonical `com.bugsee.option.capture.environment-hints`, browser launch only, default TRUE (opt-out), fail-closed boolean: anything but a boolean is OFF and reported) -> tracker `detectPrivateMode`; the WebView launch never enables it. **OWNER DECISION (2026-10-20, binding): private/incognito detection is OPT-OUT, ON by default** (the launch option keeps its name `captureEnvironmentHints`, default TRUE, `false` opts out, an invalid value is OFF and reported); still a tri-state heuristic (`yes`/`no`/`unknown`, method `storage-quota`), never a certainty. The decision covers private mode only, not other sensitive signals. The WebView launch still passes `detectPrivateMode: false` (no launch option there; a WebView's storage quota is not a private-window signal). **Not done / scope notes:** no `displayMode` stage (the UiTracker owns display-mode; `display_mode_changed` will be derived from it later); `hints` and `permissions` are tracker-level only, they have no wire consumer (the environment envelope schema is closed and has no field for them, so language/timezone were NOT added to `environment` either: `platform.locale`/`utc_offset` are still read once at launch by `buildBrowserEnvironment`, and the new events carry the later changes); **hints and permissions are NOT read by default (PR #84 review):** tracker env flags `observeHints` / `queryPermissions` (default false; a change while running re-attaches, turning one off drops its reading and, for permissions, the status listeners and in-flight queries); the browser system-events source passes both `false` explicitly (never inherited from the window's shared tracker), so a launch issues no `permissions.query()` and reads no hints until a real consumer sets them. Launch tests prove launch goes through `environmentTrackerFor` (the window's singleton holds the readings; mutating the source to `new EnvironmentTracker` fails them) and that private mode is ON by default and off with `captureEnvironmentHints: false`; the WebView launch has the same singleton test. **Review round 2:** persistent host-read failures of language/timezone/hints/visibility are reported once per failure streak (a clean run or `reset()` ends it); emitted stage payloads are copies, so a subscriber cannot mutate retained readings; the WebView launch now passes `document` + `scheduler` to the environment source; `resetEnvironmentTracker` is NOT re-exported from `@bugsee/browser` (reach teardown via `environmentTrackerFor(env).reset()`). **Round 3:** a host read that THROWS keeps that field's retained value (language, languages, time zone id, offset, each hint), so a transient failure never yields a fake `language_changed`/`timezone_changed`; `reset()` now drops all subscribers (`removeAllListeners`, as ApplicationTracker does) so the tracker re-activates for the next subscriber; a throwing `PermissionStatus.state` getter is reported once per streak. **Round 4:** per-permission-name streaks; a throwing state read, or a name whose re-query is rejected with a non-TypeError, keeps that name's retained state (a TypeError still omits it). **Round 5:** the browser system-events source subscribes to the tracker with ONE `onAny` (the activation read can emit a change made while the SDK was stopped, so per-stage subscriptions lost the later stages: a zone change alongside a language change vanished); the WebView launch passes `detectPrivateMode: false` explicitly (never inherits ON from an earlier launch on the window); stage payloads copy `previous` as well (a kept field shares its array). **PR review:** `privateMode` is no longer re-emitted on every activation: it emits when the answer first arrives and when it changes, a retained yes/no is not re-probed on a restart or a seam-moving rebind (an `unknown` is retried), and turning detection off then on re-detects; the browser events source always passes `detectPrivateMode` explicitly (omitted = false, never inherited); an answered-but-unusable permission state clears the stale entry (a failed/throwing read still retains it); private-probe getter failures (storage/estimate/quota) are reported once per streak; it stays a heuristic (constrained storage can false-positive). **Per-session delivery:** the tracker outlives sessions and never re-probes a retained yes/no, so the browser events source reads `current().privateMode` right after subscribing and re-emits `private_mode_detected` once for a retained `yes` (never for `no`/`unknown`, and never when this source opted out; the sink-side `detectPrivateMode` guard also covers the retained read). - **TR5 (implemented on `feat/process-tracker`, PR #83; multi-agent review rounds still in progress, one owner decision open — see "Known limit" below):** domain **`ProcessTracker`** (`packages/trackers/src/browser/process-tracker.ts`), singleton per window via `createTrackerRegistry` (`processTrackerFor(env, onError?)`, `resetProcessTracker`; keyed window -> performance -> env object, `rebind` merge-not-drop: a seam change on a RUNNING tracker first detaches every armed stage with the seams it was armed with, then swaps and restarts). It ABSORBS and replaces the per-signal `MemoryTracker` and `MainThreadLagTracker` (files + tests deleted; `MemoryProducer`/`MainThreadLagProducer` now subscribe to it; trace names and wire shapes unchanged). Five stages, each armed ONLY while it has listeners (via the `listenerCount(name)` / `onListenersChanged()` hooks `MultiKeyEmitterBase` already has on `main` from TR2; this PR does not touch the emitter; `start()`/`stop()` do nothing here): `heap` `{used, limit}` (`performance.memory`, polled; a figure that is not a finite non-negative number omits the reading), `mainThreadLag` `{lagMs}` (TIMER DRIFT, algorithm and visible-only gating unchanged: block >= 1 s always shows, shorter that crosses no due tick reads 0, reported once, fresh baseline each visible start), `idle` `{idleMs}` (NEW, ONE `requestIdleCallback` probe per sampling tick, never a loop and at most one in flight, visible pages only, `timeRemaining()` clamped to <= 50 ms; a tick whose callback never runs yields NO sample, never 0; omitted without rIC, e.g. Safari), `userIdle` `{userState: 'active'|'idle', screenState?: 'locked'|'unlocked'}` (NEW, Idle Detection API), `uptime` `{uptimeMs}` (`performance.now()`, omitted without a clock; no trace consumes it yet). **Idle Detection permission rule (binding): the SDK NEVER prompts.** `IdleDetector.requestPermission()` is never called; the detector starts ONLY when `navigator.permissions.query({name:'idle-detection'})` ALREADY reports `granted` (an engine that does not know the name rejects = stage inert, silent). The permission is re-queried on every activation and watched through its `change` event (granted later -> starts; revoked -> aborts and drops the tracker's retained state). Threshold floored at the spec's 60 s; stop aborts via `AbortController`; every async continuation is guarded by its run. **Retention rule (tested):** the last `userIdle` state survives the last unsubscribe (`currentUserIdle()`; `currentUserIdleFresh()`, which `UserIdleProducer` seeds from, answers only once the ARMED stage's detector has PUBLISHED in this run (permission re-confirmed and its `start()` settled; no age bound, a live detector reports changes only) and is undefined while the async permission re-query or `start()` is pending, or after a refusal/revoke; with NO armed stage it serves the state only while younger than 2 detector thresholds, via a new injectable `now` seam defaulting to `Date.now`, mirroring TR6's `currentFresh()`. A revoke while no stage is armed is therefore only noticed by the next activation's re-query (the plain `currentUserIdle()` read keeps the snapshot until then), but it can never seed a held trace); heap is read live; idle/lag/uptime keep nothing (a stale baseline would be a lie); timers, lifecycle subscription, pending probe and detector are released. New JS-specific traces (`BrowserTraceEnv` gains `requestIdleCallback`/`cancelIdleCallback`/`IdleDetector`/`AbortController`, bound/taken from the injected window by `browserTraceEnvFrom`): `main_thread_idle_ms` (ms, polled/not held; built under the same lifecycle gate as lag/fps) and `user_idle_state` (a STRING like `app_state`: `active` | `idle` | `locked`, where a locked screen wins over `userState`; held, de-duplicated; no trace at all unless the permission was already granted). **`user_idle_state` is OPT-IN (review P2; the maintainer's decision, PENDING OWNER CONFIRMATION): the host page's `idle-detection` grant shows only that the page was given the permission, not consent to record and upload idle/locked state. So `createBrowserSystemTraceProducers` builds `UserIdleProducer` only for `BrowserTraceEnv.captureUserIdle === true`, fed by the new browser + WebView launch option `captureUserIdle` (default false, strict boolean: any other value stays off and is reported; local key `com.bugsee.option.browser.capture-user-idle`, exported as `CAPTURE_USER_IDLE_OPTION_KEY`). Even when on, the tracker starts a detector only if the permission ALREADY reports `granted` (the SDK still never prompts). Without the opt-in the permission is not even queried. This applies ONLY to `user_idle_state`; private-mode detection is opt-out by a separate owner decision.** The viewer has no definition for either name yet (generic value block, design §13.3). **Known limit (OWNER DECISION PENDING — no retract mechanism invented):** `user_idle_state` is a HELD trace and the tracker emits nothing when the permission is revoked, so a producer that already published a value keeps having it re-recorded every `refreshMs` until the producer stops (the revoke only drops the tracker's retained copy and so stops NEW seeds; the seed is gated as above). Resolving it needs a decision on a retract/clear signal for held traces (same family as TR6's held `storage_estimate`). Other hardening from review: a hostile `performance.now` getter is reported exactly once in total (the factory reads silently, the tracker reports; only a SUCCESSFUL clock read is memoised per env, a failed one is retried on the next activation); `ProcessTracker.reset()` now drops every listener (like `ApplicationTracker`), so a later listener change cannot re-arm a stage for a gone subscriber; `browserTraceEnvFrom` now hands back stable bound closures per (window, function), so a no-op relaunch no longer restarts armed stages; a non-finite lag baseline omits that tick's `main_thread_lag_ms` instead of sending NaN; the gated teardown always closes the run. Launch-level test proves browser launch goes through `processTrackerFor` (the window's singleton can read the heap through launch's seams); the WebView launch shares the producers. `sensors:false` (a `systemMetricsSampler`) still builds only the battery producer. - **ST0′ (DONE, `feat/trace-producers`):** `traces.system` is now producers over shared trackers. `@bugsee/capture` has `TraceProducer`/`TraceProducerBase`; the traces provider is a producer container diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6ead1e4d..c42f8c6a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -22,6 +22,10 @@ export { type ApplicationState, ApplicationTracker, applicationTrackerFor, + type EnvironmentEnv, + type EnvironmentEvents, + EnvironmentTracker, + environmentTrackerFor, } from '@bugsee/trackers/browser'; export { COMPONENT_ATTRIBUTE, componentNameFromElement } from './component-name'; export { diff --git a/packages/browser/src/launch-options.invalid.test.ts b/packages/browser/src/launch-options.invalid.test.ts index be4a7693..5a5ad968 100644 --- a/packages/browser/src/launch-options.invalid.test.ts +++ b/packages/browser/src/launch-options.invalid.test.ts @@ -70,6 +70,8 @@ const INVALID: unknown[] = [null, 0, '', 'false', 'true', 1]; const failClosed: Array<[string, string]> = [ ['captureInteractions', BugseeOption.CaptureInteractions], ['captureViewHierarchy', BugseeOption.CaptureViewHierarchy], + // private-mode detection: opt-out (owner decision, 2026-10-20); an invalid value is OFF, never consent + ['captureEnvironmentHints', BugseeOption.CaptureEnvironmentHints], ]; const defaultOff: Array<[string, string]> = [ ['persistUserIdentifier', BugseeOption.PersistUserIdentifier], diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 362b8ba8..36bb5b84 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -45,7 +45,9 @@ import { import { applicationTrackerFor, connectivityTrackerFor, + environmentTrackerFor, processTrackerFor, + resetEnvironmentTracker, resetProcessTracker, resetStorageTracker, resetUiTracker, @@ -1514,6 +1516,150 @@ describe('launch', () => { }); }); + it('options.scheduler drives the environment tracker slow timer: a time-zone shift becomes a timezone_changed event', async () => { + stubSensors(); + vi.stubGlobal('navigator', { language: 'en-US' }); + const w = fakeWindow(); + const ticks: Array<{ cb: () => void; ms: number }> = []; + try { + const store = memStore(); + await launchTracked( + 'tok', + noSampler({ + window: w.win, + captureStore: store, + scheduler: { + setInterval: (cb, ms) => { + ticks.push({ cb, ms }); + return `h${ticks.length}`; + }, + clearInterval: () => {}, + }, + }), + ); + await new Promise((r) => setTimeout(r, 0)); + const slow = ticks.filter((t) => t.ms === 60_000); // the tracker's default cadence + expect(slow).toHaveLength(1); + const real = new Date().getTimezoneOffset(); + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(real - 120); + slow[0]?.cb(); + const events = ((await drain(store, 'events.system')) ?? []).map( + (e) => e.data as { name: string; params?: { utcOffsetMinutes?: number } }, + ); + expect( + events + .filter((e) => e.name === 'timezone_changed') + .map((e) => e.params?.utcOffsetMinutes), + ).toEqual([-(real - 120) + 0]); + } finally { + vi.restoreAllMocks(); + resetEnvironmentTracker({ window: w.win }); + } + }); + + it('the launch document reaches the environment tracker: its visibility gates the slow tick and a return to visible re-reads', async () => { + stubSensors(); + vi.stubGlobal('navigator', { language: 'en-US' }); + const w = fakeWindow(); + const d = fakeWindow(); + const vis = { state: 'visible' }; + const document = Object.defineProperty({ ...d.win, body: {} }, 'visibilityState', { + get: () => vis.state, + }) as unknown as Document; + const ticks: Array<{ cb: () => void; ms: number }> = []; + try { + await launchTracked( + 'tok', + noSampler({ + window: w.win, + document, + captureStore: memStore(), + scheduler: { + setInterval: (cb, ms) => { + ticks.push({ cb, ms }); + return `h${ticks.length}`; + }, + clearInterval: () => {}, + }, + }), + ); + const tracker = environmentTrackerFor({ window: w.win }); + const slow = ticks.filter((t) => t.ms === 60_000); + expect(slow).toHaveLength(1); + const real = new Date().getTimezoneOffset(); + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0); + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(real - 45); + vis.state = 'hidden'; + slow[0]?.cb(); + d.emit('visibilitychange', {}); + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0); // hidden: nothing re-read + vis.state = 'visible'; + d.emit('visibilitychange', {}); + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-(real - 45) + 0); + } finally { + vi.restoreAllMocks(); + environmentTrackerFor({ window: w.win }).reset(); + } + }); + + it('launch goes through the per-window EnvironmentTracker singleton (language/timezone and private mode on by default, no hints/permissions)', async () => { + stubSensors(); + const estimate = vi.fn(() => Promise.resolve({ usage: 1, quota: 10 * 1024 * 1024 })); + const query = vi.fn(() => Promise.resolve({ state: 'granted' })); + vi.stubGlobal('navigator', { + language: 'en-US', + languages: ['en-US', 'en'], + cookieEnabled: true, + permissions: { query }, + storage: { estimate }, + }); + const w = fakeWindow(); + try { + const store = memStore(); + await launchTracked('tok', noSampler({ window: w.win, captureStore: store })); + await new Promise((r) => setTimeout(r, 0)); + const snapshot = environmentTrackerFor({ window: w.win }).current(); + expect(snapshot.language).toEqual({ language: 'en-US', languages: ['en-US', 'en'] }); + expect(snapshot.timeZone?.utcOffsetMinutes).toBeTypeOf('number'); + expect(snapshot.privateMode).toEqual({ private: 'yes', method: 'storage-quota' }); // on by default + expect(snapshot.hints).toBeUndefined(); // nothing consumes hints / permissions: never read + expect(snapshot.permissions).toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + expect(w.count('languagechange')).toBe(1); + const events = ((await drain(store, 'events.system')) ?? []).map( + (e) => e.data as { name: string; params?: unknown }, + ); + expect(events.find((e) => e.name === 'private_mode_detected')?.params).toEqual({ + method: 'storage-quota', + }); + } finally { + resetEnvironmentTracker({ window: w.win }); + } + }); + + it('captureEnvironmentHints: false opts out of the private-mode heuristic (no probe, no event)', async () => { + stubSensors(); + const estimate = vi.fn(() => Promise.resolve({ usage: 1, quota: 10 })); + vi.stubGlobal('navigator', { language: 'en-US', storage: { estimate } }); + const w = fakeWindow(); + try { + const store = memStore(); + await launchTracked( + 'tok', + noSampler({ window: w.win, captureStore: store, captureEnvironmentHints: false }), + ); + await new Promise((r) => setTimeout(r, 0)); + // (the storage trace tracker calls estimate() too, so the proof is the missing answer and event) + expect(environmentTrackerFor({ window: w.win }).current().privateMode).toBeUndefined(); + const names = ((await drain(store, 'events.system')) ?? []).map( + (e) => (e.data as { name: string }).name, + ); + expect(names).not.toContain('private_mode_detected'); + } finally { + resetEnvironmentTracker({ window: w.win }); + } + }); + it('a rejected storage.estimate() reaches options.onError and never fails launch', async () => { stubSensors(); const boom = new Error('estimate refused'); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 75473da2..307473f4 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -184,6 +184,14 @@ const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ // Safe side is ON: an invalid value (`'true'`, `1`, `null`) means DARK, never "not dark". ...FAIL_ON_BOOLEAN_OPTION, }, + // Environment hints that read as sensitive (private-browsing detection): the browser launch default is true, so + // `false` disables. An invalid value is OFF and reported (never read as consent to detect). + { + friendly: 'captureEnvironmentHints', + key: BugseeOption.CaptureEnvironmentHints, + default: true, + ...FAIL_CLOSED_BOOLEAN_OPTION, + }, // Session replay: on by default, `false` opts out, an object customises it. It COLLECTS data, so an invalid // scalar (`0`, `null`, `''`, `'false'`, ...) is OFF and reported; it must never throw. // Masking is fail-closed regardless. @@ -371,6 +379,15 @@ export interface BugseeLaunchOptions { * the clear applies to reports created afterwards. */ persistUserIdentifier?: boolean; + /** + * Observe environment hints that read as sensitive. Today: the private-browsing HEURISTIC (a storage-quota probe) + * behind a `private_mode_detected` system event, emitted only for a `yes` answer and never as a certainty + * (constrained storage can false-positive). + * Default in the browser launch: `true` (the protocol layer defines the option key and its boolean type, not a + * default); pass `false` to turn it off. A non-boolean value is treated as off and reported. Language and time-zone changes (`language_changed`, `timezone_changed`) are always observed. Hint and + * permission states are not read at all. + */ + captureEnvironmentHints?: boolean; /** * Record the `user_idle_state` system trace (`active` / `idle` / `locked`, from the Idle Detection API). Default * FALSE: it is behavioural data, and a page's own `idle-detection` grant is not consent to upload it. Even when @@ -931,6 +948,11 @@ async function buildLaunch( createBrowserSystemEventsSource({ window: win, lifecycle, + // The window's environment singleton (EnvironmentTracker): language/timezone events, and the private-mode + // heuristic (on by default, `captureEnvironmentHints: false` opts out). + ...(domDocument !== undefined ? { document: domDocument } : {}), + detectPrivateMode: resolved.options.get(BugseeOption.CaptureEnvironmentHints, true), + ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), // from the INJECTED window (bound to it); never the host global ...(matchMedia !== undefined ? { matchMedia } : {}), ...(options.onError !== undefined ? { onError: options.onError } : {}), diff --git a/packages/browser/src/system-events.test.ts b/packages/browser/src/system-events.test.ts index e9dbe7c7..b9a7116b 100644 --- a/packages/browser/src/system-events.test.ts +++ b/packages/browser/src/system-events.test.ts @@ -1,5 +1,12 @@ import type { SystemEvent } from '@bugsee/capture'; -import { ApplicationTracker, resetApplicationTracker } from '@bugsee/trackers/browser'; +import { createMultiKeyEmitter } from '@bugsee/trackers'; +import { + ApplicationTracker, + type EnvironmentEvents, + environmentTrackerFor, + resetApplicationTracker, + resetEnvironmentTracker, +} from '@bugsee/trackers/browser'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { type BrowserSystemEventsEnv, createBrowserSystemEventsSource } from './system-events'; @@ -27,6 +34,10 @@ function target() { }; } +// The environment tracker has its own listeners (languagechange, visibilitychange); tests of the LIFECYCLE wiring +// inject an inert one so they count only the lifecycle's. The environment events are tested separately below. +const inertEnvironment = { onAny: () => () => {} }; + function fakeEnv() { const win = target(); const doc = target(); @@ -39,7 +50,12 @@ function fakeEnv() { return state.visibilityState; }, }; - const env: BrowserSystemEventsEnv = { window: win, document, screen: { orientation } }; + const env: BrowserSystemEventsEnv = { + window: win, + document, + screen: { orientation }, + environment: inertEnvironment as never, + }; return { env, win, doc, state, orientation }; } @@ -285,10 +301,11 @@ describe('createBrowserSystemEventsSource', () => { 'screen blocked', 'window blocked', ]); - // a supplied lifecycle owns the DOCUMENT (not read again); the window is still read for the UI tracker + // a supplied lifecycle AND environment own the DOCUMENT (not read again); the window is still read for the UI tracker const none: unknown[] = []; createBrowserSystemEventsSource({ lifecycle: { on: () => () => {} }, + environment: inertEnvironment as never, onError: (e) => none.push(e), }); expect(none.map((e) => (e as Error).message)).toEqual(['window blocked', 'screen blocked']); @@ -326,8 +343,12 @@ describe('createBrowserSystemEventsSource', () => { const source = createBrowserSystemEventsSource(); const off = source.onAny(() => {}); expect(win.count('pagehide')).toBe(1); - expect(doc.count('visibilitychange')).toBe(1); + // the lifecycle tracker's listener AND the environment tracker's (time-zone re-read on visible) + expect(doc.count('visibilitychange')).toBe(2); + expect(win.count('languagechange')).toBe(1); off(); + expect(doc.count('visibilitychange')).toBe(0); + expect(win.count('languagechange')).toBe(0); }); it('without an injected window the UI tracker gets the host window (and its screen), like the lifecycle tracker', () => { @@ -446,4 +467,358 @@ describe('createBrowserSystemEventsSource', () => { ]); second(); }); + + describe('environment events', () => { + const lifecycleStub = { on: () => () => {} }; + + it('maps the language / timezone stages to language_changed / timezone_changed, omitting unknown fields', () => { + const environment = createMultiKeyEmitter(); + const { events } = activate({ lifecycle: lifecycleStub as never, environment }); + environment.emit('language', { + language: 'uk-UA', + languages: ['uk-UA', 'en'], + previous: { language: 'en-US' }, + }); + environment.emit('language', { languages: ['fr'], previous: {} }); + environment.emit('timezone', { + timeZone: 'Asia/Tokyo', + utcOffsetMinutes: 540, + previous: { timeZone: 'Europe/Kyiv', utcOffsetMinutes: 120 }, + }); + environment.emit('timezone', { utcOffsetMinutes: 60, previous: {} }); + expect(events.slice(1)).toStrictEqual([ + { + name: 'language_changed', + params: { language: 'uk-UA', languages: ['uk-UA', 'en'], previousLanguage: 'en-US' }, + }, + { name: 'language_changed', params: { languages: ['fr'] } }, + { + name: 'timezone_changed', + params: { + timezone: 'Asia/Tokyo', + utcOffsetMinutes: 540, + previousTimezone: 'Europe/Kyiv', + previousUtcOffsetMinutes: 120, + }, + }, + { name: 'timezone_changed', params: { utcOffsetMinutes: 60 } }, + ]); + }); + + it('private_mode_detected only for a yes answer (never for no / unknown), with the method', () => { + const environment = createMultiKeyEmitter(); + const { events } = activate({ lifecycle: lifecycleStub as never, environment }); + environment.emit('privateMode', { private: 'no', method: 'storage-quota' }); + environment.emit('privateMode', { private: 'unknown' }); + expect(events.slice(1)).toEqual([]); + environment.emit('privateMode', { private: 'yes', method: 'storage-quota' }); + environment.emit('privateMode', { private: 'yes' }); + expect(events.slice(1)).toEqual([ + { name: 'private_mode_detected', params: { method: 'storage-quota' } }, + { name: 'private_mode_detected', params: {} }, + ]); + }); + + it('an opted-out source (detectPrivateMode: false) never forwards a yes, even if another consumer enabled detection on the shared tracker', async () => { + const injected = createMultiKeyEmitter(); + const out = activate({ + lifecycle: lifecycleStub as never, + environment: injected, + detectPrivateMode: false, + }); + injected.emit('privateMode', { private: 'yes', method: 'storage-quota' }); + expect(out.events.map((e) => e.name)).toEqual(['process_started']); + // and over the REAL window singleton, with a second consumer turning detection on + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + Object.assign(win, { + navigator: { + language: 'en-US', + storage: { estimate: () => Promise.resolve({ quota: 10 }) }, + }, + }); + try { + const off = activate({ + ...rest, + lifecycle: lifecycleStub as never, + detectPrivateMode: false, + }); + const other = environmentTrackerFor({ window: win, detectPrivateMode: true }); + const unsub = other.onAny(() => {}); + await new Promise((r) => setTimeout(r, 0)); + expect(other.current().privateMode?.private).toBe('yes'); // the tracker did detect it + expect(off.events.map((e) => e.name)).not.toContain('private_mode_detected'); + unsub(); + off.off(); + } finally { + resetEnvironmentTracker({ window: win }); + } + out.off(); + }); + + it('a retained yes is re-delivered once to each new session (no re-probe); no / unknown stay silent; an opt-out stays silent', async () => { + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + const estimate = vi.fn(() => Promise.resolve({ quota: 10 })); + Object.assign(win, { navigator: { language: 'en-US', storage: { estimate } } }); + try { + const first = activate({ ...rest, lifecycle: lifecycleStub as never }); + await new Promise((r) => setTimeout(r, 0)); + expect(first.events.filter((e) => e.name === 'private_mode_detected')).toHaveLength(1); + first.off(); + estimate.mockClear(); + // a later session on the same window (the tracker outlived the first one) + const second = activate({ ...rest, lifecycle: lifecycleStub as never }); + await new Promise((r) => setTimeout(r, 0)); + expect(estimate).not.toHaveBeenCalled(); // not re-probed + expect(second.events.filter((e) => e.name === 'private_mode_detected')).toEqual([ + { name: 'private_mode_detected', params: { method: 'storage-quota' } }, + ]); + second.off(); + // an opted-out session never gets it from the retained answer + const optedOut = activate({ + ...rest, + lifecycle: lifecycleStub as never, + detectPrivateMode: false, + }); + await new Promise((r) => setTimeout(r, 0)); + expect(optedOut.events.map((e) => e.name)).not.toContain('private_mode_detected'); + optedOut.off(); + } finally { + resetEnvironmentTracker({ window: win }); + } + }); + + it('a retained no is not delivered to a later session', async () => { + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + Object.assign(win, { + navigator: { + language: 'en-US', + storage: { estimate: () => Promise.resolve({ quota: 2 * 1024 * 1024 * 1024 }) }, + }, + }); + try { + const first = activate({ ...rest, lifecycle: lifecycleStub as never }); + await new Promise((r) => setTimeout(r, 0)); + expect(environmentTrackerFor({ window: win }).current().privateMode?.private).toBe('no'); + first.off(); + const second = activate({ ...rest, lifecycle: lifecycleStub as never }); + await new Promise((r) => setTimeout(r, 0)); + expect(second.events.map((e) => e.name)).not.toContain('private_mode_detected'); + second.off(); + } finally { + resetEnvironmentTracker({ window: win }); + } + }); + + it('a retained yes read from the tracker is emitted on subscribe, once even if the activation also emits it; later answers still forward', () => { + const injected = createMultiKeyEmitter(); + const withCurrent = Object.assign(injected, { + current: () => ({ privateMode: { private: 'yes' as const, method: 'storage-quota' } }), + }); + const out = activate({ lifecycle: lifecycleStub as never, environment: withCurrent }); + expect(out.events.filter((e) => e.name === 'private_mode_detected')).toHaveLength(1); + injected.emit('privateMode', { private: 'yes', method: 'storage-quota' }); + expect(out.events.filter((e) => e.name === 'private_mode_detected')).toHaveLength(2); // a new answer is still forwarded + out.off(); + // the activation itself emits the same yes while subscribing: still one event + const loud = createMultiKeyEmitter(); + const onAny = loud.onAny.bind(loud); + const sub = Object.assign(loud, { + current: () => ({ privateMode: { private: 'yes' as const } }), + onAny: ((listener: Parameters[0]) => { + const off = onAny(listener); + loud.emit('privateMode', { private: 'yes' }); + return off; + }) as typeof onAny, + }); + const two = activate({ lifecycle: lifecycleStub as never, environment: sub }); + expect(two.events.filter((e) => e.name === 'private_mode_detected')).toHaveLength(1); + two.off(); + // an opted-out source never delivers a retained yes + const quiet = createMultiKeyEmitter(); + const held = Object.assign(quiet, { + current: () => ({ privateMode: { private: 'yes' as const } }), + }); + const optedOut = activate({ + lifecycle: lifecycleStub as never, + environment: held, + detectPrivateMode: false, + }); + expect(optedOut.events.map((e) => e.name)).not.toContain('private_mode_detected'); + optedOut.off(); + }); + + it('a language AND time-zone change while the source was stopped are BOTH reported on restart', () => { + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + const nav = { language: 'en-US' }; + Object.assign(win, { navigator: nav }); + const real = new Date().getTimezoneOffset(); + try { + const first = activate({ ...rest, lifecycle: lifecycleStub as never }); + first.off(); // the tracker retains its readings + nav.language = 'de-DE'; + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(real - 60); + const second = activate({ ...rest, lifecycle: lifecycleStub as never }); + expect(second.events.map((e) => e.name)).toEqual([ + 'process_started', + 'language_changed', + 'timezone_changed', + ]); + second.off(); + } finally { + vi.restoreAllMocks(); + resetEnvironmentTracker({ window: win }); + } + }); + + it('an omitted detectPrivateMode means ON (opt-out), and an explicit false turns it OFF even after an earlier source turned it on for the window', async () => { + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + const estimate = vi.fn(() => Promise.resolve({ quota: 10 })); + Object.assign(win, { navigator: { language: 'en-US', storage: { estimate } } }); + try { + const on = activate({ + ...rest, + lifecycle: lifecycleStub as never, + detectPrivateMode: true, + }); + await new Promise((r) => setTimeout(r, 0)); + expect(estimate).toHaveBeenCalledTimes(1); + expect(on.events.at(-1)?.name).toBe('private_mode_detected'); + on.off(); + resetEnvironmentTracker({ window: win }); // forget the answer; keep nothing but the seam question + const first = activate({ + ...rest, + lifecycle: lifecycleStub as never, + detectPrivateMode: true, + }); + first.off(); + estimate.mockClear(); + const off = activate({ + ...rest, + lifecycle: lifecycleStub as never, + detectPrivateMode: false, + }); + await new Promise((r) => setTimeout(r, 0)); + expect(estimate).not.toHaveBeenCalled(); + expect(off.events.map((e) => e.name)).not.toContain('private_mode_detected'); + off.off(); + resetEnvironmentTracker({ window: win }); + const dflt = activate({ ...rest, lifecycle: lifecycleStub as never }); // option omitted + await new Promise((r) => setTimeout(r, 0)); + expect(estimate).toHaveBeenCalledTimes(1); + expect(dflt.events.at(-1)?.name).toBe('private_mode_detected'); + dflt.off(); + } finally { + resetEnvironmentTracker({ window: win }); + } + }); + + it('never queries permissions nor reads hints: nothing consumes them', async () => { + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + const query = vi.fn(() => Promise.resolve({ state: 'granted' })); + Object.assign(win, { + navigator: { language: 'en-US', cookieEnabled: true, permissions: { query } }, + }); + try { + const a = activate({ ...rest, lifecycle: lifecycleStub as never }); + await new Promise((r) => setTimeout(r, 0)); + expect(query).not.toHaveBeenCalled(); + expect(environmentTrackerFor({ window: win }).current().hints).toBeUndefined(); + expect(environmentTrackerFor({ window: win }).current().permissions).toBeUndefined(); + a.off(); + // an earlier user of the window's tracker that asked for them does not leak into this source + const tracker = environmentTrackerFor({ + window: win, + observeHints: true, + queryPermissions: true, + }); + const off = tracker.onAny(() => {}); + await new Promise((r) => setTimeout(r, 0)); + expect(query).toHaveBeenCalled(); + off(); + query.mockClear(); + const b = activate({ ...rest, lifecycle: lifecycleStub as never }); + await new Promise((r) => setTimeout(r, 0)); + expect(query).not.toHaveBeenCalled(); + expect(environmentTrackerFor({ window: win }).current().hints).toBeUndefined(); + b.off(); + } finally { + resetEnvironmentTracker({ window: win }); + } + }); + + it('stops listening to the environment tracker on deactivate', () => { + const environment = createMultiKeyEmitter(); + const { events, off } = activate({ lifecycle: lifecycleStub as never, environment }); + off(); + environment.emit('language', { language: 'x', previous: {} }); + expect(events).toEqual([{ name: 'process_started' }]); + }); + + it('over the window singleton: a real languagechange and a time-zone tick become events; private mode is opt-out', async () => { + const { env, win } = fakeEnv(); + const { environment: _inert, ...rest } = env; + const nav = { + language: 'en-US', + storage: { estimate: vi.fn(() => Promise.resolve({ quota: 10 })) }, + }; + Object.assign(win, { navigator: nav }); + const noTick = () => {}; + let tick: () => void = noTick; + const scheduler = { + setInterval: (cb: () => void) => { + tick = cb; + return 1; + }, + clearInterval: () => {}, + }; + try { + // opted out: language/timezone on, private mode off + const a = activate({ + ...rest, + lifecycle: lifecycleStub as never, + scheduler, + detectPrivateMode: false, + }); + nav.language = 'de-DE'; + win.emit('languagechange', {}); + expect(a.events.map((e) => e.name)).toEqual(['process_started', 'language_changed']); + expect(nav.storage.estimate).not.toHaveBeenCalled(); + a.off(); + resetEnvironmentTracker({ window: win }); + // the default: a yes answer is an event + const b = activate({ ...rest, lifecycle: lifecycleStub as never, scheduler }); + await new Promise((r) => setTimeout(r, 0)); + expect(b.events.at(-1)).toEqual({ + name: 'private_mode_detected', + params: { method: 'storage-quota' }, + }); + expect(tick).not.toBe(noTick); // the injected scheduler reached the singleton's slow timer + const before = b.events.length; + const real = new Date().getTimezoneOffset(); + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(real - 60); // zone moved 1 h east + tick(); + expect(b.events.slice(before)).toEqual([ + { + name: 'timezone_changed', + params: { + timezone: expect.any(String), + utcOffsetMinutes: -(real - 60) + 0, + previousTimezone: expect.any(String), + previousUtcOffsetMinutes: -real + 0, + }, + }, + ]); + b.off(); + } finally { + vi.restoreAllMocks(); + resetEnvironmentTracker({ window: win }); + } + }); + }); }); diff --git a/packages/browser/src/system-events.ts b/packages/browser/src/system-events.ts index 01b86603..e5bae71e 100644 --- a/packages/browser/src/system-events.ts +++ b/packages/browser/src/system-events.ts @@ -3,6 +3,9 @@ import { type Interceptor, InterceptorBase } from '@bugsee/core'; import { type ApplicationTracker, applicationTrackerFor, + type EnvironmentEvents, + type EnvironmentTracker, + environmentTrackerFor, type UiTracker, uiTrackerFor, } from '@bugsee/trackers/browser'; @@ -16,6 +19,11 @@ import { hostGlobal, hostRead } from './host-global'; // pagehide → process_exiting (the best "about to unload — flush now" signal + bfcache flag) // visibilitychange → process_foreground / process_background (app fg/bg, from document.visibilityState) // online / offline → online / offline (connectivity transitions) +// languagechange → language_changed ({language?, languages?, previousLanguage?}) — from the EnvironmentTracker +// time zone / UTC offset → timezone_changed ({timezone?, utcOffsetMinutes?, previousTimezone?, previousUtcOffsetMinutes?}) +// private-mode HEURISTIC → private_mode_detected ({method}) ONLY for a `yes` answer (a small storage quota; constrained +// storage can false-positive, so it is a hint, never a certainty), once per answer, ON by default (the +// launch opts out with `captureEnvironmentHints: false`); `no`/`unknown` never produce an event (never claim certainty) // orientation (UiTracker: screen.orientation change + orientationchange) → orientation_changed ({type, angle}) // colorScheme (UiTracker) → dark_mode_changed ({dark} from prefers-color-scheme) // Each signal degrades gracefully where its global is absent (e.g. no document/screen in a worker). @@ -41,6 +49,18 @@ interface MediaQueryLike { ): void; } +/** The slice of a scheduler the environment tracker's slow tick needs. */ +type EnvironmentTimersLike = NonNullable[0]['scheduler']>; + +/** Drops the keys whose value is undefined (an unknown value is omitted from event params, never sent as null). */ +function defined(params: Record): Record { + return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined)); +} + +/** What the source needs from the environment tracker; `current` lets it re-deliver a retained private-mode answer. */ +type EnvironmentSource = Pick & + Partial>; + /** Injected globals (each defaults to the real global; an absent one is skipped). */ export interface BrowserSystemEventsEnv { window?: WindowEvents; @@ -53,6 +73,12 @@ export interface BrowserSystemEventsEnv { matchMedia?: (query: string) => MediaQueryLike; /** The shared lifecycle tracker; when absent one is created over `window` + `document`. */ lifecycle?: Pick; + /** The shared environment tracker; when absent the window's singleton is used (created over `window`/`document`). */ + environment?: EnvironmentSource; + /** Run the private-mode heuristic (default TRUE, opt-out; see `captureEnvironmentHints`). A `false` is also enforced at the event sink: `private_mode_detected` is never forwarded, even from a shared tracker another consumer enabled. */ + detectPrivateMode?: boolean; + /** Drives the time-zone re-read tick; default the global timers. Ignored when `environment` is supplied. */ + scheduler?: EnvironmentTimersLike; onError?: (error: unknown) => void; } @@ -60,12 +86,17 @@ class BrowserSystemEventsSource extends InterceptorBase<{ event: SystemEvent }> readonly name = 'browser-system-events'; readonly #lifecycle: Pick; readonly #ui: Pick; + readonly #environment: EnvironmentSource; + /** This source's own opt-out, enforced at the sink: the shared tracker may run the probe for another consumer. */ + readonly #privateMode: boolean; #unsubscribe: Array<() => void> = []; constructor( lifecycle: Pick, ui: Pick, - onError?: (error: unknown) => void, + environment: EnvironmentSource, + onError: ((error: unknown) => void) | undefined, + detectPrivateMode: boolean, ) { // A throwing reporter must not escape into the browser's event dispatch or abort activation. const report = (error: unknown): void => { @@ -78,6 +109,8 @@ class BrowserSystemEventsSource extends InterceptorBase<{ event: SystemEvent }> super(report); this.#lifecycle = lifecycle; this.#ui = ui; + this.#environment = environment; + this.#privateMode = detectPrivateMode; } protected onActivate(): void { @@ -106,6 +139,42 @@ class BrowserSystemEventsSource extends InterceptorBase<{ event: SystemEvent }> this.emit('event', { name: 'dark_mode_changed', params: { dark } }), ), ]; + // ONE subscription for every environment stage: the first subscriber activates the tracker, whose activation read + // may emit (a change made while we were stopped) straight away, so a stage subscribed later would miss it. + // private_mode_detected is a PER-SESSION event but the tracker outlives sessions and never re-probes a retained + // yes/no: so a retained `yes` is re-delivered once on subscribe (unless the activation already delivered it). + let privateDelivered = false; + const deliverPrivate = (method: string | undefined): void => { + privateDelivered = true; + this.emit('event', { name: 'private_mode_detected', params: defined({ method }) }); + }; + this.#unsubscribe.push( + this.#environment.onAny((name, payload) => { + if (name === 'language') { + const { language, languages, previous } = payload as EnvironmentEvents['language']; + this.emit('event', { + name: 'language_changed', + params: defined({ language, languages, previousLanguage: previous.language }), + }); + } else if (name === 'timezone') { + const { timeZone, utcOffsetMinutes, previous } = payload as EnvironmentEvents['timezone']; + this.emit('event', { + name: 'timezone_changed', + params: defined({ + timezone: timeZone, + utcOffsetMinutes, + previousTimezone: previous.timeZone, + previousUtcOffsetMinutes: previous.utcOffsetMinutes, + }), + }); + } else if (name === 'privateMode') { + const { private: answer, method } = payload as EnvironmentEvents['privateMode']; + if (answer === 'yes' && this.#privateMode) deliverPrivate(method); + } + }), + ); + const retained = this.#privateMode ? this.#environment.current?.().privateMode : undefined; + if (retained?.private === 'yes' && !privateDelivered) deliverPrivate(retained.method); } protected override onDeactivate(): void { @@ -122,11 +191,16 @@ export function createBrowserSystemEventsSource( // borrows the host's window or document. ONE read of the window serves the lifecycle tracker and the UI tracker. const injected = env.window !== undefined || env.document !== undefined; const win = injected ? env.window : hostGlobal('window', env.onError); - // The document is read only when no lifecycle tracker was supplied: a supplied one already owns it. + // The document is read lazily, once, and only when a tracker built here needs it (a supplied one already owns it). + let docRead: { doc: DocumentLike | undefined } | undefined; + const hostDoc = (): DocumentLike | undefined => { + docRead ??= { doc: injected ? env.document : hostGlobal('document', env.onError) }; + return docRead.doc; + }; const lifecycle = env.lifecycle ?? ((): ApplicationTracker => { - const doc = injected ? env.document : hostGlobal('document', env.onError); + const doc = hostDoc(); return applicationTrackerFor( { ...(win !== undefined ? { window: win } : {}), @@ -153,5 +227,35 @@ export function createBrowserSystemEventsSource( }, env.onError, ); - return new BrowserSystemEventsSource(lifecycle, ui, env.onError); + const environment = + env.environment ?? + ((): EnvironmentSource => { + const doc = hostDoc(); + const nav = + win === undefined + ? undefined + : hostRead(() => (win as { navigator?: object }).navigator, env.onError); + return environmentTrackerFor( + { + ...(win !== undefined ? { window: win } : {}), + ...(doc !== undefined ? { document: doc } : {}), + ...(nav !== undefined ? { navigator: nav } : {}), + ...(env.scheduler !== undefined ? { scheduler: env.scheduler } : {}), + // Always explicit: an omitted field would be RETAINED by the window's shared tracker (an earlier source + // may have changed it). Private-mode detection is on unless + // `detectPrivateMode: false`. Hints and permission states have no consumer here, so they are never read. + detectPrivateMode: env.detectPrivateMode ?? true, + observeHints: false, + queryPermissions: false, + }, + env.onError, + ); + })(); + return new BrowserSystemEventsSource( + lifecycle, + ui, + environment, + env.onError, + env.detectPrivateMode ?? true, + ); } diff --git a/packages/bugsee/src/option-directions.test.ts b/packages/bugsee/src/option-directions.test.ts index 08025e3f..eb682bd1 100644 --- a/packages/bugsee/src/option-directions.test.ts +++ b/packages/bugsee/src/option-directions.test.ts @@ -52,6 +52,7 @@ const DIRECTIONS: Record = { captureNetworkDefaultSanitizer: 'default', // default ON: the redaction stays on // browser captureInteractions: 'off', + captureEnvironmentHints: 'off', // private-mode heuristic, default ON: an invalid value turns it OFF (fail closed) captureViewHierarchy: 'off', startInBlackout: 'on', replay: 'off', // default ON, collects the DOM: an invalid scalar is OFF; masking is fail-closed on its own diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index 68afeb3b..58ba22ba 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -139,6 +139,7 @@ const RESET_OPTIONS: readonly string[] = [ 'bridge', 'captureBreadcrumbs', 'captureDiskSpace', + 'captureEnvironmentHints', 'captureInteractions', 'captureLocalVariables', 'captureLogs', diff --git a/packages/protocol/src/options.test.ts b/packages/protocol/src/options.test.ts index f5d36ceb..3a322f83 100644 --- a/packages/protocol/src/options.test.ts +++ b/packages/protocol/src/options.test.ts @@ -63,6 +63,9 @@ describe('BugseeOption canonical identifiers', () => { expect(BugseeOption.CaptureSystemEvents).toBe('com.bugsee.option.capture.system-events'); expect(BugseeOption.CaptureInteractions).toBe('com.bugsee.option.capture.interactions'); expect(BugseeOption.CaptureViewHierarchy).toBe('com.bugsee.option.capture.view-hierarchy'); + expect(BugseeOption.CaptureEnvironmentHints).toBe( + 'com.bugsee.option.capture.environment-hints', + ); expect(BugseeOption.CaptureStartInBlackout).toBe('com.bugsee.option.capture.start-in-blackout'); expect(BugseeOption.DetectCrash).toBe('com.bugsee.option.detect.crash'); expect(BugseeOption.Duration).toBe('com.bugsee.option.config.duration'); diff --git a/packages/protocol/src/options.ts b/packages/protocol/src/options.ts index 30d0c813..14405447 100644 --- a/packages/protocol/src/options.ts +++ b/packages/protocol/src/options.ts @@ -72,6 +72,13 @@ export const BugseeOption = { * begin. Default false. Browser only. */ CaptureStartInBlackout: 'com.bugsee.option.capture.start-in-blackout', + /** + * Browser only: observe environment hints that read as sensitive, starting with the PRIVATE-BROWSING heuristic (an + * `private_mode_detected` event for a `yes` answer; the answer is never certain). The protocol layer only defines + * the key and its boolean type; the default is set by the platform launch: the browser launch defaults to true + * (`false` opts out, a non-boolean value is treated as off and reported), the WebView launch never enables it. Language and time-zone changes are NOT gated by this. + */ + CaptureEnvironmentHints: 'com.bugsee.option.capture.environment-hints', /** Detect uncaught exceptions / unhandled rejections and report them. */ DetectCrash: 'com.bugsee.option.detect.crash', /** Report failed HTTP responses (>= 400) and network failures as `error` reports. Default false. */ @@ -140,6 +147,7 @@ export interface BugseeOptionTypes { 'com.bugsee.option.capture.system-events': boolean; 'com.bugsee.option.capture.interactions': boolean; 'com.bugsee.option.capture.view-hierarchy': boolean; + 'com.bugsee.option.capture.environment-hints': boolean; 'com.bugsee.option.capture.start-in-blackout': boolean; 'com.bugsee.option.detect.crash': boolean; 'com.bugsee.option.detect.hang': boolean; diff --git a/packages/trackers/README.md b/packages/trackers/README.md index 00b201e0..b04805a4 100644 --- a/packages/trackers/README.md +++ b/packages/trackers/README.md @@ -22,7 +22,7 @@ Trackers are the abstraction over system and runtime APIs. The package root is r - `@bugsee/trackers/node` (`src/node/`) - `@bugsee/trackers/electron` (`src/electron/`) -The `./browser` entry exports the `ApplicationTracker` (`applicationTrackerFor`, `resetApplicationTracker`), the `PowerTracker` (`powerTrackerFor`, `resetPowerTracker`), the `ConnectivityTracker` (`connectivityTrackerFor`, `resetConnectivityTracker`), the `ProcessTracker` (`processTrackerFor`, `resetProcessTracker`; stages `heap`, `mainThreadLag` (timer drift), `idle` (`requestIdleCallback`), `userIdle` (Idle Detection) and `uptime`, each armed only while it has listeners; the SDK never prompts, so `userIdle` runs only when the `idle-detection` permission is already granted; the SDK's `user_idle_state` trace additionally needs the browser/WebView launch option `captureUserIdle: true`, because a page's own grant is not consent to record and upload idle/locked state), the UI/display `UiTracker` (`uiTrackerFor`, `resetUiTracker`: orientation, screenSize, viewport, colorScheme, reducedMotion, displayMode, frameRate), the `StorageTracker` (`storageTrackerFor`, `resetStorageTracker`; an approximate origin quota estimate, not device free space: browsers may pad `usage`, and `quota` can roughly reveal the device's disk size, so treat it as potentially identifying) and `hostRead`; `./node` and `./electron` are reserved in `exports` and currently export nothing. Its API is internal and may change between releases. +The `./browser` entry exports the `ApplicationTracker` (`applicationTrackerFor`, `resetApplicationTracker`), the `PowerTracker` (`powerTrackerFor`, `resetPowerTracker`), the `ConnectivityTracker` (`connectivityTrackerFor`, `resetConnectivityTracker`), the `ProcessTracker` (`processTrackerFor`, `resetProcessTracker`; stages `heap`, `mainThreadLag` (timer drift), `idle` (`requestIdleCallback`), `userIdle` (Idle Detection) and `uptime`, each armed only while it has listeners; the SDK never prompts, so `userIdle` runs only when the `idle-detection` permission is already granted; the SDK's `user_idle_state` trace additionally needs the browser/WebView launch option `captureUserIdle: true`, because a page's own grant is not consent to record and upload idle/locked state), the UI/display `UiTracker` (`uiTrackerFor`, `resetUiTracker`: orientation, screenSize, viewport, colorScheme, reducedMotion, displayMode, frameRate), the `StorageTracker` (`storageTrackerFor`, `resetStorageTracker`; an approximate origin quota estimate, not device free space: browsers may pad `usage`, and `quota` can roughly reveal the device's disk size, so treat it as potentially identifying), the `EnvironmentTracker` (`environmentTrackerFor`, `resetEnvironmentTracker`; language, time zone, and, only when a consumer sets `observeHints` / `queryPermissions`, environment hints and Permissions API states queried without ever prompting; the private-browsing heuristic runs when `detectPrivateMode` is set (the browser launch sets it by default; opt out with `captureEnvironmentHints: false`) and is never reported as a certainty) and `hostRead`; `./node` and `./electron` are reserved in `exports` and currently export nothing. Its API is internal and may change between releases. ## Links diff --git a/packages/trackers/package.json b/packages/trackers/package.json index c560ed79..92079302 100644 --- a/packages/trackers/package.json +++ b/packages/trackers/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/bugsee/bugsee-javascript.git", "directory": "packages/trackers" }, - "description": "Domain-tracker foundation: multi-key emitter, InterceptorBase and the per-window tracker registry, with a ./browser entry (ApplicationTracker, ConnectivityTracker, PowerTracker, ProcessTracker, StorageTracker, UiTracker, hostRead) and reserved ./node and ./electron platform entries", + "description": "Domain-tracker foundation: multi-key emitter, InterceptorBase and the per-window tracker registry, with a ./browser entry (ApplicationTracker, ConnectivityTracker, PowerTracker, ProcessTracker, StorageTracker, UiTracker, EnvironmentTracker, hostRead) and reserved ./node and ./electron platform entries", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/trackers/src/browser/environment-tracker.test.ts b/packages/trackers/src/browser/environment-tracker.test.ts new file mode 100644 index 00000000..f00e8abb --- /dev/null +++ b/packages/trackers/src/browser/environment-tracker.test.ts @@ -0,0 +1,2174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_PERMISSION_NAMES, + type EnvironmentEnv, + type EnvironmentEvents, + type EnvironmentTimers, + EnvironmentTracker, + environmentTrackerFor, + NORMAL_QUOTA_FROM_BYTES, + PRIVATE_QUOTA_BELOW_BYTES, + resetEnvironmentTracker, +} from './environment-tracker'; + +const flush = (): Promise => + new Promise((r) => + (globalThis as unknown as { setTimeout(f: () => void, ms: number): void }).setTimeout( + () => r(), + 0, + ), + ); + +function mkTarget(extra: Record = {}) { + const l = new Map void>>(); + return Object.assign( + { + addEventListener: (t: string, f: () => void) => { + const s = l.get(t) ?? new Set<() => void>(); + s.add(f); + l.set(t, s); + }, + removeEventListener: (t: string, f: () => void) => void l.get(t)?.delete(f), + fire: (t: string) => { + for (const f of [...(l.get(t) ?? [])]) f(); + }, + count: (t?: string) => + t === undefined ? [...l.values()].reduce((n, s) => n + s.size, 0) : (l.get(t)?.size ?? 0), + }, + extra, + ); +} + +function mkScheduler() { + let next = 1; + const live = new Map void>(); + const scheduler: EnvironmentTimers & { live: typeof live; tick(): void; periods: number[] } = { + live, + periods: [], + setInterval(cb, ms) { + const id = next++; + live.set(id, cb); + scheduler.periods.push(ms); + return id; + }, + clearInterval(h) { + live.delete(h as number); + }, + tick() { + for (const cb of [...live.values()]) cb(); + }, + }; + return scheduler; +} + +const mkDocument = (state = 'visible') => mkTarget({ visibilityState: state }); + +function mkStatus(state: string) { + return mkTarget({ state }); +} +function mkPermissions(states: Record) { + const statuses = new Map>(); + const query = vi.fn(({ name }: { name: string }) => { + const s = states[name]; + if (s === undefined) return Promise.reject(Object.assign(new TypeError('bad name'), {})); + if (s instanceof Error) return Promise.reject(s); + const status = mkStatus(s); + statuses.set(name, status); + return Promise.resolve(status); + }); + return { query, statuses }; +} + +const wins: object[] = []; +function setup( + nav: Record = { language: 'en-US', languages: ['en-US', 'en'] }, + env: Partial = {}, +) { + const win = mkTarget({ isSecureContext: true, crossOriginIsolated: false }); + wins.push(win); + const document = mkDocument(); + const scheduler = mkScheduler(); + const zone = { id: 'Europe/Kyiv', offset: 120 }; + const onError = vi.fn(); + const navigator = nav; + const tracker = new EnvironmentTracker( + { + window: win, + navigator, + document, + scheduler, + intervalMs: 777, + timeZoneId: () => zone.id, + utcOffsetMinutes: () => zone.offset, + // the tracker reads hints / queries permissions only when asked: most tests are about those stages + observeHints: true, + queryPermissions: true, + ...env, + }, + onError, + ); + const seen: Array<[keyof EnvironmentEvents, unknown]> = []; + const listen = (): (() => void) => tracker.onAny((n, p) => seen.push([n, p])); + return { tracker, win, document, scheduler, zone, onError, navigator, seen, listen }; +} +afterEach(() => { + for (const w of wins.splice(0)) resetEnvironmentTracker({ window: w }); +}); + +describe('EnvironmentTracker: language', () => { + it('does nothing at construction; the first reading is a silent baseline in current()', () => { + const { tracker, win, scheduler, seen, listen } = setup(); + expect(win.count()).toBe(0); + expect(scheduler.live.size).toBe(0); + expect(tracker.current()).toEqual({}); + listen(); + expect(tracker.current().language).toEqual({ language: 'en-US', languages: ['en-US', 'en'] }); + expect(seen.filter(([n]) => n === 'language')).toEqual([]); + expect(win.count('languagechange')).toBe(1); + }); + + it('emits language {…, previous} on languagechange and not when nothing changed', () => { + const { tracker, win, navigator, seen, listen } = setup(); + listen(); + win.fire('languagechange'); + expect(seen.filter(([n]) => n === 'language')).toEqual([]); + navigator.language = 'uk-UA'; + navigator.languages = ['uk-UA']; + win.fire('languagechange'); + expect(seen.filter(([n]) => n === 'language')).toEqual([ + [ + 'language', + { + language: 'uk-UA', + languages: ['uk-UA'], + previous: { language: 'en-US', languages: ['en-US', 'en'] }, + }, + ], + ]); + expect(tracker.current().language).toEqual({ language: 'uk-UA', languages: ['uk-UA'] }); + }); + + it('omits unusable values: non-strings, empty strings, empty lists, non-arrays', () => { + const a = setup({ language: '', languages: [] }); + a.listen(); + expect(a.tracker.current().language).toBeUndefined(); + const b = setup({ language: 5, languages: 'en' }); + b.listen(); + expect(b.tracker.current().language).toBeUndefined(); + const c = setup({ language: 'de', languages: ['', 3, 'de'] }); + c.listen(); + expect(c.tracker.current().language).toEqual({ language: 'de', languages: ['de'] }); + const d = new EnvironmentTracker({ navigator: undefined, scheduler: mkScheduler() }); + d.on('language', () => {}); + expect(d.current().language).toBeUndefined(); + }); + + it('an unreadable value keeps the retained reading and emits nothing', () => { + const { tracker, win, navigator, seen, listen } = setup(); + listen(); + navigator.language = undefined; + navigator.languages = undefined; + win.fire('languagechange'); + expect(seen.filter(([n]) => n === 'language')).toEqual([]); + expect(tracker.current().language?.language).toBe('en-US'); + }); + + it('a throwing getter is reported and costs only that field', () => { + const nav = { + get language(): string { + throw new Error('lang'); + }, + languages: ['fr'], + }; + const { tracker, onError, listen } = setup(nav as never); + listen(); + expect(onError).toHaveBeenCalledTimes(1); + expect(tracker.current().language).toEqual({ languages: ['fr'] }); + const nav2 = { + language: 'fr', + get languages(): string[] { + throw new Error('langs'); + }, + }; + const b = setup(nav2 as never); + b.listen(); + expect(b.onError).toHaveBeenCalledTimes(1); + expect(b.tracker.current().language).toEqual({ language: 'fr' }); + }); +}); + +describe('EnvironmentTracker: timezone', () => { + it('reads the zone + offset silently, then emits on a tick when either changes', () => { + const { tracker, scheduler, zone, seen, listen } = setup(); + listen(); + expect(tracker.current().timeZone).toEqual({ timeZone: 'Europe/Kyiv', utcOffsetMinutes: 120 }); + expect(scheduler.periods).toEqual([777]); + scheduler.tick(); + expect(seen.filter(([n]) => n === 'timezone')).toEqual([]); + zone.offset = 180; // DST + scheduler.tick(); + zone.id = 'Asia/Tokyo'; + zone.offset = 540; + scheduler.tick(); + expect(seen.filter(([n]) => n === 'timezone')).toEqual([ + [ + 'timezone', + { + timeZone: 'Europe/Kyiv', + utcOffsetMinutes: 180, + previous: { timeZone: 'Europe/Kyiv', utcOffsetMinutes: 120 }, + }, + ], + [ + 'timezone', + { + timeZone: 'Asia/Tokyo', + utcOffsetMinutes: 540, + previous: { timeZone: 'Europe/Kyiv', utcOffsetMinutes: 180 }, + }, + ], + ]); + }); + + it('re-reads on visibility -> visible only (not on hidden)', () => { + const { document, zone, seen, listen } = setup(); + listen(); + zone.offset = 60; + document.visibilityState = 'hidden'; + document.fire('visibilitychange'); + expect(seen.filter(([n]) => n === 'timezone')).toEqual([]); + document.visibilityState = 'visible'; + document.fire('visibilitychange'); + expect(seen.filter(([n]) => n === 'timezone')).toHaveLength(1); + }); + + it('skips ticks while the page is hidden', () => { + const { document, scheduler, zone, seen, listen } = setup(); + listen(); + document.visibilityState = 'hidden'; + zone.offset = 60; + scheduler.tick(); + expect(seen.filter(([n]) => n === 'timezone')).toEqual([]); + document.visibilityState = 'visible'; + scheduler.tick(); + expect(seen.filter(([n]) => n === 'timezone')).toHaveLength(1); + }); + + it('normalizes -0, omits unusable offsets/ids, never fabricates', () => { + const a = setup(undefined, { utcOffsetMinutes: () => -0 }); + a.listen(); + expect(Object.is(a.tracker.current().timeZone?.utcOffsetMinutes, 0)).toBe(true); + const b = setup(undefined, { utcOffsetMinutes: () => Number.NaN, timeZoneId: () => '' }); + b.listen(); + expect(b.tracker.current().timeZone).toBeUndefined(); + const c = setup(undefined, { utcOffsetMinutes: () => '120', timeZoneId: () => 7 }); + c.listen(); + expect(c.tracker.current().timeZone).toBeUndefined(); + }); + + it('a throwing reader is reported and costs only its field', () => { + const a = setup(undefined, { + timeZoneId: () => { + throw new Error('tz'); + }, + }); + a.listen(); + expect(a.onError).toHaveBeenCalledTimes(1); + expect(a.tracker.current().timeZone).toEqual({ utcOffsetMinutes: 120 }); + }); + + it('defaults to Intl + Date and the global timers when no seam is injected', () => { + vi.useFakeTimers(); + try { + const t = new EnvironmentTracker({ intervalMs: 50 }); + const off = t.on('timezone', () => {}); + const tz = t.current().timeZone; + expect(tz?.timeZone).toBe(new Intl.DateTimeFormat().resolvedOptions().timeZone); + expect(tz?.utcOffsetMinutes).toBe(-new Date().getTimezoneOffset() + 0); + expect(vi.getTimerCount()).toBe(1); + off(); + expect(vi.getTimerCount()).toBe(0); + let offset = 0; + const d = new EnvironmentTracker({ timeZoneId: () => 'UTC', utcOffsetMinutes: () => offset }); + const changes: unknown[] = []; + d.on('timezone', (e) => changes.push(e)); + expect(vi.getTimerCount()).toBe(1); + offset = 60; + vi.advanceTimersByTime(59_999); + expect(changes).toEqual([]); // not before the default 60 s period + vi.advanceTimersByTime(1); + expect(changes).toEqual([ + { + timeZone: 'UTC', + utcOffsetMinutes: 60, + previous: { timeZone: 'UTC', utcOffsetMinutes: 0 }, + }, + ]); + d.reset(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('EnvironmentTracker: hints', () => { + const nav = () => ({ + language: 'en', + cookieEnabled: true, + webdriver: false, + doNotTrack: '1', + globalPrivacyControl: true, + serviceWorker: { controller: {} }, + }); + + it('emits every available hint on the first reading', () => { + const { tracker, seen, listen } = setup(nav()); + listen(); + const expected = { + cookiesEnabled: true, + secureContext: true, + crossOriginIsolated: false, + serviceWorkerControlled: true, + automation: false, + doNotTrack: true, + globalPrivacyControl: true, + }; + expect(seen.filter(([n]) => n === 'hints')).toEqual([['hints', expected]]); + expect(tracker.current().hints).toEqual(expected); + }); + + it('maps DNT values and omits unspecified ones; no service-worker container -> omitted', () => { + for (const [raw, want] of [ + ['1', true], + ['yes', true], + ['0', false], + ['no', false], + ] as const) { + const s = setup({ doNotTrack: raw }); + s.listen(); + expect(s.tracker.current().hints?.doNotTrack).toBe(want); + } + for (const raw of ['unspecified', null, undefined, 1]) { + const s = setup({ doNotTrack: raw, cookieEnabled: true }); + s.listen(); + expect(s.tracker.current().hints).not.toHaveProperty('doNotTrack'); + expect(s.tracker.current().hints).not.toHaveProperty('serviceWorkerControlled'); + } + const s = setup({ serviceWorker: { controller: null } }); + s.listen(); + expect(s.tracker.current().hints?.serviceWorkerControlled).toBe(false); + const u = setup({ serviceWorker: { controller: undefined } }); + u.listen(); + expect(u.tracker.current().hints?.serviceWorkerControlled).toBe(false); + }); + + it('omits non-boolean values; a tracker with no readable hint has none', () => { + const s = setup({ cookieEnabled: 'yes', webdriver: 1, globalPrivacyControl: 'true' }); + s.listen(); + expect(s.tracker.current().hints).toEqual({ secureContext: true, crossOriginIsolated: false }); + const bare = new EnvironmentTracker({ navigator: {}, window: {}, scheduler: mkScheduler() }); + bare.on('hints', () => {}); + expect(bare.current().hints).toBeUndefined(); + }); + + it('emits again only when a hint changes (tick / visibility)', () => { + const n = nav(); + const { document, scheduler, seen, listen } = setup(n); + listen(); + scheduler.tick(); + expect(seen.filter(([k]) => k === 'hints')).toHaveLength(1); + n.serviceWorker = { controller: null as never }; + document.fire('visibilitychange'); + const hints = seen.filter(([k]) => k === 'hints'); + expect(hints).toHaveLength(2); + expect((hints[1]?.[1] as { serviceWorkerControlled: boolean }).serviceWorkerControlled).toBe( + false, + ); + }); + + it('a throwing getter is reported and costs only that hint', () => { + const throwing = { + get cookieEnabled(): boolean { + throw new Error('cookie'); + }, + webdriver: true, + get serviceWorker(): unknown { + throw new Error('sw'); + }, + }; + const { tracker, onError, listen } = setup(throwing); + listen(); + expect(onError).toHaveBeenCalledTimes(2); + expect(tracker.current().hints).toEqual({ + secureContext: true, + crossOriginIsolated: false, + automation: true, + }); + }); +}); + +describe('EnvironmentTracker: permissions', () => { + it('queries only the default names, never prompts, emits states and refreshes on status change', async () => { + const perms = mkPermissions({ + camera: 'denied', + microphone: 'prompt', + geolocation: 'granted', + notifications: 'granted', + 'idle-detection': 'prompt', + }); + const requestPermission = vi.fn(); + const getUserMedia = vi.fn(); + const { tracker, seen, listen } = setup({ + permissions: perms, + requestPermission, + mediaDevices: { getUserMedia }, + }); + listen(); + expect(perms.query.mock.calls.map((c) => c[0])).toEqual( + DEFAULT_PERMISSION_NAMES.map((name) => ({ name })), + ); + await flush(); + const expected = { + camera: 'denied', + microphone: 'prompt', + geolocation: 'granted', + notifications: 'granted', + 'idle-detection': 'prompt', + }; + expect(seen.filter(([n]) => n === 'permissions')).toEqual([['permissions', expected]]); + expect(tracker.current().permissions).toEqual(expected); + // the user grants the camera in the browser UI + const cam = perms.statuses.get('camera'); + if (cam === undefined) throw new Error('no status'); + cam.state = 'granted'; + cam.fire('change'); + expect(seen.filter(([n]) => n === 'permissions')[1]).toEqual([ + 'permissions', + { ...expected, camera: 'granted' }, + ]); + // a no-op change does not emit + cam.fire('change'); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(2); + // an unreadable state is omitted, not kept + cam.state = 'weird'; + cam.fire('change'); + expect(tracker.current().permissions).not.toHaveProperty('camera'); + expect(requestPermission).not.toHaveBeenCalled(); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + + it('a name the browser rejects with TypeError is omitted silently; other failures are reported once per streak', async () => { + const boom = new Error('denied by policy'); + const perms = mkPermissions({ camera: 'granted', microphone: boom }); + const { tracker, onError, listen } = setup( + { permissions: perms }, + { permissionNames: ['camera', 'microphone', 'unknown-name'] }, + ); + const off = listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(boom); + // a second activation while still failing does not report again + off(); + listen(); + await flush(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('after a clean run the streak resets and a new failure is reported again', async () => { + let failing = true; + const boom = new Error('x'); + const query = vi.fn(() => + failing ? Promise.reject(boom) : Promise.resolve(mkStatus('granted')), + ); + const { tracker, onError } = setup({ permissions: { query } }, { permissionNames: ['camera'] }); + const off = tracker.onAny(() => {}); + await flush(); + expect(onError).toHaveBeenCalledTimes(1); + off(); + failing = false; + const off2 = tracker.onAny(() => {}); + await flush(); + off2(); + failing = true; + tracker.onAny(() => {}); + await flush(); + expect(onError).toHaveBeenCalledTimes(2); + }); + + it('a synchronously throwing query is classified the same way', async () => { + const boom = new Error('sync'); + const query = vi.fn(() => { + throw boom; + }); + const typeErr = vi.fn(() => { + throw new TypeError('no'); + }); + const a = setup({ permissions: { query } }, { permissionNames: ['camera'] }); + a.listen(); + await flush(); + expect(a.onError).toHaveBeenCalledWith(boom); + const b = setup({ permissions: { query: typeErr } }, { permissionNames: ['camera'] }); + b.listen(); + await flush(); + expect(b.onError).not.toHaveBeenCalled(); + }); + + it('an error whose name getter throws is still reported (never escapes)', async () => { + const hostile = { + get name(): string { + throw new Error('name'); + }, + }; + const query = vi.fn(() => Promise.reject(hostile)); + const { onError, listen } = setup({ permissions: { query } }, { permissionNames: ['camera'] }); + listen(); + await flush(); + expect(onError.mock.calls[0]?.[0]).toBe(hostile); + }); + + it('no Permissions API / hostile access: no states, launch is unaffected', async () => { + const a = setup({}); + a.listen(); + await flush(); + expect(a.tracker.current().permissions).toBeUndefined(); + const b = setup({ permissions: {} }); + b.listen(); + expect(b.tracker.current().permissions).toBeUndefined(); + const c = setup({ + get permissions(): unknown { + throw new Error('perm'); + }, + }); + c.listen(); + expect(c.onError).toHaveBeenCalledTimes(1); + const d = setup({ + permissions: { + get query(): unknown { + throw new Error('q'); + }, + }, + }); + d.listen(); + expect(d.onError).toHaveBeenCalledTimes(1); + }); + + it('when every query fails the retained snapshot is kept and nothing is emitted', async () => { + const perms = mkPermissions({ camera: 'granted' }); + const { tracker, seen, listen } = setup( + { permissions: perms }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + off(); + perms.query.mockImplementation(() => Promise.reject(new TypeError('nope'))); + seen.length = 0; + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + expect(seen).toEqual([]); + }); + + it('a status with a non-object value or without addEventListener is tolerated', async () => { + const query = vi.fn((arg: { name: string }) => + Promise.resolve(arg.name === 'camera' ? 'raw-string' : { state: 'granted' }), + ); + const { tracker, onError, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera', 'microphone'] }, + ); + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ microphone: 'granted' }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('a throwing status addEventListener / state getter / hostile add getter is reported, others unaffected', async () => { + const badAdd = { + state: 'granted', + addEventListener: () => { + throw new Error('add'); + }, + }; + const hostileGetter = { + state: 'denied', + get addEventListener(): unknown { + throw new Error('addget'); + }, + }; + const hostileState = { + get state(): string { + throw new Error('state'); + }, + }; + const query = vi.fn(({ name }: { name: string }) => + Promise.resolve( + { camera: badAdd, microphone: hostileGetter, geolocation: hostileState }[name], + ), + ); + const { tracker, onError, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera', 'microphone', 'geolocation'] }, + ); + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'granted', microphone: 'denied' }); + // add throw + getter throw + state throw (watch listener not attached for hostileState: state read at publish) + expect(onError.mock.calls.map(([e]) => (e as Error).message)).toEqual([ + 'add', + 'addget', + 'state', + ]); + }); + + it('releases the status listeners on the last unsubscribe and ignores a late change', async () => { + const perms = mkPermissions({ camera: 'granted' }); + const { tracker, seen, listen } = setup( + { permissions: perms }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + const cam = perms.statuses.get('camera'); + expect(cam?.count('change')).toBe(1); + off(); + expect(cam?.count('change')).toBe(0); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); // retained + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + }); + + it('a late status change from an older attachment is ignored (generation guard)', async () => { + const kept: Array<() => void> = []; + const status = { + state: 'granted', + addEventListener: (_t: string, l: () => void) => void kept.push(l), + removeEventListener: () => {}, // a host that keeps delivering to a removed listener + }; + const { tracker, seen, listen } = setup( + { permissions: { query: () => Promise.resolve(status) } }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + off(); + seen.length = 0; + status.state = 'denied'; + for (const l of kept) l(); + expect(seen).toEqual([]); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + }); + + it('a query still in flight at the last unsubscribe is ignored when it settles', async () => { + let resolve: (v: unknown) => void = () => {}; + const query = vi.fn(() => new Promise((r) => (resolve = r))); + const { tracker, seen, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + off(); + resolve(mkStatus('granted')); + await flush(); + expect(tracker.current().permissions).toBeUndefined(); + expect(seen.filter(([n]) => n === 'permissions')).toEqual([]); + }); +}); + +describe('EnvironmentTracker: privateMode', () => { + const quotaNav = (quota: unknown) => ({ + storage: { estimate: vi.fn(() => Promise.resolve({ quota })) }, + }); + + it('is OFF by default: no storage call, no stage', async () => { + const nav = quotaNav(1); + const { tracker, seen, listen } = setup(nav); + listen(); + await flush(); + expect(nav.storage.estimate).not.toHaveBeenCalled(); + expect(seen.filter(([n]) => n === 'privateMode')).toEqual([]); + expect(tracker.current().privateMode).toBeUndefined(); + }); + + it('an explicit detectPrivateMode:false is off too', async () => { + const nav = quotaNav(1); + const { tracker, listen } = setup(nav, { detectPrivateMode: false }); + listen(); + await flush(); + expect(nav.storage.estimate).not.toHaveBeenCalled(); + expect(tracker.current().privateMode).toBeUndefined(); + }); + + it.each([ + [1024, 'yes', 'storage-quota'], + [PRIVATE_QUOTA_BELOW_BYTES - 1, 'yes', 'storage-quota'], + [PRIVATE_QUOTA_BELOW_BYTES, 'unknown', 'storage-quota'], + [NORMAL_QUOTA_FROM_BYTES - 1, 'unknown', 'storage-quota'], + [NORMAL_QUOTA_FROM_BYTES, 'no', 'storage-quota'], + ])('quota %s -> %s', async (quota, answer, method) => { + const { tracker, seen, listen } = setup(quotaNav(quota), { detectPrivateMode: true }); + listen(); + await flush(); + expect(seen.filter(([n]) => n === 'privateMode')).toEqual([ + ['privateMode', { private: answer, method }], + ]); + expect(tracker.current().privateMode).toEqual({ private: answer, method }); + }); + + it.each([ + [undefined], + [0], + [-1], + [Number.NaN], + ['big'], + ])('an unusable quota (%s) is unknown with no method', async (quota) => { + const { tracker, listen } = setup(quotaNav(quota), { detectPrivateMode: true }); + listen(); + await flush(); + expect(tracker.current().privateMode).toEqual({ private: 'unknown' }); + }); + + it('no Storage API -> unknown, immediately', () => { + const { tracker, listen } = setup({}, { detectPrivateMode: true }); + listen(); + expect(tracker.current().privateMode).toEqual({ private: 'unknown' }); + const b = setup({ storage: {} }, { detectPrivateMode: true }); + b.listen(); + expect(b.tracker.current().privateMode).toEqual({ private: 'unknown' }); + }); + + it('a rejected / throwing estimate is reported once per streak and yields unknown', async () => { + const boom = new Error('estimate'); + const nav = { storage: { estimate: vi.fn(() => Promise.reject(boom)) } }; + const { tracker, onError } = setup(nav, { detectPrivateMode: true }); + const off = tracker.onAny(() => {}); + await flush(); + expect(tracker.current().privateMode).toEqual({ private: 'unknown' }); + off(); + tracker.onAny(() => {}); + await flush(); + expect(onError).toHaveBeenCalledTimes(1); + const sync = vi.fn(() => { + throw new Error('sync'); + }); + const b = setup({ storage: { estimate: sync } }, { detectPrivateMode: true }); + b.listen(); + await flush(); + expect(b.onError).toHaveBeenCalledTimes(1); + expect(b.tracker.current().privateMode).toEqual({ private: 'unknown' }); + }); + + it('a clean run ends the failure streak', async () => { + let fail = true; + const nav = { + storage: { + estimate: vi.fn(() => + fail ? Promise.reject(new Error('x')) : Promise.resolve({ quota: 1 }), + ), + }, + }; + const { tracker, onError } = setup(nav, { detectPrivateMode: true }); + let off = tracker.onAny(() => {}); + await flush(); + off(); + fail = false; + off = tracker.onAny(() => {}); + await flush(); + off(); + tracker.rebind({ detectPrivateMode: false }); // forget the definitive answer so it is probed again + tracker.rebind({ detectPrivateMode: true }); + fail = true; + tracker.onAny(() => {}); + await flush(); + expect(onError).toHaveBeenCalledTimes(2); + }); + + it('hostile storage / estimate / quota getters are reported, never thrown', async () => { + const a = setup( + { + get storage(): unknown { + throw new Error('s'); + }, + }, + { detectPrivateMode: true }, + ); + a.listen(); + expect(a.onError).toHaveBeenCalledTimes(1); + expect(a.tracker.current().privateMode).toEqual({ private: 'unknown' }); + const b = setup( + { + storage: { + get estimate(): unknown { + throw new Error('e'); + }, + }, + }, + { detectPrivateMode: true }, + ); + b.listen(); + expect(b.onError).toHaveBeenCalledTimes(1); + const c = setup( + { + storage: { + estimate: () => + Promise.resolve({ + get quota(): number { + throw new Error('q'); + }, + }), + }, + }, + { detectPrivateMode: true }, + ); + c.listen(); + await flush(); + expect(c.onError).toHaveBeenCalledTimes(1); + expect(c.tracker.current().privateMode).toEqual({ private: 'unknown' }); + }); + + it('a probe in flight at the last unsubscribe is ignored', async () => { + let resolve: (v: unknown) => void = () => {}; + const nav = { storage: { estimate: () => new Promise((r) => (resolve = r)) } }; + const { tracker, seen, listen } = setup(nav, { detectPrivateMode: true }); + listen()(); + resolve({ quota: 1 }); + await flush(); + expect(seen.filter(([n]) => n === 'privateMode')).toEqual([]); + expect(tracker.current().privateMode).toBeUndefined(); + }); + + it('a stale rejection after the last unsubscribe is ignored (not reported)', async () => { + let reject: (e: unknown) => void = () => {}; + const nav = { storage: { estimate: () => new Promise((_, r) => (reject = r)) } }; + const { tracker, onError, listen } = setup(nav, { detectPrivateMode: true }); + listen()(); + reject(new Error('late')); + await flush(); + expect(onError).not.toHaveBeenCalled(); + expect(tracker.current().privateMode).toBeUndefined(); + }); + + it('a restart does not repeat a definitive answer: no second probe and no duplicate event', async () => { + const nav = quotaNav(1); + const { tracker, seen, listen } = setup(nav, { detectPrivateMode: true }); + const off = listen(); + await flush(); + off(); + listen(); + await flush(); + expect(nav.storage.estimate).toHaveBeenCalledTimes(1); + expect(seen.filter(([n]) => n === 'privateMode')).toEqual([ + ['privateMode', { private: 'yes', method: 'storage-quota' }], + ]); + expect(tracker.current().privateMode).toEqual({ private: 'yes', method: 'storage-quota' }); + }); + + it('a restart does not repeat a definitive NO either', async () => { + const nav = quotaNav(NORMAL_QUOTA_FROM_BYTES); + const { seen, listen } = setup(nav, { detectPrivateMode: true }); + const off = listen(); + await flush(); + off(); + listen(); + await flush(); + expect(nav.storage.estimate).toHaveBeenCalledTimes(1); + expect(seen.filter(([n]) => n === 'privateMode')).toHaveLength(1); + }); + + it('a restart re-probes an inconclusive answer, and emits only if it changed', async () => { + let quota = 500 * 1024 * 1024; // between the thresholds: unknown + const estimate = vi.fn(() => Promise.resolve({ quota })); + const { seen, listen } = setup({ storage: { estimate } }, { detectPrivateMode: true }); + const off = listen(); + await flush(); + off(); + const off2 = listen(); + await flush(); + expect(estimate).toHaveBeenCalledTimes(2); + expect(seen.filter(([n]) => n === 'privateMode')).toHaveLength(1); // same unknown: not repeated + off2(); + quota = 1; + listen(); + await flush(); + expect(seen.filter(([n]) => n === 'privateMode')).toEqual([ + ['privateMode', { private: 'unknown', method: 'storage-quota' }], + ['privateMode', { private: 'yes', method: 'storage-quota' }], + ]); + }); + + it('a rebind that moves seams while running does not repeat the answer', async () => { + const nav = quotaNav(1); + const { tracker, seen, listen } = setup(nav, { detectPrivateMode: true }); + listen(); + await flush(); + const win2 = mkTarget(); + wins.push(win2); + tracker.rebind({ window: win2 }); + await flush(); + expect(win2.count('languagechange')).toBe(1); // it did re-attach + expect(nav.storage.estimate).toHaveBeenCalledTimes(1); + expect(seen.filter(([n]) => n === 'privateMode')).toHaveLength(1); + }); + + it('turning detection off then on again re-detects and re-emits', async () => { + const nav = quotaNav(1); + const { tracker, seen, listen } = setup(nav, { detectPrivateMode: true }); + listen(); + await flush(); + tracker.rebind({ detectPrivateMode: false }); + expect(tracker.current().privateMode).toBeUndefined(); + tracker.rebind({ detectPrivateMode: true }); + await flush(); + expect(nav.storage.estimate).toHaveBeenCalledTimes(2); + expect(seen.filter(([n]) => n === 'privateMode')).toHaveLength(2); + }); + + it('a superseded in-flight probe does not double-emit when the tracker is re-listened', async () => { + const { seen, listen } = setup(quotaNav(1), { detectPrivateMode: true }); + listen()(); + listen(); + await flush(); + expect(seen.filter(([n]) => n === 'privateMode')).toHaveLength(1); + }); +}); + +describe('EnvironmentTracker: lifecycle and retention', () => { + it('releases every listener and timer on the last unsubscribe', () => { + const { win, document, scheduler, listen } = setup(); + const off = listen(); + expect(win.count()).toBe(1); + expect(document.count()).toBe(1); + expect(scheduler.live.size).toBe(1); + off(); + expect(win.count()).toBe(0); + expect(document.count()).toBe(0); + expect(scheduler.live.size).toBe(0); + }); + + it('retains readings across stop/start and compares new readings against them', () => { + const { tracker, navigator, seen, listen } = setup(); + const off = listen(); + off(); + expect(tracker.current().language?.language).toBe('en-US'); + navigator.language = 'fr-FR'; + navigator.languages = ['fr-FR']; + listen(); + const changes = seen.filter(([n]) => n === 'language'); + expect(changes).toHaveLength(1); + expect((changes[0]?.[1] as { previous: unknown }).previous).toEqual({ + language: 'en-US', + languages: ['en-US', 'en'], + }); + }); + + it('reset() forgets readings and releases everything', () => { + const { tracker, win, scheduler, listen } = setup(); + listen(); + tracker.reset(); + expect(tracker.current()).toEqual({}); + expect(win.count()).toBe(0); + expect(scheduler.live.size).toBe(0); + }); + + it('a throwing onError never escapes; a missing one is fine', () => { + const t = new EnvironmentTracker( + { + navigator: { + get language(): string { + throw new Error('x'); + }, + }, + scheduler: mkScheduler(), + }, + () => { + throw new Error('reporter'); + }, + ); + expect(() => t.on('language', () => {})).not.toThrow(); + const u = new EnvironmentTracker({ + navigator: { + get language(): string { + throw new Error('x'); + }, + }, + scheduler: mkScheduler(), + }); + expect(() => u.on('language', () => {})).not.toThrow(); + }); + + it('a throwing subscriber is isolated through the guarded reporter', () => { + const { tracker, navigator, win, onError } = setup(); + tracker.on('language', () => { + throw new Error('listener'); + }); + navigator.language = 'de'; + win.fire('languagechange'); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('refused listener registration / hostile addEventListener are reported and do not abort activation', () => { + const win = { + addEventListener: () => { + throw new Error('add'); + }, + }; + const doc = { + get addEventListener(): unknown { + throw new Error('doc add'); + }, + }; + const onError = vi.fn(); + const t = new EnvironmentTracker( + { + window: win, + document: doc as never, + navigator: { language: 'en' }, + scheduler: mkScheduler(), + }, + onError, + ); + t.on('language', () => {}); + expect(onError).toHaveBeenCalledTimes(2); + expect(t.current().language?.language).toBe('en'); + const nonFn = new EnvironmentTracker({ + window: { addEventListener: 1 }, + scheduler: mkScheduler(), + }); + expect(() => nonFn.on('language', () => {})).not.toThrow(); + }); + + it('a throwing removeEventListener / clearInterval is tolerated (clearInterval reported)', () => { + const scheduler = mkScheduler(); + scheduler.clearInterval = () => { + throw new Error('clear'); + }; + const win = { + addEventListener: () => {}, + removeEventListener: () => { + throw new Error('remove'); + }, + }; + const onError = vi.fn(); + const t = new EnvironmentTracker({ window: win, scheduler }, onError); + const off = t.on('language', () => {}); + expect(() => off()).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('a throwing scheduler.setInterval is reported and the immediate readings still work', () => { + const scheduler = mkScheduler(); + scheduler.setInterval = () => { + throw new Error('arm'); + }; + const onError = vi.fn(); + const t = new EnvironmentTracker({ navigator: { language: 'en' }, scheduler }, onError); + t.on('language', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + expect(t.current().language?.language).toBe('en'); + }); + + it('a hostile status.removeEventListener is tolerated', async () => { + const status = { + state: 'granted', + addEventListener: () => {}, + removeEventListener: () => { + throw new Error('rm'); + }, + }; + const { listen, onError } = setup( + { permissions: { query: () => Promise.resolve(status) } }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + expect(() => off()).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('a hostile visibilityState is reported and treated as "not hidden"', () => { + const document = { + get visibilityState(): string { + throw new Error('vis'); + }, + addEventListener: () => {}, + }; + const scheduler = mkScheduler(); + const onError = vi.fn(); + const zone = { offset: 0 }; + const t = new EnvironmentTracker( + { document, scheduler, timeZoneId: () => 'UTC', utcOffsetMinutes: () => zone.offset }, + onError, + ); + const seen: unknown[] = []; + t.on('timezone', (e) => seen.push(e)); + zone.offset = 60; + scheduler.tick(); + expect(onError).toHaveBeenCalledTimes(1); + expect(seen).toHaveLength(1); + }); +}); + +describe('EnvironmentTracker: rebind', () => { + it('merges seams: a partial env never drops what the tracker has; defined ones replace', () => { + const { tracker, navigator, scheduler, listen } = setup(); + tracker.rebind({ detectPrivateMode: false }); + listen(); + expect(tracker.current().language?.language).toBe('en-US'); + expect(scheduler.live.size).toBe(1); + tracker.rebind({ timeZoneId: () => 'UTC', utcOffsetMinutes: () => 0, permissionNames: [] }); + navigator.language = 'it'; + tracker.rebind({}, undefined); + expect(tracker.current().language?.language).toBe('en-US'); // nothing re-read by a seam-less rebind + }); + + it('a running tracker moves its listeners: detach with OLD seams, then attach to the new ones', () => { + const { tracker, win, document, scheduler, listen } = setup(); + listen(); + const win2 = mkTarget({ isSecureContext: true }); + const doc2 = mkDocument(); + const sched2 = mkScheduler(); + wins.push(win2); + tracker.rebind({ window: win2, document: doc2, scheduler: sched2 }); + expect(win.count()).toBe(0); + expect(document.count()).toBe(0); + expect(scheduler.live.size).toBe(0); + expect(win2.count('languagechange')).toBe(1); + expect(doc2.count()).toBe(1); + expect(sched2.live.size).toBe(1); + }); + + it('an idle tracker only swaps seams (nothing is attached)', () => { + const { tracker, win } = setup(); + const win2 = mkTarget(); + tracker.rebind({ window: win2, navigator: { language: 'ja' } }); + expect(win2.count()).toBe(0); + expect(win.count()).toBe(0); + tracker.onAny(() => {}); + expect(win2.count('languagechange')).toBe(1); + expect(tracker.current().language?.language).toBe('ja'); + wins.push(win2); + }); + + it('an unchanged-object rebind does not re-attach', () => { + const { tracker, win, document, scheduler, listen } = setup(); + listen(); + tracker.rebind({ window: win, document, scheduler, intervalMs: 777 }); + expect(win.count()).toBe(1); + expect(scheduler.periods).toEqual([777]); + }); + + it('a changed navigator or interval also re-attaches', () => { + const { tracker, scheduler, listen } = setup(); + listen(); + tracker.rebind({ navigator: { language: 'es' } }); + expect(scheduler.periods).toEqual([777, 777]); + tracker.rebind({ intervalMs: 5 }); + expect(scheduler.periods).toEqual([777, 777, 5]); + }); + + it('the latest onError wins (undefined keeps it)', () => { + const { tracker, onError } = setup({ + get language(): string { + throw new Error('x'); + }, + }); + const later = vi.fn(); + tracker.rebind({}, later); + tracker.rebind({}, undefined); + tracker.onAny(() => {}); + expect(later).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('detectPrivateMode turned ON while running probes right away; OFF drops the result and in-flight probes', async () => { + const nav = { storage: { estimate: vi.fn(() => Promise.resolve({ quota: 1 })) } }; + const { tracker, seen, listen } = setup(nav); + listen(); + expect(nav.storage.estimate).not.toHaveBeenCalled(); + tracker.rebind({ detectPrivateMode: true }); + await flush(); + expect(nav.storage.estimate).toHaveBeenCalledTimes(1); + expect(tracker.current().privateMode).toEqual({ private: 'yes', method: 'storage-quota' }); + // restarting an already-on tracker does not probe twice via rebind + tracker.rebind({ detectPrivateMode: true }); + expect(nav.storage.estimate).toHaveBeenCalledTimes(1); + tracker.rebind({ detectPrivateMode: false }); + expect(tracker.current().privateMode).toBeUndefined(); + seen.length = 0; + // in-flight probe dropped when switched off + let resolve: (v: unknown) => void = () => {}; + const slow = { storage: { estimate: () => new Promise((r) => (resolve = r)) } }; + const s = setup(slow, { detectPrivateMode: true }); + s.listen(); + s.tracker.rebind({ detectPrivateMode: false }); + resolve({ quota: 1 }); + await flush(); + expect(s.tracker.current().privateMode).toBeUndefined(); + expect(s.seen.filter(([n]) => n === 'privateMode')).toEqual([]); + }); + + it('turning detectPrivateMode ON while idle only takes effect at activation', async () => { + const nav = { storage: { estimate: vi.fn(() => Promise.resolve({ quota: 1 })) } }; + const { tracker, listen } = setup(nav); + tracker.rebind({ detectPrivateMode: true }); + expect(nav.storage.estimate).not.toHaveBeenCalled(); + listen(); + await flush(); + expect(nav.storage.estimate).toHaveBeenCalledTimes(1); + }); + + it('an in-flight permission query from before a move is ignored', async () => { + let resolve: (v: unknown) => void = () => {}; + const query = vi.fn(() => new Promise((r) => (resolve = r))); + const { tracker, listen } = setup({ permissions: { query } }, { permissionNames: ['camera'] }); + listen(); + tracker.rebind({ + navigator: { permissions: { query: () => Promise.resolve(mkStatus('denied')) } }, + }); + const stale = resolve; + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'denied' }); + stale(mkStatus('granted')); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'denied' }); + }); +}); + +describe('environmentTrackerFor', () => { + it('is a singleton per window and rebinds on every later call', async () => { + const win = mkTarget(); + wins.push(win); + const probeError = new Error('quota'); + const probe = vi.fn(() => Promise.reject(probeError)); + const first = vi.fn(); + const a = environmentTrackerFor( + { window: win, navigator: { language: 'en', storage: { estimate: probe } } }, + first, + ); + const onError = vi.fn(); + const b = environmentTrackerFor({ window: win, detectPrivateMode: true }, onError); + expect(b).toBe(a); + // the shared tracker answers to the SECOND call's seams: private mode on (probing the first call's navigator, + // which a partial env keeps) and the second call's reporter, not the first + b.onAny(() => {}); + await flush(); + expect(probe).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(probeError); + expect(first).not.toHaveBeenCalled(); + const other = mkTarget(); + wins.push(other); + expect(environmentTrackerFor({ window: other })).not.toBe(a); + resetEnvironmentTracker({ window: other }); + }); + + it('keys on the window, else the navigator, else the env object; hostile getters are reported', () => { + const nav = { language: 'en' }; + expect(environmentTrackerFor({ navigator: nav })).toBe( + environmentTrackerFor({ navigator: nav }), + ); + const env = {}; + expect(environmentTrackerFor(env)).toBe(environmentTrackerFor(env)); + expect(environmentTrackerFor({})).not.toBe(environmentTrackerFor({})); + const onError = vi.fn(); + const hostile = { + get window(): object { + throw new Error('win'); + }, + get navigator(): object { + throw new Error('nav'); + }, + }; + const tracker = environmentTrackerFor(hostile, onError); + expect(tracker).toBeInstanceOf(EnvironmentTracker); + // each hostile getter is read (and reported) exactly once per call + expect(onError.mock.calls.map(([e]) => (e as Error).message)).toEqual(['win', 'nav']); + }); + + it('reads env.window and env.navigator once per call (seams and key share the reading)', () => { + const win = mkTarget(); + wins.push(win); + const nav = { language: 'en' }; + let windowReads = 0; + let navigatorReads = 0; + const env: EnvironmentEnv = { + get window() { + windowReads += 1; + return win; + }, + get navigator() { + navigatorReads += 1; + return nav; + }, + }; + const tracker = environmentTrackerFor(env); + expect([windowReads, navigatorReads]).toEqual([1, 1]); + expect(environmentTrackerFor(env)).toBe(tracker); + expect([windowReads, navigatorReads]).toEqual([2, 2]); + expect(environmentTrackerFor({ window: win })).toBe(tracker); // keyed on the window it read + }); + + it('resetEnvironmentTracker drops the singleton and releases its listeners', () => { + const win = mkTarget(); + wins.push(win); + const first = environmentTrackerFor({ window: win, navigator: { language: 'en' } }); + first.on('language', () => {}); + expect(win.count()).toBe(1); + resetEnvironmentTracker({ window: win }); + expect(win.count()).toBe(0); + expect(environmentTrackerFor({ window: win })).not.toBe(first); + }); +}); + +describe('EnvironmentTracker: guarded callbacks and comparison', () => { + const poisonEmit = (tracker: EnvironmentTracker, error: Error) => + vi + .spyOn(tracker as unknown as { emit(...a: unknown[]): void }, 'emit') + .mockImplementation(() => { + throw error; + }); + + it('a throwing timer callback is reported, never thrown, and the next tick still works', () => { + const { tracker, scheduler, zone, onError, listen } = setup(); + listen(); + const boom = new Error('emit'); + const emit = poisonEmit(tracker, boom); + zone.offset = 180; + expect(() => scheduler.tick()).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(boom); + emit.mockRestore(); + zone.offset = 240; + expect(() => scheduler.tick()).not.toThrow(); + expect(tracker.current().timeZone).toEqual({ timeZone: 'Europe/Kyiv', utcOffsetMinutes: 240 }); + }); + + it('a throwing PermissionStatus change listener is reported, never thrown into the DOM dispatch', async () => { + const perms = mkPermissions({ camera: 'granted' }); + const { tracker, onError, listen } = setup( + { permissions: perms }, + { permissionNames: ['camera'] }, + ); + listen(); + await flush(); + const boom = new Error('emit'); + poisonEmit(tracker, boom); + const cam = perms.statuses.get('camera'); + if (cam === undefined) throw new Error('no status'); + cam.state = 'denied'; + expect(() => cam.fire('change')).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('compares readings regardless of key order (a re-ordered but equal permissions snapshot is not a change)', async () => { + const perms = mkPermissions({ camera: 'granted', microphone: 'denied' }); + const { tracker, seen, listen } = setup( + { permissions: perms }, + { permissionNames: ['camera', 'microphone'] }, + ); + const off = listen(); + await flush(); + expect(Object.keys(tracker.current().permissions ?? {})).toEqual(['camera', 'microphone']); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + off(); + tracker.rebind({ permissionNames: ['microphone', 'camera'] }); + listen(); + await flush(); + expect(Object.keys(tracker.current().permissions ?? {})).toEqual(['microphone', 'camera']); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); // same values: no re-emission + const cam = perms.statuses.get('camera'); + if (cam === undefined) throw new Error('no status'); + cam.state = 'denied'; + cam.fire('change'); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(2); // a real change still emits + }); + + it('does not re-emit an unchanged hints / permissions snapshot to a restarted tracker (retained comparison)', async () => { + const perms = mkPermissions({ camera: 'granted' }); + const { tracker, seen, listen } = setup( + { permissions: perms, cookieEnabled: true }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + expect(seen.filter(([n]) => n === 'hints' || n === 'permissions')).toHaveLength(2); + off(); + seen.length = 0; + listen(); + await flush(); + expect(seen.filter(([n]) => n === 'hints' || n === 'permissions')).toEqual([]); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + }); +}); + +describe('EnvironmentTracker: failure streaks (reported once, then quiet until a clean run)', () => { + const boomOn = (flag: { on: boolean }, message: string) => { + if (flag.on) throw new Error(message); + }; + const messages = (onError: ReturnType) => + onError.mock.calls.map(([e]) => (e as Error).message); + + it('language: a persistently failing read is reported once; recovery then a new failure reports again', () => { + const flag = { on: true }; + const nav = { + get language(): string { + boomOn(flag, 'lang'); + return 'en'; + }, + }; + const { scheduler, onError, listen } = setup(nav); + listen(); // activation read fails + scheduler.tick(); + scheduler.tick(); + expect(messages(onError)).toEqual(['lang']); + flag.on = false; + scheduler.tick(); + expect(messages(onError)).toEqual(['lang']); + flag.on = true; + scheduler.tick(); + expect(messages(onError)).toEqual(['lang', 'lang']); + }); + + it('time zone: once per streak', () => { + const flag = { on: true }; + const { scheduler, onError, listen } = setup(undefined, { + timeZoneId: () => { + boomOn(flag, 'tz'); + return 'UTC'; + }, + }); + listen(); + scheduler.tick(); + scheduler.tick(); + expect(messages(onError)).toEqual(['tz']); + flag.on = false; + scheduler.tick(); + flag.on = true; + scheduler.tick(); + expect(messages(onError)).toEqual(['tz', 'tz']); + }); + + it('hints: once per streak', () => { + const flag = { on: true }; + const nav = { + language: 'en', + get cookieEnabled(): boolean { + boomOn(flag, 'hint'); + return true; + }, + }; + const { scheduler, onError, listen } = setup(nav); + listen(); + scheduler.tick(); + scheduler.tick(); + expect(messages(onError)).toEqual(['hint']); + flag.on = false; + scheduler.tick(); + flag.on = true; + scheduler.tick(); + expect(messages(onError)).toEqual(['hint', 'hint']); + }); + + it('visibility: once per streak (timer ticks and visibilitychange share it)', () => { + const flag = { on: true }; + const document = mkTarget(); + Object.defineProperty(document, 'visibilityState', { + get(): string { + boomOn(flag, 'vis'); + return 'visible'; + }, + }); + const { scheduler, onError, listen } = setup(undefined, { document }); + listen(); + scheduler.tick(); + document.fire('visibilitychange'); + scheduler.tick(); + expect(messages(onError)).toEqual(['vis']); + flag.on = false; + scheduler.tick(); + flag.on = true; + scheduler.tick(); + expect(messages(onError)).toEqual(['vis', 'vis']); + }); + + it('a streak reports every error of its first run (two failing reads of one stage)', () => { + const nav = { + get language(): string { + throw new Error('a'); + }, + get languages(): string[] { + throw new Error('b'); + }, + }; + const { scheduler, onError, listen } = setup(nav); + listen(); + scheduler.tick(); + expect(messages(onError)).toEqual(['a', 'b']); + }); + + it('reset() ends every streak, so the same failure is reported again afterwards', async () => { + const nav = { + get language(): string { + throw new Error('lang'); + }, + storage: { estimate: () => Promise.reject(new Error('quota')) }, + permissions: { query: () => Promise.reject(new Error('perm')) }, + }; + const { tracker, onError, listen } = setup(nav, { + detectPrivateMode: true, + permissionNames: ['camera'], + }); + const off = listen(); + await flush(); + expect(messages(onError).sort()).toEqual(['lang', 'perm', 'quota']); + off(); + tracker.reset(); + onError.mockClear(); + listen(); + await flush(); + expect(messages(onError).sort()).toEqual(['lang', 'perm', 'quota']); + }); +}); + +describe('EnvironmentTracker: rebind seams', () => { + it.each([ + ['window', () => ({ window: mkTarget({ isSecureContext: true }) })], + ['navigator', () => ({ navigator: { language: 'fr' } })], + ['document', () => ({ document: mkDocument() })], + ['scheduler', () => ({ scheduler: mkScheduler() })], + ['intervalMs', () => ({ intervalMs: 999 })], + ])('changing ONLY %s on a running tracker re-attaches', (key, change) => { + const { tracker, win, document, scheduler, listen } = setup(); + listen(); + const swap = change() as Partial; + const next = (swap.window ?? win) as ReturnType; + if (swap.window) wins.push(swap.window); + tracker.rebind(swap); + // detached from the OLD seams and attached to the (new or kept) ones, exactly once each + const nextDoc = (swap.document ?? document) as ReturnType; + const nextSched = (swap.scheduler ?? scheduler) as ReturnType; + expect(next.count('languagechange')).toBe(1); + expect(nextDoc.count('visibilitychange')).toBe(1); + expect(nextSched.live.size).toBe(1); + if (key === 'window') expect(win.count()).toBe(0); + if (key === 'document') expect(document.count()).toBe(0); + if (key === 'scheduler') expect(scheduler.live.size).toBe(0); + if (key === 'intervalMs') expect(scheduler.periods).toEqual([777, 999]); + if (key === 'navigator') expect(tracker.current().language?.language).toBe('fr'); // re-read from the new navigator + }); + + it('timeZoneId / utcOffsetMinutes replace ours on rebind and survive a seam-less rebind', () => { + const { tracker, listen } = setup(); + tracker.rebind({ timeZoneId: () => 'Asia/Tokyo', utcOffsetMinutes: () => 540 }); + tracker.rebind({}); + listen(); + expect(tracker.current().timeZone).toEqual({ timeZone: 'Asia/Tokyo', utcOffsetMinutes: 540 }); + }); + + it('a hostile getter on the rebind env is reported through the latest reporter, not thrown', () => { + const { tracker, onError } = setup(); + const later = vi.fn(); + const env = { + get window(): object { + throw new Error('hostile'); + }, + }; + expect(() => tracker.rebind(env as EnvironmentEnv, later)).not.toThrow(); + expect(later).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); +}); + +describe('EnvironmentTracker: emitted payloads are copies', () => { + it('a subscriber mutating an emitted stage object cannot change the retained readings', async () => { + const perms = mkPermissions({ camera: 'granted' }); + const { tracker, win, navigator, scheduler, zone, document, listen } = setup( + { language: 'en', languages: ['en'], cookieEnabled: true, permissions: perms }, + { permissionNames: ['camera'], detectPrivateMode: true }, + ); + const got: Record = {}; + tracker.onAny((name, payload) => { + got[name] = payload; + const p = payload as Record; + p.mutated = true; + p.languages?.push('xx'); + }); + void listen; + await flush(); + navigator.language = 'de'; + navigator.languages = ['de']; + win.fire('languagechange'); + zone.offset = 180; + scheduler.tick(); + expect(Object.keys(got).sort()).toEqual([ + 'hints', + 'language', + 'permissions', + 'privateMode', + 'timezone', + ]); + const cur = tracker.current(); + expect(cur.language).toEqual({ language: 'de', languages: ['de'] }); + expect(cur.timeZone).toEqual({ timeZone: 'Europe/Kyiv', utcOffsetMinutes: 180 }); + expect(cur.hints).not.toHaveProperty('mutated'); + expect(cur.permissions).toEqual({ camera: 'granted' }); + expect(cur.privateMode).not.toHaveProperty('mutated'); + void document; + }); +}); + +describe('EnvironmentTracker: a failing field keeps its retained value (no fake changes)', () => { + const evs = (seen: Array<[string, unknown]>, name: string) => seen.filter(([n]) => n === name); + + it('language: one field throwing then recovering emits nothing; a real change afterwards still does', () => { + const flag = { langFails: false, languagesFail: false }; + const state = { language: 'en', languages: ['en', 'de'] }; + const nav = { + get language(): string { + if (flag.langFails) throw new Error('language'); + return state.language; + }, + get languages(): string[] { + if (flag.languagesFail) throw new Error('languages'); + return state.languages; + }, + }; + const { tracker, scheduler, win, seen, listen } = setup(nav); + listen(); + flag.langFails = true; + win.fire('languagechange'); + expect(tracker.current().language).toEqual({ language: 'en', languages: ['en', 'de'] }); + flag.langFails = false; + flag.languagesFail = true; + win.fire('languagechange'); + expect(tracker.current().language).toEqual({ language: 'en', languages: ['en', 'de'] }); + flag.languagesFail = false; + scheduler.tick(); + expect(evs(seen, 'language')).toEqual([]); + // a failing field next to a REAL change of the other: only the real change shows + flag.langFails = true; + state.languages = ['de']; + win.fire('languagechange'); + expect(evs(seen, 'language')).toEqual([ + [ + 'language', + { + language: 'en', + languages: ['de'], + previous: { language: 'en', languages: ['en', 'de'] }, + }, + ], + ]); + }); + + it('a subscriber mutating previous.languages cannot corrupt the retained reading (kept array shared)', () => { + const state = { language: 'en', languagesFail: false }; + const nav = { + get language(): string { + return state.language; + }, + get languages(): string[] { + if (state.languagesFail) throw new Error('languages'); + return ['en', 'de']; + }, + }; + const { tracker, win, listen } = setup(nav); + const got: Array<{ previous: { languages?: string[] } }> = []; + tracker.on('language', (e) => { + got.push(e as never); + e.previous.languages?.push('xx'); + }); + listen(); + state.languagesFail = true; // the failing field keeps the retained array + state.language = 'fr'; + win.fire('languagechange'); + expect(got).toHaveLength(1); + expect(tracker.current().language).toEqual({ language: 'fr', languages: ['en', 'de'] }); + }); + + it('time zone: id or offset throwing once then recovering emits nothing', () => { + const fail = { id: false, offset: false }; + const { tracker, scheduler, seen, listen } = setup(undefined, { + timeZoneId: () => { + if (fail.id) throw new Error('id'); + return 'Europe/Kyiv'; + }, + utcOffsetMinutes: () => { + if (fail.offset) throw new Error('offset'); + return 120; + }, + }); + listen(); + fail.id = true; + scheduler.tick(); + expect(tracker.current().timeZone).toEqual({ timeZone: 'Europe/Kyiv', utcOffsetMinutes: 120 }); + fail.id = false; + fail.offset = true; + scheduler.tick(); + expect(tracker.current().timeZone).toEqual({ timeZone: 'Europe/Kyiv', utcOffsetMinutes: 120 }); + fail.offset = false; + scheduler.tick(); + expect(evs(seen, 'timezone')).toEqual([]); + }); + + it('hints: a throwing hint keeps its retained value (each hint, incl. doNotTrack and serviceWorker)', () => { + const fail = { on: false }; + const guard = (v: T): T => { + if (fail.on) throw new Error('hint'); + return v; + }; + const nav = { + language: 'en', + get cookieEnabled() { + return guard(true); + }, + get webdriver() { + return guard(false); + }, + get globalPrivacyControl() { + return guard(true); + }, + get doNotTrack() { + return guard('1'); + }, + get serviceWorker() { + return guard({ controller: {} }); + }, + }; + const win = mkTarget(); + Object.defineProperty(win, 'isSecureContext', { get: () => guard(true) }); + Object.defineProperty(win, 'crossOriginIsolated', { get: () => guard(false) }); + wins.push(win); + const { tracker, scheduler, seen, listen } = setup(nav, { window: win }); + listen(); + const full = tracker.current().hints; + expect(full).toEqual({ + cookiesEnabled: true, + secureContext: true, + crossOriginIsolated: false, + serviceWorkerControlled: true, + automation: false, + doNotTrack: true, + globalPrivacyControl: true, + }); + fail.on = true; + scheduler.tick(); + expect(tracker.current().hints).toEqual(full); + fail.on = false; + scheduler.tick(); + expect(evs(seen, 'hints')).toHaveLength(1); // only the first snapshot + }); + + it('a throwing PermissionStatus.state getter is reported once per streak, not every run', async () => { + const flag = { on: true }; + const status = mkTarget(); + Object.defineProperty(status, 'state', { + get() { + if (flag.on) throw new Error('state'); + return 'granted'; + }, + }); + const { onError, tracker, listen } = setup( + { permissions: { query: () => Promise.resolve(status) } }, + { permissionNames: ['camera'] }, + ); + listen(); + await flush(); + status.fire('change'); + status.fire('change'); + expect(onError.mock.calls.map(([e]) => (e as Error).message)).toEqual(['state']); + flag.on = false; + status.fire('change'); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + flag.on = true; + status.fire('change'); + expect(onError).toHaveBeenCalledTimes(2); + }); + + const throwingStatus = (flag: { on: boolean }, state: string, message: string) => { + const status = mkTarget(); + Object.defineProperty(status, 'state', { + get() { + if (flag.on) throw new Error(message); + return state; + }, + }); + return status; + }; + + it('a throwing state read keeps the retained state: no fake {} change, no emission, then recovery is silent', async () => { + const flag = { on: false }; + const status = throwingStatus(flag, 'granted', 'state'); + const { tracker, seen, listen } = setup( + { permissions: { query: () => Promise.resolve(status) } }, + { permissionNames: ['camera'] }, + ); + listen(); + await flush(); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + flag.on = true; + status.fire('change'); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + flag.on = false; + status.fire('change'); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + }); + + it('a name whose re-query FAILS keeps its retained state; a TypeError (unsupported) omits it', async () => { + const mode = { mic: 'ok' as 'ok' | 'fail' | 'type' }; + const query = vi.fn(({ name }: { name: string }) => { + if (name === 'microphone' && mode.mic === 'fail') return Promise.reject(new Error('mic')); + if (name === 'microphone' && mode.mic === 'type') return Promise.reject(new TypeError('x')); + return Promise.resolve(mkStatus(name === 'camera' ? 'granted' : 'denied')); + }); + const { tracker, seen, onError, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera', 'microphone'] }, + ); + let off = listen(); + await flush(); + const full = { camera: 'granted', microphone: 'denied' }; + expect(tracker.current().permissions).toEqual(full); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + off(); + mode.mic = 'fail'; + off = listen(); + await flush(); + expect(onError.mock.calls.map(([e]) => (e as Error).message)).toEqual(['mic']); + expect(tracker.current().permissions).toEqual(full); // retained, not dropped + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); // and no fake change + off(); + mode.mic = 'type'; + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); // unsupported: omitted + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(2); + }); + + it('a state read that throws during a re-query keeps the retained state of that name', async () => { + const flag = { on: false }; + const cam = throwingStatus(flag, 'granted', 'cam'); + const mic = mkStatus('denied'); + const query = ({ name }: { name: string }) => Promise.resolve(name === 'camera' ? cam : mic); + const { tracker, seen, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera', 'microphone'] }, + ); + const off = listen(); + await flush(); + off(); + flag.on = true; + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'granted', microphone: 'denied' }); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + }); + + it('state-read streaks are per permission name', async () => { + const camFlag = { on: true }; + const micFlag = { on: true }; + const cam = throwingStatus(camFlag, 'granted', 'cam'); + const mic = throwingStatus(micFlag, 'denied', 'mic'); + const query = ({ name }: { name: string }) => Promise.resolve(name === 'camera' ? cam : mic); + const { onError, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera', 'microphone'] }, + ); + listen(); + await flush(); + // both names fail on the first run: BOTH are reported + expect(onError.mock.calls.map(([e]) => (e as Error).message)).toEqual(['cam', 'mic']); + cam.fire('change'); + mic.fire('change'); + expect(onError).toHaveBeenCalledTimes(2); // silent for both while the streaks last + // camera keeps failing while microphone is clean: camera stays quiet, microphone's clean run does not reset it + micFlag.on = false; + mic.fire('change'); + cam.fire('change'); + mic.fire('change'); + cam.fire('change'); + expect(onError).toHaveBeenCalledTimes(2); + }); +}); + +describe('EnvironmentTracker: reset with subscribers, defaults and listener pins', () => { + it('reset() drops subscribers and a NEW subscriber re-activates the tracker', () => { + const { tracker, win, scheduler, document, seen, listen } = setup(); + listen(); + expect(scheduler.live.size).toBe(1); + tracker.reset(); + expect(scheduler.live.size).toBe(0); + expect(win.count()).toBe(0); + expect(document.count()).toBe(0); + expect(tracker.current()).toEqual({}); + seen.length = 0; + const off = listen(); + expect(scheduler.live.size).toBe(1); + expect(win.count('languagechange')).toBe(1); + expect(document.count('visibilitychange')).toBe(1); + expect(tracker.current().language?.language).toBe('en-US'); + off(); + expect(scheduler.live.size).toBe(0); + }); + + it('the default UTC offset is positive EAST of UTC (getTimezoneOffset is positive WEST)', () => { + const spy = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120); // UTC+2 + try { + const t = new EnvironmentTracker({}); + const off = t.on('timezone', () => {}); + expect(t.current().timeZone?.utcOffsetMinutes).toBe(120); + off(); + spy.mockReturnValue(300); // UTC-5 + const u = new EnvironmentTracker({}); + const offU = u.on('timezone', () => {}); + expect(u.current().timeZone?.utcOffsetMinutes).toBe(-300); + offU(); + } finally { + spy.mockRestore(); + } + }); + + it('release still tries to remove a listener whose addEventListener threw', () => { + const removed: string[] = []; + const win = { + addEventListener: () => { + throw new Error('add'); + }, + removeEventListener: (type: string) => void removed.push(type), + isSecureContext: true, + }; + const { tracker, onError } = setup(undefined, { window: win as never }); + const off = tracker.onAny(() => {}); + expect(onError.mock.calls.map(([e]) => (e as Error).message)).toEqual(['add']); + off(); + expect(removed).toEqual(['languagechange']); + }); + + it.each([ + ['hidden', 'hidden'], + ['prerender', 'prerender'], + ['unreadable', undefined], + ])('visibilitychange to a %s page does not re-read', (_label, state) => { + const document = mkTarget(); + let current: string | undefined = 'visible'; + Object.defineProperty(document, 'visibilityState', { + get() { + if (current === undefined) throw new Error('vis'); + return current; + }, + }); + const { tracker, navigator, listen } = setup(undefined, { document }); + listen(); + navigator.language = 'fr'; + current = state; + document.fire('visibilitychange'); + expect(tracker.current().language?.language).toBe('en-US'); + current = 'visible'; + document.fire('visibilitychange'); + expect(tracker.current().language?.language).toBe('fr'); + }); +}); + +describe('EnvironmentTracker: unusable answers and private-mode read streaks', () => { + it('an answered but UNUSABLE state replaces a retained one: the stale entry is cleared and the change emitted', async () => { + const status = mkStatus('granted'); + const { tracker, seen, listen } = setup( + { permissions: { query: () => Promise.resolve(status) } }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + off(); + status.state = 'weird'; // answers, but not a usable state + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({}); + expect(seen.filter(([n]) => n === 'permissions')).toEqual([ + ['permissions', { camera: 'granted' }], + ['permissions', {}], + ]); + }); + + it('a throwing state with nothing retained yields no snapshot and no emission (a failed read is not an answer)', async () => { + const status = mkTarget(); + Object.defineProperty(status, 'state', { + get(): string { + throw new Error('state'); + }, + }); + const { tracker, seen, listen } = setup( + { permissions: { query: () => Promise.resolve(status) } }, + { permissionNames: ['camera'] }, + ); + listen(); + await flush(); + expect(tracker.current().permissions).toBeUndefined(); + expect(seen.filter(([n]) => n === 'permissions')).toEqual([]); + }); + + it('a FAILED query (or throwing state) still retains the snapshot and emits nothing', async () => { + let mode: 'ok' | 'reject' = 'ok'; + const query = vi.fn(() => + mode === 'ok' ? Promise.resolve(mkStatus('granted')) : Promise.reject(new Error('q')), + ); + const { tracker, seen, listen } = setup( + { permissions: { query } }, + { permissionNames: ['camera'] }, + ); + const off = listen(); + await flush(); + off(); + mode = 'reject'; + listen(); + await flush(); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + expect(seen.filter(([n]) => n === 'permissions')).toHaveLength(1); + }); + + it('private mode: storage / estimate / quota getter failures are reported once per streak, recovery starts a new one', async () => { + const fail = { storage: true, quota: false }; + const nav = { + get storage(): unknown { + if (fail.storage) throw new Error('storage'); + return { + estimate: () => + Promise.resolve({ + get quota(): number { + if (fail.quota) throw new Error('quota'); + return 500 * 1024 * 1024; // inconclusive: probed again on every restart + }, + }), + }; + }, + }; + const { tracker, onError, listen } = setup(nav as never, { detectPrivateMode: true }); + const messages = () => onError.mock.calls.map(([e]) => (e as Error).message); + let off = listen(); + await flush(); + off(); + off = listen(); + await flush(); + off(); + expect(messages()).toEqual(['storage']); // the second failing probe is quiet + fail.storage = false; + off = listen(); // clean probe ends the streak + await flush(); + off(); + fail.quota = true; + off = listen(); + await flush(); + off(); + off = listen(); + await flush(); + off(); + expect(messages()).toEqual(['storage', 'quota']); + fail.storage = true; + listen(); + await flush(); + expect(messages()).toEqual(['storage', 'quota', 'storage']); // a new streak + expect(tracker.current().privateMode).toEqual({ private: 'unknown' }); + }); +}); + +describe('EnvironmentTracker: hints and permissions are read only when asked for', () => { + const nav = () => ({ + language: 'en', + cookieEnabled: true, + doNotTrack: '1', + permissions: mkPermissions({ camera: 'granted' }), + }); + + it('by default neither the hints are read nor a permission is queried, for any subscriber', async () => { + const n = nav(); + const { tracker, seen, listen, win, document } = setup(n, { + observeHints: undefined, + queryPermissions: undefined, + }); + listen(); + await flush(); + expect(n.permissions.query).not.toHaveBeenCalled(); + expect(tracker.current().hints).toBeUndefined(); + expect(tracker.current().permissions).toBeUndefined(); + expect(seen.filter(([k]) => k === 'hints' || k === 'permissions')).toEqual([]); + expect(tracker.current().language?.language).toBe('en'); // the other stages still run + document.fire('visibilitychange'); + expect(tracker.current().hints).toBeUndefined(); + expect(win.count('languagechange')).toBe(1); + }); + + it('queryPermissions alone queries permissions and does not read hints; observeHints alone the reverse', async () => { + const a = nav(); + const onlyPerms = setup(a, { observeHints: false }); + onlyPerms.listen(); + await flush(); + expect(a.permissions.query).toHaveBeenCalled(); + expect(onlyPerms.tracker.current().permissions).toEqual({ camera: 'granted' }); + expect(onlyPerms.tracker.current().hints).toBeUndefined(); + const b = nav(); + const onlyHints = setup(b, { queryPermissions: false }); + onlyHints.listen(); + await flush(); + expect(b.permissions.query).not.toHaveBeenCalled(); + expect(onlyHints.tracker.current().permissions).toBeUndefined(); + expect(onlyHints.tracker.current().hints?.cookiesEnabled).toBe(true); + }); + + it('turning them on while running reads/queries at once; off drops the readings and the status listeners', async () => { + const n = nav(); + const { tracker, listen, seen } = setup(n, { observeHints: false, queryPermissions: false }); + listen(); + await flush(); + expect(n.permissions.query).not.toHaveBeenCalled(); + tracker.rebind({ observeHints: true, queryPermissions: true }); + await flush(); + expect(n.permissions.query).toHaveBeenCalled(); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + expect(tracker.current().hints?.doNotTrack).toBe(true); + const status = n.permissions.statuses.get('camera'); + expect(status?.count('change')).toBe(1); + tracker.rebind({ observeHints: false, queryPermissions: false }); + expect(tracker.current().permissions).toBeUndefined(); + expect(tracker.current().hints).toBeUndefined(); + expect(status?.count('change')).toBe(0); + seen.length = 0; + tracker.rebind({ observeHints: true }); + expect(seen.filter(([k]) => k === 'hints')).toHaveLength(1); // re-read after being off + }); + + it('each gate re-attaches on its own: queryPermissions off drops the statuses while hints stay, and back', async () => { + const n = nav(); + const { tracker, listen } = setup(n); + listen(); + await flush(); + const status = n.permissions.statuses.get('camera'); + expect(status?.count('change')).toBe(1); + tracker.rebind({ queryPermissions: false }); + expect(status?.count('change')).toBe(0); + expect(tracker.current().permissions).toBeUndefined(); + expect(tracker.current().hints?.cookiesEnabled).toBe(true); + const calls = n.permissions.query.mock.calls.length; + tracker.rebind({ queryPermissions: true }); + await flush(); + expect(n.permissions.query.mock.calls.length).toBeGreaterThan(calls); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + tracker.rebind({ observeHints: false }); + expect(tracker.current().hints).toBeUndefined(); + expect(tracker.current().permissions).toEqual({ camera: 'granted' }); + tracker.rebind({ observeHints: true }); + expect(tracker.current().hints?.cookiesEnabled).toBe(true); + }); + + it('an idle tracker only applies the change at activation', async () => { + const n = nav(); + const { tracker, listen } = setup(n, { observeHints: false, queryPermissions: false }); + tracker.rebind({ queryPermissions: true }); + expect(n.permissions.query).not.toHaveBeenCalled(); + listen(); + await flush(); + expect(n.permissions.query).toHaveBeenCalled(); + }); +}); diff --git a/packages/trackers/src/browser/environment-tracker.ts b/packages/trackers/src/browser/environment-tracker.ts new file mode 100644 index 00000000..7a832f36 --- /dev/null +++ b/packages/trackers/src/browser/environment-tracker.ts @@ -0,0 +1,828 @@ +import { InterceptorBase } from '../interceptor-base'; +import { createTrackerRegistry } from '../tracker-registry'; +import { hostRead } from './host-read'; + +// Browser ENVIRONMENT tracker (domain tracker, design §15.3 item 2; Android reference: BugseeTrackerDevice / +// Application). It aggregates what can be OBSERVED about the runtime, in five stages: +// +// language {language?, languages?, previous} navigator.language(s); the `languagechange` window event. +// CHANGE-ONLY: the first reading is a silent baseline (see `current()`), a later different one emits. +// timezone {timeZone?, utcOffsetMinutes?, previous} Intl time zone id + UTC offset (minutes, positive east). +// No browser event exists, so it is RE-READ on a slow timer (default 60 s; ticks while the page is hidden +// are skipped) and on visibility -> visible. CHANGE-ONLY like `language` (a DST shift is an offset change). +// hints (read ONLY when `observeHints` is set: nothing consumes it, so the browser source never sets it) +// {cookiesEnabled?, secureContext?, crossOriginIsolated?, serviceWorkerControlled?, automation?, +// doNotTrack?, globalPrivacyControl?} SNAPSHOT: emitted for the first reading of this tracker and on +// every change after it; on a restart the reading is compared with the RETAINED one, so an unchanged +// reading is not emitted again (read it via `current()`). +// `automation` is `navigator.webdriver`; `doNotTrack`/`globalPrivacyControl` are the user's own +// preference signals, recorded as booleans (an unspecified/unknown DNT value is omitted). +// permissions (queried ONLY when `queryPermissions` is set, same reason: no `permissions.query()` call and no `change` +// listener otherwise) {camera?: 'granted'|'denied'|'prompt', microphone?, geolocation?, notifications?, +// 'idle-detection'?} +// SNAPSHOT of the Permissions API states. Queried WITHOUT EVER PROMPTING: only `navigator.permissions +// .query()` is called (never requestPermission/getUserMedia/...), only the state STRING is kept (no +// stream, position or token is ever touched), and a PermissionStatus `change` event refreshes it. Emitted +// for the first snapshot and on every change; on a restart it is compared with the RETAINED one (same as +// hints), so an unchanged snapshot is not emitted again (read it via `current()`). A name +// the browser does not know (query rejects with TypeError) is simply omitted, not an error. +// privateMode {private: 'yes'|'no'|'unknown', method?} runs when `detectPrivateMode` is set (false at this level; +// the browser launch turns it ON by default, owner decision 2026-10-20: opt-out with +// `captureEnvironmentHints: false`). A HEURISTIC, never a +// certainty: the only method is `storage-quota` (a quota under 120 MiB suggests a private window, one over +// 1 GiB suggests a normal one, anything between or unreadable is `unknown`; constrained or unusual storage +// can therefore FALSE-POSITIVE). It emits when the answer first arrives and whenever it changes (it is +// the basis of the `private_mode_detected` event); a retained yes/no is not probed again on a restart +// or a seam move, an inconclusive `unknown` is retried. +// +// Every value that is unusable (or that a read found nothing for) is OMITTED, never fabricated; a value whose read +// THREW keeps its retained value (a transient failure is not a change). Host reads go through `hostRead` +// (reported through `onError`, a throwing reporter never escapes) and never fail the launch. Permission-query +// failures are reported once per failure streak. +// +// SINGLETON per key object (see `environmentTrackerFor`). RETENTION rule — what survives the last unsubscribe: +// * the LAST language / time zone / hints / permissions / private-mode readings (`current()` keeps answering with +// them, and language / time zone / hints / permissions keep comparing against them when the tracker is +// restarted: a late subscriber to a restarted tracker gets NO re-emission of an unchanged hints / permissions +// snapshot — a `privateMode` answer is likewise not repeated unless it changed). +// What does NOT survive: the DOM listeners (languagechange, visibilitychange, PermissionStatus change), the timer, and +// any async work in flight (permission queries, the private-mode probe) — a stale promise is ignored when it settles. +// The next subscriber re-reads everything. + +export interface LanguageInfo { + language?: string; + languages?: string[]; +} +export interface TimeZoneInfo { + timeZone?: string; + /** Minutes from UTC, positive east (UTC+2 -> 120). */ + utcOffsetMinutes?: number; +} +export interface EnvironmentHints { + cookiesEnabled?: boolean; + secureContext?: boolean; + crossOriginIsolated?: boolean; + serviceWorkerControlled?: boolean; + /** `navigator.webdriver` — the page is driven by automation. */ + automation?: boolean; + doNotTrack?: boolean; + globalPrivacyControl?: boolean; +} +export type PermissionStateName = 'granted' | 'denied' | 'prompt'; +export type PermissionStates = Record; +export interface PrivateModeResult { + private: 'yes' | 'no' | 'unknown'; + /** The heuristic that produced the answer; absent when nothing could be probed. */ + method?: string; +} +export interface EnvironmentEvents { + language: LanguageInfo & { previous: LanguageInfo }; + timezone: TimeZoneInfo & { previous: TimeZoneInfo }; + hints: EnvironmentHints; + permissions: PermissionStates; + privateMode: PrivateModeResult; +} +export interface EnvironmentSnapshot { + language?: LanguageInfo; + timeZone?: TimeZoneInfo; + hints?: EnvironmentHints; + permissions?: PermissionStates; + privateMode?: PrivateModeResult; +} +export interface EnvironmentTimers { + setInterval(callback: () => void, ms: number): unknown; + clearInterval(handle: unknown): void; +} +interface DocumentLike { + readonly visibilityState?: string; + addEventListener?(type: string, listener: () => void): void; + removeEventListener?(type: string, listener: () => void): void; +} +interface TargetLike { + addEventListener(type: string, listener: () => void): void; + removeEventListener(type: string, listener: () => void): void; +} + +/** What the tracker needs from a launch (every seam optional; an absent one just means that stage has no data). */ +export interface EnvironmentEnv { + /** Source of `languagechange`, `isSecureContext`, `crossOriginIsolated`. */ + window?: object; + /** Source of language(s), cookies, webdriver, DNT/GPC, serviceWorker, permissions, storage. */ + navigator?: object; + /** Source of `visibilitychange` / `visibilityState`. */ + document?: DocumentLike; + /** Drives the slow time-zone tick; default the global timers. */ + scheduler?: EnvironmentTimers; + /** Time-zone tick period in ms (default 60 s). */ + intervalMs?: number; + /** IANA time-zone id; default `Intl.DateTimeFormat().resolvedOptions().timeZone`. */ + timeZoneId?: () => unknown; + /** UTC offset in minutes, positive east; default `-new Date().getTimezoneOffset()`. */ + utcOffsetMinutes?: () => unknown; + /** Permission names to query (default {@link DEFAULT_PERMISSION_NAMES}). */ + permissionNames?: readonly string[]; + /** Run the private-mode heuristic. Default false at THIS level (the browser launch turns it on, see the header). */ + detectPrivateMode?: boolean; + /** + * Read the `hints` stage. Default false: nothing in the SDK consumes it, so it is read only for a consumer that asks + * (no needless privacy surface). + */ + observeHints?: boolean; + /** + * Query the Permissions API for the `permissions` stage. Default false, for the same reason as `observeHints`: no + * `permissions.query()` call and no `change` listener unless a consumer asks. + */ + queryPermissions?: boolean; +} + +export const DEFAULT_PERMISSION_NAMES: readonly string[] = [ + 'camera', + 'microphone', + 'geolocation', + 'notifications', + 'idle-detection', +]; +const DEFAULT_INTERVAL_MS = 60_000; +/** A storage quota below this suggests a private window (Chromium gives incognito a small fixed quota). */ +export const PRIVATE_QUOTA_BELOW_BYTES = 120 * 1024 * 1024; +/** A storage quota at or above this suggests a normal window. Between the two the answer is `unknown`. */ +export const NORMAL_QUOTA_FROM_BYTES = 1024 * 1024 * 1024; +const PRIVATE_MODE_METHOD = 'storage-quota'; + +const globalScope = globalThis as unknown as { + setInterval(callback: () => void, ms: number): unknown; + clearInterval(handle: unknown): void; + Intl: { DateTimeFormat(): { resolvedOptions(): { timeZone?: unknown } } }; + Date: new () => { getTimezoneOffset(): number }; +}; +const defaultTimers: EnvironmentTimers = { + setInterval: (cb, ms) => globalScope.setInterval(cb, ms), + clearInterval: (h) => globalScope.clearInterval(h), +}; +const defaultTimeZoneId = (): unknown => + globalScope.Intl.DateTimeFormat().resolvedOptions().timeZone; +const defaultUtcOffset = (): unknown => -new globalScope.Date().getTimezoneOffset(); + +// Key-order-insensitive structural form (objects with sorted keys, arrays in order): two readings holding the same +// values are the same reading however their keys were assembled. +const canonical = (value: unknown): string => + JSON.stringify(value, (_key, v: unknown) => + typeof v === 'object' && v !== null && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0))) + : v, + ); +const same = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b); +const nonEmpty = (value: unknown): string | undefined => + typeof value === 'string' && value !== '' ? value : undefined; +const bool = (value: unknown): boolean | undefined => + typeof value === 'boolean' ? value : undefined; + +export class EnvironmentTracker extends InterceptorBase { + readonly name = 'browser-environment'; + #env: EnvironmentEnv; + /** Bumped on every attach/release: async work from an earlier attachment is ignored. */ + #epoch = 0; + /** Bumped when the private-mode probe is superseded, cancelled or turned off. */ + #privateEpoch = 0; + #running = false; + /** Retained across deactivation (see the retention rule). */ + #language: LanguageInfo | undefined; + #timeZone: TimeZoneInfo | undefined; + #hints: EnvironmentHints | undefined; + #permissions: PermissionStates | undefined; + #private: PrivateModeResult | undefined; + /** Failure streak flags (a failure is reported once, until a clean run). */ + #permissionsFailing = false; + #privateFailing = false; + /** Streaks of the synchronous stages ('language' | 'timezone' | 'hints' | 'visibility'): reported once each. */ + #failing = new Set(); + #listeners: Array<{ target: Partial; type: string; listener: () => void }> = []; + #timer: { handle: unknown; scheduler: EnvironmentTimers } | undefined; + #statuses: Array<{ status: Partial; listener: () => void }> = []; + + // Mutable indirection: the SHARED tracker outlives launches, so the reporter is always the latest launch's. + readonly #report: { fn: ((error: unknown) => void) | undefined }; + + constructor(env: EnvironmentEnv, onError?: (error: unknown) => void) { + const report = { fn: onError }; + // Guarded: a throwing reporter must not escape the emitter's subscriber isolation into a DOM dispatch. + super((error) => { + try { + report.fn?.(error); + } catch { + /* the reporter itself failed */ + } + }); + this.#report = report; + this.#env = env; + } + + /** + * Hand the latest launch's seams to this shared tracker (latest launch wins, but a PARTIAL env never drops what the + * tracker already has): every defined seam replaces ours, an undefined one keeps it; `detectPrivateMode` (a + * boolean) replaces whenever defined. A RUNNING tracker whose attached seams (window, navigator, document, + * scheduler, interval) changed first detaches with the OLD ones, swaps in place, then attaches to the new. + * Turning `detectPrivateMode` on while running probes right away; turning it off drops the result. + */ + rebind(env: EnvironmentEnv, onError?: (error: unknown) => void): void { + if (onError !== undefined) this.#report.fn = onError; + const old = this.#env; + const next = seamsOf(env, old, (e) => this.#onError(e)); + const moved = + next.window !== old.window || + next.navigator !== old.navigator || + next.document !== old.document || + next.scheduler !== old.scheduler || + next.intervalMs !== old.intervalMs; + const privateTurnedOn = next.detectPrivateMode === true && old.detectPrivateMode !== true; + const privateTurnedOff = next.detectPrivateMode !== true && old.detectPrivateMode === true; + const hintsChanged = (next.observeHints === true) !== (old.observeHints === true); + const permissionsChanged = (next.queryPermissions === true) !== (old.queryPermissions === true); + // A change of either gate re-attaches like a moved seam: off must drop the status listeners and in-flight queries. + const reattach = moved || hintsChanged || permissionsChanged; + if (reattach && this.#running) this.#release(); // OLD seams + this.#env = next; + if (next.observeHints !== true) this.#hints = undefined; + if (next.queryPermissions !== true) this.#permissions = undefined; + if (privateTurnedOff) { + this.#privateEpoch += 1; + this.#private = undefined; + } + if (!this.#running) return; + if (reattach) this.#attach(); + else if (privateTurnedOn) this.#detectPrivateMode(); + } + + #onError(error: unknown): void { + try { + this.#report.fn?.(error); + } catch { + /* the reporter itself failed */ + } + } + + /** The latest readings (each omitted until read). Retained after the last unsubscribe. */ + current(): EnvironmentSnapshot { + return { + ...(this.#language !== undefined ? { language: this.#language } : {}), + ...(this.#timeZone !== undefined ? { timeZone: this.#timeZone } : {}), + ...(this.#hints !== undefined ? { hints: this.#hints } : {}), + ...(this.#permissions !== undefined ? { permissions: this.#permissions } : {}), + ...(this.#private !== undefined ? { privateMode: this.#private } : {}), + }; + } + + /** Test/teardown: forget every reading, release listeners and timer, ignore anything in flight. */ + reset(): void { + this.removeAllListeners(); // drops every subscriber: the base deactivates, so a NEW subscriber re-activates us + this.#running = false; + this.#release(); + this.#language = undefined; + this.#timeZone = undefined; + this.#hints = undefined; + this.#permissions = undefined; + this.#private = undefined; + this.#permissionsFailing = false; + this.#privateFailing = false; + this.#failing.clear(); + } + + protected onActivate(): void { + this.#running = true; + this.#attach(); + } + + protected override onDeactivate(): void { + this.#running = false; + this.#release(); // readings are retained + } + + #attach(): void { + this.#epoch += 1; + this.#listen(asTarget(this.#env.window), 'languagechange', () => this.#publishLanguage()); + this.#listen(asTarget(this.#env.document), 'visibilitychange', () => { + if (this.#visibility() === 'visible') this.#refresh(); + }); + this.#arm(); + this.#refresh(); + this.#queryPermissions(); + this.#detectPrivateMode(); + } + + /** + * Runs one synchronous host read through a reporter that is quiet while the failure persists: the first failing run + * of a streak reports (every error of it), later failing runs say nothing, and a clean run ends the streak. + */ + #streak(key: string, read: (report: (error: unknown) => void) => T): T { + const errors: unknown[] = []; + const result = read((error) => void errors.push(error)); + if (errors.length === 0) this.#failing.delete(key); + else if (!this.#failing.has(key)) { + this.#failing.add(key); + for (const error of errors) this.#onError(error); + } + return result; + } + + #visibility(): unknown { + return this.#streak('visibility', (report) => + hostRead(() => this.#env.document?.visibilityState, report), + ); + } + + /** + * One host read of a multi-field reading: a read that THREW keeps the retained value of that field (a failing field + * must not turn into a fake change); one that merely found nothing (undefined) omits it. + */ + #field( + report: (error: unknown) => void, + read: () => T, + retained: T | undefined, + ): T | undefined { + let failed = false; + const value = hostRead(read, (error) => { + failed = true; + report(error); + }); + return failed ? retained : value; + } + + #listen(target: Partial | undefined, type: string, run: () => void): void { + if (target === undefined) return; + // The READ can throw too (a cross-origin WindowProxy): reported, and the others still attach. + const add = hostRead( + () => target.addEventListener, + (e) => this.#onError(e), + ); + if (typeof add !== 'function') return; + const listener = (): void => { + try { + run(); + } catch (error) /* v8 ignore next 3 -- defense in depth: every read and the emitter are already guarded */ { + this.#onError(error); + } + }; + // Recorded BEFORE attaching so a failing addEventListener still leaves a removable record. + this.#listeners.push({ target, type, listener }); + try { + add.call(target, type, listener); + } catch (error) { + this.#onError(error); + } + } + + #arm(): void { + try { + const scheduler = this.#env.scheduler ?? defaultTimers; + const handle = scheduler.setInterval(() => { + try { + // A hidden page is not re-read (visibility -> visible re-reads on return). + if (this.#visibility() !== 'hidden') this.#refresh(); + } catch (error) { + this.#onError(error); + } + }, this.#env.intervalMs ?? DEFAULT_INTERVAL_MS); + this.#timer = { handle, scheduler }; + } catch (error) { + this.#onError(error); + } + } + + /** Listeners, timer, status listeners; invalidates async work in flight. Best-effort throughout. */ + #release(): void { + this.#epoch += 1; + this.#privateEpoch += 1; + const listeners = this.#listeners; + this.#listeners = []; + for (const { target, type, listener } of listeners) { + try { + target.removeEventListener?.(type, listener); + } catch { + /* best-effort: one detached target must not leak the others */ + } + } + this.#releaseStatuses(); + const timer = this.#timer; + this.#timer = undefined; + if (timer !== undefined) { + try { + timer.scheduler.clearInterval(timer.handle); + } catch (error) { + this.#onError(error); + } + } + } + + #releaseStatuses(): void { + const statuses = this.#statuses; + this.#statuses = []; + for (const { status, listener } of statuses) { + try { + status.removeEventListener?.('change', listener); + } catch { + /* best-effort */ + } + } + } + + // ---- synchronous stages: language, timezone, hints ---- + + #refresh(): void { + this.#publishLanguage(); + this.#publishTimeZone(); + this.#publishHints(); + } + + #readLanguage(report: (error: unknown) => void): LanguageInfo | undefined { + const nav = this.#env.navigator as { language?: unknown; languages?: unknown } | undefined; + if (nav === undefined) return undefined; + const kept = this.#language; + const language = nonEmpty(this.#field(report, () => nav.language, kept?.language)); + const languages = this.#field( + report, + () => { + const raw = nav.languages; + return Array.isArray(raw) ? raw.filter((l) => nonEmpty(l) !== undefined) : undefined; + }, + kept?.languages, + ); + const info: LanguageInfo = { + ...(language !== undefined ? { language } : {}), + ...(languages !== undefined && languages.length > 0 ? { languages } : {}), + }; + return Object.keys(info).length === 0 ? undefined : info; + } + + #publishLanguage(): void { + const next = this.#streak('language', (report) => this.#readLanguage(report)); + if (next === undefined) return; // nothing readable: keep what we have, say nothing + const previous = this.#language; + this.#language = next; + if (previous !== undefined && !same(previous, next)) { + this.emit('language', { ...copyLanguage(next), previous: copyLanguage(previous) }); + } + } + + #readTimeZone(report: (error: unknown) => void): TimeZoneInfo | undefined { + const kept = this.#timeZone; + const timeZone = nonEmpty( + this.#field(report, this.#env.timeZoneId ?? defaultTimeZoneId, kept?.timeZone), + ); + const offset = this.#field( + report, + this.#env.utcOffsetMinutes ?? defaultUtcOffset, + kept?.utcOffsetMinutes, + ); + const utcOffsetMinutes = + typeof offset === 'number' && Number.isFinite(offset) ? offset + 0 : undefined; // -0 -> 0 + const info: TimeZoneInfo = { + ...(timeZone !== undefined ? { timeZone } : {}), + ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}), + }; + return Object.keys(info).length === 0 ? undefined : info; + } + + #publishTimeZone(): void { + const next = this.#streak('timezone', (report) => this.#readTimeZone(report)); + if (next === undefined) return; + const previous = this.#timeZone; + this.#timeZone = next; + if (previous !== undefined && !same(previous, next)) { + this.emit('timezone', { ...next, previous }); + } + } + + #readHints(report: (error: unknown) => void): EnvironmentHints | undefined { + const nav = this.#env.navigator as Record | undefined; + const win = this.#env.window as Record | undefined; + const kept = this.#hints; + // A read that threw keeps the retained value of its hint (see #field). + const flag = ( + source: Record | undefined, + key: string, + hint: keyof EnvironmentHints, + ): boolean | undefined => + bool(this.#field(report, () => source?.[key], kept?.[hint] as boolean | undefined)); + const cookiesEnabled = flag(nav, 'cookieEnabled', 'cookiesEnabled'); + const secureContext = flag(win, 'isSecureContext', 'secureContext'); + const crossOriginIsolated = flag(win, 'crossOriginIsolated', 'crossOriginIsolated'); + const automation = flag(nav, 'webdriver', 'automation'); + const globalPrivacyControl = flag(nav, 'globalPrivacyControl', 'globalPrivacyControl'); + const doNotTrack = this.#field( + report, + () => { + const dnt = nav?.doNotTrack; + return dnt === '1' || dnt === 'yes' + ? true + : dnt === '0' || dnt === 'no' + ? false + : undefined; + }, + kept?.doNotTrack, + ); + // Only a browser that HAS a service-worker container can say whether the page is controlled. + const serviceWorkerControlled = this.#field( + report, + () => { + const container = nav?.serviceWorker as { controller?: unknown } | undefined; + return typeof container === 'object' && container !== null + ? container.controller !== null && container.controller !== undefined + : undefined; + }, + kept?.serviceWorkerControlled, + ); + const hints: EnvironmentHints = { + ...(cookiesEnabled !== undefined ? { cookiesEnabled } : {}), + ...(secureContext !== undefined ? { secureContext } : {}), + ...(crossOriginIsolated !== undefined ? { crossOriginIsolated } : {}), + ...(serviceWorkerControlled !== undefined ? { serviceWorkerControlled } : {}), + ...(automation !== undefined ? { automation } : {}), + ...(doNotTrack !== undefined ? { doNotTrack } : {}), + ...(globalPrivacyControl !== undefined ? { globalPrivacyControl } : {}), + }; + return Object.keys(hints).length === 0 ? undefined : hints; + } + + #publishHints(): void { + if (this.#env.observeHints !== true) return; + const next = this.#streak('hints', (report) => this.#readHints(report)); + if (next === undefined) return; + const changed = !same(this.#hints, next); + this.#hints = next; + if (changed) this.emit('hints', { ...next }); + } + + // ---- permissions (never prompts) ---- + + #queryPermissions(): void { + if (this.#env.queryPermissions !== true) return; + const mine = this.#epoch; + const report = (e: unknown): void => this.#onError(e); + const permissions = hostRead( + () => (this.#env.navigator as { permissions?: unknown } | undefined)?.permissions, + report, + ) as { query?: (descriptor: { name: string }) => Promise } | undefined; + if (typeof permissions !== 'object' || permissions === null) return; + const query = hostRead(() => permissions.query, report); + if (typeof query !== 'function') return; + const names = this.#env.permissionNames ?? DEFAULT_PERMISSION_NAMES; + const errors: unknown[] = []; + const failedNames = new Set(); // names whose query FAILED (a fault): their retained state is kept + const fail = + (name: string) => + (error: unknown): undefined => { + // An unknown permission NAME rejects with TypeError: that is "unsupported here", not a fault. + if (hostRead(() => (error as { name?: unknown }).name) !== 'TypeError') { + errors.push(error); + failedNames.add(name); + } + return undefined; + }; + const ask = (name: string): Promise<{ name: string; status: unknown } | undefined> => { + try { + return Promise.resolve(query.call(permissions, { name })).then( + (status) => ({ name, status }), + fail(name), + ); + } catch (error) { + return Promise.resolve(fail(name)(error)); + } + }; + void Promise.all(names.map(ask)).then((results) => { + if (this.#epoch !== mine) return; // released or re-attached while in flight + if (errors.length === 0) this.#permissionsFailing = false; + else if (!this.#permissionsFailing) { + this.#permissionsFailing = true; + for (const error of errors) this.#onError(error); + } + const answered = results.filter( + (r): r is { name: string; status: unknown } => r !== undefined, + ); + for (const { name, status } of answered) this.#watchStatus(name, status, mine); + this.#publishPermissions(answered, failedNames); + }); + } + + /** `threw`: the read itself failed (keep the retained state); `state` undefined otherwise = unusable (omit). */ + #readState( + name: string, + status: unknown, + ): { state: PermissionStateName | undefined; threw: boolean } { + let threw = false; + const state = this.#streak(`permission:${name}`, (report) => + hostRead( + () => (status as { state?: unknown }).state, + (error) => { + threw = true; + report(error); + }, + ), + ); + return { + state: state === 'granted' || state === 'denied' || state === 'prompt' ? state : undefined, + threw, + }; + } + + #watchStatus(name: string, status: unknown, mine: number): void { + const target = asTarget(status); + if (target === undefined) return; + const listener = (): void => { + if (this.#epoch !== mine) return; + try { + const { state, threw } = this.#readState(name, status); + if (threw) return; // a failed read is not a change: keep what we have + const next: PermissionStates = { ...this.#permissions }; + if (state === undefined) + delete next[name]; // unusable now: omit, never keep a stale claim + else next[name] = state; + this.#commitPermissions(next); + } catch (error) { + this.#onError(error); // a DOM dispatch must never see a throw + } + }; + const add = hostRead( + () => target.addEventListener, + (e) => this.#onError(e), + ); + if (typeof add !== 'function') return; + this.#statuses.push({ status: target, listener }); + try { + add.call(target, 'change', listener); + } catch (error) { + this.#onError(error); + } + } + + #publishPermissions( + answered: Array<{ name: string; status: unknown }>, + failedNames: ReadonlySet, + ): void { + const next: PermissionStates = {}; + const kept = this.#permissions; + let unusable = false; // a status ANSWERED but its state is not usable: that name's stale claim must go + for (const { name, status } of answered) { + const { state, threw } = this.#readState(name, status); + const value = threw ? kept?.[name] : state; + if (value !== undefined) next[name] = value; + else if (!threw) unusable = true; + } + for (const name of failedNames) { + const value = kept?.[name]; // the query failed: keep the retained state of that name + if (value !== undefined) next[name] = value; + } + // Nothing usable because nothing ANSWERED (unsupported / every query failed): keep the retained snapshot, emit + // nothing. An answer that was unusable does replace it (the stale claim is cleared). + if (Object.keys(next).length === 0 && !unusable) return; + this.#commitPermissions(next); + } + + #commitPermissions(next: PermissionStates): void { + const changed = !same(this.#permissions, next); + this.#permissions = next; + if (changed) this.emit('permissions', { ...next }); + } + + // ---- private mode (heuristic; the launch runs it unless opted out) ---- + + #detectPrivateMode(): void { + if (this.#env.detectPrivateMode !== true) return; + // A retained yes/no is not probed again (it does not change within a page); an inconclusive one is retried. + if (this.#private?.private === 'yes' || this.#private?.private === 'no') return; + const mine = ++this.#privateEpoch; + const { storage, estimate } = this.#streak('private:storage', (report) => { + const found = hostRead( + () => (this.#env.navigator as { storage?: unknown } | undefined)?.storage, + report, + ) as { estimate?: () => Promise<{ quota?: unknown }> } | undefined; + return { + storage: found, + estimate: + typeof found === 'object' && found !== null + ? hostRead(() => found.estimate, report) + : undefined, + }; + }); + if (typeof estimate !== 'function') { + this.#settlePrivate({ private: 'unknown' }); + return; + } + let pending: Promise<{ quota?: unknown }>; + try { + pending = Promise.resolve(estimate.call(storage)); + } catch (error) { + pending = Promise.reject(error); + } + void pending.then( + (raw) => { + if (this.#privateEpoch !== mine) return; + this.#privateFailing = false; + const quota = this.#streak('private:quota', (report) => hostRead(() => raw?.quota, report)); + this.#settlePrivate(classifyQuota(quota)); + }, + (error: unknown) => { + if (this.#privateEpoch !== mine) return; + if (!this.#privateFailing) { + this.#privateFailing = true; + this.#onError(error); + } + this.#settlePrivate({ private: 'unknown' }); + }, + ); + } + + #settlePrivate(result: PrivateModeResult): void { + const changed = !same(this.#private, result); + this.#private = result; + if (changed) this.emit('privateMode', { ...result }); + } +} + +function classifyQuota(quota: unknown): PrivateModeResult { + if (typeof quota !== 'number' || !Number.isFinite(quota) || quota <= 0) { + return { private: 'unknown' }; + } + if (quota < PRIVATE_QUOTA_BELOW_BYTES) return { private: 'yes', method: PRIVATE_MODE_METHOD }; + if (quota >= NORMAL_QUOTA_FROM_BYTES) return { private: 'no', method: PRIVATE_MODE_METHOD }; + return { private: 'unknown', method: PRIVATE_MODE_METHOD }; +} + +// Copied on every payload: a kept (failed-read) field shares its array between the old and the new reading. +function copyLanguage(info: LanguageInfo): LanguageInfo { + return { ...info, ...(info.languages !== undefined ? { languages: [...info.languages] } : {}) }; +} + +function asTarget(value: unknown): Partial | undefined { + return typeof value === 'object' && value !== null ? (value as Partial) : undefined; +} + +/** `{[key]: next[key]}` when defined, else the previous value (if any), else nothing. */ +function pick( + next: EnvironmentEnv, + previous: EnvironmentEnv, + key: K, + onError?: (error: unknown) => void, +): Pick { + const value = hostRead(() => next[key], onError) ?? previous[key]; // a hostile env getter is reported, not thrown + return (value !== undefined ? { [key]: value } : {}) as Pick; +} + +/** Every seam of `env`, each falling back to `previous` when `env` has none. */ +function seamsOf( + env: EnvironmentEnv, + previous: EnvironmentEnv = {}, + onError?: (error: unknown) => void, +): EnvironmentEnv { + return { + ...pick(env, previous, 'window', onError), + ...pick(env, previous, 'navigator', onError), + ...pick(env, previous, 'document', onError), + ...pick(env, previous, 'scheduler', onError), + ...pick(env, previous, 'intervalMs', onError), + ...pick(env, previous, 'timeZoneId', onError), + ...pick(env, previous, 'utcOffsetMinutes', onError), + ...pick(env, previous, 'permissionNames', onError), + ...pick(env, previous, 'detectPrivateMode', onError), + ...pick(env, previous, 'observeHints', onError), + ...pick(env, previous, 'queryPermissions', onError), + }; +} + +const registry = createTrackerRegistry(); + +// The key is the window the SDK is bound to; failing that the navigator; failing that the env object itself (an +// ad-hoc env then gets a private tracker). Host reads are guarded AND reported. +function keyFrom(env: EnvironmentEnv, win: unknown, nav: unknown): object { + if (typeof win === 'object' && win !== null) return win; + if (typeof nav === 'object' && nav !== null) return nav; + return env; +} +function keyOf(env: EnvironmentEnv, onError?: (error: unknown) => void): object { + return keyFrom( + env, + hostRead(() => env.window, onError), + hostRead(() => env.navigator, onError), + ); +} + +/** + * The singleton EnvironmentTracker for the env's window. Every call (a relaunch included) hands the shared tracker + * the latest launch's `onError` and seams (see {@link EnvironmentTracker.rebind}). + */ +export function environmentTrackerFor( + env: EnvironmentEnv, + onError?: (error: unknown) => void, +): EnvironmentTracker { + // window/navigator are read ONCE (by seamsOf); the registry key is derived from those same readings. + const seams = seamsOf(env, {}, onError); + return registry.for( + keyFrom(env, seams.window, seams.navigator), + () => new EnvironmentTracker(seams, onError), + (tracker) => tracker.rebind(seams, onError), + ); +} + +/** Test/teardown: drop the env's tracker (resetting it, so its listeners and timer are released). */ +export function resetEnvironmentTracker(env: EnvironmentEnv): void { + registry.reset(keyOf(env)); +} diff --git a/packages/trackers/src/browser/index.ts b/packages/trackers/src/browser/index.ts index 01362b6c..ebf6efca 100644 --- a/packages/trackers/src/browser/index.ts +++ b/packages/trackers/src/browser/index.ts @@ -17,6 +17,24 @@ export { type NetworkInfo, resetConnectivityTracker, } from './connectivity-tracker'; +export { + DEFAULT_PERMISSION_NAMES, + type EnvironmentEnv, + type EnvironmentEvents, + type EnvironmentHints, + type EnvironmentSnapshot, + type EnvironmentTimers, + EnvironmentTracker, + environmentTrackerFor, + type LanguageInfo, + NORMAL_QUOTA_FROM_BYTES, + type PermissionStateName, + type PermissionStates, + PRIVATE_QUOTA_BELOW_BYTES, + type PrivateModeResult, + resetEnvironmentTracker, + type TimeZoneInfo, +} from './environment-tracker'; export { hostRead } from './host-read'; export { type BatteryEnv, diff --git a/packages/trackers/src/index.test.ts b/packages/trackers/src/index.test.ts index 0cf22db8..b34bac5f 100644 --- a/packages/trackers/src/index.test.ts +++ b/packages/trackers/src/index.test.ts @@ -21,17 +21,23 @@ describe('@bugsee/trackers public surface', () => { expect(Object.keys(await import('./browser/index')).sort()).toEqual([ 'ApplicationTracker', 'ConnectivityTracker', + 'DEFAULT_PERMISSION_NAMES', + 'EnvironmentTracker', + 'NORMAL_QUOTA_FROM_BYTES', + 'PRIVATE_QUOTA_BELOW_BYTES', 'PowerTracker', 'ProcessTracker', 'StorageTracker', 'UiTracker', 'applicationTrackerFor', 'connectivityTrackerFor', + 'environmentTrackerFor', 'hostRead', 'powerTrackerFor', 'processTrackerFor', 'resetApplicationTracker', 'resetConnectivityTracker', + 'resetEnvironmentTracker', 'resetPowerTracker', 'resetProcessTracker', 'resetStorageTracker', diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index fec95e25..c8d47fd5 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -1,4 +1,4 @@ -import { applicationTrackerFor, type WindowEvents } from '@bugsee/browser'; +import { applicationTrackerFor, environmentTrackerFor, type WindowEvents } from '@bugsee/browser'; import { BUGSEE_SDK_VERSION, BugseeStatus, @@ -178,6 +178,84 @@ describe('launch (webview) goes through the ApplicationTracker registry', () => off(); }); + it('shares the browser system-events source: launch goes through the per-window EnvironmentTracker (private mode off)', () => { + const win = fakeEventTarget(); + const { document } = blockedDoc(); + Object.assign(win.target, { navigator: { language: 'pl-PL' } }); + try { + track('tok', baseOptions({ window: win.target as unknown as WindowEvents, document })); + const tracker = environmentTrackerFor({ window: win.target as never }); + expect(tracker.current().language).toEqual({ language: 'pl-PL' }); + expect(tracker.current().privateMode).toBeUndefined(); + } finally { + environmentTrackerFor({ window: win.target as never }).reset(); + } + }); + + it('the environment tracker runs over the launch document and scheduler: slow tick re-reads the zone, hidden ticks and visibility changes follow the document', () => { + const win = fakeEventTarget(); + const doc = fakeEventTarget(); + const vis = { state: 'visible' }; + Object.defineProperty(doc.target, 'visibilityState', { get: () => vis.state }); + Object.assign(win.target, { navigator: { language: 'pl-PL' } }); + const ticks: Array<{ cb: () => void; ms: number }> = []; + const real = new Date().getTimezoneOffset(); + const offset = (minutesEast: number) => + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-minutesEast); + try { + track( + 'tok', + baseOptions({ + window: win.target as unknown as WindowEvents, + document: doc.target as unknown as Document, + scheduler: { + setInterval: (cb, ms) => { + ticks.push({ cb, ms }); + return ticks.length; + }, + clearInterval: () => {}, + }, + }), + ); + const tracker = environmentTrackerFor({ window: win.target as never }); + const slow = ticks.filter((t) => t.ms === 60_000); + expect(slow).toHaveLength(1); // the launch scheduler reached the tracker + expect(doc.count('visibilitychange')).toBeGreaterThan(0); // and its document + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0); + offset(-real + 90); + vis.state = 'hidden'; + slow[0]?.cb(); + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0); // hidden: tick skipped + doc.emit('visibilitychange', {}); // still hidden: no re-read + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0); + vis.state = 'visible'; + doc.emit('visibilitychange', {}); + expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 90); // visible: re-read + } finally { + vi.restoreAllMocks(); + environmentTrackerFor({ window: win.target as never }).reset(); + } + }); + + it('never turns private-mode detection on, even when an earlier launch on the same window had it on', async () => { + const win = fakeEventTarget(); + const { document } = blockedDoc(); + const estimate = vi.fn(() => Promise.resolve({ quota: 1 })); + const navigator = { language: 'en', storage: { estimate } }; + Object.assign(win.target, { navigator }); + try { + environmentTrackerFor({ window: win.target as never, navigator, detectPrivateMode: true }); + track('tok', baseOptions({ window: win.target as unknown as WindowEvents, document })); + await new Promise((r) => setTimeout(r, 0)); + expect(estimate).toHaveBeenCalled(); // the storage trace probes it; private mode must not be inferred + expect( + environmentTrackerFor({ window: win.target as never }).current().privateMode, + ).toBeUndefined(); + } finally { + environmentTrackerFor({ window: win.target as never }).reset(); + } + }); + it("the latest launch's onError becomes the reporter; a relaunch without one keeps it", () => { const win = fakeEventTarget(); const { doc, boom, state, document } = blockedDoc(); diff --git a/packages/webview/src/launch.ts b/packages/webview/src/launch.ts index fcfd09e3..ac11edcc 100644 --- a/packages/webview/src/launch.ts +++ b/packages/webview/src/launch.ts @@ -750,7 +750,10 @@ export function launchWithSeams( createSystemEventsProvider( createBrowserSystemEventsSource({ window: win, + ...(domDocument !== undefined ? { document: domDocument } : {}), lifecycle, + detectPrivateMode: false, // explicit: never inherit ON from an earlier launch on the same window + ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), ...(matchMedia !== undefined ? { matchMedia } : {}), ...(options.onError !== undefined ? { onError: options.onError } : {}), }),