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:
@@ -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,
|
||||
});
|
||||
+6
@@ -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",
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
|
||||
|
||||
runIssue450RerenderFixture({
|
||||
heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows : rows - 1),
|
||||
});
|
||||
+21
@@ -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");
|
||||
+21
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user