fix(testing): pin interactive:true in render() so resize/rawMode are deterministic (#171)
render() never set `interactive`, so the runtime derived it as `!isInCi && Boolean(stdout.isTTY)`. `is-in-ci` is evaluated once at module import, so consumers running @vue-tui/testing in CI silently got a non-interactive app: `terminal.resize()` emitted but never re-laid-out (the resize handler is registered only when interactive), and the lifetime raw-mode hold never engaged (`terminal.rawMode.current` stayed false) — breaking both APIs the README advertises. Pin `interactive: options.interactive ?? true` in the mount options so the harness is deterministic and independent of ambient CI/TTY detection, and expose `interactive?: boolean` on RenderOptions so non-interactive behavior stays testable. Runtime behavior is unchanged. Add a subprocess test (runtime-tests, sequential — depends on the process-global CI env baked into the child at import time) that spawns the BUILT dist with CI=true vs CI=false, renders a bordered Box that fills the columns, resizes 40→12, and asserts the re-layout happened and raw mode is held. It fails on origin/main (resize ignored under CI=true) and passes with the fix. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// Subprocess fixture for testing-render-ci.sequential.test.ts.
|
||||
//
|
||||
// Imports the BUILT, PUBLISHED dist of @vue-tui/testing (the artifact consumers
|
||||
// install) and exercises the two interactive-only APIs render() advertises:
|
||||
// - terminal.resize() → layout-driven components must re-lay-out
|
||||
// - terminal.rawMode → the lifetime raw-mode hold must engage
|
||||
//
|
||||
// The parent test spawns this with CI=true vs CI=false to prove render() pins
|
||||
// `interactive` deterministically instead of inheriting the ambient CI/TTY-
|
||||
// derived default (which silently disabled both APIs for consumers in CI).
|
||||
//
|
||||
// Plain ESM with h() (no JSX/tsx) so `node <file>` runs it directly, matching
|
||||
// how the published consumer would call the dist.
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text } from "@vue-tui/runtime";
|
||||
import { h } from "vue";
|
||||
|
||||
// Width of the box's top border line, ANSI-stripped. A bordered <Box> with no
|
||||
// explicit width fills the terminal columns, so this tracks the laid-out width.
|
||||
function topBorderWidth(frame) {
|
||||
const line = (frame ?? "").split("\n").find((l) => l.includes("╭") || l.includes("┌")) ?? "";
|
||||
// Control-char class is required to strip terminal SGR escapes byte-faithfully.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return line.replace(/\x1b\[[0-9;]*m/g, "").trim().length;
|
||||
}
|
||||
|
||||
const App = () => h(Box, { borderStyle: "round" }, () => h(Text, () => "x"));
|
||||
|
||||
const r = await render(App, { columns: 40, rows: 10 });
|
||||
const before = topBorderWidth(r.lastFrame());
|
||||
|
||||
await r.terminal.resize(12, 6);
|
||||
const after = topBorderWidth(r.lastFrame());
|
||||
|
||||
// Single machine-readable line the parent test parses.
|
||||
process.stdout.write(JSON.stringify({ before, after, rawMode: r.terminal.rawMode.current }) + "\n");
|
||||
|
||||
r.unmount();
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,76 @@
|
||||
// SEQUENTIAL: spawns child Node processes whose behavior is governed by the
|
||||
// PROCESS-GLOBAL `CI` env var. `is-in-ci` reads it ONCE at module-import time, so
|
||||
// the bug under test (consumers running @vue-tui/testing in CI) can only be
|
||||
// reproduced in a fresh process with CI baked into its env — an in-process test
|
||||
// under this repo's forced CI=false cannot see it. Children also each get a fresh
|
||||
// chalk, so FORCE_COLOR is set per-spawn (project rule). Grouped here, off the
|
||||
// parallel pool, because it shells out and asserts cross-process behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = path.join(here, "subprocess-fixtures", "testing-render-interactive.mjs");
|
||||
|
||||
type FixtureResult = { before: number; after: number; rawMode: boolean };
|
||||
|
||||
// Run the fixture (which imports the BUILT @vue-tui/testing dist) in a child
|
||||
// process with an explicit CI value. cwd is this package so the workspace dist
|
||||
// of @vue-tui/testing + @vue-tui/runtime resolves.
|
||||
function runFixture(ci: "true" | "false"): Promise<FixtureResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [fixture], {
|
||||
cwd: path.resolve(here, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
// Bake the CI value into the child so its import-time is-in-ci sees it.
|
||||
CI: ci,
|
||||
// Each child is a fresh Node with its own chalk; force ANSI so the
|
||||
// bordered frame actually renders box-drawing chars (project rule).
|
||||
FORCE_COLOR: "3",
|
||||
NODE_NO_WARNINGS: "1",
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (d) => (stdout += d));
|
||||
child.stderr.on("data", (d) => (stderr += d));
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`fixture exited ${code} (CI=${ci})\n${stderr}\n${stdout}`));
|
||||
return;
|
||||
}
|
||||
const last = stdout.trim().split("\n").at(-1) ?? "";
|
||||
try {
|
||||
resolve(JSON.parse(last) as FixtureResult);
|
||||
} catch {
|
||||
reject(new Error(`could not parse fixture output (CI=${ci}): ${stdout}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// render() must pin interactive ON so its advertised resize()/rawMode APIs work
|
||||
// for consumers regardless of ambient CI/TTY detection. Before the fix, a CI=true
|
||||
// consumer silently lost both: resize() was ignored (no re-layout) and the
|
||||
// lifetime raw-mode hold never engaged.
|
||||
test("@vue-tui/testing render() honors resize() under CI=true (interactive pinned)", async () => {
|
||||
const result = await runFixture("true");
|
||||
// The bordered box fills the terminal: 40 cols → 12 cols after resize.
|
||||
expect(result.before).toBe(40);
|
||||
expect(result.after).toBe(12); // pre-fix this was 40 — resize ignored.
|
||||
// The lifetime raw-mode hold must engage too (advertised in the README).
|
||||
expect(result.rawMode).toBe(true); // pre-fix this was false.
|
||||
});
|
||||
|
||||
test("@vue-tui/testing render() behaves identically under CI=false", async () => {
|
||||
// Sanity that pinning interactive is a no-op for the already-interactive path:
|
||||
// CI=false produced correct behavior before and after the fix.
|
||||
const result = await runFixture("false");
|
||||
expect(result.before).toBe(40);
|
||||
expect(result.after).toBe(12);
|
||||
expect(result.rawMode).toBe(true);
|
||||
});
|
||||
@@ -17,6 +17,6 @@ export default defineConfig({
|
||||
exclude: ["integration/pty/**", "node_modules/**"],
|
||||
},
|
||||
lint: {
|
||||
ignorePatterns: ["integration/pty/fixtures/**"],
|
||||
ignorePatterns: ["integration/pty/fixtures/**", "integration/subprocess-fixtures/*.mjs"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user