diff --git a/.github/actions/setup-gitnexus-web/action.yml b/.github/actions/setup-gitnexus-web/action.yml index ef90dbf92..fb734d3ca 100644 --- a/.github/actions/setup-gitnexus-web/action.yml +++ b/.github/actions/setup-gitnexus-web/action.yml @@ -11,12 +11,19 @@ runs: cache: npm cache-dependency-path: gitnexus-web/package-lock.json - - name: Build gitnexus-shared - run: npm install && npm run build - shell: bash - working-directory: gitnexus-shared - - name: Install web dependencies run: npm ci shell: bash working-directory: gitnexus-web + env: + # Browsers are installed explicitly by e2e. Typecheck only needs types. + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + + # Compile shared with the web package's TypeScript 5. Do not npm-ci + # gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes). + - name: Build gitnexus-shared + # node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc + # (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell). + run: node ../gitnexus-web/node_modules/typescript/lib/tsc.js + shell: bash + working-directory: gitnexus-shared diff --git a/.github/actions/setup-gitnexus/action.yml b/.github/actions/setup-gitnexus/action.yml index bc928d045..3594afdb1 100644 --- a/.github/actions/setup-gitnexus/action.yml +++ b/.github/actions/setup-gitnexus/action.yml @@ -6,6 +6,13 @@ inputs: description: Whether to run npm run build after install required: false default: 'false' + lifecycle-scripts: + description: > + Run npm lifecycle scripts (prepare/postinstall) during gitnexus npm ci. + Typecheck-only and pack-only jobs should set this to false: they do not + need dist/ or native grammar builds. + required: false + default: 'true' runs: using: composite @@ -16,16 +23,30 @@ runs: cache: npm cache-dependency-path: gitnexus/package-lock.json - - name: Build gitnexus-shared - run: npm install && npm run build - shell: bash - working-directory: gitnexus-shared - + # Do not npm-ci gitnexus-shared. Its TypeScript 7 install is a 7-minute + # stall (optional platform packages) and is not in the CLI npm cache. + # prepare/build.js compiles shared with gitnexus's tsc; typecheck does + # the same below after an ignore-scripts install. - name: Install dependencies + if: ${{ inputs.lifecycle-scripts != 'false' }} run: npm ci shell: bash working-directory: gitnexus + - name: Install dependencies + if: ${{ inputs.lifecycle-scripts == 'false' }} + run: npm ci --ignore-scripts + shell: bash + working-directory: gitnexus + + - name: Build gitnexus-shared + if: ${{ inputs.lifecycle-scripts == 'false' }} + # node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc + # (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell). + run: node ../gitnexus/node_modules/typescript/lib/tsc.js + shell: bash + working-directory: gitnexus-shared + - name: Build if: ${{ inputs.build == 'true' }} run: npm run build diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 182c620d2..1a2ac4728 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -9,7 +9,9 @@ permissions: jobs: format: runs-on: ubuntu-latest - timeout-minutes: 5 + # Same root npm ci as lint. A cold install already took 4m19s here and + # canceled prettier at the 5-minute job cap; lint needed 7m41s the same run. + timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -19,7 +21,7 @@ jobs: node-version: 22 cache: npm cache-dependency-path: package-lock.json - - run: npm ci + - run: npm ci --ignore-scripts - run: npx prettier --check . lint: @@ -34,7 +36,7 @@ jobs: node-version: 22 cache: npm cache-dependency-path: package-lock.json - - run: npm ci + - run: npm ci --ignore-scripts - run: npx eslint . typecheck: @@ -44,13 +46,20 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + # tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall + # so a cold shared install cannot eat the 10-minute budget on a second tsc. - uses: ./.github/actions/setup-gitnexus + with: + lifecycle-scripts: 'false' - run: npx tsc --noEmit working-directory: gitnexus typecheck-web: runs-on: ubuntu-latest - timeout-minutes: 10 + # Cold gitnexus-web npm ci is several minutes (mermaid/langchain/playwright). + # A 10-minute cancel prevents setup-node from saving the cache, so the next + # run is cold again. + timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 4ac6b8845..04d5a27e2 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -307,7 +307,9 @@ jobs: matrix: os: [windows-latest, ubuntu-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 15 + # Windows pack + web install regularly exceeds 15 minutes when setup also + # runs prepare/postinstall/build before prepack compiles the same tree again. + timeout-minutes: 20 steps: # persist-credentials: false — this job runs npm pack + npm install -g # from a tarball and never pushes back; the token in .git/config would @@ -316,9 +318,20 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + # Skip prepare/postinstall/build here. `npm pack` runs prepack, which + # compiles CLI + web into the tarball this job actually installs. - uses: ./.github/actions/setup-gitnexus with: - build: 'true' + lifecycle-scripts: 'false' + + # `npm pack` runs prepack, which builds the web UI into gitnexus/web/ + # so the tarball matches what `npm publish` ships. Install those deps + # here, in their own visible step, rather than letting build.js do it + # from inside an execSync. + - name: Install gitnexus-web dependencies + shell: bash + run: npm ci + working-directory: gitnexus-web - name: Pack gitnexus tarball shell: bash @@ -360,6 +373,10 @@ jobs: fi echo "Installed package at: $INSTALLED" + # The npm package contract includes the built web UI. Validate the + # installed artifact, not just the source workflow that produced it. + node "$INSTALLED/scripts/assert-web-assets.mjs" "$INSTALLED/web" + # #836 invariant: no node_modules/ or build/ under any vendor/*. BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true) if [ -n "$BAD" ]; then @@ -419,9 +436,6 @@ jobs: node-version: '22' cache: npm cache-dependency-path: gitnexus/package-lock.json - - name: Build gitnexus-shared - run: npm ci && npm run build - working-directory: gitnexus-shared - name: Install and build gitnexus shell: bash run: | @@ -856,11 +870,6 @@ jobs: "${canary_runtime}/node_modules/@anthropic-ai/claude-code/package.json" test "$("${canary_runtime}/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = \ '2.1.214 (Claude Code)' - - name: Build pinned shared runtime - run: | - npm ci - npm run build - working-directory: gitnexus-shared - name: Install and build pinned GitNexus runtime run: | npm ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90b711f6f..c03a801c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -396,14 +396,17 @@ jobs: # cache-poisoning audit). ~30s slower per release; runs rarely. package-manager-cache: false - - name: Build gitnexus-shared - run: npm ci && npm run build - working-directory: gitnexus-shared - - name: Install gitnexus dependencies run: npm ci working-directory: gitnexus + # The published tarball ships the web UI (`files: [... "web"]`), built + # by prepack during `npm publish`. Install its deps in their own step + # so a slow install is visible here instead of dying inside build.js. + - name: Install gitnexus-web dependencies + run: npm ci + working-directory: gitnexus-web + # ── Stable-only: verify the tag and package.json agree ─────────────── - name: Verify version consistency (stable) if: needs.route.outputs.mode == 'stable' diff --git a/.github/workflows/skill-sync.yml b/.github/workflows/skill-sync.yml index e9c1a0881..d029a79a8 100644 --- a/.github/workflows/skill-sync.yml +++ b/.github/workflows/skill-sync.yml @@ -55,9 +55,6 @@ jobs: node-version: '22' cache: npm cache-dependency-path: gitnexus/package-lock.json - - name: Build gitnexus-shared - run: npm ci && npm run build - working-directory: gitnexus-shared - name: Install gitnexus run: npm ci working-directory: gitnexus diff --git a/eval/tests/test_workflow_bench.py b/eval/tests/test_workflow_bench.py index b1222e7d2..7caaaf542 100644 --- a/eval/tests/test_workflow_bench.py +++ b/eval/tests/test_workflow_bench.py @@ -189,11 +189,13 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs(): assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["integrity"].startswith("sha512-") assert "if(p.version!=='2.1.214') process.exit(1)" in workflow assert "'2.1.214 (Claude Code)'" in workflow - assert containment_steps["Build pinned shared runtime"]["working-directory"] == "gitnexus-shared" - assert containment_steps["Build pinned shared runtime"]["run"].splitlines() == [ - "npm ci", - "npm run build", - ] + # Shared is compiled by gitnexus `npm run build` (scripts/build.js runTsc). + # A dedicated npm ci in gitnexus-shared pulls TypeScript 7 and stalls CI. + assert "Build pinned shared runtime" not in containment_steps + assert not any( + step.get("working-directory") == "gitnexus-shared" and "npm ci" in str(step.get("run", "")) + for step in containment["steps"] + ) assert containment_steps["Install and build pinned GitNexus runtime"]["working-directory"] == "gitnexus" assert containment_steps["Install and build pinned GitNexus runtime"]["run"].splitlines() == [ "npm ci", diff --git a/gitnexus/package.json b/gitnexus/package.json index d99734523..2eff9e678 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -40,6 +40,7 @@ ], "scripts": { "build": "node scripts/build.js", + "build:web": "node scripts/build.js --web", "serve": "tsx src/cli/index.ts serve", "dev": "tsx watch src/cli/index.ts", "test": "vitest run", @@ -52,7 +53,7 @@ "postinstall": "node scripts/build-tree-sitter-grammars.cjs", "assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs", "prepare": "node scripts/build.js", - "prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js", + "prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js --web && node scripts/assert-web-assets.mjs web", "version": "node scripts/sync-plugin-manifests.mjs" }, "dependencies": { diff --git a/gitnexus/scripts/assert-web-assets.mjs b/gitnexus/scripts/assert-web-assets.mjs new file mode 100644 index 000000000..60c3a4cad --- /dev/null +++ b/gitnexus/scripts/assert-web-assets.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const webDir = path.resolve(process.argv[2] ?? 'web'); +const indexPath = path.join(webDir, 'index.html'); + +let html; +try { + html = fs.readFileSync(indexPath, 'utf8'); +} catch (err) { + if (err?.code === 'ENOENT') { + console.error(`[web-assets] missing ${indexPath}`); + process.exit(1); + } + throw err; +} +const localRefs = [...html.matchAll(/(?:src|href)=["']([^"'#]+)["']/g)] + .map((match) => match[1]) + .filter((ref) => !/^(?:[a-z]+:|\/\/|data:)/i.test(ref)) + .map((ref) => ref.split(/[?#]/, 1)[0].replace(/^\/+/, '')); +const assetRefs = localRefs.filter((ref) => ref.startsWith('assets/')); + +if (assetRefs.length === 0) { + console.error(`[web-assets] ${indexPath} references no assets`); + process.exit(1); +} + +const missing = assetRefs.filter((ref) => !fs.existsSync(path.join(webDir, ref))); +if (missing.length > 0) { + console.error(`[web-assets] ${indexPath} references missing assets:\n${missing.join('\n')}`); + process.exit(1); +} + +console.log(`[web-assets] verified index.html and ${assetRefs.length} referenced asset(s)`); diff --git a/gitnexus/scripts/build-web.js b/gitnexus/scripts/build-web.js new file mode 100644 index 000000000..ccfc87b93 --- /dev/null +++ b/gitnexus/scripts/build-web.js @@ -0,0 +1,72 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +export function shouldBuildWeb(argv = process.argv, env = process.env) { + return argv.includes('--web') || env.GITNEXUS_BUILD_WEB === '1'; +} + +export function shouldPreserveWebOutput(env = process.env) { + return ( + env.npm_lifecycle_event === 'prepare' && + (env.npm_command === 'pack' || env.npm_command === 'publish') + ); +} + +/** Build and copy the web UI when `--web` / GITNEXUS_BUILD_WEB=1 is set. */ +export function runWebBuild({ + root, + dist, + timeoutMs, + argv = process.argv, + env = process.env, + fsImpl = fs, + exec = execSync, +}) { + const webRoot = path.resolve(root, '..', 'gitnexus-web'); + const webDest = path.join(dist, '..', 'web'); + + if (!shouldBuildWeb(argv, env)) { + if (shouldPreserveWebOutput(env)) { + console.log('[build] preserving prepack web UI during npm prepare'); + } else { + fsImpl.rmSync(webDest, { recursive: true, force: true }); + console.log( + '[build] skipping web UI and removed stale output ' + + '(pass --web or set GITNEXUS_BUILD_WEB=1 to include it)', + ); + } + return { status: 'skipped', webDest }; + } + + if (!fsImpl.existsSync(path.join(webRoot, 'package.json'))) { + throw new Error( + `[build] web UI requested, but gitnexus-web was not found at ${webRoot}. ` + + 'Run this command from the complete monorepo checkout.', + ); + } + + console.log('[build] building gitnexus-web…'); + if (!fsImpl.existsSync(path.join(webRoot, 'node_modules'))) { + // Deliberately untimed: this is a full second install, and killing it + // partway through leaves a broken tree and a misleading ETIMEDOUT. + // CI should install gitnexus-web itself (cached, its own step) so this + // fallback only fires for a local `npm pack` / `npm publish`. + console.log('[build] installing gitnexus-web dependencies (no local node_modules)…'); + // String form uses the platform shell (cmd.exe / sh) so `npm` resolves to + // npm.cmd on Windows. execFileSync('npm') / execFileSync('npm.cmd') + // without a shell fails on Windows. + exec('npm ci', { cwd: webRoot, stdio: 'inherit' }); + } + exec('npm run build', { cwd: webRoot, stdio: 'inherit', timeout: timeoutMs }); + + const builtIndex = path.join(webRoot, 'dist', 'index.html'); + if (!fsImpl.existsSync(builtIndex)) { + throw new Error(`[build] gitnexus-web build completed without ${builtIndex}`); + } + + fsImpl.rmSync(webDest, { recursive: true, force: true }); + fsImpl.cpSync(path.join(webRoot, 'dist'), webDest, { recursive: true }); + console.log('[build] copied web UI → gitnexus/web/'); + return { status: 'built', webDest }; +} diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 044fc36ee..6cead2784 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -8,17 +8,18 @@ * 3. Copy gitnexus-shared/dist → dist/_shared * 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { runWebBuild } from './build-web.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared'); const DIST = path.join(ROOT, 'dist'); const SHARED_DEST = path.join(DIST, '_shared'); -const DEFAULT_BUILD_TIMEOUT_MS = 300_000; +const DEFAULT_BUILD_TIMEOUT_MS = 600_000; function getBuildTimeoutMs() { const raw = process.env.GITNEXUS_BUILD_TIMEOUT_MS; @@ -51,17 +52,29 @@ if (!fs.existsSync(SHARED_ROOT)) { process.exit(1); } +// Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` / +// `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a +// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls +// TypeScript 7 optional platform packages (7+ minutes in CI). +const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js'); +if (!fs.existsSync(tscJs)) { + console.error( + `[build] missing ${tscJs}. Install gitnexus dependencies first (npm ci in gitnexus/).`, + ); + process.exit(1); +} + +function runTsc(cwd) { + execFileSync(process.execPath, [tscJs], { cwd, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); +} + // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); -const tscCmd = - process.platform === 'win32' - ? path.join('node_modules', '.bin', 'tsc.cmd') - : path.join('node_modules', '.bin', 'tsc'); -execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); +runTsc(SHARED_ROOT); // ── 2. Build gitnexus ────────────────────────────────────────────── console.log('[build] compiling gitnexus…'); -execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); +runTsc(ROOT); // ── 3. Copy shared dist ──────────────────────────────────────────── console.log('[build] copying shared module into dist/_shared…'); @@ -104,26 +117,16 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile); // ── 5. Make CLI entry executable ──────────────────────────────────── const cliEntry = path.join(DIST, 'cli', 'index.js'); -if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755); - -// ── 6. Build & copy web UI ────────────────────────────────────────── -const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web'); -const WEB_DEST = path.join(DIST, '..', 'web'); - -if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) { - console.log('[build] building gitnexus-web…'); - if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) { - console.log('[build] installing gitnexus-web dependencies…'); - execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); - } - execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); - - // Copy dist → gitnexus/web/ (shipped in the npm package) - fs.rmSync(WEB_DEST, { recursive: true, force: true }); - fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true }); - console.log('[build] copied web UI → gitnexus/web/'); -} else { - console.log('[build] skipping web UI (gitnexus-web not found)'); +if (process.platform !== 'win32' && fs.existsSync(cliEntry)) { + fs.chmodSync(cliEntry, 0o755); } +// ── 6. Build & copy web UI (opt-in) ───────────────────────────────── +// Web UI is a separate package and is only required in the published +// tarball, so it is built by `prepack --web`, not by `prepare`. Serve +// falls back to the landing page when web/ is absent. CLI-only builds +// delete stale web/ except during npm pack/publish prepare, which must +// keep the prepack output. +runWebBuild({ root: ROOT, dist: DIST, timeoutMs: BUILD_TIMEOUT_MS }); + console.log(`[build] done — rewrote ${rewritten} files.`); diff --git a/gitnexus/test/unit/build-web-optin.test.ts b/gitnexus/test/unit/build-web-optin.test.ts new file mode 100644 index 000000000..68e8cd464 --- /dev/null +++ b/gitnexus/test/unit/build-web-optin.test.ts @@ -0,0 +1,346 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { load } from 'js-yaml'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scripts/build-web.js'; + +/** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */ +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const PACKAGE_JSON = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'), +) as { scripts?: Record }; +const tempDirs: string[] = []; + +interface WorkflowStep { + name?: string; + run?: unknown; + uses?: string; + with?: Record; + env?: Record; + if?: string; + 'working-directory'?: string; +} + +interface WorkflowJob { + 'timeout-minutes'?: number; + steps?: WorkflowStep[]; +} + +function jobs(workflowPath: string): Record { + const doc = load(readFileSync(path.join(REPO_ROOT, workflowPath), 'utf8')) as { + jobs?: Record; + }; + return doc.jobs ?? {}; +} + +function compositeAction(actionPath: string): { + inputs?: Record; + runs?: { steps?: WorkflowStep[] }; +} { + return load(readFileSync(path.join(REPO_ROOT, actionPath), 'utf8')) as { + inputs?: Record; + runs?: { steps?: WorkflowStep[] }; + }; +} + +const ciJobs = jobs('.github/workflows/ci-tests.yml'); +const publishJobs = jobs('.github/workflows/publish.yml'); +const qualityJobs = jobs('.github/workflows/ci-quality.yml'); +const setupGitnexus = compositeAction('.github/actions/setup-gitnexus/action.yml'); +const setupGitnexusWeb = compositeAction('.github/actions/setup-gitnexus-web/action.yml'); + +function stepIndex(steps: WorkflowStep[], predicate: (step: WorkflowStep) => boolean): number { + return steps.findIndex(predicate); +} + +const installsWeb = (step: WorkflowStep) => + step['working-directory'] === 'gitnexus-web' && String(step.run ?? '').includes('npm ci'); + +function runWeb( + fixture: ReturnType, + overrides: { + timeoutMs?: number; + argv?: string[]; + env?: NodeJS.Dict; + exec?: (...args: unknown[]) => unknown; + } = {}, +) { + return runWebBuild({ + root: fixture.root, + dist: fixture.dist, + timeoutMs: 600_000, + argv: ['node', 'build.js'], + env: {}, + exec: vi.fn(), + ...overrides, + }); +} + +function buildFixture({ withWeb = true, withNodeModules = true } = {}) { + const workspace = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-build-web-')); + tempDirs.push(workspace); + + const root = path.join(workspace, 'gitnexus'); + const dist = path.join(root, 'dist'); + const webRoot = path.join(workspace, 'gitnexus-web'); + mkdirSync(dist, { recursive: true }); + + if (withWeb) { + mkdirSync(path.join(webRoot, 'dist', 'assets'), { recursive: true }); + writeFileSync(path.join(webRoot, 'package.json'), '{}'); + writeFileSync( + path.join(webRoot, 'dist', 'index.html'), + '', + ); + writeFileSync(path.join(webRoot, 'dist', 'assets', 'app.js'), 'export {};'); + if (withNodeModules) mkdirSync(path.join(webRoot, 'node_modules')); + } + + return { root, dist, webRoot, webDest: path.join(root, 'web') }; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('gitnexus build scripts', () => { + it('keeps the default build CLI-only', () => { + expect(PACKAGE_JSON.scripts?.build).toBe('node scripts/build.js'); + expect(PACKAGE_JSON.scripts?.prepare).toBe('node scripts/build.js'); + expect(PACKAGE_JSON.scripts?.prepare).not.toContain('--web'); + }); + + it('compiles gitnexus-shared with gitnexus TypeScript, not a separate TypeScript 7 install', () => { + const src = readFileSync(path.join(REPO_ROOT, 'gitnexus/scripts/build.js'), 'utf8'); + expect(src).toContain("path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js')"); + expect(src).toContain('execFileSync(process.execPath, [tscJs]'); + expect(src).not.toMatch(/node_modules['"]?, ['"]\.bin/); + expect(src).not.toMatch(/execFileSync\([^)]*tsc\.cmd/); + expect(src).not.toContain("typescript', 'bin', 'tsc'"); + }); + + it.skipIf(!existsSync(path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js')))( + 'can launch TypeScript via node + lib/tsc.js on this OS', + () => { + const probe = spawnSync( + process.execPath, + [path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js'), '--version'], + { encoding: 'utf8' }, + ); + expect(probe.status).toBe(0); + expect(probe.stdout).toMatch(/Version \d+/); + }, + ); + + it('builds the web UI from prepack, which is what ships the tarball', () => { + expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/build.js --web'); + expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/assert-web-assets.mjs web'); + expect(PACKAGE_JSON.scripts?.['build:web']).toBe('node scripts/build.js --web'); + }); + + it('recognizes only explicit CLI or environment opt-ins', () => { + expect(shouldBuildWeb(['node', 'build.js'], {})).toBe(false); + expect(shouldBuildWeb(['node', 'build.js', '--web'], {})).toBe(true); + expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: '1' })).toBe(true); + expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: 'true' })).toBe(false); + }); + + it('removes stale packaged output from a default build', () => { + const fixture = buildFixture(); + mkdirSync(fixture.webDest, { recursive: true }); + writeFileSync(path.join(fixture.webDest, 'index.html'), 'stale'); + + const exec = vi.fn(); + const result = runWeb(fixture, { exec }); + + expect(result.status).toBe('skipped'); + expect(exec).not.toHaveBeenCalled(); + expect(existsSync(fixture.webDest)).toBe(false); + }); + + it('preserves prepack output during npm prepare for pack and publish', () => { + for (const npmCommand of ['pack', 'publish']) { + const fixture = buildFixture(); + mkdirSync(fixture.webDest, { recursive: true }); + writeFileSync(path.join(fixture.webDest, 'index.html'), npmCommand); + + expect( + shouldPreserveWebOutput({ + npm_lifecycle_event: 'prepare', + npm_command: npmCommand, + }), + ).toBe(true); + runWeb(fixture, { + env: { npm_lifecycle_event: 'prepare', npm_command: npmCommand }, + }); + + expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toBe(npmCommand); + } + }); + + it('fails closed when an explicit web build has no web package', () => { + const fixture = buildFixture({ withWeb: false }); + expect(() => runWeb(fixture, { argv: ['node', 'build.js', '--web'] })).toThrow( + 'web UI requested, but gitnexus-web was not found', + ); + }); + + it('builds and copies the web UI with an untimed fallback install', () => { + const fixture = buildFixture({ withNodeModules: false }); + const exec = vi.fn(); + const result = runWeb(fixture, { + timeoutMs: 123_456, + argv: ['node', 'build.js', '--web'], + exec, + }); + + expect(exec).toHaveBeenNthCalledWith(1, 'npm ci', { + cwd: fixture.webRoot, + stdio: 'inherit', + }); + expect(exec).toHaveBeenNthCalledWith(2, 'npm run build', { + cwd: fixture.webRoot, + stdio: 'inherit', + timeout: 123_456, + }); + expect(result.status).toBe('built'); + expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toContain('app.js'); + }); + + it('rejects a packaged web UI with missing referenced assets', () => { + const fixture = buildFixture(); + const checker = path.join(REPO_ROOT, 'gitnexus/scripts/assert-web-assets.mjs'); + + expect(spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')]).status).toBe( + 0, + ); + rmSync(path.join(fixture.webRoot, 'dist', 'assets', 'app.js')); + + const invalid = spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')], { + encoding: 'utf8', + }); + expect(invalid.status).toBe(1); + expect(invalid.stderr).toContain('references missing assets'); + + const missingIndex = spawnSync( + process.execPath, + [checker, path.join(fixture.webRoot, 'none')], + { + encoding: 'utf8', + }, + ); + expect(missingIndex.status).toBe(1); + expect(missingIndex.stderr).toContain('missing'); + }); +}); + +describe('workflows that need the web UI install it themselves', () => { + it('packaged install smoke installs gitnexus-web before npm pack', () => { + const steps = ciJobs['packaged-install-smoke']?.steps ?? []; + const webIdx = stepIndex(steps, installsWeb); + const packIdx = stepIndex(steps, (step) => String(step.run ?? '').includes('npm pack')); + expect(webIdx).toBeGreaterThanOrEqual(0); + expect(packIdx).toBeGreaterThan(webIdx); + }); + + it('packaged install smoke validates web assets in the installed tarball', () => { + const steps = ciJobs['packaged-install-smoke']?.steps ?? []; + const artifactCheck = steps.find((step) => + String(step.run ?? '').includes('scripts/assert-web-assets.mjs'), + ); + expect(artifactCheck).toBeTruthy(); + expect(String(artifactCheck?.run)).toContain('$INSTALLED/web'); + }); + + it('publish installs gitnexus-web before it packs the tarball', () => { + const steps = publishJobs['publish']?.steps ?? []; + const webIdx = stepIndex(steps, installsWeb); + const publishIdx = stepIndex(steps, (step) => + String(step.run ?? '').includes('npm publish --dry-run'), + ); + expect(webIdx).toBeGreaterThanOrEqual(0); + expect(publishIdx).toBeGreaterThan(webIdx); + }); + + it('node floor compat stays CLI-only — it never installs the web tree', () => { + const steps = ciJobs['node-floor-compat']?.steps ?? []; + expect(steps.length).toBeGreaterThan(0); + expect(steps.filter(installsWeb)).toHaveLength(0); + }); + + it('packaged install smoke skips a pre-pack CLI build and keeps a 20-minute budget', () => { + const job = ciJobs['packaged-install-smoke']; + const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus'); + expect(job?.['timeout-minutes']).toBe(20); + expect(setup?.with?.['lifecycle-scripts']).toBe('false'); + expect(setup?.with?.build).toBeUndefined(); + }); +}); + +describe('setup-gitnexus job budget', () => { + it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => { + const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared'); + expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js'); + expect(String(shared?.run)).not.toContain('.bin'); + expect(shared?.if).toContain("lifecycle-scripts == 'false'"); + expect( + setupGitnexus.runs?.steps?.some( + (step) => + step['working-directory'] === 'gitnexus-shared' && + String(step.run ?? '').includes('npm ci'), + ), + ).toBe(false); + expect(setupGitnexus.inputs?.['lifecycle-scripts']?.default).toBe('true'); + expect( + setupGitnexus.runs?.steps?.some((step) => + String(step.run ?? '').includes('--ignore-scripts'), + ), + ).toBe(true); + }); + + it('setup-gitnexus-web compiles shared with the web TypeScript and skips Playwright browsers', () => { + const setupNode = setupGitnexusWeb.runs?.steps?.find((step) => + String(step.uses ?? '').startsWith('actions/setup-node@'), + ); + const shared = setupGitnexusWeb.runs?.steps?.find( + (step) => step.name === 'Build gitnexus-shared', + ); + const webInstall = setupGitnexusWeb.runs?.steps?.find( + (step) => step.name === 'Install web dependencies', + ); + expect(String(setupNode?.with?.['cache-dependency-path'])).toBe( + 'gitnexus-web/package-lock.json', + ); + expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js'); + expect(String(shared?.run)).not.toContain('.bin'); + expect(String(shared?.run)).not.toContain('npm ci'); + expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1'); + }); + + it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => { + const job = qualityJobs.typecheck; + const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus'); + expect(job?.['timeout-minutes']).toBe(10); + expect(setup?.with?.['lifecycle-scripts']).toBe('false'); + }); + + it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => { + expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15); + }); + + it('quality format matches lint budget and skips husky during npm ci', () => { + const formatCi = qualityJobs.format?.steps?.find((step) => + String(step.run ?? '').includes('npm ci'), + ); + const lintCi = qualityJobs.lint?.steps?.find((step) => + String(step.run ?? '').includes('npm ci'), + ); + expect(qualityJobs.format?.['timeout-minutes']).toBe(10); + expect(qualityJobs.lint?.['timeout-minutes']).toBe(10); + expect(String(formatCi?.run)).toContain('--ignore-scripts'); + expect(String(lintCi?.run)).toContain('--ignore-scripts'); + }); +});