bfd680490f
#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>
365 lines
11 KiB
TypeScript
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();
|
|
});
|