Files
vue-tui/packages/runtime-tests/integration/lifecycle/instance-reuse-guard.test.tsx
T
Yunfei He 5d071d44ab docs(divergences): reframe second-mount no-op as an intentional, blessed divergence (#136)
A second mount() on a live stdout stays a warn + no-op (no behavior change).
This reframes it as a deliberate, maintainer-blessed choice instead of the
vaguely-justified entry it was.

- warning (render.ts): rewrite the stderr message to state the situation plus
  the two recovery paths (update reactive state, or unmount() the existing app
  first), replacing the old "unsupported / call unmount() first" phrasing.
- divergence doc: rewrite the Why with the real rationale (it is a misuse path;
  Ink warns it is unsupported too; vue-tui fails safe by keeping the live app
  and warning; there is no clean public path to Ink's reuse-and-rerender under
  the createApp model). Move the entry from "Vue-Idiomatic Choices" to
  "Intentional Divergence Choices"; record "Maintainer decision: KEEP"; tighten
  the inert-handle wording (unmount() only settles its own exit promise).
- tests: retarget the instance-reuse-guard assertions to the new warning text,
  including a negative assertion that previously matched a stale substring.

Verified: vp run ready (integration 1104 + PTY 123 passing, lint/type/build).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:17:53 +08:00

133 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* G14 – per-stdout instance-reuse guard (Ink parity).
*
* Mirrors Ink's WeakMap<WriteStream, Ink> / getInstance() contract:
* mounting a second vue-tui app on a stdout that already has a live (not-yet-
* unmounted) instance should warn on process.stderr and NOT wire a second
* renderer. After the first app unmounts, mounting on the same stdout again
* must work normally.
*/
import { defineComponent } from "vue";
import { expect, test, vi, afterEach } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import { makeFakeWritable, makeFakeStdin } from "./test-streams.ts";
afterEach(() => {
vi.restoreAllMocks();
});
test("warn + skip wiring when mount() is called on an already-live stdout", async () => {
const App = defineComponent(() => () => <Text>hello</Text>);
const stdout = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
// Use a separate fake stderr so we can observe writes to it without
// interfering with real process.stderr.
const stderrWrites: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrWrites.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
// ---- First app: mounts normally ----
const app1 = createApp(App);
app1.mount({ stdout, stdin, stderr: process.stderr, interactive: false });
// ---- Second app: same stdout → warn + no-op ----
const app2 = createApp(App);
// Intercept stdout writes after the second mount to verify the second app
// does NOT fire an initial renderer commit on the shared stream.
const originalWrite = stdout.write.bind(stdout);
let writeCountAfterSecondMount = 0;
stdout.write = ((...args: unknown[]) => {
writeCountAfterSecondMount++;
return (originalWrite as Function)(...args);
}) as NodeJS.WriteStream["write"];
app2.mount({ stdout, stdin, stderr: process.stderr, interactive: false });
// (a) A warning containing the key phrase was written to process.stderr.
stderrSpy.mockRestore();
expect(stderrWrites.join("")).toContain("this stdout already has a live app");
// (b) The second mount did NOT wire a second renderer: no additional writes
// to stdout happened immediately after the second mount (the second app
// performed no initial render commit on the shared stream).
// Give a tick for any async renders to fire.
await new Promise<void>((r) => setImmediate(r));
expect(writeCountAfterSecondMount).toBe(0);
// (c) app2.unmount() must NOT write anything to any stream (fake stdout OR
// real process.stdout). Before the fix, resolveExit() fell back to
// process.stdout and wrote an empty write-barrier chunk ("") when
// mountedAppContext was null — spy on both streams to catch that.
let writesFromApp2Unmount = 0;
const savedWrite = stdout.write.bind(stdout);
stdout.write = ((...args: unknown[]) => {
writesFromApp2Unmount++;
return (savedWrite as Function)(...args);
}) as NodeJS.WriteStream["write"];
const realStdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
app2.unmount();
// Restore spies before asserting.
stdout.write = savedWrite as NodeJS.WriteStream["write"];
const realStdoutCallCount = realStdoutWriteSpy.mock.calls.length;
realStdoutWriteSpy.mockRestore();
// No write to the fake shared stdout from app2's unmount.
expect(writesFromApp2Unmount).toBe(0);
// No write to real process.stdout from app2's unmount (the write-barrier
// fallback in resolveExit() must not fire for skipped apps).
expect(realStdoutCallCount).toBe(0);
// (d) app1 is still the live owner — a THIRD mount on the same stdout must
// still warn (proves app2.unmount() did not evict app1's WeakMap entry).
const thirdWrites: string[] = [];
const thirdSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
thirdWrites.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
const app3 = createApp(App);
app3.mount({ stdout, stdin, stderr: process.stderr, interactive: false });
thirdSpy.mockRestore();
expect(thirdWrites.join("")).toContain("this stdout already has a live app");
// Cleanup: app1 still owns the stream; unmount it cleanly.
app1.unmount();
});
test("unmounting first app allows a subsequent mount on the same stdout (no warning)", async () => {
const App = defineComponent(() => () => <Text>world</Text>);
const stdout = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const stderrWrites: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrWrites.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
// Mount app1, then unmount it.
const app1 = createApp(App);
app1.mount({ stdout, stdin, stderr: process.stderr, interactive: false });
app1.unmount();
// Mount app2 on the same stdout — must work normally (no warning).
const app2 = createApp(App);
app2.mount({ stdout, stdin, stderr: process.stderr, interactive: false });
stderrSpy.mockRestore();
const warnText = stderrWrites.join("");
expect(warnText).not.toContain("this stdout already has a live app");
// app2 renders cleanly.
await app2.waitUntilRenderFlush();
app2.unmount();
});