@lookit/record 0.0.3
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.
- package/README.md +376 -0
- package/dist/consentVideo.d.ts +353 -0
- package/dist/errors.d.ts +158 -0
- package/dist/index.browser.js +26321 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +4 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +26321 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +26319 -0
- package/dist/index.js.map +1 -0
- package/dist/mic_check.d.ts +33 -0
- package/dist/recorder.d.ts +146 -0
- package/dist/start.d.ts +24 -0
- package/dist/stop.d.ts +30 -0
- package/dist/trial.d.ts +28 -0
- package/dist/types.d.ts +5 -0
- package/dist/utils.d.ts +21 -0
- package/dist/video_config.d.ts +296 -0
- package/package.json +52 -0
- package/src/consentVideo.spec.ts +274 -0
- package/src/consentVideo.ts +284 -0
- package/src/environment.d.ts +10 -0
- package/src/errors.ts +243 -0
- package/src/img-import.d.ts +1 -0
- package/src/index.spec.ts +104 -0
- package/src/index.ts +13 -0
- package/src/mic_check.d.ts +1 -0
- package/src/mic_check.js +74 -0
- package/src/mic_check.ts +77 -0
- package/src/recorder.spec.ts +446 -0
- package/src/recorder.ts +353 -0
- package/src/start.ts +36 -0
- package/src/stop.ts +46 -0
- package/src/string-import.d.ts +14 -0
- package/src/trial.ts +47 -0
- package/src/types.ts +8 -0
- package/src/utils.spec.ts +55 -0
- package/src/utils.ts +119 -0
- package/src/video_config.spec.ts +1113 -0
- package/src/video_config.ts +665 -0
- package/src/video_config_mic_check.spec.ts +268 -0
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
import { clickTarget } from "@jspsych/test-utils";
|
|
2
|
+
import Handlebars from "handlebars";
|
|
3
|
+
import { initJsPsych, JsPsych } from "jspsych";
|
|
4
|
+
import videoConfig from "../templates/video-config.hbs";
|
|
5
|
+
import { NoStreamError } from "./errors";
|
|
6
|
+
import chromeInitialPrompt from "./img/chrome_initialprompt.png";
|
|
7
|
+
import chromeAlwaysAllow from "./img/chrome_step1_alwaysallow.png";
|
|
8
|
+
import chromePermissions from "./img/chrome_step1_permissions.png";
|
|
9
|
+
import firefoxInitialPrompt from "./img/firefox_initialprompt.png";
|
|
10
|
+
import firefoxChooseDevice from "./img/firefox_prompt_choose_device.png";
|
|
11
|
+
import firefoxDevicesBlocked from "./img/firefox_prompt_devices_blocked.png";
|
|
12
|
+
import Recorder from "./recorder";
|
|
13
|
+
import VideoConfigPlugin, { VideoConsentTrialType } from "./video_config";
|
|
14
|
+
|
|
15
|
+
jest.mock("./recorder");
|
|
16
|
+
jest.mock("@lookit/data");
|
|
17
|
+
jest.mock("jspsych", () => ({
|
|
18
|
+
...jest.requireActual("jspsych"),
|
|
19
|
+
initJsPsych: jest.fn().mockReturnValue({
|
|
20
|
+
finishTrial: jest.fn().mockImplementation(),
|
|
21
|
+
pluginAPI: {
|
|
22
|
+
initializeCameraRecorder: jest.fn(),
|
|
23
|
+
getCameraStream: jest.fn().mockReturnValue({
|
|
24
|
+
active: true,
|
|
25
|
+
clone: jest.fn(),
|
|
26
|
+
getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]),
|
|
27
|
+
}),
|
|
28
|
+
getCameraRecorder: jest.fn().mockReturnValue({
|
|
29
|
+
addEventListener: jest.fn(),
|
|
30
|
+
start: jest.fn(),
|
|
31
|
+
stop: jest.fn(),
|
|
32
|
+
stream: {
|
|
33
|
+
active: true,
|
|
34
|
+
clone: jest.fn(),
|
|
35
|
+
getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]),
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
let display_el: HTMLBodyElement;
|
|
43
|
+
let jsPsych: JsPsych;
|
|
44
|
+
let video_config: VideoConfigPlugin;
|
|
45
|
+
let html_params: object;
|
|
46
|
+
let devices: MediaDeviceInfo[];
|
|
47
|
+
let devicesObj: {
|
|
48
|
+
cam1: MediaDeviceInfo;
|
|
49
|
+
cam2: MediaDeviceInfo;
|
|
50
|
+
mic1: MediaDeviceInfo;
|
|
51
|
+
mic2: MediaDeviceInfo;
|
|
52
|
+
};
|
|
53
|
+
let returnedDeviceLists: {
|
|
54
|
+
cameras: MediaDeviceInfo[];
|
|
55
|
+
mics: MediaDeviceInfo[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Clean rendered html to be compared with DOM.
|
|
60
|
+
*
|
|
61
|
+
* @param html - HTML string
|
|
62
|
+
* @returns Cleaned HTML string
|
|
63
|
+
*/
|
|
64
|
+
const cleanHTML = (html: string) => {
|
|
65
|
+
return (
|
|
66
|
+
html
|
|
67
|
+
// Multiple whitespaces
|
|
68
|
+
.replace(/\s\s+/gm, " ")
|
|
69
|
+
// Whitespace and or slash before html element end
|
|
70
|
+
.replace(/\s*\/*>/gm, ">")
|
|
71
|
+
// equals empty string
|
|
72
|
+
.replace(/(="")/gm, "")
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
jsPsych = initJsPsych();
|
|
78
|
+
display_el = document.getElementsByTagName("body")[0] as HTMLBodyElement;
|
|
79
|
+
video_config = new VideoConfigPlugin(jsPsych);
|
|
80
|
+
video_config["display_el"] = display_el;
|
|
81
|
+
|
|
82
|
+
// Set up parameters for rendering HTML template
|
|
83
|
+
html_params = {
|
|
84
|
+
webcam_container_id: video_config["webcam_container_id"],
|
|
85
|
+
reload_button_id_cam: video_config["reload_button_id_cam"],
|
|
86
|
+
camera_selection_id: video_config["camera_selection_id"],
|
|
87
|
+
mic_selection_id: video_config["mic_selection_id"],
|
|
88
|
+
step1_id: video_config["step1_id"],
|
|
89
|
+
step2_id: video_config["step2_id"],
|
|
90
|
+
step3_id: video_config["step3_id"],
|
|
91
|
+
step_complete_class: video_config["step_complete_class"],
|
|
92
|
+
step_complete_text: video_config["step_complete_text"],
|
|
93
|
+
reload_button_id_text: video_config["reload_button_id_text"],
|
|
94
|
+
next_button_id: video_config["next_button_id"],
|
|
95
|
+
chromeInitialPrompt: chromeInitialPrompt,
|
|
96
|
+
chromeAlwaysAllow: chromeAlwaysAllow,
|
|
97
|
+
chromePermissions: chromePermissions,
|
|
98
|
+
firefoxInitialPrompt: firefoxInitialPrompt,
|
|
99
|
+
firefoxChooseDevice: firefoxChooseDevice,
|
|
100
|
+
firefoxDevicesBlocked: firefoxDevicesBlocked,
|
|
101
|
+
troubleshooting_intro: "",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Mocks for handling media streams and devices
|
|
105
|
+
const mic1 = {
|
|
106
|
+
deviceId: "mic1",
|
|
107
|
+
kind: "audioinput",
|
|
108
|
+
label: "",
|
|
109
|
+
groupId: "default",
|
|
110
|
+
} as MediaDeviceInfo;
|
|
111
|
+
const cam1 = {
|
|
112
|
+
deviceId: "cam1",
|
|
113
|
+
kind: "videoinput",
|
|
114
|
+
label: "",
|
|
115
|
+
groupId: "default",
|
|
116
|
+
} as MediaDeviceInfo;
|
|
117
|
+
const mic2 = {
|
|
118
|
+
deviceId: "mic2",
|
|
119
|
+
kind: "audioinput",
|
|
120
|
+
label: "",
|
|
121
|
+
groupId: "other",
|
|
122
|
+
} as MediaDeviceInfo;
|
|
123
|
+
const cam2 = {
|
|
124
|
+
deviceId: "cam2",
|
|
125
|
+
kind: "videoinput",
|
|
126
|
+
label: "",
|
|
127
|
+
groupId: "other",
|
|
128
|
+
} as MediaDeviceInfo;
|
|
129
|
+
|
|
130
|
+
devicesObj = { cam1, cam2, mic1, mic2 };
|
|
131
|
+
|
|
132
|
+
// Array format for devices returned from global.navigator.mediaDevices.enumerateDevices
|
|
133
|
+
devices = [cam1, cam2, mic1, mic2];
|
|
134
|
+
Object.defineProperty(global.navigator, "mediaDevices", {
|
|
135
|
+
writable: true,
|
|
136
|
+
value: {
|
|
137
|
+
ondevicechange: jest.fn(),
|
|
138
|
+
enumerateDevices: jest.fn(
|
|
139
|
+
() =>
|
|
140
|
+
new Promise<MediaDeviceInfo[]>((resolve) => {
|
|
141
|
+
resolve(devices);
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Object format for devices returned from getDeviceLists
|
|
148
|
+
returnedDeviceLists = {
|
|
149
|
+
cameras: [devicesObj.cam1, devicesObj.cam2],
|
|
150
|
+
mics: [devicesObj.mic1, devicesObj.mic2],
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
jest.clearAllMocks();
|
|
156
|
+
// Reset the document body.
|
|
157
|
+
document.body.innerHTML = "";
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("Video config trial method sets up trial", () => {
|
|
161
|
+
const trial_info = {
|
|
162
|
+
troubleshooting_intro: "",
|
|
163
|
+
} as unknown as VideoConsentTrialType;
|
|
164
|
+
|
|
165
|
+
// Set up mocks for upstream functions called in trial method.
|
|
166
|
+
const addHtmlMock = jest
|
|
167
|
+
.spyOn(video_config, "addHtmlContent")
|
|
168
|
+
.mockImplementation(jest.fn());
|
|
169
|
+
const addEventListenerslMock = jest
|
|
170
|
+
.spyOn(video_config, "addEventListeners")
|
|
171
|
+
.mockImplementation(jest.fn());
|
|
172
|
+
const setupRecorderMock = jest
|
|
173
|
+
.spyOn(video_config, "setupRecorder")
|
|
174
|
+
.mockImplementation(jest.fn());
|
|
175
|
+
|
|
176
|
+
expect(addHtmlMock).toHaveBeenCalledTimes(0);
|
|
177
|
+
expect(addEventListenerslMock).toHaveBeenCalledTimes(0);
|
|
178
|
+
expect(setupRecorderMock).toHaveBeenCalledTimes(0);
|
|
179
|
+
expect(video_config["start_time"]).toBeNull();
|
|
180
|
+
|
|
181
|
+
video_config.trial(display_el, trial_info);
|
|
182
|
+
|
|
183
|
+
expect(global.navigator.mediaDevices.ondevicechange).toBe(
|
|
184
|
+
video_config["onDeviceChange"],
|
|
185
|
+
);
|
|
186
|
+
expect(addHtmlMock).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(addEventListenerslMock).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(setupRecorderMock).toHaveBeenCalledTimes(1);
|
|
189
|
+
expect(video_config["start_time"]).not.toBeNull();
|
|
190
|
+
expect(video_config["start_time"]).toBeGreaterThan(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("Video config addHtmlContent loads template", () => {
|
|
194
|
+
expect(video_config["display_el"]?.innerHTML).toBe("");
|
|
195
|
+
|
|
196
|
+
// Render the template with the HTML parameters.
|
|
197
|
+
const rendered_trial_html = cleanHTML(
|
|
198
|
+
Handlebars.compile(videoConfig)(html_params),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Run addHtmlContent to get the actual trial HTML.
|
|
202
|
+
video_config["addHtmlContent"]("");
|
|
203
|
+
const displayed_html = cleanHTML(document.body.innerHTML);
|
|
204
|
+
|
|
205
|
+
expect(displayed_html).toStrictEqual(rendered_trial_html);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("Video config addHtmlContent loads template with custom troubleshooting text", () => {
|
|
209
|
+
expect(video_config["display_el"]?.innerHTML).toBe("");
|
|
210
|
+
|
|
211
|
+
// Render the template with a custom trial parameter.
|
|
212
|
+
const troubleshooting_intro = "Custom text.";
|
|
213
|
+
const html_params_custom_intro = {
|
|
214
|
+
...html_params,
|
|
215
|
+
troubleshooting_intro,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Remove new lines, indents (tabs or spaces), and empty HTML property values.
|
|
219
|
+
const rendered_trial_html = cleanHTML(
|
|
220
|
+
Handlebars.compile(videoConfig)(html_params_custom_intro),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Get the actual trial HTML
|
|
224
|
+
video_config["addHtmlContent"](troubleshooting_intro);
|
|
225
|
+
const displayed_html = cleanHTML(document.body.innerHTML);
|
|
226
|
+
|
|
227
|
+
expect(displayed_html).toStrictEqual(rendered_trial_html);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("Video config add event listeners", async () => {
|
|
231
|
+
expect(video_config["display_el"]?.innerHTML).toBe("");
|
|
232
|
+
video_config["addHtmlContent"]("");
|
|
233
|
+
|
|
234
|
+
// Get relevant elements (device selection elements tested separately)
|
|
235
|
+
const next_button_el = video_config["display_el"]?.querySelector(
|
|
236
|
+
`#${video_config["next_button_id"]}`,
|
|
237
|
+
) as HTMLButtonElement;
|
|
238
|
+
const reload_button_els = video_config["display_el"]?.querySelectorAll(
|
|
239
|
+
`#${video_config["reload_button_id_cam"]}, #${video_config["reload_button_id_text"]}`,
|
|
240
|
+
) as NodeListOf<HTMLButtonElement>;
|
|
241
|
+
const acc_button_els = document.getElementsByClassName(
|
|
242
|
+
"lookit-jspsych-accordion",
|
|
243
|
+
) as HTMLCollectionOf<HTMLButtonElement>;
|
|
244
|
+
|
|
245
|
+
// Mock event-related callback functions.
|
|
246
|
+
const nextBtnClickMock = jest
|
|
247
|
+
.spyOn(video_config, "nextButtonClick")
|
|
248
|
+
.mockImplementation(jest.fn());
|
|
249
|
+
const reloadBtnClickMock = jest
|
|
250
|
+
.spyOn(video_config, "reloadButtonClick")
|
|
251
|
+
.mockImplementation(jest.fn());
|
|
252
|
+
|
|
253
|
+
video_config["addEventListeners"]();
|
|
254
|
+
|
|
255
|
+
// Next button is disabled when the page loads - it must be enabled before it will accept a click event.
|
|
256
|
+
video_config["enableNext"](true);
|
|
257
|
+
expect(nextBtnClickMock).toHaveBeenCalledTimes(0);
|
|
258
|
+
await clickTarget(next_button_el);
|
|
259
|
+
expect(nextBtnClickMock).toHaveBeenCalledTimes(1);
|
|
260
|
+
|
|
261
|
+
// Reload buttons.
|
|
262
|
+
expect(reloadBtnClickMock.mock.calls).toHaveLength(0);
|
|
263
|
+
for (let i = 0; i < reload_button_els.length; i++) {
|
|
264
|
+
await clickTarget(reload_button_els[i]);
|
|
265
|
+
}
|
|
266
|
+
expect(reloadBtnClickMock.mock.calls).toHaveLength(reload_button_els.length);
|
|
267
|
+
|
|
268
|
+
// Buttons and content (panel) for accordion section (troubleshooting)
|
|
269
|
+
for (let i = 0; i < acc_button_els.length; i++) {
|
|
270
|
+
const this_btn = acc_button_els[i];
|
|
271
|
+
const this_panel = this_btn.nextElementSibling as HTMLDivElement;
|
|
272
|
+
|
|
273
|
+
// Initial state is no active class and panel display: none
|
|
274
|
+
expect(this_btn.classList.contains("active")).toBe(false);
|
|
275
|
+
expect(this_panel.style.display).toBe("none");
|
|
276
|
+
|
|
277
|
+
await clickTarget(this_btn);
|
|
278
|
+
|
|
279
|
+
// Button active and panel display: block
|
|
280
|
+
expect(this_btn.classList.contains("active")).toBe(true);
|
|
281
|
+
expect(this_panel.style.display).toBe("block");
|
|
282
|
+
|
|
283
|
+
await clickTarget(this_btn);
|
|
284
|
+
|
|
285
|
+
// Clicking it again returns it to initial state.
|
|
286
|
+
expect(this_btn.classList.contains("active")).toBe(false);
|
|
287
|
+
expect(this_panel.style.display).toBe("none");
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("Video config enable next", () => {
|
|
292
|
+
expect(video_config["display_el"]?.innerHTML).toBe("");
|
|
293
|
+
|
|
294
|
+
video_config["addHtmlContent"]("");
|
|
295
|
+
const next_button_el = video_config["display_el"]?.querySelector(
|
|
296
|
+
`#${video_config["next_button_id"]}`,
|
|
297
|
+
) as HTMLButtonElement;
|
|
298
|
+
|
|
299
|
+
// When trial first loads, next button should exist but be disabled and no 'step_complete_class'
|
|
300
|
+
expect(next_button_el).toBeTruthy();
|
|
301
|
+
expect(
|
|
302
|
+
next_button_el.classList.contains(video_config["step_complete_class"]),
|
|
303
|
+
).toBe(false);
|
|
304
|
+
expect(next_button_el.disabled).toBe(true);
|
|
305
|
+
|
|
306
|
+
// Calling with 'true' enables the button and adds the class.
|
|
307
|
+
video_config["enableNext"](true);
|
|
308
|
+
expect(
|
|
309
|
+
next_button_el.classList.contains(video_config["step_complete_class"]),
|
|
310
|
+
).toBe(true);
|
|
311
|
+
expect(next_button_el.disabled).toBe(false);
|
|
312
|
+
|
|
313
|
+
// Calling with 'false' sets everything back.
|
|
314
|
+
video_config["enableNext"](false);
|
|
315
|
+
expect(
|
|
316
|
+
next_button_el.classList.contains(video_config["step_complete_class"]),
|
|
317
|
+
).toBe(false);
|
|
318
|
+
expect(next_button_el.disabled).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("Video config update instructions", () => {
|
|
322
|
+
expect(video_config["display_el"]?.innerHTML).toBe("");
|
|
323
|
+
video_config["addHtmlContent"]("");
|
|
324
|
+
|
|
325
|
+
// Get the relevant elements from the instructions section.
|
|
326
|
+
const step1_id = video_config["step1_id"];
|
|
327
|
+
const step2_id = video_config["step2_id"];
|
|
328
|
+
const step3_id = video_config["step3_id"];
|
|
329
|
+
const step1_span = video_config["display_el"]?.querySelector(
|
|
330
|
+
`#${step1_id}-span`,
|
|
331
|
+
) as HTMLSpanElement;
|
|
332
|
+
const step1_para = video_config["display_el"]?.querySelector(
|
|
333
|
+
`#${step1_id}-paragraph`,
|
|
334
|
+
) as HTMLParagraphElement;
|
|
335
|
+
const step2_span = video_config["display_el"]?.querySelector(
|
|
336
|
+
`#${step2_id}-span`,
|
|
337
|
+
) as HTMLSpanElement;
|
|
338
|
+
const step2_para = video_config["display_el"]?.querySelector(
|
|
339
|
+
`#${step2_id}-paragraph`,
|
|
340
|
+
) as HTMLParagraphElement;
|
|
341
|
+
const step3_span = video_config["display_el"]?.querySelector(
|
|
342
|
+
`#${step3_id}-span`,
|
|
343
|
+
) as HTMLSpanElement;
|
|
344
|
+
const step3_para = video_config["display_el"]?.querySelector(
|
|
345
|
+
`#${step3_id}-paragraph`,
|
|
346
|
+
) as HTMLParagraphElement;
|
|
347
|
+
|
|
348
|
+
// For each step 1-3:
|
|
349
|
+
// - calling with 'true' should make the span element visible and set the paragraph text color to gray.
|
|
350
|
+
// - calling with 'false' should make the span hidden and set the paragraph text color to black.
|
|
351
|
+
// The initial state is the same as with 'false', except that the text color is unset (inherits).
|
|
352
|
+
expect(step1_span.style.visibility).toBe("hidden");
|
|
353
|
+
video_config["updateInstructions"](1, true);
|
|
354
|
+
expect(step1_span.style.visibility).toBe("visible");
|
|
355
|
+
expect(step1_para.style.color).toBe("gray");
|
|
356
|
+
video_config["updateInstructions"](1, false);
|
|
357
|
+
expect(step1_span.style.visibility).toBe("hidden");
|
|
358
|
+
expect(step1_para.style.color).toBe("black");
|
|
359
|
+
|
|
360
|
+
expect(step2_span.style.visibility).toBe("hidden");
|
|
361
|
+
video_config["updateInstructions"](2, true);
|
|
362
|
+
expect(step2_span.style.visibility).toBe("visible");
|
|
363
|
+
expect(step2_para.style.color).toBe("gray");
|
|
364
|
+
video_config["updateInstructions"](2, false);
|
|
365
|
+
expect(step2_span.style.visibility).toBe("hidden");
|
|
366
|
+
expect(step2_para.style.color).toBe("black");
|
|
367
|
+
|
|
368
|
+
expect(step3_span.style.visibility).toBe("hidden");
|
|
369
|
+
video_config["updateInstructions"](3, true);
|
|
370
|
+
expect(step3_span.style.visibility).toBe("visible");
|
|
371
|
+
expect(step3_para.style.color).toBe("gray");
|
|
372
|
+
video_config["updateInstructions"](3, false);
|
|
373
|
+
expect(step3_span.style.visibility).toBe("hidden");
|
|
374
|
+
expect(step3_para.style.color).toBe("black");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("Video config update errors", () => {
|
|
378
|
+
expect(video_config["display_el"]?.innerHTML).toBe("");
|
|
379
|
+
video_config["addHtmlContent"]("");
|
|
380
|
+
const error_msg_div = video_config["display_el"]?.querySelector(
|
|
381
|
+
`#${video_config["error_msg_div_id"]}`,
|
|
382
|
+
) as HTMLDivElement;
|
|
383
|
+
|
|
384
|
+
// Error/info message div is empty when the page first loads.
|
|
385
|
+
expect(error_msg_div.innerHTML).toStrictEqual("");
|
|
386
|
+
|
|
387
|
+
const test_msg = "Test message.";
|
|
388
|
+
video_config["updateErrors"](test_msg);
|
|
389
|
+
|
|
390
|
+
expect(error_msg_div.innerHTML).toStrictEqual(test_msg);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("Video config reload button click", async () => {
|
|
394
|
+
expect(video_config["hasReloaded"]).toBe(false);
|
|
395
|
+
|
|
396
|
+
video_config["addHtmlContent"]("");
|
|
397
|
+
|
|
398
|
+
const reload_button_els = video_config["display_el"]?.querySelectorAll(
|
|
399
|
+
`#${video_config["reload_button_id_cam"]}, #${video_config["reload_button_id_text"]}`,
|
|
400
|
+
) as NodeListOf<HTMLButtonElement>;
|
|
401
|
+
|
|
402
|
+
// Mock upstream function calls.
|
|
403
|
+
const updateInstructionskMock = jest
|
|
404
|
+
.spyOn(video_config, "updateInstructions")
|
|
405
|
+
.mockImplementation(jest.fn());
|
|
406
|
+
const destroyMock = jest
|
|
407
|
+
.spyOn(video_config, "destroyRecorder")
|
|
408
|
+
.mockImplementation(jest.fn());
|
|
409
|
+
const setupRecorderMock = jest
|
|
410
|
+
.spyOn(video_config, "setupRecorder")
|
|
411
|
+
.mockImplementation(jest.fn());
|
|
412
|
+
|
|
413
|
+
video_config["addEventListeners"]();
|
|
414
|
+
|
|
415
|
+
expect(updateInstructionskMock).toHaveBeenCalledTimes(0);
|
|
416
|
+
expect(destroyMock).toHaveBeenCalledTimes(0);
|
|
417
|
+
expect(setupRecorderMock).toHaveBeenCalledTimes(0);
|
|
418
|
+
|
|
419
|
+
await clickTarget(reload_button_els[0]);
|
|
420
|
+
|
|
421
|
+
expect(updateInstructionskMock).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(destroyMock).toHaveBeenCalledTimes(1);
|
|
423
|
+
expect(setupRecorderMock).toHaveBeenCalledTimes(1);
|
|
424
|
+
expect(video_config["hasReloaded"]).toBe(true);
|
|
425
|
+
|
|
426
|
+
await clickTarget(reload_button_els[1]);
|
|
427
|
+
|
|
428
|
+
expect(updateInstructionskMock).toHaveBeenCalledTimes(2);
|
|
429
|
+
expect(destroyMock).toHaveBeenCalledTimes(2);
|
|
430
|
+
expect(setupRecorderMock).toHaveBeenCalledTimes(2);
|
|
431
|
+
expect(video_config["hasReloaded"]).toBe(true);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("Video config updateDeviceSelection", () => {
|
|
435
|
+
video_config["addHtmlContent"]("");
|
|
436
|
+
const cam_selection_el = video_config["display_el"]?.querySelector(
|
|
437
|
+
`#${video_config["camera_selection_id"]}`,
|
|
438
|
+
) as HTMLSelectElement;
|
|
439
|
+
const mic_selection_el = video_config["display_el"]?.querySelector(
|
|
440
|
+
`#${video_config["mic_selection_id"]}`,
|
|
441
|
+
) as HTMLSelectElement;
|
|
442
|
+
|
|
443
|
+
expect(cam_selection_el).not.toBeUndefined();
|
|
444
|
+
expect(cam_selection_el.innerHTML).toBe("");
|
|
445
|
+
expect(mic_selection_el).not.toBeUndefined();
|
|
446
|
+
expect(mic_selection_el.innerHTML).toBe("");
|
|
447
|
+
|
|
448
|
+
// Populates the select elements with new device info.
|
|
449
|
+
video_config["updateDeviceSelection"]({
|
|
450
|
+
cameras: [devicesObj.cam1],
|
|
451
|
+
mics: [devicesObj.mic1],
|
|
452
|
+
});
|
|
453
|
+
expect(mic_selection_el.innerHTML).toContain(
|
|
454
|
+
`<option value="${devicesObj.mic1.deviceId}"></option>`,
|
|
455
|
+
);
|
|
456
|
+
expect(cam_selection_el.innerHTML).toContain(
|
|
457
|
+
`<option value="${devicesObj.cam1.deviceId}"></option>`,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Also clears old options in select elements that are no longer in the devices lists.
|
|
461
|
+
video_config["updateDeviceSelection"]({
|
|
462
|
+
cameras: [devicesObj.cam2],
|
|
463
|
+
mics: [devicesObj.mic2],
|
|
464
|
+
});
|
|
465
|
+
expect(mic_selection_el.innerHTML).not.toContain(
|
|
466
|
+
`<option value="${devicesObj.mic1.deviceId}"></option>`,
|
|
467
|
+
);
|
|
468
|
+
expect(cam_selection_el.innerHTML).not.toContain(
|
|
469
|
+
`<option value="${devicesObj.cam1.deviceId}"></option>`,
|
|
470
|
+
);
|
|
471
|
+
expect(mic_selection_el.innerHTML).toContain(
|
|
472
|
+
`<option value="${devicesObj.mic2.deviceId}"></option>`,
|
|
473
|
+
);
|
|
474
|
+
expect(cam_selection_el.innerHTML).toContain(
|
|
475
|
+
`<option value="${devicesObj.cam2.deviceId}"></option>`,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// If a device was previously selected (via setDevices), and is in the updated list, it is automatically selected again.
|
|
479
|
+
video_config["camId"] = devicesObj.cam2.deviceId;
|
|
480
|
+
video_config["micId"] = devicesObj.mic2.deviceId;
|
|
481
|
+
cam_selection_el.value = devicesObj.cam2.deviceId;
|
|
482
|
+
mic_selection_el.value = devicesObj.mic2.deviceId;
|
|
483
|
+
video_config["updateDeviceSelection"]({
|
|
484
|
+
cameras: [devicesObj.cam1, devicesObj.cam2],
|
|
485
|
+
mics: [devicesObj.mic1, devicesObj.mic2],
|
|
486
|
+
});
|
|
487
|
+
expect(mic_selection_el.innerHTML).toContain(
|
|
488
|
+
`<option value="${devicesObj.mic1.deviceId}"></option>`,
|
|
489
|
+
);
|
|
490
|
+
expect(cam_selection_el.innerHTML).toContain(
|
|
491
|
+
`<option value="${devicesObj.cam1.deviceId}"></option>`,
|
|
492
|
+
);
|
|
493
|
+
expect(mic_selection_el.innerHTML).toContain(
|
|
494
|
+
`<option value="${devicesObj.mic2.deviceId}"></option>`,
|
|
495
|
+
);
|
|
496
|
+
expect(cam_selection_el.innerHTML).toContain(
|
|
497
|
+
`<option value="${devicesObj.cam2.deviceId}"></option>`,
|
|
498
|
+
);
|
|
499
|
+
expect(cam_selection_el.value).toBe(devicesObj.cam2.deviceId);
|
|
500
|
+
expect(mic_selection_el.value).toBe(devicesObj.mic2.deviceId);
|
|
501
|
+
|
|
502
|
+
// If a previously-selected device is no longer in the list, there is no error.
|
|
503
|
+
video_config["updateDeviceSelection"]({
|
|
504
|
+
cameras: [devicesObj.cam1],
|
|
505
|
+
mics: [devicesObj.mic1],
|
|
506
|
+
});
|
|
507
|
+
expect(mic_selection_el.innerHTML).toContain(
|
|
508
|
+
`<option value="${devicesObj.mic1.deviceId}"></option>`,
|
|
509
|
+
);
|
|
510
|
+
expect(cam_selection_el.innerHTML).toContain(
|
|
511
|
+
`<option value="${devicesObj.cam1.deviceId}"></option>`,
|
|
512
|
+
);
|
|
513
|
+
expect(mic_selection_el.innerHTML).not.toContain(
|
|
514
|
+
`<option value="${devicesObj.mic2.deviceId}"></option>`,
|
|
515
|
+
);
|
|
516
|
+
expect(cam_selection_el.innerHTML).not.toContain(
|
|
517
|
+
`<option value="${devicesObj.cam2.deviceId}"></option>`,
|
|
518
|
+
);
|
|
519
|
+
expect(cam_selection_el.value).toBe(devicesObj.cam1.deviceId);
|
|
520
|
+
expect(mic_selection_el.value).toBe(devicesObj.mic1.deviceId);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("Video config end trial", () => {
|
|
524
|
+
// Set up some data values
|
|
525
|
+
const response = { rt: 1000 };
|
|
526
|
+
const camId = "cam Id";
|
|
527
|
+
const micId = "mic Id";
|
|
528
|
+
video_config["response"] = response;
|
|
529
|
+
video_config["camId"] = camId;
|
|
530
|
+
video_config["micId"] = micId;
|
|
531
|
+
|
|
532
|
+
// Ending the trial should trigger finishTrial with the data.
|
|
533
|
+
video_config["endTrial"]();
|
|
534
|
+
expect(jsPsych.finishTrial).toHaveBeenCalledWith({
|
|
535
|
+
rt: response.rt,
|
|
536
|
+
camId,
|
|
537
|
+
micId,
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("Video config setupRecorder", async () => {
|
|
542
|
+
// Mock the upstream function calls.
|
|
543
|
+
const updateInstructionsMock = jest
|
|
544
|
+
.spyOn(video_config, "updateInstructions")
|
|
545
|
+
.mockImplementation(jest.fn());
|
|
546
|
+
const updateErrorsMock = jest
|
|
547
|
+
.spyOn(video_config, "updateErrors")
|
|
548
|
+
.mockImplementation(jest.fn());
|
|
549
|
+
const requestPermissionMock = jest
|
|
550
|
+
.spyOn(video_config, "requestPermission")
|
|
551
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
552
|
+
.mockImplementation((constraints: MediaStreamConstraints) =>
|
|
553
|
+
Promise.resolve(jsPsych.pluginAPI.getCameraRecorder().stream),
|
|
554
|
+
);
|
|
555
|
+
const getDeviceListsMock = jest
|
|
556
|
+
.spyOn(video_config, "getDeviceLists")
|
|
557
|
+
.mockImplementation(
|
|
558
|
+
(
|
|
559
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
560
|
+
include_audio?: boolean | undefined,
|
|
561
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
562
|
+
include_camera?: boolean | undefined,
|
|
563
|
+
) => {
|
|
564
|
+
return Promise.resolve(returnedDeviceLists);
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
const updateDeviceSelectionMock = jest
|
|
568
|
+
.spyOn(video_config, "updateDeviceSelection")
|
|
569
|
+
.mockImplementation(jest.fn());
|
|
570
|
+
const setDevicesMock = jest
|
|
571
|
+
.spyOn(video_config, "setDevices")
|
|
572
|
+
.mockImplementation(jest.fn());
|
|
573
|
+
const runStreamChecksMock = jest
|
|
574
|
+
.spyOn(video_config, "runStreamChecks")
|
|
575
|
+
.mockImplementation(jest.fn());
|
|
576
|
+
|
|
577
|
+
await video_config["setupRecorder"]();
|
|
578
|
+
|
|
579
|
+
// Updates the Instructions - resets the "completed" status of steps 1 and 3.
|
|
580
|
+
expect(updateInstructionsMock).toHaveBeenCalledTimes(2);
|
|
581
|
+
expect(updateInstructionsMock.mock.calls[0]).toStrictEqual([1, false]);
|
|
582
|
+
expect(updateInstructionsMock.mock.calls[1]).toStrictEqual([3, false]);
|
|
583
|
+
// Adds error message when waiting for stream access, then clears the message after access is granted.
|
|
584
|
+
expect(updateErrorsMock).toHaveBeenCalledTimes(2);
|
|
585
|
+
expect(updateErrorsMock.mock.calls[0]).toStrictEqual([
|
|
586
|
+
video_config["waiting_for_access_msg"],
|
|
587
|
+
]);
|
|
588
|
+
expect(updateErrorsMock.mock.calls[1]).toStrictEqual([""]);
|
|
589
|
+
// Needs to request permissions before it can get device lists.
|
|
590
|
+
expect(requestPermissionMock).toHaveBeenCalledWith({
|
|
591
|
+
video: true,
|
|
592
|
+
audio: true,
|
|
593
|
+
});
|
|
594
|
+
// Gets device lists.
|
|
595
|
+
expect(getDeviceListsMock).toHaveBeenCalledTimes(1);
|
|
596
|
+
// Updates selection elements with available devices.
|
|
597
|
+
expect(updateDeviceSelectionMock).toHaveBeenCalledTimes(1);
|
|
598
|
+
expect(updateDeviceSelectionMock).toHaveBeenCalledWith(returnedDeviceLists);
|
|
599
|
+
// Set the current devices.
|
|
600
|
+
expect(setDevicesMock).toHaveBeenCalledTimes(1);
|
|
601
|
+
// Run stream checks on current devices.
|
|
602
|
+
expect(runStreamChecksMock).toHaveBeenCalledTimes(1);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("Video config setDevices", async () => {
|
|
606
|
+
video_config["addHtmlContent"]("");
|
|
607
|
+
const cam_selection_el = video_config["display_el"]?.querySelector(
|
|
608
|
+
`#${video_config["camera_selection_id"]}`,
|
|
609
|
+
) as HTMLSelectElement;
|
|
610
|
+
const mic_selection_el = video_config["display_el"]?.querySelector(
|
|
611
|
+
`#${video_config["mic_selection_id"]}`,
|
|
612
|
+
) as HTMLSelectElement;
|
|
613
|
+
const next_button_el = video_config["display_el"]?.querySelector(
|
|
614
|
+
`#${video_config["next_button_id"]}`,
|
|
615
|
+
) as HTMLButtonElement;
|
|
616
|
+
video_config["recorder"] = new Recorder(jsPsych);
|
|
617
|
+
|
|
618
|
+
// Populate the selection elements with device options.
|
|
619
|
+
video_config["updateDeviceSelection"]({
|
|
620
|
+
cameras: [devicesObj.cam1, devicesObj.cam2],
|
|
621
|
+
mics: [devicesObj.mic1, devicesObj.mic2],
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Select some devices.
|
|
625
|
+
cam_selection_el.value = devicesObj.cam2.deviceId;
|
|
626
|
+
mic_selection_el.value = devicesObj.mic2.deviceId;
|
|
627
|
+
|
|
628
|
+
// Setup upstream mocks.
|
|
629
|
+
const enableNextMock = jest
|
|
630
|
+
.spyOn(video_config, "enableNext")
|
|
631
|
+
.mockImplementation(jest.fn());
|
|
632
|
+
const updateInstructionsMock = jest
|
|
633
|
+
.spyOn(video_config, "updateInstructions")
|
|
634
|
+
.mockImplementation(jest.fn());
|
|
635
|
+
const requestPermissionMock = jest
|
|
636
|
+
.spyOn(video_config, "requestPermission")
|
|
637
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
638
|
+
.mockImplementation((constraints: MediaStreamConstraints) =>
|
|
639
|
+
Promise.resolve(jsPsych.pluginAPI.getCameraRecorder().stream),
|
|
640
|
+
);
|
|
641
|
+
const createInitializeRecorderMock = jest.spyOn(
|
|
642
|
+
video_config,
|
|
643
|
+
"initializeAndCreateRecorder",
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
expect(next_button_el.disabled).toBe(true);
|
|
647
|
+
expect(video_config["camId"]).toStrictEqual("");
|
|
648
|
+
expect(video_config["micId"]).toStrictEqual("");
|
|
649
|
+
|
|
650
|
+
await video_config["setDevices"]();
|
|
651
|
+
|
|
652
|
+
// Disables next button
|
|
653
|
+
expect(enableNextMock).toHaveBeenCalledTimes(1);
|
|
654
|
+
expect(enableNextMock).toHaveBeenCalledWith(false);
|
|
655
|
+
expect(next_button_el.disabled).toBe(true);
|
|
656
|
+
// Updates instructions step 3 (mic check)
|
|
657
|
+
expect(updateInstructionsMock).toHaveBeenCalledTimes(1);
|
|
658
|
+
expect(updateInstructionsMock).toHaveBeenCalledWith(3, false);
|
|
659
|
+
// Sets this.camId and this.micId values based on selection elements.
|
|
660
|
+
expect(video_config["camId"]).toStrictEqual(devicesObj.cam2.deviceId);
|
|
661
|
+
expect(video_config["micId"]).toStrictEqual(devicesObj.mic2.deviceId);
|
|
662
|
+
// Requests permissions for those specific devices
|
|
663
|
+
expect(requestPermissionMock).toHaveBeenCalledWith({
|
|
664
|
+
video: { deviceId: devicesObj.cam2.deviceId },
|
|
665
|
+
audio: { deviceId: devicesObj.mic2.deviceId },
|
|
666
|
+
});
|
|
667
|
+
// Calls initializeAndCreateRecorder with the returned stream
|
|
668
|
+
expect(createInitializeRecorderMock).toHaveBeenCalledWith(
|
|
669
|
+
jsPsych.pluginAPI.getCameraRecorder().stream,
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
// Now change the device selections - values should update accordingly.
|
|
673
|
+
cam_selection_el.value = devicesObj.cam1.deviceId;
|
|
674
|
+
mic_selection_el.value = devicesObj.mic1.deviceId;
|
|
675
|
+
|
|
676
|
+
await video_config["setDevices"]();
|
|
677
|
+
expect(video_config["camId"]).toStrictEqual(devicesObj.cam1.deviceId);
|
|
678
|
+
expect(video_config["micId"]).toStrictEqual(devicesObj.mic1.deviceId);
|
|
679
|
+
expect(requestPermissionMock).toHaveBeenCalledWith({
|
|
680
|
+
video: { deviceId: devicesObj.cam1.deviceId },
|
|
681
|
+
audio: { deviceId: devicesObj.mic1.deviceId },
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("Video config runStreamChecks throws NoStreamAccess", () => {
|
|
686
|
+
video_config["addHtmlContent"]("");
|
|
687
|
+
video_config["recorder"] = new Recorder(initJsPsych());
|
|
688
|
+
jest
|
|
689
|
+
.spyOn(jsPsych.pluginAPI, "getCameraRecorder")
|
|
690
|
+
// @ts-expect-error - jsPsych says the return value should be MediaRecorder, but in fact it's MediaRecorder or null.
|
|
691
|
+
.mockImplementationOnce(() => null);
|
|
692
|
+
// When the recorder has not been initialized with jsPsych.
|
|
693
|
+
expect(async () => await video_config["runStreamChecks"]()).rejects.toThrow(
|
|
694
|
+
NoStreamError,
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
expect(jsPsych.pluginAPI.getCameraRecorder).toBeTruthy();
|
|
698
|
+
|
|
699
|
+
// When the Recorder has not been created.
|
|
700
|
+
video_config["recorder"] = null;
|
|
701
|
+
expect(async () => await video_config["runStreamChecks"]()).rejects.toThrow(
|
|
702
|
+
NoStreamError,
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("Video config runStreamChecks throws Mic Check error", async () => {
|
|
707
|
+
video_config["addHtmlContent"]("");
|
|
708
|
+
video_config["recorder"] = new Recorder(jsPsych);
|
|
709
|
+
|
|
710
|
+
// Setup upstream mocks.
|
|
711
|
+
Recorder.prototype.insertWebcamFeed = jest.fn();
|
|
712
|
+
video_config["updateInstructions"] = jest.fn();
|
|
713
|
+
const updateErrorsMock = jest
|
|
714
|
+
.spyOn(video_config, "updateErrors")
|
|
715
|
+
.mockImplementation(jest.fn());
|
|
716
|
+
// Mock an error during the mic check setup.
|
|
717
|
+
const mockError = jest.fn(() => {
|
|
718
|
+
const promise = new Promise<void>(() => {
|
|
719
|
+
throw "Mic check problem";
|
|
720
|
+
});
|
|
721
|
+
promise.catch(() => null); // Prevent an uncaught error here so that it propogates to the catch block.
|
|
722
|
+
return promise;
|
|
723
|
+
});
|
|
724
|
+
video_config["checkMic"] = mockError;
|
|
725
|
+
|
|
726
|
+
await expect(video_config["runStreamChecks"]()).rejects.toThrow(
|
|
727
|
+
"Mic check problem",
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
// If the recorder mic check throws an error, then the plugin should update the error message and throw the error.
|
|
731
|
+
expect(updateErrorsMock).toHaveBeenCalledTimes(2);
|
|
732
|
+
expect(updateErrorsMock.mock.calls[0]).toStrictEqual([
|
|
733
|
+
video_config["checking_mic_msg"],
|
|
734
|
+
]);
|
|
735
|
+
expect(updateErrorsMock.mock.calls[1]).toStrictEqual([
|
|
736
|
+
video_config["setup_problem_msg"],
|
|
737
|
+
]);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("Video config runStreamChecks", async () => {
|
|
741
|
+
video_config["addHtmlContent"]("");
|
|
742
|
+
video_config["recorder"] = new Recorder(jsPsych);
|
|
743
|
+
|
|
744
|
+
// Setup upstream mocks.
|
|
745
|
+
const insertWebcamMock = jest
|
|
746
|
+
.spyOn(Recorder.prototype, "insertWebcamFeed")
|
|
747
|
+
.mockImplementation(jest.fn());
|
|
748
|
+
const updateInstructionsMock = jest
|
|
749
|
+
.spyOn(video_config, "updateInstructions")
|
|
750
|
+
.mockImplementation(jest.fn());
|
|
751
|
+
const updateErrorsMock = jest
|
|
752
|
+
.spyOn(video_config, "updateErrors")
|
|
753
|
+
.mockImplementation(jest.fn());
|
|
754
|
+
const checkMicMock = jest
|
|
755
|
+
.spyOn(video_config, "checkMic")
|
|
756
|
+
.mockImplementation(jest.fn(() => Promise.resolve()));
|
|
757
|
+
|
|
758
|
+
await video_config["runStreamChecks"]();
|
|
759
|
+
|
|
760
|
+
expect(insertWebcamMock).toHaveBeenCalledTimes(1);
|
|
761
|
+
// Called twice: first for passing the stream access check, then for passing the mic input check.
|
|
762
|
+
expect(updateInstructionsMock).toHaveBeenCalledTimes(2);
|
|
763
|
+
expect(updateInstructionsMock.mock.calls[0]).toStrictEqual([1, true]);
|
|
764
|
+
expect(updateInstructionsMock.mock.calls[1]).toStrictEqual([3, true]);
|
|
765
|
+
// Called twice: first with "checking mic" message, then to clear that message.
|
|
766
|
+
expect(updateErrorsMock).toHaveBeenCalledTimes(2);
|
|
767
|
+
expect(updateErrorsMock.mock.calls[0]).toStrictEqual([
|
|
768
|
+
video_config["checking_mic_msg"],
|
|
769
|
+
]);
|
|
770
|
+
expect(updateErrorsMock.mock.calls[1]).toStrictEqual([""]);
|
|
771
|
+
expect(checkMicMock).toHaveBeenCalledTimes(1);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("Video config runStreamChecks enables next button when all checks have passed", async () => {
|
|
775
|
+
video_config["addHtmlContent"]("");
|
|
776
|
+
video_config["recorder"] = new Recorder(jsPsych);
|
|
777
|
+
|
|
778
|
+
// Setup upstream mocks.
|
|
779
|
+
Recorder.prototype.insertWebcamFeed = jest.fn();
|
|
780
|
+
video_config["updateInstructions"] = jest.fn();
|
|
781
|
+
video_config["updateErrors"] = jest.fn();
|
|
782
|
+
video_config["checkMic"] = jest.fn(() => Promise.resolve());
|
|
783
|
+
const enableNextMock = jest
|
|
784
|
+
.spyOn(video_config, "enableNext")
|
|
785
|
+
.mockImplementation(jest.fn());
|
|
786
|
+
|
|
787
|
+
video_config["hasReloaded"] = true;
|
|
788
|
+
|
|
789
|
+
await video_config["runStreamChecks"]();
|
|
790
|
+
|
|
791
|
+
expect(enableNextMock).toHaveBeenCalledTimes(1);
|
|
792
|
+
expect(enableNextMock).toHaveBeenCalledWith(true);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("Video config onDeviceChange event listener", async () => {
|
|
796
|
+
video_config["recorder"] = new Recorder(jsPsych);
|
|
797
|
+
// Mock the MediaDevices API
|
|
798
|
+
const mockMediaDevices = {
|
|
799
|
+
ondevicechange: null as ((this: MediaDevices, ev: Event) => void) | null,
|
|
800
|
+
};
|
|
801
|
+
global.navigator["mediaDevices"] = mockMediaDevices;
|
|
802
|
+
|
|
803
|
+
// The onDeviceChange event handler is set up in the plugin's trial method.
|
|
804
|
+
// Set up mocks for all other functions called in trial method.
|
|
805
|
+
video_config["addHtmlContent"] = jest.fn();
|
|
806
|
+
video_config["addEventListeners"] = jest.fn();
|
|
807
|
+
video_config["setupRecorder"] = jest.fn();
|
|
808
|
+
|
|
809
|
+
// Setup upstream mocks for functions called inside onDeviceChange.
|
|
810
|
+
const getDeviceListsMock = jest
|
|
811
|
+
.spyOn(video_config, "getDeviceLists")
|
|
812
|
+
.mockImplementation(
|
|
813
|
+
(
|
|
814
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
815
|
+
include_audio?: boolean | undefined,
|
|
816
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
817
|
+
include_camera?: boolean | undefined,
|
|
818
|
+
) => {
|
|
819
|
+
return Promise.resolve(returnedDeviceLists);
|
|
820
|
+
},
|
|
821
|
+
);
|
|
822
|
+
const updateDeviceSelectionMock = jest
|
|
823
|
+
.spyOn(video_config, "updateDeviceSelection")
|
|
824
|
+
.mockImplementation(jest.fn());
|
|
825
|
+
|
|
826
|
+
// Call trial method to set up onDeviceChange.
|
|
827
|
+
expect(navigator.mediaDevices.ondevicechange).toBeNull();
|
|
828
|
+
const trial_info = {
|
|
829
|
+
troubleshooting_intro: "",
|
|
830
|
+
} as unknown as VideoConsentTrialType;
|
|
831
|
+
video_config.trial(display_el, trial_info);
|
|
832
|
+
expect(navigator.mediaDevices.ondevicechange).not.toBeNull();
|
|
833
|
+
|
|
834
|
+
// Simulate a device change event.
|
|
835
|
+
const event = new Event("devicechange");
|
|
836
|
+
await navigator.mediaDevices.ondevicechange?.(event);
|
|
837
|
+
|
|
838
|
+
expect(getDeviceListsMock).toHaveBeenCalledTimes(1);
|
|
839
|
+
expect(updateDeviceSelectionMock).toHaveBeenCalledWith(returnedDeviceLists);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("Video config device selection element on change event listener", async () => {
|
|
843
|
+
video_config["addHtmlContent"]("");
|
|
844
|
+
|
|
845
|
+
// Mock upstream functions called by device selection change event listener.
|
|
846
|
+
const setDevicesMock = jest
|
|
847
|
+
.spyOn(video_config, "setDevices")
|
|
848
|
+
.mockImplementation(jest.fn(() => Promise.resolve()));
|
|
849
|
+
const runStreamChecksMock = jest
|
|
850
|
+
.spyOn(video_config, "runStreamChecks")
|
|
851
|
+
.mockImplementation(jest.fn(() => Promise.resolve()));
|
|
852
|
+
|
|
853
|
+
// Get device selection elements.
|
|
854
|
+
const device_selection_els = video_config["display_el"]?.querySelectorAll(
|
|
855
|
+
".lookit-jspsych-device-selection",
|
|
856
|
+
) as NodeListOf<HTMLSelectElement>;
|
|
857
|
+
|
|
858
|
+
video_config["addEventListeners"]();
|
|
859
|
+
|
|
860
|
+
expect(setDevicesMock.mock.calls).toHaveLength(0);
|
|
861
|
+
expect(runStreamChecksMock.mock.calls).toHaveLength(0);
|
|
862
|
+
|
|
863
|
+
// Simulate device selection change events.
|
|
864
|
+
for (let i = 0; i < device_selection_els.length; i++) {
|
|
865
|
+
const changeEvent = new Event("change", { bubbles: true });
|
|
866
|
+
await device_selection_els[i].dispatchEvent(changeEvent);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
expect(setDevicesMock.mock.calls).toHaveLength(device_selection_els.length);
|
|
870
|
+
expect(runStreamChecksMock.mock.calls).toHaveLength(
|
|
871
|
+
device_selection_els.length,
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("Video config next button click", async () => {
|
|
876
|
+
video_config["start_time"] = 1;
|
|
877
|
+
const perfNowMock = jest
|
|
878
|
+
.spyOn(performance, "now")
|
|
879
|
+
.mockImplementation(jest.fn().mockReturnValue(5));
|
|
880
|
+
const destroyMock = jest
|
|
881
|
+
.spyOn(video_config, "destroyRecorder")
|
|
882
|
+
.mockImplementation(jest.fn().mockReturnValue(Promise.resolve()));
|
|
883
|
+
const endMock = jest
|
|
884
|
+
.spyOn(video_config, "endTrial")
|
|
885
|
+
.mockImplementation(jest.fn());
|
|
886
|
+
|
|
887
|
+
await video_config["nextButtonClick"]();
|
|
888
|
+
|
|
889
|
+
expect(perfNowMock).toHaveBeenCalledTimes(1);
|
|
890
|
+
expect(video_config["response"]).toHaveProperty("rt");
|
|
891
|
+
expect(video_config["response"]["rt"]).toBe(4);
|
|
892
|
+
expect(destroyMock).toHaveBeenCalledTimes(1);
|
|
893
|
+
expect(endMock.mock.calls).toHaveLength(1);
|
|
894
|
+
|
|
895
|
+
video_config["start_time"] = null;
|
|
896
|
+
|
|
897
|
+
await video_config["nextButtonClick"]();
|
|
898
|
+
|
|
899
|
+
expect(video_config["response"]).toHaveProperty("rt");
|
|
900
|
+
expect(video_config["response"]["rt"]).toBeNull();
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("Video config destroyRecorder", () => {
|
|
904
|
+
const recorderStopTracksSpy = jest.spyOn(Recorder.prototype, "stopTracks");
|
|
905
|
+
video_config["recorder"] = new Recorder(jsPsych);
|
|
906
|
+
const enableNextMock = jest
|
|
907
|
+
.spyOn(video_config, "enableNext")
|
|
908
|
+
.mockImplementation(jest.fn());
|
|
909
|
+
const updateInstructionsMock = jest
|
|
910
|
+
.spyOn(video_config, "updateInstructions")
|
|
911
|
+
.mockImplementation(jest.fn());
|
|
912
|
+
|
|
913
|
+
expect(video_config["recorder"]).not.toBeNull();
|
|
914
|
+
|
|
915
|
+
video_config["destroyRecorder"]();
|
|
916
|
+
|
|
917
|
+
// Should stop the tracks, set the recorder to null, disable the next button,
|
|
918
|
+
// and update the instructions (checks 1/3 not passed).
|
|
919
|
+
expect(recorderStopTracksSpy).toHaveBeenCalledTimes(1);
|
|
920
|
+
expect(video_config["recorder"]).toBeNull();
|
|
921
|
+
expect(enableNextMock).toHaveBeenCalledTimes(1);
|
|
922
|
+
expect(enableNextMock).toHaveBeenCalledWith(false);
|
|
923
|
+
expect(updateInstructionsMock).toHaveBeenCalledTimes(2);
|
|
924
|
+
expect(updateInstructionsMock.mock.calls[0]).toStrictEqual([3, false]);
|
|
925
|
+
expect(updateInstructionsMock.mock.calls[1]).toStrictEqual([1, false]);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("Video config destroyRecorder with no recorder", () => {
|
|
929
|
+
const recorderStopTracksSpy = jest.spyOn(Recorder.prototype, "stopTracks");
|
|
930
|
+
const enableNextMock = jest
|
|
931
|
+
.spyOn(video_config, "enableNext")
|
|
932
|
+
.mockImplementation(jest.fn());
|
|
933
|
+
const updateInstructionsMock = jest
|
|
934
|
+
.spyOn(video_config, "updateInstructions")
|
|
935
|
+
.mockImplementation(jest.fn());
|
|
936
|
+
|
|
937
|
+
expect(video_config["recorder"]).toBeNull();
|
|
938
|
+
|
|
939
|
+
video_config["destroyRecorder"]();
|
|
940
|
+
|
|
941
|
+
expect(recorderStopTracksSpy).not.toHaveBeenCalled();
|
|
942
|
+
expect(video_config["recorder"]).toBeNull();
|
|
943
|
+
expect(enableNextMock).toHaveBeenCalledTimes(1);
|
|
944
|
+
expect(enableNextMock).toHaveBeenCalledWith(false);
|
|
945
|
+
expect(updateInstructionsMock).toHaveBeenCalledTimes(2);
|
|
946
|
+
expect(updateInstructionsMock.mock.calls[0]).toStrictEqual([3, false]);
|
|
947
|
+
expect(updateInstructionsMock.mock.calls[1]).toStrictEqual([1, false]);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test("Video config requestPermission", async () => {
|
|
951
|
+
const stream = { fake: "stream" } as unknown as MediaStream;
|
|
952
|
+
const mockGetUserMedia = jest.fn(
|
|
953
|
+
() =>
|
|
954
|
+
new Promise<MediaStream>((resolve) => {
|
|
955
|
+
resolve(stream);
|
|
956
|
+
}),
|
|
957
|
+
);
|
|
958
|
+
Object.defineProperty(global.navigator, "mediaDevices", {
|
|
959
|
+
writable: true,
|
|
960
|
+
value: {
|
|
961
|
+
getUserMedia: mockGetUserMedia,
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
const constraints = { video: true, audio: true };
|
|
965
|
+
|
|
966
|
+
const returnedStream = await video_config.requestPermission(constraints);
|
|
967
|
+
expect(returnedStream).toStrictEqual(stream);
|
|
968
|
+
expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(
|
|
969
|
+
constraints,
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("Video config getDeviceLists", async () => {
|
|
974
|
+
const mic1 = {
|
|
975
|
+
deviceId: "mic1",
|
|
976
|
+
kind: "audioinput",
|
|
977
|
+
label: "",
|
|
978
|
+
groupId: "default",
|
|
979
|
+
} as MediaDeviceInfo;
|
|
980
|
+
const cam1 = {
|
|
981
|
+
deviceId: "cam1",
|
|
982
|
+
kind: "videoinput",
|
|
983
|
+
label: "",
|
|
984
|
+
groupId: "default",
|
|
985
|
+
} as MediaDeviceInfo;
|
|
986
|
+
const mic2 = {
|
|
987
|
+
deviceId: "mic2",
|
|
988
|
+
kind: "audioinput",
|
|
989
|
+
label: "",
|
|
990
|
+
groupId: "other",
|
|
991
|
+
} as MediaDeviceInfo;
|
|
992
|
+
const cam2 = {
|
|
993
|
+
deviceId: "cam2",
|
|
994
|
+
kind: "videoinput",
|
|
995
|
+
label: "",
|
|
996
|
+
groupId: "other",
|
|
997
|
+
} as MediaDeviceInfo;
|
|
998
|
+
|
|
999
|
+
// Returns the mic/cam devices from navigator.mediaDevices.enumerateDevices as an object with 'cameras' and 'mics' (arrays of media device info objects).
|
|
1000
|
+
const devices = [mic1, mic2, cam1, cam2];
|
|
1001
|
+
Object.defineProperty(global.navigator, "mediaDevices", {
|
|
1002
|
+
writable: true,
|
|
1003
|
+
value: {
|
|
1004
|
+
enumerateDevices: jest.fn(
|
|
1005
|
+
() =>
|
|
1006
|
+
new Promise<MediaDeviceInfo[]>((resolve) => {
|
|
1007
|
+
resolve(devices);
|
|
1008
|
+
}),
|
|
1009
|
+
),
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
const returnedDevices = await video_config.getDeviceLists();
|
|
1014
|
+
expect(global.navigator.mediaDevices.enumerateDevices).toHaveBeenCalledTimes(
|
|
1015
|
+
1,
|
|
1016
|
+
);
|
|
1017
|
+
expect(returnedDevices).toHaveProperty("cameras");
|
|
1018
|
+
expect(returnedDevices).toHaveProperty("mics");
|
|
1019
|
+
expect(returnedDevices.cameras.sort()).toStrictEqual([cam1, cam2].sort());
|
|
1020
|
+
expect(returnedDevices.mics.sort()).toStrictEqual([mic1, mic2].sort());
|
|
1021
|
+
|
|
1022
|
+
// Removes duplicate devices and handles empty device categories.
|
|
1023
|
+
const devices_duplicate = [mic1, mic1, mic1];
|
|
1024
|
+
Object.defineProperty(global.navigator, "mediaDevices", {
|
|
1025
|
+
writable: true,
|
|
1026
|
+
value: {
|
|
1027
|
+
enumerateDevices: jest.fn(
|
|
1028
|
+
() =>
|
|
1029
|
+
new Promise<MediaDeviceInfo[]>((resolve) => {
|
|
1030
|
+
resolve(devices_duplicate);
|
|
1031
|
+
}),
|
|
1032
|
+
),
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const returnedDevicesDuplicates = await video_config.getDeviceLists();
|
|
1037
|
+
expect(returnedDevicesDuplicates.cameras).toStrictEqual([]);
|
|
1038
|
+
expect(returnedDevicesDuplicates.mics).toStrictEqual([mic1]);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("Video config onMicActivityLevel", () => {
|
|
1042
|
+
type micEventType = {
|
|
1043
|
+
currentActivityLevel: number;
|
|
1044
|
+
minVolume: number;
|
|
1045
|
+
resolve: () => void;
|
|
1046
|
+
};
|
|
1047
|
+
const event_fail = {
|
|
1048
|
+
currentActivityLevel: 0.0001,
|
|
1049
|
+
minVolume: video_config["minVolume"],
|
|
1050
|
+
resolve: jest.fn(),
|
|
1051
|
+
} as micEventType;
|
|
1052
|
+
|
|
1053
|
+
expect(video_config["micChecked"]).toBe(false);
|
|
1054
|
+
video_config["onMicActivityLevel"](
|
|
1055
|
+
event_fail.currentActivityLevel,
|
|
1056
|
+
event_fail.minVolume,
|
|
1057
|
+
event_fail.resolve,
|
|
1058
|
+
);
|
|
1059
|
+
expect(video_config["micChecked"]).toBe(false);
|
|
1060
|
+
expect(event_fail.resolve).not.toHaveBeenCalled();
|
|
1061
|
+
|
|
1062
|
+
const event_pass = {
|
|
1063
|
+
currentActivityLevel: 0.2,
|
|
1064
|
+
minVolume: video_config["minVolume"],
|
|
1065
|
+
resolve: jest.fn(),
|
|
1066
|
+
} as micEventType;
|
|
1067
|
+
|
|
1068
|
+
expect(video_config["micChecked"]).toBe(false);
|
|
1069
|
+
video_config["onMicActivityLevel"](
|
|
1070
|
+
event_pass.currentActivityLevel,
|
|
1071
|
+
event_pass.minVolume,
|
|
1072
|
+
event_pass.resolve,
|
|
1073
|
+
);
|
|
1074
|
+
expect(video_config["micChecked"]).toBe(true);
|
|
1075
|
+
expect(event_pass.resolve).toHaveBeenCalled();
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("Video config onMicActivityLevel", () => {
|
|
1079
|
+
type micEventType = {
|
|
1080
|
+
currentActivityLevel: number;
|
|
1081
|
+
minVolume: number;
|
|
1082
|
+
resolve: () => void;
|
|
1083
|
+
};
|
|
1084
|
+
const event_fail = {
|
|
1085
|
+
currentActivityLevel: 0.0001,
|
|
1086
|
+
minVolume: video_config["minVolume"],
|
|
1087
|
+
resolve: jest.fn(),
|
|
1088
|
+
} as micEventType;
|
|
1089
|
+
|
|
1090
|
+
expect(video_config["micChecked"]).toBe(false);
|
|
1091
|
+
video_config["onMicActivityLevel"](
|
|
1092
|
+
event_fail.currentActivityLevel,
|
|
1093
|
+
event_fail.minVolume,
|
|
1094
|
+
event_fail.resolve,
|
|
1095
|
+
);
|
|
1096
|
+
expect(video_config["micChecked"]).toBe(false);
|
|
1097
|
+
expect(event_fail.resolve).not.toHaveBeenCalled();
|
|
1098
|
+
|
|
1099
|
+
const event_pass = {
|
|
1100
|
+
currentActivityLevel: 0.2,
|
|
1101
|
+
minVolume: video_config["minVolume"],
|
|
1102
|
+
resolve: jest.fn(),
|
|
1103
|
+
} as micEventType;
|
|
1104
|
+
|
|
1105
|
+
expect(video_config["micChecked"]).toBe(false);
|
|
1106
|
+
video_config["onMicActivityLevel"](
|
|
1107
|
+
event_pass.currentActivityLevel,
|
|
1108
|
+
event_pass.minVolume,
|
|
1109
|
+
event_pass.resolve,
|
|
1110
|
+
);
|
|
1111
|
+
expect(video_config["micChecked"]).toBe(true);
|
|
1112
|
+
expect(event_pass.resolve).toHaveBeenCalled();
|
|
1113
|
+
});
|