fix(runtime): throw a descriptive error when raw mode is unsupported (Ink parity, G10) (#40)
* fix(runtime): throw a descriptive error when raw mode is unsupported (Ink parity, G10) Previously the raw-mode acquire path silently no-opped on a stdin where raw mode is unsupported (non-TTY / isRawModeSupported false), so using useInput on such a stdin did nothing with no diagnostic. Ink's handleSetRawMode (App.tsx:315-327) instead throws immediately when enabling raw mode is unsupported, with two distinct messages (default process.stdin vs a custom stdin) both pointing at the isRawModeSupported docs. StdinController.acquireRawMode now throws that two-message error on !isRawModeSupported. The unguarded useInput path surfaces it (matching Ink's use-input.ts, which calls setRawMode(true) ungated). useFocus now guards on isRawModeSupported before acquiring (matching Ink's use-focus.ts), so focus degrades to a safe no-op on a non-TTY rather than throwing. releaseRawMode keeps its no-op guard — Ink only throws when enabling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(parity): ledger — G10 pr-open, reconcile G09 merged Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(runtime): assert full raw-mode error message for exact Ink parity (G10, codex) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,8 +42,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
||||
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
|
||||
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | candidate | — (see ink-parity.md) | — |
|
||||
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
|
||||
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | pr-open | `fix/parity-external-bsu` | #39 |
|
||||
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — |
|
||||
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | merged | `fix/parity-external-bsu` | #39 |
|
||||
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | pr-open | `fix/parity-setrawmode-throw` | #40 |
|
||||
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — |
|
||||
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 |
|
||||
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { nextTick, defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { createApp, Text, useFocus, useInput } 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 {
|
||||
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(s, {
|
||||
isTTY: false,
|
||||
setEncoding(this: NodeJS.ReadStream) {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
(s as { ref?: () => void }).ref = () => {};
|
||||
(s as { unref?: () => void }).unref = () => {};
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mounts a component against a non-TTY stdin and resolves with any error that
|
||||
// surfaces through the app's exit promise (the error-boundary → exit path the
|
||||
// testing render() helper relies on), or undefined if it mounts cleanly.
|
||||
async function mountNonTtyAndCaptureError(component: Parameters<typeof createApp>[0]): Promise<{
|
||||
error: Error | undefined;
|
||||
unmount: () => void;
|
||||
}> {
|
||||
const stdout = makeFakeWritable();
|
||||
const stdin = makeNonTtyStdin();
|
||||
|
||||
const app = createApp(component);
|
||||
|
||||
let error: Error | undefined;
|
||||
app.waitUntilExit().catch((e) => {
|
||||
error = e as Error;
|
||||
});
|
||||
|
||||
try {
|
||||
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
// Flush the Vue queue so the error boundary → nextTick → exit → reject chain runs.
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
await Promise.resolve();
|
||||
|
||||
return { error, unmount: () => app.unmount() };
|
||||
}
|
||||
|
||||
// Test A: useInput on an unsupported stdin must surface Ink's descriptive error.
|
||||
test("useInput on a non-TTY stdin throws a descriptive raw-mode error", async () => {
|
||||
const App = defineComponent(() => {
|
||||
useInput(() => {});
|
||||
return () => <Text>listening</Text>;
|
||||
});
|
||||
|
||||
const { error, unmount } = await mountNonTtyAndCaptureError(App);
|
||||
unmount();
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
// Assert the FULL custom-stdin message (a non-process.stdin PassThrough hits the
|
||||
// "provided to Vue TUI" branch), so exact two-line parity with Ink's wording +
|
||||
// docs URL can't silently regress (mirrors Ink App.tsx:323-325).
|
||||
expect(error?.message).toBe(
|
||||
"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",
|
||||
);
|
||||
});
|
||||
|
||||
// Test B (regression): useFocus must guard like Ink's use-focus.ts and NOT throw
|
||||
// on a non-TTY stdin. This guards against over-throwing in the acquire chokepoint.
|
||||
test("useFocus on a non-TTY stdin does not throw (graceful no-op)", async () => {
|
||||
const App = defineComponent(() => {
|
||||
useFocus();
|
||||
return () => <Text>focusable</Text>;
|
||||
});
|
||||
|
||||
const { error, unmount } = await mountNonTtyAndCaptureError(App);
|
||||
unmount();
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
@@ -35,7 +35,11 @@ export function useFocus(options: UseFocusOptions = {}): {
|
||||
let rawModeAcquired = false;
|
||||
|
||||
function acquireRaw() {
|
||||
if (!rawModeAcquired && stdin) {
|
||||
// Guard on isRawModeSupported before acquiring — mirrors Ink's use-focus.ts
|
||||
// (`if (!isRawModeSupported || !isActive) return;`). acquireRawMode() throws
|
||||
// on an unsupported stdin (see render.ts), so without this guard useFocus
|
||||
// would throw on a non-TTY. Focus should degrade to a no-op there instead.
|
||||
if (!rawModeAcquired && stdin?.isRawModeSupported) {
|
||||
stdin.acquireRawMode();
|
||||
rawModeAcquired = true;
|
||||
}
|
||||
|
||||
@@ -1048,7 +1048,22 @@ function createStdinController(
|
||||
internal_eventEmitter: emitter,
|
||||
internal_exitOnCtrlC: opts.exitOnCtrlC,
|
||||
acquireRawMode() {
|
||||
if (!appCtx.isRawModeSupported) return;
|
||||
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",
|
||||
);
|
||||
}
|
||||
const state = getRawModeState(stdin);
|
||||
if (state.refs === 0) {
|
||||
state.prevRaw = (stdin as { isRaw?: boolean }).isRaw ?? false;
|
||||
|
||||
Reference in New Issue
Block a user