@lookit/lookit-initjspsych
Version:
This package overloads jsPsych's init function.
1,264 lines (1,102 loc) • 37.4 kB
text/typescript
import Api from "@lookit/data";
import {
Child,
JsPsychExpData,
Response as ApiResponseData,
Study,
} from "@lookit/data/dist/types";
import chsTemplates from "@lookit/templates";
import { DataCollection, JsPsych } from "jspsych";
import { NoJsPsychInstanceError } from "./errors";
import {
add_session_recording_data,
get_session_recording_data,
on_data_update,
on_finish,
} from "./utils";
delete global.window.location;
global.window = Object.create(window);
global.window.location = { replace: jest.fn(), origin: "http://localhost" };
// Even though we're not using Api.retrieveResponse in on_data_update/on_finish anymore, we still need to mock fetch because it is used to send the PATCH request.
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
/**
* Mock json method and returned data object.
*
* @returns Promise that resolves with a data attribute.
*/
json: () => Promise.resolve({ data: {} }),
} as Response),
);
let consoleLogSpy: jest.SpyInstance<
void,
[message?: unknown, ...optionalParams: unknown[]],
unknown
>;
let consoleWarnSpy: jest.SpyInstance<
void,
[message?: unknown, ...optionalParams: unknown[]],
unknown
>;
let consoleErrorSpy: jest.SpyInstance<
void,
[message?: unknown, ...optionalParams: unknown[]],
unknown
>;
beforeEach(() => {
jest.useRealTimers();
// Hide the console output during tests. Tests can still assert on these spies to check console calls.
consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
test("jsPsych's on_data_update with some exp_data", async () => {
// mock jsPsych data
const mockTrialData = [
{ trial_index: 0, trial_type: "test" },
{ trial_index: 1, trial_type: "survey" },
];
const jsPsychMock = {
data: {
/**
* Mocked jsPsych.data.get() function used in on_data_update
*
* @returns JsPsych data collection
*/
get: () => ({
/**
* Mocked jsPsych.data.get().values() function used in on_data_update
*
* @returns Values from jsPsych data collection
*/
values: () => mockTrialData,
}),
},
};
expect(jsPsychMock.data.get().values()).toEqual(mockTrialData);
// on_data_update receives the latest data addition as its argument
const data = { trial_index: 1, trial_type: "survey" } as JsPsychExpData;
const userFn = jest.fn();
global.Request = jest.fn();
expect(
await on_data_update(jsPsychMock as JsPsych, "some id", userFn)(data),
).toBeUndefined();
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
expect(Request).toHaveBeenCalledTimes(1);
});
test("jsPsych's on_data_update with no exp_data", async () => {
// mock jsPsych data
const mockTrialData = [] as JsPsychExpData[];
const jsPsychMock = {
data: {
/**
* Mocked jsPsych.data.get() function used in on_data_update
*
* @returns JsPsych data collection
*/
get: () => ({
/**
* Mocked jsPsych.data.get().values() function used in on_data_update
*
* @returns Values from jsPsych data collection
*/
values: () => mockTrialData,
}),
},
};
expect(jsPsychMock.data.get().values()).toEqual(mockTrialData);
const data = {} as JsPsychExpData;
const userFn = jest.fn();
global.Request = jest.fn();
expect(
await on_data_update(jsPsychMock as JsPsych, "some id", userFn)(data),
).toBeUndefined();
expect(userFn).toHaveBeenCalledTimes(1);
expect(Request).toHaveBeenCalledTimes(1);
});
test("on_data_update throws error if jsPsych instance is null", () => {
const jsPsychInstance: JsPsych | null = null;
const data = {} as JsPsychExpData;
const userFn = jest.fn();
global.Request = jest.fn();
expect(async () => {
await on_data_update(
jsPsychInstance as unknown as JsPsych,
"some id",
userFn,
)(data);
}).rejects.toThrow(NoJsPsychInstanceError);
});
test("on_data_update throws error if jsPsych instance is undefined", () => {
const jsPsychInstance: JsPsych | undefined = undefined;
const data = {} as JsPsychExpData;
const userFn = jest.fn();
global.Request = jest.fn();
expect(async () => {
await on_data_update(
jsPsychInstance as unknown as JsPsych,
"some id",
userFn,
)(data);
}).rejects.toThrow(NoJsPsychInstanceError);
});
test("jsPsych's on_finish", async () => {
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
expect(jsPsychMock.getDisplayElement().innerHTML).toBe("");
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
expect(
await on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).toBeUndefined();
expect(jsPsychMock.getDisplayElement).toHaveBeenCalledTimes(2); // once to check initial state, once to modify
expect(displayElement.innerHTML).toContain(chsTemplates.loadingAnimation());
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
expect(Request).toHaveBeenCalledTimes(1);
});
test("jsPsych's on_finish with successful pending uploads", async () => {
const successfulUpload = Promise.resolve("url");
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [{ file: "video1", promise: successfulUpload }],
},
});
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
expect(jsPsychMock.getDisplayElement().innerHTML).toBe("");
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
await expect(
on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).resolves.toBeUndefined();
expect(jsPsychMock.getDisplayElement).toHaveBeenCalledTimes(2); // once to check initial state, once to modify
expect(displayElement.innerHTML).toContain(chsTemplates.loadingAnimation());
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
expect(Request).toHaveBeenCalledTimes(1); // single request: no retries were needed
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://example.com/exit?child=hash-child-id&response=response-uuid",
);
// Nothing failed, so nothing should have been logged.
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
test("jsPsych's on_finish with a rejected pending upload", async () => {
const rejectedUpload = Promise.reject(new Error("Upload failed"));
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [{ file: "video1", promise: rejectedUpload }],
},
});
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
expect(jsPsychMock.getDisplayElement().innerHTML).toContain("");
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
// Upload promise rejections should not cause the on_finish function to throw
await expect(
on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).resolves.toBeUndefined();
expect(jsPsychMock.getDisplayElement).toHaveBeenCalledTimes(2); // once to check initial state, once to modify
expect(displayElement.innerHTML).toContain(chsTemplates.loadingAnimation());
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
// Response data update still succeeds on its own (single request, no retries
// needed) — a failed upload doesn't affect it.
expect(Request).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://example.com/exit?child=hash-child-id&response=response-uuid",
);
// The failed upload is logged (once, with no retry) rather than silently dropped.
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Pending upload failed for "video1": ',
new Error("Upload failed"),
);
});
test("jsPsych's on_finish with all pending uploads rejected", async () => {
const rejectedUpload1 = Promise.reject(new Error("Upload failed: video1"));
const rejectedUpload2 = Promise.reject(new Error("Upload failed: video2"));
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [
{ file: "video1", promise: rejectedUpload1 },
{ file: "video2", promise: rejectedUpload2 },
],
},
});
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
// All uploads failing still shouldn't cause on_finish to throw, or block
// the response data update / redirect.
await expect(
on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).resolves.toBeUndefined();
// Response data update still succeeds independently (single request).
expect(Request).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://example.com/exit?child=hash-child-id&response=response-uuid",
);
// Every failed upload is logged individually — none are retried.
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Pending upload failed for "video1": ',
new Error("Upload failed: video1"),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Pending upload failed for "video2": ',
new Error("Upload failed: video2"),
);
});
test("jsPsych's on_finish records upload outcomes in the trial data and writes them", async () => {
const successfulUpload = Promise.resolve();
const failedUpload = Promise.reject(new Error("upload boom"));
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data: JsPsychExpData[] = [
{
trial_index: 0,
trial_type: "start-record-plugin",
chs_recording: {
filename: "vid-A.webm",
is_session_recording: true,
stream_time: { trial_start_ms: 0 },
},
},
{
trial_index: 1,
trial_type: "html-keyboard-response",
chs_recording: {
filename: "vid-B.webm",
is_session_recording: false,
stream_time: null,
},
},
];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as unknown as DataCollection;
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [
{ file: "vid-A.webm", promise: successfulUpload, status: "success" },
{
file: "vid-B.webm",
promise: failedUpload,
status: "failure",
error_message: "upload boom",
},
],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "response-uuid")(data);
// Upload outcomes are written into the matching trials' recording data.
expect(exp_data[0].chs_recording?.upload_status).toBe("success");
expect(exp_data[0].chs_recording?.upload_error).toBeUndefined();
expect(exp_data[1].chs_recording?.upload_status).toBe("failure");
expect(exp_data[1].chs_recording?.upload_error).toBe("upload boom");
// Two writes: the initial data save, then a second write with the upload
// outcomes (which weren't known at the first save).
expect(Request).toHaveBeenCalledTimes(2);
});
test("jsPsych's on_finish records one recording's outcome on every trial sharing its filename (session recording)", async () => {
const successfulUpload = Promise.resolve();
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
// A single session recording spans multiple trials, so several trials share
// the same filename and all should be annotated from the one upload.
const exp_data: JsPsychExpData[] = [
{
trial_index: 0,
trial_type: "start-record-plugin",
chs_recording: {
filename: "session.webm",
is_session_recording: true,
stream_time: { trial_start_ms: 0 },
},
},
{
trial_index: 1,
trial_type: "html-keyboard-response",
chs_recording: {
filename: "session.webm",
is_session_recording: true,
stream_time: { trial_start_ms: 1200 },
},
},
{
trial_index: 2,
trial_type: "survey-html-form",
chs_recording: {
filename: "session.webm",
is_session_recording: true,
stream_time: { trial_start_ms: 3400 },
},
},
];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as unknown as DataCollection;
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [
{ file: "session.webm", promise: successfulUpload, status: "success" },
],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "response-uuid")(data);
// Every trial belonging to the session recording is annotated from the one
// upload record.
exp_data.forEach((trial) => {
expect(trial.chs_recording?.upload_status).toBe("success");
expect(trial.chs_recording?.upload_error).toBeUndefined();
});
// Still two writes: the initial data save, then a single second write
// covering all the annotated trials.
expect(Request).toHaveBeenCalledTimes(2);
});
test("jsPsych's on_finish warns and does not write a second time for an upload with no matching trial", async () => {
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
// Trial data has no recording, so the upload's filename won't match.
const exp_data: JsPsychExpData[] = [
{ trial_index: 0, trial_type: "html-keyboard-response" },
];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as unknown as DataCollection;
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [
{ file: "orphan.webm", promise: Promise.resolve(), status: "success" },
],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "response-uuid")(data);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'No trial data found for uploaded recording "orphan.webm"; its upload status was not recorded in the data.',
);
// No trial was annotated, so only the initial data save happens.
expect(Request).toHaveBeenCalledTimes(1);
});
test("jsPsych's on_finish retries the response update, then catches and logs errors after retries are exhausted", async () => {
jest.useFakeTimers();
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
expect(jsPsychMock.getDisplayElement().innerHTML).toBe("");
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
// Mock an error that originates from API requests
const error = new Error("API failed");
const updateResponseSpy = jest
.spyOn(Api, "updateResponse")
.mockRejectedValue(error);
jest.spyOn(Api, "finish").mockResolvedValue([]);
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
const fn = on_finish(
jsPsychMock as unknown as JsPsych,
"response-uuid",
userFn,
);
// Should not throw — error is caught internally after retries are exhausted
const finishPromise = fn(data);
await jest.runAllTimersAsync();
await finishPromise;
expect(displayElement.innerHTML).toBe(chsTemplates.loadingAnimation());
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
// Initial attempt + 3 retries = 4 total calls
expect(updateResponseSpy).toHaveBeenCalledTimes(4);
// Backoff waits of 1s, 2s, then 4s between attempts — ~7s total before
// giving up. This is the ceiling on how long the participant waits here.
const backoffDelays = setTimeoutSpy.mock.calls.map(([, delay]) => delay);
expect(backoffDelays).toEqual([1000, 2000, 4000]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error while saving final response data after retries: ",
error,
);
// Redirect still happens even though the response update ultimately failed,
// so the participant isn't stuck indefinitely. But because updateResponse
// never succeeded, the final exp_data and completed:true were never
// persisted — the response stays at whatever state the last successful
// on_data_update call left it in.
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
test("jsPsych's on_finish retries the response update twice, then succeeds", async () => {
jest.useFakeTimers();
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
const error = new Error("Transient API failure");
const updateResponseSpy = jest
.spyOn(Api, "updateResponse")
.mockRejectedValueOnce(error)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce({} as ApiResponseData);
jest.spyOn(Api, "finish").mockResolvedValue([]);
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
const fn = on_finish(
jsPsychMock as unknown as JsPsych,
"response-uuid",
userFn,
);
const finishPromise = fn(data);
await jest.runAllTimersAsync();
await finishPromise;
// 2 failures + 1 success = 3 total calls; the 3rd one persisted the data,
// so nothing is lost.
expect(updateResponseSpy).toHaveBeenCalledTimes(3);
// Only two backoff waits (before attempt 2 and attempt 3) — ~3s total
// before the participant sees the redirect.
const backoffDelays = setTimeoutSpy.mock.calls.map(([, delay]) => delay);
expect(backoffDelays).toEqual([1000, 2000]);
// Eventual success means nothing gets logged as an error — the retries
// were invisible to anyone not watching the network tab.
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://example.com/exit?child=hash-child-id&response=response-uuid",
);
jest.useRealTimers();
});
test("jsPsych's on_finish catches and logs errors thrown while waiting for pending uploads", async () => {
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
// Not an array, so `.map()` inside the try block throws synchronously —
// this is what exercises the outer catch (Promise.allSettled itself
// never rejects, so that's the only way into this catch block).
pendingUploads: {},
},
});
// Should not throw — error is caught internally
await expect(
on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).resolves.toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error while waiting for pending uploads: ",
expect.any(TypeError),
);
// Redirect still happens even though this failed.
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
});
test("jsPsych's on_finish with no recording or pending uploads", async () => {
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
expect(jsPsychMock.getDisplayElement().innerHTML).toBe("");
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
expect(
await on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).toBeUndefined();
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
expect(Request).toHaveBeenCalledTimes(1);
});
test("on_finish shows loading animation before uploads complete", async () => {
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
// pending upload that never settles
let uploadResolve!: () => void;
const pendingUpload = new Promise<void>((resolve) => {
uploadResolve = resolve;
});
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [{ promise: pendingUpload, filename: "video.webm" }],
},
});
const fn = on_finish(jsPsychMock as unknown as JsPsych, "response-uuid");
// Call on_finish but DO NOT await it so that we can inspect state before resolution
const finishPromise = fn(data);
expect(jsPsychMock.getDisplayElement).toHaveBeenCalled();
expect(displayElement.innerHTML).toBe(chsTemplates.loadingAnimation());
// Now allow uploads to complete so test can finish
uploadResolve();
await finishPromise;
});
test("jsPsych's on_finish with no pendingUploads property on window.chs", async () => {
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
},
});
// mock jsPsych getDisplayElement
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
expect(jsPsychMock.getDisplayElement().innerHTML).toBe("");
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
await expect(
on_finish(jsPsychMock as unknown as JsPsych, "some id", userFn)(data),
).resolves.toBeUndefined();
expect(jsPsychMock.getDisplayElement).toHaveBeenCalledTimes(2); // once to check initial state, once to modify
expect(displayElement.innerHTML).toContain(chsTemplates.loadingAnimation());
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
expect(Request).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledTimes(1);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://example.com/exit?child=hash-child-id&response=response-uuid",
);
});
test("on_finish appends child and response IDs to exit_url that already has query params", async () => {
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: {
attributes: { exit_url: "https://example.com/exit?existing=param" },
} as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "some id")(data);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://example.com/exit?existing=param&child=hash-child-id&response=response-uuid",
);
});
test("on_finish falls back to window.location.origin if the URL is invalid", async () => {
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "not a valid url" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "some id")(data);
expect(global.window.location.replace).toHaveBeenCalledWith(
"http://localhost/?child=hash-child-id&response=response-uuid",
);
});
test("on_finish handles exit URLs without the https prefix", async () => {
const displayElement = { innerHTML: "" };
const jsPsychMock = {
/**
* Mock for getDisplayElement
*
* @returns Object with an innerHTML property
*/
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
global.Request = jest.fn();
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "done.com" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "some id")(data);
expect(global.window.location.replace).toHaveBeenCalledWith(
"https://done.com/?child=hash-child-id&response=response-uuid",
);
});
test("on_finish throws error if jsPsych instance is null", () => {
const jsPsychInstance: JsPsych | null = null;
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
expect(async () => {
await on_finish(
jsPsychInstance as unknown as JsPsych,
"some id",
userFn,
)(data);
}).rejects.toThrow(NoJsPsychInstanceError);
});
test("on_finish throws error if jsPsych instance is undefined", () => {
const jsPsychInstance: JsPsych | undefined = undefined;
const exp_data = [{ key: "value" }];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as DataCollection;
const userFn = jest.fn();
global.Request = jest.fn();
expect(async () => {
await on_finish(
jsPsychInstance as unknown as JsPsych,
"some id",
userFn,
)(data);
}).rejects.toThrow(NoJsPsychInstanceError);
});
test("on_finish logs an error if saving the upload statuses (second write) fails", async () => {
const displayElement = { innerHTML: "" };
const jsPsychMock = {
getDisplayElement: jest.fn(() => displayElement),
};
const exp_data: JsPsychExpData[] = [
{
trial_index: 0,
trial_type: "html-keyboard-response",
chs_recording: {
filename: "vid.webm",
is_session_recording: false,
stream_time: null,
},
},
];
const data = {
/**
* Mocked jsPsych Data Collection.
*
* @returns Exp data.
*/
values: () => exp_data,
} as unknown as DataCollection;
// First (response data) write succeeds; second (upload statuses) write fails.
const updateResponseSpy = jest
.spyOn(Api, "updateResponse")
.mockResolvedValueOnce({} as ApiResponseData)
.mockRejectedValueOnce(new Error("second write failed"));
jest.spyOn(Api, "finish").mockResolvedValue([]);
Object.assign(window, {
chs: {
study: { attributes: { exit_url: "https://example.com/exit" } } as Study,
child: { id: "child-id" } as Child,
response: {
id: "response-uuid",
attributes: { hash_child_id: "hash-child-id" },
},
pastSessions: {} as Response[],
pendingUploads: [
{ file: "vid.webm", promise: Promise.resolve(), status: "success" },
],
},
});
await on_finish(jsPsychMock as unknown as JsPsych, "response-uuid")(data);
// Two writes attempted: the initial save and the upload-status write.
expect(updateResponseSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error while saving recording upload statuses: ",
new Error("second write failed"),
);
});
test("get_session_recording_data returns the session recorder's per-trial data when active", () => {
const recordingData = {
filename: "session.webm",
is_session_recording: true,
stream_time: { trial_start_ms: 100 },
};
const getSessionTrialRecordingData = jest.fn().mockReturnValue(recordingData);
Object.assign(window, {
chs: { sessionRecorder: { getSessionTrialRecordingData } },
});
expect(get_session_recording_data()).toBe(recordingData);
expect(getSessionTrialRecordingData).toHaveBeenCalledTimes(1);
});
test("get_session_recording_data returns null when no session recording is active", () => {
Object.assign(window, { chs: { sessionRecorder: null } });
expect(get_session_recording_data()).toBeNull();
});
test("add_session_recording_data attaches recording data to a trial", () => {
const data = {
trial_index: 0,
trial_type: "html-keyboard-response",
} as JsPsychExpData;
const recordingData = {
filename: "session.webm",
is_session_recording: true as const,
stream_time: null,
};
add_session_recording_data(data, recordingData);
expect(data.chs_recording).toBe(recordingData);
});
test("add_session_recording_data does nothing when there is no recording data", () => {
const data = {
trial_index: 0,
trial_type: "html-keyboard-response",
} as JsPsychExpData;
add_session_recording_data(data, null);
expect(data.chs_recording).toBeUndefined();
});
test("add_session_recording_data does not overwrite a trial's own recording data", () => {
const existing = {
filename: "own.webm",
is_session_recording: false as const,
stream_time: null,
};
const data = {
trial_index: 0,
trial_type: "start-record-plugin",
chs_recording: existing,
} as JsPsychExpData;
const sessionData = {
filename: "session.webm",
is_session_recording: true as const,
stream_time: null,
};
add_session_recording_data(data, sessionData);
// The trial's own block is kept.
expect(data.chs_recording).toBe(existing);
});