@lookit/lookit-initjspsych
Version:
This package overloads jsPsych's init function.
710 lines (621 loc) • 20.2 kB
text/typescript
import Api from "@lookit/data";
import { Child, JsPsychExpData, Study } from "@lookit/data/dist/types";
import chsTemplates from "@lookit/templates";
import { DataCollection, JsPsych } from "jspsych";
import { NoJsPsychInstanceError } from "./errors";
import { 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: [{ filename: "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);
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("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: [{ filename: "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);
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("jsPsych's on_finish catches and logs errors while awaiting 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();
// Mock an error that originates from API requests
const error = new Error("API failed");
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 fn = on_finish(
jsPsychMock as unknown as JsPsych,
"response-uuid",
userFn,
);
// Should not throw — error is caught internally
await fn(data);
expect(displayElement.innerHTML).toBe(chsTemplates.loadingAnimation());
expect(userFn).toHaveBeenCalledTimes(1);
expect(userFn).toHaveBeenCalledWith(data);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error while finishing the experiment and saving data/video: ",
error,
);
});
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);
});