5d071d44ab
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>
133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|