Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions scripts/check-build-output.mjs
Original file line number Diff line number Diff line change
@@ -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(", ")}`,
);
}
97 changes: 97 additions & 0 deletions scripts/check-build-output.test.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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"]);
});
});
Loading