diff --git a/.changeset/browser-process-tracker.md b/.changeset/browser-process-tracker.md new file mode 100644 index 00000000..75062274 --- /dev/null +++ b/.changeset/browser-process-tracker.md @@ -0,0 +1,7 @@ +--- +'@bugsee/browser': minor +'@bugsee/webview': minor +'@bugsee/trackers': minor +--- + +Browser and WebView system traces gain `main_thread_idle_ms` (the browser's granted idle period, 0-50 ms, from one `requestIdleCallback` probe per sampling tick on visible pages; omitted where `requestIdleCallback` is absent, and for a tick in which no idle period was granted) and `user_idle_state` (`active`, `idle` or `locked`, from the Idle Detection API). `user_idle_state` is OPT-IN: it is produced only with the new `captureUserIdle: true` launch option (browser and WebView; default false, only boolean `true` enables it, anything else stays off and is reported) AND when the page's `idle-detection` permission is already granted. The SDK never prompts for it, and a page's own grant is not consent to record and upload idle/locked state. The heap (`ram_js_heap_*`) and `main_thread_lag_ms` traces are unchanged but now come from one per-window `ProcessTracker` (new in `@bugsee/trackers/browser`), whose stages (`heap`, `mainThreadLag`, `idle`, `userIdle`, `uptime`) run only while they have listeners. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index af9a2c93..63c0be83 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). +- **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 (per-producer start/stop isolation, last-value refresh so a rolling window never loses a rarely-changing diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index adfe9235..6ead1e4d 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -66,6 +66,7 @@ export { export { type Bugsee, type BugseeLaunchOptions, + CAPTURE_USER_IDLE_OPTION_KEY, getStatus, type LaunchInternals, type LaunchResult, diff --git a/packages/browser/src/launch-options.invalid.test.ts b/packages/browser/src/launch-options.invalid.test.ts index 14b9c2fc..be4a7693 100644 --- a/packages/browser/src/launch-options.invalid.test.ts +++ b/packages/browser/src/launch-options.invalid.test.ts @@ -4,14 +4,19 @@ import { createMemoryCaptureStore, type HttpResponse, type HttpTransport } from import { BugseeOption } from '@bugsee/protocol'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BrowserProbe } from './environment'; -import { type Bugsee, type BugseeLaunchOptions, launch } from './launch'; +import { + type Bugsee, + type BugseeLaunchOptions, + CAPTURE_USER_IDLE_OPTION_KEY, + launch, +} from './launch'; // Every browser-owned boolean launch option, every kind of invalid value. Read by plain // truthiness these used to be wrong in BOTH directions: `persistUserIdentifier: 'false'` persisted PII, // `captureInteractions: 0` kept capturing input. The rules, per option: // captureInteractions, captureViewHierarchy (default ON, capture) -> invalid = OFF (fail-closed) + onError // startInBlackout (default OFF, but ON is the safe side) -> invalid = ON (fail-on) + onError -// persistUserIdentifier, report* UI flags (default OFF) -> only boolean true turns on +// persistUserIdentifier, captureUserIdle, report* UI flags (default OFF) -> only boolean true turns on const probe: BrowserProbe = { userAgent: () => 'Mozilla/5.0 Chrome/119.0.0.0 Safari/537.36', @@ -68,6 +73,7 @@ const failClosed: Array<[string, string]> = [ ]; const defaultOff: Array<[string, string]> = [ ['persistUserIdentifier', BugseeOption.PersistUserIdentifier], + ['captureUserIdle', CAPTURE_USER_IDLE_OPTION_KEY], ['reportSummaryRequired', BugseeOption.ReportingUiSummaryRequired], ['reportDescriptionRequired', BugseeOption.ReportingUiDescriptionRequired], ['reportEmailRequired', BugseeOption.ReportingUiEmailRequired], diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index e4abd27d..362b8ba8 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -45,6 +45,8 @@ import { import { applicationTrackerFor, connectivityTrackerFor, + processTrackerFor, + resetProcessTracker, resetStorageTracker, resetUiTracker, storageTrackerFor, @@ -1434,6 +1436,84 @@ describe('launch', () => { } }); + it('captures the heap through the per-window ProcessTracker singleton (launch uses the registry)', async () => { + stubSensors(); + const w = fakeWindow(); + try { + const store = memStore(); + await launchTracked('tok', noSampler({ window: w.win, captureStore: store })); + const rows = new Map( + ((await drain(store, 'traces.system')) ?? []).map((e) => { + const d = e.data as { name: string; value: unknown }; + return [d.name, d.value]; + }), + ); + expect(rows.get('ram_js_heap_used')).toBe(10); + // launch used the registry: the singleton for this window already has launch's performance seam, so it can read + // the heap (a tracker built with `new` would be a different instance with no seams) + expect(processTrackerFor({ window: w.win }).currentHeap()).toEqual({ + used: 10, + limit: 100, + }); + } finally { + resetProcessTracker({ window: w.win }); + } + }); + + describe('user_idle_state is opt-in (captureUserIdle) and needs an already-granted permission', () => { + const launchIdle = async (over: Partial, permission: string) => { + stubSensors(); + const query = vi.fn(() => Promise.resolve({ state: permission })); + vi.stubGlobal('navigator', { onLine: true, permissions: { query } }); + const w = fakeWindow(); + class FakeDetector { + userState = 'idle'; + screenState = 'unlocked'; + start = () => Promise.resolve(undefined); + addEventListener = () => {}; + removeEventListener = () => {}; + } + Object.assign(w.win, { + IdleDetector: FakeDetector, + AbortController: class { + signal = {}; + abort() {} + }, + }); + try { + const store = memStore(); + await launchTracked('tok', noSampler({ window: w.win, captureStore: store, ...over })); + await new Promise((r) => setTimeout(r, 0)); + const rows = new Map( + ((await drain(store, 'traces.system')) ?? []).map((e) => { + const d = e.data as { name: string; value: unknown }; + return [d.name, d.value]; + }), + ); + return { rows, query }; + } finally { + resetProcessTracker({ window: w.win }); + } + }; + + it('default: no user_idle_state and the permission is never even queried, though it is granted', async () => { + const { rows, query } = await launchIdle({}, 'granted'); + expect(rows.has('user_idle_state')).toBe(false); + expect(query).not.toHaveBeenCalled(); + }); + + it('captureUserIdle:true + permission already granted: user_idle_state is captured', async () => { + const { rows } = await launchIdle({ captureUserIdle: true }, 'granted'); + expect(rows.get('user_idle_state')).toBe('idle'); + }); + + it('captureUserIdle:true but permission not granted: no trace (the SDK never prompts)', async () => { + const { rows, query } = await launchIdle({ captureUserIdle: true }, 'prompt'); + expect(query).toHaveBeenCalled(); + expect(rows.has('user_idle_state')).toBe(false); + }); + }); + 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 1f8909c2..75473da2 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -131,6 +131,11 @@ const DEFAULT_MAX_DATA_SIZE_MB = 10; const REPLAY_OPTION_KEY = 'com.bugsee.option.replay.enabled'; /** Canonical identifier of the (browser-local) `persist` switch: no cross-SDK identity, so it lives here. */ const PERSIST_OPTION_KEY = 'com.bugsee.option.browser.persist'; +/** + * Canonical identifier of the (browser-local) `captureUserIdle` switch (no cross-SDK identity). Exported so the + * WebView launch, which shares the producers, declares the very same option. + */ +export const CAPTURE_USER_IDLE_OPTION_KEY = 'com.bugsee.option.browser.capture-user-idle'; /** Whether the page exposes an IndexedDB object; a throwing getter (sandboxed frames) counts as absent. */ const indexedDbPresent = (): boolean => { @@ -237,6 +242,14 @@ const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ default: false, ...BOOLEAN_OPTION, }, + // `user_idle_state` is behavioural data (active / idle / screen locked), so it is OPT-IN: only boolean `true` + // enables it, anything else stays OFF (and an invalid value is reported). + { + friendly: 'captureUserIdle', + key: CAPTURE_USER_IDLE_OPTION_KEY, + default: false, + ...BOOLEAN_OPTION, + }, ]; /** Session-replay options (a structural subset of `@bugsee/replay`'s options — no runtime dep here; replay @@ -358,6 +371,13 @@ export interface BugseeLaunchOptions { * the clear applies to reports created afterwards. */ persistUserIdentifier?: 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 + * true the trace exists only if the page's permission is ALREADY `granted`: the SDK never prompts for it. + * Only boolean `true` enables it; any other value stays off and is reported. Needs `captureSystemTraces`. + */ + captureUserIdle?: boolean; /** Detect window errors + unhandled rejections. Default true. */ detectCrashes?: boolean; /** Report HTTP SERVER errors (500-599) as `error` reports, once per session per (method, templated URL, status); 4xx, transport failures and aborts are not reported (mobile parity). Default false. */ @@ -894,6 +914,7 @@ async function buildLaunch( // Every surface comes from the INJECTED window/document, never the host globals. ...browserTraceEnvFrom(win, domDocument, options.onError), ...(options.systemMetricsSampler !== undefined ? { sensors: false } : {}), + captureUserIdle: resolved.options.get(CAPTURE_USER_IDLE_OPTION_KEY, false), lifecycle, ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }, diff --git a/packages/browser/src/trace-producers.process.test.ts b/packages/browser/src/trace-producers.process.test.ts new file mode 100644 index 00000000..c8b8b100 --- /dev/null +++ b/packages/browser/src/trace-producers.process.test.ts @@ -0,0 +1,492 @@ +import type { TraceProducer } from '@bugsee/capture'; +import { + ApplicationTracker, + ProcessTracker, + processTrackerFor, + resetProcessTracker, + resetUiTracker, + uiTrackerFor, +} from '@bugsee/trackers/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { + browserTraceEnvFrom, + createBrowserSystemTraceProducers, + MainThreadIdleProducer, + UserIdleProducer, +} from './trace-producers'; + +type Row = { name: string; value: unknown; hold: boolean | undefined }; +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + +function start(p: TraceProducer) { + const out: Row[] = []; + p.start({ + emit: (name: string, value: unknown, _d?: number, o?: { hold?: boolean }) => + void out.push({ name, value, hold: o?.hold }), + }); + return out; +} + +function mkTarget() { + const l = new Map void>>(); + return { + addEventListener: (t: string, f: (e: unknown) => void) => { + const s = l.get(t) ?? new Set<(e: unknown) => void>(); + s.add(f); + l.set(t, s); + }, + removeEventListener: (t: string, f: (e: unknown) => void) => void l.get(t)?.delete(f), + }; +} + +function mkDetector() { + const listeners = new Set<() => void>(); + const d = { + userState: 'active' as unknown, + screenState: 'unlocked' as unknown, + start: () => Promise.resolve(undefined as unknown), + addEventListener: (_t: string, l: () => void) => void listeners.add(l), + removeEventListener: (_t: string, l: () => void) => void listeners.delete(l), + fire: () => + listeners.forEach((l) => { + l(); + }), + }; + return d; +} + +function userIdleTracker( + detector: ReturnType, + state = 'granted', + now?: () => number, + permission?: { state: string }, + probe?: { statusListeners: Set<() => void>; aborts: number }, +) { + class FakeDetector { + constructor() { + // biome-ignore lint/correctness/noConstructorReturn: a test double hands the shared fake detector back from `new`. + return detector as unknown as FakeDetector; + } + } + return new ProcessTracker({ + navigator: { + permissions: { + query: () => + Promise.resolve({ + state: permission?.state ?? state, + addEventListener: (_t: string, l: () => void) => void probe?.statusListeners.add(l), + removeEventListener: (_t: string, l: () => void) => + void probe?.statusListeners.delete(l), + }), + }, + }, + IdleDetector: FakeDetector as never, + AbortController: class { + signal = {}; + abort() { + if (probe !== undefined) probe.aborts += 1; + } + }, + ...(now !== undefined ? { now } : {}), + }); +} + +describe('UserIdleProducer (user_idle_state)', () => { + it('maps userState to a string; a locked screen wins; held and de-duplicated', async () => { + const detector = mkDetector(); + const out = start(new UserIdleProducer(userIdleTracker(detector))); + await flush(); + expect(out).toEqual([{ name: 'user_idle_state', value: 'active', hold: true }]); + detector.fire(); // unchanged + detector.userState = 'idle'; + detector.fire(); + detector.screenState = 'locked'; + detector.fire(); + detector.userState = 'active'; // locked still wins + detector.fire(); + detector.screenState = 'unlocked'; + detector.fire(); + expect(out.map((r) => r.value)).toEqual(['active', 'idle', 'locked', 'active']); + }); + + it('a state without a screenState is just the userState', async () => { + const detector = mkDetector(); + detector.screenState = undefined; + detector.userState = 'idle'; + const out = start(new UserIdleProducer(userIdleTracker(detector))); + await flush(); + expect(out.map((r) => r.value)).toEqual(['idle']); + }); + + it('produces NO trace unless the permission is already granted', async () => { + for (const state of ['prompt', 'denied']) { + const out = start(new UserIdleProducer(userIdleTracker(mkDetector(), state))); + await flush(); + expect(out).toEqual([]); + } + }); + + it('seeds from the retained state and releases on stop', async () => { + const detector = mkDetector(); + const tracker = userIdleTracker(detector); + const offOther = tracker.on('userIdle', () => {}); + await flush(); + const p = new UserIdleProducer(tracker); + const out = start(p); + expect(out.map((r) => r.value)).toEqual(['active']); // immediate, from currentUserIdle() + p.stop(); + detector.userState = 'idle'; + detector.fire(); + expect(out).toHaveLength(1); + offOther(); + }); +}); + +describe('UserIdleProducer stop releases the stage', () => { + it('stop() unsubscribes: detector aborted, permission listener removed, no growth over start/stop cycles', async () => { + const detector = mkDetector(); + const probe = { statusListeners: new Set<() => void>(), aborts: 0 }; + const tracker = userIdleTracker(detector, 'granted', undefined, undefined, probe); + const p = new UserIdleProducer(tracker); + for (let cycle = 1; cycle <= 3; cycle += 1) { + const out = start(p); + await flush(); + expect(probe.statusListeners.size).toBe(1); // armed: exactly one permission listener, never accumulating + expect(out.length).toBeGreaterThan(0); + p.stop(); + expect(probe.statusListeners.size).toBe(0); + expect(probe.aborts).toBe(cycle); // the detector of THIS cycle was aborted + const seen = out.length; + detector.userState = cycle % 2 === 0 ? 'active' : 'idle'; + detector.fire(); // a stopped producer hears nothing + expect(out).toHaveLength(seen); + } + }); +}); + +describe('UserIdleProducer seeding', () => { + it('does not seed from a retained state while the permission re-query is pending; the live detector then reports it', async () => { + const detector = mkDetector(); + const tracker = userIdleTracker(detector); + const offOther = tracker.on('userIdle', () => {}); + await flush(); + offOther(); // the state is retained, the detector is released + const p = new UserIdleProducer(tracker); + const out = start(p); + expect(out).toEqual([]); // the retained 'active' is NOT trusted before the permission is re-confirmed + await flush(); + expect(out.map((r) => r.value)).toEqual(['active']); // the re-confirmed detector reports it + p.stop(); + }); + + it('a permission revoked while no stage was armed never seeds the held trace', async () => { + const detector = mkDetector(); + const permission = { state: 'granted' }; + const tracker = userIdleTracker(detector, 'granted', undefined, permission); + const offOther = tracker.on('userIdle', () => {}); + await flush(); + offOther(); + permission.state = 'denied'; // revoked while nobody listens + const p = new UserIdleProducer(tracker); + const out = start(p); + expect(out).toEqual([]); + await flush(); + expect(out).toEqual([]); // and the re-query dropped the snapshot: nothing is ever published + expect(tracker.currentUserIdle()).toBeUndefined(); + p.stop(); + }); +}); + +describe('MainThreadIdleProducer (main_thread_idle_ms)', () => { + it('emits each granted idle period (polled, not held) and releases on stop', () => { + const doc = { visibilityState: 'visible' }; + const lifecycle = new ApplicationTracker({ window: mkTarget(), document: mkTarget() }); + let tick: () => void = () => {}; + let grant: ((d: { timeRemaining(): number }) => void) | undefined; + const clear = vi.fn(); + const tracker = new ProcessTracker({ + lifecycle, + document: doc, + requestIdleCallback: (cb) => { + grant = cb; + return 1; + }, + scheduler: { + setInterval: (cb) => { + tick = cb; + return 9; + }, + clearInterval: clear, + }, + }); + const p = new MainThreadIdleProducer(tracker); + const out = start(p); + expect(out).toEqual([]); + tick(); + grant?.({ timeRemaining: () => 42 }); + expect(out).toEqual([{ name: 'main_thread_idle_ms', value: 42, hold: false }]); + p.stop(); + expect(clear).toHaveBeenCalledWith(9); + }); +}); + +describe('createBrowserSystemTraceProducers: process traces', () => { + it('builds main-thread-idle with a lifecycle (like lag/fps); user-idle ONLY on opt-in; none under sensors:false', () => { + const lifecycle = new ApplicationTracker({ window: mkTarget() }); + const names = (e: Parameters[0]) => + createBrowserSystemTraceProducers(e).map((p) => p.name); + expect(names({ lifecycle })).toEqual( + expect.arrayContaining(['main-thread-lag', 'main-thread-idle']), + ); + // user_idle_state is behavioural data: OFF unless the caller opts in with exactly `true` (the SDK never + // prompts, and a host page's own idle-detection grant is not consent to record it) + expect(names({ lifecycle })).not.toContain('user-idle'); + expect(names({})).not.toContain('user-idle'); + expect(names({ captureUserIdle: false })).not.toContain('user-idle'); + expect(names({ captureUserIdle: 'true' as never })).not.toContain('user-idle'); + expect(names({ captureUserIdle: true })).toContain('user-idle'); + expect(names({ lifecycle, captureUserIdle: true })).toContain('user-idle'); + expect(names({ lifecycle, captureUserIdle: true, sensors: false })).toEqual(['battery']); + expect(names({})).not.toEqual(expect.arrayContaining(['main-thread-idle'])); + expect(names({ lifecycle, sensors: false })).toEqual(['battery']); + }); + + it('browserTraceEnvFrom hands back the SAME bound closures for the same window (a no-op relaunch stays a no-op), distinct ones per window', () => { + const mk = () => ({ + requestIdleCallback: () => 1, + cancelIdleCallback: () => {}, + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + }); + const win = mk(); + const a = browserTraceEnvFrom(win, undefined); + const b = browserTraceEnvFrom(win, undefined); + expect(b.requestIdleCallback).toBe(a.requestIdleCallback); + expect(b.cancelIdleCallback).toBe(a.cancelIdleCallback); + expect(b.requestAnimationFrame).toBe(a.requestAnimationFrame); + expect(b.cancelAnimationFrame).toBe(a.cancelAnimationFrame); + const other = browserTraceEnvFrom(mk(), undefined); + expect(other.requestIdleCallback).not.toBe(a.requestIdleCallback); + // the same function on a DIFFERENT window is bound to that window + const shared = function (this: unknown) { + return this; + }; + const w1 = { requestIdleCallback: shared }; + const w2 = { requestIdleCallback: shared }; + const r1 = browserTraceEnvFrom(w1, undefined).requestIdleCallback as unknown as () => unknown; + const r2 = browserTraceEnvFrom(w2, undefined).requestIdleCallback as unknown as () => unknown; + expect(r1()).toBe(w1); + expect(r2()).toBe(w2); + }); + + it('browserTraceEnvFrom binds requestIdleCallback/cancelIdleCallback to the window and passes IdleDetector/AbortController', () => { + const calls: string[] = []; + const win = { + requestIdleCallback(this: unknown, cb: () => void): number { + calls.push(this === win ? 'ric:window' : 'ric:other'); + cb(); + return 3; + }, + cancelIdleCallback(this: unknown, h: number): void { + calls.push(this === win ? `cic:window:${h}` : 'cic:other'); + }, + IdleDetector: class {}, + AbortController: class {}, + }; + const env = browserTraceEnvFrom(win, undefined); + const { requestIdleCallback: ric, cancelIdleCallback: cic } = env; + expect(ric?.(() => {})).toBe(3); + cic?.(3); + expect(calls).toEqual(['ric:window', 'cic:window:3']); + expect(env.IdleDetector).toBe(win.IdleDetector); + expect(env.AbortController).toBe(win.AbortController); + // absent surfaces stay absent; hostile getters are reported, never thrown + const bare = browserTraceEnvFrom({}, undefined); + expect(bare.requestIdleCallback).toBeUndefined(); + expect(bare.IdleDetector).toBeUndefined(); + const errors: unknown[] = []; + const hostile = {}; + Object.defineProperty(hostile, 'IdleDetector', { + get(): never { + throw new Error('blocked'); + }, + }); + expect(browserTraceEnvFrom(hostile, undefined, (e) => errors.push(e)).IdleDetector).toBe( + undefined, + ); + expect(errors).toHaveLength(1); + }); + + it('passes requestIdleCallback and cancelIdleCallback through: the probe is cancelled on stop', () => { + const win = mkTarget(); + const lifecycle = new ApplicationTracker({ window: win }); + let tick: () => void = () => {}; + const cancel = vi.fn(); + const w = {}; + const ps = createBrowserSystemTraceProducers({ + window: w as never, + document: { visibilityState: 'visible' }, + lifecycle, + requestIdleCallback: () => 11, + cancelIdleCallback: cancel, + scheduler: { + setInterval: (cb) => { + tick = cb; + return 1; + }, + clearInterval: () => {}, + }, + }); + try { + const idle = ps.find((p) => p.name === 'main-thread-idle') as TraceProducer; + start(idle); + tick(); + idle.stop(); + expect(cancel).toHaveBeenCalledWith(11); + } finally { + resetProcessTracker({ window: w }); + } + }); + + it('the merged factory wires ui traces on the UiTracker and process traces on the ProcessTracker, each producer once', () => { + const win = Object.assign(mkTarget(), { + performance: { now: () => 1 }, + screen: { width: 1, height: 2 }, + devicePixelRatio: 1, + }); + const lifecycle = new ApplicationTracker({ window: win }); + try { + const ps = createBrowserSystemTraceProducers({ + ...browserTraceEnvFrom(win, undefined), + window: win, + lifecycle, + captureUserIdle: true, + }); + const names = ps.map((p) => p.name); + expect(new Set(names).size).toBe(names.length); // no producer twice (e.g. a second main-thread-lag) + expect(names).toEqual( + expect.arrayContaining([ + 'memory', + 'user-idle', + 'main-thread-lag', + 'main-thread-idle', + 'orientation', + 'screen-size', + 'fps', + ]), + ); + expect(names.filter((n) => n === 'main-thread-lag')).toHaveLength(1); + // both domain trackers are per-window singletons + expect(uiTrackerFor({ window: win })).toBe(uiTrackerFor({ window: win })); + expect(processTrackerFor({ window: win })).toBe(processTrackerFor({ window: win })); + } finally { + resetProcessTracker({ window: win }); + resetUiTracker({ window: win }); + } + }); + + it('all process traces share ONE tracker per window and the env seams reach it (idle + user idle)', async () => { + const win = Object.assign(mkTarget(), { + performance: { + memory: { usedJSHeapSize: 1, totalJSHeapSize: 2, jsHeapSizeLimit: 3 }, + now: () => 1, + }, + }); + const detector = mkDetector(); + class FakeDetector { + constructor() { + // biome-ignore lint/correctness/noConstructorReturn: a test double hands the shared fake detector back from `new`. + return detector as unknown as FakeDetector; + } + } + const lifecycle = new ApplicationTracker({ window: win }); + try { + const ps = createBrowserSystemTraceProducers({ + ...browserTraceEnvFrom(win, undefined), + window: win, + lifecycle, + captureUserIdle: true, + navigator: { permissions: { query: () => Promise.resolve({ state: 'granted' }) } }, + IdleDetector: FakeDetector as never, + AbortController: class { + signal = {}; + abort() {} + }, + }); + const userIdle = ps.find((p) => p.name === 'user-idle') as TraceProducer; + const memory = ps.find((p) => p.name === 'memory') as TraceProducer; + const outUser = start(userIdle); + const outMem = start(memory); + await flush(); + expect(outUser.map((r) => r.value)).toEqual(['active']); + expect(outMem.map((r) => r.name)).toEqual([ + 'ram_js_heap_total', + 'ram_js_heap_free', + 'ram_js_heap_used', + ]); + // ONE tracker serves both producers: the window's registry instance saw the user-idle subscription (it retained + // the state) AND carries the heap seam, and a second lookup returns that same object. + const shared = processTrackerFor({ window: win }); + expect(processTrackerFor({ window: win })).toBe(shared); + expect(shared.currentUserIdle()).toEqual({ userState: 'active', screenState: 'unlocked' }); + expect(shared.currentHeap()).toEqual({ used: 1, limit: 3 }); + } finally { + resetProcessTracker({ window: win }); + } + }); +}); + +describe('user_idle_state opt-in x permission (end to end through the factory)', () => { + const run = async (env: { captureUserIdle?: boolean }, state: string) => { + const win = Object.assign(mkTarget(), { performance: { now: () => 1 } }); + const detector = mkDetector(); + class FakeDetector { + constructor() { + // biome-ignore lint/correctness/noConstructorReturn: a test double hands the shared fake detector back from `new`. + return detector as unknown as FakeDetector; + } + } + const query = vi.fn(() => Promise.resolve({ state })); + try { + const ps = createBrowserSystemTraceProducers({ + ...browserTraceEnvFrom(win, undefined), + window: win, + ...env, + navigator: { permissions: { query } }, + IdleDetector: FakeDetector as never, + AbortController: class { + signal = {}; + abort() {} + }, + }); + const producer = ps.find((p) => p.name === 'user-idle'); + const out = producer === undefined ? [] : start(producer); + await flush(); + return { out, query, hasProducer: producer !== undefined }; + } finally { + resetProcessTracker({ window: win }); + } + }; + + it('off by default: no producer, no permission query, no trace, even with the permission granted', async () => { + const r = await run({}, 'granted'); + expect(r.hasProducer).toBe(false); + expect(r.query).not.toHaveBeenCalled(); + expect(r.out).toEqual([]); + }); + + it('opted in and already granted: the trace is produced', async () => { + const r = await run({ captureUserIdle: true }, 'granted'); + expect(r.out.map((x) => x.value)).toEqual(['active']); + }); + + it.each([ + 'prompt', + 'denied', + ])('opted in but permission %s: no trace (the SDK never prompts)', async (state) => { + const r = await run({ captureUserIdle: true }, state); + expect(r.hasProducer).toBe(true); + expect(r.query).toHaveBeenCalled(); + expect(r.out).toEqual([]); + }); +}); diff --git a/packages/browser/src/trace-producers.screen-lag.test.ts b/packages/browser/src/trace-producers.screen-lag.test.ts index 38c5b61d..5f4b2323 100644 --- a/packages/browser/src/trace-producers.screen-lag.test.ts +++ b/packages/browser/src/trace-producers.screen-lag.test.ts @@ -1,5 +1,5 @@ import type { TraceProducer } from '@bugsee/capture'; -import { ApplicationTracker, UiTracker } from '@bugsee/trackers/browser'; +import { ApplicationTracker, ProcessTracker, UiTracker } from '@bugsee/trackers/browser'; import { describe, expect, it } from 'vitest'; import { browserTraceEnvFrom, @@ -7,7 +7,6 @@ import { MainThreadLagProducer, ScreenSizeProducer, } from './trace-producers'; -import { MainThreadLagTracker } from './trackers/main-thread-lag-tracker'; function mkTarget() { const l = new Map void>>(); @@ -89,10 +88,10 @@ describe('MainThreadLagProducer', () => { const lifecycle = new ApplicationTracker({ window: win, document: doc }); let timer: (() => void) | undefined; let now = 0; - const tracker = new MainThreadLagTracker({ + const tracker = new ProcessTracker({ lifecycle, document: doc, - now: () => now, + performance: { now: () => now }, scheduler: { setInterval: (cb) => { timer = cb; @@ -274,10 +273,23 @@ describe('performance.now wiring (lag and fps)', () => { }, }); const errors: unknown[] = []; - createBrowserSystemTraceProducers({ lifecycle, document: doc, performance: perf }, (e) => - errors.push(e), + const ps = createBrowserSystemTraceProducers( + { lifecycle, document: doc, performance: perf }, + (e) => errors.push(e), ); - expect(errors.map((e) => (e as Error).message)).toEqual(['now blocked']); + const out: Emit[] = []; + // The factory reads silently; the tracker reports the persistent failure ONCE (not once per activation: errors used + // to grow 1,2,3 across start/stop/restart), and nothing is reported before a lag/uptime stage activates. + expect(errors).toEqual([]); + const rounds: number[] = []; + for (let round = 0; round < 3; round += 1) { + for (const p of ps) p.start(sinkOf(out)); + for (const p of ps) p.stop(); + rounds.push(errors.length); + } + expect(rounds).toEqual([1, 1, 1]); + expect(errors.every((e) => (e as Error).message === 'now blocked')).toBe(true); + expect(out.filter((o) => o.name === 'main_thread_lag_ms')).toEqual([]); }); }); diff --git a/packages/browser/src/trace-producers.sensors.test.ts b/packages/browser/src/trace-producers.sensors.test.ts index 893febd0..717e6eee 100644 --- a/packages/browser/src/trace-producers.sensors.test.ts +++ b/packages/browser/src/trace-producers.sensors.test.ts @@ -1,5 +1,5 @@ import type { TraceProducer } from '@bugsee/capture'; -import { ConnectivityTracker, UiTracker } from '@bugsee/trackers/browser'; +import { ConnectivityTracker, ProcessTracker, UiTracker } from '@bugsee/trackers/browser'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AdvertisedMemoryProducer, @@ -8,7 +8,6 @@ import { MemoryProducer, OrientationProducer, } from './trace-producers'; -import { MemoryTracker } from './trackers/memory-tracker'; type Row = { name: string; value: unknown }; const startAll = (producers: readonly TraceProducer[]) => { @@ -65,6 +64,18 @@ const heap = (used: number, limit: number) => ({ afterEach(() => vi.unstubAllGlobals()); +/** A ProcessTracker whose `performance.memory` is the live getter `memory()`. */ +function mkProcess(memory: () => unknown, scheduler?: ReturnType) { + return new ProcessTracker({ + ...(scheduler !== undefined ? { scheduler } : {}), + performance: { + get memory() { + return memory() as never; + }, + }, + }); +} + describe('MemoryProducer', () => { it('emits the GROUPED ram_js_heap_* series (limit as total, free = limit - used) as a baseline, then every tick', () => { // `total` is the heap CEILING (jsHeapSizeLimit) and `free` is total - used — Android's ram_jvm_heap @@ -72,7 +83,7 @@ describe('MemoryProducer', () => { // the heap grew; and no fourth `committed` series, which would break the stacked area. const s = mkScheduler(); let used = 1; - const p = new MemoryProducer(new MemoryTracker({ memory: () => heap(used, 3), scheduler: s })); + const p = new MemoryProducer(mkProcess(() => heap(used, 3), s)); const { out, stop } = startAll([p]); expect(out).toEqual([ { name: 'ram_js_heap_total', value: 3 }, @@ -92,7 +103,7 @@ describe('MemoryProducer', () => { it('emits through the unconditional (polled) path: an unchanged reading is still emitted each tick', () => { const s = mkScheduler(); - const p = new MemoryProducer(new MemoryTracker({ memory: () => heap(1, 3), scheduler: s })); + const p = new MemoryProducer(mkProcess(() => heap(1, 3), s)); const { out } = startAll([p]); s.tick(); expect(out).toHaveLength(6); @@ -100,17 +111,15 @@ describe('MemoryProducer', () => { it('emits nothing and starts no timer where the engine has no performance.memory', () => { const s = mkScheduler(); - const { out } = startAll([ - new MemoryProducer(new MemoryTracker({ memory: () => undefined, scheduler: s })), - ]); + const { out } = startAll([new MemoryProducer(mkProcess(() => undefined, s))]); expect(out).toEqual([]); expect(s.live()).toBe(0); }); it('never emits the retired browser_memory_* names', () => { - const names = startAll([ - new MemoryProducer(new MemoryTracker({ memory: () => heap(1, 3) })), - ]).out.map((r) => r.name); + const names = startAll([new MemoryProducer(mkProcess(() => heap(1, 3)))]).out.map( + (r) => r.name, + ); expect(names.filter((n) => n.startsWith('browser_memory'))).toEqual([]); }); }); diff --git a/packages/browser/src/trace-producers.ts b/packages/browser/src/trace-producers.ts index bc0c9c9d..11190d25 100644 --- a/packages/browser/src/trace-producers.ts +++ b/packages/browser/src/trace-producers.ts @@ -9,9 +9,14 @@ import { type ConnectivityState, type ConnectivityTracker, connectivityTrackerFor, + type HeapReading, + type HeapState, type OrientationState, type PowerTracker, + type ProcessEnv, + type ProcessTracker, powerTrackerFor, + processTrackerFor, type ScreenSizeState, type StorageEstimate, type StorageManagerLike, @@ -19,12 +24,11 @@ import { storageTrackerFor, type UiEnv, type UiTracker, + type UserIdleState, uiTrackerFor, } from '@bugsee/trackers/browser'; import { boundMatchMedia } from './host-global'; import { chargingToWire, connectionTypeToWire, orientationToWire } from './system-trace-values'; -import { MainThreadLagTracker } from './trackers/main-thread-lag-tracker'; -import { type MemoryReading, type MemoryState, MemoryTracker } from './trackers/memory-tracker'; // Browser `traces.system` producers (mirrors the mobile tracker/producer split). Each producer subscribes to // one shared tracker in start() and turns its state into the Android-vocabulary wire values; the trackers own @@ -135,17 +139,17 @@ export class StorageProducer extends TraceProducerBase { */ export class MemoryProducer extends TraceProducerBase { readonly name = 'memory'; - readonly #tracker: MemoryTracker; + readonly #tracker: ProcessTracker; #off: (() => void) | undefined; - constructor(tracker: MemoryTracker) { + constructor(tracker: ProcessTracker) { super(); this.#tracker = tracker; } protected onStart(): void { - this.#off = this.#tracker.on('memory', (m) => this.#publish(m)); - const cur = this.#tracker.current(); + this.#off = this.#tracker.on('heap', (m) => this.#publish(m)); + const cur = this.#tracker.currentHeap(); if (cur !== undefined) this.#publish(cur); } @@ -154,7 +158,7 @@ export class MemoryProducer extends TraceProducerBase { this.#off = undefined; } - #publish(m: MemoryState): void { + #publish(m: HeapState): void { this.emit('ram_js_heap_total', m.limit); this.emit('ram_js_heap_free', m.limit - m.used); this.emit('ram_js_heap_used', m.used); @@ -352,16 +356,18 @@ export class ScreenSizeProducer extends TraceProducerBase { */ export class MainThreadLagProducer extends TraceProducerBase { readonly name = 'main-thread-lag'; - readonly #tracker: MainThreadLagTracker; + readonly #tracker: ProcessTracker; #off: (() => void) | undefined; - constructor(tracker: MainThreadLagTracker) { + constructor(tracker: ProcessTracker) { super(); this.#tracker = tracker; } protected onStart(): void { - this.#off = this.#tracker.on('lag', (ms) => this.emit('main_thread_lag_ms', ms)); + this.#off = this.#tracker.on('mainThreadLag', ({ lagMs }) => + this.emit('main_thread_lag_ms', lagMs), + ); } protected override onStop(): void { @@ -370,13 +376,75 @@ export class MainThreadLagProducer extends TraceProducerBase { } } +/** + * `main_thread_idle_ms` (JS-specific): how long the browser says the main thread can stay idle, from ONE + * `requestIdleCallback` probe per sampling tick (`deadline.timeRemaining()`, capped at 50 ms by the spec). Visible pages + * only. A tick in which no idle period was granted produces NO sample (absence, never 0); engines without + * `requestIdleCallback` (Safari) never produce it. Polled, so each value is emitted and never held. + */ +export class MainThreadIdleProducer extends TraceProducerBase { + readonly name = 'main-thread-idle'; + readonly #tracker: ProcessTracker; + #off: (() => void) | undefined; + + constructor(tracker: ProcessTracker) { + super(); + this.#tracker = tracker; + } + + protected onStart(): void { + this.#off = this.#tracker.on('idle', ({ idleMs }) => this.emit('main_thread_idle_ms', idleMs)); + } + + protected override onStop(): void { + this.#off?.(); + this.#off = undefined; + } +} + +/** + * `user_idle_state` (JS-specific): the Idle Detection API, a string like `app_state`: `active`, `idle`, or `locked` + * (the screen is locked, which wins over `userState` since the user is away either way). Held, de-duplicated step + * trace. OPT-IN: the factory builds this producer only for `captureUserIdle: true`. The SDK never prompts: even then + * it exists ONLY in a page whose `idle-detection` permission is already `granted` (Chromium); otherwise no trace is + * produced at all. Known limit (owner decision pending, see docs/PROGRESS.md TR5): a + * held value cannot be retracted, so after the permission is revoked the last published value keeps being re-recorded + * until the producer stops; only the SEED is gated (the armed detector must be live, i.e. the permission re-confirmed). + */ +export class UserIdleProducer extends TraceProducerBase { + readonly name = 'user-idle'; + readonly #tracker: ProcessTracker; + #off: (() => void) | undefined; + + constructor(tracker: ProcessTracker) { + super(); + this.#tracker = tracker; + } + + protected onStart(): void { + this.#off = this.#tracker.on('userIdle', (s) => this.#publish(s)); + // Held trace: only a FRESH retained state may seed it (an old snapshot would be re-recorded for as long as it lives). + const cur = this.#tracker.currentUserIdleFresh(); + if (cur !== undefined) this.#publish(cur); + } + + protected override onStop(): void { + this.#off?.(); + this.#off = undefined; + } + + #publish(s: UserIdleState): void { + this.emitIfChanged('user_idle_state', s.screenState === 'locked' ? 'locked' : s.userState); + } +} + /** * Injected browser surfaces. Each is optional and an absent one is simply not sampled; pass the LIVE * objects (`navigator`, `performance`, `screen`, `window`) — never a spread copy, since a real * `navigator` keeps `onLine` / `connection` / `deviceMemory` as accessors on its prototype. */ export interface BrowserTraceEnv extends BatteryEnv { - performance?: { readonly memory?: MemoryReading; now?(): number }; + performance?: { readonly memory?: HeapReading; now?(): number }; navigator?: { /** `navigator.deviceMemory` in GiB — a coarse bucket, not a measurement. */ readonly deviceMemory?: number; @@ -385,6 +453,8 @@ export interface BrowserTraceEnv extends BatteryEnv { readonly onLine?: boolean; /** NetworkInformation (read by the ConnectivityTracker). */ readonly connection?: object; + /** `navigator.permissions` (the ProcessTracker only ever QUERIES `idle-detection`). */ + readonly permissions?: NonNullable['permissions']; }; screen?: UiEnv['screen']; /** Source of `online` / `offline` / `orientationchange` / `resize`, and the live `devicePixelRatio`. */ @@ -401,6 +471,17 @@ export interface BrowserTraceEnv extends BatteryEnv { * connection, orientation, `app_state`, `fps`, `screen_size` and `main_thread_lag_ms` traces (as on Node), so the sensor producers must not also emit them. */ sensors?: boolean; + /** `window.requestIdleCallback` / `cancelIdleCallback` (bound to that window): drive `main_thread_idle_ms`. */ + requestIdleCallback?: ProcessEnv['requestIdleCallback']; + cancelIdleCallback?: ProcessEnv['cancelIdleCallback']; + /** `window.IdleDetector` (Chromium, secure context) and `window.AbortController`: drive `user_idle_state`. */ + IdleDetector?: ProcessEnv['IdleDetector']; + AbortController?: ProcessEnv['AbortController']; + /** + * Build the `user_idle_state` producer. OFF unless exactly `true` (the launch's `captureUserIdle` option). It also + * needs the page's `idle-detection` permission to be ALREADY granted; the SDK never prompts for it. + */ + captureUserIdle?: boolean; /** Drives the heap poll; default the global timers. */ scheduler?: Scheduler; /** @@ -417,6 +498,25 @@ interface WindowSurfaces { screen?: BrowserTraceEnv['screen']; requestAnimationFrame?: (callback: (timestamp: number) => void) => number; cancelAnimationFrame?: (handle: number) => void; + requestIdleCallback?: (callback: (deadline: { timeRemaining(): number }) => void) => number; + cancelIdleCallback?: (handle: number) => void; + IdleDetector?: ProcessEnv['IdleDetector']; + AbortController?: ProcessEnv['AbortController']; +} + +// One bound wrapper per (window, function) pair, so wrapping the same window function twice yields the SAME closure. +const boundCache = new WeakMap>(); +function boundTo unknown>(win: object, fn: F): F { + let perWin = boundCache.get(win); + if (perWin === undefined) { + perWin = new WeakMap(); + boundCache.set(win, perWin); + } + const cached = perWin.get(fn); + if (cached !== undefined) return cached as F; + const bound = ((...args: never[]) => fn.apply(win, args)) as F; + perWin.set(fn, bound); + return bound; } /** @@ -452,6 +552,16 @@ export function browserTraceEnvFrom( const getBattery = nav === undefined ? undefined : read(() => nav.getBattery); const raf = read(() => win.requestAnimationFrame); const caf = read(() => win.cancelAnimationFrame); + const ric = read(() => win.requestIdleCallback); + const cic = read(() => win.cancelIdleCallback); + const idleDetector = read(() => win.IdleDetector); + const abortController = read(() => win.AbortController); + // Stable identities: the shared trackers restart their armed stages when a seam changes identity, so wrapping the + // SAME window function must hand back the SAME closure on every launch (a no-op relaunch stays a no-op). + const raf2 = typeof raf === 'function' ? boundTo(win, raf) : undefined; + const caf2 = typeof caf === 'function' ? boundTo(win, caf) : undefined; + const ric2 = typeof ric === 'function' ? boundTo(win, ric) : undefined; + const cic2 = typeof cic === 'function' ? boundTo(win, cic) : undefined; const matchMedia = boundMatchMedia(win, onError); return { ...(matchMedia !== undefined ? { matchMedia } : {}), @@ -460,8 +570,12 @@ export function browserTraceEnvFrom( ...(screen !== undefined ? { screen } : {}), window: source as NonNullable, ...(document !== undefined ? { document } : {}), - ...(typeof raf === 'function' ? { requestAnimationFrame: (cb) => raf.call(win, cb) } : {}), - ...(typeof caf === 'function' ? { cancelAnimationFrame: (h) => caf.call(win, h) } : {}), + ...(raf2 !== undefined ? { requestAnimationFrame: raf2 } : {}), + ...(caf2 !== undefined ? { cancelAnimationFrame: caf2 } : {}), + ...(ric2 !== undefined ? { requestIdleCallback: ric2 } : {}), + ...(cic2 !== undefined ? { cancelIdleCallback: cic2 } : {}), + ...(typeof idleDetector === 'function' ? { IdleDetector: idleDetector } : {}), + ...(typeof abortController === 'function' ? { AbortController: abortController } : {}), ...(nav !== undefined && typeof getBattery === 'function' ? { getBattery: () => getBattery.call(nav) } : {}), @@ -523,12 +637,12 @@ export function createBrowserSystemTraceProducers( env: BrowserTraceEnv = defaultBrowserTraceEnv(), onError?: (error: unknown) => void, ): TraceProducer[] { - const perf = env.performance; const nav = env.navigator; const battery = new BatteryProducer(powerTrackerFor(env, onError)); if (env.sensors === false) return [battery]; - // Read ONCE: a hostile `performance.now` getter must report one error, not one per consumer. - const clock = safePerformanceNow(env, onError); + // Read ONCE and SILENTLY (only to decide whether the UiTracker's frame-rate gets a clock): the ProcessTracker owns + // reporting an unreadable `performance.now` when its lag/uptime stage activates, so a hostile getter is reported once. + const clock = safePerformanceNow(env, undefined); // ONE UI/display tracker per window serves orientation, screen_size and fps (and the events source). const ui = uiTrackerFor( { @@ -551,16 +665,34 @@ export function createBrowserSystemTraceProducers( }, onError, ); + // ONE domain tracker per window feeds the heap, main-thread lag/idle and user-idle traces. The live `performance` + // object is passed (`performance.memory` is a live getter the tracker reads on every poll). + const processTracker = processTrackerFor( + { + ...(env.window !== undefined ? { window: env.window } : {}), + ...(env.performance !== undefined ? { performance: env.performance } : {}), + ...(env.document !== undefined ? { document: env.document } : {}), + ...(env.lifecycle !== undefined ? { lifecycle: env.lifecycle } : {}), + ...(env.requestIdleCallback !== undefined + ? { requestIdleCallback: env.requestIdleCallback } + : {}), + ...(env.cancelIdleCallback !== undefined + ? { cancelIdleCallback: env.cancelIdleCallback } + : {}), + ...(nav !== undefined ? { navigator: nav } : {}), + ...(env.IdleDetector !== undefined ? { IdleDetector: env.IdleDetector } : {}), + ...(env.AbortController !== undefined ? { AbortController: env.AbortController } : {}), + ...(env.scheduler !== undefined ? { scheduler: env.scheduler } : {}), + }, + onError, + ); return [ battery, - new MemoryProducer( - new MemoryTracker({ - // Read through `env.performance` on every poll: `performance.memory` is a live getter. - memory: () => perf?.memory, - ...(env.scheduler !== undefined ? { scheduler: env.scheduler } : {}), - ...(onError !== undefined ? { onError } : {}), - }), - ), + new MemoryProducer(processTracker), + // OPT-IN, strictly `true`: idle/locked state is behavioural data, and a host page's own `idle-detection` grant is + // not consent to record and upload it. Even when opted in, the tracker starts a detector only if the permission + // is ALREADY granted (the SDK never prompts). + ...(env.captureUserIdle === true ? [new UserIdleProducer(processTracker)] : []), new AdvertisedMemoryProducer(() => nav?.deviceMemory), new StorageProducer( storageTrackerFor( @@ -579,20 +711,8 @@ export function createBrowserSystemTraceProducers( ? [ new AppStateProducer(env.lifecycle), new ScreenSizeProducer(ui), - new MainThreadLagProducer( - new MainThreadLagTracker( - { - lifecycle: env.lifecycle, - ...(env.document !== undefined ? { document: env.document } : {}), - // Only a real clock function is wired; anything else omits the trace quietly (no error). - ...(typeof clock === 'function' - ? { now: () => (env.performance as { now(): number }).now() } - : {}), - ...(env.scheduler !== undefined ? { scheduler: env.scheduler } : {}), - }, - onError, - ), - ), + new MainThreadLagProducer(processTracker), + new MainThreadIdleProducer(processTracker), new FpsProducer(ui), ] : []), diff --git a/packages/browser/src/trace-producers.visibility.test.ts b/packages/browser/src/trace-producers.visibility.test.ts index bd0498e5..9f9dced1 100644 --- a/packages/browser/src/trace-producers.visibility.test.ts +++ b/packages/browser/src/trace-producers.visibility.test.ts @@ -387,7 +387,13 @@ describe('browserTraceEnvFrom', () => { ); }).not.toThrow(); expect(built.map((p) => p.name)).toContain('fps'); - expect(errors.some((e) => (e as Error).message === 'now blocked')).toBe(true); + // the lag stage (owner of the clock report) activates: the unreadable clock is reported, exactly once + const lag = built.find((p) => p.name === 'main-thread-lag') as TraceProducer; + lag.start({ emit: () => {} }); + lag.stop(); + lag.start({ emit: () => {} }); + lag.stop(); + expect(errors.map((e) => (e as Error).message)).toEqual(['now blocked']); }); it('omits what the window lacks (a missing performance.memory is not borrowed from the global)', () => { diff --git a/packages/browser/src/trackers/main-thread-lag-tracker.test.ts b/packages/browser/src/trackers/main-thread-lag-tracker.test.ts deleted file mode 100644 index d1b0aa94..00000000 --- a/packages/browser/src/trackers/main-thread-lag-tracker.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import type { Scheduler } from '@bugsee/core'; -import { describe, expect, it, vi } from 'vitest'; -import { MainThreadLagTracker } from './main-thread-lag-tracker'; - -// A controllable page. Tests are REAL-ORDER aware: a main-thread block is modelled as "advance the clock by N ms -// between two tick callbacks" — the late tick then fires after the block, exactly as a real timer does. -function mkEnv(over: { visible?: boolean; noNow?: boolean } = {}) { - let now = 0; - const doc = { visibilityState: over.visible === false ? 'hidden' : 'visible' }; - const visListeners = new Set<(e: { visible: boolean }) => void>(); - let timerCb: (() => void) | undefined; - const timers = { set: 0, cleared: 0, live: 0 }; - const control = { setFails: 0 }; - const scheduler: Scheduler = { - setInterval: (cb) => { - if (control.setFails-- > 0) throw new Error('timer'); - timerCb = cb; - timers.set += 1; - timers.live += 1; - return { id: timers.set }; - }, - clearInterval: () => { - timers.cleared += 1; - timers.live -= 1; - }, - }; - const env = { - document: doc, - lifecycle: { - on: (type: string, fn: (e: never) => void) => { - expect(type).toBe('visibility'); - visListeners.add(fn as never); - return () => void visListeners.delete(fn as never); - }, - }, - ...(over.noNow ? {} : { now: () => now }), - scheduler, - }; - return { - env, - timers, - control, - advance: (ms: number) => { - now += ms; - }, - setNow: (ms: number) => { - now = ms; - }, - tick: () => timerCb?.(), - setVisible: (visible: boolean) => { - doc.visibilityState = visible ? 'visible' : 'hidden'; - for (const l of [...visListeners]) l({ visible }); - }, - listeners: () => visListeners.size, - }; -} - -const collect = (t: MainThreadLagTracker) => { - const got: number[] = []; - const off = t.on('lag', (v) => got.push(v)); - return { got, off }; -}; - -describe('MainThreadLagTracker (timer drift)', () => { - it('attaches nothing until the first subscriber', () => { - const e = mkEnv(); - new MainThreadLagTracker(e.env); - expect(e.timers.set).toBe(0); - expect(e.listeners()).toBe(0); - }); - - it('two ticks exactly on time report 0 (a measured zero is a value, and is emitted)', () => { - const e = mkEnv(); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.advance(1000); - e.tick(); - e.advance(1000); - e.tick(); - expect(got).toEqual([0, 0]); - }); - - it('a block spanning a due tick shows as the lateness of that tick, once; the next on-time tick reads 0', () => { - const e = mkEnv(); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.advance(1000); - e.tick(); // on time - e.advance(1000 + 700); // a 700 ms synchronous block, then the overdue tick fires - e.tick(); - expect(got).toEqual([0, 700]); - e.advance(1000); // and the NEXT interval is measured from the late tick, so the block is not counted twice - e.tick(); - expect(got).toEqual([0, 700, 0]); - }); - - it('a block shorter than the interval that crosses no due tick reads exactly 0 (the documented limit)', () => { - const e = mkEnv(); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.advance(100); // a 100 ms block mid-interval... - e.advance(900); // ...the tick still fires on schedule - e.tick(); - expect(got).toEqual([0]); - }); - - it('the first tick is measured against the run start + interval', () => { - const e = mkEnv(); - e.setNow(5000); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.setNow(5000 + 1000 + 250); - e.tick(); - expect(got).toEqual([250]); - }); - - it('a tick that fires a hair early is on time (never a negative lag)', () => { - const e = mkEnv(); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.advance(998); - e.tick(); - expect(got).toEqual([0]); - }); - - it('honors intervalMs (default 1000) as both the timer period and the drift baseline', () => { - const seen: number[] = []; - const scheduler: Scheduler = { - setInterval: (_cb, ms) => { - seen.push(ms); - return 1; - }, - clearInterval: () => {}, - }; - const e = mkEnv(); - new MainThreadLagTracker({ ...e.env, scheduler }).on('lag', () => {}); - new MainThreadLagTracker({ ...e.env, scheduler, intervalMs: 250 }).on('lag', () => {}); - expect(seen).toEqual([1000, 250]); - const f = mkEnv(); - const { got } = collect(new MainThreadLagTracker({ ...f.env, intervalMs: 250 })); - f.advance(250); - f.tick(); - f.advance(250 + 40); - f.tick(); - expect(got).toEqual([0, 40]); - }); - - it('uses the INJECTED clock: its distinctive value, and nothing else, decides the drift', () => { - const e = mkEnv(); - const reads: number[] = []; - let t = 123_456; - const { got } = collect( - new MainThreadLagTracker({ - ...e.env, - now: () => { - reads.push(t); - return t; - }, - }), - ); - t = 123_456 + 1000 + 77; - e.tick(); - expect(got).toEqual([77]); - expect(reads).toEqual([123_456, 123_456 + 1077]); - }); - - it('hidden: the timer is torn down and nothing is emitted, even by a stray tick', () => { - const e = mkEnv(); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.setVisible(false); - expect(e.timers.live).toBe(0); - e.advance(1000); - e.tick(); - expect(got).toEqual([]); - }); - - it('a hidden stretch never counts as lag: hide 60 s, show, the first tick reports ~0 — a FRESH baseline', () => { - const e = mkEnv(); - const { got } = collect(new MainThreadLagTracker(e.env)); - e.advance(1000); - e.tick(); - e.setVisible(false); - e.advance(60_000); - e.setVisible(true); - e.advance(1000); - e.tick(); - expect(got).toEqual([0, 0]); - }); - - it('hidden at start: no timer, nothing emitted; the first visible event starts a run', () => { - const e = mkEnv({ visible: false }); - const { got } = collect(new MainThreadLagTracker(e.env)); - expect(e.timers.live).toBe(0); - e.tick(); - expect(got).toEqual([]); - e.setVisible(true); - expect(e.timers.live).toBe(1); - }); - - it('duplicate visible/hidden events do not stack timers', () => { - const e = mkEnv(); - collect(new MainThreadLagTracker(e.env)); - e.setVisible(true); - e.setVisible(true); - expect(e.timers.live).toBe(1); - e.setVisible(false); - e.setVisible(false); - expect(e.timers.live).toBe(0); - expect(e.timers.cleared).toBe(1); - }); - - it('the last unsubscribe stops the timer and the subscription; a re-subscribe restarts with a fresh baseline', () => { - const e = mkEnv(); - const t = new MainThreadLagTracker(e.env); - const { off } = collect(t); - expect(e.listeners()).toBe(1); - off(); - expect(e.listeners()).toBe(0); - expect(e.timers.live).toBe(0); - e.advance(30_000); - const { got } = collect(t); - expect(e.timers.live).toBe(1); - e.advance(1000); - e.tick(); - expect(got).toEqual([0]); - }); - - it('without a clock it measures nothing: no timer, no subscription, no error (no wall-clock guess)', () => { - const e = mkEnv({ noNow: true }); - const errors: unknown[] = []; - const { got } = collect(new MainThreadLagTracker(e.env, (x) => errors.push(x))); - expect(e.timers.set).toBe(0); - expect(e.listeners()).toBe(0); - e.tick(); - expect(got).toEqual([]); - expect(errors).toEqual([]); - }); - - it('with no lifecycle it measures nothing: a hidden tab could not be gated', () => { - const e = mkEnv(); - const { lifecycle: _l, ...rest } = e.env; - const errors: unknown[] = []; - collect(new MainThreadLagTracker(rest, (x) => errors.push(x))); - expect(e.timers.set).toBe(0); - expect(errors).toEqual([]); - }); - - it('an unreadable visibilityState counts as hidden and is reported', () => { - const e = mkEnv(); - const errors: unknown[] = []; - const doc = {}; - Object.defineProperty(doc, 'visibilityState', { - get(): never { - throw new Error('vis'); - }, - }); - collect(new MainThreadLagTracker({ ...e.env, document: doc }, (x) => errors.push(x))); - expect(e.timers.set).toBe(0); - expect((errors[0] as Error).message).toBe('vis'); - }); - - it('a throwing clock at start is reported and rolls back; the next visible event retries', () => { - const e = mkEnv({ visible: false }); - const errors: unknown[] = []; - let boom = true; - collect( - new MainThreadLagTracker( - { - ...e.env, - now: () => { - if (boom) throw new Error('clock'); - return 0; - }, - }, - (x) => errors.push(x), - ), - ); - e.setVisible(true); - expect((errors[0] as Error).message).toBe('clock'); - expect(e.timers.live).toBe(0); - boom = false; - e.setVisible(true); - expect(e.timers.live).toBe(1); - }); - - it('a throwing scheduler is reported and rolls back; the next visible event retries', () => { - const e = mkEnv({ visible: false }); - const errors: unknown[] = []; - collect(new MainThreadLagTracker(e.env, (x) => errors.push(x))); - e.control.setFails = 1; - e.setVisible(true); - expect((errors[0] as Error).message).toBe('timer'); - expect(e.timers.live).toBe(0); - e.setVisible(true); - expect(e.timers.live).toBe(1); - }); - - it('a clock that throws at a tick is reported, skips it, and the next tick does not read the skipped interval as lag', () => { - const e = mkEnv(); - const errors: unknown[] = []; - let boom = false; - let t = 0; - const { got } = collect( - new MainThreadLagTracker( - { - ...e.env, - now: () => { - if (boom) throw new Error('clock'); - return t; - }, - }, - (x) => errors.push(x), - ), - ); - boom = true; - t = 1000; - expect(() => e.tick()).not.toThrow(); - expect((errors[0] as Error).message).toBe('clock'); - expect(got).toEqual([]); - boom = false; - t = 2000; - e.tick(); - expect(got).toEqual([0]); // not ~1000 - t = 3000 + 90; - e.tick(); - expect(got).toEqual([0, 90]); - }); - - it('a non-finite clock reading is dropped without poisoning the baseline', () => { - const e = mkEnv(); - let t = 0; - const { got } = collect(new MainThreadLagTracker({ ...e.env, now: () => t })); - t = Number.NaN; - e.tick(); - t = Number.POSITIVE_INFINITY; - e.tick(); - expect(got).toEqual([]); - t = 3000 + 5; // three intervals elapsed in total, two of them skipped - e.tick(); - expect(got).toEqual([5]); - }); - - it('uses the global timers by default, and releases them on the last unsubscribe', () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); - try { - const e = mkEnv(); - const { scheduler: _s, ...rest } = e.env; - let now = 0; - const t = new MainThreadLagTracker({ ...rest, now: () => now }); - const got: number[] = []; - const off = t.on('lag', (v) => got.push(v)); - now = 1000; - vi.advanceTimersByTime(1000); - expect(got).toEqual([0]); - off(); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('a lifecycle that throws on subscribe is reported, not thrown out of on()', () => { - const e = mkEnv(); - const errors: unknown[] = []; - const t = new MainThreadLagTracker( - { - ...e.env, - lifecycle: { - on: () => { - throw new Error('lifecycle'); - }, - }, - }, - (x) => errors.push(x), - ); - expect(() => t.on('lag', () => {})).not.toThrow(); - expect((errors[0] as Error).message).toBe('lifecycle'); - }); - - it('a throwing reporter never escapes', () => { - const e = mkEnv({ visible: false }); - const t = new MainThreadLagTracker( - { - ...e.env, - now: () => { - throw new Error('clock'); - }, - }, - () => { - throw new Error('reporter'); - }, - ); - t.on('lag', () => {}); - expect(() => e.setVisible(true)).not.toThrow(); - }); -}); diff --git a/packages/browser/src/trackers/main-thread-lag-tracker.ts b/packages/browser/src/trackers/main-thread-lag-tracker.ts deleted file mode 100644 index 8265139a..00000000 --- a/packages/browser/src/trackers/main-thread-lag-tracker.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { InterceptorBase, type Scheduler } from '@bugsee/core'; -import type { ApplicationTracker } from '@bugsee/trackers/browser'; - -// Browser main-thread responsiveness TRACKER (`main_thread_lag_ms`): TIMER DRIFT. The sampling timer runs on the -// page's main thread, so on every tick `lag = max(0, now - (previousTickTime + intervalMs))` is how late it fired. -// The first tick compares against the run's start time + interval. A measured ~0 is a TRUE measurement and is -// emitted every tick. -// -// What a reading means (measured in Chromium): a tick fires late by (block END - the first due time the block -// covered). A block B >= 1 s therefore reads between B - 1000 and B (a 1800 ms block read 907..1797, a 2500 ms -// block 1807). A block SHORTER than 1 s that does not cross a due tick reads exactly 0 — the chance is about -// 1 - B/1000, so ~30% for a 700 ms block and ~90% for a 100 ms one. One block is reported once: the tick after -// it is measured from the late tick and clamps to 0. This is NOT Node's event-loop monitor, which samples every -// ~10 ms; sampling every 1000 ms is why short blocks can go unseen. -// -// Why not a MessageChannel ping: a ping only sees a block that begins while it is in flight, so most real -// freezes read ~0.1 ms (measured in Chromium), and a tick that runs on the same thread never sees a ping -// "still in flight". -// -// Honest limit: it reports long freezes reliably and short jank only by luck. If short jank matters, the -// follow-ups are a finer internal timer reporting the MAX drift per 1 s window, or -// `PerformanceObserver('longtask')` where supported. It may also read timer throttling that a browser applies -// to a page that still says `visible` (an occluded or minimised window, a background tab that never fired -// `visibilitychange`) as lag; that is UNVERIFIED (it could not be reproduced headless). -// -// Measured ONLY while the page is visible (the same rule as `fps`). A hidden tab throttles timers to ~1 s then -// ~1 min, which would paint a hang that never happened, so the timer is torn down on hidden and every visible -// start takes a FRESH baseline: a hidden stretch can never count as lag. No clock, no lifecycle, or hidden: -// emit NOTHING. - -export interface MainThreadLagEnv { - /** Shared lifecycle tracker for visibility changes. */ - lifecycle?: Pick; - /** Visibility is read here at activation; unknown (absent) counts as not visible. */ - document?: { readonly visibilityState?: string }; - /** Monotonic ms (the injected `performance.now`); absent omits the trace — no wall-clock guess. */ - now?: () => number; - scheduler?: Scheduler; - intervalMs?: number; -} - -const defaultScheduler: Scheduler = { - setInterval: (cb, ms) => setInterval(cb, ms), - clearInterval: (h) => clearInterval(h as ReturnType), -}; - -export class MainThreadLagTracker extends InterceptorBase<{ lag: number }> { - readonly name = 'browser-main-thread-lag'; - readonly #env: MainThreadLagEnv; - readonly #report: (error: unknown) => void; - readonly #scheduler: Scheduler; - readonly #interval: number; - #subscription: (() => void) | undefined; - #timer: unknown = null; - #running = false; - /** When the next tick is due: previous tick time + interval (the run's start + interval before the first). */ - #expectedAt = 0; - - constructor(env: MainThreadLagEnv, onError?: (error: unknown) => void) { - // A throwing reporter must not escape into the host's timer dispatch. - const report = (error: unknown): void => { - try { - onError?.(error); - } catch { - /* the reporter itself failed; nothing left to tell */ - } - }; - super(report); - this.#env = env; - this.#report = report; - this.#scheduler = env.scheduler ?? defaultScheduler; - this.#interval = env.intervalMs ?? 1000; - } - - // Guarded: the tracker is already marked active before this runs, so a throwing environment must be - // reported here rather than leak out of `on()`. - protected onActivate(): void { - // No clock to time it with, or no visibility events to gate on (a hidden tab must never keep a timer): omit. - if (typeof this.#env.now !== 'function' || this.#env.lifecycle === undefined) return; - try { - this.#subscription = this.#env.lifecycle.on('visibility', ({ visible }) => - visible ? this.#startRun() : this.#stopRun(), - ); - if (this.#isVisible()) this.#startRun(); - } catch (error) { - this.#report(error); - } - } - - protected override onDeactivate(): void { - this.#subscription?.(); - this.#subscription = undefined; - this.#stopRun(); - } - - #isVisible(): boolean { - try { - return this.#env.document?.visibilityState === 'visible'; - } catch (error) { - this.#report(error); - return false; - } - } - - #startRun(): void { - if (this.#running) return; - this.#running = true; - try { - // The clock read is inside the guarded setup: a throwing clock rolls the run back like any other setup - // failure, or `#running` would stay true with no timer and block every later start. - this.#expectedAt = (this.#env.now as () => number)() + this.#interval; // a FRESH baseline every run - this.#timer = this.#scheduler.setInterval(() => this.#tick(), this.#interval); - } catch (error) { - this.#report(error); - this.#stopRun(); // never leave a half-started run behind - } - } - - #stopRun(): void { - this.#running = false; - if (this.#timer !== null) this.#scheduler.clearInterval(this.#timer); - this.#timer = null; - } - - #tick(): void { - if (!this.#running) return; - let now: number; - try { - now = (this.#env.now as () => number)(); - } catch (error) { - this.#report(error); // an unreadable clock skips this tick; it runs inside the host's timer dispatch - this.#expectedAt += this.#interval; // the skipped interval must not read as lag on the next tick - return; - } - if (!Number.isFinite(now)) { - this.#expectedAt += this.#interval; - return; - } - const lag = Math.max(0, now - this.#expectedAt); // fired early (a hair) is on time, not negative lag - this.#expectedAt = now + this.#interval; - this.emit('lag', lag); - } -} diff --git a/packages/browser/src/trackers/memory-tracker.test.ts b/packages/browser/src/trackers/memory-tracker.test.ts deleted file mode 100644 index 49d5c83c..00000000 --- a/packages/browser/src/trackers/memory-tracker.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { type MemoryState, MemoryTracker } from './memory-tracker'; - -function mkScheduler() { - const timers = new Map void>(); - let next = 1; - return { - setInterval: vi.fn((cb: () => void, _ms: number) => { - const h = next++; - timers.set(h, cb); - return h; - }), - clearInterval: vi.fn((h: unknown) => void timers.delete(h as number)), - tick: () => - [...timers.values()].forEach((cb) => { - cb(); - }), - live: () => timers.size, - }; -} -const mem = (used: number, limit = 100) => ({ - usedJSHeapSize: used, - totalJSHeapSize: limit / 2, - jsHeapSizeLimit: limit, -}); - -describe('MemoryTracker', () => { - it('attaches nothing until the first subscriber (no timer, no read)', () => { - const s = mkScheduler(); - const read = vi.fn(() => mem(1)); - new MemoryTracker({ memory: read, scheduler: s }); - expect(s.setInterval).not.toHaveBeenCalled(); - expect(read).not.toHaveBeenCalled(); - }); - - it('polls on ONE shared timer at the default 1000 ms and emits {used, limit} per tick', () => { - const s = mkScheduler(); - let used = 10; - const t = new MemoryTracker({ memory: () => mem(used, 200), scheduler: s }); - const a: MemoryState[] = []; - const b: MemoryState[] = []; - const offA = t.on('memory', (m) => a.push(m)); - const offB = t.on('memory', (m) => b.push(m)); - expect(s.setInterval).toHaveBeenCalledTimes(1); - expect(s.setInterval.mock.calls[0]?.[1]).toBe(1000); - used = 20; - s.tick(); - expect(a).toEqual([{ used: 20, limit: 200 }]); - expect(b).toEqual(a); - offA(); - expect(s.live()).toBe(1); // still one subscriber - offB(); - expect(s.live()).toBe(0); // last one releases the timer - }); - - it('honours intervalMs and re-arms after a stop -> start', () => { - const s = mkScheduler(); - const t = new MemoryTracker({ memory: () => mem(1), scheduler: s, intervalMs: 250 }); - t.on('memory', () => {})(); - expect(s.setInterval.mock.calls[0]?.[1]).toBe(250); - expect(s.live()).toBe(0); - const off = t.on('memory', () => {}); - expect(s.setInterval).toHaveBeenCalledTimes(2); - expect(s.live()).toBe(1); - off(); - }); - - it('current() reads the live heap, and is undefined when the engine has no performance.memory', () => { - expect(new MemoryTracker({ memory: () => mem(7, 70) }).current()).toEqual({ - used: 7, - limit: 70, - }); - expect(new MemoryTracker({}).current()).toBeUndefined(); - expect(new MemoryTracker({ memory: () => undefined }).current()).toBeUndefined(); - }); - - it('starts no timer at all where the engine cannot answer (Safari/Firefox)', () => { - const s = mkScheduler(); - const t = new MemoryTracker({ memory: () => undefined, scheduler: s }); - const seen = vi.fn(); - const off = t.on('memory', seen); - expect(s.setInterval).not.toHaveBeenCalled(); - off(); - expect(s.clearInterval).not.toHaveBeenCalled(); - expect(seen).not.toHaveBeenCalled(); - }); - - it('a throwing read is reported and skips that tick; the next tick recovers', () => { - const s = mkScheduler(); - const onError = vi.fn(); - let boom = true; - const t = new MemoryTracker({ - memory: () => { - if (boom) throw new Error('getter'); - return mem(5); - }, - scheduler: s, - onError, - }); - const got: MemoryState[] = []; - t.on('memory', (m) => got.push(m)); - expect(onError).toHaveBeenCalledTimes(1); // the activation probe threw: reported, timer still armed - expect(s.live()).toBe(1); - expect(() => s.tick()).not.toThrow(); - expect(onError).toHaveBeenCalledTimes(2); - expect(got).toEqual([]); - boom = false; - s.tick(); - expect(got).toEqual([{ used: 5, limit: 100 }]); - }); - - it('a throwing current() is reported, not thrown', () => { - const onError = vi.fn(); - const t = new MemoryTracker({ - memory: () => { - throw new Error('getter'); - }, - onError, - }); - expect(t.current()).toBeUndefined(); - expect(onError).toHaveBeenCalledTimes(1); - }); - - it('defaults to the global timers (real setInterval) and releases them', () => { - vi.useFakeTimers(); - try { - const t = new MemoryTracker({ memory: () => mem(3) }); - const got: MemoryState[] = []; - const off = t.on('memory', (m) => got.push(m)); - vi.advanceTimersByTime(2100); - expect(got.length).toBe(2); - off(); - vi.advanceTimersByTime(5000); - expect(got.length).toBe(2); - } finally { - vi.useRealTimers(); - } - }); - - it('releases a timer exactly once: a later activation that finds no heap API must not clear a stale handle again', () => { - const s = mkScheduler(); - let supported = true; - const t = new MemoryTracker({ memory: () => (supported ? mem(1) : undefined), scheduler: s }); - t.on('memory', () => {})(); // activate, then deactivate - expect(s.clearInterval).toHaveBeenCalledTimes(1); - supported = false; - t.on('memory', () => {})(); // activate (no timer), deactivate - expect(s.clearInterval).toHaveBeenCalledTimes(1); - }); - - it('a throwing scheduler is reported, not thrown out of on(); a later subscriber re-arms nothing but the tracker still releases cleanly', () => { - const onError = vi.fn(); - const t = new MemoryTracker({ - memory: () => mem(1), - scheduler: { - setInterval: () => { - throw new Error('no timers'); - }, - clearInterval: vi.fn(), - }, - onError, - }); - let off: (() => void) | undefined; - expect(() => { - off = t.on('memory', () => {}); - }).not.toThrow(); - expect(onError).toHaveBeenCalledTimes(1); - expect(() => off?.()).not.toThrow(); // no timer was armed, so nothing to clear - }); - - it('a throwing clearInterval is reported and the handle kept: a later activation retries it and never double-arms', () => { - const onError = vi.fn(); - const s = mkScheduler(); - let boom = true; - const t = new MemoryTracker({ - memory: () => mem(1), - scheduler: { - ...s, - clearInterval: (h: unknown) => { - if (boom) throw new Error('clear refused'); - s.clearInterval(h); - }, - }, - onError, - }); - const off = t.on('memory', () => {}); - expect(() => off()).not.toThrow(); - expect(onError).toHaveBeenCalledTimes(1); - expect(s.live()).toBe(1); // the refused clear left it live - // re-activation while the clear still fails: reuse the live timer, arm no second one - const got: unknown[] = []; - const off2 = t.on('memory', (m) => got.push(m)); - expect(onError).toHaveBeenCalledTimes(2); - expect(s.setInterval).toHaveBeenCalledTimes(1); - s.tick(); - expect(got).toHaveLength(1); // exactly one poller feeds the subscriber - // once the scheduler recovers, the next release succeeds and the cycle is clean again - boom = false; - off2(); - expect(s.live()).toBe(0); - t.on('memory', () => {})(); - expect(s.setInterval).toHaveBeenCalledTimes(2); - expect(s.live()).toBe(0); - }); - - it('re-activation retries a refused clear and, when it succeeds, arms a fresh timer', () => { - const s = mkScheduler(); - let boom = true; - const t = new MemoryTracker({ - memory: () => mem(1), - scheduler: { - ...s, - clearInterval: (h: unknown) => { - if (boom) throw new Error('clear refused'); - s.clearInterval(h); - }, - }, - onError: () => {}, - }); - t.on('memory', () => {})(); // release refused: handle kept - boom = false; - const off = t.on('memory', () => {}); // retry succeeds -> old cleared, new armed - expect(s.setInterval).toHaveBeenCalledTimes(2); - expect(s.live()).toBe(1); - off(); - expect(s.live()).toBe(0); - }); -}); diff --git a/packages/browser/src/trackers/memory-tracker.ts b/packages/browser/src/trackers/memory-tracker.ts deleted file mode 100644 index 47640bb4..00000000 --- a/packages/browser/src/trackers/memory-tracker.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { InterceptorBase, type Scheduler } from '@bugsee/core'; - -// Browser JS-heap TRACKER (mirrors the mobile tracker/producer split): owns the one poll timer over -// `performance.memory` (Chromium-only). It starts the timer on the first subscriber and clears it on the -// last, so a page that is not capturing pays nothing; where the engine cannot answer it never starts a -// timer at all. `performance.memory` has no change event, so polling is the only way to see it. - -/** The heap figures the traces need. `limit` is the heap CEILING (jsHeapSizeLimit), not what is committed. */ -export interface MemoryState { - used: number; - limit: number; -} -export interface MemoryReading { - usedJSHeapSize: number; - totalJSHeapSize: number; - jsHeapSizeLimit: number; -} -export interface MemoryTrackerDeps { - /** Reads performance.memory live; undefined = this engine has none. */ - memory?: () => MemoryReading | undefined; - scheduler?: Scheduler; - intervalMs?: number; - onError?: (error: unknown) => void; -} - -const defaultScheduler: Scheduler = { - setInterval: (cb, ms) => setInterval(cb, ms), - clearInterval: (h) => clearInterval(h as ReturnType), -}; - -export class MemoryTracker extends InterceptorBase<{ memory: MemoryState }> { - readonly name = 'browser-memory'; - readonly #read: (() => MemoryReading | undefined) | undefined; - readonly #scheduler: Scheduler; - readonly #intervalMs: number; - readonly #onError: ((error: unknown) => void) | undefined; - #timer: unknown = null; - - constructor(deps: MemoryTrackerDeps = {}) { - super(deps.onError); - this.#read = deps.memory; - this.#scheduler = deps.scheduler ?? defaultScheduler; - this.#intervalMs = deps.intervalMs ?? 1000; - this.#onError = deps.onError; - } - - /** The live heap reading, or undefined when the engine has none (or the read threw). */ - current(): MemoryState | undefined { - try { - const m = this.#read?.(); - return m === undefined ? undefined : { used: m.usedJSHeapSize, limit: m.jsHeapSizeLimit }; - } catch (error) { - this.#onError?.(error); - return undefined; - } - } - - protected onActivate(): void { - // An engine that answers `undefined` has no performance.memory: never start a timer. A read that - // THROWS is a transient failure, not absence — it is reported and polling still starts, so the next - // tick can recover. - let supported = true; - try { - supported = this.#read?.() !== undefined; - } catch (error) { - this.#onError?.(error); - } - if (!supported) return; - // A handle still held here means the last release threw, so that timer may still be live. Retry the - // release; if it fails again, keep polling on that timer rather than arm a second one (duplicate rows). - if (this.#timer !== null && !this.#release()) return; - // Guarded like the other trackers: the subscriber is already registered, so a throwing scheduler must - // be reported here, not leak out of on() and strand an "active" tracker with a leaked listener. - try { - this.#timer = this.#scheduler.setInterval(() => this.#poll(), this.#intervalMs); - } catch (error) { - this.#onError?.(error); - } - } - - protected override onDeactivate(): void { - this.#release(); - } - - /** Clears the timer. The handle is dropped only once the clear succeeded; a failure is reported and kept. */ - #release(): boolean { - if (this.#timer === null) return true; - try { - this.#scheduler.clearInterval(this.#timer); - this.#timer = null; - return true; - } catch (error) { - this.#onError?.(error); - return false; - } - } - - #poll(): void { - const state = this.current(); - if (state !== undefined) this.emit('memory', state); - } -} diff --git a/packages/bugsee/src/option-directions.test.ts b/packages/bugsee/src/option-directions.test.ts index c087c3e7..08025e3f 100644 --- a/packages/bugsee/src/option-directions.test.ts +++ b/packages/bugsee/src/option-directions.test.ts @@ -56,6 +56,7 @@ const DIRECTIONS: Record = { startInBlackout: 'on', replay: 'off', // default ON, collects the DOM: an invalid scalar is OFF; masking is fail-closed on its own persistUserIdentifier: 'default', // default OFF + captureUserIdle: 'default', // default OFF (opt-in behavioural trace): only boolean true enables reportSummaryRequired: 'default', reportDescriptionRequired: 'default', reportEmailRequired: 'default', diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index 9a76227c..68afeb3b 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -152,6 +152,7 @@ const RESET_OPTIONS: readonly string[] = [ 'captureSourceContext', 'captureSystemEvents', 'captureSystemTraces', + 'captureUserIdle', 'captureViewHierarchy', 'controlNonce', 'crashReporter', diff --git a/packages/trackers/README.md b/packages/trackers/README.md index 24c72556..00b201e0 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 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) 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 9676e939..c560ed79 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, 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, 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/index.ts b/packages/trackers/src/browser/index.ts index ef386e74..01362b6c 100644 --- a/packages/trackers/src/browser/index.ts +++ b/packages/trackers/src/browser/index.ts @@ -26,6 +26,18 @@ export { powerTrackerFor, resetPowerTracker, } from './power-tracker'; +export { + type HeapReading, + type HeapState, + type ProcessEnv, + type ProcessEvents, + type ProcessTimers, + ProcessTracker, + type ProcessTrackerEnv, + processTrackerFor, + resetProcessTracker, + type UserIdleState, +} from './process-tracker'; export { resetStorageTracker, type StorageEnv, diff --git a/packages/trackers/src/browser/process-tracker.test.ts b/packages/trackers/src/browser/process-tracker.test.ts new file mode 100644 index 00000000..946a52f4 --- /dev/null +++ b/packages/trackers/src/browser/process-tracker.test.ts @@ -0,0 +1,1981 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + IDLE_PERIOD_CAP_MS, + type ProcessEnv, + ProcessTracker, + processTrackerFor, + resetProcessTracker, + USER_IDLE_MIN_THRESHOLD_MS, +} from './process-tracker'; + +const flush = (): Promise => + new Promise((r) => + (globalThis as unknown as { setTimeout(f: () => void, ms: number): void }).setTimeout( + () => r(), + 0, + ), + ); + +function mkScheduler() { + const timers = new Map void; ms: number }>(); + let next = 1; + const s = { + setInterval: vi.fn((cb: () => void, ms: number) => { + const h = next++; + timers.set(h, { cb, ms }); + return h; + }), + clearInterval: vi.fn((h: unknown) => void timers.delete(h as number)), + tick: () => + [...timers.values()].forEach((t) => { + t.cb(); + }), + /** Fire every timer ever armed, including ones whose clear "failed" (kept below). */ + live: () => timers.size, + periods: () => [...timers.values()].map((t) => t.ms), + }; + return s; +} + +function mkLifecycle() { + const subs = new Set<(p: { visible: boolean }) => void>(); + const unsub = vi.fn(); + return { + on: vi.fn((_name: string, l: (p: { visible: boolean }) => void) => { + subs.add(l); + return () => { + unsub(); + subs.delete(l); + }; + }), + emit: (visible: boolean) => + subs.forEach((l) => { + l({ visible }); + }), + count: () => subs.size, + unsub, + }; +} + +function mkClock(start = 1000) { + let t = start; + return { + performance: { now: () => t }, + set: (v: number) => { + t = v; + }, + advance: (d: number) => { + t += d; + }, + }; +} + +const mem = (used: number, limit = 100) => ({ + usedJSHeapSize: used, + totalJSHeapSize: limit / 2, + jsHeapSizeLimit: limit, +}); + +type Env = ProcessEnv; +const asEnv = (e: unknown): Env => e as Env; + +describe('ProcessTracker — activation', () => { + it('constructs inert: no timer, no read, no subscription', () => { + const s = mkScheduler(); + const lc = mkLifecycle(); + const read = vi.fn(() => mem(1)); + new ProcessTracker( + asEnv({ + scheduler: s, + lifecycle: lc as never, + performance: { + get memory() { + return read(); + }, + now: () => 1, + }, + }), + ); + expect(s.setInterval).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); + expect(lc.on).not.toHaveBeenCalled(); + }); + + it('arms ONLY the stage that has a listener, and releases it with the last one', () => { + const s = mkScheduler(); + const lc = mkLifecycle(); + const clock = mkClock(); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + lifecycle: lc as never, + document: { visibilityState: 'visible' }, + performance: { memory: mem(1), now: clock.performance.now }, + }), + ); + const off = t.on('heap', () => {}); + expect(s.setInterval).toHaveBeenCalledTimes(1); + expect(lc.on).not.toHaveBeenCalled(); // lag/idle not armed + const off2 = t.on('heap', () => {}); + expect(s.setInterval).toHaveBeenCalledTimes(1); + off(); + expect(s.live()).toBe(1); + off2(); + expect(s.live()).toBe(0); + }); + + it('an onAny listener arms every stage; start()/stop() do nothing by themselves', () => { + const s = mkScheduler(); + const lc = mkLifecycle(); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + lifecycle: lc as never, + document: { visibilityState: 'visible' }, + requestIdleCallback: () => 1, + performance: { memory: mem(1), now: () => 1 }, + }), + ); + t.start(); + t.stop(); + expect(s.setInterval).not.toHaveBeenCalled(); + const off = t.onAny(() => {}); + // heap + uptime + lag (visible) + idle (visible) + expect(s.live()).toBe(4); + off(); + expect(s.live()).toBe(0); + expect(lc.count()).toBe(0); + }); + + it('a throwing onError never escapes the emitter dispatch', () => { + const s = mkScheduler(); + const t = new ProcessTracker(asEnv({ scheduler: s, performance: { memory: mem(1) } }), () => { + throw new Error('reporter'); + }); + t.on('heap', () => { + throw new Error('listener'); + }); + expect(() => s.tick()).not.toThrow(); + }); + + it('retries an inert stage only after its listeners have all left', () => { + let memory: unknown; + const s = mkScheduler(); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + performance: { + get memory() { + return memory; + }, + }, + }), + ); + const off = t.on('heap', () => {}); + expect(s.live()).toBe(0); // no performance.memory + memory = mem(1); + const other = t.on('uptime', () => {}); // an unrelated mutation does not re-probe heap + expect(s.live()).toBe(0); + other(); + off(); + t.on('heap', () => {}); + expect(s.live()).toBe(1); + }); +}); + +describe('ProcessTracker — heap', () => { + it('polls on ONE shared timer at the default 1000 ms and emits {used, limit} per tick', () => { + const s = mkScheduler(); + let used = 10; + const t = new ProcessTracker( + asEnv({ + scheduler: s, + performance: { + get memory() { + return mem(used, 200); + }, + }, + }), + ); + const a: unknown[] = []; + const b: unknown[] = []; + t.on('heap', (m) => a.push(m)); + t.on('heap', (m) => b.push(m)); + expect(s.periods()).toEqual([1000]); + used = 20; + s.tick(); + expect(a).toEqual([{ used: 20, limit: 200 }]); + expect(b).toEqual(a); + }); + + it('honours intervalMs', () => { + const s = mkScheduler(); + const t = new ProcessTracker( + asEnv({ scheduler: s, intervalMs: 250, performance: { memory: mem(1) } }), + ); + t.on('heap', () => {}); + expect(s.periods()).toEqual([250]); + }); + + it('uses the global timers when no scheduler is injected', () => { + vi.useFakeTimers(); + try { + const t = new ProcessTracker(asEnv({ intervalMs: 10, performance: { memory: mem(5) } })); + const out: unknown[] = []; + const off = t.on('heap', (m) => out.push(m)); + vi.advanceTimersByTime(25); + expect(out).toEqual([ + { used: 5, limit: 100 }, + { used: 5, limit: 100 }, + ]); + off(); + vi.advanceTimersByTime(50); + expect(out).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it('no performance.memory (or no performance) -> no timer, no emission', () => { + const s = mkScheduler(); + const t = new ProcessTracker(asEnv({ scheduler: s, performance: {} })); + t.on('heap', () => {}); + const u = new ProcessTracker(asEnv({ scheduler: s })); + u.on('heap', () => {}); + expect(s.live()).toBe(0); + expect(t.currentHeap()).toBeUndefined(); + }); + + it('a memory read that THROWS is reported once and polling still starts (transient)', () => { + const s = mkScheduler(); + const boom = new Error('getter'); + let fail = true; + const onError = vi.fn(); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + performance: { + get memory() { + if (fail) throw boom; + return mem(7); + }, + }, + }), + onError, + ); + const out: unknown[] = []; + t.on('heap', (m) => out.push(m)); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(boom); + expect(s.live()).toBe(1); + s.tick(); // still throwing: reported, nothing emitted, never thrown + expect(out).toEqual([]); + expect(onError).toHaveBeenCalledTimes(2); + fail = false; + s.tick(); + expect(out).toEqual([{ used: 7, limit: 100 }]); + }); + + it('omits an unusable reading (never 0, never NaN) and reports a throwing field', () => { + const s = mkScheduler(); + const onError = vi.fn(); + let reading: unknown = mem(1); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + performance: { + get memory() { + return reading; + }, + }, + }), + onError, + ); + const out: unknown[] = []; + t.on('heap', (m) => out.push(m)); + for (const bad of [ + { usedJSHeapSize: Number.NaN, jsHeapSizeLimit: 5 }, + { usedJSHeapSize: -1, jsHeapSizeLimit: 5 }, + { usedJSHeapSize: 1, jsHeapSizeLimit: Number.POSITIVE_INFINITY }, + { usedJSHeapSize: '1', jsHeapSizeLimit: 5 }, + {}, + ]) { + reading = bad; + s.tick(); + } + expect(out).toEqual([]); + reading = { + get usedJSHeapSize(): number { + throw new Error('field'); + }, + jsHeapSizeLimit: 5, + }; + s.tick(); + expect(out).toEqual([]); + expect(onError).toHaveBeenCalledTimes(1); + reading = mem(0, 5); // a real zero IS a measurement + s.tick(); + expect(out).toEqual([{ used: 0, limit: 5 }]); + }); + + it('currentHeap reads live and is undefined when nothing is available', () => { + let used = 3; + const t = new ProcessTracker( + asEnv({ + performance: { + get memory() { + return mem(used, 9); + }, + }, + }), + ); + expect(t.currentHeap()).toEqual({ used: 3, limit: 9 }); + used = 4; + expect(t.currentHeap()).toEqual({ used: 4, limit: 9 }); + expect(new ProcessTracker({}).currentHeap()).toBeUndefined(); + }); + + it('a throwing scheduler is reported, leaves the stage inert, and never leaks out of on()', () => { + const boom = new Error('sched'); + const onError = vi.fn(); + const t = new ProcessTracker( + asEnv({ + scheduler: { + setInterval: () => { + throw boom; + }, + clearInterval: () => {}, + }, + performance: { memory: mem(1) }, + }), + onError, + ); + expect(() => t.on('heap', () => {})).not.toThrow(); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('a clearInterval that throws is reported, and that timer can never emit again', () => { + const boom = new Error('clear'); + const onError = vi.fn(); + const cbs: Array<() => void> = []; + const t = new ProcessTracker( + asEnv({ + scheduler: { + setInterval: (cb: () => void) => { + cbs.push(cb); + return 1; + }, + clearInterval: () => { + throw boom; + }, + }, + performance: { memory: mem(1) }, + }), + onError, + ); + const out: unknown[] = []; + const off = t.on('heap', (m) => out.push(m)); + off(); + expect(onError).toHaveBeenCalledWith(boom); + cbs[0]?.(); // the leaked timer fires + expect(out).toEqual([]); + }); +}); + +describe('ProcessTracker — uptime', () => { + it('emits performance.now() each tick', () => { + const s = mkScheduler(); + const clock = mkClock(5); + const t = new ProcessTracker(asEnv({ scheduler: s, performance: clock.performance })); + const out: unknown[] = []; + t.on('uptime', (u) => out.push(u)); + clock.set(1500); + s.tick(); + clock.set(2500); + s.tick(); + expect(out).toEqual([{ uptimeMs: 1500 }, { uptimeMs: 2500 }]); + }); + + it('omitted without a clock; unusable readings are dropped, a throwing clock is reported', () => { + const s = mkScheduler(); + const t = new ProcessTracker(asEnv({ scheduler: s, performance: {} })); + t.on('uptime', () => {}); + expect(s.live()).toBe(0); + const onError = vi.fn(); + let v: number = 1; + let fail = false; + const u = new ProcessTracker( + asEnv({ + scheduler: s, + performance: { + now: () => { + if (fail) throw new Error('clock'); + return v; + }, + }, + }), + onError, + ); + const out: unknown[] = []; + u.on('uptime', (x) => out.push(x)); + v = Number.NaN; + s.tick(); + v = -5; + s.tick(); + fail = true; + s.tick(); + expect(out).toEqual([]); + expect(onError).toHaveBeenCalledTimes(1); + fail = false; + v = 0; + s.tick(); + expect(out).toEqual([{ uptimeMs: 0 }]); + }); + + it('a hostile performance getter is reported and the stage stays inert', () => { + const onError = vi.fn(); + const s = mkScheduler(); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + get performance(): never { + throw new Error('perf'); + }, + }), + onError, + ); + t.on('uptime', () => {}); + expect(onError).toHaveBeenCalled(); + expect(s.live()).toBe(0); + }); + + it('a hostile performance.now getter is reported and the stage stays inert', () => { + const onError = vi.fn(); + const s = mkScheduler(); + const t = new ProcessTracker( + asEnv({ + scheduler: s, + performance: { + get now(): never { + throw new Error('now'); + }, + }, + }), + onError, + ); + t.on('uptime', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + expect(s.live()).toBe(0); + }); +}); + +function lagEnv(extra: Partial = {}, visible = 'visible') { + const s = mkScheduler(); + const lc = mkLifecycle(); + const clock = mkClock(1000); + const doc = { visibilityState: visible }; + const env = asEnv({ + scheduler: s, + lifecycle: lc, + document: doc, + performance: clock.performance, + ...extra, + }); + return { s, lc, clock, doc, env }; +} + +describe('ProcessTracker — mainThreadLag (timer drift)', () => { + it('a clock that is non-finite at the start gives no baseline: that tick emits nothing (never NaN), then it recovers', () => { + const onError = vi.fn(); + let t = Number.NaN; + const { s, env } = lagEnv({ performance: { now: () => t } }); + const tracker = new ProcessTracker(env, onError); + const out: number[] = []; + tracker.on('mainThreadLag', (l) => out.push(l.lagMs)); + t = 2000; + s.tick(); // establishes the baseline only + expect(out).toEqual([]); + t = 3005; + s.tick(); // 5 ms late against 2000 + 1000 + expect(out).toEqual([5]); + expect(onError).not.toHaveBeenCalled(); + }); + + it('a hostile performance.now getter is reported ONCE across stop/restart of the same stages', () => { + const onError = vi.fn(); + const { env } = lagEnv({ + performance: { + get now(): never { + throw new Error('now'); + }, + }, + }); + const tracker = new ProcessTracker(env, onError); + for (let i = 0; i < 3; i += 1) { + const a = tracker.on('mainThreadLag', () => {}); + const b = tracker.on('uptime', () => {}); + a(); + b(); + } + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('measures drift against the CONFIGURED interval (250 ms), not the default', () => { + const { s, clock, env } = lagEnv({ intervalMs: 250 }); + const t = new ProcessTracker(env); + const out: number[] = []; + t.on('mainThreadLag', (l) => out.push(l.lagMs)); + expect(s.periods()).toEqual([250]); + clock.set(1250); // on time for 250 + s.tick(); + clock.set(1500); // exactly 250 after the previous tick: on time + s.tick(); + clock.set(1760); // 10 ms late against 1500 + 250 + s.tick(); + clock.set(2010); // on time against 1760 + 250 + s.tick(); + expect(out).toEqual([0, 0, 10, 0]); + }); + + it('first tick compares against start + interval; later ticks against the previous tick + interval', () => { + const { s, clock, env } = lagEnv(); + const t = new ProcessTracker(env); + const out: number[] = []; + t.on('mainThreadLag', (l) => out.push(l.lagMs)); + clock.set(2000); // on time + s.tick(); + clock.set(3003); // 3 ms late + s.tick(); + clock.set(5500); // 1497 late (a block) + s.tick(); + clock.set(6500); // measured from the late tick: on time -> 0 (a block is reported ONCE) + s.tick(); + expect(out).toEqual([0, 3, 1497, 0]); + }); + + it('a hair EARLY clamps to 0, never negative', () => { + const { s, clock, env } = lagEnv(); + const t = new ProcessTracker(env); + const out: number[] = []; + t.on('mainThreadLag', (l) => out.push(l.lagMs)); + clock.set(1999); + s.tick(); + expect(out).toEqual([0]); + }); + + it('runs only while visible: hidden tears the timer down, visible starts a FRESH baseline', () => { + const { s, lc, clock, doc, env } = lagEnv(); + const t = new ProcessTracker(env); + const out: number[] = []; + t.on('mainThreadLag', (l) => out.push(l.lagMs)); + expect(s.live()).toBe(1); + doc.visibilityState = 'hidden'; + lc.emit(false); + expect(s.live()).toBe(0); + clock.set(900_000); // a long hidden stretch must never count as lag + doc.visibilityState = 'visible'; + lc.emit(true); + expect(s.live()).toBe(1); + clock.set(901_000); + s.tick(); + expect(out).toEqual([0]); + lc.emit(true); // already running: no second timer + expect(s.live()).toBe(1); + }); + + it('starts nothing on a hidden or unknown-visibility page, and the visible event starts it', () => { + const hidden = lagEnv({}, 'hidden'); + const t = new ProcessTracker(hidden.env); + t.on('mainThreadLag', () => {}); + expect(hidden.s.live()).toBe(0); + expect(hidden.lc.count()).toBe(1); + hidden.lc.emit(true); + expect(hidden.s.live()).toBe(1); + + const unknown = lagEnv({ document: undefined }); + const u = new ProcessTracker(unknown.env); + u.on('mainThreadLag', () => {}); + expect(unknown.s.live()).toBe(0); + }); + + it('a throwing visibility read counts as not visible and is reported', () => { + const onError = vi.fn(); + const { s, env } = lagEnv({ + document: { + get visibilityState(): never { + throw new Error('doc'); + }, + }, + }); + new ProcessTracker(env, onError).on('mainThreadLag', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + expect(s.live()).toBe(0); + }); + + it('is omitted (inert, no subscription) without a clock or without a lifecycle', () => { + const noClock = lagEnv({ performance: {} }); + new ProcessTracker(noClock.env).on('mainThreadLag', () => {}); + expect(noClock.lc.on).not.toHaveBeenCalled(); + expect(noClock.s.live()).toBe(0); + const noLifecycle = lagEnv({ lifecycle: undefined }); + new ProcessTracker(noLifecycle.env).on('mainThreadLag', () => {}); + expect(noLifecycle.s.live()).toBe(0); + }); + + it('a hidden event while nothing is running is a no-op', () => { + const { s, lc, env } = lagEnv({}, 'hidden'); + new ProcessTracker(env).on('mainThreadLag', () => {}); + expect(() => lc.emit(false)).not.toThrow(); + expect(s.live()).toBe(0); + }); + + it('stopping unsubscribes from the lifecycle and clears the timer', () => { + const { s, lc, env } = lagEnv(); + const off = new ProcessTracker(env).on('mainThreadLag', () => {}); + off(); + expect(s.live()).toBe(0); + expect(lc.unsub).toHaveBeenCalledTimes(1); + expect(lc.count()).toBe(0); + }); + + it('a throwing clock at start rolls the run back, is reported, and a later visible start recovers', () => { + const onError = vi.fn(); + let fail = true; + const { s, lc, env } = lagEnv({ + performance: { + now: () => { + if (fail) throw new Error('clock'); + return 1000; + }, + }, + }); + new ProcessTracker(env, onError).on('mainThreadLag', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + expect(s.live()).toBe(0); + fail = false; + lc.emit(true); + expect(s.live()).toBe(1); + }); + + it('a throwing scheduler at start rolls back and is reported', () => { + const onError = vi.fn(); + const boom = new Error('sched'); + const { env } = lagEnv({ + scheduler: { + setInterval: () => { + throw boom; + }, + clearInterval: () => {}, + }, + }); + new ProcessTracker(env, onError).on('mainThreadLag', () => {}); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('a throwing or non-finite clock at a tick skips the tick without reading the gap as lag', () => { + const onError = vi.fn(); + let mode: 'ok' | 'throw' | 'nan' = 'ok'; + let now = 1000; + const { s, env } = lagEnv({ + performance: { + now: () => { + if (mode === 'throw') throw new Error('clock'); + return mode === 'nan' ? Number.NaN : now; + }, + }, + }); + const t = new ProcessTracker(env, onError); + const out: number[] = []; + t.on('mainThreadLag', (l) => out.push(l.lagMs)); + mode = 'throw'; + s.tick(); + expect(onError).toHaveBeenCalledTimes(1); + mode = 'nan'; + s.tick(); + mode = 'ok'; + now = 4500; // expectedAt advanced past both skipped ticks (start 1000 + 3 intervals = 4000) + s.tick(); + expect(out).toEqual([500]); + }); + + it('a lifecycle that throws on subscribe is reported and leaves the stage inert', () => { + const onError = vi.fn(); + const boom = new Error('lc'); + const { s, env } = lagEnv({ + lifecycle: { + on: () => { + throw boom; + }, + } as never, + }); + expect(() => new ProcessTracker(env, onError).on('mainThreadLag', () => {})).not.toThrow(); + expect(onError).toHaveBeenCalledWith(boom); + expect(s.live()).toBe(0); + }); + + it('a teardown that throws while hidden is reported', () => { + const onError = vi.fn(); + const lc = mkLifecycle(); + const { env } = lagEnv({ + lifecycle: lc as never, + scheduler: { + setInterval: () => 1, + clearInterval: () => { + throw new Error('clear'); + }, + }, + }); + new ProcessTracker(env, onError).on('mainThreadLag', () => {}); + lc.emit(false); + expect(onError).toHaveBeenCalledTimes(1); + }); +}); + +function idleEnv(extra: Partial = {}, visible = 'visible') { + const base = lagEnv({}, visible); + const callbacks = new Map void>(); + let next = 1; + const ric = vi.fn((cb: (d: { timeRemaining(): number }) => void) => { + const h = next++; + callbacks.set(h, cb); + return h; + }); + const cancel = vi.fn((h: number) => void callbacks.delete(h)); + const env = asEnv({ + ...base.env, + requestIdleCallback: ric, + cancelIdleCallback: cancel, + ...extra, + }); + const grant = (remaining: number | (() => number)) => { + const entries = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of entries) { + cb({ timeRemaining: typeof remaining === 'function' ? remaining : () => remaining }); + } + }; + return { ...base, env, ric, cancel, grant, callbacks }; +} + +describe('ProcessTracker — idle (requestIdleCallback probe)', () => { + it('probes ONCE per tick, emits timeRemaining clamped to 50 ms, and never stacks probes', () => { + const { s, env, ric, grant } = idleEnv(); + const t = new ProcessTracker(env); + const out: number[] = []; + t.on('idle', (i) => out.push(i.idleMs)); + expect(ric).not.toHaveBeenCalled(); // nothing at activation: only per tick + s.tick(); + s.tick(); // previous probe still pending: no second one + expect(ric).toHaveBeenCalledTimes(1); + grant(12.5); + expect(out).toEqual([12.5]); + s.tick(); + grant(500); // a deadline can be larger than the spec cap + expect(out).toEqual([12.5, IDLE_PERIOD_CAP_MS]); + s.tick(); + grant(0); // a granted zero is a real zero + expect(out).toEqual([12.5, 50, 0]); + }); + + it('a busy page that never grants the callback produces NO sample (not 0)', () => { + const { s, env, ric } = idleEnv(); + const out: number[] = []; + new ProcessTracker(env).on('idle', (i) => out.push(i.idleMs)); + s.tick(); + s.tick(); + s.tick(); + expect(ric).toHaveBeenCalledTimes(1); + expect(out).toEqual([]); + }); + + it('omits invalid timeRemaining values and reports a throwing one', () => { + const onError = vi.fn(); + const { s, env, grant } = idleEnv(); + const out: number[] = []; + new ProcessTracker(env, onError).on('idle', (i) => out.push(i.idleMs)); + for (const bad of [Number.NaN, -1, Number.POSITIVE_INFINITY]) { + s.tick(); + grant(bad); + } + expect(out).toEqual([]); + s.tick(); + grant(() => { + throw new Error('tr'); + }); + expect(onError).toHaveBeenCalledTimes(1); + s.tick(); // a thrown read left no probe stuck pending + grant(5); + expect(out).toEqual([5]); + }); + + it('is omitted where requestIdleCallback is absent (Safari) or without a lifecycle', () => { + const noRic = idleEnv({ requestIdleCallback: undefined }); + new ProcessTracker(noRic.env).on('idle', () => {}); + expect(noRic.lc.on).not.toHaveBeenCalled(); + expect(noRic.s.live()).toBe(0); + const noLc = idleEnv({ lifecycle: undefined }); + new ProcessTracker(noLc.env).on('idle', () => {}); + expect(noLc.s.live()).toBe(0); + }); + + it('a hostile requestIdleCallback getter is reported and the stage is inert', () => { + const onError = vi.fn(); + const { s, env } = idleEnv(); + Object.defineProperty(env, 'requestIdleCallback', { + get(): never { + throw new Error('ric'); + }, + }); + new ProcessTracker(env, onError).on('idle', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + expect(s.live()).toBe(0); + }); + + it('is gated to visible pages: hidden cancels the outstanding probe and the timer', () => { + const { s, lc, env, ric, cancel, doc } = idleEnv(); + const out: number[] = []; + new ProcessTracker(env).on('idle', (i) => out.push(i.idleMs)); + s.tick(); + expect(ric).toHaveBeenCalledTimes(1); + doc.visibilityState = 'hidden'; + lc.emit(false); + expect(cancel).toHaveBeenCalledWith(1); + expect(s.live()).toBe(0); + lc.emit(true); + expect(s.live()).toBe(1); + s.tick(); + expect(ric).toHaveBeenCalledTimes(2); + }); + + it('a callback that fires after teardown (cancel ignored) is inert', () => { + const { s, env, callbacks } = idleEnv({ cancelIdleCallback: () => {} }); + const out: number[] = []; + const off = new ProcessTracker(env).on('idle', (i) => out.push(i.idleMs)); + s.tick(); + const stale = [...callbacks.values()][0]; + off(); + stale?.({ timeRemaining: () => 9 }); + expect(out).toEqual([]); + }); + + it('a stale callback from a torn-down run neither emits to nor unblocks the next run', () => { + const { s, env, ric, callbacks } = idleEnv({ cancelIdleCallback: () => {} }); + const t = new ProcessTracker(env); + const out: number[] = []; + const off = t.on('idle', () => {}); + s.tick(); + const stale = [...callbacks.values()][0]; + off(); + t.on('idle', (i) => out.push(i.idleMs)); + s.tick(); // the new run's own probe is pending + expect(ric).toHaveBeenCalledTimes(2); + stale?.({ timeRemaining: () => 9 }); + expect(out).toEqual([]); + s.tick(); // the stale callback did not clear the new run's pending probe + expect(ric).toHaveBeenCalledTimes(2); + }); + + it('works without cancelIdleCallback, and reports a throwing cancel (timer still released)', () => { + const noCancel = idleEnv({ cancelIdleCallback: undefined }); + const off = new ProcessTracker(noCancel.env).on('idle', () => {}); + noCancel.s.tick(); + expect(() => off()).not.toThrow(); + expect(noCancel.s.live()).toBe(0); + + const onError = vi.fn(); + const boom = idleEnv({ + cancelIdleCallback: () => { + throw new Error('cancel'); + }, + }); + const off2 = new ProcessTracker(boom.env, onError).on('idle', () => {}); + boom.s.tick(); + off2(); + expect(onError).toHaveBeenCalledTimes(1); + expect(boom.s.live()).toBe(0); + }); + + it('tearing down with NO pending probe cancels nothing and reports nothing', () => { + const onError = vi.fn(); + const { s, env } = idleEnv(); + const cancel = env.cancelIdleCallback as ReturnType; + const off = new ProcessTracker(env, onError).on('idle', () => {}); + expect(s.live()).toBe(1); // armed, but no tick has queued a probe + off(); + expect(cancel).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(s.live()).toBe(0); + }); + + it('a throwing requestIdleCallback is reported by the tick and does not wedge later probes', () => { + const onError = vi.fn(); + let fail = true; + const { s, env, grant } = idleEnv({ + requestIdleCallback: ((cb: (d: { timeRemaining(): number }) => void) => { + if (fail) throw new Error('ric'); + cb({ timeRemaining: () => 3 }); + return 1; + }) as never, + }); + const out: number[] = []; + new ProcessTracker(env, onError).on('idle', (i) => out.push(i.idleMs)); + s.tick(); + expect(onError).toHaveBeenCalledTimes(1); + fail = false; + s.tick(); + expect(out).toEqual([3]); + void grant; + }); + + it('a timer whose clear failed does not probe', () => { + const { env, ric } = idleEnv(); + const cbs: Array<() => void> = []; + const e = asEnv({ + ...env, + scheduler: { + setInterval: (cb: () => void) => { + cbs.push(cb); + return 1; + }, + clearInterval: () => { + throw new Error('clear'); + }, + }, + }); + const onError = vi.fn(); + const off = new ProcessTracker(e, onError).on('idle', () => {}); + off(); + cbs[0]?.(); + expect(ric).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); +}); + +// ---- userIdle -------------------------------------------------------------------------------------------------- + +function mkDetector() { + const listeners = new Set<() => void>(); + const d = { + userState: 'active' as unknown, + screenState: 'unlocked' as unknown, + startCalls: [] as Array<{ threshold: number; signal: unknown }>, + startResult: () => Promise.resolve(undefined as unknown), + start(options: { threshold: number; signal: unknown }) { + d.startCalls.push(options); + return d.startResult(); + }, + addEventListener: vi.fn((_t: string, l: () => void) => void listeners.add(l)), + removeEventListener: vi.fn((_t: string, l: () => void) => void listeners.delete(l)), + fire: () => + listeners.forEach((l) => { + l(); + }), + listenerCount: () => listeners.size, + }; + return d; +} + +function mkPermission(state: unknown) { + const listeners = new Set<() => void>(); + const status = { + state, + addEventListener: vi.fn((_t: string, l: () => void) => void listeners.add(l)), + removeEventListener: vi.fn((_t: string, l: () => void) => void listeners.delete(l)), + set(next: unknown) { + status.state = next; + listeners.forEach((l) => { + l(); + }); + }, + listenerCount: () => listeners.size, + }; + return status; +} + +function userEnv( + opts: { state?: unknown; detector?: ReturnType } & Partial = {}, +) { + const status = mkPermission('state' in opts ? opts.state : 'granted'); + const detector = opts.detector ?? mkDetector(); + const requestPermission = vi.fn(); + const aborts: Array<{ signal: object; abort: ReturnType }> = []; + class FakeDetector { + static requestPermission = requestPermission; + userState: unknown; + screenState: unknown; + constructor() { + // biome-ignore lint/correctness/noConstructorReturn: a test double hands the shared fake detector back from `new`. + return detector as unknown as FakeDetector; + } + } + class FakeAbort { + signal = {}; + abort = vi.fn(); + constructor() { + aborts.push(this); + } + } + // Called as a method of `permissions` (it needs its receiver, like the real one): any other `this` rejects. + const query = vi.fn(function (this: unknown, _d: { name: string }) { + return this === permissions + ? Promise.resolve(status) + : Promise.reject(new TypeError('Illegal invocation')); + }); + const permissions = { query }; + const { state: _s, detector: _d, ...rest } = opts; + const env = asEnv({ + navigator: { permissions }, + IdleDetector: FakeDetector, + AbortController: FakeAbort, + ...rest, + }); + return { env, status, detector, query, requestPermission, aborts }; +} + +describe('ProcessTracker — userIdle (Idle Detection, never prompts)', () => { + it('starts the detector ONLY when idle-detection is already granted; never requests permission', async () => { + const { env, detector, query, requestPermission, aborts } = userEnv(); + const t = new ProcessTracker(env); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + expect(query).toHaveBeenCalledWith({ name: 'idle-detection' }); + await flush(); + expect(requestPermission).not.toHaveBeenCalled(); + expect(detector.startCalls).toEqual([ + { threshold: USER_IDLE_MIN_THRESHOLD_MS, signal: aborts[0]?.signal }, + ]); + expect(out).toEqual([{ userState: 'active', screenState: 'unlocked' }]); + expect(t.currentUserIdle()).toEqual({ userState: 'active', screenState: 'unlocked' }); + detector.userState = 'idle'; + detector.screenState = 'locked'; + detector.fire(); + expect(out.at(-1)).toEqual({ userState: 'idle', screenState: 'locked' }); + }); + + it.each([ + 'prompt', + 'denied', + undefined, + 'garbage', + ])('permission %s: no detector, no emission, no trace', async (state) => { + const { env, detector, requestPermission } = userEnv({ state }); + const t = new ProcessTracker(env); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + await flush(); + expect(detector.startCalls).toEqual([]); + expect(requestPermission).not.toHaveBeenCalled(); + expect(out).toEqual([]); + expect(t.currentUserIdle()).toBeUndefined(); + }); + + it('honours a larger threshold but floors anything lower (or invalid) at 60 s', async () => { + for (const [asked, expected] of [ + [120_000, 120_000], + [5_000, 60_000], + [Number.NaN, 60_000], + [undefined, 60_000], + ] as const) { + const { env, detector } = userEnv({ + ...(asked !== undefined ? { userIdleThresholdMs: asked } : {}), + }); + new ProcessTracker(env).on('userIdle', () => {}); + await flush(); + expect(detector.startCalls[0]?.threshold).toBe(expected); + } + }); + + it('is inert when any piece is missing, or when the engine does not know the permission', async () => { + const e = userEnv(); + for (const missing of ['navigator', 'IdleDetector', 'AbortController'] as const) { + const env = asEnv({ ...e.env, [missing]: undefined }); + const t = new ProcessTracker(env); + t.on('userIdle', () => {}); + await flush(); + expect(e.detector.startCalls).toEqual([]); + } + const noQuery = asEnv({ ...e.env, navigator: { permissions: {} } }); + new ProcessTracker(noQuery).on('userIdle', () => {}); + // query() rejects (unknown permission name, e.g. Firefox/Safari): silent, nothing emitted + const onError = vi.fn(); + const rejecting = asEnv({ + ...e.env, + navigator: { permissions: { query: () => Promise.reject(new TypeError('unknown')) } }, + }); + new ProcessTracker(rejecting, onError).on('userIdle', () => {}); + // query() throws synchronously: the same + const throwing = asEnv({ + ...e.env, + navigator: { + permissions: { + query: () => { + throw new TypeError('unknown'); + }, + }, + }, + }); + new ProcessTracker(throwing, onError).on('userIdle', () => {}); + await flush(); + expect(e.detector.startCalls).toEqual([]); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reports hostile host getters and stays inert', () => { + const onError = vi.fn(); + const env = asEnv({ + get navigator(): never { + throw new Error('nav'); + }, + }); + new ProcessTracker(env, onError).on('userIdle', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('stopping aborts the detector, removes its listener, and ignores a late permission answer', async () => { + const { env, detector, status, aborts } = userEnv(); + const t = new ProcessTracker(env); + const off = t.on('userIdle', () => {}); + await flush(); + expect(detector.listenerCount()).toBe(1); + expect(status.listenerCount()).toBe(1); + off(); + expect(aborts[0]?.abort).toHaveBeenCalledTimes(1); + expect(detector.listenerCount()).toBe(0); + expect(status.listenerCount()).toBe(0); + + // a permission answer that lands AFTER stop starts nothing + let resolve: (s: unknown) => void = () => {}; + const slow = userEnv(); + const env2 = asEnv({ + ...slow.env, + navigator: { + permissions: { + query: () => + new Promise((r) => { + resolve = r; + }), + }, + }, + }); + const t2 = new ProcessTracker(env2); + const off2 = t2.on('userIdle', () => {}); + off2(); + const lateStatus = mkPermission('granted'); + resolve(lateStatus); + await flush(); + expect(slow.detector.startCalls).toEqual([]); + expect(lateStatus.addEventListener).not.toHaveBeenCalled(); // no listener was attached after the stop + expect(lateStatus.listenerCount()).toBe(0); + }); + + it('retains the last state across a stop; re-activation re-queries the permission', async () => { + const { env, status, query } = userEnv(); + const t = new ProcessTracker(env); + const off = t.on('userIdle', () => {}); + await flush(); + off(); + expect(t.currentUserIdle()).toEqual({ userState: 'active', screenState: 'unlocked' }); + status.state = 'denied'; + t.on('userIdle', () => {}); + expect(query).toHaveBeenCalledTimes(2); + await flush(); + expect(t.currentUserIdle()).toBeUndefined(); // no longer entitled: the snapshot is dropped + }); + + it('a permission that becomes granted later starts the detector; one that is revoked aborts it', async () => { + const { env, status, detector, aborts } = userEnv({ state: 'prompt' }); + const t = new ProcessTracker(env); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + await flush(); + expect(detector.startCalls).toEqual([]); + status.set('granted'); + await flush(); + expect(detector.startCalls).toHaveLength(1); + expect(out).toHaveLength(1); + status.set('granted'); // already running: no second detector + expect(detector.startCalls).toHaveLength(1); + status.set('denied'); + expect(aborts[0]?.abort).toHaveBeenCalledTimes(1); + expect(detector.listenerCount()).toBe(0); + expect(t.currentUserIdle()).toBeUndefined(); + }); + + it('omits an unusable userState, and drops a bogus screenState but keeps the user state', async () => { + const detector = mkDetector(); + detector.userState = 'bogus'; + const { env } = userEnv({ detector }); + const t = new ProcessTracker(env); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + await flush(); + expect(out).toEqual([]); + detector.userState = 'idle'; + detector.screenState = 'sideways'; + detector.fire(); + expect(out).toEqual([{ userState: 'idle' }]); + }); + + it('reports a throwing detector getter (host read) and never throws into the event dispatch', async () => { + const onError = vi.fn(); + const detector = mkDetector(); + const { env } = userEnv({ detector }); + const t = new ProcessTracker(env, onError); + t.on('userIdle', () => {}); + await flush(); + Object.defineProperty(detector, 'userState', { + get(): never { + throw new Error('state'); + }, + }); + expect(() => detector.fire()).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('a start() that REJECTS ends that detector quietly; a later grant retries', async () => { + const onError = vi.fn(); + const detector = mkDetector(); + detector.startResult = () => Promise.reject(new Error('NotAllowedError')); + const { env, status, aborts } = userEnv({ detector }); + const t = new ProcessTracker(env, onError); + t.on('userIdle', () => {}); + await flush(); + expect(onError).not.toHaveBeenCalled(); + expect(aborts[0]?.abort).toHaveBeenCalled(); + expect(detector.listenerCount()).toBe(0); + detector.startResult = () => Promise.resolve(undefined); + status.set('granted'); + await flush(); + expect(detector.startCalls).toHaveLength(2); + expect(t.currentUserIdle()).toBeDefined(); + }); + + it('a start() settling after the run ended does not publish', async () => { + const detector = mkDetector(); + let settle: () => void = () => {}; + detector.startResult = () => + new Promise((r) => { + settle = () => r(undefined); + }); + const { env } = userEnv({ detector }); + const t = new ProcessTracker(env); + const out: unknown[] = []; + const off = t.on('userIdle', (u) => out.push(u)); + await flush(); + off(); + settle(); + await flush(); + expect(out).toEqual([]); + expect(t.currentUserIdle()).toBeUndefined(); + }); + + it('a detector that cannot be built, started or listened to is reported and cleaned up', async () => { + const onError = vi.fn(); + const boom = new Error('ctor'); + const e = userEnv(); + const env = asEnv({ + ...e.env, + IdleDetector: class { + constructor() { + throw boom; + } + }, + }); + new ProcessTracker(env, onError).on('userIdle', () => {}); + await flush(); + expect(onError).toHaveBeenCalledWith(boom); + + // start() returns a non-promise: `.then` throws -> reported + const detector = mkDetector(); + (detector as unknown as { start: () => unknown }).start = () => undefined; + const bad = userEnv({ detector }); + const onError2 = vi.fn(); + new ProcessTracker(bad.env, onError2).on('userIdle', () => {}); + await flush(); + expect(onError2).toHaveBeenCalledTimes(1); + expect(bad.aborts[0]?.abort).toHaveBeenCalled(); + }); + + it('reports abort() and permission-listener failures, and tolerates throwing removeEventListener', async () => { + const onError = vi.fn(); + const detector = mkDetector(); + detector.removeEventListener.mockImplementation(() => { + throw new Error('remove'); + }); + const { env, status, aborts } = userEnv({ detector }); + status.removeEventListener.mockImplementation(() => { + throw new Error('remove2'); + }); + const t = new ProcessTracker(env, onError); + const off = t.on('userIdle', () => {}); + await flush(); + (aborts[0]?.abort as ReturnType).mockImplementation(() => { + throw new Error('abort'); + }); + expect(() => off()).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('a permission status that cannot be listened to or read is reported', async () => { + const onError = vi.fn(); + const { env, status } = userEnv(); + status.addEventListener.mockImplementation(() => { + throw new Error('listen'); + }); + new ProcessTracker(env, onError).on('userIdle', () => {}); + await flush(); + expect(onError).toHaveBeenCalledTimes(1); + + const onError2 = vi.fn(); + const e = userEnv(); + Object.defineProperty(e.status, 'state', { + get(): never { + throw new Error('state'); + }, + }); + new ProcessTracker(e.env, onError2).on('userIdle', () => {}); + await flush(); + expect(onError2).toHaveBeenCalledTimes(1); + }); + + it('a status without addEventListener still works (no live watching)', async () => { + const e = userEnv(); + const bare = { state: 'granted' }; + const env = asEnv({ + ...e.env, + navigator: { permissions: { query: () => Promise.resolve(bare) } }, + }); + const off = new ProcessTracker(env).on('userIdle', () => {}); + await flush(); + expect(e.detector.startCalls).toHaveLength(1); + expect(() => off()).not.toThrow(); + }); + + it('a stale change/start/permission event after the run ended is ignored even when removal failed', async () => { + // detector listener removal throws: the stale onChange is still reachable + const d1 = mkDetector(); + d1.removeEventListener.mockImplementation(() => { + throw new Error('remove'); + }); + const e = userEnv({ detector: d1 }); + const t = new ProcessTracker(e.env); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + await flush(); + e.status.set('denied'); // ends the detector + out.length = 0; + d1.fire(); + expect(out).toEqual([]); + + // a start() that settles after the detector was ended (permission revoked) does not publish + const d2 = mkDetector(); + let settle: () => void = () => {}; + d2.startResult = () => + new Promise((r) => { + settle = () => r(undefined); + }); + const e2 = userEnv({ detector: d2 }); + const t2 = new ProcessTracker(e2.env); + const out2: unknown[] = []; + t2.on('userIdle', (u) => out2.push(u)); + await flush(); + e2.status.set('denied'); + settle(); + await flush(); + expect(out2).toEqual([]); + + // a permission change after teardown (its listener could not be removed) starts nothing + const e3 = userEnv(); + e3.status.removeEventListener.mockImplementation(() => { + throw new Error('remove'); + }); + const t3 = new ProcessTracker(e3.env); + const off3 = t3.on('userIdle', () => {}); + await flush(); + off3(); + e3.status.set('granted'); + await flush(); + expect(e3.detector.startCalls).toHaveLength(1); // only the original start + + // ... and a stale REVOKE after teardown neither aborts again nor drops the retained state + const e4 = userEnv(); + e4.status.removeEventListener.mockImplementation(() => { + throw new Error('remove'); + }); + const t4 = new ProcessTracker(e4.env); + const off4 = t4.on('userIdle', () => {}); + await flush(); + off4(); + expect(e4.aborts[0]?.abort).toHaveBeenCalledTimes(1); + e4.status.set('denied'); + expect(e4.aborts[0]?.abort).toHaveBeenCalledTimes(1); + expect(t4.currentUserIdle()).toEqual({ userState: 'active', screenState: 'unlocked' }); + }); + + it('a detector that reports no userState (undefined) publishes nothing and retains nothing', async () => { + const detector = mkDetector(); + detector.userState = undefined; + const { env } = userEnv({ detector }); + const t = new ProcessTracker(env); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + await flush(); + expect(out).toEqual([]); + expect(t.currentUserIdle()).toBeUndefined(); + }); + + it('a detector without addEventListener/removeEventListener is tolerated', async () => { + const detector = mkDetector(); + (detector as unknown as { addEventListener?: unknown }).addEventListener = undefined; + (detector as unknown as { removeEventListener?: unknown }).removeEventListener = undefined; + const { env } = userEnv({ detector }); + const off = new ProcessTracker(env).on('userIdle', () => {}); + await flush(); + expect(() => off()).not.toThrow(); + }); +}); + +// ---- registry / rebind ---------------------------------------------------------------------------------------- + +describe('processTrackerFor / rebind', () => { + it('is a singleton per window and a different window gets its own', () => { + const w1 = {}; + const w2 = {}; + const a = processTrackerFor({ window: w1 }); + expect(processTrackerFor({ window: w1 })).toBe(a); + expect(processTrackerFor({ window: w2 })).not.toBe(a); + resetProcessTracker({ window: w1 }); + resetProcessTracker({ window: w2 }); + expect(processTrackerFor({ window: w1 })).not.toBe(a); + resetProcessTracker({ window: w1 }); + }); + + it('keys by window, else performance, else the env object; a hostile getter is reported', () => { + const perf = { now: () => 1 }; + const a = processTrackerFor({ performance: perf }); + expect(processTrackerFor({ performance: perf })).toBe(a); + const env = {}; + expect(processTrackerFor(env)).toBe(processTrackerFor(env)); + expect(processTrackerFor({})).not.toBe(processTrackerFor({})); + const onError = vi.fn(); + const hostile = { + get window(): never { + throw new Error('w'); + }, + get performance(): never { + throw new Error('p'); + }, + }; + expect(() => processTrackerFor(hostile, onError)).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(3); // window (key), performance (seam), performance (key) + resetProcessTracker({ performance: perf }); + resetProcessTracker(env); + resetProcessTracker({ window: undefined }); + }); + + it('rebind merges (an undefined seam never clobbers), swaps onError, and a throwing reporter is isolated', () => { + const w = {}; + const first = vi.fn(); + const second = vi.fn(); + const boom = new Error('heap'); + const s = mkScheduler(); + const t = processTrackerFor( + { + window: w, + scheduler: s, + performance: { + get memory(): never { + throw boom; + }, + }, + }, + first, + ); + // a relaunch that brings NO scheduler/performance keeps the earlier ones + expect(processTrackerFor({ window: w }, second)).toBe(t); + t.on('heap', () => {}); + expect(s.live()).toBe(1); // the kept scheduler armed the stage + // onError replaced: the failing heap read (kept performance) reports to `second` only + expect(second).toHaveBeenCalledWith(boom); + expect(first).not.toHaveBeenCalled(); + // a reporter that itself throws is isolated: neither the tick nor the tracker call escapes + const throwing = vi.fn(() => { + throw new Error('reporter'); + }); + processTrackerFor({ window: w }, throwing); + expect(() => s.tick()).not.toThrow(); + expect(throwing).toHaveBeenCalled(); + resetProcessTracker({ window: w }); + }); + + it('rebind replaces onError and keeps it when undefined', () => { + const first = vi.fn(); + const second = vi.fn(); + const boom = new Error('x'); + const s = mkScheduler(); + const env = { + scheduler: s, + performance: { + get memory(): never { + throw boom; + }, + }, + }; + const w = {}; + const t = processTrackerFor({ window: w, ...env }, first); + processTrackerFor({ window: w }, second); + t.on('heap', () => {}); + expect(second).toHaveBeenCalledWith(boom); + expect(first).not.toHaveBeenCalled(); + processTrackerFor({ window: w }); // undefined keeps `second` + s.tick(); + expect(second.mock.calls.length).toBeGreaterThan(1); + expect(first).not.toHaveBeenCalled(); + resetProcessTracker({ window: w }); + }); + + it('a seam change on a RUNNING tracker detaches with the OLD seams, then restarts on the NEW ones', () => { + const oldS = mkScheduler(); + const newS = mkScheduler(); + const w = {}; + const t = processTrackerFor({ + window: w, + scheduler: oldS, + performance: { memory: mem(1) }, + }); + const out: unknown[] = []; + t.on('heap', (m) => out.push(m)); + expect(oldS.live()).toBe(1); + const t2 = processTrackerFor({ + window: w, + scheduler: newS, + performance: { memory: mem(9) }, + }); + expect(t2).toBe(t); + expect(oldS.clearInterval).toHaveBeenCalledTimes(1); // OLD scheduler cleared its OWN handle + expect(oldS.live()).toBe(0); + expect(newS.live()).toBe(1); + newS.tick(); + expect(out).toEqual([{ used: 9, limit: 100 }]); + resetProcessTracker({ window: w }); + expect(newS.live()).toBe(0); + }); + + it('processTrackerFor forwards intervalMs, userIdleThresholdMs and now to the tracker', async () => { + const s = mkScheduler(); + const { env, detector } = userEnv(); + const w = {}; + let at = 0; + const t = processTrackerFor({ + window: w, + ...env, + scheduler: s, + performance: { memory: mem(1) }, + intervalMs: 250, + userIdleThresholdMs: 90_000, + now: () => at, + }); + t.on('heap', () => {}); + const off = t.on('userIdle', () => {}); + await flush(); + expect(s.periods()).toEqual([250]); + expect(detector.startCalls[0]?.threshold).toBe(90_000); + off(); + at = 180_000; // 2 x 90 s + expect(t.currentUserIdleFresh()).toBeDefined(); + at = 180_001; + expect(t.currentUserIdleFresh()).toBeUndefined(); + resetProcessTracker({ window: w }); + }); + + it('an unchanged relaunch does not restart anything', () => { + const s = mkScheduler(); + const perf = { memory: mem(1) }; + const w = {}; + const t = processTrackerFor({ window: w, scheduler: s, performance: perf }); + t.on('heap', () => {}); + processTrackerFor({ window: w, scheduler: s, performance: perf }); + expect(s.setInterval).toHaveBeenCalledTimes(1); + expect(s.clearInterval).not.toHaveBeenCalled(); + resetProcessTracker({ window: w }); + }); + + it('a rebind lets a previously inert stage start (e.g. a lifecycle arrives later)', () => { + const { s, lc, env } = lagEnv(); + const w = {}; + const { lifecycle: _l, ...noLifecycle } = env; + const t = processTrackerFor({ window: w, ...noLifecycle }); + t.on('mainThreadLag', () => {}); + expect(s.live()).toBe(0); + processTrackerFor({ window: w, lifecycle: lc as never }); + expect(s.live()).toBe(1); + resetProcessTracker({ window: w }); + }); + + it('reset() releases every stage, forgets the retained state, and teardown failures are reported', async () => { + const onError = vi.fn(); + const e = userEnv(); + const s = mkScheduler(); + const t = new ProcessTracker( + asEnv({ ...e.env, scheduler: s, performance: { memory: mem(1) } }), + onError, + ); + t.on('heap', () => {}); + t.on('userIdle', () => {}); + await flush(); + expect(t.currentUserIdle()).toBeDefined(); + t.reset(); + expect(s.live()).toBe(0); + expect(t.currentUserIdle()).toBeUndefined(); + expect(e.aborts[0]?.abort).toHaveBeenCalled(); + }); + + it('reset() reports a teardown that throws (to onError) and still releases the other stages', async () => { + const onError = vi.fn(); + const e = userEnv(); + const s = mkScheduler(); + const boom = new Error('clear failed'); + s.clearInterval.mockImplementationOnce(() => { + throw boom; + }); + const t = new ProcessTracker( + asEnv({ ...e.env, scheduler: s, performance: { memory: mem(1) } }), + onError, + ); + t.on('heap', () => {}); + t.on('userIdle', () => {}); + await flush(); + expect(() => t.reset()).not.toThrow(); + expect(onError).toHaveBeenCalledWith(boom); + expect(e.aborts[0]?.abort).toHaveBeenCalled(); // the stage after the failing one was still torn down + expect(t.currentUserIdle()).toBeUndefined(); + }); + + it('a throwing reporter does not escape a failing teardown (reset stays total)', () => { + const s = mkScheduler(); + s.clearInterval.mockImplementation(() => { + throw new Error('clear failed'); + }); + const reporter = vi.fn(() => { + throw new Error('reporter failed'); + }); + const t = new ProcessTracker( + asEnv({ scheduler: s, performance: { memory: mem(1) } }), + reporter, + ); + t.on('heap', () => {}); + expect(() => t.reset()).not.toThrow(); + expect(reporter).toHaveBeenCalledTimes(1); + }); + + it('a seam that changes identity (now, userIdleThresholdMs, cancelIdleCallback) restarts an armed stage; an equal one does not', () => { + for (const [key, a, b] of [ + ['now', () => 1, () => 2], + ['userIdleThresholdMs', 70_000, 80_000], + ['cancelIdleCallback', () => {}, () => {}], + ] as const) { + const s = mkScheduler(); + const first = { scheduler: s, performance: { memory: mem(1) }, [key]: a }; + const t = new ProcessTracker(asEnv(first)); + t.on('heap', () => {}); + expect(s.setInterval).toHaveBeenCalledTimes(1); + t.rebind(asEnv({ [key]: a })); // identical: nothing restarts + expect(s.setInterval, key).toHaveBeenCalledTimes(1); + expect(s.clearInterval, key).not.toHaveBeenCalled(); + t.rebind(asEnv({ [key]: b })); // changed: the armed stage is detached and re-armed + expect(s.clearInterval, key).toHaveBeenCalledTimes(1); + expect(s.setInterval, key).toHaveBeenCalledTimes(2); + } + }); + + it('a FAILED clock read is not cached: the stage retries on its next activation and a persistent failure is reported once', () => { + const onError = vi.fn(); + let broken = true; + const { s, env } = lagEnv({ + performance: { + get now() { + if (broken) throw new Error('now'); + return () => 50; + }, + }, + }); + const tracker = new ProcessTracker(env, onError); + const out: number[] = []; + const up = tracker.on('uptime', (u) => out.push(u.uptimeMs)); + expect(s.live()).toBe(0); // inert while the read fails + up(); + tracker.on('uptime', () => {})(); // another activation: still failing, NOT reported again + expect(onError).toHaveBeenCalledTimes(1); + broken = false; // recovers: the next activation arms + const up2 = tracker.on('uptime', (u) => out.push(u.uptimeMs)); + expect(s.live()).toBe(1); + s.tick(); + expect(out).toEqual([50]); + up2(); + broken = true; // a success is cached for the env: later activations use it without re-reading + const up3 = tracker.on('uptime', () => {}); + expect(s.live()).toBe(1); + up3(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('currentUserIdleFresh(): armed and confirmed = fresh at any age; pending re-query or revoked = undefined', async () => { + let at = 0; + const { env, status } = userEnv({ now: () => at }); + const t = new ProcessTracker(env); + const first = t.on('userIdle', () => {}); + expect(t.currentUserIdleFresh()).toBeUndefined(); // armed but the permission re-query is pending + await flush(); + const state = { userState: 'active', screenState: 'unlocked' }; + expect(t.currentUserIdleFresh()).toEqual(state); + at = 10 * 60 * 60_000; // hours later, the detector still live: a second subscriber must still get it + expect(t.currentUserIdleFresh()).toEqual(state); + status.set('denied'); // revoked while armed + expect(t.currentUserIdleFresh()).toBeUndefined(); + status.set('granted'); // regranted: the detector is live again + await flush(); + expect(t.currentUserIdleFresh()).toBeDefined(); + first(); + at += 10 * 60 * 60_000; // no armed stage any more: the age bound applies + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it("an armed stage whose detector has not PUBLISHED yet never serves an older run's snapshot (pending or rejecting start())", async () => { + let at = 0; + const { env, detector } = userEnv({ now: () => at }); + const t = new ProcessTracker(env); + const first = t.on('userIdle', () => {}); + await flush(); + first(); + expect(t.currentUserIdle()).toBeDefined(); + at = 10 * 60 * 60_000; // hours later + // a start() that never settles: the permission is granted and the detector exists, but nothing was read yet + detector.startResult = () => new Promise(() => {}); + const second = t.on('userIdle', () => {}); + await flush(); + expect(detector.startCalls).toHaveLength(2); + expect(t.currentUserIdleFresh()).toBeUndefined(); + second(); + // a start() that REJECTS: the stale value must not become servable either + detector.startResult = () => Promise.reject(new Error('refused')); + t.on('userIdle', () => {}); + expect(t.currentUserIdleFresh()).toBeUndefined(); + await flush(); + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it('a synchronous query() failure leaves no armed run behind: the age-bounded read still works once listeners are gone', async () => { + let at = 0; + const { env, query } = userEnv({ now: () => at }); + const t = new ProcessTracker(env); + const first = t.on('userIdle', () => {}); + await flush(); + first(); + const state = { userState: 'active', screenState: 'unlocked' }; + query.mockImplementation(() => { + throw new Error('query'); + }); + const second = t.on('userIdle', () => {}); // the stage cannot arm (inert) + second(); + at = 1000; + expect(t.currentUserIdleFresh()).toEqual(state); // not stuck on a phantom run + }); + + it('every new reading re-stamps the age: the 2-threshold bound runs from the LATEST change', async () => { + let at = 0; + const { env, detector } = userEnv({ now: () => at }); + const t = new ProcessTracker(env); + const off = t.on('userIdle', () => {}); + await flush(); + at = 100_000; + detector.userState = 'idle'; + detector.fire(); // a later change + off(); + at = 100_000 + 2 * USER_IDLE_MIN_THRESHOLD_MS; // at the bound measured from the LATEST reading + expect(t.currentUserIdleFresh()).toEqual({ userState: 'idle', screenState: 'unlocked' }); + at += 1; + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it('reset() drops the listeners: removing one later does not re-arm another stage', () => { + const s = mkScheduler(); + const t = new ProcessTracker( + asEnv({ scheduler: s, performance: { memory: mem(1), now: () => 1 } }), + ); + const offHeap = t.on('heap', () => {}); + const offUptime = t.on('uptime', () => {}); + expect(s.live()).toBe(2); + t.reset(); + expect(s.live()).toBe(0); + offHeap(); // a listener change after reset must not restart a stage for a subscriber that is gone + expect(s.live()).toBe(0); + offUptime(); + expect(s.live()).toBe(0); + expect(s.setInterval).toHaveBeenCalledTimes(2); + // and the tracker is reusable + t.on('heap', () => {}); + expect(s.live()).toBe(1); + }); + + it('a revoke while NO stage is armed is only noticed by the next activation: the plain read is retained until then, the fresh read never seeds', async () => { + const { env, status } = userEnv({ now: () => 0 }); + const t = new ProcessTracker(env); + const off = t.on('userIdle', () => {}); + await flush(); + off(); + status.state = 'denied'; + expect(t.currentUserIdle()).toBeDefined(); // documented: nothing has re-asked yet + t.on('userIdle', () => {}); + expect(t.currentUserIdleFresh()).toBeUndefined(); // armed but unconfirmed + await flush(); + expect(t.currentUserIdle()).toBeUndefined(); + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it('a late rejection from an OLD detector does not end the NEWER run', async () => { + const onError = vi.fn(); + const status = mkPermission('granted'); + const detectors: Array> = []; + const rejectOld: Array<(e: unknown) => void> = []; + const aborts: Array<{ abort: ReturnType }> = []; + class FreshDetector { + constructor() { + const d = mkDetector(); + if (detectors.length === 0) { + d.startResult = () => + new Promise((_, reject) => { + rejectOld.push(reject); + }); + } + detectors.push(d); + // biome-ignore lint/correctness/noConstructorReturn: a test double hands a fresh fake detector back from `new`. + return d as unknown as FreshDetector; + } + } + class FakeAbort { + signal = {}; + abort = vi.fn(); + constructor() { + aborts.push(this); + } + } + const t = new ProcessTracker( + asEnv({ + navigator: { permissions: { query: () => Promise.resolve(status) } }, + IdleDetector: FreshDetector, + AbortController: FakeAbort, + }), + onError, + ); + const out: unknown[] = []; + t.on('userIdle', (u) => out.push(u)); + await flush(); + expect(detectors).toHaveLength(1); + status.set('denied'); // run 1's detector is ended while its start() is still pending + expect(aborts[0]?.abort).toHaveBeenCalledTimes(1); + status.set('granted'); // a NEWER detector starts + await flush(); + expect(detectors).toHaveLength(2); + expect(out).toHaveLength(1); + const newer = detectors[1] as ReturnType; + rejectOld[0]?.(new Error('AbortError')); // the OLD one settles late + await flush(); + expect(aborts[1]?.abort).not.toHaveBeenCalled(); // the newer run was NOT ended + expect(newer.removeEventListener).not.toHaveBeenCalled(); + newer.userState = 'idle'; + newer.fire(); + expect(out).toHaveLength(2); // and it still publishes + expect(onError).not.toHaveBeenCalled(); + }); + + it('currentUserIdleFresh() hands the state out only within 2 thresholds and never on an untrustworthy clock', async () => { + let at = 1_000_000; + const { env } = userEnv({ now: () => at }); + const t = new ProcessTracker(env); + expect(t.currentUserIdleFresh()).toBeUndefined(); // nothing read yet + const off = t.on('userIdle', () => {}); + await flush(); + off(); + const state = { userState: 'active', screenState: 'unlocked' }; + expect(t.currentUserIdle()).toEqual(state); + expect(t.currentUserIdleFresh()).toEqual(state); // age 0 + at += 2 * USER_IDLE_MIN_THRESHOLD_MS; + expect(t.currentUserIdleFresh()).toEqual(state); // exactly at the bound + at += 1; + expect(t.currentUserIdleFresh()).toBeUndefined(); // one ms past it + expect(t.currentUserIdle()).toEqual(state); // the plain retained read is unchanged + at = 1_000_000 - 1; + expect(t.currentUserIdleFresh()).toBeUndefined(); // clock stepped backwards + at = Number.NaN; + expect(t.currentUserIdleFresh()).toBeUndefined(); + t.reset(); + at = 1_000_000; + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it('the freshness bound scales with the configured threshold', async () => { + let at = 0; + const { env } = userEnv({ now: () => at, userIdleThresholdMs: 300_000 }); + const t = new ProcessTracker(env); + const off = t.on('userIdle', () => {}); + await flush(); + off(); + at = 600_000; + expect(t.currentUserIdleFresh()).toBeDefined(); + at = 600_001; + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it('an unreadable clock is reported and never vouches for freshness (stamp and age)', async () => { + const onError = vi.fn(); + let broken = true; + const { env } = userEnv({ + now: () => { + if (broken) throw new Error('clock'); + return 5; + }, + }); + const t = new ProcessTracker(env, onError); + const off = t.on('userIdle', () => {}); + await flush(); + off(); + expect(onError).toHaveBeenCalledTimes(1); // the stamp failed + expect(t.currentUserIdle()).toBeDefined(); + expect(t.currentUserIdleFresh()).toBeUndefined(); // no timestamp -> never fresh + // a stamped state whose age cannot be read + broken = false; + const off2 = t.on('userIdle', () => {}); + await flush(); + off2(); + expect(t.currentUserIdleFresh()).toBeDefined(); + broken = true; + expect(t.currentUserIdleFresh()).toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(2); + }); + + it('a revoked permission drops the retained state AND its age', async () => { + const { env, status } = userEnv({ now: () => 1 }); + const t = new ProcessTracker(env); + t.on('userIdle', () => {}); + await flush(); + expect(t.currentUserIdleFresh()).toBeDefined(); + status.set('denied'); + expect(t.currentUserIdle()).toBeUndefined(); + expect(t.currentUserIdleFresh()).toBeUndefined(); + }); + + it('an unsubscribe that throws still closes the gated run (no timer left armed)', () => { + const { s, lc, env } = lagEnv(); + lc.on.mockImplementationOnce(() => () => { + throw new Error('unsubscribe failed'); + }); + const onError = vi.fn(); + const off = new ProcessTracker(env, onError).on('mainThreadLag', () => {}); + expect(s.live()).toBe(1); + // the stage teardown rethrows into #stop, which reports it; the run itself must be closed regardless + expect(() => off()).not.toThrow(); + expect(s.live()).toBe(0); + expect(onError).toHaveBeenCalledTimes(1); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe('unsubscribe failed'); + }); +}); diff --git a/packages/trackers/src/browser/process-tracker.ts b/packages/trackers/src/browser/process-tracker.ts new file mode 100644 index 00000000..d049ec55 --- /dev/null +++ b/packages/trackers/src/browser/process-tracker.ts @@ -0,0 +1,744 @@ +import { InterceptorBase } from '../interceptor-base'; +import { createTrackerRegistry } from '../tracker-registry'; +import type { ApplicationTracker } from './application-tracker'; +import { hostRead } from './host-read'; + +// Browser PROCESS tracker (domain tracker, design §15.2/§15.3 item 4; Android reference: BugseeTrackerProcess). It +// owns everything the page can say about its own process, as FIVE independent stages: +// +// heap `performance.memory` (Chromium-only) -> { used, limit } bytes, polled every tick. +// mainThreadLag TIMER DRIFT (see below) -> { lagMs }, every tick, visible pages only. +// idle JS main-thread idle, `requestIdleCallback` -> { idleMs } (0..50), visible pages only. +// userIdle Idle Detection API (`IdleDetector`) -> { userState, screenState? }, on change. +// uptime `performance.now()` (ms since the time origin) -> { uptimeMs }, every tick. +// +// PER-STAGE ACTIVATION. Nothing runs at construction. A stage's timer/listener/detector exists only while THAT stage +// has a listener (`listenerCount`, re-evaluated after every listener mutation via `onListenersChanged`); an `onAny` +// listener counts for every stage. `start()`/`stop()` (InterceptorBase) therefore do nothing here. A stage that cannot +// work in this environment (no `performance.memory`, no `requestIdleCallback`, no clock, no lifecycle, no permission) +// stays INERT: no timer, no emission, and it is looked at again only after its listeners have all left and a new one +// arrives (or after a `rebind` changes the seams). Absent data is ABSENCE: a value that is not a finite non-negative +// number is omitted, never sent as 0. +// +// SINGLETON per key object (see `processTrackerFor`). RETENTION rule — what survives a stage's last unsubscribe: +// * the last `userIdle` state (`currentUserIdle()`, a plain retained read; dropped from the tracker when the +// permission is found NOT granted by a RUNNING stage — a revoke while NO stage is armed is only noticed on the +// next activation's async re-query). `currentUserIdleFresh()` (the seed for a held trace) is stricter: while the +// stage is armed it answers only once THIS run's detector has published (permission re-confirmed; no age bound, a +// live detector reports changes only), and undefined while that re-query or the detector's start() is pending, or after a refusal/revoke; +// with no armed stage it hands the state out only while younger than 2 detector thresholds. KNOWN LIMIT (owner decision pending, docs/PROGRESS.md TR5): the tracker emits nothing on +// revoke, and a HELD trace cannot be retracted, so a consumer that already published the last value keeps +// re-recording it until its producer stops; the drop above only stops the tracker serving it to NEW seeds, and +// * the heap is read live (`currentHeap()`), uptime/idle/lag keep nothing: a stale idle figure or drift baseline +// would be a lie, so every run starts FRESH. +// What does NOT survive: timers, the lifecycle subscription, the pending idle probe, the IdleDetector and its abort +// controller. Every async continuation (idle callback, permission query, `IdleDetector.start()`) is guarded by the +// run it belongs to and is inert once that run is torn down; every timer callback is guarded the same way, so a timer +// whose clear failed can never emit. +// +// `mainThreadLag` — TIMER DRIFT. The sampling timer runs on the page's main thread, so on every tick +// `lag = max(0, now - (previousTickTime + intervalMs))` is how late it fired. The first tick compares against the +// run's start time + interval. Measured in Chromium: a tick fires late by (block END - the first due time the block +// covered). A block B >= 1 s reads between B - 1000 and B; a block SHORTER than 1 s that crosses no due tick reads 0 +// (chance ~ 1 - B/1000); one block is reported once (the next tick measures from the late tick and clamps to 0). +// This is NOT Node's ~10 ms event-loop monitor. A MessageChannel ping was built and rejected: it misses real freezes. +// Measured ONLY while the page is visible: a hidden tab throttles timers, which would paint a hang that never +// happened, so the run is torn down on hidden and every visible start takes a FRESH baseline. No clock, no lifecycle, +// or hidden: emit NOTHING. +// +// `idle` — ONE `requestIdleCallback` probe per sampling tick, never a loop and never more than one in flight (a tick +// that finds the previous probe still pending queues nothing). When the browser grants the callback, the value is +// `deadline.timeRemaining()` clamped to <= 50 ms (the spec's cap). A busy page whose callback has not run yet +// produces NO sample for that tick (absence, not 0). Absent where `requestIdleCallback` is (Safari). +// +// `userIdle` — the SDK MUST NEVER prompt. `IdleDetector.requestPermission()` is never called; the detector is +// started ONLY when `navigator.permissions.query({name:'idle-detection'})` ALREADY reports `granted` (Chromium; an +// engine that does not know the permission name rejects, which just leaves the stage inert). The permission is +// re-queried on every activation and watched via its `change` event (granted later -> starts, revoked -> aborts). +// The threshold is >= 60 s (the spec's minimum). Stopping aborts the detector through its AbortController. + +export interface HeapReading { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; +} +/** The heap figures the traces need. `limit` is the heap CEILING (jsHeapSizeLimit), not what is committed. */ +export interface HeapState { + used: number; + limit: number; +} +export interface UserIdleState { + userState: 'active' | 'idle'; + /** Omitted when the detector reports something other than `locked`/`unlocked`. */ + screenState?: 'locked' | 'unlocked'; +} +export interface ProcessEvents { + heap: HeapState; + mainThreadLag: { lagMs: number }; + /** 0..50 ms, `requestIdleCallback`'s `timeRemaining()`. */ + idle: { idleMs: number }; + userIdle: UserIdleState; + uptime: { uptimeMs: number }; +} +type Stage = keyof ProcessEvents; +const STAGES: readonly Stage[] = ['heap', 'mainThreadLag', 'idle', 'userIdle', 'uptime']; + +export interface ProcessTimers { + setInterval(callback: () => void, ms: number): unknown; + clearInterval(handle: unknown): void; +} +export interface IdleDeadlineLike { + timeRemaining(): number; +} +export interface IdleDetectorLike { + readonly userState?: unknown; + readonly screenState?: unknown; + start(options: { threshold: number; signal: unknown }): Promise; + addEventListener?(type: string, listener: () => void): void; + removeEventListener?(type: string, listener: () => void): void; +} +export interface PermissionStatusLike { + readonly state?: unknown; + addEventListener?(type: string, listener: () => void): void; + removeEventListener?(type: string, listener: () => void): void; +} +export interface AbortControllerLike { + readonly signal: unknown; + abort(): void; +} + +/** What the tracker needs from a launch. Every member is optional; an absent one disables the stages that need it. */ +export interface ProcessEnv { + /** `performance.memory` (heap) and `performance.now()` (lag, uptime), read live. */ + performance?: { readonly memory?: HeapReading; now?(): number }; + /** Visibility is read here when a gated stage starts; unknown counts as not visible. */ + document?: { readonly visibilityState?: string }; + /** Shared lifecycle tracker for visibility changes (gates `mainThreadLag` and `idle`). */ + lifecycle?: Pick; + /** Bound to its window by the caller. */ + requestIdleCallback?: (callback: (deadline: IdleDeadlineLike) => void) => number; + cancelIdleCallback?: (handle: number) => void; + /** `navigator.permissions` (the SDK only ever QUERIES it). */ + navigator?: { + readonly permissions?: { + query?(descriptor: { name: string }): Promise; + }; + }; + /** The `IdleDetector` constructor (Chromium, secure context). `requestPermission` is never touched. */ + IdleDetector?: new () => IdleDetectorLike; + AbortController?: new () => AbortControllerLike; + /** Drives every polled stage; default the global timers. */ + scheduler?: ProcessTimers; + /** Sampling period in ms (default 1000). */ + intervalMs?: number; + /** Idle Detection threshold in ms; floored at the spec's 60 000 (default 60 000). */ + userIdleThresholdMs?: number; + /** Wall-clock ms for the age of the retained user-idle state; default `Date.now`. */ + now?: () => number; +} + +const DEFAULT_INTERVAL_MS = 1000; +/** + * A retained user-idle state older than this many detector thresholds is not handed out as `currentUserIdleFresh()` + * (the detector reports only CHANGES, so an old snapshot may have been overtaken by something we never saw). + */ +export const USER_IDLE_FRESH_THRESHOLDS = 2; +const defaultNow = (): number => Date.now(); +/** The Idle Detection spec's minimum threshold. */ +export const USER_IDLE_MIN_THRESHOLD_MS = 60_000; +/** requestIdleCallback's spec cap on a granted idle period. */ +export const IDLE_PERIOD_CAP_MS = 50; + +const globalTimers = globalThis as unknown as ProcessTimers; +const defaultTimers: ProcessTimers = { + setInterval: (cb, ms) => globalTimers.setInterval(cb, ms), + clearInterval: (h) => globalTimers.clearInterval(h), +}; + +const SEAM_KEYS = [ + 'performance', + 'document', + 'lifecycle', + 'requestIdleCallback', + 'cancelIdleCallback', + 'navigator', + 'IdleDetector', + 'AbortController', + 'scheduler', + 'intervalMs', + 'userIdleThresholdMs', + 'now', +] as const satisfies readonly (keyof ProcessEnv)[]; + +function usable(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export class ProcessTracker extends InterceptorBase { + readonly name = 'browser-process'; + #env: ProcessEnv; + /** Teardown of every stage that is armed (or waiting on its async setup). */ + readonly #running = new Map void>(); + /** Stages that found themselves unable to run; they retry only after their listeners have all left. */ + readonly #inert = new Set(); + /** Retained across deactivation (see the retention rule). */ + #userIdle: UserIdleState | undefined; + /** The armed user-idle run (`published` once its detector has reported a reading this run). */ + #userIdleRun: { published: boolean } | undefined; + /** When `#userIdle` was read; only meaningful while `#userIdle` is defined (always set together). */ + #userIdleAt: number | undefined; + // 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: ProcessEnv, onError?: (error: unknown) => void) { + const report = { fn: onError }; + // A throwing reporter must not escape into the emitter's dispatch (which runs inside the host's timers/events). + 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. A defined `onError` replaces the reporter (undefined keeps + * it). Every defined seam REPLACES ours; an undefined one never clobbers an earlier one (merge, never drop). When + * a seam actually changed, every armed stage is first detached with the seams it was ARMED with (their captured + * timers/cancel functions/detector), then the merged seams are swapped in and the stages that still have listeners + * start again against the new ones (fresh lag baseline, permission re-queried). + */ + rebind(env: ProcessEnv, onError?: (error: unknown) => void): void { + if (onError !== undefined) this.#report.fn = onError; + const merged: Record = {}; + let changed = false; + for (const key of SEAM_KEYS) { + const next = env[key] ?? this.#env[key]; + if (next !== undefined) merged[key] = next; + if (next !== this.#env[key]) changed = true; + } + if (!changed) return; + this.#stopAll(); // OLD seams (captured by each stage's teardown) + this.#env = merged as ProcessEnv; + this.#sync(); + } + + #onError(error: unknown): void { + try { + this.#report.fn?.(error); + } catch { + /* the reporter itself failed */ + } + } + + /** The live heap reading, or undefined when the engine has none, the read threw, or a figure is unusable. */ + currentHeap(): HeapState | undefined { + return this.#readHeap(this.#env); + } + + /** Last Idle Detection state; retained after the last unsubscribe. Undefined until one is read. */ + currentUserIdle(): UserIdleState | undefined { + return this.#userIdle; + } + + /** + * The retained user-idle state, but only while it is younger than {@link USER_IDLE_FRESH_THRESHOLDS} detector + * thresholds (the threshold in force: the configured one, floored at the spec minimum). Undefined once older, + * unknown, or when the clock cannot vouch for the age (unreadable, non-finite, or stepped backwards). For consumers + * that publish the state as a HELD reading: a stale snapshot must not seed one. + */ + currentUserIdleFresh(): UserIdleState | undefined { + // The stage is armed: the state is trustworthy exactly once THIS run's detector has PUBLISHED (the permission was + // re-confirmed and the detector's own start() settled with a reading). Until then the retained snapshot is from an + // earlier run: the permission query / start() may still be pending (or refuse), and a revoke may have outdated it. + // A published state does not age while its detector stays live: it reports changes only. + const run = this.#userIdleRun; + if (run !== undefined) return run.published ? this.#userIdle : undefined; + const at = this.#userIdleAt; + if (this.#userIdle === undefined || at === undefined) return undefined; + let age: number; + try { + age = (this.#env.now ?? defaultNow)() - at; + } catch (error) { + this.#onError(error); + return undefined; + } + const limit = USER_IDLE_FRESH_THRESHOLDS * this.#userIdleThreshold(this.#env); + // NaN fails both comparisons, so a non-finite age is stale too + return age >= 0 && age <= limit ? this.#userIdle : undefined; + } + + #userIdleThreshold(env: ProcessEnv): number { + const requested = env.userIdleThresholdMs; + return usable(requested) + ? Math.max(USER_IDLE_MIN_THRESHOLD_MS, requested) + : USER_IDLE_MIN_THRESHOLD_MS; + } + + /** + * Test/teardown: drop EVERY listener (which releases every stage, and keeps a later listener change from re-arming + * a stage for a subscriber that no longer exists) and forget the retained state. + */ + reset(): void { + this.removeAllListeners(); + this.#userIdle = undefined; + } + + // Activation is per stage (onListenersChanged); the whole-tracker hook has nothing to do. + protected onActivate(): void {} + + protected override onListenersChanged(): void { + this.#sync(); + } + + #stopAll(): void { + for (const stage of [...this.#running.keys()]) this.#stop(stage); + this.#inert.clear(); + } + + #stop(stage: Stage): void { + const teardown = this.#running.get(stage); + this.#running.delete(stage); + if (teardown === undefined) return; + try { + teardown(); + } catch (error) { + this.#onError(error); + } + } + + #sync(): void { + for (const stage of STAGES) { + const wanted = this.listenerCount(stage) > 0; + if (!wanted) { + this.#stop(stage); + this.#inert.delete(stage); + continue; + } + if (this.#running.has(stage) || this.#inert.has(stage)) continue; + let teardown: (() => void) | undefined; + try { + teardown = this.#start(stage, this.#env); + } catch (error) { + this.#onError(error); + } + if (teardown === undefined) this.#inert.add(stage); + else this.#running.set(stage, teardown); + } + } + + #start(stage: Stage, env: ProcessEnv): (() => void) | undefined { + switch (stage) { + case 'heap': + return this.#startHeap(env); + case 'mainThreadLag': + return this.#startLag(env); + case 'idle': + return this.#startIdle(env); + case 'userIdle': + return this.#startUserIdle(env); + case 'uptime': + return this.#startUptime(env); + } + } + + /** One polled run on the env's scheduler (captured, so the teardown clears with the SAME one). Ticks never throw. */ + #every(env: ProcessEnv, tick: () => void): () => void { + const scheduler = env.scheduler ?? defaultTimers; + let alive = true; + const handle = scheduler.setInterval(() => { + if (!alive) return; // a timer whose clear failed must stay silent + try { + tick(); + } catch (error) { + this.#onError(error); // inside the host's timer dispatch: never throw + } + }, env.intervalMs ?? DEFAULT_INTERVAL_MS); + return () => { + alive = false; + scheduler.clearInterval(handle); + }; + } + + /** + * Runs `begin` only while the page is visible: subscribes to the lifecycle's visibility, begins now when visible, + * and tears the run down on hidden. The caller has checked that a lifecycle exists. + */ + #gated( + env: ProcessEnv, + lifecycle: NonNullable, + begin: () => () => void, + ): () => void { + let stop: (() => void) | undefined; + const close = (): void => { + const current = stop; + stop = undefined; + if (current === undefined) return; + try { + current(); + } catch (error) { + this.#onError(error); + } + }; + const open = (): void => { + if (stop !== undefined) return; + try { + stop = begin(); // a throwing setup rolls back: nothing half-started is left behind + } catch (error) { + this.#onError(error); + } + }; + const unsubscribe = lifecycle.on('visibility', ({ visible }) => (visible ? open() : close())); + const visible = hostRead( + () => env.document?.visibilityState, + (error) => this.#onError(error), + ); + if (visible === 'visible') open(); + return () => { + try { + unsubscribe(); + } finally { + close(); // a throwing unsubscribe must never leave the run armed + } + }; + } + + // A SUCCESSFUL clock read is memoised per env object (re-reading a live getter on every stage start buys nothing). + // A FAILED read is never cached, so the stage is looked at again on its next activation (the retry rule); a failure + // that persists is reported once per env (until a read succeeds), not once per activation. + readonly #clocks = new WeakMap number>(); + readonly #clockFailing = new WeakSet(); + + #clockOf(env: ProcessEnv): (() => number) | undefined { + const cached = this.#clocks.get(env); + if (cached !== undefined) return cached; + const report = (error: unknown): void => { + if (!this.#clockFailing.has(env)) { + this.#clockFailing.add(env); + this.#onError(error); + } + }; + const perf = hostRead(() => env.performance, report); + const now = perf === undefined ? undefined : hostRead(() => perf.now, report); + if (typeof now !== 'function' || perf === undefined) return undefined; + const clock = (): number => (perf.now as () => number)(); + this.#clocks.set(env, clock); + return clock; + } + + // ---- heap ------------------------------------------------------------------------------------------------------- + + #readHeap(env: ProcessEnv): HeapState | undefined { + const report = (error: unknown): void => this.#onError(error); + const memory = hostRead(() => env.performance?.memory, report); + if (memory === undefined || memory === null) return undefined; + const used = hostRead(() => memory.usedJSHeapSize, report); + const limit = hostRead(() => memory.jsHeapSizeLimit, report); + return usable(used) && usable(limit) ? { used, limit } : undefined; + } + + #startHeap(env: ProcessEnv): (() => void) | undefined { + // An engine that answers nothing has no performance.memory: never start a timer. A READ that threw is a transient + // failure, not absence: it was reported and polling still starts so the next tick can recover. + let threw = false; + const memory = hostRead( + () => env.performance?.memory, + (error) => { + threw = true; + this.#onError(error); + }, + ); + if ((memory === undefined || memory === null) && !threw) return undefined; + return this.#every(env, () => { + const state = this.#readHeap(env); + if (state !== undefined) this.emit('heap', state); + }); + } + + // ---- uptime ----------------------------------------------------------------------------------------------------- + + #startUptime(env: ProcessEnv): (() => void) | undefined { + const now = this.#clockOf(env); + if (now === undefined) return undefined; + return this.#every(env, () => { + const uptimeMs = now(); + if (usable(uptimeMs)) this.emit('uptime', { uptimeMs }); + }); + } + + // ---- mainThreadLag ---------------------------------------------------------------------------------------------- + + #startLag(env: ProcessEnv): (() => void) | undefined { + const now = this.#clockOf(env); + const lifecycle = env.lifecycle; + if (now === undefined || lifecycle === undefined) return undefined; + const interval = env.intervalMs ?? DEFAULT_INTERVAL_MS; + return this.#gated(env, lifecycle, () => { + let expectedAt = now() + interval; // a FRESH baseline every run (non-finite = no baseline, see the tick) + return this.#every(env, () => { + let at: number; + try { + at = now(); + } catch (error) { + this.#onError(error); // an unreadable clock skips this tick without reading the skipped interval as lag + expectedAt += interval; + return; + } + if (!Number.isFinite(at)) { + expectedAt += interval; + return; + } + const baseline = expectedAt; + expectedAt = at + interval; + // No finite baseline (the clock was unreadable at the run's start): this tick only establishes one. A lag + // computed from it would be NaN, and an unknown lag is omitted, never sent. + if (!Number.isFinite(baseline)) return; + const lagMs = Math.max(0, at - baseline); // fired early (a hair) is on time, not negative lag + this.emit('mainThreadLag', { lagMs }); + }); + }); + } + + // ---- idle ------------------------------------------------------------------------------------------------------- + + #startIdle(env: ProcessEnv): (() => void) | undefined { + const report = (error: unknown): void => this.#onError(error); + const request = hostRead(() => env.requestIdleCallback, report); + const lifecycle = env.lifecycle; + if (typeof request !== 'function' || lifecycle === undefined) return undefined; + const cancel = hostRead(() => env.cancelIdleCallback, report); + return this.#gated(env, lifecycle, () => { + let pending: { handle: number } | undefined; + const stopTimer = this.#every(env, () => { + if (pending !== undefined) return; // one probe in flight at most: never a pile-up or a loop + const mine: { handle: number } = { handle: 0 }; + pending = mine; + try { + mine.handle = request((deadline) => { + if (pending !== mine) return; // torn down (or replaced) while waiting + pending = undefined; + try { + const remaining = deadline.timeRemaining(); + if (usable(remaining)) { + this.emit('idle', { idleMs: Math.min(IDLE_PERIOD_CAP_MS, remaining) }); + } + } catch (error) { + this.#onError(error); // inside the browser's idle dispatch + } + }); + } catch (error) { + pending = undefined; + throw error; // reported by the tick guard + } + }); + return () => { + const outstanding = pending; + pending = undefined; + try { + stopTimer(); + } finally { + if (outstanding !== undefined && typeof cancel === 'function') { + try { + cancel(outstanding.handle); + } catch (error) { + this.#onError(error); + } + } + } + }; + }); + } + + // ---- userIdle --------------------------------------------------------------------------------------------------- + + /** Now, or undefined when the clock is unreadable (the state is then never "fresh"). */ + #stamp(env: ProcessEnv): number | undefined { + try { + return (env.now ?? defaultNow)(); + } catch (error) { + this.#onError(error); + return undefined; + } + } + + #startUserIdle(env: ProcessEnv): (() => void) | undefined { + const report = (error: unknown): void => this.#onError(error); + const nav = hostRead(() => env.navigator, report); + const permissions = hostRead(() => nav?.permissions, report); + const query = hostRead(() => permissions?.query, report); + const Detector = hostRead(() => env.IdleDetector, report); + const Abort = hostRead(() => env.AbortController, report); + if ( + typeof query !== 'function' || + typeof Detector !== 'function' || + typeof Abort !== 'function' + ) { + return undefined; + } + const threshold = this.#userIdleThreshold(env); + + const run: { + alive: boolean; + detector?: IdleDetectorLike; + controller?: AbortControllerLike; + onChange?: () => void; + detachPermission?: () => void; + published: boolean; + } = { alive: true, published: false }; + + const publish = (detector: IdleDetectorLike): void => { + const userState = hostRead(() => detector.userState, report); + if (userState !== 'active' && userState !== 'idle') return; // nothing honest to say + const screenState = hostRead(() => detector.screenState, report); + const state: UserIdleState = { + userState, + ...(screenState === 'locked' || screenState === 'unlocked' ? { screenState } : {}), + }; + run.published = true; + this.#userIdle = state; + this.#userIdleAt = this.#stamp(env); + this.emit('userIdle', state); + }; + + const end = (): void => { + const { detector, controller, onChange } = run; + run.detector = undefined; + run.controller = undefined; + run.onChange = undefined; + if (detector !== undefined && onChange !== undefined) { + try { + detector.removeEventListener?.('change', onChange); + } catch { + /* best-effort */ + } + } + try { + controller?.abort(); + } catch (error) { + report(error); + } + }; + + const begin = (): void => { + if (!run.alive || run.detector !== undefined) return; + try { + const controller = new Abort(); + const detector = new Detector(); + // publish() is total (every host read is guarded, emit() isolates listeners), so these callbacks cannot throw. + const onChange = (): void => { + if (run.detector === detector) publish(detector); + }; + run.detector = detector; + run.controller = controller; + run.onChange = onChange; + detector.addEventListener?.('change', onChange); + detector.start({ threshold, signal: controller.signal }).then( + () => { + if (run.alive && run.detector === detector) publish(detector); + }, + // Refused (policy blocked, aborted): the stage simply produces nothing; a later permission change retries. + () => { + if (run.detector === detector) end(); + }, + ); + } catch (error) { + end(); + report(error); + } + }; + + const decide = (status: PermissionStatusLike): void => { + if (!run.alive) return; + if (hostRead(() => status.state, report) === 'granted') { + begin(); + } else { + end(); // never granted, or revoked: stop, and do not keep serving a snapshot we may no longer read + this.#userIdle = undefined; + } + }; + + try { + void Promise.resolve(query.call(permissions, { name: 'idle-detection' })).then( + (status) => { + if (!run.alive) return; + try { + const onPermission = (): void => decide(status); + status.addEventListener?.('change', onPermission); + run.detachPermission = () => status.removeEventListener?.('change', onPermission); + decide(status); + } catch (error) { + report(error); + } + }, + () => {}, // the engine does not know the permission name (not Chromium): no user-idle trace + ); + } catch { + return undefined; // a synchronous throw from query() means the same + } + + this.#userIdleRun = run; // only once the stage is really armed (a synchronous query() failure returns above) + return () => { + run.alive = false; + if (this.#userIdleRun === run) this.#userIdleRun = undefined; + end(); + const detach = run.detachPermission; + run.detachPermission = undefined; + try { + detach?.(); + } catch { + /* best-effort */ + } + }; + } +} + +const registry = createTrackerRegistry(); + +/** A {@link ProcessEnv} plus the objects the singleton is keyed on. */ +export interface ProcessTrackerEnv extends ProcessEnv { + window?: object; +} + +// The key is the window the SDK is bound to; failing that the performance object; failing that the env object itself +// (an ad-hoc env then gets a private tracker). Host reads are guarded AND reported. +function keyOf(env: ProcessTrackerEnv, onError?: (error: unknown) => void): object { + const win = hostRead(() => env.window, onError); + if (typeof win === 'object' && win !== null) return win; + const perf = hostRead(() => env.performance, onError); + if (typeof perf === 'object' && perf !== null) return perf; + return env; +} + +// Each seam is read ONCE through hostRead (a hostile env getter costs only that seam, reported). +function seamsOf(env: ProcessTrackerEnv, onError?: (error: unknown) => void): ProcessEnv { + const seams: Record = {}; + for (const key of SEAM_KEYS) { + const value = hostRead(() => env[key], onError); + if (value !== undefined) seams[key] = value; + } + return seams as ProcessEnv; +} + +/** + * The singleton ProcessTracker for the env's window. Every call (a relaunch included) hands the shared tracker the + * latest launch's `onError` and seams (see {@link ProcessTracker.rebind}); nothing else of the env is kept. + */ +export function processTrackerFor( + env: ProcessTrackerEnv, + onError?: (error: unknown) => void, +): ProcessTracker { + const seams = seamsOf(env, onError); + return registry.for( + keyOf(env, onError), + () => new ProcessTracker(seams, onError), + (tracker) => tracker.rebind(seams, onError), + ); +} + +/** Test/teardown: drop the env's tracker (resetting it, so its timers and listeners are released). */ +export function resetProcessTracker(env: ProcessTrackerEnv): void { + registry.reset(keyOf(env)); +} diff --git a/packages/trackers/src/index.test.ts b/packages/trackers/src/index.test.ts index 60b3dedf..0cf22db8 100644 --- a/packages/trackers/src/index.test.ts +++ b/packages/trackers/src/index.test.ts @@ -22,15 +22,18 @@ describe('@bugsee/trackers public surface', () => { 'ApplicationTracker', 'ConnectivityTracker', 'PowerTracker', + 'ProcessTracker', 'StorageTracker', 'UiTracker', 'applicationTrackerFor', 'connectivityTrackerFor', 'hostRead', 'powerTrackerFor', + 'processTrackerFor', 'resetApplicationTracker', 'resetConnectivityTracker', 'resetPowerTracker', + 'resetProcessTracker', 'resetStorageTracker', 'resetUiTracker', 'storageTrackerFor', diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index f22bfb31..fec95e25 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -1109,6 +1109,81 @@ describe('launch (webview)', () => { } }); + describe('user_idle_state is opt-in (captureUserIdle) and needs an already-granted permission', () => { + const idleRows = async (over: Partial, permission: string) => { + stubSensors(); + const query = vi.fn(() => Promise.resolve({ state: permission })); + vi.stubGlobal('navigator', { onLine: true, permissions: { query } }); + try { + const fake = fakeGlobal(); + const w = fakeWin(); + class FakeDetector { + userState = 'idle'; + screenState = 'unlocked'; + start = () => Promise.resolve(undefined); + addEventListener = () => {}; + removeEventListener = () => {}; + } + Object.assign(w.win, { + IdleDetector: FakeDetector, + AbortController: class { + signal = {}; + abort() {} + }, + }); + track('tok', baseOptions({ global: fake.global, window: w.win, ...over })); + await new Promise((r) => setTimeout(r, 0)); + const rows = new Map( + entriesOfType(fake.msgs(), 'traces.system').map((e) => { + const d = (e.p as unknown as { data: { name: string; value: unknown } }).data; + return [d.name, d.value]; + }), + ); + return { rows, query, w }; + } finally { + vi.unstubAllGlobals(); + } + }; + + it('default: no user_idle_state and no permission query, though granted', async () => { + const { rows, query } = await idleRows({}, 'granted'); + expect(rows.has('user_idle_state')).toBe(false); + expect(query).not.toHaveBeenCalled(); + }); + + it('captureUserIdle:true + already granted: user_idle_state is streamed', async () => { + const { rows } = await idleRows({ captureUserIdle: true }, 'granted'); + expect(rows.get('user_idle_state')).toBe('idle'); + }); + + it('captureUserIdle:true but not granted: no trace (never prompts)', async () => { + const { rows, query } = await idleRows({ captureUserIdle: true }, 'prompt'); + expect(query).toHaveBeenCalled(); + expect(rows.has('user_idle_state')).toBe(false); + }); + + it.each([ + 'true', + 1, + 0, + '', + null, + ])('captureUserIdle=%j (invalid) stays OFF and reports', async (bad) => { + const onError = vi.fn(); + const { rows, query } = await idleRows( + { captureUserIdle: bad as never, onError }, + 'granted', + ); + expect(rows.has('user_idle_state')).toBe(false); + expect(query).not.toHaveBeenCalled(); + expect( + onError.mock.calls.filter((c) => + String((c[0] as Error).message).startsWith('captureUserIdle must be a boolean'), + ), + ).toHaveLength(1); + }); + }); + it('a window whose getters throw (including its document) never fails launch: each is omitted and reported', () => { stubSensors(); try { diff --git a/packages/webview/src/launch.ts b/packages/webview/src/launch.ts index dc88ba3f..fcfd09e3 100644 --- a/packages/webview/src/launch.ts +++ b/packages/webview/src/launch.ts @@ -3,6 +3,7 @@ import { bindInputBlackout, boundMatchMedia, browserTraceEnvFrom, + CAPTURE_USER_IDLE_OPTION_KEY, createBrowserInputSource, createBrowserSystemEventsSource, createBrowserSystemTraceProducers, @@ -29,6 +30,7 @@ import { } from '@bugsee/capture'; import { BlackoutToken, + BOOLEAN_OPTION, type BreadcrumbFilter, BUGSEE_SDK_VERSION, type BugseeClient, @@ -100,6 +102,13 @@ const WEBVIEW_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ default: true, ...FAIL_ON_BOOLEAN_OPTION, }, + // `user_idle_state` is behavioural data: OPT-IN, only boolean `true` enables it (same switch as @bugsee/browser). + { + friendly: 'captureUserIdle', + key: CAPTURE_USER_IDLE_OPTION_KEY, + default: false, + ...BOOLEAN_OPTION, + }, ]; /** @@ -191,6 +200,12 @@ export interface BugseeWebViewLaunchOptions { * injected, so opting out leaves this SDK contributing no rects at all. */ captureObscuring?: boolean; + /** + * Record the `user_idle_state` system trace (`active` / `idle` / `locked`, Idle Detection API). Default FALSE: + * behavioural data, and a page's own `idle-detection` grant is not consent to upload it. Even when true the trace + * exists only if the permission is ALREADY `granted` (the SDK never prompts). Only boolean `true` enables it. + */ + captureUserIdle?: boolean; /** * Allow the WebView to emit report TRIGGERS — an uncaught error / `logException` OPENING a native bug: @@ -718,6 +733,7 @@ export function launchWithSeams( ...(domDocument !== undefined ? { document: domDocument } : {}), }), ...(options.systemMetricsSampler !== undefined ? { sensors: false } : {}), + captureUserIdle: resolved.options.get(CAPTURE_USER_IDLE_OPTION_KEY, false), lifecycle, ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), },