@lookit/record 4.1.0 → 6.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,7 +1,9 @@
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";
4
- import { initJsPsych, JsPsych } from "jspsych";
6
+ import { initJsPsych } from "jspsych";
5
7
  import playbackFeed from "../hbs/playback-feed.hbs";
6
8
  import recordFeed from "../hbs/record-feed.hbs";
7
9
  import webcamFeed from "../hbs/webcam-feed.hbs";
@@ -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);
505
+ });
506
+
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
+ ]);
125
588
  });
126
589
 
127
- test("Recorder initialize error", () => {
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,81 +631,91 @@ test("Recorder initialize error", () => {
142
631
  jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder;
143
632
  });
144
633
 
145
- test("Recorder handleStop", async () => {
146
- // Define a custom mockStream so that we can dynamically modify active value
147
- const mockStream = {
148
- active: true,
149
- clone: jest.fn(),
150
- getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]),
151
- };
152
- mockStream.clone = jest.fn().mockReturnValue(mockStream);
153
- // We need a mock MediaRecorder object that uses the custom mockStream
154
- const mockRecorder = {
155
- addEventListener: jest.fn(),
156
- mimeType: "video/webm",
157
- start: jest.fn(),
158
- stop: jest.fn(),
159
- stream: mockStream,
160
- };
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;
161
638
 
162
- // Minimal fake jsPsych object to pass to the Recorder constructor
163
- const jsPsych = {
164
- pluginAPI: {
165
- getCameraRecorder: jest.fn().mockReturnValue(mockRecorder),
166
- initializeCameraRecorder: jest.fn().mockReturnValue(mockRecorder),
167
- },
168
- data: {
169
- getLastTrialData: jest.fn().mockReturnValue({
170
- values: jest.fn().mockReturnValue([]),
171
- }),
172
- },
173
- } as unknown as JsPsych;
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";
643
+
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);
174
651
 
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();
175
658
  const rec = new Recorder(jsPsych);
176
659
 
177
- const download = jest.fn();
178
- const resolve = jest.fn();
179
- // This allows us to keep strict typing and avoid use of 'any'
180
- const resetSpy = jest.spyOn(rec, "reset" satisfies keyof typeof rec);
660
+ // satisfy other requirements for recorder.stop
661
+ rec["stopPromise"] = new Promise<string>(() => {});
662
+ rec["filename"] = "fakename";
181
663
 
182
- // Manual mock
183
- rec["download"] = download;
184
- rec["blobs"] = ["some recorded data" as unknown as Blob];
185
- URL.createObjectURL = jest.fn();
664
+ // no s3
665
+ rec["_s3"] = undefined;
186
666
 
187
- // Download the file locally
188
- rec["localDownload"] = true;
667
+ rec["localDownload"] = false;
189
668
 
190
- // Stream cannot be active when handleStop/reset is called
191
- mockStream.active = false;
669
+ expect(rec.stop).toThrow(S3UndefinedError);
670
+ expect(window.chs.pendingUploads.length).toBe(0);
671
+ });
192
672
 
193
- const handleStop = rec["handleStop"](resolve);
673
+ test("S3 undefined error does not throw from recorder stop with local download", () => {
674
+ const jsPsych = initJsPsych();
675
+ const rec = new Recorder(jsPsych);
194
676
 
195
- await handleStop();
677
+ // satisty other requirements for recorder.stop and s3 upload
678
+ rec["stopPromise"] = new Promise<string>(() => {});
679
+ rec["filename"] = "fakename";
196
680
 
197
- expect(download).toHaveBeenCalledTimes(1);
198
- expect(resetSpy).toHaveBeenCalledTimes(1);
681
+ // no s3
682
+ rec["_s3"] = undefined;
199
683
 
200
- // Upload the file to s3
201
- rec["localDownload"] = false;
202
- rec["_s3"] = new Data.LookitS3("some key");
684
+ rec["localDownload"] = true;
203
685
 
204
- // The first 'handleStop' resets the recorder, which resets blobs to [], so we need to fake the blob data again.
686
+ expect(rec.stop).not.toThrow(S3UndefinedError);
687
+ });
688
+
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
205
701
  rec["blobs"] = ["some recorded data" as unknown as Blob];
206
702
 
207
- await handleStop();
703
+ const handleStop = rec["handleStop"](resolve);
704
+
705
+ handleStop();
208
706
 
