Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/AGENT_BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ for (let i = 0; i < 30 && !window.__mt; i++) await new Promise((r) => setTimeout
| `hideModal(id)` | hide a modal by `ModalId` |
| `restartTest()` | restart the typing test |
| `typeText(text, msPerChar?)` | type through the real input handlers; `"\n"` is a line break, `"\b"` a backspace. Paced with `msPerChar`, else all in one task |
| `compose(updates)` | IME composition through the real input handlers: each update is the composition so far (eg. `["k", "か"]`), the last one commits |
| `wordsSnapshot()` | rendered `#words` children as a flat array: word entries `{ index, classes, letters: [{ text, icon?, classes }] }` mixed with sibling entries `{ sibling: className }` (newlines) |
| `caretOffset()` | `{ dx, dy }` px from the caret to where it should be; a few px is on |
| `checkLayout()` | test page layout problems, `[]` when right: active word inside the words wrapper, caret and words input on it, 3 full lines (unless all lines show), tape anchor at the tape margin. Run it after any change to the test UI |
Expand Down
10 changes: 5 additions & 5 deletions docs/specs/solid-monkeytype/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Companion to `spec.md`. Every step is one small change, behaviour-preserving apa
Each step can start once the previous step's **bold** items are done. Everything listed on one step can run in parallel.

- [ ] 1. **H1–H5** housekeeping, then start all at once: **T0** (merge #8507), **C1**, **EP1**, **EP2**, **L1**, **L2**, plus any of U1, U5, C5, EP4, L4–L11.
- [ ] 2. **TH1** test-page harness and **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3.
- [ ] 2. **TH1** test-page harness, then **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3.
- [ ] 3. **T2** break joining. Alongside: EP3.
- [ ] 4. **T3** caret boundary. Then L3 and L12 can start.
- [ ] 5. **T4** word registry private.
Expand All @@ -26,7 +26,7 @@ Each step can start once the previous step's **bold** items are done. Everything
```
T: test page (CRITICAL PATH, serial)
T0 #8507 ─► T1 letter→span + data-ui-* ─► T2 break-joining ─► T3 caret boundary ─► T4 registry private ─► T5 delete test-ui ─► T6 engine split (T6a–c) ─► T7 drop page-mount workaround
TH1 test-page harness (with T0/T1, before T2) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5
TH1 test-page harness (after T0, before T1) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5
│
C: config C1 ─► C2×5, C3 areas (parallel) ──────────────────────────► C4 delete Config + configEvent (needs T6)
U: user data U1 ─► U2, U3, U4 (parallel); U5 anytime ─────────────────► U6 delete __nonReactive (needs T6a, T6c)
Expand All @@ -43,7 +43,7 @@ Every track can start now. These are the only steps that wait on another track:

| Step | Waits on | Why |
|---|---|---|
| **TH1** test-page harness | T0 (H1 first if it extends the app-shell harness) | Builds on Words owning its layout |
| **TH1** test-page harness | T0 | Builds on Words owning its layout |
| **T2** break joining | TH1 | The harness is the safety net from here on |
| **T5** delete test-ui | EP1, EP2, L1, L2 | test-ui still calls the timer event, focus and funbox; they need new homes first |
| **T6b** restart via flag | C1 | Restart reacts to config changes, so the store must update before the event fires |
Expand Down Expand Up @@ -74,7 +74,7 @@ Fully independent from start to finish: H, U1–U5, C1–C3, C5, EP1, EP2, EP4,

| Done | ID | Size | Step | Blocked by | Unblocks |
|---|---|---|---|---|---|
| [ ] | TH1 | L · PR | **Test-page harness**: mount the real TestPage and engine in jsdom, type through WordsInput (input/beforeinput/keydown/composition), advance frames, and assert rendered words/letters via `data-ui-*`, engine phase, live stats and the result. Extends the existing render-words or app-shell harness. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 (can pair with T1) | T2 onward: every later test-page step keeps it green |
| [x] | TH1 | L · PR | **Test-page harness** (`__tests__/__harness__/test-page.tsx`, own `test-page` vitest project): mounts the real TestPage and engine in jsdom on fake timers, types through WordsInput (input/beforeinput/keydown/composition, the driver `__mt` uses), advances frames, and reads word/letter states (semantic names, not classes), engine phase and live stats. Starter suite in `TestPage.page-spec.tsx`; specs on it are named `*.page-spec.tsx`. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 | T2 onward: every later test-page step keeps it green |

## Track T: test page (critical path)

Expand All @@ -83,7 +83,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec
| Done | ID | Size | Step | Blocked by | Removes / unblocks |
|---|---|---|---|---|---|
| [ ] | T0 | M · PR #8507 | Merge **#8507**: Words triggers its own layout (flush subscription, config/font/resize effects) | none | test-ui's layout calls |
| [ ] | T1 | L · PR | **`<letter>` → `<span data-ui-element="letter" data-ui-state>`**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. Benchmark letter rendering with the profiling harness | T0 | The made-up element; proves the theme attribute approach before the caret measures letters |
| [ ] | T1 | L · PR | **`<letter>` → `<span data-ui-element="letter" data-ui-state>`**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. `data-ui-state` values are the harness's state names; switch its word/letter helpers to `data-ui-*`, specs unchanged. Benchmark letter rendering with the profiling harness | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters |
| [ ] | T2 | M · PR | **Break joining** computed in the layout pass, rendered as reactive class/style on Word; delete the break-joining module's DOM writes | T1, TH1 | The only outside writes into Word elements |
| [ ] | T3 | L · PR | **Caret boundary**: Words is the only thing that drives the caret and pace caret; line-jump/tape-scroll handoff becomes internal to Words; pace-caret timing moves to a DOM-free module outputting a word/letter target; caret and pace caret get `data-ui-element`. Caret and words layout read the typed input from the render store (input writes it alongside the EventLog), not the engine's `getCurrentInput()`. Decide the caret internals here (reactive, imperative handle, or a mix) | T2 | Outside caret callers (test-ui, timer, focus, WordsInput) |
| [ ] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported | T3 | The public word-elements bridge |
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/solid-monkeytype/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Good tests check behaviour through the highest available seam: render, act like
Seams, highest first:

1. **App-shell harness** (existing): renders the real route tree with fake auth, a test query client and controllable data. Used for routing, guards, loading steps and page lifecycle. Prior art: the app-shell routing spec and the loading-page timing spec.
2. **Test-page harness** (new, the main seam for the test): mounts the *real* TestPage with the real engine, types through WordsInput (input/beforeinput/keydown/composition), advances frames, and checks rendered words/letters (via `data-ui-*`), engine phase, live stats, the finish → result/spinner flow and the restart flow. Built early on the critical path, before the risky steps; every later test-page step keeps it green. Prior art: the render-words harness and the Words, WordsInput and TypoHints specs.
2. **Test-page harness** (`frontend/__tests__/__harness__/test-page.tsx`, usage in its header; the main seam for the test): mounts the *real* TestPage with the real engine, types through WordsInput (input/beforeinput/keydown/composition), advances frames, and checks rendered words/letters (via `data-ui-*`), engine phase, live stats, the finish → result/spinner flow and the restart flow. Built early on the critical path, before the risky steps; every later test-page step keeps it green. Prior art: the render-words harness and the Words, WordsInput and TypoHints specs.
3. **Engine, headless**: commands in, signals out: restart merging and test settings, finish/fail transitions, calculating → result, save status, tags read at finish. No DOM.
4. **Pure modules** (existing pattern): letters/display rules, typo-hint merging, keymap converter, stats as functions of an EventLog, word generation, config setter validation, config metadata. Prior art: the letters/typo-hints specs, the config and config-metadata specs, the stats specs.
5. **Store/state modules**: user collection mutations and `getUser()`, config store apply/reconcile, words-render buffer flush. Assert through their public getters.
Expand Down
3 changes: 3 additions & 0 deletions frontend/__tests__/__harness__/mock-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ vi.mock("../../src/ts/utils/json-data", async (importOriginal) => {
return {
...original,
getLayout: async (name: string) => readJson(`/layouts/${name}.json`),
getLanguage: async (name: string) => readJson(`/languages/${name}.json`),
getCurrentLanguage: async (name: string) =>
readJson(`/languages/${name}.json`),
};
});
278 changes: 278 additions & 0 deletions frontend/__tests__/__harness__/test-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* Test-page harness: the real TestPage and test engine in jsdom, typed into
* through the words input like a browser does. The main seam for test page
* behaviour: render, type, advance frames, check what's on screen.
*
* Name specs using it `*.page-spec.tsx`: they run in the `test-page` vitest
* project, which has no global DOM query mocks (the engine's vanilla modules
* query the real DOM). Loaded in another project, it throws.
*
* ```tsx
* afterEach(async () => cleanupTestPage());
*
* it("marks a typo", async () => {
* const page = await renderTestPage({ words: ["the", "fox"] });
* await page.type("tx");
* expect(page.word(0).letters()).toEqual([
* { char: "t", state: "correct" },
* { char: "h", state: "incorrect" },
* { char: "e", state: "untyped" },
* ]);
* expect(page.phase()).toBe("active");
* });
* ```
*
* - `words`: the test shows exactly these words, in order (a custom test).
* Without it the words come from english.json (read from disk), random.
* `config` overrides the default config, eg. `{ mode: "time", time: 15 }`.
* - Fake timers (setTimeout, setInterval, Date, performance and animation
* frames) for the whole spec file, from before anything loads. Animejs is
* real: the test timer and fades run on its rAF loop, so they move with the
* fake clock. `frame()` runs one
* animation frame, `advance(ms)` moves the clock (live stats tick, timed
* tests end). `renderTestPage` resolves once the first test is ready.
* - `type(text)` types through the words input (keydown, beforeinput, input,
* keyup per char; "\b" is backspace, "\n" Enter), `compose(updates)` like
* an IME. Both end with a frame, so the words are rendered: pass
* `{ flush: false }` to check before it (input never renders synchronously).
* - Reading: `word(i).letters()` / `word(i).state`, `phase()`, `liveStats()`.
* Their states are semantic, not css classes, so specs survive markup
* changes.
* - Mocked: firebase (signed out, so a finished test isn't saved), the theme
* controller (indexedDB), the chart (canvas). Stubbed in jsdom: matchMedia
* (nothing matches), scrolling. The engine's debug logs are muted.
* - The page mounts in a router with only the test page, so it reads as the
* test page (typing starts a test only there) and the result's links work.
*/
// static json (layouts, languages) read from disk
import "./mock-static";
import type { Config as ConfigSchema } from "@monkeytype/schemas/configs";

import { cleanup, render } from "@solidjs/testing-library";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RegisteredRouter,
RouterProvider,
} from "@tanstack/solid-router";
import { vi } from "vitest";

import { TestPage } from "../../src/ts/components/pages/test/TestPage";
import { __testing as configTesting } from "../../src/ts/config/testing";
import { compose, typeText } from "../../src/ts/dev/typing-driver";
import { setRouter } from "../../src/ts/router/instance";
import {
currentLiveStats,
isTestActive,
isResultCalculating,
isTestFinished,
isTestRestarting,
} from "../../src/ts/states/test";
import * as CustomText from "../../src/ts/test/custom-text";

// fake timers, then jsdom gaps: matchMedia (ad controller, on import; no
// query matches) and scrolling (the test keeps itself in view)
vi.hoisted(() => {
// mock-dom's querySelector returns a detached div
if (document.querySelector("body") !== document.body) {
throw new Error(
"The test-page harness needs the real DOM: name the spec *.page-spec.tsx",
);
}
// before any import: animejs keeps the Date.now and requestAnimationFrame
// it finds on import. Kept for the whole spec file, so it never loses them
vi.useFakeTimers({
toFake: [
"setTimeout",
"clearTimeout",
"setInterval",
"clearInterval",
"Date",
"performance",
"requestAnimationFrame",
"cancelAnimationFrame",
],
});
Element.prototype.scrollIntoView = () => undefined;
window.scrollTo = () => undefined;
// the engine logs every event and word at debug level
console.debug = () => undefined;
window.matchMedia = (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
});
vi.mock("../../src/ts/firebase", () => ({
app: undefined,
Auth: undefined,
getAuthenticatedUser: () => null,
isAuthAvailable: () => false,
}));
// no canvas in jsdom; the result chart renders nothing
vi.mock("../../src/ts/components/common/ChartJs", () => ({
ChartJs: () => null,
}));
// opens indexedDB on import
vi.mock("../../src/ts/controllers/theme-controller", () => ({
randomizeTheme: vi.fn(),
}));

export type TestPhase = "ready" | "active" | "finished";

export type LetterState =
| "untyped"
| "correct"
| "incorrect"
| "extra"
| "dead"
| "deadCorrect";

export type WordState = "active" | "typed" | "error" | "upcoming";

export type RenderTestPageOptions = {
/** The test shows exactly these words, in order (a custom test). */
words?: string[];
config?: Partial<ConfigSchema>;
};

export type TestPageHarness = {
/** Types `text`; "\b" is backspace, "\n" Enter. Ends with a frame. */
type: (text: string, options?: { flush?: boolean }) => Promise<void>;
/** IME composition: each update is the composition so far, the last commits. */
compose: (updates: string[], options?: { flush?: boolean }) => Promise<void>;
/** Runs one animation frame. */
frame: () => Promise<void>;
/** Moves the clock by `ms`, running timers and frames on the way. */
advance: (ms: number) => Promise<void>;
word: (index: number) => {
letters: () => { char: string; state: LetterState }[];
state: WordState;
};
phase: () => TestPhase;
liveStats: () => {
wpm?: number;
raw?: number;
acc?: number;
seconds?: number;
};
};

function letterState(letter: Element): LetterState {
const has = (name: string): boolean => letter.classList.contains(name);
if (has("dead")) return has("correct") ? "deadCorrect" : "dead";
if (has("extra")) return "extra";
if (has("incorrect")) return "incorrect";
if (has("correct")) return "correct";
return "untyped";
}

function wordState(word: Element): WordState {
const has = (name: string): boolean => word.classList.contains(name);
if (has("active")) return "active";
if (has("error")) return "error";
if (has("typed")) return "typed";
return "upcoming";
}

async function frame(): Promise<void> {
vi.advanceTimersToNextFrame();
// let the frame's promise chains run
await vi.advanceTimersByTimeAsync(0);
}

/** Mounts the test page and resolves once its first test is ready. */
export async function renderTestPage(
options: RenderTestPageOptions = {},
): Promise<TestPageHarness> {
if (options.words !== undefined) {
// as the custom text modal's simple mode
CustomText.setMode("repeat");
CustomText.setPipeDelimiter(false);
CustomText.setText(options.words);
CustomText.setLimitMode("word");
CustomText.setLimitValue(options.words.length);
}
configTesting.replaceConfig({
...(options.words !== undefined ? { mode: "custom" } : {}),
...options.config,
});

const root = createRootRoute();
const router = createRouter({
routeTree: root.addChildren([
createRoute({
getParentRoute: () => root,
path: "/",
staticData: { page: "test" },
component: TestPage,
}),
]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
setRouter(router as unknown as RegisteredRouter);
await router.load();
const { container } = render(() => <RouterProvider router={router} />);

// the restart on mount: fade out, generate words, fade in
for (let i = 0; i < 50 && isTestRestarting(); i++) await frame();
if (isTestRestarting()) throw new Error("The test page never got ready");
await frame();

const wordElement = (index: number): Element => {
const el = container.querySelector(
`#words .word[data-wordindex="${index}"]`,
);
if (el === null) throw new Error(`Word ${index} is not rendered`);
return el;
};

return {
async type(text, { flush = true } = {}) {
await typeText(text);
if (flush) await frame();
},
async compose(updates, { flush = true } = {}) {
compose(updates);
if (flush) await frame();
},
frame,
async advance(ms) {
await vi.advanceTimersByTimeAsync(ms);
},
word: (index) => ({
letters: () =>
[...wordElement(index).querySelectorAll(":scope > letter")].map(
(letter) => ({
char: letter.textContent ?? "",
state: letterState(letter),
}),
),
get state() {
return wordState(wordElement(index));
},
}),
phase: () =>
isTestFinished() ? "finished" : isTestActive() ? "active" : "ready",
liveStats: () => ({
wpm: currentLiveStats.wpm,
raw: currentLiveStats.raw,
acc: currentLiveStats.acc,
seconds: currentLiveStats.seconds,
}),
};
}

/** Unmounts the page, ending its test. */
export async function cleanupTestPage(): Promise<void> {
// a finishing test can't be stopped: let it finish
for (let i = 0; i < 100 && isResultCalculating(); i++) await frame();
cleanup();
setRouter(undefined);
}
Loading
Loading