test(runtime-tests): guard the shipped examples launch+paint in CI (#212) (#220)

* fix(examples): restore flexDirection column on basic-template

A past restyle (#137) replaced the root Box's `flexDirection="column"
:paddingX="1"` with `backgroundColor/borderStyle/width="20"` and dropped
the column direction. With the Ink-aligned default (row) and a fixed
`width="20"`, the six children pack into ~3-char columns and the title
interleaves illegibly. Restore `flexDirection="column"` so the example
renders as the intended bordered card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(runtime-tests): PTY smoke suite guarding the examples launch+paint (#212)

#212's `Calling \`require\` for "node:module"` crash came from the old
@vue-tui/cli bundledDev step. The @vue-tui/vite plugin (#215) removed it,
but nothing exercised the shipped examples end to end, so a future
regression could break "the examples run" silently.

Add a node-pty smoke suite that launches basic-template and basic-jsx
through both the dev server (`vite`) and the production build
(`node dist/main.js`) under a real TTY and waits for the frame to paint;
coding-agent (needs an LLM key to run) gets a key-free build guard. A
static `CJS_REQUIRE_SHIM` assertion on the built bundle locks the #212
invariant directly, and `[vue-tui] failed to launch` / process-exit
signals fail a broken launch fast instead of burning the render timeout.
Wired in as the `ci:test:examples` branch of the CI graph.

Verified RED->GREEN: injecting a bare `require()` into an entry
reproduces #212 exactly and the guard catches it (statically and at
runtime); a non-module throw is caught via the dev launch-failure signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(runtime-tests): simplify the examples smoke harness

Cleanup pass on the new #212 smoke suite (no behavior change):
- use the `strip-ansi` package instead of a hand-rolled CSI regex (already a
  devDep here and used across the suite; strips OSC too, drops the eslint
  no-control-regex suppression);
- drop the onExitWaiters wake-up set — the 100ms poll already observes the exit
  flag, so onExit only needs to record the code;
- fold the three reject sites into one `fail()` helper;
- give the #212 bundle-shim invariant a single home: move CJS_REQUIRE_SHIM next
  to the builder and factor `buildAndExpectNoCjsRequire`, shared by the runnable
  apps and the coding-agent build guard;
- drop dead exports (repoRoot, CRASH_SIGNATURE).

Re-verified RED->GREEN (bundled require still caught) and all 5 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(runtime-tests): serialize the examples suite files (match #222)

Adversarial review flagged the examples config's parallel-safety comment as
imprecise: PTY-process isolation does NOT isolate on-disk state. Each launched
example writes its optimizeDeps cache (examples/<name>/node_modules/.vite) and
bundle (examples/<name>/dist), so two test files launching the SAME example at
once would race that shared dir — the exact #222 failure the sibling
@vue-tui/vite suite just fixed with fileParallelism:false.

It can't happen today (one serial file, per-example caches), but set
fileParallelism:false to match #222 and keep it safe as the suite grows, and
correct the comment to state the real guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-28 03:01:10 +08:00
committed by GitHub
parent 345f4a5428
commit 16e49024da
7 changed files with 265 additions and 3 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ useInput((input) => {
</script>
<template>
<Box backgroundColor="blue" borderStyle="round" width="20">
<Box flexDirection="column" backgroundColor="blue" borderStyle="round" width="20">
<Text bold color="cyan">vue-tui basic (template)</Text>
<Text dimColor>Try editing counter.vue or app.vue</Text>
<Text dimColor>Press c=toggle clock, q=quit</Text>
@@ -0,0 +1,94 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { test, expect, afterEach } from "vite-plus/test";
import { exampleDir, launch, viteBin, type Launched } from "./helpers/run-example.ts";
// End-to-end smoke test for the shipped examples (#212). The 0.1.0 crash —
// `Calling \`require\` for "node:module" in an environment that doesn't expose \`require\`` — came
// from the old @vue-tui/cli's bundledDev step, which folded CJS into a single ESM bundle. The
// @vue-tui/vite plugin (#215) deleted that path: dev runs in-process through Vite's SSR module
// runner and the production build externalizes every bare dep. This guards that the shipped
// examples still launch and paint, so a regression that reintroduces a module-system crash fails
// CI on every change.
//
// Why a real PTY: a TUI gates its full paint on an interactive TTY (`interactive = !isInCi && isTTY`),
// so a piped/non-TTY child renders nothing — a non-PTY smoke test would be a false negative. Each
// runnable example is launched under a pseudo-terminal and we wait for its title to paint.
//
// What each path actually guards (be precise, don't oversell):
// - dev (`vite`): the in-process dev server boots and paints. In THIS monorepo the dev path
// BUNDLES @vue-tui/runtime (the workspace symlink's real path is outside node_modules, so Vite's
// SSR runner re-executes it), so it can't reproduce #212's externalized-load crash — it guards
// the dev plugin itself (client-compile, CLI-shortcut neutralization, HMR bridge, blank paint).
// - build (`node dist/main.js`): the bundle externalizes @vue-tui/runtime and Node loads it via
// native ESM. This is the externalized launch guard; if a regression let a CJS `require` survive
// into the ESM bundle (the #212 fault class), the shim throws at startup and this goes red
// (verified by injecting a bare `require()` into an entry — it reproduces #212 exactly).
//
// Coverage boundary: the externalized *dev* path a published `npm install` takes (runtime resolved
// through the SSR runner's externalize/conditions, not bundled) cannot be reproduced from an
// in-repo example because the workspace symlink forces bundling. It is NOT covered here; guarding it
// would need a packed-install fixture and belongs with @vue-tui/vite's own suite.
// Both the template and JSX apps title themselves "vue-tui basic (…)". Letters-only this is
// "vuetuibasic", which the wrap-robust matcher in run-example.ts finds regardless of where the
// box's `width="20"` wraps the title. NB: basic-template must keep `flexDirection="column"` — the
// default row layout interleaves its sibling Texts column-by-column and breaks this contiguous
// token (the test then fails via timeout), so that prop is load-bearing here, not cosmetic.
const TITLE_TOKEN = "vue-tui basic";
// The two "hello world" apps are deterministic and key-free, so they get the full dev + build paint
// check. coding-agent uses the same @vue-tui/vite build but needs a live LLM key to RUN, so it gets
// a build-only guard below. flappy-bird is intentionally absent: it doesn't use @vue-tui/vite at all
// (it builds via `vp build` to dist/game.mjs), so it's outside the dev/build path #212 is about.
const RUNNABLE = [
{ name: "basic-template", dir: exampleDir("basic-template") },
{ name: "basic-jsx", dir: exampleDir("basic-jsx") },
] as const;
// The fingerprint #212 leaves in a built bundle: rolldown couldn't externalize a CJS `require`, so
// it emitted the runtime shim that throws on call. Asserting the bundle is free of this is a fast,
// deterministic #212 guard that needs no PTY and no API key — usable even for examples we can't run.
const CJS_REQUIRE_SHIM = /doesn't expose the `require` function|Calling `require` for/;
// Build an example and assert the bundle carries no #212 shim — the single home for that invariant,
// shared by the runnable apps (before they're launched) and the build-only coding-agent guard.
// `vite build` needs no TTY; a plain child process is enough. Bounded so a wedged build can't hang
// the worker (execFileSync blocks synchronously, so vitest's testTimeout can't preempt it).
function buildAndExpectNoCjsRequire(dir: string): void {
execFileSync("node", [viteBin(dir), "build"], {
cwd: dir,
stdio: "pipe",
timeout: 60000,
killSignal: "SIGKILL",
env: { ...process.env, CI: "false" },
});
expect(readFileSync(path.join(dir, "dist", "main.js"), "utf8")).not.toMatch(CJS_REQUIRE_SHIM);
}
let running: Launched | undefined;
afterEach(() => {
running?.kill();
running = undefined;
});
for (const ex of RUNNABLE) {
test(`${ex.name}: dev server (vite) launches and paints a frame`, async () => {
running = launch("node", [viteBin(ex.dir)], ex.dir);
await running.waitForRenderOrCrash(TITLE_TOKEN);
expect(running.output()).not.toMatch(CJS_REQUIRE_SHIM);
});
test(`${ex.name}: production build runs (node dist/main.js) and paints a frame`, async () => {
buildAndExpectNoCjsRequire(ex.dir);
running = launch("node", ["dist/main.js"], ex.dir);
await running.waitForRenderOrCrash(TITLE_TOKEN);
});
}
// coding-agent shares the @vue-tui/vite build path but needs an API key to run, so we can't paint
// it in CI. The build itself is key-free, so we still lock the #212 invariant where it matters.
test("coding-agent: production build succeeds with no bundled CJS require (#212)", () => {
buildAndExpectNoCjsRequire(exampleDir("coding-agent"));
});
@@ -0,0 +1,127 @@
import process from "node:process";
import { createRequire } from "node:module";
import path from "node:path";
import url from "node:url";
import stripAnsi from "strip-ansi";
// node-pty is a native addon shipped as CommonJS; load it through createRequire so this
// ESM helper can use it without a default-interop wrapper (same pattern as the other PTY helpers).
const require = createRequire(import.meta.url);
const { spawn } = require("node-pty") as typeof import("node-pty");
// This file lives at packages/runtime-tests/integration/examples/helpers/ — five segments below
// the repo root. The examples live at <root>/examples/<name>.
const repoRoot = url.fileURLToPath(new URL("../../../../../", import.meta.url));
export const exampleDir = (name: string): string => path.join(repoRoot, "examples", name);
// Resolve an example's local Vite CLI entry so we launch the SAME vite the example would (its
// workspace-pinned version), not whatever is hoisted at the repo root. Vite's `exports` doesn't
// expose ./bin/vite.js, so resolve its package.json and read the `bin` field instead.
export const viteBin = (cwd: string): string => {
const pkgPath = require.resolve("vite/package.json", { paths: [cwd] });
const pkg = require(pkgPath) as { bin?: string | Record<string, string> };
const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vite;
if (!rel) throw new Error(`could not locate vite's CLI bin from ${pkgPath}`);
return path.join(path.dirname(pkgPath), rel);
};
// Launch-failure signatures, so a broken example fails fast with a useful message instead of
// burning the whole render timeout. Two families:
// - module-system crashes (#212's `Calling \`require\` ... doesn't expose the \`require\``, plus the
// adjacent ESM/CJS-interop and resolution failures) — these surface on both dev and build;
// - `[vue-tui] failed to launch` — the dev plugin's own log when `runner.import(entry)` rejects.
// The dev SERVER does NOT exit when the entry throws (it stays up and logs), so neither the
// process-exit handler nor the module-system patterns would catch a generic entry crash on the
// dev path; this string does.
const CRASH_SIGNATURE =
/Calling `require`|doesn't expose the `require`|require is not defined|ERR_REQUIRE_ESM|ERR_MODULE_NOT_FOUND|ERR_UNSUPPORTED_|Cannot find (?:module|package)|\[vue-tui\] failed to launch/;
// Reduce a rendered frame to its letters so a wrap-robust token check survives the box border,
// padding, color escapes, and hard line-wraps the renderer inserts. basic-template pins its box to
// `width="20"`, so the title "vue-tui basic (template)" wraps mid-token in a real terminal; the
// characters are still emitted in order, so letters-only concatenation reconstructs the token
// regardless of where the wrap landed.
const lettersOnly = (s: string): string => stripAnsi(s).replace(/[^A-Za-z]/g, "");
export interface Launched {
output: () => string;
/**
* Resolve once the rendered frame contains `token` (compared letters-only, so terminal wrapping
* does not hide it). Reject immediately if a module-system crash signature appears, or after
* `timeoutMs` if neither happens.
*/
waitForRenderOrCrash: (token: string, timeoutMs?: number) => Promise<void>;
kill: () => void;
}
export function launch(cmd: string, args: string[], cwd: string): Launched {
let buf = "";
let exitCode: number | undefined;
const ps = spawn(cmd, args, {
name: "xterm-256color",
cols: 100,
rows: 24,
cwd,
// FORCE_COLOR so chalk emits ANSI in the non-TTY-parent test; CI:false so vue-tui's
// `interactive = !isInCi && isTTY` stays on under the runner's CI=true (the PTY is a real TTY).
env: {
...(process.env as Record<string, string>),
CI: "false",
FORCE_COLOR: "3",
NODE_NO_WARNINGS: "1",
},
});
ps.onData((d) => {
buf += d;
});
// A successful launch (a dev server or the live app) never exits on its own — it's killed by the
// test. So an exit BEFORE the frame paints is always a failure (a Vue/yoga/plugin error that
// exits, not just the module-system strings in CRASH_SIGNATURE); the poll below reports it with
// the exit code instead of a misleading render timeout. After a successful match the poll is gone,
// so the test's own kill() lands here harmlessly.
ps.onExit(({ exitCode: code }) => {
exitCode = code;
});
return {
output: () => buf,
kill: () => {
try {
ps.kill();
} catch {
// already gone
}
},
waitForRenderOrCrash: (token, timeoutMs = 20000) =>
new Promise<void>((resolve, reject) => {
const want = lettersOnly(token);
const cleanup = () => {
clearInterval(interval);
clearTimeout(timer);
};
const fail = (msg: string, detail = buf) => {
cleanup();
reject(new Error(`${msg}\n--- output ---\n${detail}`));
};
const check = () => {
// Token first: an app that paints and then exits should still pass. Then the explicit
// failure modes — a module-system signature, or any exit before the frame appeared.
if (lettersOnly(buf).includes(want)) {
cleanup();
resolve();
} else if (CRASH_SIGNATURE.test(buf)) {
fail("example crashed before rendering.");
} else if (exitCode !== undefined) {
fail(`example exited (code ${exitCode}) before painting "${token}".`);
}
};
const interval = setInterval(check, 100);
const timer = setTimeout(
// JSON.stringify so a "rendered nothing" timeout shows the empty/whitespace output plainly.
() => fail(`timed out after ${timeoutMs}ms waiting for "${token}".`, JSON.stringify(buf)),
timeoutMs,
);
check();
}),
};
}
+2 -1
View File
@@ -4,9 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"test": "vp run test:integration && vp run test:pty",
"test": "vp run test:integration && vp run test:pty && vp run test:examples",
"test:integration": "vp test",
"test:pty": "vp test run --config vitest.pty.config.ts --passWithNoTests",
"test:examples": "vp test run --config vitest.examples.config.ts --passWithNoTests",
"check:type": "tsc --noEmit && vp run check:fixtures",
"check:fixtures": "vue-tsc -p integration/pty/fixtures/tsconfig.json --noEmit"
},
+3 -1
View File
@@ -14,7 +14,9 @@ export default defineConfig({
// timing-sensitive render/commit/flush counts that destabilize under
// in-file concurrency on a constrained (4-core) CI runner.
// PTY tests run separately via vitest.pty.config.ts (they need node-pty's forks pool and a longer timeout)
exclude: ["integration/pty/**", "node_modules/**"],
// Examples smoke tests run separately via vitest.examples.config.ts (same forks-pool + headroom
// needs); exclude them here so they don't also run under this config's pool/timeout and execute twice.
exclude: ["integration/pty/**", "integration/examples/**", "node_modules/**"],
},
lint: {
ignorePatterns: ["integration/pty/fixtures/**", "integration/subprocess-fixtures/*.mjs"],
@@ -0,0 +1,28 @@
import { defineConfig } from "vite-plus";
// Examples smoke suite (#212). Each test spawns a real example through a pseudo-terminal — a dev
// server or a `node dist/main.js` build — and waits for it to paint. Kept separate from the unit
// and PTY-fixture suites because it shells out to `vite`/`node` and needs build-and-launch
// headroom rather than the fast in-process render timings the other configs assume.
export default defineConfig({
test: {
include: ["integration/examples/**/*.test.ts"],
// node-pty requires child_process.fork(), not worker threads, so the pool MUST be "forks".
pool: "forks",
// Serialize test FILES (same resolution as @vue-tui/vite's dev-server suite, PR #222). PTY-process
// isolation does NOT isolate on-disk state: a launched example writes its optimizeDeps cache to
// examples/<name>/node_modules/.vite and its bundle to examples/<name>/dist. Two files launching
// the SAME example at once would race that shared cache/dist dir — the #222 failure mode (one
// esbuild optimizer rmdir/rewrites .vite/deps mid-import → ENOTEMPTY locally, or a transport
// disconnect → "failed to launch" on a contended runner). It can't happen today (one serial file,
// and each example owns its cache), but pinning serial files keeps it safe as the suite grows.
fileParallelism: false,
// Build + cold Node start + first paint, plus CPU contention when this runs alongside the other
// suites under `vp run ready` / CI. Generous on purpose; a healthy launch resolves in seconds.
testTimeout: 60000,
hookTimeout: 60000,
// FORCE_COLOR so the spawned apps emit ANSI; CI:false so vue-tui's interactive detection stays
// on for the real PTY (the launch helper sets both per-child too, this covers the runner level).
env: { FORCE_COLOR: "3", CI: "false" },
},
});
+10
View File
@@ -65,6 +65,15 @@ export default defineConfig({
command: "vp run @vue-tui/vite#test",
dependsOn: ["ci:build"],
},
// Examples smoke suite (#212): launch examples/basic-template and basic-jsx through a real
// PTY — both the dev server and the `node dist/main.js` build — and assert each paints a
// frame with no module-system crash. This is the regression guard for "the shipped examples
// actually run". Depends on ci:build for the built @vue-tui/runtime + @vue-tui/vite dist the
// examples consume; its own PTY pool keeps it on a separate parallel branch.
"ci:test:examples": {
command: "vp run @vue-tui/runtime-tests#test:examples",
dependsOn: ["ci:build"],
},
ci: {
command: "echo ci ok",
dependsOn: [
@@ -76,6 +85,7 @@ export default defineConfig({
"ci:test:integration",
"ci:test:pty",
"ci:test:vite-plugin",
"ci:test:examples",
],
},
},