diff --git a/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx b/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx index 73414dd..13034fd 100644 --- a/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx +++ b/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx @@ -1,16 +1,22 @@ import { PassThrough } from "node:stream"; -import { nextTick, defineComponent } from "vue"; +import { nextTick, defineComponent, onMounted, onUnmounted } from "vue"; import { expect, test } from "vite-plus/test"; -import { createApp, Text, useFocus, useInput } from "@vue-tui/runtime"; +import { createApp, Text, useFocus, useInput, useStdin } from "@vue-tui/runtime"; import { makeFakeWritable } from "../lifecycle/test-streams.ts"; // Builds a stdin that is NOT a TTY, so isRawModeSupported is false. This mirrors // piping input into a program (e.g. `echo x | node app.js`) where raw mode can't -// be enabled. Matches Ink's isRawModeSupported = stdin.isTTY check. -function makeNonTtyStdin(): NodeJS.ReadStream { +// be enabled. Matches Ink's isRawModeSupported = stdin.isTTY check. The optional +// setRawMode spy lets a test assert the underlying ioctl is NEVER issued on an +// unsupported stdin (parity with Ink's test/components.tsx setRawMode-throw test). +function makeNonTtyStdin(setRawModeCalls?: boolean[]): NodeJS.ReadStream { const s = new PassThrough() as unknown as NodeJS.ReadStream; Object.assign(s, { isTTY: false, + setRawMode(this: NodeJS.ReadStream, mode: boolean) { + setRawModeCalls?.push(mode); + return this; + }, setEncoding(this: NodeJS.ReadStream) { return this; }, @@ -87,3 +93,66 @@ test("useFocus on a non-TTY stdin does not throw (graceful no-op)", async () => expect(error).toBeUndefined(); }); + +// Test C: the PUBLIC useStdin().setRawMode must be SYMMETRIC on a non-TTY stdin — +// BOTH setRawMode(true) (enable) AND setRawMode(false) (disable) throw the same +// descriptive error, and the underlying stdin.setRawMode ioctl is never issued. +// Mirrors Ink's test/components.tsx "setRawMode() should throw if raw mode is not +// supported" (asserts didCatchInMount === 1 AND didCatchInUnmount === 1 AND +// !stdin.setRawMode.called) and Ink's handleSetRawMode (App.tsx:317-327), which +// guards at the TOP, before the enable/disable split. Before the fix vue threw on +// the enable branch (acquireRawMode) but silently no-opped the disable branch +// (releaseRawMode's `if (!isRawModeSupported) return`) — an asymmetry Ink lacks. +test("useStdin().setRawMode is symmetric on a non-TTY: both enable AND disable throw", async () => { + const setRawModeCalls: boolean[] = []; + const enableErrors: Error[] = []; + const disableErrors: Error[] = []; + + const App = defineComponent(() => { + const { setRawMode } = useStdin(); + + onMounted(() => { + try { + setRawMode(true); + } catch (e) { + enableErrors.push(e as Error); + } + }); + + onUnmounted(() => { + try { + setRawMode(false); + } catch (e) { + disableErrors.push(e as Error); + } + }); + + return () => test; + }); + + const stdout = makeFakeWritable(); + const stdin = makeNonTtyStdin(setRawModeCalls); + + const app = createApp(App); + app.waitUntilExit().catch(() => {}); + app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false }); + await nextTick(); + app.unmount(); + await nextTick(); + + const expectedMessage = + "Raw mode is not supported on the stdin provided to Vue TUI.\n" + + "Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported"; + + // Enable path throws (this already held before the fix). + expect(enableErrors).toHaveLength(1); + expect(enableErrors[0]?.message).toBe(expectedMessage); + + // Disable path throws too (this is the fix: pre-fix it silently no-opped). + expect(disableErrors).toHaveLength(1); + expect(disableErrors[0]?.message).toBe(expectedMessage); + + // The underlying terminal ioctl is never issued — both throws short-circuit + // before touching stdin.setRawMode (Ink: t.false(stdin.setRawMode.called)). + expect(setRawModeCalls).toEqual([]); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 9110a72..e104eb4 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -1511,9 +1511,35 @@ function createStdinController( // escape would survive into the next composable. let lifetimeFloor = 0; + // Match Ink's handleSetRawMode (App.tsx): raw mode on an unsupported stdin + // throws a descriptive error rather than silently no-opping. Two messages — + // one for the default process.stdin, one for a custom stream — both pointing + // at the isRawModeSupported docs. + const throwRawModeUnsupported = (): never => { + if (stdin === process.stdin) { + throw new Error( + "Raw mode is not supported on the current process.stdin, which Vue TUI uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", + ); + } + throw new Error( + "Raw mode is not supported on the stdin provided to Vue TUI.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", + ); + }; + const controller: StdinController = { stdin, setRawMode(mode: boolean) { + // Guard at the TOP — BEFORE the enable/disable split — so the PUBLIC + // useStdin().setRawMode throws symmetrically on an unsupported stdin, + // matching Ink's handleSetRawMode (App.tsx:317-327): both setRawMode(true) + // and setRawMode(false) throw. The guard lives here (not in the internal + // releaseRawMode) because the framework's own composables — useInput / + // useFocus / usePaste — call acquireRawMode()/releaseRawMode() DIRECTLY at + // teardown, and that internal release MUST stay a no-op so an unsupported- + // stdin app can unmount cleanly. Only this public wrapper enforces the + // symmetric throw. (acquireRawMode also throws on its own, so the enable + // path is unchanged for the unguarded useInput consumer.) + if (!appCtx.isRawModeSupported) throwRawModeUnsupported(); if (mode) { controller.acquireRawMode(); } else { @@ -1525,20 +1551,10 @@ function createStdinController( internal_exitOnCtrlC: opts.exitOnCtrlC, acquireRawMode() { if (!appCtx.isRawModeSupported) { - // Match Ink's handleSetRawMode (App.tsx): enabling raw mode on an - // unsupported stdin throws a descriptive error rather than silently - // no-opping. Two messages — one for the default process.stdin, one for - // a custom stream — both pointing at the isRawModeSupported docs. The - // unguarded useInput path surfaces this; useFocus guards before calling - // (see composables/useFocus.ts), so it degrades to a no-op like Ink. - if (stdin === process.stdin) { - throw new Error( - "Raw mode is not supported on the current process.stdin, which Vue TUI uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", - ); - } - throw new Error( - "Raw mode is not supported on the stdin provided to Vue TUI.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", - ); + // The unguarded useInput path surfaces this throw directly; useFocus + // guards before calling (see composables/useFocus.ts), so it degrades to + // a no-op like Ink. + throwRawModeUnsupported(); } const state = getRawModeState(stdin); if (state.refs === 0) {