209
- expect(Data.LookitS3.prototype.completeUpload).toHaveBeenCalledTimes(1);
210
- expect(resetSpy).toHaveBeenCalledTimes(2);
707
+ expect(resolve).toHaveBeenCalledWith("mock-url");
708
+ expect(rec["url"]).toBe("mock-url");
211
709
 
212
- resetSpy.mockRestore();
710
+ // Restore the original createObjectURL function
711
+ global.URL.createObjectURL = originalCreateObjectURL;
213
712
  });
214
713
 
215
- test("Recorder handleStop error with no url", () => {
714
+ test("Recorder handleStop error with no blob data", () => {
216
715
  const rec = new Recorder(initJsPsych());
217
716
  const resolve = jest.fn();
218
717
  const handleStop = rec["handleStop"](resolve);
219
- expect(async () => await handleStop()).rejects.toThrow(CreateURLError);
718
+ expect(handleStop).toThrow(CreateURLError);
220
719
  });
221
720
 
222
721
  test("Recorder handleDataAvailable", () => {
@@ -306,20 +805,25 @@ test("Webcam feed is removed when stream access stops", async () => {
306
805
 
307
806
  const jsPsych = initJsPsych();
308
807
  const rec = new Recorder(jsPsych);
309
- const stopPromise = Promise.resolve();
808
+ const stopPromise = Promise.resolve("url");
310
809
 
810
+ // manual mocks
811
+ rec["_s3"] = new Data.LookitS3("some key");
812
+ rec["filename"] = "fakename";
311
813
  rec["stopPromise"] = stopPromise;
814
+
312
815
  rec.insertWebcamFeed(webcam_div);
313
816
  expect(document.body.innerHTML).toContain("<video");
314
817
 
315
- await rec.stop();
818
+ const { stopped } = rec.stop();
819
+ await stopped;
316
820
  expect(document.body.innerHTML).not.toContain("<video");
317
821
 
318
822
  // Reset the document body.
319
823
  document.body.innerHTML = "";
320
824
  });
321
825
 
322
- 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 () => {
323
827
  // Add webcam container to document body.
324
828
  const webcam_container_id = "webcam-container";
325
829
  document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
@@ -329,9 +833,13 @@ test("Webcam feed container maintains size with recorder.stop(true)", async () =
329
833
 
330
834
  const jsPsych = initJsPsych();
331
835
  const rec = new Recorder(jsPsych);
332
- const stopPromise = Promise.resolve();
836
+ const stopPromise = Promise.resolve("url");
333
837
 
838
+ // manual mocks
839
+ rec["_s3"] = new Data.LookitS3("some key");
840
+ rec["filename"] = "fakename";
334
841
  rec["stopPromise"] = stopPromise;
842
+
335
843
  rec.insertWebcamFeed(webcam_div);
336
844
 
337
845
  // Mock the return values for the video element's offsetHeight/offsetWidth, which are used to set the container size
@@ -342,7 +850,8 @@ test("Webcam feed container maintains size with recorder.stop(true)", async () =
342
850
  .spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
343
851
  .mockImplementation(() => 300);
344
852
 
345
- await rec.stop(true);
853
+ const { stopped } = rec.stop({ maintain_container_size: true });
854
+ await stopped;
346
855
 
347
856
  // Container div's dimensions should match the video element dimensions
348
857
  expect(
@@ -369,9 +878,13 @@ test("Webcam feed container size is not maintained with recorder.stop(false)", a
369
878
 
370
879
  const jsPsych = initJsPsych();
371
880
  const rec = new Recorder(jsPsych);
372
- const stopPromise = Promise.resolve();
881
+ const stopPromise = Promise.resolve("url");
373
882
 
883
+ // manual mocks
884
+ rec["_s3"] = new Data.LookitS3("some key");
885
+ rec["filename"] = "fakename";
374
886
  rec["stopPromise"] = stopPromise;
887
+
375
888
  rec.insertWebcamFeed(webcam_div);
376
889
 
377
890
  // Mock the return values for the video element offsetHeight/offsetWidth, which are used to set the container size
@@ -382,7 +895,8 @@ test("Webcam feed container size is not maintained with recorder.stop(false)", a
382
895
  .spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
383
896
  .mockImplementation(() => 300);
384
897
 
385
- await rec.stop(false);
898
+ const { stopped } = rec.stop();
899
+ await stopped;
386
900
 
387
901
  // Container div's dimensions should not be set
388
902
  expect(
@@ -399,7 +913,7 @@ test("Webcam feed container size is not maintained with recorder.stop(false)", a
399
913
  jest.restoreAllMocks();
400
914
  });
401
915
 
402
- test("Webcam feed container size is not maintained with recorder.stop()", async () => {
916
+ test("Webcam feed container size is not maintained with recorder.stop()", () => {
403
917
  // Add webcam container to document body.
404
918
  const webcam_container_id = "webcam-container";
405
919
  document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
@@ -409,9 +923,13 @@ test("Webcam feed container size is not maintained with recorder.stop()", async
409
923
 
410
924
  const jsPsych = initJsPsych();
411
925
  const rec = new Recorder(jsPsych);
412
- const stopPromise = Promise.resolve();
926
+ const stopPromise = Promise.resolve("url");
413
927
 
928
+ // manual mocks
929
+ rec["_s3"] = new Data.LookitS3("some key");
930
+ rec["filename"] = "fakename";
414
931
  rec["stopPromise"] = stopPromise;
932
+
415
933
  rec.insertWebcamFeed(webcam_div);
416
934
 
417
935
  // Mock the return values for the video element offsetHeight/offsetWidth, which are used to set the container size
@@ -422,7 +940,7 @@ test("Webcam feed container size is not maintained with recorder.stop()", async
422
940
  .spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
423
941
  .mockImplementation(() => 300);
424
942
 
425
- await rec.stop();
943
+ rec.stop();
426
944
 
427
945
  // Container div's dimensions should not be set
428
946
  expect(
@@ -477,12 +995,10 @@ test("Recorder initializeRecorder", () => {
477
995
 
478
996
  test("Recorder download", () => {
479
997
  const rec = new Recorder(initJsPsych());
480
- rec["url"] = "some url";
481
- rec["filename"] = "some filename";
482
998
  const download = rec["download"];
483
999
  const click = jest.spyOn(HTMLAnchorElement.prototype, "click");
484
1000
 
485
- download();
1001
+ download("some filename", "some url");
486
1002
 
487
1003
  expect(click).toHaveBeenCalledTimes(1);
488
1004
  });
@@ -497,7 +1013,7 @@ test("Recorder s3 get error when undefined", () => {
497
1013
  test("Recorder reset error when stream active", () => {
498
1014
  const jsPsych = initJsPsych();
499
1015
  const rec = new Recorder(jsPsych);
500
- expect(() => rec.reset()).toThrow(StreamActiveOnResetError);
1016
+ expect(rec.reset).toThrow(StreamActiveOnResetError);
501
1017
  });
502
1018
 
503
1019
  test("Recorder reset", () => {
@@ -592,7 +1108,7 @@ test("Record initialize error inactive stream", () => {
592
1108
  .fn()
593
1109
  .mockReturnValue({ stream: { active: false } });
594
1110
 
595
- expect(() => initializeCheck()).toThrow(StreamInactiveInitializeError);
1111
+ expect(initializeCheck).toThrow(StreamInactiveInitializeError);
596
1112
 
597
1113
  jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder;
598
1114
  });
@@ -604,7 +1120,7 @@ test("Record initialize error inactive stream", () => {
604
1120
 
605
1121
  rec["blobs"] = ["some stream data" as unknown as Blob];
606
1122
 
607
- expect(() => initializeCheck()).toThrow(StreamDataInitializeError);
1123
+ expect(initializeCheck).toThrow(StreamDataInitializeError);
608
1124
  });
609
1125
 
610
1126
  test("Recorder insert record Feed with height/width", () => {
@@ -620,7 +1136,7 @@ test("Recorder insert record Feed with height/width", () => {
620
1136
  };
621
1137
 
622
1138
  jest.spyOn(Handlebars, "compile");
623
- jest.spyOn(rec, "insertVideoFeed");
1139
+ jest.spyOn(rec as any, "insertVideoFeed");
624
1140
 
625
1141
  rec.insertRecordFeed(display);
626
1142
 
@@ -757,3 +1273,47 @@ test("New recorder uses a default mime type if none is set already", () => {
757
1273
 
758
1274
  jsPsych.pluginAPI.initializeCameraRecorder = originalInitializeCameraRecorder;
759
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
+ });