Files
vue-tui/packages/runtime-tests/integration/pty/render.test.ts
T
Yunfei He 9e7449b1ee chore: exclude PTY fixtures from main tsconfig + lint
Fixture files use jsx: react-jsx (via their own tsconfig) and have
expected children type differences. Exclude from package-level checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:14:41 +08:00

218 lines
7.6 KiB
TypeScript

import { test, expect } from "vite-plus/test";
import ansiEscapes from "ansi-escapes";
import stripAnsi from "strip-ansi";
import term from "./helpers/term.ts";
const countOccurrences = (text: string, searchValue: string): number => {
if (searchValue === "") return 0;
return text.split(searchValue).length - 1;
};
const getIssue450ControlSequenceCounts = (output: string) => ({
clearTerminalCount: countOccurrences(output, ansiEscapes.clearTerminal),
eraseLineCount: (output.match(/\x1b\[\d*K/g) || []).length,
});
const runIssue450Fixture = async (fixture: string, rows = 6) => {
const ps = term(fixture, [String(rows)]);
await ps.waitForExit();
return ps.output;
};
const runIssue450FixtureWithCounts = async (fixture: string, rows = 6) => {
const output = await runIssue450Fixture(fixture, rows);
const { clearTerminalCount, eraseLineCount } = getIssue450ControlSequenceCounts(output);
return { output, clearTerminalCount, eraseLineCount };
};
const runIssue450FixtureBeforeMarker = async (fixture: string, marker: string, rows = 6) => {
const output = await runIssue450Fixture(fixture, rows);
const markerIndex = output.indexOf(marker);
expect(markerIndex).toBeGreaterThanOrEqual(0);
return markerIndex >= 0 ? output.slice(0, markerIndex) : output;
};
test.sequential("do not erase screen (content fits viewport)", async () => {
const ps = term("erase", ["4"]);
await ps.waitForExit();
expect(ps.output).not.toContain(ansiEscapes.clearTerminal);
for (const letter of ["A", "B", "C"]) {
expect(ps.output).toContain(letter);
}
});
test.sequential("do not erase screen where <Static> is taller than viewport", async () => {
const ps = term("erase-with-static", ["4"]);
await ps.waitForExit();
expect(ps.output).not.toContain(ansiEscapes.clearTerminal);
for (const letter of ["A", "B", "C", "D", "E", "F"]) {
expect(ps.output).toContain(letter);
}
});
test.sequential("erase screen (content overflows viewport)", async () => {
const ps = term("erase", ["3"]);
await ps.waitForExit();
expect(ps.output).toContain(ansiEscapes.clearTerminal);
for (const letter of ["A", "B", "C"]) {
expect(ps.output).toContain(letter);
}
});
test.sequential("erase screen where <Static> exists but interactive part is taller than viewport", async () => {
const ps = term("erase", ["3"]);
await ps.waitForExit();
expect(ps.output).toContain(ansiEscapes.clearTerminal);
for (const letter of ["A", "B", "C"]) {
expect(ps.output).toContain(letter);
}
});
test.sequential("erase screen where state changes", async () => {
const ps = term("erase-with-state-change", ["4"]);
await ps.waitForExit();
// The final frame is between the last eraseLines sequence and cursorShow
// Split on cursorShow to isolate the final rendered content before the cursor is shown
const beforeCursorShow = ps.output.split(ansiEscapes.cursorShow)[0];
expect(beforeCursorShow).toBeDefined();
// Find the last occurrence of an eraseLines sequence
// eraseLines(1) is the minimal erase pattern
const eraseLinesPattern = ansiEscapes.eraseLines(1);
const lastEraseIndex = beforeCursorShow!.lastIndexOf(eraseLinesPattern);
const lastFrame =
lastEraseIndex === -1
? beforeCursorShow!
: beforeCursorShow!.slice(lastEraseIndex + eraseLinesPattern.length);
const lastFrameContent = stripAnsi(lastFrame);
for (const letter of ["A", "B", "C"]) {
expect(lastFrameContent).not.toContain(letter);
}
});
test.sequential("erase screen where state changes in small viewport", async () => {
const ps = term("erase-with-state-change", ["3"]);
await ps.waitForExit();
const frames = ps.output.split(ansiEscapes.clearTerminal);
const lastFrame = frames.at(-1);
for (const letter of ["A", "B", "C"]) {
expect(lastFrame).not.toContain(letter);
}
});
test.sequential("fullscreen mode should not add extra newline at the bottom", async () => {
const ps = term("fullscreen-no-extra-newline", ["5"]);
await ps.waitForExit();
expect(ps.output).toContain("Bottom line");
const lastFrame = ps.output.split(ansiEscapes.clearTerminal).at(-1) ?? "";
// Check that the bottom line is at the end without extra newlines
// In a 5-line terminal:
// Line 1: Full-screen: top
// Lines 2-4: empty (from flexGrow)
// Line 5: Bottom line (should be usable)
const lines = lastFrame.split("\n");
expect(lines).toHaveLength(5);
expect(lines[4]).toContain("Bottom line");
});
test.sequential("#442: full terminal-size box should not add an extra scroll line", async () => {
const rows = 5;
const ps = term("issue-442-full-height", [String(rows)]);
await ps.waitForExit();
const lastFrame = ps.output.split(ansiEscapes.clearTerminal).at(-1) ?? "";
const lastFrameContent = stripAnsi(lastFrame);
const lines = lastFrameContent.split("\n");
expect(lastFrameContent).not.toMatch(/\n$/);
expect(lines).toHaveLength(rows);
expect(lines.at(-1)).toContain("#442 bottom");
});
// ── Issue #450 tests ────────────────────────────────────────────────
test.sequential("#450: full-height rerenders should not repeatedly clear terminal", async () => {
const { output, clearTerminalCount, eraseLineCount } = await runIssue450FixtureWithCounts(
"issue-450-full-height-rerender",
);
expect(output).toContain("frame 8");
expect(clearTerminalCount).toBeLessThanOrEqual(1);
expect(eraseLineCount).toBeGreaterThan(0);
});
test.sequential("#450: initial overflowing frame should not clear terminal", async () => {
const renderedMarker = "__INITIAL_OVERFLOW_FRAME_RENDERED__";
const outputBeforeMarker = await runIssue450FixtureBeforeMarker(
"issue-450-initial-overflow",
renderedMarker,
3,
);
expect(outputBeforeMarker).not.toContain(ansiEscapes.clearTerminal);
});
test.sequential("#450: initial full-height frame should not clear terminal", async () => {
const renderedMarker = "__INITIAL_FULLSCREEN_FRAME_RENDERED__";
const outputBeforeMarker = await runIssue450FixtureBeforeMarker(
"issue-450-initial-fullscreen",
renderedMarker,
3,
);
expect(outputBeforeMarker).not.toContain(ansiEscapes.clearTerminal);
});
test.sequential("#450: grow from rows - 1 to full-height should not clear before unmount", async () => {
const renderedMarker = "__GROW_TO_FULLSCREEN_RERENDER_COMPLETED__";
const outputBeforeMarker = await runIssue450FixtureBeforeMarker(
"issue-450-grow-to-fullscreen-rerender",
renderedMarker,
);
const { clearTerminalCount } = getIssue450ControlSequenceCounts(outputBeforeMarker);
expect(outputBeforeMarker).toContain("frame 8");
expect(clearTerminalCount).toBe(0);
});
test.sequential("#450: shrink from full-height to rows - 1 should clear exactly once", async () => {
const { output, clearTerminalCount } = await runIssue450FixtureWithCounts(
"issue-450-shrink-from-fullscreen-rerender",
);
expect(output).toContain("frame 8");
expect(clearTerminalCount).toBe(1);
});
// ── Animation exit tests ────────────────────────────────────────────
test.sequential("useAnimation can drive non-interactive process exit", async () => {
const ps = term("use-animation-non-interactive-exit");
await ps.waitForExit();
const plainOutput = stripAnsi(ps.output);
expect(plainOutput).toContain("exited");
});
test.sequential("useAnimation can drive explicitly non-interactive process exit", async () => {
const ps = term("use-animation-interactive-false-exit");
await ps.waitForExit();
const plainOutput = stripAnsi(ps.output);
expect(plainOutput).toContain("exited");
});