@lookit/record 4.0.0 → 5.0.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.
@@ -1,3 +1,5 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // explicit any needed for media recorder mocks
1
3
  import Data from "@lookit/data";
2
4
  import { LookitWindow } from "@lookit/data/dist/types";
3
5
  import Handlebars from "handlebars";
@@ -9,6 +11,7 @@ import play_icon from "../img/play-icon.svg";
9
11
  import record_icon from "../img/record-icon.svg";
10
12
  import {
11
13
  CreateURLError,
14
+ NoFileNameError,
12
15
  NoStopPromiseError,
13
16
  NoWebCamElementError,
14
17
  RecorderInitializeError,
@@ -16,6 +19,7 @@ import {
16
19
  StreamActiveOnResetError,
17
20
  StreamDataInitializeError,
18
21
  StreamInactiveInitializeError,
22
+ TimeoutError,
19
23
  } from "./errors";
20
24
  import Recorder from "./recorder";
21
25
  import { CSSWidthHeight } from "./types";
@@ -29,36 +33,123 @@ window.chs = {
29
33
  response: {
30
34
  id: "456",
31
35
  },
32
- } as typeof window.chs;
36
+ pendingUploads: [],
37
+ } as unknown as typeof window.chs;
33
38
 
34
39
  let originalDate: DateConstructor;
35
40
 
41
+ let consoleLogSpy: jest.SpyInstance<
42
+ void,
43
+ [message?: unknown, ...optionalParams: unknown[]],
44
+ unknown
45
+ >;
46
+ let consoleWarnSpy: jest.SpyInstance<
47
+ void,
48
+ [message?: unknown, ...optionalParams: unknown[]],
49
+ unknown
50
+ >;
51
+ let consoleErrorSpy: jest.SpyInstance<
52
+ void,
53
+ [message?: unknown, ...optionalParams: unknown[]],
54
+ unknown
55
+ >;
56
+
57
+ type MockStream = {
58
+ getTracks: jest.Mock<Array<{ stop: jest.Mock<void, []> }>, []>;
59
+ clone: () => MockStream;
60
+ readonly active: boolean;
61
+ /** Utility for tests - manually force stream to stop (inactive state). */
62
+ __forceStop: () => void;
63
+ /** Utility for tests - manually force stream to start (active state). */
64
+ __forceStart: () => void;
65
+ };
66
+
36
67
  jest.mock("@lookit/data");
37
- jest.mock("jspsych", () => ({
38
- ...jest.requireActual("jspsych"),
39
- initJsPsych: jest.fn().mockReturnValue({
40
- pluginAPI: {
41
- getCameraRecorder: jest.fn().mockReturnValue({
42
- addEventListener: jest.fn(),
43
- mimeType: "video/webm",
44
- start: jest.fn(),
45
- stop: jest.fn(),
46
- stream: {
47
- active: true,
48
- clone: jest.fn(),
49
- getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]),
50
- },
51
- }),
52
- },
53
- data: {
54
- getLastTrialData: jest.fn().mockReturnValue({
55
- values: jest
56
- .fn()
57
- .mockReturnValue([{ trial_type: "test-type", trial_index: 0 }]),
68
+ jest.mock("jspsych", () => {
69
+ /**
70
+ * Helper to create a mock stream. The mock for
71
+ * jsPsych.pluginAPI.getCameraRecorder().stream will use this so that it
72
+ * dynamically returns streams that are active/inactive based on whether or
73
+ * not they've been stopped.
74
+ *
75
+ * @returns Mocked stream
76
+ */
77
+ const createMockStream = (): MockStream => {
78
+ let stopped = false;
79
+
80
+ const stream: MockStream = {
81
+ // need to mock 'active', 'clone()', and 'getTracks()`
82
+ getTracks: jest.fn(() => {
83
+ return [
84
+ {
85
+ stop: jest.fn(() => {
86
+ stopped = true;
87
+ }),
88
+ },
89
+ ];
58
90
  }),
59
- },
60
- }),
61
- }));
91
+ clone: jest.fn(() => createMockStream()),
92
+ /**
93
+ * Getter for stream's active property
94
+ *
95
+ * @returns Boolean indicating whether or not the stream is active.
96
+ */
97
+ get active() {
98
+ return !stopped;
99
+ },
100
+ /** Utility for tests - manually force stream to stop (inactive state). */
101
+ __forceStop: () => {
102
+ stopped = true;
103
+ },
104
+ /** Utility for tests - maually force stream to start (active state). */
105
+ __forceStart: () => {
106
+ stopped = false;
107
+ },
108
+ };
109
+
110
+ return stream;
111
+ };
112
+
113
+ /**
114
+ * Persistent recorder that always gets returned
115
+ *
116
+ * @returns Mock recorder object
117
+ */
118
+ const createMockRecorder = () => {
119
+ return {
120
+ addEventListener: jest.fn(),
121
+ mimeType: "video/webm",
122
+ start: jest.fn(),
123
+ stop: jest.fn(),
124
+ stream: createMockStream(),
125
+ };
126
+ };
127
+
128
+ return {
129
+ ...jest.requireActual("jspsych"),
130
+ // factories for tests to call
131
+ __createMockRecorder: createMockRecorder,
132
+ __createMockStream: createMockStream,
133
+ initJsPsych: jest.fn().mockImplementation(() => {
134
+ const recorder = createMockRecorder();
135
+ return {
136
+ pluginAPI: {
137
+ initializeCameraRecorder: jest.fn((stream) => {
138
+ recorder.stream = stream;
139
+ }),
140
+ getCameraRecorder: jest.fn(() => recorder),
141
+ },
142
+ data: {
143
+ getLastTrialData: jest.fn().mockReturnValue({
144
+ values: jest
145
+ .fn()
146
+ .mockReturnValue([{ trial_type: "test-type", trial_index: 0 }]),
147
+ }),
148
+ },
149
+ };
150
+ }),
151
+ };
152
+ });
62
153
 
