61e4e09a1e
* fix(runtime): wrap external stdout/stderr writes in synchronized-update markers (Ink parity, G09) writeToStdout/writeToStderr now emit bsu/esu around clear+write+restore when shouldSynchronize, matching the render path and Ink ink.tsx:687-728. The sync variable was already computed at mount time (render.ts:489); the external-write functions simply lacked the wrapping. For writeToStderr, BSU/ESU go to stdout (not stderr) because synchronized-update mode is a stdout capability — exactly mirroring Ink's ink.tsx:717-728 behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(parity): ledger — G09 pr-open Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
4.9 KiB
TypeScript
145 lines
4.9 KiB
TypeScript
/**
|
|
* Tests that writeToStdout / writeToStderr wrap external writes in
|
|
* synchronized-update markers (BSU/ESU) when the stream is a TTY and the
|
|
* runtime is in interactive mode — Ink parity G09.
|
|
*
|
|
* We use createApp with debug:false and a fake TTY stream so the interactive
|
|
* path is exercised. The test config forces CI:"false" so isInCi() returns
|
|
* false and shouldSynchronize() returns true.
|
|
*/
|
|
import { PassThrough } from "node:stream";
|
|
import { defineComponent } from "vue";
|
|
import { expect, test } from "vite-plus/test";
|
|
import { createApp, Text, useStdout, useStderr } from "@vue-tui/runtime";
|
|
|
|
const BSU = "\x1b[?2026h";
|
|
const ESU = "\x1b[?2026l";
|
|
|
|
function makeTtyStream(): NodeJS.WriteStream & { chunks: string[] } {
|
|
const s = new PassThrough() as unknown as NodeJS.WriteStream & { chunks: string[] };
|
|
Object.assign(s, { columns: 80, rows: 24, isTTY: true, chunks: [] as string[] });
|
|
s.on("data", (chunk: Buffer) => s.chunks.push(chunk.toString()));
|
|
return s;
|
|
}
|
|
|
|
function makeFakeStdin(): NodeJS.ReadStream {
|
|
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
Object.assign(s, {
|
|
isTTY: true,
|
|
setRawMode() {
|
|
return s;
|
|
},
|
|
setEncoding() {
|
|
return s;
|
|
},
|
|
});
|
|
(s as any).ref = () => {};
|
|
(s as any).unref = () => {};
|
|
return s;
|
|
}
|
|
|
|
test("writeToStdout wraps external write in BSU/ESU on TTY interactive stream", async () => {
|
|
const stdout = makeTtyStream();
|
|
const stderr = makeTtyStream();
|
|
const stdin = makeFakeStdin();
|
|
|
|
let writeRef: ((data: string) => void) | undefined;
|
|
|
|
const App = defineComponent(() => {
|
|
const { write } = useStdout();
|
|
writeRef = write;
|
|
return () => <Text>frame</Text>;
|
|
});
|
|
|
|
const app = createApp(App);
|
|
app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false });
|
|
|
|
// Let the initial render settle
|
|
await new Promise<void>((r) => setTimeout(r, 60));
|
|
|
|
// Clear captured output from initial render
|
|
stdout.chunks.length = 0;
|
|
|
|
// Trigger an external write through useStdout().write()
|
|
writeRef!("external-data\n");
|
|
|
|
// Collect everything written during this external-write call
|
|
const output = stdout.chunks.join("");
|
|
|
|
const bsuIdx = output.indexOf(BSU);
|
|
const dataIdx = output.indexOf("external-data");
|
|
const esuIdx = output.indexOf(ESU);
|
|
|
|
// BSU must appear before the data, and ESU must appear after BSU
|
|
expect(
|
|
bsuIdx,
|
|
`BSU (\\x1b[?2026h) must be present. Got: ${JSON.stringify(output)}`,
|
|
).toBeGreaterThanOrEqual(0);
|
|
expect(
|
|
dataIdx,
|
|
`external-data must be in output. Got: ${JSON.stringify(output)}`,
|
|
).toBeGreaterThanOrEqual(0);
|
|
expect(
|
|
esuIdx,
|
|
`ESU (\\x1b[?2026l) must be present. Got: ${JSON.stringify(output)}`,
|
|
).toBeGreaterThanOrEqual(0);
|
|
expect(bsuIdx).toBeLessThan(dataIdx);
|
|
expect(dataIdx).toBeLessThan(esuIdx);
|
|
|
|
app.unmount();
|
|
});
|
|
|
|
test("writeToStderr wraps external write in BSU/ESU on stdout (Ink parity: stderr gates on stdout TTY)", async () => {
|
|
const stdout = makeTtyStream();
|
|
const stderr = makeTtyStream();
|
|
const stdin = makeFakeStdin();
|
|
|
|
let writeRef: ((data: string) => void) | undefined;
|
|
|
|
const App = defineComponent(() => {
|
|
const { write } = useStderr();
|
|
writeRef = write;
|
|
return () => <Text>frame</Text>;
|
|
});
|
|
|
|
const app = createApp(App);
|
|
app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false });
|
|
|
|
// Let the initial render settle
|
|
await new Promise<void>((r) => setTimeout(r, 60));
|
|
|
|
// Clear captured output from initial render
|
|
stdout.chunks.length = 0;
|
|
stderr.chunks.length = 0;
|
|
|
|
// Record the cross-stream write order synchronously (writes are sync calls):
|
|
// BSU/ESU go to stdout, the data goes to stderr, so a per-stream string can't
|
|
// prove the interleaving. Tag each relevant write into one shared timeline.
|
|
const timeline: string[] = [];
|
|
const origStdoutWrite = stdout.write.bind(stdout);
|
|
const origStderrWrite = stderr.write.bind(stderr);
|
|
(stdout as { write: (d: unknown, ...a: unknown[]) => unknown }).write = (d, ...a) => {
|
|
const s = String(d);
|
|
if (s.includes(BSU)) timeline.push("BSU");
|
|
if (s.includes(ESU)) timeline.push("ESU");
|
|
return (origStdoutWrite as (d: unknown, ...a: unknown[]) => unknown)(d, ...a);
|
|
};
|
|
(stderr as { write: (d: unknown, ...a: unknown[]) => unknown }).write = (d, ...a) => {
|
|
if (String(d).includes("external-err")) timeline.push("DATA");
|
|
return (origStderrWrite as (d: unknown, ...a: unknown[]) => unknown)(d, ...a);
|
|
};
|
|
|
|
// Trigger an external write through useStderr().write()
|
|
writeRef!("external-err\n");
|
|
|
|
// Per Ink ink.tsx:717-728: bsu/esu are written to STDOUT (not stderr) and the
|
|
// data goes to stderr, as one atomic synchronized update. The interleaved
|
|
// order across both streams must be BSU → stderr data → ESU.
|
|
expect(
|
|
timeline,
|
|
`expected synchronized order BSU(stdout) → DATA(stderr) → ESU(stdout); got ${JSON.stringify(timeline)}`,
|
|
).toEqual(["BSU", "DATA", "ESU"]);
|
|
|
|
app.unmount();
|
|
});
|