riot-web/test/unit-tests/utils/SessionLock-test.ts

247 lines
9.1 KiB
TypeScript
Raw Normal View History

/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { checkSessionLockFree, getSessionLock, SESSION_LOCK_CONSTANTS } from "../../../src/utils/SessionLock";
import { resetJsDomAfterEach } from "../../test-utils";
describe("SessionLock", () => {
const otherWindows: Array<Window> = [];
beforeEach(() => {
jest.useFakeTimers({ now: 1000 });
});
afterEach(() => {
// shut down other windows created by `createWindow`
otherWindows.forEach((window) => window.close());
otherWindows.splice(0);
});
resetJsDomAfterEach();
it("A single instance starts up normally", async () => {
const onNewInstance = jest.fn();
const result = await getSessionLock(onNewInstance);
expect(result).toBe(true);
expect(onNewInstance).not.toHaveBeenCalled();
});
it("A second instance starts up normally when the first shut down cleanly", async () => {
// first instance starts...
const onNewInstance1 = jest.fn();
expect(await getSessionLock(onNewInstance1)).toBe(true);
expect(onNewInstance1).not.toHaveBeenCalled();
// ... and navigates away
window.dispatchEvent(new Event("pagehide", {}));
// second instance starts as normal
expect(checkSessionLockFree()).toBe(true);
const onNewInstance2 = jest.fn();
expect(await getSessionLock(onNewInstance2)).toBe(true);
expect(onNewInstance1).not.toHaveBeenCalled();
expect(onNewInstance2).not.toHaveBeenCalled();
});
it("A second instance starts up *eventually* when the first terminated uncleanly", async () => {
// first instance starts...
const onNewInstance1 = jest.fn();
expect(await getSessionLock(onNewInstance1)).toBe(true);
expect(onNewInstance1).not.toHaveBeenCalled();
expect(checkSessionLockFree()).toBe(false);
// and pings the timer after 5 seconds
jest.advanceTimersByTime(5000);
expect(checkSessionLockFree()).toBe(false);
// oops, now it dies. We simulate this by forcibly clearing the timers.
// For some reason `jest.clearAllTimers` also resets the simulated time, so preserve that
const time = Date.now();
jest.clearAllTimers();
jest.setSystemTime(time);
expect(checkSessionLockFree()).toBe(false);
// time advances a bit more
jest.advanceTimersByTime(5000);
expect(checkSessionLockFree()).toBe(false);
// second instance tries to start. This should block for 25 more seconds
const onNewInstance2 = jest.fn();
let session2Result: boolean | undefined;
getSessionLock(onNewInstance2).then((res) => {
session2Result = res;
});
// after another 24.5 seconds, we are still waiting
jest.advanceTimersByTime(24500);
expect(session2Result).toBe(undefined);
expect(checkSessionLockFree()).toBe(false);
// another 500ms and we get the lock
await jest.advanceTimersByTimeAsync(500);
expect(session2Result).toBe(true);
expect(checkSessionLockFree()).toBe(false); // still false, because the new session has claimed it
expect(onNewInstance1).not.toHaveBeenCalled();
expect(onNewInstance2).not.toHaveBeenCalled();
});
it("A second instance waits for the first to shut down", async () => {
// first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
await getSessionLock(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 2000, 0);
}),
);
// second instance tries to start, but should block
const { window: window2, getSessionLock: getSessionLock2 } = buildNewContext();
let session2Result: boolean | undefined;
getSessionLock2(async () => {}).then((res) => {
session2Result = res;
});
await jest.advanceTimersByTimeAsync(100);
// should still be blocking
expect(session2Result).toBe(undefined);
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(0);
// session 2 now gets the lock
expect(session2Result).toBe(true);
window2.close();
});
it("If a third instance starts while we are waiting, we give up immediately", async () => {
// first instance starts. It will never release the lock.
await getSessionLock(() => new Promise(() => {}));
// first instance should ping the timer after 5 seconds
jest.advanceTimersByTime(5000);
// second instance starts
const { getSessionLock: getSessionLock2 } = buildNewContext();
let session2Result: boolean | undefined;
const onNewInstance2 = jest.fn();
getSessionLock2(onNewInstance2).then((res) => {
session2Result = res;
});
await jest.advanceTimersByTimeAsync(100);
// should still be blocking
expect(session2Result).toBe(undefined);
// third instance starts
const { getSessionLock: getSessionLock3 } = buildNewContext();
getSessionLock3(async () => {});
await jest.advanceTimersByTimeAsync(0);
// session 2 should have given up
expect(session2Result).toBe(false);
expect(onNewInstance2).toHaveBeenCalled();
});
it("If two new instances start concurrently, only one wins", async () => {
// first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
await getSessionLock(async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 2000, 0);
});
});
// first instance should ping the timer after 5 seconds
jest.advanceTimersByTime(5000);
// two new instances start at once
const { getSessionLock: getSessionLock2 } = buildNewContext();
let session2Result: boolean | undefined;
getSessionLock2(async () => {}).then((res) => {
session2Result = res;
});
const { getSessionLock: getSessionLock3 } = buildNewContext();
let session3Result: boolean | undefined;
getSessionLock3(async () => {}).then((res) => {
session3Result = res;
});
await jest.advanceTimersByTimeAsync(100);
// session 3 still be blocking. Session 2 should have given up.
expect(session2Result).toBe(false);
expect(session3Result).toBe(undefined);
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(0);
// session 3 now gets the lock
expect(session2Result).toBe(false);
expect(session3Result).toBe(true);
});
/** build a new Window in the same domain as the current one.
*
* We do this by constructing an iframe, which gets its own Window object.
*/
function createWindow() {
const iframe = window.document.createElement("iframe");
window.document.body.appendChild(iframe);
const window2: any = iframe.contentWindow;
otherWindows.push(window2);
// make the new Window use the same jest fake timers as us
for (const m of ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"]) {
// @ts-ignore
window2[m] = global[m];
}
return window2;
}
/**
* Instantiate `getSessionLock` in a new context (ie, using a different global `window`).
*
* The new window will share the same fake timer impl as the current context.
*
* @returns the new window and (a wrapper for) getSessionLock in the new context.
*/
function buildNewContext(): {
window: Window;
getSessionLock: (onNewInstance: () => Promise<void>) => Promise<boolean>;
} {
const window2 = createWindow();
// import the dependencies of getSessionLock into the new context
// eslint-disable-next-line @typescript-eslint/no-require-imports
window2._uuid = require("uuid");
// eslint-disable-next-line @typescript-eslint/no-require-imports
window2._logger = require("matrix-js-sdk/src/logger");
window2.SESSION_LOCK_CONSTANTS = SESSION_LOCK_CONSTANTS;
// now, define getSessionLock as a global
window2.eval(String(getSessionLock));
// return a function that will call it
function callGetSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
// import the callback into the context
window2._getSessionLockCallback = onNewInstance;
// start the function
try {
return window2.eval(`getSessionLock(_getSessionLockCallback)`);
} finally {
// we can now clear the callback
delete window2._getSessionLockCallback;
}
}
return { window: window2, getSessionLock: callGetSessionLock };
}
});