Files
vue-tui/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
T
Yunfei He 9d1e4d7805 feat(runtime)!: replace useExit() with Ink-aligned useAppContext() (#69)
Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.

- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
  impl shared by the `TuiApp` handle and the composable, so both resolve
  identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
  fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
  React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
  (`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
  surface one — and fix the prior wrong claim that Ink's `useApp` returns
  stdin/stdout/stderr

The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.

BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:57:52 +08:00

365 lines
11 KiB
TypeScript

import { defineComponent, nextTick, onMounted, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
import stripAnsi from "strip-ansi";
import {
makeFakeWritable,
makeFakeStdin,
createDelayedWriteCallbackStdout,
isWriteBarrierChunk,
captureWrites,
getContentWrites,
} from "./test-streams.ts";
test("waitUntilRenderFlush resolves after frame is written", async () => {
const App = defineComponent(() => () => <Text>hello</Text>);
const result = await render(App);
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("hello");
});
test("waitUntilRenderFlush waits for pending state updates", async () => {
const msg = shallowRef("before");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const result = await render(App);
expect(result.lastFrame()).toContain("before");
msg.value = "after";
await nextTick();
await nextTick();
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("after");
});
test("waitUntilRenderFlush can be called multiple times", async () => {
const App = defineComponent(() => () => <Text>stable</Text>);
const result = await render(App);
await result.waitUntilRenderFlush();
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("stable");
});
// --- waitUntilRenderFlush write-callback level tests (ported from Ink render.tsx) ---
test("waitUntilRenderFlush resolves after stdout write callback", async () => {
let didInitialWriteCallbackFire = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) => !isWriteBarrierChunk(chunk),
onDelayElapsed: () => {
didInitialWriteCallbackFire = true;
},
});
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilRenderFlush();
expect(didInitialWriteCallbackFire).toBe(true);
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush flushes pending throttled render", async () => {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
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);
msg.value = "World";
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
await app.waitUntilRenderFlush();
expect(getContentWrites(writes).length).toBe(2);
expect(stripAnsi(getContentWrites(writes)[1]!)).toContain("World");
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush resolves when stdout is not writable", async () => {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
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);
msg.value = "World";
await nextTick();
(stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false;
await app.waitUntilRenderFlush();
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush waits for rerender write callback", async () => {
let didSecondWriteCallbackFire = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) =>
!isWriteBarrierChunk(chunk) &&
stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)).includes(
"World",
),
onDelayElapsed: () => {
didSecondWriteCallbackFire = true;
},
});
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilRenderFlush();
msg.value = "World";
await nextTick();
await nextTick();
await app.waitUntilRenderFlush();
expect(didSecondWriteCallbackFire).toBe(true);
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush waits for all concurrent waiters on the same rerender", async () => {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilRenderFlush();
msg.value = "World";
await nextTick();
await nextTick();
// Ensure the "World" render is fully written before concurrent waits.
// (PassThrough can backpressure if barriers queue behind pending writes.)
await app.waitUntilRenderFlush();
let waiter1Resolved = false;
let waiter2Resolved = false;
await Promise.all([
app.waitUntilRenderFlush().then(() => {
waiter1Resolved = true;
}),
app.waitUntilRenderFlush().then(() => {
waiter2Resolved = true;
}),
]);
// Both concurrent waiters resolved
expect(waiter1Resolved).toBe(true);
expect(waiter2Resolved).toBe(true);
// The "World" content was rendered
expect(getContentWrites(writes).some((w) => stripAnsi(w).includes("World"))).toBe(true);
app.unmount();
});
test("waitUntilRenderFlush resolves after unmount", async () => {
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
app.unmount();
await app.waitUntilExit();
await app.waitUntilRenderFlush();
});
test("waitUntilRenderFlush waits for unmount write callback", async () => {
let didUnmountWriteCallbackFire = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) => isWriteBarrierChunk(chunk),
onDelayElapsed: () => {
didUnmountWriteCallbackFire = true;
},
});
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
app.unmount();
await app.waitUntilRenderFlush();
expect(didUnmountWriteCallbackFire).toBe(true);
});
test("waitUntilRenderFlush resolves after exit with error", async () => {
let exitFn!: (err: Error) => void;
const App = defineComponent(() => {
const { exit } = useAppContext();
onMounted(() => {
exitFn = exit as (err: Error) => void;
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
exitFn(new Error("boom"));
await expect(app.waitUntilExit()).rejects.toThrow("boom");
await app.waitUntilRenderFlush();
});
// useAppContext-level waitUntilRenderFlush tests (Ink parity, ported from Ink
// render.tsx "useApp waitUntilRenderFlush …"): waitUntilRenderFlush is reachable
// from INSIDE a component via useAppContext() — Ink's useApp() returns the same
// { exit, waitUntilRenderFlush } pair. Ink's third "queued in same effect tick"
// test relies on React `concurrent: true` (concurrent mode is N/A in Vue — see
// .agents/docs/ink-divergences.md), so only the first two are ported.
test("useAppContext waitUntilRenderFlush resolves after the first frame write callback", async () => {
let didInitialWriteCallbackFire = false;
let didFlushResolve = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) => !isWriteBarrierChunk(chunk),
onDelayElapsed: () => {
didInitialWriteCallbackFire = true;
},
});
const App = defineComponent(() => {
const { exit, waitUntilRenderFlush } = useAppContext();
onMounted(() => {
void (async () => {
await waitUntilRenderFlush();
didFlushResolve = true;
exit();
})();
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilExit();
expect(didInitialWriteCallbackFire).toBe(true);
expect(didFlushResolve).toBe(true);
});
test("useAppContext waitUntilRenderFlush waits for state update frame flush", async () => {
let didWorldWriteCallbackFire = false;
let didFlushResolve = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) =>
!isWriteBarrierChunk(chunk) &&
stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)).includes(
"World",
),
onDelayElapsed: () => {
didWorldWriteCallbackFire = true;
},
});
const text = shallowRef("Hello");
const App = defineComponent(() => {
const { exit, waitUntilRenderFlush } = useAppContext();
onMounted(() => {
void (async () => {
// Settle the initial "Hello" frame first (not delayed by the harness,
// which only delays the "World" chunk) so the subsequent "World" write
// is unambiguously a second, state-update frame — mirroring the stable
// app-level "waits for rerender write callback" test above.
await waitUntilRenderFlush();
text.value = "World";
await nextTick();
await nextTick();
await waitUntilRenderFlush();
didFlushResolve = true;
exit();
})();
});
return () => <Text>{text.value}</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilExit();
expect(didWorldWriteCallbackFire).toBe(true);
expect(didFlushResolve).toBe(true);
});
// --- clear() API test ---
test("clear output", async () => {
const msg = shallowRef("A\nB\nC");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("A"))).toBe(true);
app.clear();
msg.value = "D";
await nextTick();
await nextTick();
await app.waitUntilRenderFlush();
// After clear + rerender, "D" should appear in content writes
const contentWrites = getContentWrites(writes);
expect(contentWrites.some((w) => stripAnsi(w).includes("D"))).toBe(true);
app.unmount();
});