63
154
  /**
64
155
  * Remove new lines, indents (tabs or spaces), and empty HTML property values.
@@ -82,8 +173,23 @@ const cleanHTML = (html: string) => {
82
173
  );
83
174
  };
84
175
 
176
+ beforeEach(() => {
177
+ jest.useFakeTimers();
178
+ window.chs.pendingUploads = [];
179
+
180
+ // Hide the console output during tests. Tests can still assert on these spies to check console calls.
181
+ consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
182
+ consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
183
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
184
+ });
185
+
85
186
  afterEach(() => {
86
187
  jest.clearAllMocks();
188
+ jest.useRealTimers();
189
+
190
+ consoleLogSpy.mockRestore();
191
+ consoleWarnSpy.mockRestore();
192
+ consoleErrorSpy.mockRestore();
87
193
  });
88
194
 
89
195
  test("Recorder start", async () => {
@@ -99,32 +205,415 @@ test("Recorder start", async () => {
99
205
  test("Recorder stop", async () => {
100
206
  const jsPsych = initJsPsych();
101
207
  const rec = new Recorder(jsPsych);
102
- const stopPromise = Promise.resolve();
103
- const media = jsPsych.pluginAPI.getCameraRecorder();
104
-
105
- // manual mocks
208
+ const stopPromise = Promise.resolve("url");
209
+ const uploadPromise = Promise.resolve();
210
+
211
+ // capture the actual mocked recorder and stream so that we can assert on the same instance
212
+ const recorderInstance = jsPsych.pluginAPI.getCameraRecorder();
213
+ const streamInstance = recorderInstance.stream;
214
+
215
+ // spy on Recorder helper functions
216
+ const preStopCheckSpy = jest.spyOn(rec as any, "preStopCheck");
217
+ const clearWebcamFeedSpy = jest.spyOn(rec as any, "clearWebcamFeed");
218
+ const stopTracksSpy = jest.spyOn(rec as any, "stopTracks");
219
+ const resetSpy = jest.spyOn(rec as any, "reset");
220
+ const getTracksSpy = jest.spyOn(streamInstance, "getTracks");
221
+
222
+ // set the s3 completeUpload function to resolve
223
+ rec["_s3"] = { completeUpload: jest.fn() } as any;
224
+ jest.spyOn(rec["_s3"] as any, "completeUpload").mockResolvedValue(undefined);
225
+
226
+ // manual mocks to simulate having started recording
227
+ rec["filename"] = "fakename";
106
228
  rec["stopPromise"] = stopPromise;
107
229
 
108
- // check that the "stop promise" is returned on stop
109
- expect(rec.stop()).toStrictEqual(stopPromise);
230
+ expect(window.chs.pendingUploads).toStrictEqual([]);
231
+
232
+ const { stopped, uploaded } = rec.stop();
233
+ await stopped;
110
234
 
111
- await rec.stop();
235
+ // calls recorder.preStopCheck()
236
+ expect(preStopCheckSpy).toHaveBeenCalledTimes(1);
237
+ // calls recorder.clearWebcamFeed(maintain_container_size)
238
+ expect(clearWebcamFeedSpy).toHaveBeenCalledTimes(1);
239
+ // calls recorder.stopTracks(), which calls stop() and stream.getTracks()
240
+ expect(stopTracksSpy).toHaveBeenCalledTimes(1);
241
+ expect(jsPsych.pluginAPI.getCameraRecorder().stop).toHaveBeenCalledTimes(1);
242
+ expect(getTracksSpy).toHaveBeenCalledTimes(1);
243
+ // calls recorder.reset
244
+ expect(resetSpy).toHaveBeenCalledTimes(1);
112
245
 
113
- expect(media.stop).toHaveBeenCalledTimes(2);
114
- expect(media.stream.getTracks).toHaveBeenCalledTimes(2);
246
+ await uploaded;
247
+
248
+ expect(rec["s3"].completeUpload).toHaveBeenCalledTimes(1);
249
+ expect(consoleLogSpy).toHaveBeenCalledWith(
250
+ "Upload for fakename-uploaded completed.",
251
+ );
252
+
253
+ // check that the stop and upload promises are returned on stop
254
+ expect({ stopped, uploaded }).toStrictEqual({
255
+ stopped: stopPromise,
256
+ uploaded: uploadPromise,
257
+ });
258
+
259
+ // adds promise to window.chs.pendingUploads
260
+ expect(window.chs.pendingUploads.length).toBe(1);
261
+ expect(window.chs.pendingUploads[0].promise).toBeInstanceOf(Promise);
262
+ expect(window.chs.pendingUploads).toStrictEqual([
263
+ { promise: uploadPromise, file: "fakename" },
264
+ ]);
115
265
  });
116
266
 
117
- test("Recorder no stop promise", () => {
267
+ test("Recorder stop with no stop promise", () => {
118
268
  const jsPsych = initJsPsych();
119
269
  const rec = new Recorder(jsPsych);
120
270
 
121
271
  // no stop promise
122
272
  rec["stopPromise"] = undefined;
123
273
 
124
- expect(async () => await rec.stop()).rejects.toThrow(NoStopPromiseError);
274
+ // throws immediately - no need to await the returned promises
275
+ expect(rec.stop).toThrow(NoStopPromiseError);
276
+ });
277
+
278
+ test("Recorder stop and upload promises resolve", async () => {
279
+ const jsPsych = initJsPsych();
280
+ const rec = new Recorder(jsPsych);
281
+
282
+ // stop promise will resolve
283
+ rec["stopPromise"] = Promise.resolve("url");
284
+
285
+ // completeUpload will resolve
286
+ rec["_s3"] = { completeUpload: jest.fn(() => Promise.resolve()) } as any;
287
+
288
+ // manual mocks to simulate having started recording
289
+ rec["filename"] = "fakename";
290
+
291
+ const { stopped, uploaded } = rec.stop({
292
+ stop_timeout_ms: 100,
293
+ });
294
+
295
+ await jest.advanceTimersByTimeAsync(101);
296
+
297
+ await expect(stopped).resolves.toBe("url");
298
+ await expect(uploaded).resolves.toBeUndefined();
299
+ // make sure timeouts are cleared
300
+ expect(jest.getTimerCount()).toEqual(0);
301
+ expect(consoleLogSpy).toHaveBeenCalledWith(
302
+ "Upload for fakename-uploaded completed.",
303
+ );
304
+ });
305
+
306
+ test("Recorder stop promise times out", async () => {
307
+ const jsPsych = initJsPsych();
308
+ const rec = new Recorder(jsPsych);
309
+
310
+ // create a stop promise that never resolves
311
+ rec["stopPromise"] = new Promise<string>(() => {});
312
+
313
+ // manual mocks to simulate having started recording
314
+ rec["_s3"] = {
315
+ completeUpload: jest.fn().mockResolvedValue(undefined),
316
+ } as any;
317
+ rec["filename"] = "fakename";
318
+
319
+ const { stopped, uploaded } = rec.stop({
320
+ stop_timeout_ms: 100,
321
+ });
322
+
323
+ const stoppedObserved = stopped.catch((e) => e);
324
+ const uploadedObserved = uploaded.catch((e) => e);
325
+
326
+ await jest.advanceTimersByTimeAsync(101);
327
+
328
+ await expect(stoppedObserved).resolves.toBe("timeout");
329
+ await expect(uploadedObserved).resolves.toThrow(TimeoutError);
330
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
331
+ "Recorder stop timed out: fakename",
332
+ );
333
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
334
+ "Upload failed because recorder stop timed out",
335
+ );
336
+ const settled = await Promise.race([
337
+ Promise.allSettled(window.chs.pendingUploads.map((u) => u.promise)),
338
+ Promise.resolve("still-pending"),
339
+ ]);
340
+ // The uploaded promise race is settled, but the upload promise in window.chs.pendingUploads should NOT be resolved - it should still be pending
341
+ expect(settled).toBe("still-pending");
342
+ });
343
+
344
+ test("Recorder upload timeout with default duration", async () => {
345
+ const jsPsych = initJsPsych();
346
+ const rec = new Recorder(jsPsych);
347
+
348
+ // stop promise will resolve
349
+ rec["stopPromise"] = Promise.resolve("url");
350
+ rec["filename"] = "fakename";
351
+
352
+ // completeUpload never resolves - upload promise will timeout
353
+ const never = new Promise<void>(() => {});
354
+ rec["_s3"] = { completeUpload: jest.fn(() => never) } as any;
355
+
356
+ // default upload_timeout_ms is 10000
357
+ const { stopped, uploaded } = rec.stop();
358
+
359
+ // stop promise should resolve with the url
360
+ const url = await stopped;
361
+ expect(url).toBe("url");
362
+
363
+ // advance time by 10000 ms to trigger timeout
364
+ await jest.advanceTimersByTimeAsync(10000);
365
+
366
+ await expect(uploaded).resolves.toBe("timeout");
367
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
368
+ "Recorder upload timed out: fakename",
369
+ );
370
+
371
+ // Assert background upload is still pending
372
+ expect(window.chs.pendingUploads).toHaveLength(1);
373
+ let settled = false;
374
+ window.chs.pendingUploads[0].promise.finally(() => {
375
+ settled = true;
376
+ });
377
+ await Promise.resolve(); // flush microtasks
378
+ expect(settled).toBe(false);
379
+ });
380
+
381
+ test("Recorder upload promise times out with duration parameter", async () => {
382
+ const jsPsych = initJsPsych();
383
+ const rec = new Recorder(jsPsych);
384
+
385
+ // stop promise will resolve
386
+ rec["stopPromise"] = Promise.resolve("url");
387
+ rec["filename"] = "fakename";
388
+
389
+ // completeUpload never resolves - upload promise will timeout
390
+ const never = new Promise<void>(() => {});
391
+ rec["_s3"] = { completeUpload: jest.fn(() => never) } as any;
392
+
393
+ const { stopped, uploaded } = rec.stop({
394
+ upload_timeout_ms: 100,
395
+ });
396
+
397
+ // stop promise should resolve with the url
398
+ await expect(stopped).resolves.toBe("url");
399
+
400
+ // advance fake timers so that the timeout triggers
401
+ await jest.advanceTimersByTimeAsync(100);
402
+ await expect(uploaded).resolves.toBe("timeout");
403
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
404
+ "Recorder upload timed out: fakename",
405
+ );
406
+
407
+ // Assert background upload is still pending
408
+ expect(window.chs.pendingUploads).toHaveLength(1);
409
+ let settled = false;
410
+ window.chs.pendingUploads[0].promise.finally(() => {
411
+ settled = true;
412
+ });
413
+ await Promise.resolve(); // flush microtasks
414
+ expect(settled).toBe(false);
415
+ });
416
+
417
+ test("Recorder upload promise with no timeout", async () => {
418
+ const jsPsych = initJsPsych();
419
+ const rec = new Recorder(jsPsych);
420
+
421
+ // stop promise will resolve
422
+ rec["stopPromise"] = Promise.resolve("url");
423
+ rec["filename"] = "fakename";
424
+
425
+ // completeUpload never resolves
426
+ const never = new Promise<void>(() => {});
427
+ rec["_s3"] = { completeUpload: jest.fn(() => never) } as any;
428
+
429
+ const { stopped, uploaded } = rec.stop({
430
+ upload_timeout_ms: null,
431
+ });
432
+
433
+ // stop promise should resolve with the url
434
+ await expect(stopped).resolves.toBe("url");
435
+
436
+ // advance fake timers to make sure that the default timeout does not trigger resolution
437
+ await jest.advanceTimersByTimeAsync(10000);
438
+ expect(uploaded).toStrictEqual(never);
439
+
440
+ // Assert background upload is original upload promise
441
+ expect(window.chs.pendingUploads).toHaveLength(1);
442
+ expect(window.chs.pendingUploads[0].promise).toStrictEqual(uploaded);
443
+ // Promise should still be pending
444
+ let settled = false;
445
+ window.chs.pendingUploads[0].promise.finally(() => {
446
+ settled = true;
447
+ });
448
+ await Promise.resolve(); // flush microtasks
449
+ expect(settled).toBe(false);
450
+ });
451
+
452
+ test("Recorder stop with local download", async () => {
453
+ const jsPsych = initJsPsych();
454
+ const rec = new Recorder(jsPsych);
455
+ const stopPromise = Promise.resolve("url");
456
+ const uploadPromise = Promise.resolve();
457
+
458
+ // Download the file locally
459
+ rec["localDownload"] = true;
460
+
461
+ // manual mocks to simulate having started recording
462
+ // s3 is not defined when localDownload is true
463
+ rec["filename"] = "fakename";
464
+ rec["stopPromise"] = stopPromise;
465
+ const download = jest.fn();
466
+ rec["download"] = download;
467
+
468
+ expect(window.chs.pendingUploads).toStrictEqual([]);
469
+
470
+ const { stopped, uploaded } = rec.stop();
471
+
472
+ await uploaded;
473
+
474
+ // upload promise should call download
475
+ expect(download).toHaveBeenCalledTimes(1);
476
+ expect(download).toHaveBeenCalledWith(rec["filename"], "url");
477
+ expect(consoleLogSpy).toHaveBeenCalledWith(
478
+ "Upload for fakename-uploaded completed.",
479
+ );
480
+
481
+ // check that the stop and upload promises are returned from recorder.stop
482
+ expect({ stopped, uploaded }).toStrictEqual({
483
+ stopped: stopPromise,
484
+ uploaded: uploadPromise,
485
+ });
486
+
487
+ // adds promise to window.chs.pendingUploads
488
+ expect(window.chs.pendingUploads.length).toBe(1);
489
+ expect(window.chs.pendingUploads[0].promise).toBeInstanceOf(Promise);
490
+ });
491
+
492
+ test("Recorder stop with no filename", () => {
493
+ const jsPsych = initJsPsych();
494
+ const rec = new Recorder(jsPsych);
495
+ const stopPromise = Promise.resolve("url");
496
+
497
+ // manual mocks to simulate having started recording
498
+ rec["stopPromise"] = stopPromise;
499
+ rec["_s3"] = new Data.LookitS3("some key");
500
+
501
+ // no filename
502
+ rec["filename"] = undefined;
503
+
504
+ expect(rec.stop).toThrow(NoFileNameError);
125
505
  });
126
506
 
127
- test("Recorder initialize error", () => {
507
+ test("Recorder stop throws with inactive stream", () => {
508
+ const jsPsych = initJsPsych();
509
+ const rec = new Recorder(jsPsych);
510
+ const stopPromise = Promise.resolve("url");
511
+
512
+ // manual mocks to simulate having started recording
513
+ rec["stopPromise"] = stopPromise;
514
+ rec["_s3"] = new Data.LookitS3("some key");
515
+ rec["filename"] = "filename";
516
+
517
+ // de-activate stream
518
+ (
519
+ jsPsych.pluginAPI.getCameraRecorder().stream as unknown as MockStream
520
+ ).__forceStop();
521
+
522
+ expect(rec.stop).toThrow(StreamInactiveInitializeError);
523
+ });
524
+
525
+ test("Recorder stop catches error in local download", async () => {
526
+ const jsPsych = initJsPsych();
527
+ const rec = new Recorder(jsPsych);
528
+ const stopPromise = Promise.resolve("url");
529
+
530
+ // Download the file locally
531
+ rec["localDownload"] = true;
532
+
533
+ // manual mocks to simulate having started recording
534
+ // s3 is not defined when localDownload is true
535
+ rec["filename"] = "fakename";
536
+ rec["stopPromise"] = stopPromise;
537
+ const download = jest.fn().mockImplementation(() => {
538
+ throw new Error("Something went wrong.");
539
+ });
540
+ rec["download"] = download;
541
+
542
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
543
+ const { stopped, uploaded } = rec.stop();
544
+
545
+ await expect(uploaded).rejects.toThrow("Something went wrong.");
546
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
547
+ "Local download failed: ",
548
+ Error("Something went wrong."),
549
+ );
550
+ });
551
+
552
+ test("Recorder stop catches error in upload", async () => {
553
+ const jsPsych = initJsPsych();
554
+ const rec = new Recorder(jsPsych);
555
+ const stopPromise = Promise.resolve("url");
556
+
557
+ // set the s3 completeUpload function to resolve
558
+ rec["_s3"] = { completeUpload: jest.fn() } as any;
559
+ jest.spyOn(rec["_s3"] as any, "completeUpload").mockImplementation(() => {
560
+ throw new Error("Something broke.");
561
+ });
562
+
563
+ // manual mocks to simulate having started recording
564
+ rec["filename"] = "fakename";
565
+ rec["stopPromise"] = stopPromise;
566
+
567
+ expect(window.chs.pendingUploads).toStrictEqual([]);
568
+
569
+ const { stopped, uploaded } = rec.stop();
570
+
571
+ await stopped;
572
+
573
+ await expect(uploaded).rejects.toThrow("Something broke.");
574
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
575
+ "Upload failed: ",
576
+ Error("Something broke."),
577
+ );
578
+ expect(window.chs.pendingUploads.length).toBe(1);
579
+ expect(window.chs.pendingUploads[0].promise).toBeInstanceOf(Promise);
580
+ await expect(
581
+ Promise.allSettled(window.chs.pendingUploads.map((u) => u.promise)),
582
+ ).resolves.toStrictEqual([
583
+ {
584
+ status: "rejected",
585
+ reason: new Error("Something broke."),
586
+ },
587
+ ]);
588
+ });
589
+
590
+ test("Recorder stop tries to reset after stopping and handles error", async () => {
591
+ const jsPsych = initJsPsych();
592
+ const rec = new Recorder(jsPsych);
593
+ const stopPromise = Promise.resolve("url");
594
+
595
+ // manual mocks to simulate having started recording
596
+ rec["filename"] = "fakename";
597
+ rec["stopPromise"] = stopPromise;
598
+ rec["_s3"] = new Data.LookitS3("some key");
599
+
600
+ const reset = jest.fn().mockImplementation(() => {
601
+ throw new Error("Reset failed.");
602
+ });
603
+ rec["reset"] = reset;
604
+
605
+ const { stopped } = rec.stop();
606
+
607
+ await stopped;
608
+
609
+ expect(reset).toHaveBeenCalledTimes(1);
610
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
611
+ "Error while resetting recorder after stop: ",
612
+ Error("Reset failed."),
613
+ );
614
+ });
615
+
616
+ test("Recorder initialize error throws from recorder start", () => {
128
617
  const jsPsych = initJsPsych();
129
618
  const rec = new Recorder(jsPsych);
130
619
  const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder;
@@ -142,37 +631,91 @@ test("Recorder initialize error", () => {
142
631
  jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder;
143
632
  });
144
633
 
145
- test("Recorder handleStop", async () => {
146
- const rec = new Recorder(initJsPsych());
147
- const download = jest.fn();
148
- const resolve = jest.fn();
149
- const handleStop = rec["handleStop"](resolve);
634
+ test("Recorder initialize error throws from recorder stop", () => {
635
+ const jsPsych = initJsPsych();
636
+ const rec = new Recorder(jsPsych);
637
+ const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder;
150
638
 
151
- // manual mock
152
- rec["download"] = download;
153
- rec["blobs"] = ["some recorded data" as unknown as Blob];
154
- URL.createObjectURL = jest.fn();
639
+ // fy other requirements for recorder.stop
640
+ rec["stopPromise"] = new Promise<string>(() => {});
641
+ rec["_s3"] = new Data.LookitS3("some key");
642
+ rec["filename"] = "fakename";
155
643
 
156
- // let's download the file locally
157
- rec["localDownload"] = true;
644
+ // no recorder
645
+ jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(undefined);
646
+ jsPsych.pluginAPI.getMicrophoneRecorder = jest
647
+ .fn()
648
+ .mockReturnValue(undefined);
649
+
650
+ expect(rec.stop).toThrow(RecorderInitializeError);
158
651
 
159
- await handleStop();
652
+ jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder;
653
+ expect(window.chs.pendingUploads.length).toBe(0);
654
+ });
655
+
656
+ test("S3 undefined error throws from recorder stop", () => {
657
+ const jsPsych = initJsPsych();
658
+ const rec = new Recorder(jsPsych);
659
+
660
+ // satisfy other requirements for recorder.stop
661
+ rec["stopPromise"] = new Promise<string>(() => {});
662
+ rec["filename"] = "fakename";
663
+
664
+ // no s3
665
+ rec["_s3"] = undefined;
160
666
 
161
- // Upload the file to s3
162
667
  rec["localDownload"] = false;
163
- rec["_s3"] = new Data.LookitS3("some key");
164
668
 
165
- await handleStop();
669
+ expect(rec.stop).toThrow(S3UndefinedError);
670
+ expect(window.chs.pendingUploads.length).toBe(0);
671
+ });
166
672
 
167
- expect(download).toHaveBeenCalledTimes(1);
168
- expect(Data.LookitS3.prototype.completeUpload).toHaveBeenCalledTimes(1);
673
+ test("S3 undefined error does not throw from recorder stop with local download", () => {
674
+ const jsPsych = initJsPsych();
675
+ const rec = new Recorder(jsPsych);
676
+
677
+ // satisty other requirements for recorder.stop and s3 upload
678
+ rec["stopPromise"] = new Promise<string>(() => {});
679
+ rec["filename"] = "fakename";
680
+
681
+ // no s3
682
+ rec["_s3"] = undefined;
683
+
684
+ rec["localDownload"] = true;
685
+
686
+ expect(rec.stop).not.toThrow(S3UndefinedError);
169
687
  });
170
688
 
171
- test("Recorder handleStop error with no url", () => {
689
+ test("Recorder handleStop", () => {
690
+ // Mock createObjectURL to return a specific value
691
+ const originalCreateObjectURL = global.URL.createObjectURL;
692
+ global.URL.createObjectURL = jest.fn().mockReturnValue("mock-url");
693
+
694
+ const jsPsych = initJsPsych();
695
+ const rec = new Recorder(jsPsych);
696
+
697
+ // stop promise resolve function that is passed into handleStop
698
+ const resolve = jest.fn();
699
+
700
+ // Manual mock
701
+ rec["blobs"] = ["some recorded data" as unknown as Blob];
702
+
703
+ const handleStop = rec["handleStop"](resolve);
704
+
705
+ handleStop();
706
+
707
+ expect(resolve).toHaveBeenCalledWith("mock-url");
708
+ expect(rec["url"]).toBe("mock-url");
709
+
710
+ // Restore the original createObjectURL function
711
+ global.URL.createObjectURL = originalCreateObjectURL;
712
+ });
713
+
714
+ test("Recorder handleStop error with no blob data", () => {
172
715
  const rec = new Recorder(initJsPsych());
173
716
  const resolve = jest.fn();
174
717
  const handleStop = rec["handleStop"](resolve);
175
- expect(async () => await handleStop()).rejects.toThrow(CreateURLError);
718
+ expect(handleStop).toThrow(CreateURLError);
176
719
  });
177
720
 
178
721
  test("Recorder handleDataAvailable", () => {
@@ -262,20 +805,25 @@ test("Webcam feed is removed when stream access stops", async () => {
262
805
 
263
806
  const jsPsych = initJsPsych();
264
807
  const rec = new Recorder(jsPsych);
265
- const stopPromise = Promise.resolve();
808
+ const stopPromise = Promise.resolve("url");
266
809
 
810
+ // manual mocks
811
+ rec["_s3"] = new Data.LookitS3("some key");
812
+ rec["filename"] = "fakename";
267
813
  rec["stopPromise"] = stopPromise;
814
+
268
815
  rec.insertWebcamFeed(webcam_div);
269
816
  expect(document.body.innerHTML).toContain("<video");
270
817
 
271
- await rec.stop();
818
+ const { stopped } = rec.stop();
819
+ await stopped;
272
820
  expect(document.body.innerHTML).not.toContain("<video");
273
821
 
274
822
  // Reset the document body.
275
823
  document.body.innerHTML = "";
276
824
  });
277
825
 
278
- test("Webcam feed container maintains size with recorder.stop(true)", async () => {
826
+ test("Webcam feed container maintains size with maintain_container_size: true passed to recorder.stop", async () => {
279
827
  // Add webcam container to document body.
280
828
  const webcam_container_id = "webcam-container";
281
829
  document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
@@ -285,9 +833,13 @@ test("Webcam feed container maintains size with recorder.stop(true)", async () =
285
833
 
286
834
  const jsPsych = initJsPsych();
287
835
  const rec = new Recorder(jsPsych);
288
- const stopPromise = Promise.resolve();
836
+ const stopPromise = Promise.resolve("url");
289
837
 
838
+ // manual mocks
839
+ rec["_s3"] = new Data.LookitS3("some key");
840
+ rec["filename"] = "fakename";
290
841
  rec["stopPromise"] = stopPromise;
842
+
291
843
  rec.insertWebcamFeed(webcam_div);
292
844
 
293
845
  // Mock the return values for the video element's offsetHeight/offsetWidth, which are used to set the container size
@@ -298,7 +850,8 @@ test("Webcam feed container maintains size with recorder.stop(true)", async () =
298
850
  .spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
299
851
  .mockImplementation(() => 300);
300
852
 
301
- await rec.stop(true);
853
+ const { stopped } = rec.stop({ maintain_container_size: true });
854
+ await stopped;
302
855
 
303
856
  // Container div's dimensions should match the video element dimensions
304
857
  expect(
@@ -325,9 +878,13 @@ test("Webcam feed container size is not maintained with recorder.stop(false)", a
325
878
 
326
879
  const jsPsych = initJsPsych();
327
880
  const rec = new Recorder(jsPsych);
328
- const stopPromise = Promise.resolve();
881
+ const stopPromise = Promise.resolve("url");
329
882
 
883
+ // manual mocks
884
+ rec["_s3"] = new Data.LookitS3("some key");
885
+ rec["filename"] = "fakename";
330
886
  rec["stopPromise"] = stopPromise;
887
+
331
888
  rec.insertWebcamFeed(webcam_div);
332
889
 
333
890
  // Mock the return values for the video element offsetHeight/offsetWidth, which are used to set the container size
@@ -338,7 +895,8 @@ test("Webcam feed container size is not maintained with recorder.stop(false)", a
338
895
  .spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
339
896
  .mockImplementation(() => 300);
340
897
 
341
- await rec.stop(false);
898
+ const { stopped } = rec.stop();
899
+ await stopped;
342
900
 
343
901
  // Container div's dimensions should not be set
344
902
  expect(
@@ -355,7 +913,7 @@ test("Webcam feed container size is not maintained with recorder.stop(false)", a
355
913
  jest.restoreAllMocks();
356
914
  });
357
915
 
358
- test("Webcam feed container size is not maintained with recorder.stop()", async () => {
916
+ test("Webcam feed container size is not maintained with recorder.stop()", () => {
359
917
  // Add webcam container to document body.
360
918
  const webcam_container_id = "webcam-container";
361
919
  document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
@@ -365,9 +923,13 @@ test("Webcam feed container size is not maintained with recorder.stop()", async
365
923
 
366
924
  const jsPsych = initJsPsych();
367
925
  const rec = new Recorder(jsPsych);
368
- const stopPromise = Promise.resolve();
926
+ const stopPromise = Promise.resolve("url");
369
927
 
928
+ // manual mocks
929
+ rec["_s3"] = new Data.LookitS3("some key");
930
+ rec["filename"] = "fakename";
370
931
  rec["stopPromise"] = stopPromise;
932
+
371
933
  rec.insertWebcamFeed(webcam_div);
372
934
 
373
935
  // Mock the return values for the video element offsetHeight/offsetWidth, which are used to set the container size
@@ -378,7 +940,7 @@ test("Webcam feed container size is not maintained with recorder.stop()", async
378
940
  .spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
379
941
  .mockImplementation(() => 300);
380
942
 
381
- await rec.stop();
943
+ rec.stop();
382
944
 
383
945
  // Container div's dimensions should not be set
384
946
  expect(
@@ -433,12 +995,10 @@ test("Recorder initializeRecorder", () => {
433
995
 
434
996
  test("Recorder download", () => {
435
997
  const rec = new Recorder(initJsPsych());
436
- rec["url"] = "some url";
437
- rec["filename"] = "some filename";
438
998
  const download = rec["download"];
439
999
  const click = jest.spyOn(HTMLAnchorElement.prototype, "click");
440
1000
 
441
- download();
1001
+ download("some filename", "some url");
442
1002
 
443
1003
  expect(click).toHaveBeenCalledTimes(1);
444
1004
  });
@@ -453,7 +1013,7 @@ test("Recorder s3 get error when undefined", () => {
453
1013
  test("Recorder reset error when stream active", () => {
454
1014
  const jsPsych = initJsPsych();
455
1015
  const rec = new Recorder(jsPsych);
456
- expect(() => rec.reset()).toThrow(StreamActiveOnResetError);
1016
+ expect(rec.reset).toThrow(StreamActiveOnResetError);
457
1017
  });
458
1018
 
459
1019
  test("Recorder reset", () => {
@@ -548,7 +1108,7 @@ test("Record initialize error inactive stream", () => {
548
1108
  .fn()
549
1109
  .mockReturnValue({ stream: { active: false } });
550
1110
 
551
- expect(() => initializeCheck()).toThrow(StreamInactiveInitializeError);
1111
+ expect(initializeCheck).toThrow(StreamInactiveInitializeError);
552
1112
 
553
1113
  jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder;
554
1114
  });
@@ -560,7 +1120,7 @@ test("Record initialize error inactive stream", () => {
560
1120
 
561
1121
  rec["blobs"] = ["some stream data" as unknown as Blob];
562
1122
 
563
- expect(() => initializeCheck()).toThrow(StreamDataInitializeError);
1123
+ expect(initializeCheck).toThrow(StreamDataInitializeError);
564
1124
  });
565
1125
 
566
1126
  test("Recorder insert record Feed with height/width", () => {
@@ -576,7 +1136,7 @@ test("Recorder insert record Feed with height/width", () => {
576
1136
  };
577
1137
 
578
1138
  jest.spyOn(Handlebars, "compile");
579
- jest.spyOn(rec, "insertVideoFeed");
1139
+ jest.spyOn(rec as any, "insertVideoFeed");
580
1140
 
581
1141
  rec.insertRecordFeed(display);
582
1142
 
@@ -713,3 +1273,47 @@ test("New recorder uses a default mime type if none is set already", () => {
713
1273
 
714
1274
  jsPsych.pluginAPI.initializeCameraRecorder = originalInitializeCameraRecorder;
715
1275
  });
1276
+
1277
+ test("Recorder generates a timeout handler function with the event that is being awaited", () => {
1278
+ const jsPsych = initJsPsych();
1279
+ const rec = new Recorder(jsPsych);
1280
+
1281
+ const timeout_fn = rec["createTimeoutHandler"]("long process", "fakename");
1282
+
1283
+ expect(timeout_fn).toBeInstanceOf(Function);
1284
+
1285
+ timeout_fn();
1286
+
1287
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
1288
+ "Recorder long process timed out: fakename",
1289
+ );
1290
+ });
1291
+
1292
+ test("Recorder createTimeoutHandler catches error when trying to reset recorder after timeout", () => {
1293
+ const jsPsych = initJsPsych();
1294
+ const rec = new Recorder(jsPsych);
1295
+
1296
+ // mock error thrown from Recorder.reset
1297
+ jest.spyOn(rec, "reset").mockImplementation(() => {
1298
+ throw new Error("Could not reset.");
1299
+ });
1300
+
1301
+ // de-activate stream
1302
+ (
1303
+ jsPsych.pluginAPI.getCameraRecorder().stream as unknown as MockStream
1304
+ ).__forceStop();
1305
+
1306
+ const timeout_fn = rec["createTimeoutHandler"]("upload", "fakename");
1307
+
1308
+ expect(timeout_fn).toBeInstanceOf(Function);
1309
+
1310
+ timeout_fn();
1311
+
1312
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
1313
+ "Recorder upload timed out: fakename",
1314
+ );
1315
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1316
+ "Error while resetting recorder after timeout: ",
1317
+ new Error("Could not reset."),
1318
+ );
1319
+ });