|
4 | 4 | */ |
5 | 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test" |
6 | 6 | import path from "node:path" |
| 7 | +import { tmpdir } from "node:os" |
7 | 8 | import { existsSync, readFileSync, readdirSync } from "node:fs" |
8 | 9 | import { createProject, installPlugins, REPO_ROOT, runTui, runTuiScreens, startMock, type StartedMock } from "./harness.js" |
9 | 10 |
|
10 | 11 | const e2e = process.env["CTREE_E2E"] === "1" |
11 | 12 |
|
| 13 | +/** `CTREE_DUMP=<path>` writes the pyte-rendered screens for eyeballing a failure. A value that |
| 14 | + * is not a path (`CTREE_DUMP=1`, the obvious thing to type) lands in the temp dir rather than |
| 15 | + * creating a file called `1` in the repo root — which is exactly how one got committed. */ |
| 16 | +async function dumpScreens(screens: { label: string; screen: string }[]): Promise<void> { |
| 17 | + const want = process.env["CTREE_DUMP"] |
| 18 | + if (!want) return |
| 19 | + const file = want.includes("/") ? want : path.join(tmpdir(), "ctree-e2e-screens.txt") |
| 20 | + await Bun.write(file, screens.map((x) => `=== ${x.label}\n${x.screen}`).join("\n")) |
| 21 | + console.log(`screens → ${file}`) |
| 22 | +} |
| 23 | + |
12 | 24 | describe.skipIf(!e2e)("tui e2e: built plugin", () => { |
13 | 25 | let mock: StartedMock |
14 | 26 | let project: Awaited<ReturnType<typeof createProject>> |
@@ -308,7 +320,7 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { |
308 | 320 | rows: 34, |
309 | 321 | exitWhenDone: true, |
310 | 322 | }) |
311 | | - if (process.env["CTREE_DUMP"]) await Bun.write(process.env["CTREE_DUMP"]!, screens.map((x) => `=== ${x.label}\n${x.screen}`).join("\n")) |
| 323 | + await dumpScreens(screens) |
312 | 324 | // screens are captured *before* each key, so key N's screen is the state key N-1 left: |
313 | 325 | // assert the state itself rather than the notice, which has a lifetime of its own |
314 | 326 | const before = (key: number) => { |
@@ -336,6 +348,66 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { |
336 | 348 | } |
337 | 349 | }, 300_000) |
338 | 350 |
|
| 351 | + test("a summary that takes seconds shows a live progress line while it runs", async () => { |
| 352 | + // The mock answers instantly, which is why no earlier test watched the tree *during* a |
| 353 | + // draft at all. Holding the summary request back pins the two things a user reports when |
| 354 | + // they say "no feedback": that the line is up straight away (0.3s in, not whenever |
| 355 | + // something else repaints), and that it is still moving while the model thinks. |
| 356 | + const m = await startMock({ tool: false, slowSummaryMs: 20000 }) |
| 357 | + const proj = await createProject({ mockPort: m.port }) |
| 358 | + await installPlugins({ projectDir: proj.dir, server: [path.join(REPO_ROOT, "dist", "server.js")], tui: [path.join(REPO_ROOT, "dist", "tui.js")] }) |
| 359 | + try { |
| 360 | + const { screens } = await runTuiScreens({ |
| 361 | + projectDir: proj.dir, |
| 362 | + keys: [ |
| 363 | + ["Ask anything", 1, "first question\r"], |
| 364 | + ["mock reply", 8, "second question\r"], |
| 365 | + ["mock reply", 16, "/tree"], |
| 366 | + ["Context tree", 0.5, "\r"], |
| 367 | + ["Context tree ·", 2, "gg"], |
| 368 | + ["Context tree ·", 3, "\r"], |
| 369 | + // ↓ once = "Summarize everything below this point" |
| 370 | + ["Fork & prefill this turn", 1.5, "\x1b[B"], |
| 371 | + ["Summarize everything below", 1, "\r"], |
| 372 | + // four screens half a second apart while the summary is still in flight: close |
| 373 | + // enough that only a *live* line changes between them (the spinner turns every |
| 374 | + // 120ms), which is the thing OpenCode's on-demand renderer does not do by itself |
| 375 | + ["Summarize everything below", 0.3, ""], |
| 376 | + ["Summarize everything below", 0.5, ""], |
| 377 | + ["Summarize everything below", 0.5, ""], |
| 378 | + ["Summarize everything below", 0.5, ""], |
| 379 | + ["mock reply|Ask anything", 20, "\x03"], |
| 380 | + ["", 1, "\x03"], |
| 381 | + ], |
| 382 | + timeoutSec: 240, |
| 383 | + cols: 130, |
| 384 | + rows: 34, |
| 385 | + exitWhenDone: true, |
| 386 | + }) |
| 387 | + await dumpScreens(screens) |
| 388 | + // the first sample is 0.3s after the choice: the line has to be up *immediately*, not |
| 389 | + // whenever something else happens to repaint the screen |
| 390 | + const inFlight = screens.filter((x) => x.label.includes("conditional key 8") || x.label.includes("conditional key 9") || x.label.includes("conditional key 10") || x.label.includes("conditional key 11")) |
| 391 | + expect(inFlight.length).toBe(4) |
| 392 | + const during = inFlight.filter((x) => x.screen.includes("summarizing")) |
| 393 | + if (during.length < 4) throw new Error(`the progress line was missing from ${4 - during.length} of the 4 in-flight samples: ${inFlight.map((x) => (x.screen.includes("summarizing") ? "line" : "NOTHING")).join(" ")}`) |
| 394 | + expect(during.every((x) => x.screen.includes("esc cancels"))).toBe(true) |
| 395 | + |
| 396 | + // It is *live*: the spinner has turned between these half-second samples. Without a |
| 397 | + // frame request the line is drawn once and then sits frozen until something unrelated |
| 398 | + // repaints — which is exactly what "no feedback" looked like. |
| 399 | + const frames = during.map((x) => /([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]) summarizing/.exec(x.screen)?.[1]).filter((v): v is string => v !== undefined) |
| 400 | + if (process.env["CTREE_DUMP"]) console.log(`spinner frames across ${during.length} samples: ${frames.join(" ")}`) |
| 401 | + expect(frames.length).toBe(during.length) |
| 402 | + // two of four is enough to prove it moved: at 8 frames a second, samples half a second |
| 403 | + // apart alias against the 10-frame cycle, so "all four differ" would be flaky |
| 404 | + expect(new Set(frames).size).toBeGreaterThan(1) |
| 405 | + } finally { |
| 406 | + await m.stop() |
| 407 | + await proj.cleanup() |
| 408 | + } |
| 409 | + }, 300_000) |
| 410 | + |
339 | 411 | test("the g-prefixed verbs fire alongside gg", async () => { |
340 | 412 | // `gg` was the only sequence in the keymap; the realignment put every plugin verb behind |
341 | 413 | // `g`, so this proves the sequence tree branches rather than `gg` shadowing them |
|
0 commit comments