From 7ed59028629e0f0814cd3441d9611ec6db1f947b Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Jun 2026 11:33:44 +0200 Subject: [PATCH 1/3] chore: strip undefined values from eventData in logTestEvent --- frontend/__tests__/test/events/data.spec.ts | 14 ++++++++++++++ frontend/src/ts/test/events/data.ts | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index 48fe43d48423..87991b55682b 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -94,6 +94,20 @@ describe("data.ts", () => { expect(inputs).toHaveLength(1); }); + it("strips undefined values from eventData", () => { + logTestEvent("input", 1100, { + charIndex: 0, + wordIndex: 0, + inputType: "deleteWordBackward", + inputValue: "", + clearedWordIndex: undefined, + } as unknown as InputEventData); + + const stored = getAllTestEvents()[0]?.data as Record; + expect("clearedWordIndex" in stored).toBe(false); + expect(stored["inputValue"]).toBe(""); + }); + it("caches getAllTestEvents and invalidates on new event", () => { logTestEvent("timer", 1100, timerData("start", 0)); const first = getAllTestEvents(); diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 3f72860e9479..0bf8d7d1467a 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -47,6 +47,11 @@ export function logTestEvent( now = roundTo2(now); + //strip undefined values from eventData + eventData = Object.fromEntries( + Object.entries(eventData).filter(([_, v]) => v !== undefined), + ) as TestEventData; + if (type === "keydown") { const data = eventData as KeydownEventData; const code = data.code as Keycode | "NoCode"; From 357830061e686d98aa09b6dd2412124c369ae944 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Jun 2026 11:53:01 +0200 Subject: [PATCH 2/3] chore: round drift value --- frontend/src/ts/test/test-timer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 87ddf50fd0fe..01ff1a5c74c2 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -42,7 +42,7 @@ const newTimer = createTimer({ onLoop: () => { const now = performance.now(); - const drift = Math.abs(1000 - (now - lastLoop)); + const drift = Numbers.roundTo2(Math.abs(1000 - (now - lastLoop))); checkIfTimerIsSlow(drift); lastLoop = now; timerStep(); From d538f735c82fc9f759ec522dee6a93697a925811 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Jun 2026 12:04:50 +0200 Subject: [PATCH 3/3] chore: update ctrl backspace handling for firefox --- frontend/__tests__/test/events/data.spec.ts | 4 +- frontend/__tests__/test/events/stats.spec.ts | 105 +++++++++++++++++++ frontend/src/ts/input/handlers/delete.ts | 5 +- frontend/src/ts/test/events/stats.ts | 25 ++++- frontend/src/ts/test/events/types.ts | 5 + frontend/src/ts/test/test-logic.ts | 2 +- packages/contracts/src/results.ts | 2 +- 7 files changed, 140 insertions(+), 8 deletions(-) diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index 87991b55682b..987e054b53ab 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -100,11 +100,11 @@ describe("data.ts", () => { wordIndex: 0, inputType: "deleteWordBackward", inputValue: "", - clearedWordIndex: undefined, + clearedNextWord: undefined, } as unknown as InputEventData); const stored = getAllTestEvents()[0]?.data as Record; - expect("clearedWordIndex" in stored).toBe(false); + expect("clearedNextWord" in stored).toBe(false); expect(stored["inputValue"]).toBe(""); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index dbb3b1b20223..db225524e2b2 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -57,6 +57,7 @@ import { getKeypressDurations, getKeypressesPerSecond, getChars, + getInputHistory, getWpmHistory, forceReleaseAllKeys, __testing as statsTesting, @@ -511,6 +512,110 @@ describe("stats.ts", () => { }); }); + describe("getInputHistory", () => { + it("treats abandoned word as empty when Firefox Ctrl+Backspace ate the sentinel", () => { + // Firefox groups whitespace + non-word punctuation as one delete run. + // Sequence: type "=ri" at word 1, Ctrl+Backspace twice. The first delete + // leaves "=" (browser deletes "ri" only). The second deletes the + // sentinel + "=" together, which monkeytype interprets as crossing the + // word boundary → goToPreviousWord. Word 1 is abandoned with leftover + // "=" residue in its event stream; its final state should still be "". + TestWords.list.push("hello", "leave"); + + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ wordIndex: 0, data: "h", charIndex: 0 }), + ); + logTestEvent( + "input", + 1110, + input({ wordIndex: 0, data: "e", charIndex: 1 }), + ); + logTestEvent( + "input", + 1120, + input({ wordIndex: 0, data: "l", charIndex: 2 }), + ); + logTestEvent( + "input", + 1130, + input({ wordIndex: 0, data: "l", charIndex: 3 }), + ); + logTestEvent( + "input", + 1140, + input({ wordIndex: 0, data: "o", charIndex: 4 }), + ); + logTestEvent( + "input", + 1150, + input({ + wordIndex: 0, + data: " ", + charIndex: 5, + commitsWord: true, + }), + ); + + logTestEvent( + "input", + 1200, + input({ + wordIndex: 1, + data: "=", + correct: false, + charIndex: 0, + }), + ); + logTestEvent( + "input", + 1210, + input({ + wordIndex: 1, + data: "r", + correct: false, + charIndex: 1, + }), + ); + logTestEvent( + "input", + 1220, + input({ + wordIndex: 1, + data: "i", + correct: false, + charIndex: 2, + }), + ); + + // first Ctrl+Backspace: "=ri" → "=" + logTestEvent("input", 1300, { + wordIndex: 1, + charIndex: 3, + inputType: "deleteWordBackward", + inputValue: "=", + } as InputEventData); + + // second Ctrl+Backspace: Firefox ate sentinel + "=" → goToPreviousWord; + // clearedNextWord marks word 1 (= wordIndex + 1) as abandoned + logTestEvent("input", 1400, { + wordIndex: 0, + charIndex: 0, + inputType: "deleteWordBackward", + inputValue: "", + clearedNextWord: true, + } as InputEventData); + + logTestEvent("timer", 5000, timer("end", 4)); + + const history = getInputHistory(); + expect(history[0]).toBe(""); + expect(history[1]).toBe(""); + }); + }); + describe("getAccuracy", () => { it("calculates correct/incorrect/percentage", () => { logTestEvent("input", 1100, input()); diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index d177384e17cc..5bd4809e5322 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -63,7 +63,9 @@ export function onDelete(inputType: DeleteInputType, now: number): void { //normal backspace if (realInputValue === "") { - goToPreviousWord(inputType); + // if the input is NOT empty, that means the ctrl backspace deleted more than just the fake space (THANKS FIREFOX) + // which means we need to force update the current word element when we move back + goToPreviousWord(inputType, inputBeforeDelete !== ""); // Record the resulting state of the destination word const postNavInputValue = getInputElementValue().inputValue; @@ -72,6 +74,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void { wordIndex: activeWordIndex, charIndex: postNavInputValue.length, inputValue: postNavInputValue, + ...(inputBeforeDelete !== "" ? { clearedNextWord: true } : {}), }); } else { // Delete within current word diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 2e5bbe524fdf..98210c649ac3 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -348,9 +348,28 @@ export function getInputHistory(): string[] { const eventsPerWordIndex = getInputEventsPerWord(); const history: string[] = []; - for (const events of eventsPerWordIndex.values()) { - const simulatedInput = getInputFromDom(events); - history.push(simulatedInput); + for (const [wordIndex, events] of eventsPerWordIndex) { + const lastEvent = events[events.length - 1]; + if (lastEvent === undefined) { + history.push(""); + continue; + } + + // THANKS FIREFOX FOR THIS MESS + // A word is abandoned if the regression destination event — which + // lives in the previous word's bucket — carries clearedNextWord. + // Happens when Ctrl+Backspace eats the sentinel + non-word residue as + // one run; the residue stays as this word's last inputValue but its + // real final state is "". + const previousWord = eventsPerWordIndex.get(wordIndex - 1) ?? []; + const abandoned = previousWord.some( + (e) => + e.testMs > lastEvent.testMs && + "clearedNextWord" in e.data && + e.data.clearedNextWord === true, + ); + + history.push(abandoned ? "" : getInputFromDom(events)); } return history; diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 09571e45b78f..4ca1ab2f2bdf 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -98,6 +98,11 @@ export type InputEventData = }) | (BaseInputEventData & { inputType: DeleteInputType; + // true on the destination event of a regression that crossed back + // over a word with leftover content (e.g. Firefox Ctrl+Backspace + // eating sentinel + non-word residue). The cleared word is + // wordIndex + 1. + clearedNextWord?: true; }); export type CompositionTestEvent = EventProps< diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 576bc5f2104e..04f0ee3c40cb 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1341,7 +1341,7 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), - version: 22, + version: 23, data: { words: TestWords.words.list.join(" "), events: getAllTestEvents(), diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 45debaccbef9..9ffebc3ce439 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,7 +75,7 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), - version: z.literal(22), + version: z.literal(23), data: z.object({ words: z.string().max(10000), events: z.array(z.record(z.unknown())),