Files
vue-tui/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
T
Yunfei He bfd680490f refactor(runtime)!: rename useAppContext() to useApp() (full Ink alignment) (#73)
#69 added `useAppContext()` as a Vue-native rename of Ink's `useApp()`,
qualified to avoid reading as the Vue application instance. On reflection the
"Context" suffix borrowed the name of an internal grab-bag context and slightly
mislabels the hook — it returns app lifecycle controls, not that context. The
collision worry doesn't hold up: Vue has no `useApp()`, the returned
`{ exit, waitUntilRenderFlush }` is clearly not the Vue app instance, and "App"
in vue-tui already means the `TuiApp` from `createApp()`.

Rename to `useApp()` for full Ink fidelity (same name + same shape), and drop
the now-defunct "App composable" entry from ink-divergences.md — it ceases to
be a divergence.

Internal context cleanup (the grab-bag `AppContext` + the `StdinContext`
duplication) is intentionally out of scope here, tracked separately.

BREAKING CHANGE: `useAppContext()` is renamed to `useApp()`. Replace
`const { exit } = useAppContext()` with `const { exit } = useApp()`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:32:22 +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, useApp } 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 } = useApp();
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();
});
// useApp-level waitUntilRenderFlush tests (Ink parity, ported from Ink
// render.tsx "useApp waitUntilRenderFlush …"): waitUntilRenderFlush is reachable
// from INSIDE a component via useApp() — 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("useApp 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 } = useApp();
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("useApp 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 } = useApp();
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();
});