diff --git a/packages/runtime-tests/integration/subprocess-fixtures/testing-render-interactive.mjs b/packages/runtime-tests/integration/subprocess-fixtures/testing-render-interactive.mjs new file mode 100644 index 0000000..d5e349b --- /dev/null +++ b/packages/runtime-tests/integration/subprocess-fixtures/testing-render-interactive.mjs @@ -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 ` 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 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); diff --git a/packages/runtime-tests/integration/testing-render-ci.sequential.test.ts b/packages/runtime-tests/integration/testing-render-ci.sequential.test.ts new file mode 100644 index 0000000..0919cd3 --- /dev/null +++ b/packages/runtime-tests/integration/testing-render-ci.sequential.test.ts @@ -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 { + 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); +}); diff --git a/packages/runtime-tests/vite.config.ts b/packages/runtime-tests/vite.config.ts index e4acb29..4152eb2 100644 --- a/packages/runtime-tests/vite.config.ts +++ b/packages/runtime-tests/vite.config.ts @@ -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"], }, }); diff --git a/packages/testing/README.md b/packages/testing/README.md index c5fa575..45373b5 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -61,12 +61,13 @@ test("counter responds to + and - keys", async () => { 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 | +| 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` diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts index 1052367..58072fc 100644 --- a/packages/testing/src/render.ts +++ b/packages/testing/src/render.ts @@ -10,6 +10,13 @@ export interface RenderOptions { rows?: number; props?: Record; exitOnCtrlC?: boolean; + /** + * Whether the rendered app runs in interactive mode. Defaults to `true` so the + * harness is deterministic: `terminal.resize()` triggers a re-layout and the + * lifetime raw-mode hold engages regardless of the host environment. Set to + * `false` to assert non-interactive behavior. + */ + interactive?: boolean; } export interface Terminal { @@ -94,6 +101,14 @@ export async function render( stdin, stderr, debug: true, + // Pin interactive ON by default so the harness is deterministic and + // independent of ambient CI/TTY detection. The runtime otherwise derives + // `interactive = !isInCi && Boolean(stdout.isTTY)` (render.ts), and `isInCi` + // is evaluated ONCE at import time — so a consumer running tests in CI would + // silently get a non-interactive app: `terminal.resize()` would not re-lay-out + // and the lifetime raw-mode hold would never engage, breaking both APIs this + // helper advertises. `options.interactive` keeps non-interactive testable. + interactive: options.interactive ?? true, exitOnCtrlC: options.exitOnCtrlC ?? false, [INTERNAL_FRAME_SINK]: frameSink, } as Parameters[0]);