diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dda78942..872fb90c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,16 @@ jobs: shell: pwsh run: pnpm build + # Hard gate for #25-class regressions: fails the run when the OpenTUI + # runtime chunk is missing its exports. Runs only on the Windows lane + # (which already produces a build); tsdown output is platform-shape- + # consistent, so one lane covers the build regression. Enforced now that + # #28 (the build fix that makes the chunk non-empty) has landed on main — + # the chunk is expected to carry its exports, so a miss is a real failure. + - name: Build-output sentinel (#25 regression guard) + if: matrix.os == 'windows-latest' + run: node scripts/check-build-output.mjs --enforce + - name: Install with Windows script if: matrix.os == 'windows-latest' shell: pwsh diff --git a/scripts/check-build-output.mjs b/scripts/check-build-output.mjs new file mode 100644 index 00000000..eacde42e --- /dev/null +++ b/scripts/check-build-output.mjs @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Sentinel check that guards against #25-class regressions: if the bundler +// (tsdown / rolldown) ever tree-shakes the OpenTUI runtime module's exports +// away again, `step` fails at runtime with +// "OpenTUI runtime did not export createLocalTuiClientApp()". Neither the +// installer smoke test nor CI exercise the interactive TUI path, so an empty +// chunk can ship undetected. This check surfaces it in CI logs instead. +// +// The check has two modes. Default is NON-BLOCKING: it logs a warning and +// exits 0, which let it ship independently of #28 (the build fix that makes +// the chunk non-empty) while that fix was still in flight. Now that #28 has +// landed on `main`, CI runs this with `--enforce`, which makes a missing/empty +// chunk fail the run — a real hard gate. The non-blocking default is kept for +// local runs where you just want a heads-up without a non-zero exit. +// +// Run after `pnpm build`. + +export const REQUIRED_OPENTUI_RUNTIME_SYMBOLS = [ + "createLocalTuiClientApp", + "LocalStepCliTuiApp", +]; + +const LOCAL_TUI_APP_CHUNK_PATTERN = /^local-tui-app.*\.js$/u; + +/** + * Recursively collect every `local-tui-app*.js` chunk under `dir`. Matches the + * post-#28 layout (`dist/runtime/local-tui-app.js`) and any hashed variant the + * bundler may emit. Source maps and the cjs build are skipped — the esm chunk + * is what the Node launcher resolves via the dynamic import. + */ +export function findLocalTuiAppChunks(dir) { + const matches = []; + + if (!fs.existsSync(dir)) { + return matches; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + matches.push(...findLocalTuiAppChunks(fullPath)); + continue; + } + + if ( + entry.isFile() && + LOCAL_TUI_APP_CHUNK_PATTERN.test(entry.name) && + !entry.name.endsWith(".map") + ) { + matches.push(fullPath); + } + } + + return matches; +} + +/** + * Given a chunk's file contents, return the required symbols that are absent. + * Empty array means the chunk is complete. + */ +export function findMissingSymbols( + content, + required = REQUIRED_OPENTUI_RUNTIME_SYMBOLS, +) { + return required.filter((symbol) => !content.includes(symbol)); +} + +// Only run the CLI entrypoint when executed directly (`node scripts/check-build-output.mjs`), +// not when imported by tests. +const isMain = + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + const enforce = process.argv.slice(2).includes("--enforce"); + const DIST_DIR = path.resolve("dist"); + + const report = (lines) => { + const prefix = enforce ? "✗" : "⚠"; + for (const line of lines) { + console.error(`${prefix} ${line}`); + } + }; + + const chunks = findLocalTuiAppChunks(DIST_DIR); + + if (chunks.length === 0) { + report([ + "build output check: no local-tui-app chunk found under dist/", + "Run `pnpm build` first; if you already did, the tsdown entry for src/runtime/local-tui-app.ts may be missing.", + ]); + process.exit(enforce ? 1 : 0); + } + + const failures = []; + + for (const chunk of chunks) { + const missing = findMissingSymbols(fs.readFileSync(chunk, "utf8")); + + if (missing.length > 0) { + failures.push({ chunk, missing }); + } + } + + if (failures.length > 0) { + report([ + "build output check: OpenTUI runtime exports missing from built chunk(s)", + ...failures.map( + ({ chunk, missing }) => + ` ${path.relative(DIST_DIR, chunk)}: missing ${missing.join(", ")}`, + ), + "This is the #25 regression — the bundler tree-shook the dynamic-import target. Verify the tsdown entry for local-tui-app is present.", + ]); + process.exit(enforce ? 1 : 0); + } + + console.log( + `✓ build output check passed: local-tui-app chunk exports ${REQUIRED_OPENTUI_RUNTIME_SYMBOLS.join(", ")}`, + ); +} diff --git a/scripts/check-build-output.test.ts b/scripts/check-build-output.test.ts new file mode 100644 index 00000000..b6bcc75b --- /dev/null +++ b/scripts/check-build-output.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + findLocalTuiAppChunks, + findMissingSymbols, + REQUIRED_OPENTUI_RUNTIME_SYMBOLS, +} from "./check-build-output.mjs"; + +const tmpDirs: string[] = []; + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop()!; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTmpDist(tree: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "step-build-check-")); + tmpDirs.push(root); + for (const [relPath, content] of Object.entries(tree)) { + const fullPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + return root; +} + +describe("findLocalTuiAppChunks", () => { + it("finds the post-#28 layout dist/runtime/local-tui-app.js", () => { + const root = makeTmpDist({ + "runtime/local-tui-app.js": "export {}", + "runtime/local-tui-app.js.map": "{}", + "runtime/local-opentui-entry.js": "export {}", + }); + + const chunks = findLocalTuiAppChunks(root); + expect(chunks).toEqual([path.join(root, "runtime", "local-tui-app.js")]); + }); + + it("finds hashed chunk variants emitted directly under dist/", () => { + const root = makeTmpDist({ + "local-tui-app-dRVjt31N.js": "export {}", + "local-tui-app-dRVjt31N.js.map": "{}", + }); + + const chunks = findLocalTuiAppChunks(root); + expect(chunks).toEqual([path.join(root, "local-tui-app-dRVjt31N.js")]); + }); + + it("skips source maps and the cjs build", () => { + const root = makeTmpDist({ + "runtime/local-tui-app.js": "export {}", + "runtime/local-tui-app.cjs": "export {}", + "runtime/local-tui-app.js.map": "{}", + }); + + const chunks = findLocalTuiAppChunks(root); + expect(chunks).toEqual([path.join(root, "runtime", "local-tui-app.js")]); + }); + + it("returns an empty list when dist/ is missing (instead of throwing)", () => { + expect( + findLocalTuiAppChunks(path.join(os.tmpdir(), "does-not-exist")), + ).toEqual([]); + }); +}); + +describe("findMissingSymbols", () => { + it("returns no missing symbols for a chunk that exports everything", () => { + const content = ` + export class LocalStepCliTuiApp {} + export async function createLocalTuiClientApp() {} + `; + expect(findMissingSymbols(content)).toEqual([]); + }); + + it("flags the #25 regression: an empty shell with only imports", () => { + // This is literally what main produced before #28: 3 import lines, no exports. + const emptyShell = [ + 'import "./local-session-target-DAN8-Cwd.js";', + 'import "./local-tui-bootstrap-xGRRjkP0.js";', + 'import process from "node:process";', + ].join("\n"); + + expect(findMissingSymbols(emptyShell).sort()).toEqual( + [...REQUIRED_OPENTUI_RUNTIME_SYMBOLS].sort(), + ); + }); + + it("reports only the symbols that are actually absent", () => { + const partial = "export class LocalStepCliTuiApp {}"; + expect(findMissingSymbols(partial)).toEqual(["createLocalTuiClientApp"]); + }); +});