diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 906834999..6fe07a162 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -7,18 +7,28 @@ permissions: contents: read jobs: + # Ubuntu full-suite coverage, sharded. Each shard writes a vitest blob report + # (carrying its slice of V8 coverage) with thresholds forced OFF — a single + # shard's partial coverage can't meet the gate. The coverage-merge job below + # reduces the blobs and enforces the real thresholds on the combined coverage. + # FTS self-installs per shard (test/helpers/fts-availability.ts), so sharding + # the full suite across fresh runners is safe. Shard count: shard-plan.cov_total. tests: - name: ubuntu / coverage + name: ubuntu / coverage ${{ matrix.shard }}/${{ needs.shard-plan.outputs.cov_total }} + needs: shard-plan runs-on: ubuntu-latest timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(needs.shard-plan.outputs.cov_shards) }} # Fail loudly (don't silently skip) if the FTS extension is unavailable, so # FTS-dependent lbug integration suites are guaranteed to run in CI. env: GITNEXUS_REQUIRE_FTS: '1' steps: - # persist-credentials: false — this job runs tests and uploads a - # test-reports artifact (if: always()). The default-persisted token in - # .git/config must not be capturable through that upload (zizmor + # persist-credentials: false — runs tests + uploads a blob artifact; the + # default-persisted token must not be capturable through it (zizmor # credential-persistence / artipacked audit). The job never pushes. - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -26,10 +36,78 @@ jobs: - uses: ./.github/actions/setup-gitnexus with: build: 'true' - - - name: Run all tests with coverage + # Warm-cache the FTS extension (same per-OS key as the cross-platform job) + # and install it up front, so every coverage shard has FTS in ~/.lbdb before + # any test module loads. The file-path FTS gate (extension-binary-real) + # resolves the extension at module load and can't self-install, so sharding + # could otherwise drop it into a shard with no installer sibling. + - name: Cache LadybugDB FTS extension + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: ~/.lbdb/extension + key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }} + - name: Ensure FTS extension installed + run: npx tsx scripts/ensure-fts.ts + working-directory: gitnexus + - name: Run sharded tests with coverage (blob) + # Shard via env var (not `${{ }}` inlined into the shell) so it isn't a + # template-injection sink; shell: bash makes "$SHARD" expand uniformly. + # Thresholds forced to 0 — the merge job enforces the real gate on the + # MERGED coverage; a single shard's partial coverage would always fail. + shell: bash + env: + SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.cov_total }} run: >- npx vitest run + --shard="$SHARD" + --reporter=default + --reporter=blob + --coverage + --coverage.thresholds.lines=0 + --coverage.thresholds.functions=0 + --coverage.thresholds.branches=0 + --coverage.thresholds.statements=0 + working-directory: gitnexus + - name: Upload coverage blob + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-blob-${{ matrix.shard }} + path: gitnexus/.vitest-reports/ + # .vitest-reports is a dotdir; upload-artifact excludes hidden files by + # default, which would upload an empty artifact and break the merge. + include-hidden-files: true + retention-days: 5 + + # Merge the sharded coverage blobs into one report and enforce the real + # thresholds on the combined ('new') coverage — `vitest --mergeReports` re-runs + # nothing, it just reduces the stored blobs. Also emits the merged + # test-results.json and runs the (unsharded) web + docker suites, so the + # `test-reports` artifact keeps the exact shape ci-report.yml consumes for its + # base-branch ('baseline') vs new coverage delta. + coverage-merge: + name: ubuntu / coverage merge + needs: tests + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + GITNEXUS_REQUIRE_FTS: '1' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + - name: Download coverage blobs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: coverage-blob-* + path: gitnexus/.vitest-reports + merge-multiple: true + - name: Merge coverage + enforce thresholds + run: >- + npx vitest --mergeReports --reporter=default --reporter=json --outputFile=test-results.json @@ -38,14 +116,11 @@ jobs: --coverage.reporter=json --coverage.reporter=text --coverage.thresholdAutoUpdate=false - --coverage.reportOnFailure=true working-directory: gitnexus - - # gitnexus-shared already built by setup-gitnexus action above + # gitnexus-shared already built by setup-gitnexus above - name: Install gitnexus-web dependencies run: npm ci working-directory: gitnexus-web - - name: Run gitnexus-web unit tests run: >- npx vitest run @@ -53,10 +128,8 @@ jobs: --reporter=json --outputFile=web-test-results.json working-directory: gitnexus-web - - name: Run docker-server integration tests run: node --test docker-server.test.mjs - - name: Upload test reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -69,22 +142,68 @@ jobs: gitnexus-web/web-test-results.json retention-days: 5 + # Single source of truth for the platform-sensitive shard count. TOTAL below + # generates both the shard index list (the matrix) and the /N denominator (job + # name + --shard arg), so they can't drift — bump the shard count by editing + # TOTAL alone. Checkout-free (ubuntu ships jq), so no credential surface. + shard-plan: + runs-on: ubuntu-latest + outputs: + shards: ${{ steps.gen.outputs.shards }} + total: ${{ steps.gen.outputs.total }} + cov_shards: ${{ steps.gen.outputs.cov_shards }} + cov_total: ${{ steps.gen.outputs.cov_total }} + steps: + - id: gen + run: | + TOTAL=3 # cross-platform (windows/macOS) shards per OS + COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds) + if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then + echo "shard totals must be >= 1" >&2; exit 1 + fi + { + echo "shards=$(jq -nc --argjson n "$TOTAL" '[range(1; $n + 1)]')" + echo "total=$TOTAL" + echo "cov_shards=$(jq -nc --argjson n "$COV_TOTAL" '[range(1; $n + 1)]')" + echo "cov_total=$COV_TOTAL" + } >> "$GITHUB_OUTPUT" + # Platform-sensitive subset only — the full suite runs on Ubuntu above. # See gitnexus/scripts/cross-platform-tests.ts for the file list and # rationale for each included test. cross-platform: - name: ${{ matrix.os }} (platform-sensitive) + name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }} + needs: shard-plan strategy: fail-fast: false matrix: # Ubuntu already covered by the coverage job above os: [windows-latest, macos-latest] + # Shard the fixed file list across N runners per OS (N = TOTAL in the + # shard-plan job). The suite is dominated by ~50 CLI/worker process + # spawns and Windows is ~5x slower than macOS at those, so the unsharded + # run crept past the 15-min watchdog in run-cross-platform.ts. vitest + # shards by file COUNT, not runtime, so the heaviest spawn suites can + # cluster on one shard; 3 shards keep even the busiest Windows shard + # comfortably under the watchdog (macOS had margin either way). + # Shard indices come from the shard-plan job (single source of truth): + # its TOTAL drives this list and the /N in the job name + --shard arg. + shard: ${{ fromJSON(needs.shard-plan.outputs.shards) }} runs-on: ${{ matrix.os }} timeout-minutes: 20 # Same guarantee on the platform-sensitive runners: FTS-dependent suites in # the cross-platform subset must run, not silently skip. + # + # GITNEXUS_E2E_CLI=dist: the e2e suites spawn the CLI ~50 times; each spawn via + # `node --import tsx src/cli/index.ts` re-transpiles the whole CLI, and Windows + # is ~5x slower at process startup. `build: true` below produces a fresh dist + # before tests, so opting these runners into the built CLI removes that + # per-spawn transpile (see test/helpers/cli-entry.ts). Deliberately scoped to + # THIS job: the Ubuntu coverage job leaves it unset, so it keeps exercising the + # tsx-on-source path in CI (both entry points stay covered). env: GITNEXUS_REQUIRE_FTS: '1' + GITNEXUS_E2E_CLI: dist steps: # persist-credentials: false — runs tests only, never pushes (zizmor # credential-persistence / artipacked audit). @@ -94,8 +213,30 @@ jobs: - uses: ./.github/actions/setup-gitnexus with: build: 'true' + # Warm-cache the installed LadybugDB FTS extension (~/.lbdb/extension) per + # OS + lockfile so a warm run skips the network install entirely, and the + # parallel shards share one download across runs. Pure reliability/speed: + # on a cache miss the tests self-install FTS on demand (see + # test/helpers/fts-availability.ts), so a miss just falls back to install — + # never a correctness dependency. Keyed by lockfile hash so a LadybugDB + # version bump re-installs; per-OS because the extension is a native binary. + - name: Cache LadybugDB FTS extension + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: ~/.lbdb/extension + key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }} + - name: Ensure FTS extension installed + run: npx tsx scripts/ensure-fts.ts + working-directory: gitnexus - name: Run platform-sensitive tests - run: npx tsx scripts/run-cross-platform.ts + # Pass the shard through an env var (not `${{ }}` inlined into the shell) + # so it isn't a template-injection sink (zizmor). shell: bash makes the + # `"$SHARD"` expansion uniform across the windows + macOS matrix (the + # default run shell is pwsh on Windows, where `$SHARD` would be empty). + shell: bash + env: + SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }} + run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD" working-directory: gitnexus # Tree-sitter ABI gate (#1922). Two halves, both blocking: diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index b144a95e5..1d26bdf45 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -32,6 +32,9 @@ const PLATFORM_LOGIC = [ 'test/unit/setup-antigravity.test.ts', 'test/integration/setup-uninstall-roundtrip.test.ts', 'test/unit/resolve-invocation.test.ts', + // CLI-spawn entry-point resolution; its path-separator assertion (cli[/\\]index) + // must exercise the Windows backslash branch, so run it on the OS matrix (#2394). + 'test/unit/cli-entry.test.ts', 'test/unit/platform-capabilities.test.ts', 'test/unit/worker-pool-windows-quarantine.test.ts', 'test/unit/lbug-pool-fts-load.test.ts', diff --git a/gitnexus/scripts/ensure-fts.ts b/gitnexus/scripts/ensure-fts.ts new file mode 100644 index 000000000..a3611de1b --- /dev/null +++ b/gitnexus/scripts/ensure-fts.ts @@ -0,0 +1,32 @@ +/** + * Install the LadybugDB FTS extension into the shared home (~/.lbdb) up front, so + * every test in a sharded CI run finds it regardless of which shard it lands in. + * + * FTS-dependent tests split two ways: the LOAD-path gate (skipUnlessFtsAvailable) + * self-installs on miss, but the FILE-path gate (requireFtsResourceOrSkip, e.g. + * extension-binary-real.test.ts) resolves the extension path at module load and + * cannot self-install. Sharding (and the balancing sequencer) can drop such a + * test into a shard with no installer sibling — this step removes that ordering + * dependency by installing FTS once before vitest starts. `auto` is LOAD-first, + * so a cache-warmed extension costs no network. + * + * Best-effort: exits 0 on failure (offline etc.) — the per-test gates still + * hard-fail under GITNEXUS_REQUIRE_FTS=1 if FTS is genuinely unavailable, which + * is where the loud signal belongs. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { initLbug, loadFTSExtension, closeLbug } from '../src/core/lbug/lbug-adapter.js'; + +const dir = mkdtempSync(join(tmpdir(), 'gn-ensure-fts-')); +try { + await initLbug(join(dir, 'ensure-fts.lbug')); + const ok = await loadFTSExtension(undefined, { policy: 'auto' }); + console.log(ok ? 'FTS extension ready.' : 'FTS extension unavailable (continuing).'); +} catch (err) { + console.warn(`ensure-fts: skipped (${err instanceof Error ? err.message : String(err)})`); +} finally { + await closeLbug(); + rmSync(dir, { recursive: true, force: true }); +} diff --git a/gitnexus/scripts/run-cross-platform.ts b/gitnexus/scripts/run-cross-platform.ts index 1f5464caf..b5d5bfab7 100644 --- a/gitnexus/scripts/run-cross-platform.ts +++ b/gitnexus/scripts/run-cross-platform.ts @@ -14,6 +14,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { ALL_CROSS_PLATFORM } from './cross-platform-tests.js'; +import { parseShardArg } from './shard-arg.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); @@ -27,18 +28,48 @@ if (missing.length > 0) { process.exit(1); } -console.log(`Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests...\n`); +// Optional sharding (CI): `--shard=/` splits the fixed file list across +// parallel matrix shards so each runner processes ~1/n of it. Passed straight +// through to vitest, which partitions the *given* files deterministically. The +// Windows runner is ~5x slower than macOS/Linux on this spawn-heavy suite (~50 +// CLI/worker process spawns), so a single shard was creeping past the watchdog +// below; sharding keeps each runner well under it (see ci-tests.yml matrix). +// Fail loud on a malformed --shard arg (mirrors the missing-files check above): +// a silently-dropped shard flag would run the full unsharded suite and re-trip +// the watchdog. Kept outside the execFileSync try/catch below so the message +// isn't swallowed by that catch's watchdog-only branch. +let shardArg: string | undefined; +try { + shardArg = parseShardArg(process.argv.slice(2)); +} catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +} + +// Per-shard watchdog, 15 min. Sharding splits the file list by COUNT, not +// runtime, so the heaviest spawn suites can cluster on one shard — what this +// bounds is the *busiest* shard, not an even 1/n of wall-clock. With 3 shards +// even that shard clears the watchdog, where the whole unsharded Windows run +// used to trip it. +const TIMEOUT_MIN = 15; + +console.log( + `Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests` + + `${shardArg ? ` (${shardArg.replace('--shard=', 'shard ')})` : ''}...\n`, +); try { - execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM], { + execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM, ...(shardArg ? [shardArg] : [])], { cwd: ROOT, stdio: 'inherit', - timeout: 15 * 60 * 1000, + timeout: TIMEOUT_MIN * 60 * 1000, shell: true, }); -} catch (err: any) { - if (err.killed || err.signal) { - console.error('vitest timed out after 15 minutes'); +} catch (err) { + // execFileSync sets `killed`/`signal` when the watchdog above kills vitest. + const e = err as { killed?: boolean; signal?: NodeJS.Signals | null }; + if (e.killed || e.signal) { + console.error(`vitest timed out after ${TIMEOUT_MIN} minutes`); } process.exit(1); } diff --git a/gitnexus/scripts/shard-arg.ts b/gitnexus/scripts/shard-arg.ts new file mode 100644 index 000000000..513fb5e9e --- /dev/null +++ b/gitnexus/scripts/shard-arg.ts @@ -0,0 +1,30 @@ +/** + * Resolves the optional `--shard=/` argument for + * `run-cross-platform.ts`. + * + * Extracted as a pure, side-effect-free function so the branch logic is + * unit-testable without the script's top-level `execFileSync` (see + * `test/unit/shard-arg.test.ts`). Mirrors the `computeSpawnPrefix` extraction + * pattern in `test/helpers/cli-entry.ts`. + */ + +const SHARD_RE = /^--shard=\d+\/\d+$/; + +/** + * Returns the matched `--shard=/` token (e.g. `--shard=1/3`) to + * pass straight through to vitest, or `undefined` when no shard arg is present. + * + * Fails loud on a shard-shaped-but-malformed arg (e.g. `--shard=1`, `--shard`, + * `--shard=abc`): a silently-ignored malformed arg would drop the shard flag and + * run the full unsharded ~50-spawn suite, re-arming the Windows watchdog timeout + * with no signal. Only `--shard` / `--shard=…` args are inspected, so unrelated + * flags (including a hypothetical `--shardx=…`) pass through untouched. + */ +export function parseShardArg(argv: string[]): string | undefined { + const shardArgs = argv.filter((a) => a === '--shard' || a.startsWith('--shard=')); + const malformed = shardArgs.find((a) => !SHARD_RE.test(a)); + if (malformed !== undefined) { + throw new Error(`Malformed --shard arg '${malformed}' — expected --shard=/`); + } + return shardArgs[0]; +} diff --git a/gitnexus/test/helpers/cli-entry.ts b/gitnexus/test/helpers/cli-entry.ts new file mode 100644 index 000000000..2355e2c82 --- /dev/null +++ b/gitnexus/test/helpers/cli-entry.ts @@ -0,0 +1,96 @@ +/** + * How e2e tests launch the gitnexus CLI as a subprocess. + * + * Default — `node --import tsx src/cli/index.ts`: always reflects current source, + * so any local run (built or not) exercises your edits. + * + * CI opts into the built CLI by setting `GITNEXUS_E2E_CLI=dist` AFTER its build + * step (see `.github/workflows/ci-tests.yml`) — `node dist/cli/index.js`, which + * skips the per-spawn tsx transpile of the whole CLI source graph. The + * platform-sensitive suite spawns the CLI ~50 times and Windows is ~5× slower at + * process startup, so that transpile dominated the job and tripped its watchdog. + * + * We deliberately do NOT infer dist from a generic `CI` env var: CI-presence does + * not prove `dist/` is fresh, and an ambient `CI=1` (agent sandboxes, other tools) + * could otherwise silently spawn a STALE build. dist is used only when explicitly + * requested, so the entry point in effect is always knowable from the environment. + * + * Bonus: the CLI's `ensureHeap()` re-exec "just works" from dist; under tsx it drops + * the `--import` loader, which is why analyze-calling tests pre-set + * `--max-old-space-size` in their `cliEnv()`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); +// test/helpers/cli-entry.ts → repo root is two levels up. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const distEntry = path.join(repoRoot, 'dist', 'cli', 'index.js'); +const srcEntry = path.join(repoRoot, 'src', 'cli', 'index.ts'); + +/** + * Pure resolver, exported for unit testing. `mode` is the raw `GITNEXUS_E2E_CLI` + * value: `'dist'` selects the built CLI (and requires it to exist); unset / `''` / + * `'src'` select tsx-on-source; any other value throws (a typo shouldn't silently + * degrade to tsx). Kept side-effect-free so the branch logic can be locked + * without env/filesystem gymnastics. + */ +export function computeSpawnPrefix(opts: { + mode: string | undefined; + distEntry: string; + srcEntry: string; + distExists: boolean; + tsxLoaderUrl: string; +}): string[] { + if (opts.mode === 'dist') { + if (!opts.distExists) { + throw new Error( + `GITNEXUS_E2E_CLI=dist but ${opts.distEntry} is missing — run \`npm run build\` first.`, + ); + } + return [opts.distEntry]; + } + // Fail loud on a typo instead of silently degrading to tsx: an unknown value + // (e.g. `dsit`) would otherwise make CI believe it tests dist while running + // src. Unset / '' / 'src' remain the safe tsx-on-source default. + if (opts.mode !== undefined && opts.mode !== '' && opts.mode !== 'src') { + throw new Error( + `Unknown GITNEXUS_E2E_CLI value '${opts.mode}' — use 'dist', 'src', or leave unset.`, + ); + } + return ['--import', opts.tsxLoaderUrl, opts.srcEntry]; +} + +export function tsxLoaderUrl(): string { + // Absolute file:// URL to the tsx loader — a bare `tsx` specifier won't resolve + // when the CLI is spawned with a cwd outside the project tree. The subpath + // `tsx/dist/loader.mjs` isn't in tsx's `exports`, so resolve the package root + // then join. Only computed on the tsx path (never when dist is selected). + return pathToFileURL( + path.join(path.dirname(require.resolve('tsx/package.json')), 'dist', 'loader.mjs'), + ).href; +} + +function resolvePrefix(): string[] { + const mode = process.env.GITNEXUS_E2E_CLI; + return computeSpawnPrefix({ + mode, + distEntry, + srcEntry, + distExists: mode === 'dist' && fs.existsSync(distEntry), + // Resolve the tsx loader lazily so the dist path pays nothing for it. + tsxLoaderUrl: mode === 'dist' ? '' : tsxLoaderUrl(), + }); +} + +/** + * `node` argv prefix that launches the gitnexus CLI (built dist when + * `GITNEXUS_E2E_CLI=dist`, else tsx-on-source). Spread it before the CLI's own + * arguments: + * + * spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'analyze', repo], opts) + */ +export const CLI_SPAWN_PREFIX: readonly string[] = resolvePrefix(); diff --git a/gitnexus/test/helpers/fts-availability.ts b/gitnexus/test/helpers/fts-availability.ts index b876043ae..28d8eb7dd 100644 --- a/gitnexus/test/helpers/fts-availability.ts +++ b/gitnexus/test/helpers/fts-availability.ts @@ -11,6 +11,14 @@ export const FTS_UNAVAILABLE_NOTE = * (registered in LBUG_NATIVE, so they run on the ubuntu/macOS/windows jobs that * all set GITNEXUS_REQUIRE_FTS=1) could vanish from a green run. Offline/local * runs (no env var) still skip gracefully (#2299). + * + * Self-sufficient under sharding: the default load path is `load-only`, so these + * primitives only pass when *some other* test already installed FTS into the + * shared home. That co-location is not guaranteed once the cross-platform suite + * is sharded (a load-only file can land in a shard with no installer sibling — + * exactly what broke `lbug-core-adapter` on shard 2/3). So under REQUIRE_FTS we + * install-on-miss with `auto` (LOAD-first, then one bounded network INSTALL), + * matching `withTestIndexedDB`, before treating it as a hard failure. */ export const skipUnlessFtsAvailable = async (ctx: { skip: (note?: string) => void; @@ -18,6 +26,10 @@ export const skipUnlessFtsAvailable = async (ctx: { const { loadFTSExtension } = await import('../../src/core/lbug/lbug-adapter.js'); if (await loadFTSExtension()) return; if (process.env.GITNEXUS_REQUIRE_FTS === '1') { + // Not pre-installed in this (possibly-sharded) CI VM — install it once, then + // it stays available for the rest of this file's tests. `auto` is LOAD-first + // so a pre-installed extension still costs no network. + if (await loadFTSExtension(undefined, { policy: 'auto' })) return; throw new Error( 'FTS extension is required (GITNEXUS_REQUIRE_FTS=1) but could not be loaded or installed. ' + 'FTS-dependent tests must not be silently skipped in CI — install/repair the LadybugDB ' + diff --git a/gitnexus/test/helpers/perf-sequencer.ts b/gitnexus/test/helpers/perf-sequencer.ts new file mode 100644 index 000000000..d5f8604a3 --- /dev/null +++ b/gitnexus/test/helpers/perf-sequencer.ts @@ -0,0 +1,23 @@ +import { BaseSequencer, type TestSpecification } from 'vitest/node'; +import { assignShards, specWeight } from './shard-balance.js'; + +/** + * Cost-balanced shard sequencer (wired via `sequence.sequencer` in + * vitest.config.ts). + * + * Overrides only `shard()` — `sort()` keeps the base behaviour so the projects' + * `groupOrder` and duration-cache ordering are untouched. Instead of vitest's + * default hash split (balanced by file COUNT, which piled the spawn-heavy suites + * onto one runner), it balances by estimated WORK (see `specWeight`): the + * fileParallelism:false spawn-heavy files are spread evenly across shards, so the + * slowest shard's wall-clock drops and no single runner carries all the + * contention. The partition stays complete and disjoint (see `assignShards`). + */ +export default class PerfSequencer extends BaseSequencer { + override async shard(specs: TestSpecification[]): Promise { + const shard = this.ctx.config.shard; + if (!shard) return specs; + const groups = assignShards(specs, shard.count, specWeight, (spec) => spec.moduleId); + return groups[shard.index - 1] ?? []; + } +} diff --git a/gitnexus/test/helpers/shard-balance.ts b/gitnexus/test/helpers/shard-balance.ts new file mode 100644 index 000000000..3ca0a4e92 --- /dev/null +++ b/gitnexus/test/helpers/shard-balance.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; + +/** + * Minimal shape of a vitest `TestSpecification` that {@link specWeight} needs. + * Kept structural (no `vitest/node` import) so this module stays pure and + * unit-testable without pulling in the vitest node runtime. + */ +export interface WeightableSpec { + moduleId: string; + project: { config: { fileParallelism?: boolean } }; +} + +/** + * Estimated per-file cost, used to balance shards by work instead of file count. + * + * The spawn-heavy suites are already isolated into `fileParallelism: false` + * projects (`cli-e2e`, `lbug-db`) — they run one file at a time and dominate + * wall-clock, so they carry the heavy base weight. File size is a cheap + * (stat-only, no parse) secondary signal for finer balance and a stable + * tiebreak. Deterministic: the same repo checkout yields identical weights on + * every shard runner, which is what keeps sharding a complete partition. + */ +export function specWeight(spec: WeightableSpec): number { + const sequential = spec.project.config.fileParallelism === false; + let sizeKb = 0; + try { + sizeKb = fs.statSync(spec.moduleId).size / 1024; + } catch { + /* virtual / unresolved module id → treat as size 0 */ + } + return (sequential ? 1000 : 1) + sizeKb; +} + +/** + * Greedy longest-processing-time bin-packing: place each spec (heaviest first) + * into the currently-lightest shard, balancing total WEIGHT across `count` + * shards rather than file COUNT. vitest's default hash split balances by count, + * which clusters slow suites (e.g. Windows platform shard 1 ran ~4x the others). + * + * Deterministic — identical input yields identical bins on every runner, so the + * union of `assignShards(...)[0..count-1]` is exactly the input with no spec + * dropped or duplicated. Returns one array of specs per shard (index `i-1`). + */ +export function assignShards( + specs: readonly T[], + count: number, + weight: (spec: T) => number, + key: (spec: T) => string, +): T[][] { + // Weight each spec once (weight() may stat the file), then sort/assign on the + // precomputed value — the comparator runs O(n log n) times. + const scored = specs.map((spec) => ({ spec, w: weight(spec), k: key(spec) })); + scored.sort((a, b) => { + const delta = b.w - a.w; + if (delta !== 0) return delta; + return a.k < b.k ? -1 : a.k > b.k ? 1 : 0; + }); + const bins = Array.from({ length: count }, () => ({ total: 0, specs: [] as T[] })); + for (const { spec, w } of scored) { + let lightest = bins[0]; + for (const bin of bins) { + if (bin.total < lightest.total) lightest = bin; + } + lightest.specs.push(spec); + lightest.total += w; + } + return bins.map((bin) => bin.specs); +} diff --git a/gitnexus/test/integration/analyze-embedding-flags-e2e.test.ts b/gitnexus/test/integration/analyze-embedding-flags-e2e.test.ts index f34595832..86fe6e7ff 100644 --- a/gitnexus/test/integration/analyze-embedding-flags-e2e.test.ts +++ b/gitnexus/test/integration/analyze-embedding-flags-e2e.test.ts @@ -14,24 +14,16 @@ * import, no pipeline, no DB, no network — just tsx startup + commander parse. * That makes these deterministic and fast, unlike the full-analyze e2e cases. * - * Run via tsx (no build step), mirroring test/integration/cli-e2e.test.ts. + * Spawns the CLI via CLI_SPAWN_PREFIX (built dist in CI, tsx-on-source locally), + * mirroring test/integration/cli-e2e.test.ts. */ import { spawnSync } from 'child_process'; -import { createRequire } from 'module'; import os from 'os'; import path from 'path'; import fs from 'fs'; -import { fileURLToPath, pathToFileURL } from 'url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -const testDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); - -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; let cwd: string; @@ -54,7 +46,7 @@ function runAnalyze(args: string[]) { // (would drop the tsx loader). Irrelevant on the invalid-dims path since the // hook exits first, but harmless and matches the cli-e2e harness. env.NODE_OPTIONS = `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(); - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, 'analyze', ...args], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'analyze', ...args], { cwd, encoding: 'utf8', timeout: 30_000, diff --git a/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts b/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts index c517263df..1be20fd54 100644 --- a/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts +++ b/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts @@ -31,23 +31,17 @@ * (rather than the exact engine error wording) keeps this test stable. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import { createRequire } from 'module'; -import { fileURLToPath, pathToFileURL } from 'url'; +import { fileURLToPath } from 'url'; import { cleanupTempDirSync } from '../helpers/test-db.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; - let tmpParent: string; let suiteGitnexusHome: string; let repoPath: string; @@ -92,28 +86,24 @@ describe('analyze WAL auto-checkpoint rename failure (real lbug, no mocks)', () fs.mkdirSync(blockerDir, { recursive: true }); fs.writeFileSync(path.join(blockerDir, 'blocker'), 'cannot-be-renamed-over'); - const result = spawnSync( - process.execPath, - ['--import', tsxImportUrl, cliEntry, 'analyze', '--skip-skills'], - { - cwd: repoPath, - encoding: 'utf8', - // Generous timeout: the test does real CSV/COPY work before the - // first failing checkpoint, and CI runners are slow. - timeout: process.env.CI ? 120_000 : 60_000, - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - GITNEXUS_HOME: suiteGitnexusHome, - // Skip ensureHeap re-exec (which drops the tsx loader). - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - // Tiny threshold forces auto-checkpoint on every write so the - // first write into the WAL trips the planted rename blocker. - GITNEXUS_WAL_CHECKPOINT_THRESHOLD: '1', - CI: '1', - }, + const result = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'analyze', '--skip-skills'], { + cwd: repoPath, + encoding: 'utf8', + // Generous timeout: the test does real CSV/COPY work before the + // first failing checkpoint, and CI runners are slow. + timeout: process.env.CI ? 120_000 : 60_000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + GITNEXUS_HOME: suiteGitnexusHome, + // Skip ensureHeap re-exec (which drops the tsx loader). + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + // Tiny threshold forces auto-checkpoint on every write so the + // first write into the WAL trips the planted rename blocker. + GITNEXUS_WAL_CHECKPOINT_THRESHOLD: '1', + CI: '1', }, - ); + }); const combined = `${result.stderr}\n${result.stdout}`; diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 7f13b9ed7..172a405b6 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -13,14 +13,13 @@ import { spawnSync, spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import { fileURLToPath, pathToFileURL } from 'url'; +import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; import { cleanupTempDirSync } from '../helpers/test-db.js'; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); // `MINI_REPO` is a *per-run temp copy* of the fixture, not the shared @@ -39,14 +38,6 @@ let MINI_REPO: string; let tmpParent: string; let suiteGitnexusHome: string; -// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd -// outside the project tree (bare 'tsx' specifier won't resolve there). -// Cannot use require.resolve('tsx/dist/loader.mjs') because the subpath is -// not in tsx's package.json exports; resolve the package root then join. -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; - beforeAll(() => { // Copy the fixture into an isolated tmpdir named `mini-repo` so that the // `--repo mini-repo` CLI arg (which matches by basename) still works. @@ -112,7 +103,7 @@ function cliEnv(extraEnv: Record = {}) { } function runCli(command: string, cwd: string, timeoutMs = 15000) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, command], { cwd, encoding: 'utf8', timeout: timeoutMs, @@ -126,7 +117,7 @@ function runCli(command: string, cwd: string, timeoutMs = 15000) { * can pass flags (e.g. --help) or omit a command entirely. */ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, @@ -146,7 +137,7 @@ function runCliWithEnv( extraEnv: Record, timeoutMs = 15000, ) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, @@ -229,15 +220,11 @@ function runEvalServerHostFlagTest( }, ): Promise { return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - ['--import', tsxImportUrl, cliEntry, 'eval-server', ...spawnArgs], - { - cwd: MINI_REPO, - stdio: ['ignore', 'pipe', 'pipe'], - env: cliEnv(), - }, - ); + const child = spawn(process.execPath, [...CLI_SPAWN_PREFIX, 'eval-server', ...spawnArgs], { + cwd: MINI_REPO, + stdio: ['ignore', 'pipe', 'pipe'], + env: cliEnv(), + }); let stdoutBuffer = ''; let stderrBuffer = ''; @@ -1072,12 +1059,13 @@ describe('CLI end-to-end', () => { describe('CLI error handling', () => { /** - * Helper to spawn CLI from a cwd outside the project tree. - * Uses the absolute file:// URL to tsx loader so the --import hook - * resolves even when cwd has no node_modules. + * Helper to spawn CLI from a cwd outside the project tree via + * CLI_SPAWN_PREFIX (built dist in CI, tsx-on-source locally). On the tsx + * path the loader is an absolute file:// URL so the --import hook resolves + * even when cwd has no node_modules. */ function runCliOutsideProject(args: string[], cwd: string, timeoutMs = 15000) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], { cwd, encoding: 'utf8', timeout: timeoutMs, @@ -1196,17 +1184,13 @@ describe('CLI end-to-end', () => { }); // Must spawn outside project tree so it doesn't find parent .gitnexus - const result = spawnSync( - process.execPath, - ['--import', tsxImportUrl, cliEntry, 'wiki', tmpDir], - { - cwd: tmpDir, - encoding: 'utf8', - timeout: 15000, - stdio: ['pipe', 'pipe', 'pipe'], - env: cliEnv(), - }, - ); + const result = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'wiki', tmpDir], { + cwd: tmpDir, + encoding: 'utf8', + timeout: 15000, + stdio: ['pipe', 'pipe', 'pipe'], + env: cliEnv(), + }); if (result.status === null) return; expect(result.status).toBe(1); @@ -1324,15 +1308,7 @@ describe('CLI end-to-end', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, - [ - '--import', - tsxImportUrl, - cliEntry, - 'cypher', - 'MATCH (n) RETURN n LIMIT 500', - '--repo', - 'mini-repo', - ], + [...CLI_SPAWN_PREFIX, 'cypher', 'MATCH (n) RETURN n LIMIT 500', '--repo', 'mini-repo'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], @@ -1382,7 +1358,7 @@ describe('CLI end-to-end', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, - ['--import', tsxImportUrl, cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'], + [...CLI_SPAWN_PREFIX, 'eval-server', '--port', '0', '--idle-timeout', '3'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/gitnexus/test/integration/cli-limit-e2e.test.ts b/gitnexus/test/integration/cli-limit-e2e.test.ts index 00d58dcde..65652260b 100644 --- a/gitnexus/test/integration/cli-limit-e2e.test.ts +++ b/gitnexus/test/integration/cli-limit-e2e.test.ts @@ -21,24 +21,18 @@ import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import { fileURLToPath, pathToFileURL } from 'url'; +import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; import { cleanupTempDirSync } from '../helpers/test-db.js'; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); let MINI_REPO: string; let tmpParent: string; let suiteGitnexusHome: string; -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; - function cliEnv(extraEnv: Record = {}) { return { ...process.env, @@ -49,7 +43,7 @@ function cliEnv(extraEnv: Record = {}) { } function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 30000) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, diff --git a/gitnexus/test/integration/fts-extension-e2e.test.ts b/gitnexus/test/integration/fts-extension-e2e.test.ts index b906e4ab6..f74a6caf6 100644 --- a/gitnexus/test/integration/fts-extension-e2e.test.ts +++ b/gitnexus/test/integration/fts-extension-e2e.test.ts @@ -19,25 +19,16 @@ * - heal: FORCE INSTALL replaces a broken file over the network (auto) */ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import { fileURLToPath, pathToFileURL } from 'url'; -import { createRequire } from 'module'; import lbug from '@ladybugdb/core'; import { getExtensionInstallChildProcessArgs } from '../../src/core/lbug/extension-loader.js'; import { cleanupTempDirSync } from '../helpers/test-db.js'; -const testDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); - -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; - /** `.lbdb/extension///fts/libfts.lbug_extension`, discovered not hardcoded. */ let extensionRelPath: string; /** Canonical valid extension bytes (path to a known-good file). */ @@ -141,7 +132,7 @@ const runCli = ( policy: 'load-only' | 'auto', timeoutMs = 180_000, ): CliResult => { - const result = spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], { + const result = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], { cwd, encoding: 'utf8', timeout: timeoutMs, diff --git a/gitnexus/test/integration/group/bridge-cache-reopen.test.ts b/gitnexus/test/integration/group/bridge-cache-reopen.test.ts index b116c7da2..9e1150eb5 100644 --- a/gitnexus/test/integration/group/bridge-cache-reopen.test.ts +++ b/gitnexus/test/integration/group/bridge-cache-reopen.test.ts @@ -16,8 +16,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import fsp from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -29,12 +28,11 @@ import { } from '../../../src/core/group/bridge-db.js'; import { retryRename } from '../../../src/storage/fs-atomic.js'; import { cleanupTempDir } from '../../helpers/test-db.js'; +import { tsxLoaderUrl } from '../../helpers/cli-entry.js'; // Absolute file:// URL to the tsx loader so the seed script runs under tsx in a -// child process (mirrors test/integration/cli-e2e.test.ts). -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; +// child process (shared resolver — see test/helpers/cli-entry.ts). +const tsxImportUrl = tsxLoaderUrl(); const seedScript = fileURLToPath(new URL('./fixtures/seed-bridge.ts', import.meta.url)); describe('bridge RO-handle cache — cross-process seed (Windows reopen fix, #2274)', () => { diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts index 7a76f86fd..622d6b627 100644 --- a/gitnexus/test/integration/group/group-cli.test.ts +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -1,22 +1,18 @@ /** - * Smoke-test `gitnexus group` CLI via tsx (same pattern as cli-e2e.test.ts). + * Smoke-test `gitnexus group` CLI (same spawn pattern as cli-e2e.test.ts, via + * CLI_SPAWN_PREFIX: built dist in CI, tsx-on-source locally). * Does not exercise LadybugDB-backed commands end-to-end (needs indexed fixtures). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { CLI_SPAWN_PREFIX } from '../../helpers/cli-entry.js'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; import os from 'node:os'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; - let tmpHome: string; beforeAll(() => { @@ -30,7 +26,7 @@ afterAll(() => { }); function runGroup(args: string[]) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, 'group', ...args], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', ...args], { cwd: repoRoot, encoding: 'utf8', timeout: 20000, @@ -85,9 +81,7 @@ describe('group CLI', () => { const r = spawnSync( process.execPath, [ - '--import', - tsxImportUrl, - cliEntry, + ...CLI_SPAWN_PREFIX, 'group', 'impact', 'test-group', diff --git a/gitnexus/test/integration/skills-e2e.test.ts b/gitnexus/test/integration/skills-e2e.test.ts index acf11a15e..ddb1134a4 100644 --- a/gitnexus/test/integration/skills-e2e.test.ts +++ b/gitnexus/test/integration/skills-e2e.test.ts @@ -11,22 +11,11 @@ * Accepts status === null (timeout) as valid on slow CI runners. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import { fileURLToPath, pathToFileURL } from 'url'; -import { createRequire } from 'module'; - -const testDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); - -// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd -// outside the project tree (bare 'tsx' specifier won't resolve there). -const _require = createRequire(import.meta.url); -const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); -const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; // ============================================================================ // FILE-LOCAL HELPERS @@ -34,10 +23,11 @@ const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).h /** * Spawn the CLI with `analyze --skills` in the given cwd. - * Uses the absolute tsx loader URL so it works outside the project tree. + * Entry point comes from CLI_SPAWN_PREFIX (built dist in CI, tsx-on-source + * locally); the tsx path uses an absolute loader URL so it resolves from any cwd. */ function runSkillsCli(cwd: string, timeoutMs = 45000) { - return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, 'analyze', '--skills'], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'analyze', '--skills'], { cwd, encoding: 'utf8', timeout: timeoutMs, diff --git a/gitnexus/test/unit/cli-entry.test.ts b/gitnexus/test/unit/cli-entry.test.ts new file mode 100644 index 000000000..90bdd0827 --- /dev/null +++ b/gitnexus/test/unit/cli-entry.test.ts @@ -0,0 +1,93 @@ +/** + * Locks the CLI-spawn entry-point resolution used by every e2e/integration suite + * (test/helpers/cli-entry.ts). The branch logic decides whether tests exercise the + * built `dist/cli/index.js` or tsx-on-source, so a regression here silently changes + * what every spawn-based test actually runs — worth a direct, env-free unit test. + */ +import { describe, it, expect } from 'vitest'; +import { computeSpawnPrefix, CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; + +const DIST = '/repo/dist/cli/index.js'; +const SRC = '/repo/src/cli/index.ts'; +const TSX = 'file:///repo/node_modules/tsx/dist/loader.mjs'; + +describe('computeSpawnPrefix', () => { + it('selects the built dist entry when mode=dist and dist exists', () => { + expect( + computeSpawnPrefix({ + mode: 'dist', + distEntry: DIST, + srcEntry: SRC, + distExists: true, + tsxLoaderUrl: TSX, + }), + ).toEqual([DIST]); + }); + + it('throws an actionable "run npm run build" error when mode=dist but dist is missing', () => { + expect(() => + computeSpawnPrefix({ + mode: 'dist', + distEntry: DIST, + srcEntry: SRC, + distExists: false, + tsxLoaderUrl: TSX, + }), + ).toThrow(/run `npm run build`/); + }); + + it('falls back to tsx-on-source when mode is unset (the local default)', () => { + expect( + computeSpawnPrefix({ + mode: undefined, + distEntry: DIST, + srcEntry: SRC, + distExists: false, + tsxLoaderUrl: TSX, + }), + ).toEqual(['--import', TSX, SRC]); + }); + + it('forces tsx-on-source when mode=src even if dist exists', () => { + expect( + computeSpawnPrefix({ + mode: 'src', + distEntry: DIST, + srcEntry: SRC, + distExists: true, + tsxLoaderUrl: TSX, + }), + ).toEqual(['--import', TSX, SRC]); + }); + + it('throws on an unknown mode — never dist without opt-in, never a silent tsx fallback', () => { + expect(() => + computeSpawnPrefix({ + mode: 'production', + distEntry: DIST, + srcEntry: SRC, + distExists: true, + tsxLoaderUrl: TSX, + }), + ).toThrow(/Unknown GITNEXUS_E2E_CLI/); + }); + + it('ignores distExists off the dist branch (mode unset, dist present) — still tsx', () => { + expect( + computeSpawnPrefix({ + mode: undefined, + distEntry: DIST, + srcEntry: SRC, + distExists: true, + tsxLoaderUrl: TSX, + }), + ).toEqual(['--import', TSX, SRC]); + }); +}); + +describe('CLI_SPAWN_PREFIX (resolved from the current environment)', () => { + it('is a non-empty argv prefix ending at a gitnexus CLI entry point', () => { + expect(CLI_SPAWN_PREFIX.length).toBeGreaterThan(0); + expect(CLI_SPAWN_PREFIX[CLI_SPAWN_PREFIX.length - 1]).toMatch(/cli[/\\]index\.(ts|js)$/); + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index d8ab23e43..b0c276bc2 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -5,19 +5,18 @@ import { fileURLToPath } from 'node:url'; import { Command, Option } from 'commander'; import * as ts from 'typescript'; import { afterEach, describe, expect, it } from 'vitest'; +import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { localizeCliHelp } from '../../src/cli/help-i18n.js'; import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); -const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); - function runHelp(command: string, env: NodeJS.ProcessEnv = {}) { return runHelpArgs([command], env); } function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) { - return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, ...args, '--help'], { + return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args, '--help'], { cwd: repoRoot, encoding: 'utf8', env: { ...process.env, ...env }, diff --git a/gitnexus/test/unit/shard-arg.test.ts b/gitnexus/test/unit/shard-arg.test.ts new file mode 100644 index 000000000..a6f6d727f --- /dev/null +++ b/gitnexus/test/unit/shard-arg.test.ts @@ -0,0 +1,37 @@ +/** + * Locks the `--shard=/` resolution used by the CI cross-platform + * runner (scripts/shard-arg.ts). A regression here silently changes whether the + * platform-sensitive suite shards at all — worth a direct, env-free unit test. + */ +import { describe, it, expect } from 'vitest'; +import { parseShardArg } from '../../scripts/shard-arg.js'; + +describe('parseShardArg', () => { + it('returns undefined when no --shard arg is present (unsharded run)', () => { + expect(parseShardArg(['run', '--reporter=dot'])).toBeUndefined(); + }); + + it('returns the matched --shard token when present', () => { + expect(parseShardArg(['--shard=1/3'])).toBe('--shard=1/3'); + }); + + it('finds the --shard token amid other args', () => { + expect(parseShardArg(['--reporter=dot', '--shard=2/2', '--bail'])).toBe('--shard=2/2'); + }); + + it('throws on a malformed --shard arg (missing /total)', () => { + expect(() => parseShardArg(['--shard=1'])).toThrow(/Malformed --shard/); + }); + + it('throws on a bare --shard with no value', () => { + expect(() => parseShardArg(['--shard'])).toThrow(/Malformed --shard/); + }); + + it('throws on a non-numeric --shard value', () => { + expect(() => parseShardArg(['--shard=abc'])).toThrow(/Malformed --shard/); + }); + + it('ignores flags that merely start with --shard (e.g. --shardx=)', () => { + expect(parseShardArg(['--shardx=1/2'])).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/shard-balance.test.ts b/gitnexus/test/unit/shard-balance.test.ts new file mode 100644 index 000000000..ff2feb372 --- /dev/null +++ b/gitnexus/test/unit/shard-balance.test.ts @@ -0,0 +1,54 @@ +/** + * Locks the cost-balanced shard partition used by PerfSequencer + * (test/helpers/shard-balance.ts). The load-bearing property is that the union + * of all shards equals the input exactly — a drop/dup here silently changes what + * CI runs — so that is asserted directly, plus balance and determinism. + */ +import { describe, it, expect } from 'vitest'; +import { assignShards, specWeight } from '../helpers/shard-balance.js'; + +const key = (s: { id: string }) => s.id; +const weight = (s: { w: number }) => s.w; +const make = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `f${i}`, w: (i % 5) + 1 })); + +describe('assignShards', () => { + it('assigns every spec exactly once across all shards (disjoint + complete)', () => { + const specs = make(37); + const shards = [1, 2, 3].map((i) => assignShards(specs, 3, weight, key)[i - 1]); + expect(shards.flat().map(key).sort()).toEqual(specs.map(key).sort()); + expect(shards.reduce((n, s) => n + s.length, 0)).toBe(specs.length); + }); + + it('leaves no shard empty when specs outnumber shards', () => { + const bins = assignShards(make(10), 3, weight, key); + expect(bins.every((b) => b.length > 0)).toBe(true); + }); + + it('balances total weight within one item of optimal (greedy LPT)', () => { + const totals = assignShards(make(30), 3, weight, key).map((b) => + b.reduce((t, s) => t + s.w, 0), + ); + expect(Math.max(...totals) - Math.min(...totals)).toBeLessThanOrEqual(5); + }); + + it('is deterministic — identical input yields identical bins', () => { + const specs = make(20); + const a = assignShards(specs, 4, weight, key).map((b) => b.map(key)); + const b = assignShards(specs, 4, weight, key).map((b2) => b2.map(key)); + expect(a).toEqual(b); + }); +}); + +describe('specWeight', () => { + it('weights spawn-heavy (fileParallelism:false) files far above parallel ones', () => { + const heavy = specWeight({ + moduleId: '/does/not/exist/heavy.test.ts', + project: { config: { fileParallelism: false } }, + }); + const light = specWeight({ + moduleId: '/does/not/exist/light.test.ts', + project: { config: { fileParallelism: true } }, + }); + expect(heavy).toBeGreaterThan(light + 500); + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 4d4a461b7..895382040 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import PerfSequencer from './test/helpers/perf-sequencer.js'; export default defineConfig({ test: { @@ -34,6 +35,14 @@ export default defineConfig({ }, }, + // Balance shards by estimated work rather than file count, so the + // spawn-heavy sequential suites spread evenly across shard runners instead + // of clustering onto one (see test/helpers/perf-sequencer.ts). Only shard() + // is overridden — groupOrder and sort order are left to the base sequencer. + sequence: { + sequencer: PerfSequencer, + }, + // LadybugDB's native mmap addon causes file-lock conflicts when vitest // runs lbug test files in parallel forks on Windows. The 'lbug-db' // project forces sequential execution (fileParallelism: false).