889e0cdb00
* fix(runtime): make error capture first-wins and crash-safe against a racing unmount Two confirmed bugs in the InternalErrorBoundary's onErrorCaptured: BUG #2 — a component error was silently swallowed when host code threw during an update flush and then synchronously called app.unmount() in the same task. The exit was routed entirely through `void nextTick(() => exitWithError(e))`, so pendingExitError was not recorded until that deferred microtask ran; the racing unmount's resolveExit() read it as undefined and RESOLVED the exit promise clean instead of REJECTING with the error. Fix: record the error SYNCHRONOUSLY via a new recordExitError() bridge (first-wins: only sets pendingExitError if no exit is already decided), while keeping teardown DEFERRED via nextTick. Deferring teardown is load-bearing — teardown()'s final mountedCommit() paints the ErrorOverview frame, and the boundary's errored->true re-render must commit before it; a synchronous exit would drop the overview frame on non-interactive/non-debug mounts. Frame/paint timing is now byte-identical to before in every mode. BUG #5 — two descendants throwing in the same synchronous flush left the displayed overview (caught, last-wins) and the rejected error (pendingExitError, first-wins) disagreeing. Fix: guard the capture body with `if (!errored.value)` so the first thrown error drives both the display and the rejection (e17). Tests: the racing-unmount swallow (interactive/debug AND non-interactive/ non-debug), the two-throw display/reject agreement, and frame-painting guards that pin the overview behavior to main in each mode. Also corrected a stale exit-chain comment in @vue-tui/testing's render(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(runtime): exit() must not clobber an error already recorded by the boundary (first-wins) Final review found an asymmetry: recordExitError() first-wins-guards its write, but appContext.exit() recorded the error unconditionally. So a captured throw (Error1, shown in the overview, recorded via recordExitError) followed by a racing exit(Error2) before the deferred teardown made waitUntilExit() reject Error2 while the overview displayed Error1 — the BUG #5 display/reject disagreement through a different door. Fix: exit() uses `pendingExitError ??= errorOrResult`, so it keeps a synchronously-recorded error. Identical to `=` in every other case (pendingExitError is undefined on a normal first exit()). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vue-tui/testing
Early stage — under active development. Bug reports welcome, but not recommended for production use yet.
Test harness for vue-tui — render Vue 3 terminal components, simulate input, assert frames. Like @testing-library, but for the terminal.
Why
- Isolated terminal — renders into a fake TTY, no real terminal needed
- Input simulation — inject keystrokes that reach
useInputhandlers - Frame snapshots — assert exact visual output with
lastFrame()andframes[] - Auto-cleanup — unmounts all rendered apps after each test (requires Vitest
globals: true)
Install
Assumes @vue-tui/runtime and vue are already installed in your project.
npm install -D @vue-tui/testing
Quick Start
import { defineComponent, shallowRef } from "vue";
import { expect, test } from "vitest";
import { render } from "@vue-tui/testing";
import { Box, Text, useInput } from "@vue-tui/runtime";
test("counter responds to + and - keys", async () => {
const Counter = defineComponent(() => {
const count = shallowRef(0);
useInput((input) => {
if (input === "+") count.value++;
if (input === "-") count.value--;
});
return () => (
<Box>
<Text>Count: {count.value}</Text>
</Box>
);
});
const { lastFrame, stdin } = await render(Counter);
expect(lastFrame()).toContain("Count: 0");
await stdin.write("+");
expect(lastFrame()).toContain("Count: 1");
await stdin.write("-");
expect(lastFrame()).toContain("Count: 0");
});
API
render(component, options?)
Mounts a component in a fake terminal environment. Returns a RenderResult.
| Option | Type | Default | Description |
|---|---|---|---|
columns |
number |
100 |
Terminal width in columns |
rows |
number |
100 |
Terminal height in rows |
props |
object |
— | Props passed to the root component |
exitOnCtrlC |
boolean |
false |
Enable Ctrl+C exit handling |
interactive |
boolean |
true |
Run interactively so terminal.resize() re-lays-out and raw mode engages. false to test non-interactive behavior. |
RenderResult
| Property / Method | Description |
|---|---|
lastFrame(opts?) |
Latest rendered frame as a string |
frames |
Array of all captured frame snapshots |
stdin.write(data) |
Inject input (reaches useInput handlers) |
terminal |
Fake terminal — columns, rows, resize(), rawMode |
unmount() |
Tear down the app |
waitUntilExit() |
Settles when the app exits (rejects if exit(error)) |
waitUntilRenderFlush() |
Resolves after the next render cycle completes |
cleanup()
Unmounts all rendered apps. Auto-registered as a Vitest afterEach hook when globals: true is set. Call manually if your test runner doesn't expose a global afterEach.
Links
- vue-tui — monorepo root
@vue-tui/runtime— the core framework@vue-tui/cli— dev server with HMR
License
MIT