mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-26 06:02:22 +00:00
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:
parent
16dc5584e7
commit
7140a186ac
22 changed files with 717 additions and 164 deletions
169
.github/workflows/ci-tests.yml
vendored
169
.github/workflows/ci-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
32
gitnexus/scripts/ensure-fts.ts
Normal file
32
gitnexus/scripts/ensure-fts.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
30
gitnexus/scripts/shard-arg.ts
Normal file
30
gitnexus/scripts/shard-arg.ts
Normal 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];
|
||||
}
|
||||
96
gitnexus/test/helpers/cli-entry.ts
Normal file
96
gitnexus/test/helpers/cli-entry.ts
Normal 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();
|
||||
|
|
@ -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 ' +
|
||||
|
|
|
|||
23
gitnexus/test/helpers/perf-sequencer.ts
Normal file
23
gitnexus/test/helpers/perf-sequencer.ts
Normal 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] ?? [];
|
||||
}
|
||||
}
|
||||
68
gitnexus/test/helpers/shard-balance.ts
Normal file
68
gitnexus/test/helpers/shard-balance.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
93
gitnexus/test/unit/cli-entry.test.ts
Normal file
93
gitnexus/test/unit/cli-entry.test.ts
Normal 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)$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
37
gitnexus/test/unit/shard-arg.test.ts
Normal file
37
gitnexus/test/unit/shard-arg.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
gitnexus/test/unit/shard-balance.test.ts
Normal file
54
gitnexus/test/unit/shard-balance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue