Files
vue-tui/packages/runtime-tests/integration/lifecycle/throttle.test.tsx
T
Yunfei He 1ffb847a65 feat: test parity final push — 64 new tests, BSU/ESU, clear() API
Closes remaining test gaps between vue-tui and Ink:

Features:
- Add synchronized output (BSU/ESU) via DEC private mode 2026
- Add clear() API to TuiApp for erasing rendered output

Bug fixes:
- Cancel scheduler trailing timer on teardown (prevents stale commits)
- Guard teardown writes against ended/destroyed streams
- Reorder teardown to cancel timer before final commit

Tests (64 new, 2 skipped for known feature gaps):
- 7 BSU/ESU shouldSynchronize tests
- 5 borderBackgroundColor tests
- 13 component edge cases (empty text, number child, OSC hyperlink
  wrap-width, bare-text-in-Box validation, transform multi-line,
  leading whitespace, link escape closing)
- 13 text-width/CJK tests (alignment, truncation, overlay edge cases)
- 4 throttle + unmount edge cases
- 10 waitUntilRenderFlush write-callback-level tests + 1 clear() test
- 3 exit re-entrance tests
- 5 PTY #450 regression tests + 4 inline #450 tests

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

300 lines
8.9 KiB
TypeScript

import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test, vi } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import stripAnsi from "strip-ansi";
import {
makeFakeStdin,
makeFakeWritable,
captureWrites,
getContentWrites,
} from "./test-streams.ts";
// Fake timer options: only fake setTimeout/clearTimeout/Date so that
// Vue's internal scheduler (nextTick, queueMicrotask, setImmediate) still
// runs on real clocks. This matches the pattern in use-animation.test.tsx.
const FAKE_TIMER_OPTS = {
shouldAdvanceTime: false,
toFake: ["setTimeout", "clearTimeout", "Date"] as ("setTimeout" | "clearTimeout" | "Date")[],
};
test("throttle renders to maxFps", async () => {
// Port of Ink's "throttle renders to maxFps" — verifies leading+trailing
// throttle pattern with maxFps=1 (1000ms window).
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Hello");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
maxFps: 1, // 1 Hz => ~1000ms throttle window
});
// Flush Vue scheduler so the initial commit fires (leading edge)
await nextTick();
await nextTick();
const initialWriteCount = writes.length;
expect(initialWriteCount).toBeGreaterThanOrEqual(1);
expect(writes.some((w) => w.includes("Hello"))).toBe(true);
// Trigger a rerender inside the throttle window
msg.value = "World";
await nextTick();
await nextTick();
// Throttling should have held — no new writes yet
const midWriteCount = writes.length;
expect(midWriteCount).toBe(initialWriteCount);
// Advance 999ms: still within window, no trailing call yet
vi.advanceTimersByTime(999);
expect(writes.length).toBe(midWriteCount);
// Cross the 1000ms boundary: trailing render fires
vi.advanceTimersByTime(1);
expect(writes.some((w) => w.includes("World"))).toBe(true);
app.unmount();
} finally {
vi.useRealTimers();
}
});
test("immediate scheduler in debug mode commits every mutation", async () => {
// Counterpart to the throttle test: in debug mode (used by the testing
// helper), the scheduler bypasses throttling and commits synchronously.
// This verifies the immediate path in createCommitScheduler.
const msg = shallowRef("A");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
debug: true,
exitOnCtrlC: false,
});
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("A"))).toBe(true);
msg.value = "B";
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("B"))).toBe(true);
msg.value = "C";
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("C"))).toBe(true);
app.unmount();
});
test("screen reader mode bypasses throttle (immediate commits)", async () => {
// Port of Ink's screen reader behavior: isScreenReaderEnabled causes
// the scheduler to use immediate mode, ensuring every frame is flushed
// without delay for assistive technology.
const msg = shallowRef("Hello");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
isScreenReaderEnabled: true,
});
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("Hello"))).toBe(true);
// Even rapid mutations should commit immediately (no throttle)
msg.value = "World";
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("World"))).toBe(true);
app.unmount();
});
test("no throttled renders after unmount", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Foo");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
const initialCount = getContentWrites(writes).length;
expect(initialCount).toBeGreaterThanOrEqual(1);
msg.value = "Bar";
await nextTick();
msg.value = "Baz";
await nextTick();
app.unmount();
const countAfterUnmount = getContentWrites(writes).length;
vi.advanceTimersByTime(1000);
expect(getContentWrites(writes).length).toBe(countAfterUnmount);
} finally {
vi.useRealTimers();
}
});
test("unmount forces pending throttled render", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
expect(stripAnsi(getContentWrites(writes)[0]!)).toContain("Hello");
msg.value = "Final";
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
app.unmount();
const allContent = getContentWrites(writes).map((w) => stripAnsi(w));
expect(allContent.some((c) => c.includes("Final"))).toBe(true);
} finally {
vi.useRealTimers();
}
});
test("unmount cancels pending throttled log writes when stdout is ended", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const { PassThrough } = await import("node:stream");
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const writeErrors: Error[] = [];
stdout.on("error", (error: Error) => writeErrors.push(error));
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
msg.value = "World";
await nextTick();
stdout.end();
app.unmount();
vi.advanceTimersByTime(1000);
const hasWriteAfterEndError = writeErrors.some(
(e) => (e as NodeJS.ErrnoException).code === "ERR_STREAM_WRITE_AFTER_END",
);
expect(hasWriteAfterEndError).toBe(false);
} finally {
vi.useRealTimers();
}
});
test("unmount cancels pending throttled render when stdout is ended", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const { PassThrough } = await import("node:stream");
// Baseline: mount + end + unmount without pending rerender
const baseStdout = new PassThrough() as unknown as NodeJS.WriteStream;
baseStdout.columns = 100;
const BaseApp = defineComponent(() => () => <Text>Hello</Text>);
const baseApp = createApp(BaseApp);
const baseStderr = makeFakeWritable({ columns: 80 });
const { stream: baseStdin } = makeFakeStdin();
baseApp.mount({
stdout: baseStdout,
stdin: baseStdin,
stderr: baseStderr,
exitOnCtrlC: false,
maxFps: 1,
});
await nextTick();
await nextTick();
baseStdout.end();
baseApp.unmount();
const baselineTimers = vi.getTimerCount();
vi.runAllTimers();
// Test: mount + rerender + end + unmount
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
msg.value = "World";
await nextTick();
stdout.end();
app.unmount();
expect(vi.getTimerCount()).toBe(baselineTimers);
} finally {
vi.useRealTimers();
}
});