/** * Framework-agnostic pre-flight system check engine. * * Consumers can drive it either way: * * Async-generator (pull-based): * const engine = new SystemCheck(options); * for await (const snapshot of engine.run()) { * // snapshot is a frozen copy of the current rows * } * * Callback-based subscribe (push-based, for Vue's `watch` etc.): * const engine = new SystemCheck(options); * const unsubscribe = engine.subscribe((rows) => { ... }); * const report = await engine.runOnce(); * unsubscribe(); * * `runSimulated(results)` is a separate method, NOT a branch inside * `run()` — keeps the production path narrow and stories cleanly * deterministic. * * Output: * - `rows`: live array of `CheckRow` mirroring the engine's progress. * Each row updates in place but the array is replaced when iterating * via the async generator so React/Vue see a new reference. * - `runOnce()` resolves to a JSON-serialisable `PreflightReport` with * a final snapshot. * - `subscribe(cb)` calls `cb(rows)` after every state transition. */ import { type CheckKind, type CheckOverrides, type CheckRow, type CheckState, type FailCode } from "./checks.js"; import { type ConnectionQualityOptions, type HttpConnectionProbeOptions } from "./connection-quality.js"; import { type DeepCameraCheckOptions } from "./deep-camera-check.js"; export type { CheckKind, CheckOverrides, CheckRow, CheckState, FailCode }; export type SystemCheckMode = "boot" | "normal" | "recheck"; /** Which media checks to run. Off by default — keep the production path narrow. */ export interface MediaChecksConfig { microphone?: boolean; speaker?: boolean; camera?: boolean; /** * Verify the candidate can share their entire screen. The check * sits in a `permission-required` state at mount (it can't run * automatically because `getDisplayMedia` needs a user-activation * gesture). The wizard renders an action card; clicking the * button triggers the probe, which validates the chosen surface * is `monitor` and immediately stops the stream. */ screenShare?: boolean; } /** * Which system-class checks to include. Each is on by default -- * skipping one drops the row from the wizard entirely (not just * skipped, no chip in the summary). * * Practical reasons to toggle off: * - `browser`: the consuming app already gates browser * compatibility upstream (paywall, login screen, etc) so * re-checking here is noise. * - `device`: similar. * - `layout`: candidate UI handles its own responsive layout, * no need to fail at preflight. * - `externalMonitor`: the policy explicitly allows multi-monitor * setups (see also `thresholds.allowExternalMonitor` for the * finer-grained pass/fail policy). * - `connection`: low-stakes practice quiz, internet quality is * the candidate's problem. */ export interface SystemChecksConfig { /** Default: true. */ browser?: boolean; /** Default: true. */ device?: boolean; /** Default: true. */ layout?: boolean; /** Default: true. */ externalMonitor?: boolean; /** Default: true. */ connection?: boolean; } /** Browser capabilities that must be available before media steps begin. */ export interface RequiredCapabilitiesConfig { /** * Require `navigator.mediaDevices.getDisplayMedia`. When unavailable, the * initial device row fails with `screen-share-not-supported`. Default: false. */ screenShare?: boolean; } /** * Per-policy thresholds the customer can tune. Every field is * optional; omitted fields fall through to the documented default. * These knobs control *pass/fail rules*, not whether a check runs * (see `SystemChecksConfig` for the on/off toggle). */ export interface ThresholdsConfig { /** * Download-bandwidth reference used for internal quality telemetry. Browser * Mbps estimates do not decide candidate eligibility. Default: 2. */ minBandwidthMbps?: number; /** * Upload-bandwidth reference used for internal quality telemetry. Recording * readiness is derived from the enabled media workload instead. Default: 2. */ minUploadBandwidthMbps?: number; /** * Continuous-speech duration (ms) required before the mic check * verifies. The VAD waits this long after detecting voice before * declaring the mic working. Lower = more permissive but more * prone to misfires (single-word noises, coughs). * Default: 1500. */ micMinSpeechMs?: number; /** * Maximum allowed face count in the deep-camera frame for the * candidate to pass. * 1 = "exactly one face" (typical proctoring). * Higher values allow a chaperone or assistive companion in frame. * Default: 1. */ maxFacesAllowed?: number; /** * Whether to let candidates with an external monitor connected * pass the monitor check. Default: false (block multi-display * setups -- the canonical proctoring rule). */ allowExternalMonitor?: boolean; /** * Whether to let candidates on mobile devices (Android phones, * iPads, etc) pass the device check. Default: false. Note: iOS * specifically is also gated by `enableSafari`; both must be * truthy for an iPhone candidate to pass. */ allowMobile?: boolean; } export interface ConnectionReadinessCheckResult { mode: "direct" | "segments" | "post"; payloadBytes: number; completionWindowMs: number; sampleDurationsMs: number[]; medianCompletionMs: number; } export interface SystemCheckOptions { /** * Mode controls the row list and the short-circuit behaviour: * - `boot`: first-load wizard. Runs the same checks as `normal` * (browser + media), but consumers render the granular layout * instead of collapsing browser-class rows under a "System" row. * - `normal`: browser-class + media rows. Browser-class failures * mark media rows as `skipped`. * - `recheck`: same as `normal` but the consuming UI shouldn't auto- * resume on pass — it's the candidate's responsibility to click. * Default: `normal`. */ mode?: SystemCheckMode; /** Media rows to include. Default: none. */ media?: MediaChecksConfig; /** System rows to include. Each defaults to true (all five run). */ system?: SystemChecksConfig; /** Capabilities required by runtime policy. Default: none. */ requiredCapabilities?: RequiredCapabilitiesConfig; /** Per-policy pass/fail thresholds. All fields optional with documented defaults. */ thresholds?: ThresholdsConfig; /** * Treat the host as a development environment. Force-passes the * external-monitor check (otherwise devs working on two-monitor rigs * can't run the wizard). Tests opt in via stories. */ developmentMode?: boolean; /** Allow Safari/iOS through the browser + device checks. Off by default. */ enableSafari?: boolean; /** * Optional override map applied to individual checks. Used by stories * and integration tests to pin specific failures. */ overrides?: CheckOverrides; /** * Deep camera check configuration. `detectFace` is mandatory whenever * the camera row is enabled: the camera check returns pass only when * the detector reports a face count inside the configured policy band. */ deepCamera?: Omit; /** * Step delay used for visual pacing between row transitions. The * wizard looks too abrupt without this. Default: 300ms. Set to 0 in * tests to skip pacing. */ stepDelayMs?: number; /** * Minimum time the connection row stays in the "Testing speed..." * state. A fast same-origin measurement (often <100ms) makes the * spinner flash imperceptibly. Default: 700ms. */ minSpeedTestMs?: number; /** * Optional production-path upload/download measurement. When omitted, the * legacy Cloudflare download-only probe remains available for standalone * SDK consumers. */ connectionTest?: HttpConnectionProbeOptions & ConnectionQualityOptions; /** * Optional workload-aware recording-path check. When supplied, this is the * candidate eligibility signal; numeric speed tests remain telemetry only. */ connectionReadiness?: () => Promise; } /** JSON-serialisable final report. No class instances, safe to log + persist. */ export interface PreflightReport { mode: SystemCheckMode; passed: boolean; rows: CheckRow[]; failures: Array<{ kind: CheckKind; code: FailCode; detail: string; }>; finishedAt: number; } /** Result shape used by `runSimulated()`. */ export interface SimulatedCheckResult { kind: CheckKind; state: CheckState; photo?: string; mbps?: number; } /** * Listener invoked after every row state transition. Receives a frozen * copy of the rows array so the caller can rely on referential * inequality between snapshots. */ export type SystemCheckListener = (rows: ReadonlyArray) => void; /** * Threshold defaults. Exported alongside the type so consumers * (Vue wizard's MicStep, integration tests, etc) can read the * same numbers we apply internally without duplicating them. */ export declare const THRESHOLD_DEFAULTS: Required; export declare class SystemCheck { private readonly options; private rows; private readonly listeners; private running; constructor(options?: SystemCheckOptions); /** * Replace the row list. Use this after changing `mode` between * runs (eg. user retries after a media failure). */ reset(): void; /** * Current rows. Deep-cloned and frozen at every level: consumers can * hold a snapshot through later state transitions and observe it as * it was at capture time. Mutations on `this.rows[i].state` inside * `execute()` must not leak into prior snapshots — that's the bug we * had to fix for the `run()` generator's all-pending initial yield. */ getRows(): ReadonlyArray; /** * Subscribe to row transitions. Listener fires once with the initial * rows, then once per state transition during a run. Returns an * unsubscribe function. */ subscribe(listener: SystemCheckListener): () => void; /** * Pull-based runner. Yields a snapshot after every state transition. * The final yielded value is the same array as the report's `rows`. * * Throws if a run is already in progress on this instance. */ run(): AsyncGenerator, PreflightReport, void>; /** Run-and-resolve. Equivalent to draining `run()` but more ergonomic. */ runOnce(): Promise; /** * Apply a set of simulated results to the row list without running * any real checks. Used by stories (and tests, when desired). Keeps * the same visual pacing as `run()` so the stories feel real. */ runSimulated(results: SimulatedCheckResult[]): Promise; private execute; private runRow; private runConnection; /** * Run just the system-class checks (browser → device → layout → * monitor → connection). The wizard drives this as step 1; the * legacy `runChecks()` flow composes it as the first phase of a * full run. Idempotent: safe to call on retry. */ runSystemClass(): Promise; /** * Run the five system-class rows in order, gating on the non-retryable * front-runners (browser, device). If either fails, the retryable rows * behind them (layout / monitor / connection) can't unblock the * candidate, so mark any still-pending ones skipped and stop. Returns * true if the gate tripped. See {@link GATE_KINDS}. */ private runSystemClassSequence; /** True once a non-retryable gate check (browser/device) has failed. */ private gateTripped; /** Mark every still-pending row of the given kinds as skipped. */ private skipPending; /** * Probe microphone permission + device availability with an * optional explicit `deviceId`. Used by the wizard's mic step so * the candidate can pick a specific input from a dropdown after * granting permission. Without `deviceId` we use the browser * default. */ probeMic(deviceId?: string): Promise; /** * Probe speaker — really an "are audio output devices listable * yet?" check, since browsers don't expose a permission for * output. Mic permission gates the device list as a side effect; * this method should be called AFTER probeMic has resolved. */ probeSpeaker(): Promise; /** * Probe camera — permission + device + deep face/photo check. * Optional `deviceId` picks a specific camera; without it the * browser default is used. */ probeCamera(deviceId?: string, existingStream?: { videoElement: HTMLVideoElement; }): Promise<{ /** * Per-attempt frames the deep-check captured, in order. Empty * when permission was denied or no deep-check was run. The * Vue wizard's camera step uploads each attempt as a separate * WebcamPhoto row so the dashboard can review the full * sequence. */ attempts: import("./deep-camera-check.js").DeepCameraAttempt[]; }>; /** * Probe screen-share. The preflight check is *attestation-only*: * we confirm the browser supports `getDisplayMedia` and that the * candidate has acknowledged they understand the test will record * their screen. The real screen-share gesture happens at session * start under a fresh user activation. * * Why no real `getDisplayMedia` here: * - `getDisplayMedia` permission is not persistent in any * browser. A grant given here doesn't survive into the * session, and the OS picker reappears the moment the * session-recorder calls it. Validating + immediately * releasing was a permission gesture the candidate paid for * nothing functional. * - The "Entire Screen vs Window" surface check has to run * again at session start anyway; doing it twice is wasted * UX. We surface the same expectation in the preflight copy. * * `attested` is the candidate's tick of the "I understand my * screen will be recorded" checkbox in the wizard. Passing false * (default) leaves the row in `checking` so Continue stays * disabled; passing true sets pass. * * Idempotent -- safe to call again on retry. No user-activation * requirement because no real media API is invoked. */ probeScreenShare(attested?: boolean): Promise; private runMedia; /** * Camera state resolution. Permission → live stream/device → * deep verification. Camera preflight is evidence-bearing: * a detector must be configured and a frame must be captured. */ private resolveCameraState; private buildInitialRows; private hasRow; private setState; private setRowField; private notify; private buildReport; private delay; } /** * Convenience wrapper for the common case ("just run it once and give * me the report"). Equivalent to `new SystemCheck(options).runOnce()`. */ export declare const runSystemCheck: (options?: SystemCheckOptions) => Promise; //# sourceMappingURL=system-check.d.ts.map