From 10f4a13c1fb7a8b44c73d7d6fed0fa645c2a69a9 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 13:55:08 +0100 Subject: [PATCH 01/12] chore: move ui code out of the test-logic file --- frontend/src/ts/test/result.ts | 6 +++++- frontend/src/ts/test/test-logic.ts | 6 ------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 5d352af7b..eb2a3d471 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1064,10 +1064,14 @@ export async function update( } else { $("main #result .stats").removeClass("hidden"); $("main #result .chart").removeClass("hidden"); - // $("main #result #resultWordsHistory").removeClass("hidden"); if (!isAuthenticated()) { $("main #result .loginTip").removeClass("hidden"); + $("main #result #rateQuoteButton").addClass("hidden"); + $("main #result #reportQuoteButton").addClass("hidden"); + } else { + $("main #result #reportQuoteButton").removeClass("hidden"); } + $("main #result .stats .dailyLeaderboard").addClass("hidden"); $("main #result #showWordHistoryButton").removeClass("hidden"); $("main #result #watchReplayButton").removeClass("hidden"); $("main #result #saveScreenshotButton").removeClass("hidden"); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 53fcdb878..018709275 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1134,17 +1134,11 @@ export async function finish(difficultyFailed = false): Promise { Result.updateTodayTracker(); if (!isAuthenticated()) { - $(".pageTest #result #rateQuoteButton").addClass("hidden"); - $(".pageTest #result #reportQuoteButton").addClass("hidden"); void AnalyticsController.log("testCompletedNoLogin"); if (!dontSave) notSignedInLastResult = completedEvent; dontSave = true; - } else { - $(".pageTest #result #reportQuoteButton").removeClass("hidden"); } - $("#result .stats .dailyLeaderboard").addClass("hidden"); - TestStats.setLastResult(structuredClone(completedEvent)); if (!ConnectionState.get()) { From 1f4e616d74653bd4edcdc746405baf28b4644fb9 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 14:00:12 +0100 Subject: [PATCH 02/12] refactor: move code around --- frontend/src/ts/test/result.ts | 6 ++++++ frontend/src/ts/test/test-logic.ts | 8 ++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index eb2a3d471..b32fe1c89 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -46,6 +46,7 @@ import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; import { z } from "zod"; import * as TestState from "./test-state"; import { blurInputElement } from "../input/input-element"; +import * as ConnectionState from "../states/connection"; let result: CompletedEvent; let maxChartVal: number; @@ -1006,6 +1007,11 @@ export async function update( } else { $("#result #watchVideoAdButton").removeClass("hidden"); } + + if (!ConnectionState.get()) { + ConnectionState.showOfflineBanner(); + } + updateWpmAndAcc(); updateConsistency(); updateTime(); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 018709275..1a41860b0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -982,6 +982,8 @@ export async function finish(difficultyFailed = false): Promise { const completedEvent = structuredClone(ce) as CompletedEvent; + TestStats.setLastResult(structuredClone(completedEvent)); + ///////// completed event ready //afk check @@ -1139,12 +1141,6 @@ export async function finish(difficultyFailed = false): Promise { dontSave = true; } - TestStats.setLastResult(structuredClone(completedEvent)); - - if (!ConnectionState.get()) { - ConnectionState.showOfflineBanner(); - } - await Result.update( completedEvent, difficultyFailed, From 2cba7576bd3ef37283fd01889c76122f865fa66a Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 14:02:49 +0100 Subject: [PATCH 03/12] chore: move ui code to result --- frontend/src/ts/test/result.ts | 25 ++++++++++++++++++++++++- frontend/src/ts/test/test-logic.ts | 28 ---------------------------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index b32fe1c89..377f93c39 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1083,8 +1083,31 @@ export async function update( $("main #result #saveScreenshotButton").removeClass("hidden"); } - TestConfig.hide(); + if (res.wpm === 0 && !difficultyFailed && res.testDuration >= 5) { + const roundedTime = Math.round(res.testDuration); + const messages = [ + `Congratulations. You just wasted ${roundedTime} seconds of your life by typing nothing. Be proud of yourself.`, + `Bravo! You've managed to waste ${roundedTime} seconds and accomplish exactly zero. A true productivity icon.`, + `That was ${roundedTime} seconds of absolutely legendary idleness. History will remember this moment.`, + `Wow, ${roundedTime} seconds of typing... nothing. Bold. Mysterious. Completely useless.`, + `Thank you for those ${roundedTime} seconds of utter nothingness. The keyboard needed the break.`, + `A breathtaking display of inactivity. ${roundedTime} seconds of absolutely nothing. Powerful.`, + `You just gave ${roundedTime} seconds of your life to the void. And the void says thanks.`, + `Stunning. ${roundedTime} seconds of intense... whatever that wasn't. Keep it up, champ.`, + `Is it performance art? A protest? Or just ${roundedTime} seconds of glorious nothing? We may never know.`, + `You typed nothing for ${roundedTime} seconds. And in that moment, you became legend.`, + ]; + + showConfetti(); + Notifications.add(Arrays.randomElementFromArray(messages), 0, { + customTitle: "Nice", + duration: 15, + important: true, + }); + } + + TestConfig.hide(); Focus.set(false); const canQuickRestart = canQuickRestartFn( diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 1a41860b0..ef72d5547 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1163,34 +1163,6 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.keyDuration = "toolong"; } - if ( - completedEvent.wpm === 0 && - !difficultyFailed && - completedEvent.testDuration >= 5 - ) { - const roundedTime = Math.round(completedEvent.testDuration); - - const messages = [ - `Congratulations. You just wasted ${roundedTime} seconds of your life by typing nothing. Be proud of yourself.`, - `Bravo! You've managed to waste ${roundedTime} seconds and accomplish exactly zero. A true productivity icon.`, - `That was ${roundedTime} seconds of absolutely legendary idleness. History will remember this moment.`, - `Wow, ${roundedTime} seconds of typing... nothing. Bold. Mysterious. Completely useless.`, - `Thank you for those ${roundedTime} seconds of utter nothingness. The keyboard needed the break.`, - `A breathtaking display of inactivity. ${roundedTime} seconds of absolutely nothing. Powerful.`, - `You just gave ${roundedTime} seconds of your life to the void. And the void says thanks.`, - `Stunning. ${roundedTime} seconds of intense... whatever that wasn't. Keep it up, champ.`, - `Is it performance art? A protest? Or just ${roundedTime} seconds of glorious nothing? We may never know.`, - `You typed nothing for ${roundedTime} seconds. And in that moment, you became legend.`, - ]; - - Result.showConfetti(); - Notifications.add(Arrays.randomElementFromArray(messages), 0, { - customTitle: "Nice", - duration: 15, - important: true, - }); - } - if (dontSave) { void AnalyticsController.log("testCompletedInvalid"); return; From 5f506281102e11a2e22a872ef6e38ea66b6a1114 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 14:45:07 +0100 Subject: [PATCH 04/12] chore: remove unnecessary code --- frontend/src/ts/test/test-logic.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index ef72d5547..3655a962e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1152,11 +1152,6 @@ export async function finish(difficultyFailed = false): Promise { dontSave, ); - if (completedEvent.chartData !== "toolong") { - // @ts-expect-error TODO: check if this is needed - delete completedEvent.chartData.unsmoothedRaw; - } - if (completedEvent.testDuration > 122) { completedEvent.chartData = "toolong"; completedEvent.keySpacing = "toolong"; From 4fae28c0f6ab1929cded251393fd75656c9712e7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 15:25:47 +0100 Subject: [PATCH 05/12] chore: move result code to result file --- frontend/src/ts/test/result.ts | 2 ++ frontend/src/ts/test/test-logic.ts | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 377f93c39..24905cad8 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -47,6 +47,7 @@ import { z } from "zod"; import * as TestState from "./test-state"; import { blurInputElement } from "../input/input-element"; import * as ConnectionState from "../states/connection"; +import { currentQuote } from "./test-words"; let result: CompletedEvent; let maxChartVal: number; @@ -1075,6 +1076,7 @@ export async function update( $("main #result #rateQuoteButton").addClass("hidden"); $("main #result #reportQuoteButton").addClass("hidden"); } else { + updateRateQuote(currentQuote); $("main #result #reportQuoteButton").removeClass("hidden"); } $("main #result .stats .dailyLeaderboard").addClass("hidden"); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 3655a962e..5a4191df4 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1175,8 +1175,6 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.uid = user.uid; - Result.updateRateQuote(TestWords.currentQuote); - if (!completedEvent.bailedOut) { const challenge = ChallengeContoller.verify(completedEvent); if (challenge !== null) completedEvent.challenge = challenge; From 11c2bff300faeba369efff3c4789bf48c9d0a5cd Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 15:27:42 +0100 Subject: [PATCH 06/12] chore: reorder --- frontend/src/ts/test/test-logic.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5a4191df4..e33bfc5ab 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1152,12 +1152,6 @@ export async function finish(difficultyFailed = false): Promise { dontSave, ); - if (completedEvent.testDuration > 122) { - completedEvent.chartData = "toolong"; - completedEvent.keySpacing = "toolong"; - completedEvent.keyDuration = "toolong"; - } - if (dontSave) { void AnalyticsController.log("testCompletedInvalid"); return; @@ -1175,6 +1169,12 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.uid = user.uid; + if (completedEvent.testDuration > 122) { + completedEvent.chartData = "toolong"; + completedEvent.keySpacing = "toolong"; + completedEvent.keyDuration = "toolong"; + } + if (!completedEvent.bailedOut) { const challenge = ChallengeContoller.verify(completedEvent); if (challenge !== null) completedEvent.challenge = challenge; From 58b6162b1145705f3853ceccdfd9791042ab277d Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 17:56:10 +0100 Subject: [PATCH 07/12] chore: missing setInvalid calls --- frontend/src/ts/test/test-logic.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e33bfc5ab..99b4f64e2 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1013,9 +1013,11 @@ export async function finish(difficultyFailed = false): Promise { dontSave = true; } else if (afkDetected) { Notifications.add("Test invalid - AFK detected", 0); + TestStats.setInvalid(); dontSave = true; } else if (TestState.isRepeated) { Notifications.add("Test invalid - repeated", 0); + TestStats.setInvalid(); dontSave = true; } else if ( completedEvent.testDuration < 1 || @@ -1037,6 +1039,7 @@ export async function finish(difficultyFailed = false): Promise { (Config.mode === "zen" && completedEvent.testDuration < 15) ) { Notifications.add("Test invalid - too short", 0); + TestStats.setInvalid(); tooShort = true; dontSave = true; } else if ( From d11bdaa710328dc52bab918a87ed30dd3f90c0ed Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 18:08:35 +0100 Subject: [PATCH 08/12] refactor: result saving code flow --- frontend/src/ts/test/test-logic.ts | 84 ++++++++++++++++-------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 99b4f64e2..28206df79 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1138,13 +1138,48 @@ export async function finish(difficultyFailed = false): Promise { ); Result.updateTodayTracker(); - if (!isAuthenticated()) { + let savingResultPromise: ReturnType = + Promise.resolve(null); + const user = getAuthenticatedUser(); + if (user !== null) { + // logged in + if (dontSave) { + void AnalyticsController.log("testCompletedInvalid"); + } else { + TestStats.resetIncomplete(); + + if (completedEvent.testDuration > 122) { + completedEvent.chartData = "toolong"; + completedEvent.keySpacing = "toolong"; + completedEvent.keyDuration = "toolong"; + } + + if (!completedEvent.bailedOut) { + const challenge = ChallengeContoller.verify(completedEvent); + if (challenge !== null) completedEvent.challenge = challenge; + } + + completedEvent.uid = user.uid; + completedEvent.hash = objectHash(completedEvent); + + savingResultPromise = saveResult(completedEvent, false); + void savingResultPromise.then((response) => { + if (response && response.status === 200) { + void AnalyticsController.log("testCompleted"); + } + }); + } + } else { + // logged out void AnalyticsController.log("testCompletedNoLogin"); - if (!dontSave) notSignedInLastResult = completedEvent; + if (!dontSave) { + // if its valid save it for later + notSignedInLastResult = completedEvent; + } dontSave = true; } - await Result.update( + const resultUpdatePromise = Result.update( completedEvent, difficultyFailed, failReason, @@ -1155,43 +1190,13 @@ export async function finish(difficultyFailed = false): Promise { dontSave, ); - if (dontSave) { - void AnalyticsController.log("testCompletedInvalid"); - return; - } - - // because of the dont save check above, we know the user is signed in - // we check here again so that typescript doesnt complain - const user = getAuthenticatedUser(); - if (!user) { - return; - } - - // user is logged in - TestStats.resetIncomplete(); - - completedEvent.uid = user.uid; - - if (completedEvent.testDuration > 122) { - completedEvent.chartData = "toolong"; - completedEvent.keySpacing = "toolong"; - completedEvent.keyDuration = "toolong"; - } - - if (!completedEvent.bailedOut) { - const challenge = ChallengeContoller.verify(completedEvent); - if (challenge !== null) completedEvent.challenge = challenge; - } - - completedEvent.hash = objectHash(completedEvent); - - await saveResult(completedEvent, false); + await Promise.all([savingResultPromise, resultUpdatePromise]); } async function saveResult( completedEvent: CompletedEvent, isRetrying: boolean, -): Promise { +): Promise>> { AccountButton.loading(true); if (!TestState.savingEnabled) { @@ -1201,7 +1206,7 @@ async function saveResult( important: true, }); AccountButton.loading(false); - return; + return null; } if (!ConnectionState.get()) { @@ -1216,7 +1221,7 @@ async function saveResult( if (!isRetrying) { retrySaving.completedEvent = completedEvent; } - return; + return null; } const response = await Ape.results.add({ body: { result: completedEvent } }); @@ -1244,7 +1249,7 @@ async function saveResult( "Looks like your result data is using an incorrect schema. Please refresh the page to download the new update. If the problem persists, please contact support."; } Notifications.add("Failed to save result", -1, { response }); - return; + return response; } const data = response.body.data; @@ -1285,8 +1290,6 @@ async function saveResult( dataToSave.result = result; } - void AnalyticsController.log("testCompleted"); - if (data.isPb !== undefined && data.isPb) { //new pb const localPb = await DB.getLocalPB( @@ -1335,6 +1338,7 @@ async function saveResult( Notifications.add("Result saved", 1, { important: true }); } DB.saveLocalResult(dataToSave); + return response; } export function fail(reason: string): void { From ef5ef0c4273e41b9cb6f8eaf46a53022f0fc97bb Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 21 Dec 2025 10:54:24 +0100 Subject: [PATCH 09/12] feat(commandline): add sign out (@fehmer) (#7283) --- frontend/src/ts/commandline/lists.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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(); + }, + }, ], }; From 16b4ec88af94cdc6656792372e02ce21bc734c29 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 21 Dec 2025 10:56:50 +0100 Subject: [PATCH 10/12] impr: add reset function to promiseWithResolvers (@miodec) (#7280) Actual AI slop but if it works ????? --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/__tests__/utils/misc.spec.ts | 236 ++++++++++++++++++++- frontend/src/ts/modals/register-captcha.ts | 8 +- frontend/src/ts/test/test-state.ts | 10 +- frontend/src/ts/utils/misc.ts | 64 +++++- 4 files changed, 304 insertions(+), 14 deletions(-) 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/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/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, + }; } /** From a1240d3d7e0e9f64667b72f86b6cbd9b76987241 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 21 Dec 2025 11:27:20 +0100 Subject: [PATCH 11/12] refactor: clean up zen mode element removal --- frontend/src/ts/test/test-ui.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index f7422084f..4398a2aff 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1814,20 +1814,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; } } } From fd177c9ed408891d6b2cc4f86c15a965527f3dfd Mon Sep 17 00:00:00 2001 From: Seif Soliman Date: Sun, 21 Dec 2025 12:50:10 +0200 Subject: [PATCH 12/12] chore: TypeError when deleting in zen (@byseif21) (#7282) * fix error from race condition `Cannot read properties of null (reading 'remove')` when deletion in zen mode, added null check. The active word could already be removed when the debounced update runs, which caused a null error --------- Co-authored-by: Jack --- frontend/src/ts/test/test-ui.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 4398a2aff..86dba54a5 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -133,18 +133,17 @@ export function updateActiveElement( let previousActiveWordTop: number | null = null; if (initial === undefined) { - const previousActiveWord = wordsEl.querySelector( - ".active", - ) as HTMLElement; - if (direction === "forward") { - previousActiveWord.classList.add("typed"); - } else if (direction === "back") { - if (Config.mode === "zen") { - previousActiveWord.remove(); + const previousActiveWord = wordsEl.querySelector(".active"); + // in zen mode, because of the animation frame, previousActiveWord will be removed at this point, so check for null + if (previousActiveWord !== null) { + if (direction === "forward") { + previousActiveWord.classList.add("typed"); + } else if (direction === "back") { + // } + previousActiveWord.classList.remove("active"); + previousActiveWordTop = previousActiveWord.offsetTop; } - previousActiveWord.classList.remove("active"); - previousActiveWordTop = previousActiveWord.offsetTop; } const newActiveWord = getActiveWordElement();