Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The terminal boots headlessly: src/tty.ts takes its streams, and a session is under test #175
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/109-the-terminal-boots-headlessly-src-tty-ts → main 1 commit fork
5 files
changed +490
−43
README.md +18
| @@ -652,6 +652,24 @@ command: nothing, quit, or delete this match). Both are plain data in and out, | ||
| 652 | 652 | so their tests press keys and read lines with no terminal and no database — |
| 653 | 653 | with the escapes taken off by `plain()`, so an expectation is a sentence. |
| 654 | 654 | |
| 655 | The wiring is under test too. `run(io)` takes everything the session reaches | |
| 656 | for — an input that can go raw, an output that takes strings, a resize source, | |
| 657 | an exit, an open database and the environment — as one plain object, and the | |
| 658 | last lines of `src/tty.ts` are the only place `process` and `openDatabase()` | |
| 659 | are named, under `import.meta.main`. So `src/tty.test.ts` runs a whole session | |
| 660 | headlessly against objects it made up and a real SQLite database under a | |
| 661 | temporary directory: the first paint, <kbd>Tab</kbd> through all three tabs, | |
| 662 | <kbd>j</kbd>/<kbd>k</kbd> stopping at both ends, <kbd>r</kbd> picking up a row | |
| 663 | written behind its back, <kbd>x</kbd> deleting the match under the cursor, and | |
| 664 | the terminal handed back exactly once — on <kbd>q</kbd>, on <kbd>ctrl-C</kbd>, | |
| 665 | and when a draw throws, which ends the session with the tty restored and a line | |
| 666 | saying what happened rather than a raw shell. No real stdin is ever listened | |
| 667 | to. What a line of ANSI says is also an output to escape: every string the | |
| 668 | screen did not write itself — a name, a timestamp, the note, the environment | |
| 669 | pill, the ssh command — goes through `printable()` in `src/terminal/screen.ts`, | |
| 670 | which turns each control character into a middle dot, one for one so the | |
| 671 | columns still count. A player whose name carries `\x1b[2J` clears nobody's screen. | |
| 672 | ||
| 655 | 673 | The forge sets `HOST`, `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` |
| 656 | 674 | or `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH`, `FORGE_APP_COMMIT` and |
| 657 | 675 | `FORGE_APP_IDENTITY_KEY` when it starts the app. Nothing else is read from the |
src/terminal/screen.test.ts +14 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import type { Card } from "../client/weights.ts"; |
| 10 | 10 | import type { MatchRow, Standing } from "../db.ts"; |
| 11 | import { clamp, plain, rowsOf, screen, type Data, type View } from "./screen.ts"; | |
| 11 | import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts"; | |
| 12 | 12 | |
| 13 | 13 | const CARD: Card = { |
| 14 | 14 | trainedAt: "2026-09-14", |
| @@ -161,6 +161,19 @@ test("the cursor never draws past the end of the list, and the policy card has n | ||
| 161 | 161 | expect(plain(lines[6]!)).toStartWith("▸ bo"); |
| 162 | 162 | }); |
| 163 | 163 | |
| 164 | test("a name out of the database is text, not a program: every control character is a dot", () => { | |
| 165 | expect(printable("evil\x1b[2J")).toBe("evil·[2J"); | |
| 166 | expect(printable("two\r\nlines")).toBe("two··lines"); | |
| 167 | expect(printable("eight bit \x9b2J")).toBe("eight bit ·2J"); | |
| 168 | expect(printable("plain kestrel")).toBe("plain kestrel"); | |
| 169 | // One character in, one out, so a padded column is still the width it says. | |
| 170 | expect(printable("\x1b[2J\x1b[H").length).toBe("\x1b[2J\x1b[H".length); | |
| 171 | // And the row it lands in carries no escape of its own. | |
| 172 | const row = screen(view(), data({ standings: [standing("\x1b[2Jace")] }))[5]!; | |
| 173 | expect(row).not.toContain("\x1b[2J"); | |
| 174 | expect(plain(row)).toStartWith("▸ ·[2Jace"); | |
| 175 | }); | |
| 176 | ||
| 164 | 177 | test("plain() takes off exactly the escapes and leaves the words", () => { |
| 165 | 178 | expect(plain("\x1b[1mcs\x1b[0m \x1b[2mlocal\x1b[0m")).toBe("cs local"); |
| 166 | 179 | expect(plain("\x1b[33mnothing removed\x1b[0m")).toBe("nothing removed"); |
src/terminal/screen.ts +28 −7
| @@ -6,6 +6,11 @@ | ||
| 6 | 6 | * does all three around it, and a test calls `screen()` with neither. |
| 7 | 7 | * |
| 8 | 8 | * Column widths are the ones a person already knows; do not move them. |
| 9 | * | |
| 10 | * Everything that came from outside this file — a name, a timestamp, the note, | |
| 11 | * the environment pill and the ssh command — goes through `printable()` on the | |
| 12 | * way into a line, because a line of ANSI is a program: a name carrying an | |
| 13 | * escape would otherwise clear the screen or move the cursor out of its row. | |
| 9 | 14 | */ |
| 10 | 15 | |
| 11 | 16 | import type { Card } from "../client/weights.ts"; |
| @@ -56,11 +61,25 @@ export const clamp = (cursor: number, rows: number): number => | ||
| 56 | 61 | */ |
| 57 | 62 | export const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); |
| 58 | 63 | |
| 64 | /** | |
| 65 | * Text that came from somewhere else — a player's name, a timestamp, the note | |
| 66 | * under the table — made safe to put inside a line of ANSI. Every control | |
| 67 | * character becomes a middle dot: the escape above all, but the 8-bit CSI, a | |
| 68 | * carriage return and a newline too, because each of them is also a way to | |
| 69 | * move the cursor out of the row it was given. One character in, one character | |
| 70 | * out, so the columns below still count what they always counted. | |
| 71 | * | |
| 72 | * A name is checked on the way in (`cleanName` in `src/result.ts`), and it is | |
| 73 | * checked again here on the way out: the screen does not trust the database to | |
| 74 | * hold only what today's boundary would have let through. | |
| 75 | */ | |
| 76 | export const printable = (s: string): string => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "·"); | |
| 77 | ||
| 59 | 78 | const header = (tab: Tab): string => |
| 60 | 79 | TABS.map((t) => (t === tab ? `${esc}7m ${t} ${esc}0m` : `${esc}2m ${t} ${esc}0m`)).join(""); |
| 61 | 80 | |
| 62 | 81 | function policyLines(card: Card): string[] { |
| 63 | const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${v}`; | |
| 82 | const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${printable(v)}`; | |
| 64 | 83 | return [ |
| 65 | 84 | ` ${esc}1mthe policy the bots fight with${esc}0m`, |
| 66 | 85 | "", |
| @@ -82,7 +101,7 @@ function standingsLines(table: Standing[], cursor: number): string[] { | ||
| 82 | 101 | ]; |
| 83 | 102 | if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`); |
| 84 | 103 | table.forEach((s, i) => { |
| 85 | const line = `${pad(displayName(s), 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` + | |
| 104 | const line = `${pad(printable(displayName(s)), 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` + | |
| 86 | 105 | `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`; |
| 87 | 106 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 88 | 107 | }); |
| @@ -95,8 +114,8 @@ function matchLines(matches: MatchRow[], cursor: number): string[] { | ||
| 95 | 114 | ]; |
| 96 | 115 | if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); |
| 97 | 116 | matches.forEach((m, i) => { |
| 98 | const line = `${pad(displayName(m), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + | |
| 99 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` + | |
| 117 | const line = `${pad(printable(displayName(m)), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + | |
| 118 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${printable(m.updated_at)}` + | |
| 100 | 119 | `${m.finished === 1 ? "" : " (live)"}`; |
| 101 | 120 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 102 | 121 | }); |
| @@ -107,7 +126,9 @@ function matchLines(matches: MatchRow[], cursor: number): string[] { | ||
| 107 | 126 | export function screen(view: View, data: Data): string[] { |
| 108 | 127 | const cursor = clamp(view.cursor, rowsOf(view.tab, data)); |
| 109 | 128 | const lines: string[] = []; |
| 110 | lines.push(`${esc}1mcs${esc}0m ${esc}2m${view.where} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`); | |
| 129 | lines.push( | |
| 130 | `${esc}1mcs${esc}0m ${esc}2m${printable(view.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`, | |
| 131 | ); | |
| 111 | 132 | lines.push(""); |
| 112 | 133 | lines.push(header(view.tab)); |
| 113 | 134 | lines.push(""); |
| @@ -118,7 +139,7 @@ export function screen(view: View, data: Data): string[] { | ||
| 118 | 139 | lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`); |
| 119 | 140 | lines.push(view.ssh === undefined |
| 120 | 141 | ? `${esc}2mthe app's terminal is off${esc}0m` |
| 121 | : `${esc}2mreach this terminal with: ${view.ssh}${esc}0m`); | |
| 122 | if (view.note !== "") lines.push(`${esc}33m${view.note}${esc}0m`); | |
| 142 | : `${esc}2mreach this terminal with: ${printable(view.ssh)}${esc}0m`); | |
| 143 | if (view.note !== "") lines.push(`${esc}33m${printable(view.note)}${esc}0m`); | |
| 123 | 144 | return lines; |
| 124 | 145 | } |
src/tty.test.ts added +310
| @@ -0,0 +1,310 @@ | ||
| 1 | /** | |
| 2 | * A whole ssh session, run headlessly. `run(io)` takes its terminal as plain | |
| 3 | * data, so this file hands it a keyboard nobody is typing on, a screen that is | |
| 4 | * an array of strings, an exit that records its code and a real SQLite | |
| 5 | * database under a temporary directory — the queries are the part worth | |
| 6 | * exercising, so nothing about the database is faked. | |
| 7 | * | |
| 8 | * Nothing here touches the real process: `src/tty.ts` names `process` only | |
| 9 | * under `import.meta.main`, so importing it registers no listener on the real | |
| 10 | * stdin, sets no raw mode and exits nothing. | |
| 11 | * | |
| 12 | * The frames are read with `plain()` off `screen.ts`, so an expectation is a | |
| 13 | * sentence. A frame is one `write`: the clear, then the lines joined with CRLF. | |
| 14 | */ | |
| 15 | ||
| 16 | import { expect, test } from "bun:test"; | |
| 17 | import type { Database } from "bun:sqlite"; | |
| 18 | import { mkdtempSync } from "node:fs"; | |
| 19 | import { tmpdir } from "node:os"; | |
| 20 | import { join } from "node:path"; | |
| 21 | import { matchCount, openDatabase, saveMatch, type Player } from "./db.ts"; | |
| 22 | import type { Result } from "./result.ts"; | |
| 23 | import { plain } from "./terminal/screen.ts"; | |
| 24 | import { run, type Io } from "./tty.ts"; | |
| 25 | ||
| 26 | /** What `src/tty.ts` writes to wipe the screen, at the top of every frame. */ | |
| 27 | const CLEAR = "\x1b[2J\x1b[H"; | |
| 28 | ||
| 29 | const result = (over: Partial<Result> = {}): Result => ({ | |
| 30 | matchId: crypto.randomUUID(), | |
| 31 | name: "the player says the name", | |
| 32 | rounds: 5, scoreMine: 3, scoreTheirs: 2, | |
| 33 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, | |
| 34 | ...over, | |
| 35 | }); | |
| 36 | ||
| 37 | const player = (name: string): Player => ({ userId: `u-${name}`, session: "s", name }); | |
| 38 | ||
| 39 | /** One recorded match for a signed-in player, straight into the database. */ | |
| 40 | function play(db: Database, name: string, over: Partial<Result> = {}): string { | |
| 41 | const match = result(over); | |
| 42 | saveMatch(db, player(name), match); | |
| 43 | return match.matchId; | |
| 44 | } | |
| 45 | ||
| 46 | type Session = { | |
| 47 | db: Database; | |
| 48 | /** Every string written to the screen, oldest first. One a frame. */ | |
| 49 | writes: string[]; | |
| 50 | /** What raw mode was set to, in order. A session sets it twice. */ | |
| 51 | raw: boolean[]; | |
| 52 | /** The codes the session exited with. */ | |
| 53 | exits: number[]; | |
| 54 | /** Whether the keyboard was resumed and what it was decoded as. */ | |
| 55 | resumed: number; | |
| 56 | encoding: string; | |
| 57 | press(key: string): void; | |
| 58 | /** Fire a resize at this width. Nothing reads it; the terminal says it. */ | |
| 59 | resizeTo(columns: number): void; | |
| 60 | /** The last frame drawn, escapes off, one string a line. */ | |
| 61 | frame(): string[]; | |
| 62 | /** The last frame as it was written, without the leading clear. */ | |
| 63 | body(): string; | |
| 64 | /** The line the cursor is on, escapes off, or "" when no row is marked. */ | |
| 65 | marked(): string; | |
| 66 | }; | |
| 67 | ||
| 68 | /** | |
| 69 | * Boot a session on a fresh database. `seed` runs before the first paint, so a | |
| 70 | * test can decide what is on the screen when the person arrives. | |
| 71 | */ | |
| 72 | function session( | |
| 73 | options: { env?: Record<string, string | undefined>; seed?: (db: Database) => void } = {}, | |
| 74 | ): Session { | |
| 75 | const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-"))); | |
| 76 | options.seed?.(db); | |
| 77 | ||
| 78 | const writes: string[] = []; | |
| 79 | const raw: boolean[] = []; | |
| 80 | const exits: number[] = []; | |
| 81 | const keys: ((key: string) => void)[] = []; | |
| 82 | const resizes: (() => void)[] = []; | |
| 83 | const state = { resumed: 0, encoding: "", columns: 80 }; | |
| 84 | ||
| 85 | const input = { | |
| 86 | setRawMode: (on: boolean) => { raw.push(on); }, | |
| 87 | resume: () => { state.resumed++; }, | |
| 88 | setEncoding: (encoding: "utf8") => { state.encoding = encoding; }, | |
| 89 | on: (_event: "data", listener: (key: string) => void) => { keys.push(listener); }, | |
| 90 | }; | |
| 91 | // A real stdout carries its width, so this one does too — and, like the real | |
| 92 | // session, nothing reads it: `screen.ts` owns the columns. | |
| 93 | const output = { write: (text: string) => { writes.push(text); }, get columns() { return state.columns; } }; | |
| 94 | const resize = { on: (_event: "resize", listener: () => void) => { resizes.push(listener); } }; | |
| 95 | const io: Io = { input, output, resize, exit: (code) => { exits.push(code); }, db, env: options.env ?? {} }; | |
| 96 | run(io); | |
| 97 | ||
| 98 | const frame = (): string[] => (writes.at(-1) ?? "").slice(CLEAR.length).split("\r\n").map(plain); | |
| 99 | return { | |
| 100 | db, writes, raw, exits, | |
| 101 | get resumed() { return state.resumed; }, | |
| 102 | get encoding() { return state.encoding; }, | |
| 103 | press: (key: string) => { for (const listener of keys) listener(key); }, | |
| 104 | resizeTo: (columns: number) => { state.columns = columns; for (const fire of resizes) fire(); }, | |
| 105 | frame, | |
| 106 | body: () => (writes.at(-1) ?? "").slice(CLEAR.length), | |
| 107 | marked: () => frame().find((line) => line.startsWith("▸")) ?? "", | |
| 108 | }; | |
| 109 | } | |
| 110 | ||
| 111 | test("the first paint is the standings, under the environment and the match count", () => { | |
| 112 | const tty = session({ | |
| 113 | env: { FORGE_APP_SSH: "ssh -p 2222 cs@localhost" }, | |
| 114 | seed: (db) => { play(db, "ace"); play(db, "bo", { kills: 3 }); }, | |
| 115 | }); | |
| 116 | // Raw, resumed and decoded before anything is drawn, and drawn exactly once. | |
| 117 | expect(tty.raw).toEqual([true]); | |
| 118 | expect(tty.resumed).toBe(1); | |
| 119 | expect(tty.encoding).toBe("utf8"); | |
| 120 | expect(tty.writes.length).toBe(1); | |
| 121 | expect(tty.writes[0]).toStartWith(CLEAR); | |
| 122 | ||
| 123 | const lines = tty.frame(); | |
| 124 | expect(lines[0]).toBe("cs local · 2 matches"); | |
| 125 | expect(lines[2]).toBe(" standings matches policy "); | |
| 126 | expect(tty.marked()).toStartWith("▸ ace"); | |
| 127 | expect(lines.some((line) => line.startsWith(" bo"))).toBe(true); | |
| 128 | expect(tty.exits).toEqual([]); | |
| 129 | }); | |
| 130 | ||
| 131 | test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off when it is unset", () => { | |
| 132 | const on = session({ env: { FORGE_APP_SSH: "ssh -p 2222 [email protected]" } }); | |
| 133 | expect(on.frame().at(-1)).toBe("reach this terminal with: ssh -p 2222 [email protected]"); | |
| 134 | ||
| 135 | const off = session(); | |
| 136 | expect(off.frame().at(-1)).toBe("the app's terminal is off"); | |
| 137 | // Nothing that looks like an address is made up in its place. | |
| 138 | expect(off.frame().join("\n")).not.toContain("@"); | |
| 139 | expect(off.frame().join("\n")).not.toContain("ssh "); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a preview says which pull it is, and production says production", () => { | |
| 143 | const preview = session({ env: { FORGE_APP_ENV: "preview", FORGE_APP_PULL: "109" } }); | |
| 144 | expect(preview.frame()[0]).toBe("cs preview of #109 · 0 matches"); | |
| 145 | expect(session({ env: { FORGE_APP_ENV: "production" } }).frame()[0]).toBe("cs production · 0 matches"); | |
| 146 | // A preview that was not told its number still says it is one. | |
| 147 | expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches"); | |
| 148 | }); | |
| 149 | ||
| 150 | test("tab cycles the three tabs, and every press draws exactly one frame", () => { | |
| 151 | const tty = session({ seed: (db) => { play(db, "ace"); } }); | |
| 152 | expect(tty.frame()[4]).toStartWith(" player matches"); | |
| 153 | tty.press("\t"); | |
| 154 | expect(tty.frame()[2]).toBe(" standings matches policy "); | |
| 155 | expect(tty.frame()[4]).toStartWith(" player score"); | |
| 156 | tty.press("\t"); | |
| 157 | expect(tty.frame()[4]).toBe(" the policy the bots fight with"); | |
| 158 | tty.press("\t"); | |
| 159 | expect(tty.frame()[4]).toStartWith(" player matches"); | |
| 160 | expect(tty.writes.length).toBe(4); | |
| 161 | }); | |
| 162 | ||
| 163 | test("j and k move the cursor and stop at both ends of the list", () => { | |
| 164 | const tty = session({ | |
| 165 | seed: (db) => { play(db, "ace"); play(db, "bo", { kills: 3 }); play(db, "cy", { kills: 1 }); }, | |
| 166 | }); | |
| 167 | expect(tty.marked()).toStartWith("▸ ace"); | |
| 168 | tty.press("j"); | |
| 169 | expect(tty.marked()).toStartWith("▸ bo"); | |
| 170 | tty.press("j"); | |
| 171 | tty.press("j"); | |
| 172 | tty.press("j"); | |
| 173 | // Three rows, so the bottom one is where it stays. | |
| 174 | expect(tty.marked()).toStartWith("▸ cy"); | |
| 175 | tty.press("k"); | |
| 176 | expect(tty.marked()).toStartWith("▸ bo"); | |
| 177 | tty.press("k"); | |
| 178 | tty.press("k"); | |
| 179 | expect(tty.marked()).toStartWith("▸ ace"); | |
| 180 | // The arrow keys are the same keys. | |
| 181 | tty.press("\x1b[B"); | |
| 182 | expect(tty.marked()).toStartWith("▸ bo"); | |
| 183 | tty.press("\x1b[A"); | |
| 184 | expect(tty.marked()).toStartWith("▸ ace"); | |
| 185 | // Only ever one row is marked. | |
| 186 | expect(tty.frame().filter((line) => line.startsWith("▸")).length).toBe(1); | |
| 187 | }); | |
| 188 | ||
| 189 | test("r picks up a match written behind the session's back", () => { | |
| 190 | const tty = session({ seed: (db) => { play(db, "ace"); } }); | |
| 191 | expect(tty.frame()[0]).toBe("cs local · 1 match"); | |
| 192 | ||
| 193 | play(tty.db, "newcomer", { kills: 30 }); | |
| 194 | // Not on the screen until it is asked for: the session queries when it draws. | |
| 195 | expect(tty.frame().some((line) => line.includes("newcomer"))).toBe(false); | |
| 196 | tty.press("r"); | |
| 197 | expect(tty.frame()[0]).toBe("cs local · 2 matches"); | |
| 198 | expect(tty.marked()).toStartWith("▸ newcomer"); | |
| 199 | }); | |
| 200 | ||
| 201 | test("x on the matches tab removes the match under the cursor and says whose it was", () => { | |
| 202 | const tty = session({ seed: (db) => { play(db, "ace"); play(db, "bo", { kills: 3 }); } }); | |
| 203 | tty.press("\t"); | |
| 204 | tty.press("j"); | |
| 205 | const doomed = tty.marked().slice(2).split(" ")[0]; | |
| 206 | tty.press("x"); | |
| 207 | ||
| 208 | expect(matchCount(tty.db)).toBe(1); | |
| 209 | expect(tty.frame().at(-1)).toBe(`removed ${doomed}'s match`); | |
| 210 | expect(tty.frame().some((line) => line.includes(`${doomed} `))).toBe(false); | |
| 211 | // The cursor stayed inside the list it just shortened. | |
| 212 | expect(tty.frame().filter((line) => line.startsWith("▸")).length).toBe(1); | |
| 213 | // The next press clears the note: it described the press before it. | |
| 214 | tty.press("r"); | |
| 215 | expect(tty.frame().at(-1)).toBe("the app's terminal is off"); | |
| 216 | ||
| 217 | // x on the standings tab is not a delete, and takes nothing with it. | |
| 218 | tty.press("\t"); | |
| 219 | tty.press("\t"); | |
| 220 | tty.press("x"); | |
| 221 | expect(matchCount(tty.db)).toBe(1); | |
| 222 | }); | |
| 223 | ||
| 224 | test("x with nothing under the cursor removes nothing and does not throw", () => { | |
| 225 | const tty = session(); | |
| 226 | tty.press("\t"); | |
| 227 | tty.press("x"); | |
| 228 | expect(matchCount(tty.db)).toBe(0); | |
| 229 | expect(tty.frame()[5]).toBe(" no matches yet"); | |
| 230 | expect(tty.exits).toEqual([]); | |
| 231 | }); | |
| 232 | ||
| 233 | test("q gives the terminal back exactly once and leaves", () => { | |
| 234 | const tty = session({ seed: (db) => { play(db, "ace"); } }); | |
| 235 | tty.press("q"); | |
| 236 | expect(tty.raw).toEqual([true, false]); | |
| 237 | expect(tty.writes.at(-1)).toBe(CLEAR); | |
| 238 | expect(tty.exits).toEqual([0]); | |
| 239 | ||
| 240 | // A pty can deliver another key after the exit; the session is over. | |
| 241 | const drawn = tty.writes.length; | |
| 242 | tty.press("j"); | |
| 243 | tty.press("q"); | |
| 244 | tty.resizeTo(100); | |
| 245 | expect(tty.writes.length).toBe(drawn); | |
| 246 | expect(tty.raw).toEqual([true, false]); | |
| 247 | expect(tty.exits).toEqual([0]); | |
| 248 | }); | |
| 249 | ||
| 250 | test("ctrl-c leaves the same way q does", () => { | |
| 251 | const tty = session(); | |
| 252 | tty.press("\x03"); | |
| 253 | expect(tty.raw).toEqual([true, false]); | |
| 254 | expect(tty.writes.at(-1)).toBe(CLEAR); | |
| 255 | expect(tty.exits).toEqual([0]); | |
| 256 | }); | |
| 257 | ||
| 258 | test("a draw that throws still gives the terminal back, once, and says what happened", () => { | |
| 259 | const tty = session({ seed: (db) => { play(db, "ace"); } }); | |
| 260 | // The database goes out from under the session: the next draw's query throws. | |
| 261 | tty.db.close(); | |
| 262 | tty.press("j"); | |
| 263 | ||
| 264 | expect(tty.raw).toEqual([true, false]); | |
| 265 | expect(tty.exits).toEqual([1]); | |
| 266 | expect(tty.writes.at(-1)).toStartWith(CLEAR); | |
| 267 | expect(tty.body()).toStartWith("the terminal stopped: "); | |
| 268 | // And it is over: no further key redraws, and raw mode is not set again. | |
| 269 | const drawn = tty.writes.length; | |
| 270 | tty.press("q"); | |
| 271 | expect(tty.writes.length).toBe(drawn); | |
| 272 | expect(tty.raw).toEqual([true, false]); | |
| 273 | expect(tty.exits).toEqual([1]); | |
| 274 | }); | |
| 275 | ||
| 276 | test("a resize redraws, and a 20-column terminal and a 200-column one both draw", () => { | |
| 277 | const tty = session({ seed: (db) => { play(db, "ace"); } }); | |
| 278 | const first = tty.frame(); | |
| 279 | ||
| 280 | tty.resizeTo(20); | |
| 281 | expect(tty.writes.length).toBe(2); | |
| 282 | expect(tty.frame()).toEqual(first); | |
| 283 | ||
| 284 | tty.resizeTo(200); | |
| 285 | expect(tty.writes.length).toBe(3); | |
| 286 | // The columns are `screen.ts`'s and do not move with the window: what this | |
| 287 | // pins is that neither extreme throws and neither changes the layout. | |
| 288 | expect(tty.frame()).toEqual(first); | |
| 289 | expect(tty.exits).toEqual([]); | |
| 290 | }); | |
| 291 | ||
| 292 | test("a name out of the database cannot move the cursor or clear the screen", () => { | |
| 293 | // A row written before `cleanName` existed, or by anything that is not the | |
| 294 | // web boundary: the screen is the last place this can be caught. | |
| 295 | const attack = "evil\x1b[2J\x1b[Hpwn"; | |
| 296 | const tty = session({ seed: (db) => { play(db, attack); } }); | |
| 297 | ||
| 298 | // The only clear in the frame is the one the session wrote itself, first. | |
| 299 | expect(tty.writes.at(-1)).toStartWith(CLEAR); | |
| 300 | expect(tty.body()).not.toContain("\x1b[2J"); | |
| 301 | expect(tty.body()).not.toContain("\x1b[H"); | |
| 302 | expect(tty.marked()).toStartWith("▸ evil·[2J·[Hpwn"); | |
| 303 | ||
| 304 | // And the same name coming back as the note after a delete is no escape either. | |
| 305 | tty.press("\t"); | |
| 306 | tty.press("x"); | |
| 307 | expect(tty.body()).not.toContain("\x1b[2J"); | |
| 308 | expect(tty.frame().at(-1)).toBe(`removed ${attack.replace(/\x1b/g, "·")}'s match`); | |
| 309 | expect(matchCount(tty.db)).toBe(0); | |
| 310 | }); |
src/tty.ts +120 −35
| @@ -8,49 +8,134 @@ | ||
| 8 | 8 | * This file is the wiring only — the database, raw stdin, stdout, resize and |
| 9 | 9 | * exit. What it draws is `src/terminal/screen.ts` and what it answers is |
| 10 | 10 | * `src/terminal/keys.ts`, both plain data in and out, both under test. |
| 11 | * | |
| 12 | * Nothing in here is a process. `run(io)` takes everything the session reaches | |
| 13 | * for — an input that can go raw, an output that takes strings, a resize | |
| 14 | * source, an exit, an open database and the environment — as one plain object, | |
| 15 | * typed by the members it actually uses the way `src/client/main.ts` types its | |
| 16 | * host. The last lines of this file are the only place `process` and | |
| 17 | * `openDatabase()` are named, and they run only when this file is the program: | |
| 18 | * `tty.test.ts` imports `run` and drives a whole session against plain objects | |
| 19 | * and a database under a temporary directory, touching no real terminal. | |
| 11 | 20 | */ |
| 12 | 21 | |
| 22 | import type { Database } from "bun:sqlite"; | |
| 13 | 23 | import { matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts"; |
| 14 | 24 | import { CARD } from "./client/weights.ts"; |
| 15 | import { screen, type Data, type View } from "./terminal/screen.ts"; | |
| 25 | import { printable, screen, type Data, type View } from "./terminal/screen.ts"; | |
| 16 | 26 | import { press, removed } from "./terminal/keys.ts"; |
| 17 | 27 | |
| 18 | const db = openDatabase(); | |
| 28 | // --- the io: what a terminal has to be for this session to run on it ------- | |
| 19 | 29 | |
| 20 | let view: View = { | |
| 21 | tab: "standings", | |
| 22 | cursor: 0, | |
| 23 | note: "", | |
| 24 | where: process.env.FORGE_APP_ENV === "preview" | |
| 25 | ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` | |
| 26 | : (process.env.FORGE_APP_ENV ?? "local"), | |
| 27 | ssh: process.env.FORGE_APP_SSH, | |
| 30 | /** The keyboard: raw, decoded, and handing this file one key at a time. */ | |
| 31 | export type Keys = { | |
| 32 | /** A pipe is not a tty and has none, which is why it is optional. */ | |
| 33 | setRawMode?(raw: boolean): void; | |
| 34 | resume(): void; | |
| 35 | setEncoding(encoding: "utf8"): void; | |
| 36 | on(event: "data", listener: (key: string) => void): void; | |
| 28 | 37 | }; |
| 29 | 38 | |
| 30 | const read = (): Data => ({ | |
| 31 | standings: standings(db, 20), | |
| 32 | matches: recentMatches(db, 20), | |
| 33 | total: matchCount(db), | |
| 34 | card: CARD, | |
| 35 | }); | |
| 39 | /** The screen: everything this file knows how to do to one. */ | |
| 40 | export type Writes = { write(text: string): void }; | |
| 36 | 41 | |
| 37 | const clear = "\x1b[2J\x1b[H"; | |
| 38 | const draw = (): void => { process.stdout.write(clear + screen(view, read()).join("\r\n")); }; | |
| 42 | /** Whatever says the window changed size. `process.stdout` is one. */ | |
| 43 | export type Resizes = { on(event: "resize", listener: () => void): void }; | |
| 39 | 44 | |
| 40 | process.stdin.setRawMode?.(true); | |
| 41 | process.stdin.resume(); | |
| 42 | process.stdin.setEncoding("utf8"); | |
| 43 | draw(); | |
| 44 | process.stdin.on("data", (key: string) => { | |
| 45 | const step = press(view, key, read()); | |
| 46 | view = step.view; | |
| 47 | if (step.command.kind === "quit") { | |
| 48 | process.stdout.write(clear); | |
| 49 | process.exit(0); | |
| 50 | } | |
| 51 | if (step.command.kind === "delete") { | |
| 52 | view = removed(view, step.command.name, deleteMatch(db, step.command.id)); | |
| 53 | } | |
| 54 | draw(); | |
| 55 | }); | |
| 56 | process.stdout.on("resize", draw); | |
| 45 | export type Io = { | |
| 46 | input: Keys; | |
| 47 | output: Writes; | |
| 48 | resize: Resizes; | |
| 49 | /** Ends the session. The real one never returns; a test's does. */ | |
| 50 | exit(code: number): void; | |
| 51 | /** Already open: the session queries it and never decides where it lives. */ | |
| 52 | db: Database; | |
| 53 | /** FORGE_APP_ENV, FORGE_APP_PULL and FORGE_APP_SSH are all that is read. */ | |
| 54 | env: Record<string, string | undefined>; | |
| 55 | }; | |
| 56 | ||
| 57 | /** Wipe the screen and put the cursor home. Written on every paint. */ | |
| 58 | const CLEAR = "\x1b[2J\x1b[H"; | |
| 59 | ||
| 60 | /** The environment pill: `local`, `production`, `preview of #12`. */ | |
| 61 | const whereOf = (env: Io["env"]): string => | |
| 62 | env.FORGE_APP_ENV === "preview" ? `preview of #${env.FORGE_APP_PULL ?? "?"}` : (env.FORGE_APP_ENV ?? "local"); | |
| 63 | ||
| 64 | /** | |
| 65 | * Run a session until it quits. Returns once the listeners are registered — | |
| 66 | * every keypress after that is the input's to deliver. | |
| 67 | */ | |
| 68 | export function run(io: Io): void { | |
| 69 | let view: View = { | |
| 70 | tab: "standings", | |
| 71 | cursor: 0, | |
| 72 | note: "", | |
| 73 | where: whereOf(io.env), | |
| 74 | // FORGE_APP_SSH verbatim, or undefined: the screen then says the terminal | |
| 75 | // is off. No address is ever made up here. | |
| 76 | ssh: io.env.FORGE_APP_SSH, | |
| 77 | }; | |
| 78 | ||
| 79 | const read = (): Data => ({ | |
| 80 | standings: standings(io.db, 20), | |
| 81 | matches: recentMatches(io.db, 20), | |
| 82 | total: matchCount(io.db), | |
| 83 | card: CARD, | |
| 84 | }); | |
| 85 | ||
| 86 | const draw = (): void => { io.output.write(CLEAR + screen(view, read()).join("\r\n")); }; | |
| 87 | ||
| 88 | // The terminal is handed back exactly once, however the session ends: raw | |
| 89 | // mode off first, so a write that fails still leaves a usable shell behind. | |
| 90 | let over = false; | |
| 91 | const finish = (code: number, note = ""): void => { | |
| 92 | if (over) return; | |
| 93 | over = true; | |
| 94 | io.input.setRawMode?.(false); | |
| 95 | io.output.write(CLEAR + note); | |
| 96 | io.exit(code); | |
| 97 | }; | |
| 98 | ||
| 99 | /** Anything that can throw runs in here: a broken draw gives the tty back. */ | |
| 100 | const guarded = (step: () => void): void => { | |
| 101 | if (over) return; | |
| 102 | try { | |
| 103 | step(); | |
| 104 | } catch (error) { | |
| 105 | const why = error instanceof Error ? error.message : String(error); | |
| 106 | finish(1, `the terminal stopped: ${printable(why)}\r\n`); | |
| 107 | } | |
| 108 | }; | |
| 109 | ||
| 110 | guarded(() => { | |
| 111 | io.input.setRawMode?.(true); | |
| 112 | io.input.resume(); | |
| 113 | io.input.setEncoding("utf8"); | |
| 114 | draw(); | |
| 115 | }); | |
| 116 | ||
| 117 | io.input.on("data", (key) => guarded(() => { | |
| 118 | // A pty hands over a string; anything else is read as the keypress it is | |
| 119 | // not, which `press` answers by redrawing. | |
| 120 | const step = press(view, typeof key === "string" ? key : String(key), read()); | |
| 121 | view = step.view; | |
| 122 | if (step.command.kind === "quit") return finish(0); | |
| 123 | if (step.command.kind === "delete") { | |
| 124 | view = removed(view, step.command.name, deleteMatch(io.db, step.command.id)); | |
| 125 | } | |
| 126 | draw(); | |
| 127 | })); | |
| 128 | ||
| 129 | io.resize.on("resize", () => guarded(draw)); | |
| 130 | } | |
| 131 | ||
| 132 | if (import.meta.main) { | |
| 133 | run({ | |
| 134 | input: process.stdin, | |
| 135 | output: process.stdout, | |
| 136 | resize: process.stdout, | |
| 137 | exit: (code) => process.exit(code), | |
| 138 | db: openDatabase(), | |
| 139 | env: process.env, | |
| 140 | }); | |
| 141 | } |
new above
offline