diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4b02bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run test:unit diff --git a/README.md b/README.md index d399ef3..2582be2 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,20 @@ Theo, Julius and Maria. The agents take every typo literally. npm install npm run dev # http://0.0.0.0:5179 npm run build # static files in dist/, open dist/index.html from any file server -npm test # Playwright play test +npm run lint # fails on any code comment +npm run test:unit +npm test # unit tests, then the Playwright play test ``` +`npm test` starts its own Vite server on port 5180. To test a running server +instead, set `T3TYPE_URL`, for example `T3TYPE_URL=http://gibo:5179/ npm test`. + +## No code comments + +`npm run lint` rejects comments in TypeScript, JavaScript, CSS and HTML. If code +needs explaining, give it a clearer name or write a test. CI runs the lint on +every push and pull request. + ## How to play - The next prompt shows as gray ghost text in the composer. Type over it. Enter sends, typos included. @@ -29,6 +40,7 @@ Threads come from `content/threads.json`, imported unchanged by post. Everything else is parody and labeled as such. URL options for testing: `?speed=4` runs the agents 4x faster, `?seed=N` replays -the same thread draw, `?theme=light` or `?theme=dark` forces a theme. +the same thread draw, `?core=N` and `?guests=N` set how many threads come from +each core persona and from mutuals, `?theme=light` or `?theme=dark` forces a theme. See THIRD_PARTY.md for the T3 Code MIT notice. diff --git a/index.html b/index.html index 9cd9bec..062f834 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,6 @@ t3type "].join("\n"); + expect(findComments("x.html", source)).toEqual([1, 3]); + }); + + it("flags comments in inline styles", () => { + expect(findComments("x.html", [""].join("\n"))).toEqual([2]); + }); + + it("ignores an HTML comment marker inside an inline script string", () => { + expect(findComments("x.html", '')).toEqual([]); + }); + + it("skips files it does not lint", () => { + expect(findComments("x.json", '{ "a": "// b" }')).toEqual([]); + expect(findComments("README.md", "")).toEqual([]); + }); +}); diff --git a/tests/play.mjs b/tests/play.mjs index d9ca034..cf160ff 100644 --- a/tests/play.mjs +++ b/tests/play.mjs @@ -1,12 +1,3 @@ -// Plays a full t3type session in Chromium. -// -// node tests/play.mjs starts its own Vite dev server on port 5180 -// T3TYPE_URL=http://host:5179/ node tests/play.mjs uses a running server -// -// Run 1 (dark): types every prompt, with typos in some, switches threads with -// Tab while agents run, and plays until all threads are done. -// Run 2 (light): per-prompt WPM tests, time away from a thread does not count. -// Both runs fail on any console error. Screenshots go to tests/shots/. import { spawn } from "node:child_process"; import { mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -44,8 +35,7 @@ const check = (ok, message) => { console.log(`${ok ? "ok " : "FAIL"} ${message}`); }; -/** Swap two inner letters of the longest word, so the agent has a typo to take literally. */ -function withTypo(text) { +function swapInnerLettersOfLongestWord(text) { const words = text.split(" "); let best = -1; words.forEach((word, index) => { @@ -71,159 +61,155 @@ const state = (page) => page.evaluate(() => window.__game.state()); const activeOf = (s) => s.threads.find((t) => t.def.id === s.activeId); const running = (s) => s.threads.filter((t) => t.turns.length > 0 && !t.turns.at(-1).done).length; -const browser = await chromium.launch(); -try { - // Run 1: dark, full session until every thread is done. - { - const { page, errors } = await openPage(browser, "dark", "speed=2"); - await page.screenshot({ path: join(shots, "dark-start.png") }); - let sent = 0; - let typoPrompts = 0; - let switchesWhileRunning = 0; - let midShot = false; - const deadline = Date.now() + 180_000; - for (;;) { - if (Date.now() > deadline) throw new Error("session did not end in time"); - const s = await state(page); - if (s.session.status === "ended") break; - const thread = activeOf(s); - const busy = thread.turns.length > 0 && !thread.turns.at(-1).done; - const prompt = thread.def.prompts[thread.turns.length]; - if (!busy && prompt) { - const typo = sent % 3 === 1; - const text = typo ? withTypo(prompt.text) : prompt.text; - if (typo && text !== prompt.text) typoPrompts += 1; - // A backspace on the first prompt checks that corrections work. - if (sent === 0) { - await page.keyboard.type(text.slice(0, 5) + "x"); - await page.keyboard.press("Backspace"); - await page.keyboard.type(text.slice(5)); - } else { - await page.keyboard.type(text); - } - await page.keyboard.press("Enter"); - sent += 1; - const after = await state(page); - if (running(after) >= 2 && !midShot) { - await page.waitForTimeout(250); - await page.screenshot({ path: join(shots, "dark-running.png") }); - midShot = true; - } - // Switch away while this agent works, like running parallel agents. - await page.keyboard.press("Tab"); - const switched = await state(page); - const previous = switched.threads.find((t) => t.def.id === thread.def.id); - if (switched.activeId !== thread.def.id && !previous.turns.at(-1).done) switchesWhileRunning += 1; - continue; +async function playFullSessionInDark(browser) { + const { page, errors } = await openPage(browser, "dark", "speed=2"); + await page.screenshot({ path: join(shots, "dark-start.png") }); + let sent = 0; + let typoPrompts = 0; + let switchesWhileRunning = 0; + let midShot = false; + const deadline = Date.now() + 180_000; + for (;;) { + if (Date.now() > deadline) throw new Error("session did not end in time"); + const s = await state(page); + if (s.session.status === "ended") break; + const thread = activeOf(s); + const busy = thread.turns.length > 0 && !thread.turns.at(-1).done; + const prompt = thread.def.prompts[thread.turns.length]; + if (!busy && prompt) { + const typo = sent % 3 === 1; + const text = typo ? swapInnerLettersOfLongestWord(prompt.text) : prompt.text; + if (typo && text !== prompt.text) typoPrompts += 1; + const typeWithCorrection = sent === 0; + if (typeWithCorrection) { + await page.keyboard.type(text.slice(0, 5) + "x"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(text.slice(5)); + } else { + await page.keyboard.type(text); } - // Nothing to type here: Tab to a thread waiting on input, or wait for agents. - await page.keyboard.press("Tab"); - const next = await state(page); - if (next.activeId === s.activeId) await page.waitForTimeout(150); - } - const end = await state(page); - await page.waitForSelector("[data-end-summary]"); - await page.screenshot({ path: join(shots, "dark-end.png") }); - - const totalPrompts = end.threads.reduce((sum, t) => sum + t.def.prompts.length, 0); - check(end.session.endReason === "done", `dark run ends with every thread done (got ${end.session.endReason})`); - check(end.stats.promptsSent === totalPrompts, `sent all ${totalPrompts} prompts (got ${end.stats.promptsSent})`); - check(end.stats.typosLiteral >= typoPrompts, `typos taken literally: ${end.stats.typosLiteral} for ${typoPrompts} typo prompts`); - check(switchesWhileRunning >= 5, `switched threads while an agent ran ${switchesWhileRunning} times`); - check(end.stats.parallelism > 1, `parallelism above 1 (got ${end.stats.parallelism.toFixed(2)})`); - check(end.stats.accuracy < 1 && end.stats.accuracy > 0.9, `accuracy is plausible (${(end.stats.accuracy * 100).toFixed(1)}%)`); - check(end.stats.wpm > 0, `wpm is positive (${end.stats.wpm})`); - const literal = end.threads.flatMap((t) => t.turns).filter((turn) => turn.reply.literal); - const quotesTypo = literal.every((turn) => turn.diff.typos.length === 0 || turn.reply.text.includes(turn.diff.typos[0].typed)); - check(literal.length > 0 && quotesTypo, `${literal.length} literal replies quote the typo as typed`); - const summary = await page.locator("[data-summary]").allTextContents(); - check(summary.length === 7, `end screen shows ${summary.length} stat rows`); - check(errors.length === 0, `no console errors in dark run${errors.length ? `: ${errors.join(" | ")}` : ""}`); - - // The timeline shows a real prompt's source and the parody label. - await page.keyboard.press("Enter"); // play again - await page.waitForFunction(() => window.__game.state().session.status === "ready"); - check(true, "Enter on the end screen starts a new session"); - await page.close(); - } - - // Run 3: auto-settle. Finish one thread without pressing Tab. It should move - // to the Settled shelf and navigation should move forward on its own. - { - const { page, errors } = await openPage(browser, "dark", "speed=4"); - const s0 = await state(page); - const first = activeOf(s0); - for (const prompt of first.def.prompts) { - await page.waitForFunction((id) => { - const t = window.__game.state().threads.find((x) => x.def.id === id); - return t.turns.length === 0 || t.turns.at(-1).done; - }, first.def.id); - await page.keyboard.type(prompt.text); await page.keyboard.press("Enter"); + sent += 1; + const after = await state(page); + if (running(after) >= 2 && !midShot) { + await page.waitForTimeout(250); + await page.screenshot({ path: join(shots, "dark-running.png") }); + midShot = true; + } + await page.keyboard.press("Tab"); + const switched = await state(page); + const previous = switched.threads.find((t) => t.def.id === thread.def.id); + if (switched.activeId !== thread.def.id && !previous.turns.at(-1).done) switchesWhileRunning += 1; + continue; } - await page.waitForFunction((id) => window.__game.state().activeId !== id, first.def.id, { timeout: 15_000 }); - const s1 = await state(page); - const settled = s1.threads.find((t) => t.def.id === first.def.id); - check(settled.settledAt !== null, "finished thread auto-settles"); - check(s1.activeId !== first.def.id, `navigation moved forward on its own (to ${s1.activeId})`); - const header = await page.locator('[data-testid="sidebar-settled-header"]').textContent(); - check(/1/.test(header ?? ""), `Settled shelf shows 1 (got "${header?.trim()}")`); await page.keyboard.press("Tab"); - check((await state(page)).activeId !== first.def.id, "Tab never returns to a settled thread"); - await page.screenshot({ path: join(shots, "dark-settled.png") }); - check(errors.length === 0, `no console errors in settle run${errors.length ? `: ${errors.join(" | ")}` : ""}`); - await page.close(); + const next = await state(page); + if (next.activeId === s.activeId) await page.waitForTimeout(150); } + const end = await state(page); + await page.waitForSelector("[data-end-summary]"); + await page.screenshot({ path: join(shots, "dark-end.png") }); - // Run 2: light. Each prompt is its own WPM test: check the measured speed, - // that time spent in another thread does not count, and that nothing ends on a clock. - { - const { page, errors } = await openPage(browser, "light", "speed=4"); - const s0 = await state(page); - const firstId = s0.activeId; - const first = activeOf(s0).def.prompts[0].text; - // About 120 wpm: 100ms per key. Half the prompt, a 3s trip to another thread, then the rest. - const half = Math.floor(first.length / 2); - await page.keyboard.type(first.slice(0, half), { delay: 100 }); - await page.keyboard.press("Tab"); - check((await state(page)).activeId !== firstId, "Tab leaves the thread mid-prompt"); - await page.waitForTimeout(3000); - await page.evaluate((id) => window.__game.select(id), firstId); - await page.keyboard.type(first.slice(half), { delay: 100 }); - await page.keyboard.press("Enter"); - const s1 = await state(page); - const result = s1.threads.find((t) => t.def.id === firstId).turns[0].result; - check(result.wpm >= 90 && result.wpm <= 160, `per-prompt wpm ignores time away (${result.wpm} wpm, expected about 120)`); - check(result.accuracy === 1, `per-prompt accuracy is 100% for a clean prompt (${result.accuracy})`); - const shown = await page.locator("[data-prompt-result]").first().textContent(); - check(shown?.includes(`${result.wpm} wpm`), `user message shows its result ("${shown?.trim()}")`); - await page.keyboard.press("Tab"); - const s2 = await state(page); - const second = activeOf(s2).def.prompts[0].text; - await page.keyboard.type(second.slice(0, 30)); - await page.waitForTimeout(300); - await page.screenshot({ path: join(shots, "light-running.png") }); - await page.keyboard.type(second.slice(30)); + const totalPrompts = end.threads.reduce((sum, t) => sum + t.def.prompts.length, 0); + check(end.session.endReason === "done", `dark run ends with every thread done (got ${end.session.endReason})`); + check(end.stats.promptsSent === totalPrompts, `sent all ${totalPrompts} prompts (got ${end.stats.promptsSent})`); + check(end.stats.typosLiteral >= typoPrompts, `typos taken literally: ${end.stats.typosLiteral} for ${typoPrompts} typo prompts`); + check(switchesWhileRunning >= 5, `switched threads while an agent ran ${switchesWhileRunning} times`); + check(end.stats.parallelism > 1, `parallelism above 1 (got ${end.stats.parallelism.toFixed(2)})`); + check(end.stats.accuracy < 1 && end.stats.accuracy > 0.9, `accuracy is plausible (${(end.stats.accuracy * 100).toFixed(1)}%)`); + check(end.stats.wpm > 0, `wpm is positive (${end.stats.wpm})`); + const literal = end.threads.flatMap((t) => t.turns).filter((turn) => turn.reply.literal); + const quotesTypo = literal.every((turn) => turn.diff.typos.length === 0 || turn.reply.text.includes(turn.diff.typos[0].typed)); + check(literal.length > 0 && quotesTypo, `${literal.length} literal replies quote the typo as typed`); + const summary = await page.locator("[data-summary]").allTextContents(); + check(summary.length === 7, `end screen shows ${summary.length} stat rows`); + check(errors.length === 0, `no console errors in dark run${errors.length ? `: ${errors.join(" | ")}` : ""}`); + + await page.keyboard.press("Enter"); + await page.waitForFunction(() => window.__game.state().session.status === "ready"); + check(true, "Enter on the end screen starts a new session"); + await page.close(); +} + +async function checkAutoSettle(browser) { + const { page, errors } = await openPage(browser, "dark", "speed=4"); + const s0 = await state(page); + const first = activeOf(s0); + for (const prompt of first.def.prompts) { + await page.waitForFunction((id) => { + const t = window.__game.state().threads.find((x) => x.def.id === id); + return t.turns.length === 0 || t.turns.at(-1).done; + }, first.def.id); + await page.keyboard.type(prompt.text); await page.keyboard.press("Enter"); - // Back to the first thread once its agent is done, and check the reply. - await page.waitForFunction( - (id) => window.__game.state().threads.find((t) => t.def.id === id).turns[0]?.done === true, - firstId, - { timeout: 20000 }, - ); - await page.evaluate((id) => window.__game.select(id), firstId); - await page.waitForSelector("[data-assistant-message]"); - await page.screenshot({ path: join(shots, "light-reply.png") }); - const sources = await page.locator("[data-prompt-kind]").count(); - check(sources > 0, "prompt kind label shows under the user message"); - check((await page.locator("[data-parody-label]").count()) === 1, "parody label shows in the header"); - await page.waitForTimeout(4000); - const later = await state(page); - check(later.session.status === "playing", `no session clock: still playing after idling (${later.session.status})`); - check(later.stats.promptsSent === 2 && later.stats.bestWpm >= result.wpm, `stats track 2 prompts, best ${later.stats.bestWpm} wpm`); - check(errors.length === 0, `no console errors in light run${errors.length ? `: ${errors.join(" | ")}` : ""}`); - await page.close(); } + await page.waitForFunction((id) => window.__game.state().activeId !== id, first.def.id, { timeout: 15_000 }); + const s1 = await state(page); + const settled = s1.threads.find((t) => t.def.id === first.def.id); + check(settled.settledAt !== null, "finished thread auto-settles"); + check(s1.activeId !== first.def.id, `navigation moved forward on its own (to ${s1.activeId})`); + const header = await page.locator('[data-testid="sidebar-settled-header"]').textContent(); + check(/1/.test(header ?? ""), `Settled shelf shows 1 (got "${header?.trim()}")`); + await page.keyboard.press("Tab"); + check((await state(page)).activeId !== first.def.id, "Tab never returns to a settled thread"); + await page.screenshot({ path: join(shots, "dark-settled.png") }); + check(errors.length === 0, `no console errors in settle run${errors.length ? `: ${errors.join(" | ")}` : ""}`); + await page.close(); +} + +async function checkPerPromptWpmInLight(browser) { + const { page, errors } = await openPage(browser, "light", "speed=4"); + const s0 = await state(page); + const firstId = s0.activeId; + const first = activeOf(s0).def.prompts[0].text; + const msPerKeyAt120Wpm = 100; + const timeAwayMs = 3000; + const half = Math.floor(first.length / 2); + await page.keyboard.type(first.slice(0, half), { delay: msPerKeyAt120Wpm }); + await page.keyboard.press("Tab"); + check((await state(page)).activeId !== firstId, "Tab leaves the thread mid-prompt"); + await page.waitForTimeout(timeAwayMs); + await page.evaluate((id) => window.__game.select(id), firstId); + await page.keyboard.type(first.slice(half), { delay: msPerKeyAt120Wpm }); + await page.keyboard.press("Enter"); + const s1 = await state(page); + const result = s1.threads.find((t) => t.def.id === firstId).turns[0].result; + check(result.wpm >= 90 && result.wpm <= 160, `per-prompt wpm ignores time away (${result.wpm} wpm, expected about 120)`); + check(result.accuracy === 1, `per-prompt accuracy is 100% for a clean prompt (${result.accuracy})`); + const shown = await page.locator("[data-prompt-result]").first().textContent(); + check(shown?.includes(`${result.wpm} wpm`), `user message shows its result ("${shown?.trim()}")`); + await page.keyboard.press("Tab"); + const s2 = await state(page); + const second = activeOf(s2).def.prompts[0].text; + await page.keyboard.type(second.slice(0, 30)); + await page.waitForTimeout(300); + await page.screenshot({ path: join(shots, "light-running.png") }); + await page.keyboard.type(second.slice(30)); + await page.keyboard.press("Enter"); + await page.waitForFunction( + (id) => window.__game.state().threads.find((t) => t.def.id === id).turns[0]?.done === true, + firstId, + { timeout: 20000 }, + ); + await page.evaluate((id) => window.__game.select(id), firstId); + await page.waitForSelector("[data-assistant-message]"); + await page.screenshot({ path: join(shots, "light-reply.png") }); + const sources = await page.locator("[data-prompt-kind]").count(); + check(sources > 0, "prompt kind label shows under the user message"); + check((await page.locator("[data-parody-label]").count()) === 1, "parody label shows in the header"); + await page.waitForTimeout(4000); + const later = await state(page); + check(later.session.status === "playing", `no session clock: still playing after idling (${later.session.status})`); + check(later.stats.promptsSent === 2 && later.stats.bestWpm >= result.wpm, `stats track 2 prompts, best ${later.stats.bestWpm} wpm`); + check(errors.length === 0, `no console errors in light run${errors.length ? `: ${errors.join(" | ")}` : ""}`); + await page.close(); +} + +const browser = await chromium.launch(); +try { + await playFullSessionInDark(browser); + await checkAutoSettle(browser); + await checkPerPromptWpmInLight(browser); } finally { await browser.close(); server?.kill(); diff --git a/vite.config.ts b/vite.config.ts index ca4e3c6..d61c324 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,14 +6,12 @@ import { fileURLToPath } from "node:url"; const here = fileURLToPath(new URL(".", import.meta.url)); export default defineConfig({ - // Relative asset paths, so dist/ works from any static folder. base: "./", plugins: [react(), tailwindcss()], resolve: { alias: { "~": `${here}src` } }, server: { host: "0.0.0.0", port: 5179, - // Reachable by tailnet hostname (for example gibo:5179), not only localhost. allowedHosts: true, }, });