Skip to content

Commit 4a59bb2

Browse files
authored
test(core): strict vim full-screen render guard
Add a strict integration test rendering vim over a real VM PTY to guard the termcap tgoto() render fix against regression.
1 parent 43c30e5 commit 4a59bb2

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {
2+
copyFileSync,
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
writeFileSync,
7+
} from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { dirname, join, resolve } from "node:path";
10+
import { fileURLToPath } from "node:url";
11+
import xterm from "@xterm/headless";
12+
import { afterEach, describe, expect, it } from "vitest";
13+
import type { AgentOs } from "../src/index.js";
14+
15+
const { Terminal } = xterm;
16+
17+
// The vim binary comes from the @agentos-software/vim registry package (built
18+
// from source in secure-exec). Gate the suite on it being present so it skips
19+
// on a checkout that has not built the registry.
20+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
21+
const VIM_PACKAGE_BIN = resolve(
22+
REPO_ROOT,
23+
"../secure-exec/registry/software/vim/dist/package/bin/vim",
24+
);
25+
const VIM_BINARY = process.env.AGENTOS_VIM_FIXTURE_BIN ?? VIM_PACKAGE_BIN;
26+
27+
const COLS = 80;
28+
const ROWS = 24;
29+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
30+
31+
/** Render the terminal buffer to an array of trimmed rows (length ROWS). */
32+
function rows(term: InstanceType<typeof Terminal>): string[] {
33+
const buf = term.buffer.active;
34+
const out: string[] = [];
35+
for (let y = 0; y < ROWS; y++) {
36+
const line = buf.getLine(y);
37+
out.push((line ? line.translateToString(true) : "").replace(/\s+$/, ""));
38+
}
39+
return out;
40+
}
41+
42+
/**
43+
* Full-screen vim rendering over a real VM PTY. This is a STRICT layout guard:
44+
* it asserts exact screen geometry that a correct vim produces and that the
45+
* known-broken render violates — the status/ruler on the BOTTOM row (not
46+
* mashed onto a tilde a few rows up), the file message NOT stranded at the top,
47+
* the `-- INSERT --` indicator on entering insert mode, and the file written
48+
* byte-exact. Fuzzy `toContain` checks let a 2-row-offset render slip through;
49+
* these positional assertions do not.
50+
*/
51+
describe.skipIf(!existsSync(VIM_BINARY))("vim full-screen rendering (strict)", () => {
52+
let vm: AgentOs | undefined;
53+
afterEach(async () => {
54+
await vm?.dispose();
55+
vm = undefined;
56+
});
57+
58+
it("lays out the screen with the status line on the bottom row", async () => {
59+
const { AgentOs } = await import("../src/index.js");
60+
const common = (await import("@agentos-software/common")).default;
61+
// Use the registry vim package by default; when AGENTOS_VIM_FIXTURE_BIN is
62+
// set, materialize a package around that binary (lets the same strict
63+
// assertions run against any candidate vim build).
64+
let vimPkg: unknown;
65+
if (process.env.AGENTOS_VIM_FIXTURE_BIN) {
66+
const dir = mkdtempSync(join(tmpdir(), "vim-render-"));
67+
mkdirSync(join(dir, "bin"));
68+
copyFileSync(process.env.AGENTOS_VIM_FIXTURE_BIN, join(dir, "bin", "vim"));
69+
writeFileSync(
70+
join(dir, "package.json"),
71+
JSON.stringify({ name: "vim", version: "0.0.0", bin: { vim: "bin/vim" } }),
72+
);
73+
writeFileSync(join(dir, "agentos-package.json"), JSON.stringify({ name: "vim" }));
74+
vimPkg = { packageDir: dir };
75+
} else {
76+
vimPkg = (await import("@agentos-software/vim")).default;
77+
}
78+
vm = await AgentOs.create({ software: [common, vimPkg] });
79+
await vm.mkdir("/work", { recursive: true });
80+
81+
const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true });
82+
let writes = Promise.resolve();
83+
// vim as the PTY's top-level process. This faithfully reproduces the
84+
// screen layout a real terminal (tmux) shows for `just shell` → `vim`:
85+
// full-screen renders, but the status line lands on the wrong row.
86+
const { shellId } = vm.openShell({
87+
command: "vim",
88+
args: ["-N", "-u", "NONE", "-i", "NONE", "-n", "/work/render.txt"],
89+
cols: COLS,
90+
rows: ROWS,
91+
cwd: "/work",
92+
env: { TERM: "xterm" },
93+
});
94+
vm.onShellData(shellId, (data) => {
95+
const bytes = Buffer.from(data);
96+
writes = writes.then(() => new Promise<void>((r) => term.write(bytes, r)));
97+
});
98+
const settle = async (ms = 700) => {
99+
await sleep(ms);
100+
await writes;
101+
await sleep(30);
102+
await writes;
103+
};
104+
// Wait until vim has actually painted the full-screen UI (a column of
105+
// tildes) before asserting layout — otherwise we would assert against the
106+
// transient startup/warning state. A timeout here means vim never entered
107+
// full-screen mode (e.g. it printed "not a terminal" and gave up).
108+
const waitForRender = async (timeoutMs = 20_000) => {
109+
const deadline = Date.now() + timeoutMs;
110+
while (Date.now() < deadline) {
111+
await settle(400);
112+
const r = rows(term);
113+
// "rendered" = vim painted its tilde column and cleared the startup
114+
// warnings. We intentionally accept the (broken) render here so the
115+
// precise layout assertions below are what fail, naming the defect.
116+
const tildes = r.filter((line) => line.startsWith("~")).length;
117+
if (tildes >= 10 && !r.some((l) => l.includes("not to a terminal")))
118+
return r;
119+
}
120+
throw new Error(
121+
`vim never rendered full-screen (still showing warnings):\n${rows(term).map((l, i) => `${i}|${l}`).join("\n")}`,
122+
);
123+
};
124+
125+
const opened = await waitForRender();
126+
127+
// (1) The empty buffer fills the window with `~` on every line EXCEPT the
128+
// first content line (row 0) and the last (status) line (row 23).
129+
for (let y = 1; y <= ROWS - 2; y++) {
130+
expect(opened[y], `row ${y} should be a lone tilde`).toBe("~");
131+
}
132+
133+
// (2) The status/ruler MUST be on the bottom row. The known-broken render
134+
// leaves rows 22-23 blank and mashes the ruler onto a tilde ~2 rows up.
135+
expect(opened[ROWS - 1], "bottom row should hold the ruler").toMatch(
136+
/\d+,\d+(-\d+)?\s+All\s*$/,
137+
);
138+
// It must NOT sit on the same row as a tilde.
139+
expect(opened[ROWS - 1]).not.toMatch(/^~/);
140+
141+
// (3) The "[New]" file message belongs on the command line (bottom area),
142+
// NOT stranded at the very top mashed against the first tilde.
143+
expect(opened[0], "row 0 must not carry the file message + tilde").not.toContain(
144+
"render.txt",
145+
);
146+
expect(opened[0], "row 0 (empty buffer) starts blank").toBe("");
147+
148+
// (4) Insert mode renders the typed text on the FIRST content row (vim
149+
// draws it at the cursor via cursor addressing). The known-broken
150+
// render instead ECHOES the keystrokes onto the bottom/status row and
151+
// never places them on row 0 — so this row-0 assertion is the strict
152+
// discriminator between a correct redraw and raw-echo garbling.
153+
await vm.writeShell(shellId, "i");
154+
await settle(900);
155+
await vm.writeShell(shellId, "The quick brown fox");
156+
await settle(900);
157+
const inserting = rows(term);
158+
expect(inserting[0], "typed text lands on the first content row").toContain(
159+
"The quick brown fox",
160+
);
161+
// The known-broken render echoes keystrokes onto the status row; the text
162+
// must NOT appear on the bottom row.
163+
expect(inserting[ROWS - 1], "text must not be echoed onto the status row").not.toContain(
164+
"quick brown fox",
165+
);
166+
// The status/ruler must still be on the bottom row (not scrolled away).
167+
expect(inserting[ROWS - 1], "bottom row still holds the ruler").toMatch(
168+
/\d+,\d+(-\d+)?/,
169+
);
170+
171+
// (5) Write + quit; the file is byte-exact.
172+
await vm.writeShell(shellId, ":wq\r");
173+
await settle(1200);
174+
const content = Buffer.from(await vm.readFile("/work/render.txt")).toString(
175+
"utf8",
176+
);
177+
expect(content).toBe("The quick brown fox\n");
178+
}, 90_000);
179+
});

0 commit comments

Comments
 (0)