fix(runtime): setRawMode(false) throws on unsupported stdin (Ink parity) (#122)
useStdin().setRawMode(false) on a non-TTY stdin silently no-opped while setRawMode(true) threw — an asymmetry. Ink's handleSetRawMode throws before the enable/disable split (App.tsx:315), so both directions throw on an unsupported stdin, and its test asserts both mount-enable and unmount-disable throw without ever calling stdin.setRawMode. Move the isRawModeSupported guard into the public setRawMode wrapper (via a shared throwRawModeUnsupported helper reusing the existing messages) so both true/false throw. Internal acquireRawMode/releaseRawMode are unchanged — composables (useInput/useFocus/usePaste) call those directly, so teardown release stays a no-op and an unsupported-stdin app still unmounts cleanly. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => <Text>test</Text>;
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user