test: add issue-450 rerender + animation exit PTY fixtures + 7 tests

- issue-450-fixture-helpers.tsx with rerender/initial helpers
- 5 issue-450 fixture files (fullscreen, overflow, grow, shrink)
- 2 useAnimation exit fixtures (interactive false, non-interactive)
- 7 tests with exact clearTerminal/eraseLine count assertions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 23:12:00 +08:00
parent 34031f35b6
commit 54b5394e88
9 changed files with 329 additions and 0 deletions
@@ -0,0 +1,150 @@
import process from "node:process";
import { Box, Static, Text, createApp, useExit } from "@vue-tui/runtime";
import { defineComponent, onMounted, onScopeDispose, shallowRef, watch } from "vue";
type RerenderFixtureOptions = {
readonly completionMarker?: string;
readonly frameLimit?: number;
readonly includeStaticLine?: boolean;
readonly rowsFallback?: number;
readonly heightForFrame: (rows: number, frameCount: number) => number;
};
const Issue450RerenderFixtureComponent = defineComponent(
(props: {
completionMarker?: string;
frameLimit: number;
includeStaticLine: boolean;
heightForFrame: (rows: number, frameCount: number) => number;
rows: number;
}) => {
const exit = useExit();
const frameCount = shallowRef(0);
let timer: ReturnType<typeof setTimeout> | undefined;
// Mirror React useEffect: whenever frameCount changes, schedule next action
watch(
frameCount,
(count) => {
clearTimeout(timer);
if (count >= props.frameLimit) {
timer = setTimeout(() => {
if (props.completionMarker) {
process.stdout.write(props.completionMarker);
}
exit();
}, 0);
return;
}
timer = setTimeout(() => {
frameCount.value++;
}, 100);
},
{ immediate: true },
);
onScopeDispose(() => {
clearTimeout(timer);
});
return () => {
const targetHeight = props.heightForFrame(props.rows, frameCount.value);
return (
<>
{props.includeStaticLine ? (
<Static items={["#450 static line"]}>
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
</Static>
) : null}
<Box height={targetHeight} flexDirection="column">
<Text>#450 top</Text>
<Box flexGrow={1}>
<Text>{`frame ${frameCount.value}`}</Text>
</Box>
<Text>#450 bottom</Text>
</Box>
</>
);
};
},
{ props: ["completionMarker", "frameLimit", "includeStaticLine", "heightForFrame", "rows"] },
);
export const runIssue450RerenderFixture = ({
completionMarker,
frameLimit = 8,
includeStaticLine = false,
rowsFallback = 6,
heightForFrame,
}: RerenderFixtureOptions): void => {
const rows = Number(process.argv[2]) || rowsFallback;
process.stdout.rows = rows;
const app = createApp(Issue450RerenderFixtureComponent, {
completionMarker,
frameLimit,
includeStaticLine,
heightForFrame,
rows,
});
app.mount();
};
type InitialFixtureOptions = {
readonly rowsFallback?: number;
readonly renderedMarker: string;
readonly lineCount: number;
readonly linePrefix: string;
};
const Issue450InitialFixtureComponent = defineComponent(
(props: {
renderedMarker: string;
lineCount: number;
linePrefix: string;
}) => {
const exit = useExit();
onMounted(() => {
const timer = setTimeout(() => {
process.stdout.write(props.renderedMarker);
exit();
}, 0);
onScopeDispose(() => {
clearTimeout(timer);
});
});
return () => {
const lines = [];
for (let lineNumber = 1; lineNumber <= props.lineCount; lineNumber++) {
lines.push(<Text key={lineNumber}>{`${props.linePrefix} line ${lineNumber}`}</Text>);
}
return <Box flexDirection="column">{lines}</Box>;
};
},
{ props: ["renderedMarker", "lineCount", "linePrefix"] },
);
export const runIssue450InitialFixture = ({
rowsFallback = 3,
renderedMarker,
lineCount,
linePrefix,
}: InitialFixtureOptions): void => {
const rows = Number(process.argv[2]) || rowsFallback;
process.stdout.rows = rows;
const app = createApp(Issue450InitialFixtureComponent, {
renderedMarker,
lineCount,
linePrefix,
});
app.mount();
};
@@ -0,0 +1,5 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
heightForFrame: (rows) => rows,
});
@@ -0,0 +1,6 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
completionMarker: "__GROW_TO_FULLSCREEN_RERENDER_COMPLETED__",
heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows - 1 : rows),
});
@@ -0,0 +1,7 @@
import { runIssue450InitialFixture } from "./issue-450-fixture-helpers.js";
runIssue450InitialFixture({
renderedMarker: "__INITIAL_FULLSCREEN_FRAME_RENDERED__",
lineCount: 3,
linePrefix: "#450 initial fullscreen",
});
@@ -0,0 +1,7 @@
import { runIssue450InitialFixture } from "./issue-450-fixture-helpers.js";
runIssue450InitialFixture({
renderedMarker: "__INITIAL_OVERFLOW_FRAME_RENDERED__",
lineCount: 4,
linePrefix: "#450 initial overflow",
});
@@ -0,0 +1,5 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows : rows - 1),
});
@@ -0,0 +1,21 @@
import { Text, createApp, useAnimation, useExit } from "@vue-tui/runtime";
import { defineComponent, watch } from "vue";
const Spinner = defineComponent(() => {
const { frame } = useAnimation({ interval: 8 });
const exit = useExit();
watch(frame, (value) => {
if (value >= 3) {
exit();
}
});
return () => <Text>{String(frame.value)}</Text>;
});
const app = createApp(Spinner);
app.mount({ interactive: false });
await app.waitUntilExit();
console.log("exited");
@@ -0,0 +1,21 @@
import { Text, createApp, useAnimation, useExit } from "@vue-tui/runtime";
import { defineComponent, watch } from "vue";
const Spinner = defineComponent(() => {
const { frame } = useAnimation({ interval: 8 });
const exit = useExit();
watch(frame, (value) => {
if (value >= 3) {
exit();
}
});
return () => <Text>{String(frame.value)}</Text>;
});
const app = createApp(Spinner);
app.mount();
await app.waitUntilExit();
console.log("exited");
@@ -3,6 +3,35 @@ 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();
@@ -119,3 +148,81 @@ test.sequential(
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");
});