diff --git a/frontend/__tests__/utils/misc.spec.ts b/frontend/__tests__/utils/misc.spec.ts index 893282946..669371c46 100644 --- a/frontend/__tests__/utils/misc.spec.ts +++ b/frontend/__tests__/utils/misc.spec.ts @@ -1,5 +1,10 @@ -import { describe, it, expect } from "vitest"; -import { getErrorMessage, isObject, escapeHTML } from "../../src/ts/utils/misc"; +import { describe, it, expect, vi } from "vitest"; +import { + getErrorMessage, + isObject, + escapeHTML, + promiseWithResolvers, +} from "../../src/ts/utils/misc"; import { getLanguageDisplayString, removeLanguageSize, @@ -218,4 +223,231 @@ describe("misc.ts", () => { }); }); }); + + describe("promiseWithResolvers", () => { + it("should resolve the promise from outside", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + resolve(42); + + //THEN + await expect(promise).resolves.toBe(42); + }); + + it("should resolve new promise after reset using same promise reference", async () => { + const { promise, resolve, reset } = promiseWithResolvers(); + const firstPromise = promise; + + reset(); + + resolve(10); + + await expect(firstPromise).resolves.toBe(10); + expect(promise).toBe(firstPromise); + }); + + it("should reject the promise from outside", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const error = new Error("test error"); + + //WHEN + reject(error); + + //THEN + await expect(promise).rejects.toThrow("test error"); + }); + + it("should work with void type", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + resolve(); + + //THEN + await expect(promise).resolves.toBeUndefined(); + }); + + it("should allow multiple resolves (only first takes effect)", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + resolve(42); + resolve(100); // This should have no effect + + //THEN + await expect(promise).resolves.toBe(42); + }); + + it("should reset and create a new promise", async () => { + //GIVEN + const { promise, resolve, reset } = promiseWithResolvers(); + resolve(42); + + //WHEN + reset(); + resolve(100); + + //THEN + await expect(promise).resolves.toBe(100); + }); + + it("should keep the same promise reference after reset", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + const firstPromise = wrapper.promise; + wrapper.resolve(42); + await expect(firstPromise).resolves.toBe(42); + + //WHEN + wrapper.reset(); + const secondPromise = wrapper.promise; + wrapper.resolve(100); + + //THEN + expect(firstPromise).toBe(secondPromise); // Same reference + await expect(wrapper.promise).resolves.toBe(100); + }); + + it("should allow reject after reset", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + wrapper.resolve(42); + await wrapper.promise; + + //WHEN + wrapper.reset(); + const error = new Error("after reset"); + wrapper.reject(error); + + //THEN + await expect(wrapper.promise).rejects.toThrow("after reset"); + }); + + it("should work with complex types", async () => { + //GIVEN + type ComplexType = { id: number; data: string[] }; + const { promise, resolve } = promiseWithResolvers(); + const data: ComplexType = { id: 1, data: ["a", "b", "c"] }; + + //WHEN + resolve(data); + + //THEN + await expect(promise).resolves.toEqual(data); + }); + + it("should handle rejection with non-Error values", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + + //WHEN + reject("string error"); + + //THEN + await expect(promise).rejects.toBe("string error"); + }); + + it("should allow chaining with then/catch", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + const onFulfilled = vi.fn((value) => value * 2); + const chained = promise.then(onFulfilled); + + //WHEN + resolve(21); + + //THEN + await expect(chained).resolves.toBe(42); + expect(onFulfilled).toHaveBeenCalledWith(21); + }); + + it("should support async/await patterns", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + setTimeout(() => resolve("delayed"), 10); + + //THEN + const result = await promise; + expect(result).toBe("delayed"); + }); + + it("should resolve old promise reference after reset", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + const oldPromise = wrapper.promise; + + //WHEN + wrapper.reset(); + wrapper.resolve(42); + + //THEN + // Old promise reference should still resolve with the same value + await expect(oldPromise).resolves.toBe(42); + expect(oldPromise).toBe(wrapper.promise); + }); + + it("should handle catch", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const error = new Error("test error"); + + //WHEN + const caught = promise.catch(() => "recovered"); + reject(error); + + //THEN + await expect(caught).resolves.toBe("recovered"); + }); + + it("should call finally handler on resolution", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + const onFinally = vi.fn(); + + //WHEN + const final = promise.finally(onFinally); + resolve(42); + + //THEN + await expect(final).resolves.toBe(42); + expect(onFinally).toHaveBeenCalledOnce(); + }); + + it("should call finally handler on rejection", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const onFinally = vi.fn(); + const error = new Error("test error"); + + //WHEN + const final = promise.finally(onFinally); + reject(error); + + //THEN + await expect(final).rejects.toThrow("test error"); + expect(onFinally).toHaveBeenCalledOnce(); + }); + + it("should preserve rejection through finally", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const onFinally = vi.fn(); + const error = new Error("preserved error"); + + //WHEN + const final = promise.finally(onFinally); + reject(error); + + //THEN + await expect(final).rejects.toThrow("preserved error"); + expect(onFinally).toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index b7405613e..09894ad90 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -31,6 +31,7 @@ import * as FPSCounter from "../elements/fps-counter"; import { Command, CommandsSubgroup } from "./types"; import { buildCommandForConfigKey } from "./util"; import { CommandlineConfigMetadataObject } from "./commandline-metadata"; +import { isAuthAvailable, isAuthenticated, signOut } from "../firebase"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -366,6 +367,17 @@ export const commands: CommandsSubgroup = { window.open("https://discord.gg/monkeytype"); }, }, + { + id: "signOut", + display: "Sign out", + icon: "fa-sign-out-alt", + exec: (): void => { + void signOut(); + }, + available: () => { + return isAuthAvailable() && isAuthenticated(); + }, + }, ], }; diff --git a/frontend/src/ts/modals/register-captcha.ts b/frontend/src/ts/modals/register-captcha.ts index 55bf621c0..99b6e17c8 100644 --- a/frontend/src/ts/modals/register-captcha.ts +++ b/frontend/src/ts/modals/register-captcha.ts @@ -3,7 +3,11 @@ import AnimatedModal from "../utils/animated-modal"; import { promiseWithResolvers } from "../utils/misc"; import * as Notifications from "../elements/notifications"; -let { promise, resolve } = promiseWithResolvers(); +const { + promise, + resolve, + reset: resetPromise, +} = promiseWithResolvers(); export { promise }; @@ -20,7 +24,7 @@ export async function show(): Promise { await modal.show({ mode: "dialog", beforeAnimation: async (modal) => { - ({ promise, resolve } = promiseWithResolvers()); + resetPromise(); CaptchaController.reset("register"); CaptchaController.render( diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 2cebf3531..1b5079d9d 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -67,16 +67,18 @@ export function setIsDirectionReversed(val: boolean): void { isDirectionReversed = val; } -let { promise: testRestartingPromise, resolve: restartingResolve } = - promiseWithResolvers(); +const { + promise: testRestartingPromise, + resolve: restartingResolve, + reset: resetTestRestarting, +} = promiseWithResolvers(); export { testRestartingPromise }; export function setTestRestarting(val: boolean): void { testRestarting = val; if (val) { - ({ promise: testRestartingPromise, resolve: restartingResolve } = - promiseWithResolvers()); + resetTestRestarting(); } else { restartingResolve(); } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 0b58743ee..1c2d7ba3b 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1816,20 +1816,19 @@ export async function afterTestWordChange( // } else if (direction === "back") { if (Config.mode === "zen") { - const wordsChildren = [...(wordsEl.children ?? [])] as HTMLElement[]; - + // because we need to delete newline, beforenewline and afternewline elements which dont have wordindex attributes + // we need to do this loop thingy and delete all elements after the active word let deleteElements = false; - for (const child of wordsChildren) { - if ( - !deleteElements && - parseInt(child.getAttribute("data-wordindex") ?? "-1", 10) === - TestState.activeWordIndex - ) { - deleteElements = true; - continue; - } + for (const child of wordsEl.children) { if (deleteElements) { child.remove(); + continue; + } + const attr = child.getAttribute("data-wordindex"); + if (attr === null) continue; + const wordIndex = parseInt(attr, 10); + if (wordIndex === TestState.activeWordIndex) { + deleteElements = true; } } } diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 105bb4e44..6e6ea8fca 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -597,19 +597,71 @@ export function applyReducedMotion(animationTime: number): number { /** * Creates a promise with resolvers. * This is useful for creating a promise that can be resolved or rejected from outside the promise itself. + * The returned promise reference stays constant even after reset() - it will always await the current internal promise. + * Note: Promise chains created via .then()/.catch()/.finally() will always follow the current internal promise state, even if created before reset(). */ export function promiseWithResolvers(): { resolve: (value: T) => void; reject: (reason?: unknown) => void; promise: Promise; + reset: () => void; } { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; + let innerResolve!: (value: T) => void; + let innerReject!: (reason?: unknown) => void; + let currentPromise = new Promise((res, rej) => { + innerResolve = res; + innerReject = rej; }); - return { resolve, reject, promise }; + + /** + * This was fully AI generated to make the reset function work. Black magic, but its unit-tested and works. + */ + + const promiseLike = { + // oxlint-disable-next-line no-thenable promise-function-async require-await + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | null, + ): Promise { + return currentPromise.then(onfulfilled, onrejected); + }, + // oxlint-disable-next-line promise-function-async + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise { + return currentPromise.catch(onrejected); + }, + // oxlint-disable-next-line promise-function-async + finally(onfinally?: (() => void) | null): Promise { + return currentPromise.finally(onfinally); + }, + [Symbol.toStringTag]: "Promise" as const, + }; + + const reset = (): void => { + currentPromise = new Promise((res, rej) => { + innerResolve = res; + innerReject = rej; + }); + }; + + // Wrapper functions that always call the current resolver/rejecter + const resolve = (value: T): void => { + innerResolve(value); + }; + + const reject = (reason?: unknown): void => { + innerReject(reason); + }; + + return { + resolve, + reject, + promise: promiseLike as Promise, + reset, + }; } /**