@a4anthony/proctorkit-sdk 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,10 +49,7 @@ import {
49
49
  type ConnectionQualityOptions,
50
50
  type HttpConnectionProbeOptions,
51
51
  } from "./connection-quality.js";
52
- import {
53
- runDeepCameraCheck,
54
- type DeepCameraCheckOptions,
55
- } from "./deep-camera-check.js";
52
+ import { runDeepCameraCheck, type DeepCameraCheckOptions } from "./deep-camera-check.js";
56
53
 
57
54
  export type { CheckKind, CheckOverrides, CheckRow, CheckState, FailCode };
58
55
 
@@ -122,14 +119,13 @@ export interface RequiredCapabilitiesConfig {
122
119
  */
123
120
  export interface ThresholdsConfig {
124
121
  /**
125
- * Minimum download bandwidth (Mbps) for the connection check to
126
- * pass. A measurement below this trips `slow-connection`.
127
- * Default: 2.
122
+ * Download-bandwidth reference used for internal quality telemetry. Browser
123
+ * Mbps estimates do not decide candidate eligibility. Default: 2.
128
124
  */
129
125
  minBandwidthMbps?: number;
130
126
  /**
131
- * Minimum application-path upload bandwidth (Mbps). Upload is the primary
132
- * gate for continuous webcam and screen recordings. Default: 2.
127
+ * Upload-bandwidth reference used for internal quality telemetry. Recording
128
+ * readiness is derived from the enabled media workload instead. Default: 2.
133
129
  */
134
130
  minUploadBandwidthMbps?: number;
135
131
  /**
@@ -163,6 +159,52 @@ export interface ThresholdsConfig {
163
159
  allowMobile?: boolean;
164
160
  }
165
161
 
162
+ export interface ConnectionReadinessCheckResult {
163
+ mode: "direct" | "segments" | "post";
164
+ payloadBytes: number;
165
+ completionWindowMs: number;
166
+ sampleDurationsMs: number[];
167
+ medianCompletionMs: number;
168
+ }
169
+
170
+ type ReadinessTelemetry = Partial<
171
+ Pick<
172
+ CheckRow,
173
+ | "recordingUploadMode"
174
+ | "readinessPayloadBytes"
175
+ | "readinessMedianMs"
176
+ | "readinessWindowMs"
177
+ | "readinessSampleCount"
178
+ >
179
+ >;
180
+
181
+ function readinessErrorDetails(error: unknown): ReadinessTelemetry {
182
+ if (!error || typeof error !== "object" || !("details" in error)) return {};
183
+ const details = (error as { details?: unknown }).details;
184
+ if (!details || typeof details !== "object") return {};
185
+ const values = details as Record<string, unknown>;
186
+ const mode = values["recordingUploadMode"];
187
+ const payloadBytes = finiteNumber(values["readinessPayloadBytes"]);
188
+ const medianMs = finiteNumber(values["readinessMedianMs"]);
189
+ const windowMs = finiteNumber(values["readinessWindowMs"]);
190
+ const sampleCount = finiteNumber(values["readinessSampleCount"]);
191
+ return {
192
+ ...(mode === "direct" || mode === "segments" || mode === "post"
193
+ ? { recordingUploadMode: mode }
194
+ : {}),
195
+ ...(payloadBytes !== undefined ? { readinessPayloadBytes: payloadBytes } : {}),
196
+ ...(medianMs !== undefined ? { readinessMedianMs: medianMs } : {}),
197
+ ...(windowMs !== undefined ? { readinessWindowMs: windowMs } : {}),
198
+ ...(sampleCount !== undefined
199
+ ? { readinessSampleCount: Math.max(0, Math.floor(sampleCount)) }
200
+ : {}),
201
+ };
202
+ }
203
+
204
+ function finiteNumber(value: unknown): number | undefined {
205
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
206
+ }
207
+
166
208
  export interface SystemCheckOptions {
167
209
  /**
168
210
  * Mode controls the row list and the short-circuit behaviour:
@@ -221,6 +263,11 @@ export interface SystemCheckOptions {
221
263
  * SDK consumers.
222
264
  */
223
265
  connectionTest?: HttpConnectionProbeOptions & ConnectionQualityOptions;
266
+ /**
267
+ * Optional workload-aware recording-path check. When supplied, this is the
268
+ * candidate eligibility signal; numeric speed tests remain telemetry only.
269
+ */
270
+ connectionReadiness?: () => Promise<ConnectionReadinessCheckResult>;
224
271
  }
225
272
 
226
273
  /** JSON-serialisable final report. No class instances, safe to log + persist. */
@@ -247,20 +294,9 @@ export interface SimulatedCheckResult {
247
294
  */
248
295
  export type SystemCheckListener = (rows: ReadonlyArray<CheckRow>) => void;
249
296
 
250
- const BROWSER_CLASS_KINDS: CheckKind[] = [
251
- "browser",
252
- "device",
253
- "layout",
254
- "monitor",
255
- "connection",
256
- ];
257
-
258
- const MEDIA_KINDS: CheckKind[] = [
259
- "microphone",
260
- "speaker",
261
- "camera",
262
- "screen-share",
263
- ];
297
+ const BROWSER_CLASS_KINDS: CheckKind[] = ["browser", "device", "layout", "monitor", "connection"];
298
+
299
+ const MEDIA_KINDS: CheckKind[] = ["microphone", "speaker", "camera", "screen-share"];
264
300
 
265
301
  /**
266
302
  * The non-retryable front-runners. Browser and device failures are
@@ -310,6 +346,7 @@ interface ResolvedOptions {
310
346
  overrides: CheckOverrides | undefined;
311
347
  deepCamera: SystemCheckOptions["deepCamera"];
312
348
  connectionTest: SystemCheckOptions["connectionTest"];
349
+ connectionReadiness: SystemCheckOptions["connectionReadiness"];
313
350
  }
314
351
 
315
352
  export class SystemCheck {
@@ -336,6 +373,7 @@ export class SystemCheck {
336
373
  overrides: options.overrides,
337
374
  deepCamera: options.deepCamera,
338
375
  connectionTest: options.connectionTest,
376
+ connectionReadiness: options.connectionReadiness,
339
377
  };
340
378
  this.rows = this.buildInitialRows();
341
379
  }
@@ -426,11 +464,9 @@ export class SystemCheck {
426
464
  continue;
427
465
  }
428
466
  if (done) break;
429
- const next = await new Promise<ReadonlyArray<CheckRow> | null>(
430
- (resolve) => {
431
- resolveWaiter = resolve;
432
- },
433
- );
467
+ const next = await new Promise<ReadonlyArray<CheckRow> | null>((resolve) => {
468
+ resolveWaiter = resolve;
469
+ });
434
470
  if (next === null) break;
435
471
  yield next;
436
472
  }
@@ -495,10 +531,7 @@ export class SystemCheck {
495
531
  row.state = { kind: "pass", detail: "Ready" };
496
532
  }
497
533
  this.notify();
498
- if (
499
- row.state.kind === "fail" &&
500
- BROWSER_CLASS_KINDS.includes(row.kind)
501
- ) {
534
+ if (row.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(row.kind)) {
502
535
  browserClassFailed = true;
503
536
  }
504
537
  }
@@ -529,8 +562,7 @@ export class SystemCheck {
529
562
 
530
563
  // Short-circuit media when any system-class check failed.
531
564
  const browserClassFailed = this.rows.some(
532
- (r) =>
533
- r.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(r.kind),
565
+ (r) => r.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(r.kind),
534
566
  );
535
567
  if (browserClassFailed) {
536
568
  this.skipPending(MEDIA_KINDS);
@@ -548,10 +580,7 @@ export class SystemCheck {
548
580
  return this.buildReport();
549
581
  }
550
582
 
551
- private async runRow(
552
- kind: CheckKind,
553
- compute: () => CheckState,
554
- ): Promise<void> {
583
+ private async runRow(kind: CheckKind, compute: () => CheckState): Promise<void> {
555
584
  if (!this.hasRow(kind)) return;
556
585
  this.setState(kind, { kind: "checking", message: "Checking..." });
557
586
  await this.delay(this.options.stepDelayMs);
@@ -580,19 +609,61 @@ export class SystemCheck {
580
609
  message: "Testing connection...",
581
610
  });
582
611
  const start = performance.now();
583
- const result = this.options.connectionTest
584
- ? applyConnectionQualityToResult(
585
- await measureConnectionQuality(
586
- createHttpConnectionProbe(this.options.connectionTest),
587
- this.options.connectionTest,
588
- ),
589
- this.options.thresholds.minBandwidthMbps,
590
- this.options.thresholds.minUploadBandwidthMbps,
591
- )
592
- : applySpeedToResult(
593
- await measureSpeed(),
594
- this.options.thresholds.minBandwidthMbps,
595
- );
612
+ let result: CheckRow;
613
+ if (this.options.connectionReadiness) {
614
+ try {
615
+ const readiness = await this.options.connectionReadiness();
616
+ result = {
617
+ kind: "connection",
618
+ state: { kind: "pass", detail: "Connection available" },
619
+ recordingUploadMode: readiness.mode,
620
+ readinessPayloadBytes: readiness.payloadBytes,
621
+ readinessMedianMs: readiness.medianCompletionMs,
622
+ readinessWindowMs: readiness.completionWindowMs,
623
+ readinessSampleCount: readiness.sampleDurationsMs.length,
624
+ };
625
+ } catch (error) {
626
+ const diagnostic =
627
+ error && typeof error === "object" && "code" in error
628
+ ? String((error as { code: unknown }).code)
629
+ : "unavailable";
630
+ const details = readinessErrorDetails(error);
631
+ result = {
632
+ kind: "connection",
633
+ state: {
634
+ kind: "fail",
635
+ code: "speed-test-failed",
636
+ detail: `Connection readiness failed: ${diagnostic}`,
637
+ },
638
+ ...(details.recordingUploadMode !== undefined
639
+ ? { recordingUploadMode: details.recordingUploadMode }
640
+ : {}),
641
+ ...(details.readinessPayloadBytes !== undefined
642
+ ? { readinessPayloadBytes: details.readinessPayloadBytes }
643
+ : {}),
644
+ ...(details.readinessMedianMs !== undefined
645
+ ? { readinessMedianMs: details.readinessMedianMs }
646
+ : {}),
647
+ ...(details.readinessWindowMs !== undefined
648
+ ? { readinessWindowMs: details.readinessWindowMs }
649
+ : {}),
650
+ ...(details.readinessSampleCount !== undefined
651
+ ? { readinessSampleCount: details.readinessSampleCount }
652
+ : {}),
653
+ };
654
+ }
655
+ } else {
656
+ result = this.options.connectionTest
657
+ ? applyConnectionQualityToResult(
658
+ await measureConnectionQuality(
659
+ createHttpConnectionProbe(this.options.connectionTest),
660
+ this.options.connectionTest,
661
+ ),
662
+ this.options.thresholds.minBandwidthMbps,
663
+ this.options.thresholds.minUploadBandwidthMbps,
664
+ )
665
+ : applySpeedToResult(await measureSpeed(), this.options.thresholds.minBandwidthMbps);
666
+ }
596
667
  const elapsed = performance.now() - start;
597
668
  if (elapsed < this.options.minSpeedTestMs) {
598
669
  await this.delay(this.options.minSpeedTestMs - elapsed);
@@ -613,6 +684,21 @@ export class SystemCheck {
613
684
  if (result.band !== undefined) {
614
685
  this.setRowField("connection", "band", result.band);
615
686
  }
687
+ if (result.recordingUploadMode !== undefined) {
688
+ this.setRowField("connection", "recordingUploadMode", result.recordingUploadMode);
689
+ }
690
+ if (result.readinessPayloadBytes !== undefined) {
691
+ this.setRowField("connection", "readinessPayloadBytes", result.readinessPayloadBytes);
692
+ }
693
+ if (result.readinessMedianMs !== undefined) {
694
+ this.setRowField("connection", "readinessMedianMs", result.readinessMedianMs);
695
+ }
696
+ if (result.readinessWindowMs !== undefined) {
697
+ this.setRowField("connection", "readinessWindowMs", result.readinessWindowMs);
698
+ }
699
+ if (result.readinessSampleCount !== undefined) {
700
+ this.setRowField("connection", "readinessSampleCount", result.readinessSampleCount);
701
+ }
616
702
  }
617
703
 
618
704
  /**
@@ -636,9 +722,7 @@ export class SystemCheck {
636
722
  * candidate, so mark any still-pending ones skipped and stop. Returns
637
723
  * true if the gate tripped. See {@link GATE_KINDS}.
638
724
  */
639
- private async runSystemClassSequence(
640
- overrides: CheckOverrides,
641
- ): Promise<boolean> {
725
+ private async runSystemClassSequence(overrides: CheckOverrides): Promise<boolean> {
642
726
  await this.runRow("browser", () => checkBrowser(overrides).state);
643
727
  await this.runRow(
644
728
  "device",
@@ -671,9 +755,7 @@ export class SystemCheck {
671
755
 
672
756
  /** True once a non-retryable gate check (browser/device) has failed. */
673
757
  private gateTripped(): boolean {
674
- return this.rows.some(
675
- (r) => GATE_KINDS.includes(r.kind) && r.state.kind === "fail",
676
- );
758
+ return this.rows.some((r) => GATE_KINDS.includes(r.kind) && r.state.kind === "fail");
677
759
  }
678
760
 
679
761
  /** Mark every still-pending row of the given kinds as skipped. */
@@ -742,11 +824,7 @@ export class SystemCheck {
742
824
  message: "Checking permission...",
743
825
  });
744
826
  const perms = await queryPermissions({ mic: false, camera: true });
745
- const camState = await this.resolveCameraState(
746
- perms.camera,
747
- deviceId,
748
- existingStream,
749
- );
827
+ const camState = await this.resolveCameraState(perms.camera, deviceId, existingStream);
750
828
  this.setState("camera", camState.state);
751
829
  if (camState.photo) this.setRowField("camera", "photo", camState.photo);
752
830
  return { attempts: camState.attempts ?? [] };
@@ -788,10 +866,7 @@ export class SystemCheck {
788
866
  // a browser without `getDisplayMedia` will fail at session
789
867
  // start anyway, but the candidate deserves to know now while
790
868
  // they can still switch to a supported browser.
791
- if (
792
- typeof navigator === "undefined" ||
793
- !navigator.mediaDevices?.getDisplayMedia
794
- ) {
869
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
795
870
  this.setState("screen-share", {
796
871
  kind: "fail",
797
872
  code: "screen-share-not-supported",
@@ -1059,9 +1134,8 @@ export class SystemCheck {
1059
1134
  * Convenience wrapper for the common case ("just run it once and give
1060
1135
  * me the report"). Equivalent to `new SystemCheck(options).runOnce()`.
1061
1136
  */
1062
- export const runSystemCheck = async (
1063
- options: SystemCheckOptions = {},
1064
- ): Promise<PreflightReport> => new SystemCheck(options).runOnce();
1137
+ export const runSystemCheck = async (options: SystemCheckOptions = {}): Promise<PreflightReport> =>
1138
+ new SystemCheck(options).runOnce();
1065
1139
 
1066
1140
  // ============================================================================
1067
1141
  // Permissions + media-device probing
@@ -1079,9 +1153,7 @@ interface PermissionResults {
1079
1153
  camera: PermissionResult;
1080
1154
  }
1081
1155
 
1082
- const queryPermissions = async (
1083
- q: PermissionQuery,
1084
- ): Promise<PermissionResults> => {
1156
+ const queryPermissions = async (q: PermissionQuery): Promise<PermissionResults> => {
1085
1157
  const probe = async (name: PermissionName): Promise<PermissionResult> => {
1086
1158
  if (typeof navigator === "undefined" || !navigator.permissions) {
1087
1159
  return "unknown";
@@ -1147,8 +1219,7 @@ const resolveAudioInputState = async (
1147
1219
  detail: "Permission required",
1148
1220
  };
1149
1221
  }
1150
- const firstLabel =
1151
- pickDeviceLabel(devices, deviceId) || "Ready";
1222
+ const firstLabel = pickDeviceLabel(devices, deviceId) || "Ready";
1152
1223
  return { kind: "pass", detail: firstLabel };
1153
1224
  }
1154
1225
  const devices = await enumerateAudioInputs();
@@ -1163,11 +1234,8 @@ const resolveAudioInputState = async (
1163
1234
  return { kind: "pass", detail: label };
1164
1235
  };
1165
1236
 
1166
- const hasLiveVideoStream = (
1167
- video: HTMLVideoElement | undefined,
1168
- ): boolean => {
1169
- const stream =
1170
- video?.srcObject instanceof MediaStream ? video.srcObject : null;
1237
+ const hasLiveVideoStream = (video: HTMLVideoElement | undefined): boolean => {
1238
+ const stream = video?.srcObject instanceof MediaStream ? video.srcObject : null;
1171
1239
  return stream?.getVideoTracks().some((track) => track.readyState === "live") ?? false;
1172
1240
  };
1173
1241
 
@@ -1176,10 +1244,7 @@ const hasLiveVideoStream = (
1176
1244
  * candidate's specific deviceId resolves, use its label; otherwise
1177
1245
  * fall back to the first labelled device.
1178
1246
  */
1179
- const pickDeviceLabel = (
1180
- devices: ReadonlyArray<MediaDeviceInfo>,
1181
- deviceId?: string,
1182
- ): string => {
1247
+ const pickDeviceLabel = (devices: ReadonlyArray<MediaDeviceInfo>, deviceId?: string): string => {
1183
1248
  if (deviceId) {
1184
1249
  const exact = devices.find((d) => d.deviceId === deviceId);
1185
1250
  if (exact?.label) return exact.label;
@@ -1206,7 +1271,6 @@ const resolveSpeakerState = async (): Promise<CheckState> => {
1206
1271
  detail: "Grant microphone permission to list audio outputs",
1207
1272
  };
1208
1273
  }
1209
- const label =
1210
- devices.find((d) => d.label)?.label ?? "Default output";
1274
+ const label = devices.find((d) => d.label)?.label ?? "Default output";
1211
1275
  return { kind: "pass", detail: label };
1212
1276
  };