/** * Discriminated-union check protocol used by the system check engine. * * `CheckState` is the per-check result. `CheckRow` carries the kind, * the state, and a narrowed payload (eg. mbps on the connection row, * a photo on the camera row). Consumers branch on `state.kind` and * `state.code` — never on the human `detail` string. * * Replaces the teq-ts pattern of `{ key, status, detail }` where * `detail` was a translation key OR a raw string and consumers had to * `detail.includes("iOS")` to branch. Here every branch is a typed * code. */ import { type BrowserInfo, type DeviceCategory, detectBrowser, getDeviceType, getOS, isIOS, isLowMemoryDevice, isMobileOrTablet, isSafari, } from "./browser-detect.js"; import type { ConnectionQualityResult } from "./connection-quality.js"; /** Machine-readable failure codes. Consumers branch on these, never on `detail`. */ export type FailCode = | "unsupported-browser" | "outdated-browser" | "ios-device" | "low-memory" | "incompatible-device" | "bad-layout" | "external-monitor" | "offline" | "slow-connection" | "speed-test-failed" | "permission-denied" | "permission-required" | "device-disconnected" | "no-device-found" | "no-face" | "multiple-faces" | "deep-check-failed" | "screen-share-declined" | "screen-share-wrong-surface" | "screen-share-not-supported"; /** * Failure codes a "Retry" can plausibly clear — the user changes something * in their environment (rotate/resize, unplug a display, reconnect the * network, free up bandwidth) and the same check re-runs in the same tab. * * Everything NOT in this set is deterministic for the page load: browser and * device failures derive from the userAgent / hardware and will fail * identically on every retry. For those, the UI should point the user at the * only real fix (switch browser/device) rather than offer a dead-end retry. */ export const RETRYABLE_FAIL_CODES: ReadonlySet = new Set([ "bad-layout", "external-monitor", "offline", "slow-connection", "speed-test-failed", ]); /** True when a "Retry" could plausibly clear this failure without a reload. */ export const isRetryableFailCode = (code: FailCode): boolean => RETRYABLE_FAIL_CODES.has(code); /** Per-check state. Each row stores its own state plus any kind-specific payload. */ export type CheckState = | { kind: "pending" } | { kind: "checking"; message?: string } | { kind: "pass"; detail: string } | { kind: "fail"; code: FailCode; detail: string } | { kind: "skipped" }; /** Built-in check identifiers. */ export type CheckKind = | "browser" | "device" | "layout" | "monitor" | "connection" | "microphone" | "speaker" | "camera" | "screen-share"; /** * One row in the engine's check sequence. `kind` discriminates; the * camera row carries an optional photo payload populated by the deep * camera check. */ export interface CheckRow { kind: CheckKind; state: CheckState; /** JPEG data URL of the photo captured during the deep camera check. Camera row only. */ photo?: string; /** Legacy measured downlink speed in Mbps. */ mbps?: number; /** Application-path upload speed in Mbps. Connection row only. */ uploadMbps?: number; /** Median idle round-trip latency in milliseconds. Connection row only. */ latencyMs?: number; /** Median variation between latency samples in milliseconds. Connection row only. */ jitterMs?: number; /** Server-selected recording upload mode verified by the readiness canary. */ recordingUploadMode?: "direct" | "segments" | "post"; /** Representative readiness payload size. Internal telemetry only. */ readinessPayloadBytes?: number; /** Median representative upload completion time. Internal telemetry only. */ readinessMedianMs?: number; /** Workload-derived completion window. Internal telemetry only. */ readinessWindowMs?: number; /** Number of successful representative readiness samples. Internal telemetry only. */ readinessSampleCount?: number; /** * Plain-language quality bucket for a PASSING connection row (Fair / Good / * Excellent), relative to the configured floor. Absent on fail rows and on * every non-connection row. Consumers render a localised label from this * rather than the raw Mbps `detail`. */ band?: ConnectionBand; } /** Overrides accepted by every individual check. Used by stories + tests. */ export interface CheckOverrides { browser?: BrowserInfo; isIOS?: boolean; isLowMemory?: boolean; isMobile?: boolean; online?: boolean; /** Force a specific mbps reading; pass null to simulate speed-test failure. */ speed?: number | null; badLandscape?: boolean; /** Pass `true` to allow Safari/iOS through the browser check. */ enableSafari?: boolean; hasExternalMonitor?: boolean; /** Override browser support for `getDisplayMedia`. */ screenShareSupported?: boolean; } /** Default reference floor for downlink quality telemetry in megabits/sec. */ export const MIN_SPEED_MBPS = 2; /** * Plain-language quality bucket for measured connection telemetry. A value * below the reference floor has no band; measured Mbps never decides candidate * eligibility. */ export type ConnectionBand = "fair" | "good" | "excellent"; /** * Fair→Good and Good→Excellent boundaries, expressed as MULTIPLES of the * configured floor (not absolute Mbps). Anchoring to the floor means raising * `minUploadBandwidthMbps` / `minBandwidthMbps` slides every boundary up in * lockstep, so the label can never contradict the pass/fail gate. Each * boundary belongs to the HIGHER band (>=). */ const BAND_GOOD_MULTIPLE = 1.5; const BAND_EXCELLENT_MULTIPLE = 3; /** * Bucket a measured speed into a {@link ConnectionBand} relative to the * reference floor. Returns null below the floor or for invalid inputs. */ export const connectionQualityBand = (mbps: number, floorMbps: number): ConnectionBand | null => { if (!Number.isFinite(mbps) || !Number.isFinite(floorMbps) || floorMbps <= 0 || mbps < floorMbps) { return null; } if (mbps >= floorMbps * BAND_EXCELLENT_MULTIPLE) return "excellent"; if (mbps >= floorMbps * BAND_GOOD_MULTIPLE) return "good"; return "fair"; }; /** English band labels — the SDK-default `detail` string. Vue localises via the `band` field. */ const BAND_LABELS: Record = { fair: "Fair", good: "Good", excellent: "Excellent", }; /** Supported browsers, surfaced on the wizard's "your browser isn't supported" alert. */ export const SUPPORTED_BROWSERS = [ { name: "Google Chrome", link: "https://www.google.com/chrome/" }, { name: "Microsoft Edge", link: "https://www.microsoft.com/en-us/edge" }, { name: "Safari 16.4+", link: "https://support.apple.com/safari" }, { name: "Brave", link: "https://brave.com/" }, { name: "Opera", link: "https://www.opera.com/" }, ] as const; // ============================================================================ // Browser // ============================================================================ /** Browser-version check. Side-effect free. */ export const checkBrowser = (overrides?: CheckOverrides): CheckRow => { const browser = overrides?.browser ?? detectBrowser(isSafari); let supported = browser.supported; let reason = browser.failReason; if ( !supported && (browser.name === "Safari" || browser.name === "iOS Safari") && overrides?.enableSafari ) { supported = true; reason = undefined; } if (supported) { const versionPart = browser.version ? `${browser.name} ${browser.version}` : browser.name; return { kind: "browser", state: { kind: "pass", detail: `${versionPart} · ${getOS()}` }, }; } // Fail code: we distinguish "we don't support this browser at all" // (Firefox, Unknown, iOS Safari) from "we support this browser, // just not this old a version" — the latter is fixable by updating, // the former is not. // // Safari on desktop is supported from version 16.4 onwards; older // Safari falls into the "outdated, please update" bucket. iOS // Safari is a separate beast (no getDisplayMedia, OS interruptions // mid-session) and stays as `unsupported-browser`. const code: FailCode = browser.name === "Chrome" || browser.name === "Edge" || browser.name === "Safari" ? "outdated-browser" : "unsupported-browser"; return { kind: "browser", state: { kind: "fail", code, detail: reason ?? `${browser.name} is not supported`, }, }; }; // ============================================================================ // Device // ============================================================================ /** * Device check. Four independent fail conditions in priority order: * * 1. iOS device + iOS-not-enabled → `ios-device` * 2. Mobile/tablet + mobile-not-allowed → `incompatible-device` * 3. Required screen sharing without `getDisplayMedia` → `screen-share-not-supported` * 4. Low-memory (any platform) → `low-memory` * * Everything else passes -- desktop support is the bare minimum. * * `allowMobile` defaults to false. Pre-existing customers got * desktop-only behaviour for iOS by accident (we blocked iOS but * not Android); the explicit default makes the policy uniform. */ export const checkDevice = ( overrides?: CheckOverrides, allowMobile = false, requireScreenShareSupport = false, ): CheckRow => { const iosBlocked = overrides?.isIOS ?? (isIOS() && !overrides?.enableSafari); const lowMem = overrides?.isLowMemory ?? isLowMemoryDevice(); // Non-iOS mobile gate. iOS is already covered by `iosBlocked` // above, so we only check the remaining mobile case here. const mobileBlocked = !allowMobile && !iosBlocked && isMobileOrTablet() && !isIOS(); const screenShareSupported = overrides?.screenShareSupported ?? (typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getDisplayMedia === "function"); const requiredScreenShareUnavailable = requireScreenShareSupport && !screenShareSupported; if (iosBlocked) { return { kind: "device", state: { kind: "fail", code: "ios-device", detail: "iOS device" }, }; } if (mobileBlocked) { return { kind: "device", state: { kind: "fail", code: "incompatible-device", detail: "Mobile device not allowed", }, }; } if (requiredScreenShareUnavailable) { return { kind: "device", state: { kind: "fail", code: "screen-share-not-supported", detail: "Screen sharing is not supported on this device", }, }; } if (lowMem) { return { kind: "device", state: { kind: "fail", code: "low-memory", detail: "Low memory" }, }; } return { kind: "device", state: { kind: "pass", detail: deviceTypeDetail(getDeviceType()) }, }; }; const deviceTypeDetail = (t: DeviceCategory): string => t; // ============================================================================ // Layout // ============================================================================ /** * Minimum height÷width ratio the candidate UI tolerates. Below this the * screen is too wide (a landscape phone/tablet) and the tall, narrow exam * layout breaks. 0.6 is a 5:3 (≈1.67:1) width:height cap. */ const MIN_LAYOUT_ASPECT_RATIO = 0.6; /** * Screen-layout check. Fails when a mobile device is in landscape with * a very wide aspect ratio (>1.66:1) — the candidate UI is built for * tall, narrow screens. Desktops pass regardless. */ export const checkScreenLayout = (overrides?: CheckOverrides): CheckRow => { const badLandscape = overrides?.badLandscape ?? (isMobileOrTablet() && window.innerWidth > window.innerHeight && window.innerHeight / window.innerWidth <= MIN_LAYOUT_ASPECT_RATIO); const detail = `${window.innerWidth} × ${window.innerHeight}`; if (badLandscape) { return { kind: "layout", state: { kind: "fail", code: "bad-layout", detail }, }; } return { kind: "layout", state: { kind: "pass", detail }, }; }; // ============================================================================ // External monitor // ============================================================================ /** * Best-effort external-monitor detection via `screen.isExtended` (Chromium- * only — Safari/Firefox leave the property undefined and we treat that as * "single display"). Spoofable; a deterrent rather than enforcement. * * `developmentMode` lets test code and dev environments force-pass even * when the dev's actual machine has a second monitor attached. Stories * still exercise the failure path via `overrides.hasExternalMonitor`. * * `allowExternalMonitor` is the *policy* knob (vs `developmentMode`'s * "I'm just a dev"): if true, an extended display still detects but * passes, with the detail string surfacing the fact for the audit * trail. Use this when the exam explicitly permits multi-monitor setups. */ export const checkExternalMonitor = ( overrides?: CheckOverrides, developmentMode = false, allowExternalMonitor = false, ): CheckRow => { const screenWithExtended = window.screen as Screen & { isExtended?: boolean; }; let extended: boolean; let forcedPassInDev = false; if (overrides?.hasExternalMonitor !== undefined) { extended = overrides.hasExternalMonitor; } else if (developmentMode) { extended = false; forcedPassInDev = true; } else { extended = screenWithExtended.isExtended === true; } const baseDetail = extended ? "Extended display detected" : "Single display"; const detail = forcedPassInDev ? `${baseDetail} (dev override)` : baseDetail; if (extended && !allowExternalMonitor) { return { kind: "monitor", state: { kind: "fail", code: "external-monitor", detail }, }; } return { kind: "monitor", state: { kind: "pass", detail }, }; }; // ============================================================================ // Connection — speed test // ============================================================================ /** * Cloudflare's public speed endpoint. `?bytes=N` returns exactly N bytes * of payload; `?bytes=0` is a zero-byte probe useful for latency. * * No auth, generous CORS, broadly geo-distributed — good enough for a * preflight signal. We don't care about the absolute number, just * "<2 mbps is going to make webcam upload painful." */ const CLOUDFLARE_SPEED_URL = "https://speed.cloudflare.com/__down"; /** * Hard ceiling on the whole connection measurement (latency warmup + * downloads). A slow or stalled link yields a low (or null) reading within * this budget instead of hanging the preflight on a multi-minute 25 MB pull. */ const MAX_MEASURE_MS = 8_000; /** Per-probe ceiling for the latency warmup (each is a 0-byte request). */ const LATENCY_PROBE_TIMEOUT_MS = 3_000; /** Median round-trip across 5 zero-byte probes, in ms. Null if every probe failed. */ const measureLatency = (): Promise => new Promise((resolve) => { const samples: number[] = []; let done = 0; const rounds = 5; const ping = (): void => { const xhr = new XMLHttpRequest(); const url = `${CLOUDFLARE_SPEED_URL}?bytes=0&_=${Date.now()}${Math.random()}`; const start = performance.now(); xhr.open("GET", url, true); // A stalled probe must not hang the warmup — a probe that times out (or // errors) ends it; latency is only a warmup, the speed downloads gate // the check. xhr.timeout = LATENCY_PROBE_TIMEOUT_MS; xhr.onload = (): void => { samples.push(performance.now() - start); done += 1; if (done < rounds) ping(); else { samples.sort((a, b) => a - b); resolve(Math.round(samples[Math.floor(samples.length / 2)] ?? 0)); } }; xhr.onerror = (): void => resolve(null); xhr.ontimeout = (): void => resolve(null); xhr.send(); }; ping(); }); const downloadTest = (bytes: number, timeoutMs: number): Promise => new Promise((resolve) => { const xhr = new XMLHttpRequest(); const url = `${CLOUDFLARE_SPEED_URL}?bytes=${bytes}&_=${Date.now()}${Math.random()}`; const start = performance.now(); let loaded = 0; let settled = false; const speedFrom = (n: number): number | null => { const elapsed = (performance.now() - start) / 1000; return elapsed > 0 && n > 0 ? (n * 8) / elapsed / 1e6 : null; }; const settle = (value: number | null): void => { if (settled) return; settled = true; resolve(value); }; xhr.open("GET", url, true); xhr.responseType = "arraybuffer"; xhr.timeout = Math.max(0, Math.round(timeoutMs)); xhr.onprogress = (e: ProgressEvent): void => { loaded = e.loaded; }; xhr.onload = (): void => settle(speedFrom(bytes)); // Out of time: salvage a lower-bound reading from the bytes we did get, so // a slow link still produces a (low) speed instead of nothing — that fails // the min-speed threshold rather than hanging the wizard. xhr.ontimeout = (): void => { xhr.abort(); settle(speedFrom(loaded)); }; xhr.onerror = (): void => settle(null); xhr.send(); }); /** * Runs a 5-ping latency warmup and four sequential downloads of 1/5/10/25 * MB. Returns the average successful-download Mbps, rounded to one decimal, * or null if every download failed. */ export const measureSpeed = async (): Promise => { // One budget spans the warmup + all downloads, so the whole check can't run // longer than MAX_MEASURE_MS no matter how slow the link is. const deadline = performance.now() + MAX_MEASURE_MS; await measureLatency(); const sizes = [1e6, 5e6, 10e6, 25e6]; const speeds: number[] = []; for (const bytes of sizes) { const remaining = deadline - performance.now(); if (remaining <= 500) break; // budget spent — go with what completed const mbps = await downloadTest(bytes, remaining); if (mbps !== null) speeds.push(mbps); } if (speeds.length === 0) return null; const avg = speeds.reduce((a, b) => a + b, 0) / speeds.length; if (!Number.isFinite(avg) || avg <= 0) return null; return Math.round(avg * 10) / 10; }; /** * Online-state check. Returns a pass/fail row directly when offline OR * when a speed override is supplied; otherwise returns null to signal * "the caller should now run an async speed measurement." * * Splitting the sync vs async paths keeps overrides instant (stories + * tests get deterministic output) while still letting the engine show * a "Testing speed..." intermediate state for real measurements. */ export const checkConnection = (overrides?: CheckOverrides): CheckRow | null => { const online = overrides?.online ?? navigator.onLine; if (!online) { return { kind: "connection", state: { kind: "fail", code: "offline", detail: "Offline" }, }; } if (overrides && "speed" in overrides) { return applySpeedToResult(overrides.speed ?? null); } return null; }; /** * Build the connection row from a measured speed (or null on measurement * failure). Numeric speed is retained as telemetry, but never gates the * candidate because short browser probes are dominated by request latency. */ export const applySpeedToResult = ( speed: number | null, minMbps: number = MIN_SPEED_MBPS, ): CheckRow => { if (speed === null) { return { kind: "connection", state: { kind: "fail", code: "speed-test-failed", detail: "Unable to measure", }, }; } const band = connectionQualityBand(speed, minMbps); return { kind: "connection", state: { kind: "pass", detail: band ? BAND_LABELS[band] : `${speed} Mbps` }, mbps: speed, ...(band ? { band } : {}), }; }; /** * Preserve application-path measurements as internal diagnostics. A completed * measurement passes regardless of the estimated Mbps; candidate eligibility * is decided separately by the workload-aware recording-storage canary. */ export const applyConnectionQualityToResult = ( result: ConnectionQualityResult, minDownloadMbps: number = MIN_SPEED_MBPS, minUploadMbps: number = MIN_SPEED_MBPS, ): CheckRow => { const download = result.conservativeDownloadMbps; const upload = result.conservativeUploadMbps; const fields = { ...(result.downloadMbps !== null ? { mbps: result.downloadMbps } : {}), ...(result.uploadMbps !== null ? { uploadMbps: result.uploadMbps } : {}), ...(result.latencyMs !== null ? { latencyMs: result.latencyMs } : {}), ...(result.jitterMs !== null ? { jitterMs: result.jitterMs } : {}), }; if (download === null || upload === null) { return { kind: "connection", state: { kind: "fail", code: "speed-test-failed", detail: "Unable to measure", }, ...fields, }; } // Band the worst direction only when both values meet the configured // reference floors. The band is display telemetry, never a gate. const uploadBand = connectionQualityBand(upload, minUploadMbps); const downloadBand = connectionQualityBand(download, minDownloadMbps); const band = uploadBand && downloadBand ? worseBand(uploadBand, downloadBand) : null; return { kind: "connection", state: { kind: "pass", detail: band ? BAND_LABELS[band] : "Connection available", }, ...fields, ...(band ? { band } : {}), }; }; /** Rank order for reducing two direction bands to the binding (lower) one. */ const BAND_RANK: Record = { fair: 0, good: 1, excellent: 2, }; /** * The lower (worse) of two bands. A null (below-floor) direction can't reach * this branch — the gate already failed the row — so treat null as "no * opinion" and fall back to the other direction. */ function worseBand(a: ConnectionBand | null, b: ConnectionBand | null): ConnectionBand | null { if (a === null) return b; if (b === null) return a; return BAND_RANK[a] <= BAND_RANK[b] ? a : b; }