Public: anyone can read this; the crew can change it.
The direction's own sentence is a test: two viewers and an agent on one board, each seeing the others' moves #119
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/61-the-direction-s-own-sentence-is-a-test-t → main 1 commit fork
2 files
changed +144
−1
README.md +5 −1
| @@ -78,7 +78,11 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 78 | 78 | a separate process) reaches the same open page too — see `src/watch.ts` |
| 79 | 79 | below — arriving as a whole-board reconcile rather than one placed |
| 80 | 80 | card, since the poller that notices it only knows the board changed, |
| 81 | not what changed. | |
| 81 | not what changed. `src/live-e2e.test.ts` is where the direction's own | |
| 82 | sentence — two people and one agent on one board, each seeing the | |
| 83 | others' moves as they happen — is checked end to end, over real HTTP | |
| 84 | and a real `/events` stream rather than the bus directly; it's the | |
| 85 | first place to look when live updates stop working. | |
| 82 | 86 | - **Presence**: the header shows who *else* has this board's `/events` |
| 83 | 87 | stream open right now — a forge username for a signed-in visitor (once, |
| 84 | 88 | even if they have it open in two tabs), "Guest" for one anonymous tab, |
src/live-e2e.test.ts added +139
| @@ -0,0 +1,139 @@ | ||
| 1 | /** | |
| 2 | * The direction's own sentence, asserted end to end: two people and one | |
| 3 | * agent on one board, each seeing the others' moves as they happen. This | |
| 4 | * file drives only public surfaces — HTTP requests and the SSE stream — | |
| 5 | * and imports nothing from src/bus.ts, so it keeps failing the day | |
| 6 | * something stops publishing even if the bus itself is rewritten. | |
| 7 | */ | |
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { mkdtempSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | ||
| 13 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | |
| 14 | process.env.PORT = "0"; | |
| 15 | const { server } = await import("./server.ts"); | |
| 16 | // Only used to seed a board and its default columns/cards directly — the | |
| 17 | // same shortcut src/server.test.ts takes to skip the board-creation rate | |
| 18 | // limit — never to read the bus or the events this test is checking for. | |
| 19 | const db = await import("./db.ts"); | |
| 20 | ||
| 21 | const base = `http://${server.hostname}:${server.port}`; | |
| 22 | ||
| 23 | type Frame = { | |
| 24 | kind?: string; | |
| 25 | boardId?: string; | |
| 26 | by?: string | null; | |
| 27 | client?: string | null; | |
| 28 | ids?: Record<string, string>; | |
| 29 | }; | |
| 30 | ||
| 31 | /** | |
| 32 | * One open `/events` connection, named for the assertion messages: a | |
| 33 | * timeout here says which seat stopped hearing the bus, rather than | |
| 34 | * failing with a bare "timeout" that could be any of the three. | |
| 35 | */ | |
| 36 | function seat(name: string, stream: Response) { | |
| 37 | const reader = stream.body!.getReader(); | |
| 38 | const decoder = new TextDecoder(); | |
| 39 | let buffer = ""; | |
| 40 | ||
| 41 | async function rawFrame(timeoutMs: number): Promise<string> { | |
| 42 | let timer: ReturnType<typeof setTimeout>; | |
| 43 | const timeout = new Promise<never>((_, reject) => { | |
| 44 | timer = setTimeout(() => reject(new Error(`${name}'s stream went silent — the bus stopped reaching this seat`)), timeoutMs); | |
| 45 | }); | |
| 46 | try { | |
| 47 | while (!buffer.includes("\n\n")) { | |
| 48 | const { value, done } = await Promise.race([reader.read(), timeout]); | |
| 49 | if (done) throw new Error(`${name}'s stream ended`); | |
| 50 | buffer += decoder.decode(value, { stream: true }); | |
| 51 | } | |
| 52 | } finally { | |
| 53 | clearTimeout(timer!); | |
| 54 | } | |
| 55 | const at = buffer.indexOf("\n\n"); | |
| 56 | const frame = buffer.slice(0, at); | |
| 57 | buffer = buffer.slice(at + 2); | |
| 58 | return frame; | |
| 59 | } | |
| 60 | ||
| 61 | // The next board-change frame, skipping heartbeats and presence frames, | |
| 62 | // failing loudly (naming this seat) if none lands inside timeoutMs. | |
| 63 | async function next(timeoutMs: number): Promise<Frame> { | |
| 64 | const deadline = Date.now() + timeoutMs; | |
| 65 | for (;;) { | |
| 66 | const remaining = deadline - Date.now(); | |
| 67 | if (remaining <= 0) throw new Error(`${name} never saw the change within ${timeoutMs}ms — the bus stopped reaching this seat`); | |
| 68 | const frame = await rawFrame(remaining); | |
| 69 | if (frame.startsWith(":") || frame.startsWith("retry:") || frame.startsWith("event: presence")) continue; | |
| 70 | const data = frame.match(/^data: (.*)$/m)?.[1]; | |
| 71 | if (!data) continue; | |
| 72 | return JSON.parse(data) as Frame; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | return { next, cancel: () => reader.cancel() }; | |
| 77 | } | |
| 78 | ||
| 79 | // The direction's number is a second; this test waits up to two so a | |
| 80 | // loaded machine doesn't flake, without letting a genuinely broken bus | |
| 81 | // hang the suite. | |
| 82 | const WITHIN = 2000; | |
| 83 | ||
| 84 | test("two people and an agent on one board each see the others' moves within a second", async () => { | |
| 85 | const board = db.createBoard("Live e2e board"); | |
| 86 | const [todo, doing] = db.listColumns(board.id); | |
| 87 | const card = db.createCard(board.id, todo.id, "Move me"); | |
| 88 | if (card === "not_found" || card === "full") throw new Error("test setup failed to create a card"); | |
| 89 | ||
| 90 | const streamA = await fetch(`${base}/b/${board.id}/events`); | |
| 91 | const streamB = await fetch(`${base}/b/${board.id}/events`); | |
| 92 | expect(streamA.headers.get("content-type")).toBe("text/event-stream"); | |
| 93 | expect(streamB.headers.get("content-type")).toBe("text/event-stream"); | |
| 94 | const a = seat("viewer A", streamA); | |
| 95 | const b = seat("viewer B", streamB); | |
| 96 | ||
| 97 | try { | |
| 98 | // Person A moves a card. Both A's own stream and B's must see it — A's | |
| 99 | // own frame identifies A as the writer, so A's tab can skip work it | |
| 100 | // already did, and B's frame is how B's page redraws without a reload. | |
| 101 | const moved = await fetch(`${base}/b/${board.id}/cards/${card.id}/move`, { | |
| 102 | method: "POST", | |
| 103 | redirect: "manual", | |
| 104 | headers: { "content-type": "application/x-www-form-urlencoded", "x-tempo-client": "A" }, | |
| 105 | body: new URLSearchParams({ column_id: doing.id, index: "0" }), | |
| 106 | }); | |
| 107 | expect(moved.status).toBe(303); | |
| 108 | ||
| 109 | const [movedForA, movedForB] = await Promise.all([a.next(WITHIN), b.next(WITHIN)]); | |
| 110 | expect(movedForA.kind).toBe("card.moved"); | |
| 111 | expect(movedForA.client).toBe("A"); | |
| 112 | expect(movedForA.ids?.cardId).toBe(card.id); | |
| 113 | expect(movedForB.kind).toBe("card.moved"); | |
| 114 | expect(movedForB.ids?.cardId).toBe(card.id); | |
| 115 | ||
| 116 | // The agent takes its turn over MCP, with no tab and so no client id | |
| 117 | // of its own — both open seats still hear it within the same window. | |
| 118 | const call = await fetch(`${base}/mcp`, { | |
| 119 | method: "POST", | |
| 120 | headers: { "content-type": "application/json" }, | |
| 121 | body: JSON.stringify({ | |
| 122 | jsonrpc: "2.0", | |
| 123 | id: 1, | |
| 124 | method: "tools/call", | |
| 125 | params: { name: "add_card", arguments: { board_id: board.id, column_id: todo.id, title: "From the agent" } }, | |
| 126 | }), | |
| 127 | }); | |
| 128 | expect(call.status).toBe(200); | |
| 129 | const rpcBody = (await call.json()) as { result?: { isError?: boolean } }; | |
| 130 | expect(rpcBody.result?.isError).toBeUndefined(); | |
| 131 | ||
| 132 | const [addedForA, addedForB] = await Promise.all([a.next(WITHIN), b.next(WITHIN)]); | |
| 133 | expect(addedForA.kind).toBe("card.added"); | |
| 134 | expect(addedForB.kind).toBe("card.added"); | |
| 135 | } finally { | |
| 136 | await a.cancel(); | |
| 137 | await b.cancel(); | |
| 138 | } | |
| 139 | }); |
new above
offline