fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout (#2394)

* fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout

The `windows-latest (platform-sensitive)` job was hitting its 15-min internal
vitest watchdog in run-cross-platform.ts. It's cumulative slowness, not a hang:
the fixed 72-file suite is dominated by ~50 CLI/worker process spawns, and
Windows is ~5x slower than macOS at process startup (macOS ran the same set in
~3min of tests). Two complementary changes bring it back under the watchdog with
headroom, without touching any test assertion:

- Shard the platform-sensitive matrix (windows/macos × shard [1,2]) and forward
  `--shard=i/2` through run-cross-platform.ts to vitest, which partitions the
  fixed file list deterministically (sha1, equal file-count) — halving each
  runner. macOS/Ubuntu were already under budget.
- New test/helpers/cli-entry.ts (`CLI_SPAWN_PREFIX`): spawn the built
  `dist/cli/index.js` when `GITNEXUS_E2E_CLI=dist` (set on the cross-platform job,
  which already builds) instead of `node --import tsx src/cli/index.ts`, which
  re-transpiles the whole CLI on every spawn. Defaults to tsx-on-source so local
  runs always reflect current source; `GITNEXUS_E2E_CLI=dist` on an unbuilt tree
  throws an actionable "run npm run build" error. dist is opt-in only — never
  inferred from a generic `CI` env — so an ambient `CI=1` can't silently run a
  stale build. Converted 8 spawn-based e2e suites; added test/unit/cli-entry.test.ts.

The Ubuntu coverage job leaves `GITNEXUS_E2E_CLI` unset, so the tsx-on-source path
stays exercised in CI too (both entry points covered).

Measured on Linux: cli-limit-e2e 121.5s→91s, cli-e2e 289s→217s (~25%); larger on
Windows where the transpile is a bigger share of each spawn.

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

* refactor(ci): derive platform-sensitive shard count from one source (#2394)

The shard total was hardcoded in three coupled, unenforced places (matrix
length, job-name suffix, --shard denominator); editing one without the others
silently dropped a shard's tests with green CI. Add a checkout-free shard-plan
job whose single TOTAL generates both the shard index list (consumed via
fromJSON) and the /N denominator (job name + --shard arg), so they cannot
drift. Asserts TOTAL>=1 to rule out an empty-matrix silent skip. No behavior
change — still 2 shards per OS.

Addresses PR #2394 tri-review finding F2.

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

* fix(ci): 3 shards for real Windows headroom + honest sharding comments (#2394)

vitest shards by file COUNT, not runtime, so the heaviest spawn suites cluster
into one shard: live CI showed Windows shard 1/2 at 12m12s (~81% of the 15-min
watchdog) vs shard 2/2 at 3m0s. The old comments claimed "comfortable/generous
headroom", which the count-based split doesn't deliver at 2 shards. Bump TOTAL
to 3 (one line, single source) so even the busiest Windows shard clears the
watchdog, and reword the comments to describe count-based (not time-based)
sharding.

Addresses PR #2394 tri-review finding F1.

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

* test(ci): extract testable parseShardArg from run-cross-platform (#2394)

The --shard parse/forward glue had no unit test. Extract it into a pure
scripts/shard-arg.ts (mirroring the computeSpawnPrefix extraction precedent) so
the branch logic is lockable without the script's top-level execFileSync, and
add test/unit/shard-arg.test.ts (absent -> undefined, valid token -> passed
through, found amid other args). Behavior unchanged; U4 adds the malformed
fail-loud on top.

Addresses PR #2394 tri-review finding F3.

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

* fix(ci): fail loud on a malformed --shard arg (#2394)

A shard-shaped-but-malformed arg (--shard=1, --shard, --shard=abc) was silently
ignored, dropping the shard flag so both legs ran the full unsharded ~50-spawn
suite — re-arming the Windows watchdog timeout with no signal. parseShardArg now
throws an actionable error on any --shard/--shard=… arg that fails the strict
regex (unrelated flags like --shardx= pass through), and the call site in
run-cross-platform.ts catches it into console.error + exit 1, kept outside the
execFileSync try so the message isn't swallowed by that catch's watchdog-only
branch.

Addresses PR #2394 tri-review finding F4.

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

* fix(test): fail loud on an unknown GITNEXUS_E2E_CLI value (#2394)

computeSpawnPrefix silently degraded any unknown GITNEXUS_E2E_CLI value to
tsx-on-source, so a typo (e.g. `dsit`) would make CI believe it tests the dist
entry point while actually running src. Throw on any value other than
'dist'/'src'/unset (the safe tsx default is preserved for unset/''/'src', so it
still never selects dist without an explicit opt-in). Flip the unknown-mode unit
test to assert the throw and add the missing {mode:undefined, distExists:true}
case. Only ci-tests.yml sets the var (=dist), so no existing suite is affected.

Addresses PR #2394 tri-review findings minor-a/b.

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

* test(ci): run cli-entry.test.ts on the cross-platform matrix (#2394)

cli-entry.test.ts resolves CLI_SPAWN_PREFIX from a real path, and its last
assertion (cli[/\\]index) has a Windows backslash branch that only Ubuntu
exercised. Register it in PLATFORM_LOGIC so it runs on the Windows/macOS matrix
too. (shard-arg.test.ts stays out — pure string logic, OS-independent.) List
grows 73 -> 74; the generated shard matrix keeps coverage complete.

Addresses PR #2394 tri-review finding minor-c.

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

* refactor(test): share tsxLoaderUrl(), dedup the last tsx-loader boilerplate (#2394)

bridge-cache-reopen.test.ts carried its own copy of the tsx-loader-resolution
boilerplate (createRequire -> resolve('tsx/package.json') -> pathToFileURL) —
the one site the PR's CLI_SPAWN_PREFIX migration didn't cover (it spawns a seed
script, not the CLI). Export the existing tsxLoaderUrl() from cli-entry.ts and
reuse it here; the resolved loader URL is byte-identical.

Addresses PR #2394 tri-review finding minor-d.

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

* fix(test): make skipUnlessFtsAvailable install FTS on miss so shards are self-sufficient (#2394)

Sharding the platform-sensitive suite into 3 exposed a latent test-isolation
bug: load-only FTS primitives (test/integration/lbug-core-adapter.test.ts) only
passed because a sibling installer test happened to co-locate in the same shard
and install FTS into the shared ~/.lbdb first. At 3 shards, lbug-core-adapter
landed in a shard with no installer sibling, so its load-only loadFTSExtension()
failed deterministically on macOS+Windows shard 2/3 under GITNEXUS_REQUIRE_FTS=1.

Make the gate self-sufficient: on a load-only miss under REQUIRE_FTS, install
FTS with `auto` (LOAD-first, then one bounded network INSTALL) before treating
it as a hard failure — mirroring withTestIndexedDB. A pre-installed extension
still costs no network (auto is LOAD-first); offline/local runs (no env var)
still skip gracefully. Verified: with a fresh HOME (no pre-installed FTS) +
REQUIRE_FTS=1, lbug-core-adapter now passes 15/15 (previously threw).

Addresses the 3-shard CI failure surfaced while validating PR #2394's F1 fix.

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

* ci: warm-cache the LadybugDB FTS extension across platform shards (#2394)

Follow-up to the FTS self-install fix: cache ~/.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
still self-install FTS on demand (test/helpers/fts-availability.ts), so this is
never a correctness dependency, just a way to cut the network-install surface
that made the sharded FTS tests flaky. Keyed by lockfile hash (a LadybugDB
version bump re-installs); per-OS since the extension is a native binary.

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

* fix(ci): pass shard via env to clear zizmor template-injection (#2394)

Interpolating ${{ matrix.shard }} (now sourced from the shard-plan job output)
directly into the run: shell tripped zizmor's template-injection audit
(code-scanning alert #824, ci-tests.yml:147). Move the value into a SHARD env
var — assigned via ${{ }} but referenced as "$SHARD" in the shell, which is not
an injection sink — and set shell: bash so the expansion is uniform across the
windows + macOS matrix (the default run shell is pwsh on Windows, where $SHARD
would be empty and trip the new malformed-shard fail-loud). Verified locally
with zizmor: the :147 template-injection finding is gone.

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

* ci: shard the ubuntu coverage job and merge blobs before the threshold gate (#2394)

The coverage job ran the full suite unsharded (~16 min). Shard it like the
cross-platform matrix, then merge the per-shard coverage before enforcing the
threshold gate:

- shard-plan now also single-sources the coverage shard count (cov_total /
  cov_shards), so the coverage matrix + /N denominator can't drift.
- The `tests` job becomes a coverage shard matrix: each shard runs
  `vitest run --shard --coverage --reporter=blob` with thresholds forced to 0
  (a single shard's partial coverage can never meet the gate) and uploads its
  blob. FTS self-installs per shard, so sharding the full suite is safe.
- New `coverage-merge` job (needs: tests) reduces the blobs with
  `vitest --mergeReports`, enforcing the REAL config thresholds on the combined
  ('new') coverage — this is the gate. It 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.

The shard arg goes through a SHARD env var + shell: bash (no template-injection).
Validated locally: shard blobs write and merge into a coverage-summary.json +
merged test-results.json; the merge enforces thresholds on the union. CI Gate
still aggregates the coverage-merge result via the reusable-workflow call.

Note: the coverage check names change (ubuntu / coverage 1/3 … + merge) — update
any pinned branch-protection required checks.

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

* fix(ci): include hidden files when uploading the coverage blob (#2394)

The coverage shards write their blob to gitnexus/.vitest-reports/ (a dotdir).
actions/upload-artifact excludes hidden files by default, so the coverage-blob-*
artifacts uploaded empty — the merge job then downloaded 0 artifacts and
vitest --mergeReports failed with ENOENT scandir '.vitest-reports'. Set
include-hidden-files: true on the blob upload so the blobs actually ship.

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

* fix(ci): group shard-plan GITHUB_OUTPUT writes to satisfy shellcheck SC2129 (#2394)

Adding the coverage shard outputs (cov_shards/cov_total) made the shard-plan gen
step write four individual `>> "$GITHUB_OUTPUT"` redirects, which shellcheck
(run by the actionlint check) flags as SC2129. Group the echoes into a single
`{ …; } >> "$GITHUB_OUTPUT"` block.

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

* perf(test): cost-balanced shard sequencer to cut CPU contention (#2394)

vitest's default --shard hashes file paths and splits by file COUNT, which
clustered the spawn-heavy suites onto one runner (Windows platform shard 1 ran
~4x the others). Add a custom sequence.sequencer that overrides only shard()
and balances by estimated WORK instead:

- specWeight() weights the fileParallelism:false spawn-heavy suites (cli-e2e,
  lbug-db — already isolated to run sequentially) far above the parallel default
  files, plus file size as a cheap finer signal. Deterministic per checkout.
- assignShards() does greedy longest-processing-time bin-packing (heaviest file
  into the currently-lightest shard). The partition stays complete and disjoint
  — verified: on the 74-file cross-platform set the three shards weigh
  7611/7610/8064 (the sequential-heavy files spread ~7/7/8) with zero overlap and
  no file dropped, vs the hash split's count-only balance.

sort() is left to the base sequencer so project groupOrder / duration-cache
ordering is untouched. Pure logic split into shard-balance.ts with a unit test
locking the disjoint+complete, balance, and determinism properties.

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

* fix(ci): install + cache FTS up front on the coverage (and cross-platform) shards (#2394)

coverage 3/3 failed on extension-binary-real.test.ts: it uses the file-path FTS
gate (requireFtsResourceOrSkip), which resolves ~/.lbdb/extension at MODULE LOAD
and cannot self-install the way the load-path gate (skipUnlessFtsAvailable, U8)
does. The coverage job had no FTS cache and relied on an installer test running
first in the shard — the balancing sequencer reshuffled the shards and dropped
extension-binary-real into a shard with no installer, so FTS was absent.

Remove the ordering dependency: add scripts/ensure-fts.ts (init a throwaway lbug
db, loadFTSExtension with policy:auto → LOAD-first, INSTALL on miss) and run it
up front on every coverage AND cross-platform shard, after restoring the per-OS
FTS cache. The coverage job now shares that same cache key (it previously had
none — this is the "share the cached FTS with coverage" the failure pointed at).
Cold cache installs once; warm cache is a no-network load. Verified locally:
ensure-fts installs FTS into a fresh HOME and is a no-op when already present.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-07-08 09:09:11 +01:00 • committed by GitHub
parent 16dc5584e7
commit 7140a186ac
22 changed files with 717 additions and 164 deletions

View file

@ -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:

View file

@ -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',

View file

@ -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 });
}

View file

@ -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=<i>/<n>` 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);
}

View file

@ -0,0 +1,30 @@
/**
* Resolves the optional `--shard=<index>/<total>` 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=<index>/<total>` 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=<index>/<total>`);
}
return shardArgs[0];
}

View file

@ -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();

View file

@ -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 ' +

View file

@ -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<TestSpecification[]> {
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] ?? [];
}
}

View file

@ -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<T>(
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);
}

View file

@ -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,

View file

@ -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}`;

View file

@ -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<string, string> = {}) {
}
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<string, string>,
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<void> {
return new Promise<void>((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<void>((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<void>((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'],

View file

@ -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<string, string> = {}) {
return {
...process.env,
@ -49,7 +43,7 @@ function cliEnv(extraEnv: Record<string, string> = {}) {
}
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,

View file

@ -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/<version>/<platform>/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,

View file

@ -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)', () => {

View file

@ -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',

View file

@ -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,

View file

@ -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)$/);
});
});

View file

@ -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 },

View file

@ -0,0 +1,37 @@
/**
* Locks the `--shard=<index>/<total>` 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();
});
});

View file

@ -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);
});
});

View file

@ -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).