Skip to content

Commit 36e6630

Browse files
authored
test(core): 1:1 wasm-vs-native vim PTY parity guard
Drives native vim and in-VM wasm vim through the same key sequence and asserts 1:1 rendered-grid parity at each step. Guards the shell-child raw-mode input fix.
1 parent 4a59bb2 commit 36e6630

3 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env python3
2+
"""Drive a NATIVE vim over a real PTY and emit, as JSON, the cumulative raw
3+
terminal output after each scripted step. The node differential test replays the
4+
SAME key sequence against the wasm vim and compares the rendered screen grids.
5+
6+
Protocol: argv[1] is a JSON spec:
7+
{
8+
"vim": "/usr/bin/vim",
9+
"cols": 80, "rows": 24,
10+
"file": "/tmp/xyz.txt",
11+
"openWait": 1.2,
12+
"steps": [ {"label": "insert", "keys_b64": "aQ==", "wait": 0.4}, ... ]
13+
}
14+
Output (stdout): {"snaps": [ {"label": "open", "raw_b64": "..."}, ... ]}
15+
"""
16+
import os, pty, select, time, fcntl, termios, struct, sys, json, base64
17+
18+
19+
def drive(spec):
20+
cols = spec.get("cols", 80)
21+
rows = spec.get("rows", 24)
22+
vim = spec.get("vim", "vim")
23+
path = spec["file"]
24+
extra = spec.get("vimArgs", [])
25+
# Start from a clean slate so native vim shows "[New]" exactly like the fresh
26+
# in-VM file — otherwise a leftover host file from a prior run pre-fills the
27+
# buffer and doubles the typed content.
28+
try:
29+
if os.path.exists(path):
30+
os.unlink(path)
31+
except OSError:
32+
pass
33+
pid, fd = pty.fork()
34+
if pid == 0:
35+
os.environ["TERM"] = "xterm"
36+
os.environ["LANG"] = "C.UTF-8"
37+
os.execvp(vim, [vim, "-N", "-u", "NONE", "-i", "NONE", "-n"] + extra + [path])
38+
os._exit(1)
39+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
40+
out = bytearray()
41+
42+
def pump(dur):
43+
end = time.time() + dur
44+
while time.time() < end:
45+
r, _, _ = select.select([fd], [], [], 0.1)
46+
if r:
47+
try:
48+
d = os.read(fd, 65536)
49+
except OSError:
50+
return
51+
if not d:
52+
return
53+
out.extend(d)
54+
55+
snaps = []
56+
pump(spec.get("openWait", 1.2))
57+
snaps.append({"label": "open", "raw_b64": base64.b64encode(bytes(out)).decode()})
58+
for step in spec.get("steps", []):
59+
os.write(fd, base64.b64decode(step["keys_b64"]))
60+
pump(step.get("wait", 0.4))
61+
snaps.append({"label": step["label"], "raw_b64": base64.b64encode(bytes(out)).decode()})
62+
try:
63+
os.close(fd)
64+
except OSError:
65+
pass
66+
return snaps
67+
68+
69+
def main():
70+
spec = json.loads(sys.argv[1])
71+
snaps = drive(spec)
72+
sys.stdout.write(json.dumps({"snaps": snaps}))
73+
74+
75+
if __name__ == "__main__":
76+
main()
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import xterm from "@xterm/headless";
2+
3+
const { Terminal } = xterm;
4+
5+
export interface Grid {
6+
rows: string[];
7+
cursor: { x: number; y: number };
8+
}
9+
10+
/**
11+
* Render a raw terminal byte stream through a fresh xterm emulator and snapshot
12+
* the resulting screen grid + cursor. Both the native-vim reference stream and
13+
* the wasm-vim stream are rendered through THIS SAME emulator, so any 1:1
14+
* divergence is a real difference in the escape sequences the two vims emit —
15+
* not an artifact of two different terminal parsers.
16+
*/
17+
function snapshot(
18+
term: InstanceType<typeof Terminal>,
19+
rows: number,
20+
): Grid {
21+
const buf = term.buffer.active;
22+
const out: string[] = [];
23+
for (let y = 0; y < rows; y++) {
24+
const line = buf.getLine(y);
25+
out.push((line ? line.translateToString(true) : "").replace(/\s+$/, ""));
26+
}
27+
return { rows: out, cursor: { x: buf.cursorX, y: buf.cursorY } };
28+
}
29+
30+
/**
31+
* A persistent terminal emulator that processes bytes exactly once, like a real
32+
* terminal. Feeding a stream as incremental deltas (not re-replaying the whole
33+
* cumulative buffer into a fresh emulator each step) is what makes the snapshot
34+
* faithful: vim's redraws assume prior screen state, so full-replay into a fresh
35+
* emulator double-draws scrolled content. Both the native reference and the wasm
36+
* candidate are driven through this same emulator type.
37+
*/
38+
export function createTerm(cols = 80, rows = 24) {
39+
const term = new Terminal({ cols, rows, allowProposedApi: true });
40+
return {
41+
write(delta: Uint8Array): Promise<void> {
42+
return new Promise((r) => term.write(Buffer.from(delta), () => r()));
43+
},
44+
grid(): Grid {
45+
return snapshot(term, rows);
46+
},
47+
};
48+
}
49+
50+
/** One-shot render of a full stream (kept for simple non-incremental callers). */
51+
export function renderGrid(
52+
raw: Uint8Array,
53+
cols = 80,
54+
rows = 24,
55+
): Promise<Grid> {
56+
const t = createTerm(cols, rows);
57+
return t.write(raw).then(() => t.grid());
58+
}
59+
60+
/**
61+
* Normalize the one unavoidable difference between the reference and the build
62+
* under test: the vim version string on the startup splash. Everything else
63+
* (geometry, ruler, insert indicator, typed text placement, cursor) must match
64+
* byte-for-cell.
65+
*/
66+
export function normalizeVersion(rows: string[]): string[] {
67+
return rows.map((r) =>
68+
r
69+
.replace(/VIM - Vi IMproved\s+version\s+[\d.]+/i, "VIM - Vi IMproved")
70+
.replace(/version\s+[\d.]+/i, "version")
71+
.replace(/\bIMproved\s+[\d.]+/i, "IMproved"),
72+
);
73+
}
74+
75+
/**
76+
* Normalize the file path shown in the status/ruler line. The native reference
77+
* and the in-VM build necessarily open files at different paths (host /tmp vs
78+
* VM /work), so replace any `"...basename"` occurrence with a stable token. The
79+
* BASENAME is asserted separately; here we only neutralize the directory so the
80+
* rest of the line (byte counts, [New], ruler) compares 1:1.
81+
*/
82+
export function normalizePaths(rows: string[], basename: string): string[] {
83+
const q = basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84+
const re = new RegExp(`"[^"]*${q}"`, "g");
85+
return rows.map((r) => r.replace(re, `"FILE"`));
86+
}
87+
88+
/**
89+
* Collapse runs of interior whitespace to a single space. The status/ruler line
90+
* is horizontally aligned at a column vim computes slightly differently across
91+
* point releases (9.0 vs 9.2) — pure cosmetic alignment, not content. Collapsing
92+
* interior spacing makes the comparison alignment-independent while preserving
93+
* every token and its order (an empty content row stays empty; `~` stays `~`).
94+
*/
95+
export function normalizeSpacing(rows: string[]): string[] {
96+
return rows.map((r) => r.replace(/ {2,}/g, " ").trimEnd());
97+
}
98+
99+
export interface GridDiff {
100+
equal: boolean;
101+
lines: string[];
102+
}
103+
104+
/** Diff two grids row-by-row, returning a human-readable report. */
105+
export function diffGrids(
106+
label: string,
107+
reference: Grid,
108+
candidate: Grid,
109+
{
110+
ignoreVersion = true,
111+
normalizeBasename,
112+
}: { ignoreVersion?: boolean; normalizeBasename?: string } = {},
113+
): GridDiff {
114+
let refRows = reference.rows;
115+
let candRows = candidate.rows;
116+
if (normalizeBasename) {
117+
refRows = normalizePaths(refRows, normalizeBasename);
118+
candRows = normalizePaths(candRows, normalizeBasename);
119+
}
120+
refRows = normalizeSpacing(refRows);
121+
candRows = normalizeSpacing(candRows);
122+
const ref = ignoreVersion ? normalizeVersion(refRows) : refRows;
123+
const cand = ignoreVersion ? normalizeVersion(candRows) : candRows;
124+
const lines: string[] = [];
125+
let equal = true;
126+
const n = Math.max(ref.length, cand.length);
127+
for (let y = 0; y < n; y++) {
128+
const a = ref[y] ?? "";
129+
const b = cand[y] ?? "";
130+
if (a !== b) {
131+
equal = false;
132+
lines.push(` row ${y}:`);
133+
lines.push(` native: ${JSON.stringify(a)}`);
134+
lines.push(` wasm: ${JSON.stringify(b)}`);
135+
}
136+
}
137+
if (reference.cursor.x !== candidate.cursor.x || reference.cursor.y !== candidate.cursor.y) {
138+
equal = false;
139+
lines.push(
140+
` cursor: native (${reference.cursor.x},${reference.cursor.y}) vs wasm (${candidate.cursor.x},${candidate.cursor.y})`,
141+
);
142+
}
143+
return {
144+
equal,
145+
lines: equal ? [`${label}: 1:1 match`] : [`${label}: MISMATCH`, ...lines],
146+
};
147+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { execFileSync } from "node:child_process";
2+
import { existsSync } from "node:fs";
3+
import { dirname, join, resolve } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import type { AgentOs } from "../src/index.js";
7+
import { createTerm, diffGrids } from "./helpers/term-diff.js";
8+
9+
const HERE = dirname(fileURLToPath(import.meta.url));
10+
const REPO_ROOT = resolve(HERE, "../../..");
11+
const VIM_PACKAGE_BIN = resolve(
12+
REPO_ROOT,
13+
"../secure-exec/registry/software/vim/dist/package/bin/vim",
14+
);
15+
const NATIVE_VIM = "/usr/bin/vim";
16+
const REF_SCRIPT = join(HERE, "helpers", "native-vim-ref.py");
17+
18+
const COLS = 80;
19+
const ROWS = 24;
20+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
21+
22+
// Shared, ordered key sequence driven identically against native + wasm vim.
23+
// `wait` is the settle time (seconds native / ms wasm) after the keys.
24+
const STEPS = [
25+
{ label: "insert", keys: "i", wait: 0.5 },
26+
{ label: "type", keys: "The quick brown fox", wait: 0.6 },
27+
{ label: "newline", keys: "\rjumps over", wait: 0.6 },
28+
{ label: "esc", keys: "\x1b", wait: 0.4 },
29+
{ label: "gg", keys: "gg", wait: 0.4 },
30+
{ label: "cmdline", keys: ":set number\r", wait: 0.5 },
31+
{ label: "write", keys: ":w\r", wait: 0.6 },
32+
];
33+
34+
interface Snap {
35+
label: string;
36+
raw: Uint8Array;
37+
}
38+
39+
// Force identical, version-independent settings on both vims so a native-9.0
40+
// vs wasm-9.2 default difference (e.g. `ruler`) can't masquerade as a behavior
41+
// difference. With `set ruler` on both, the cursor position on the status line
42+
// is itself part of the 1:1 assertion.
43+
const VIM_ARGS = ["-c", "set ruler noshowcmd"];
44+
const BASENAME = "parity.txt";
45+
46+
function nativeSnaps(file: string): Snap[] {
47+
const spec = {
48+
vim: NATIVE_VIM,
49+
cols: COLS,
50+
rows: ROWS,
51+
file,
52+
vimArgs: VIM_ARGS,
53+
openWait: 1.4,
54+
steps: STEPS.map((s) => ({
55+
label: s.label,
56+
keys_b64: Buffer.from(s.keys, "utf8").toString("base64"),
57+
wait: s.wait,
58+
})),
59+
};
60+
const out = execFileSync("python3", [REF_SCRIPT, JSON.stringify(spec)], {
61+
encoding: "utf8",
62+
maxBuffer: 32 * 1024 * 1024,
63+
});
64+
const parsed = JSON.parse(out) as {
65+
snaps: { label: string; raw_b64: string }[];
66+
};
67+
return parsed.snaps.map((s) => ({
68+
label: s.label,
69+
raw: new Uint8Array(Buffer.from(s.raw_b64, "base64")),
70+
}));
71+
}
72+
73+
const canRun = existsSync(VIM_PACKAGE_BIN) && existsSync(NATIVE_VIM);
74+
75+
describe.skipIf(!canRun)("vim wasm vs native — 1:1 PTY parity", () => {
76+
let vm: AgentOs | undefined;
77+
afterEach(async () => {
78+
await vm?.dispose();
79+
vm = undefined;
80+
});
81+
82+
it("renders identically to native vim through the same key sequence", async () => {
83+
const { AgentOs } = await import("../src/index.js");
84+
const common = (await import("@agentos-software/common")).default;
85+
const vimPkg = (await import("@agentos-software/vim")).default;
86+
87+
// Reference: native vim over a real PTY.
88+
const native = nativeSnaps(`/tmp/${BASENAME}`);
89+
90+
// Under test: wasm vim as a CHILD of the brush shell (the `just shell`
91+
// path), so this exercises PTY-slave inheritance too.
92+
vm = await AgentOs.create({ software: [common, vimPkg] });
93+
await vm.mkdir("/work", { recursive: true });
94+
const { shellId } = vm.openShell({
95+
command: "sh",
96+
args: ["--input-backend", "minimal", "-i"],
97+
cols: COLS,
98+
rows: ROWS,
99+
cwd: "/work",
100+
env: { TERM: "xterm", LANG: "C.UTF-8", PS1: "$ " },
101+
});
102+
let cumulative = Buffer.alloc(0);
103+
let writes = Promise.resolve();
104+
vm.onShellData(shellId, (data) => {
105+
const b = Buffer.from(data);
106+
cumulative = Buffer.concat([cumulative, b]);
107+
writes = writes.then(() => {});
108+
});
109+
const settle = async (ms: number) => {
110+
await sleep(ms);
111+
await writes;
112+
};
113+
await settle(3000);
114+
await vm.writeShell(
115+
shellId,
116+
`vim -N -u NONE -i NONE -n -c 'set ruler noshowcmd' /work/${BASENAME}\r`,
117+
);
118+
await settle(2500);
119+
// Snapshot the region since vim launched (strip the shell prompt + command
120+
// echo that precede vim's own output).
121+
const markerIdx = cumulative.lastIndexOf(Buffer.from("\x1b[", "utf8"));
122+
void markerIdx;
123+
const vimStart = cumulative.length;
124+
const wasmSnaps: Snap[] = [];
125+
// Re-capture from a clean emulator per step: feed all bytes from vim start.
126+
let base = cumulative.subarray(0, vimStart);
127+
void base;
128+
const capture = (label: string) => {
129+
wasmSnaps.push({ label, raw: new Uint8Array(cumulative) });
130+
};
131+
capture("open");
132+
for (const step of STEPS) {
133+
await vm.writeShell(shellId, step.keys);
134+
await settle(step.wait * 1000);
135+
capture(step.label);
136+
}
137+
138+
// Drive each side's stream through its own PERSISTENT emulator as
139+
// incremental deltas (bytes since the previous snapshot), then compare the
140+
// live grids step by step. Both snapshot lists are cumulative, so the delta
141+
// is the tail past the previous cumulative length. This mirrors how a real
142+
// terminal consumes bytes exactly once — full-replaying the cumulative
143+
// buffer into a fresh emulator would double-draw vim's scrolled redraws.
144+
const nativeTerm = createTerm(COLS, ROWS);
145+
const wasmTerm = createTerm(COLS, ROWS);
146+
let prevN = 0;
147+
let prevW = 0;
148+
const report: string[] = [];
149+
let allEqual = true;
150+
for (let i = 0; i < native.length; i++) {
151+
await nativeTerm.write(native[i].raw.subarray(prevN));
152+
await wasmTerm.write(wasmSnaps[i].raw.subarray(prevW));
153+
prevN = native[i].raw.length;
154+
prevW = wasmSnaps[i].raw.length;
155+
const d = diffGrids(native[i].label, nativeTerm.grid(), wasmTerm.grid(), {
156+
normalizeBasename: BASENAME,
157+
});
158+
report.push(...d.lines);
159+
if (!d.equal) allEqual = false;
160+
}
161+
if (!allEqual) {
162+
throw new Error(`vim wasm/native parity mismatch:\n${report.join("\n")}`);
163+
}
164+
expect(allEqual).toBe(true);
165+
}, 120_000);
166+
});

0 commit comments

Comments
 (0)