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,
},
});