mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a861a984 | ||
|
|
002375e34f | ||
|
|
ffb773b98e | ||
|
|
d0a88c4310 | ||
|
|
76a591def4 | ||
|
|
2c3303e611 | ||
|
|
5d411565e7 | ||
|
|
a283875e44 | ||
|
|
9f4dda699f | ||
|
|
58d88375b0 | ||
|
|
a2676b0fef |
@@ -23,3 +23,7 @@ test/.cache/
|
||||
|
||||
.claude/
|
||||
export/
|
||||
|
||||
# Tier 3 PGLite snapshot fixture (built on demand by build:pglite-snapshot)
|
||||
test/fixtures/pglite-snapshot.tar
|
||||
test/fixtures/pglite-snapshot.version
|
||||
|
||||
@@ -43,9 +43,16 @@ writing or reviewing an operation, consult `src/core/operations.ts` for the cont
|
||||
|
||||
## Before shipping
|
||||
|
||||
Run `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin up the test
|
||||
Postgres container, run `bun run test:e2e`, tear it down). Ship via the `/ship` skill,
|
||||
not by hand.
|
||||
Easiest path: `bun run ci:local` runs the full CI gate inside Docker (gitleaks,
|
||||
unit tests with `DATABASE_URL` unset, then all 29 E2E files sequentially against a
|
||||
fresh pgvector container) and tears down. Use `bun run ci:local:diff` for the
|
||||
diff-aware subset during fast iteration on a focused branch. Requires Docker
|
||||
(Docker Desktop / OrbStack / Colima) and `gitleaks` (`brew install gitleaks`).
|
||||
|
||||
Manual path: `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin
|
||||
up the test Postgres container, run `bun run test:e2e`, tear it down).
|
||||
|
||||
Ship via the `/ship` skill, not by hand.
|
||||
|
||||
## Privacy
|
||||
|
||||
|
||||
@@ -2,6 +2,89 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.23.1] - 2026-04-30
|
||||
|
||||
**`bun run ci:local` runs the full CI gate on your laptop, 4-way sharded, in ~100 seconds warm. Doc-only diffs go in 5 seconds.**
|
||||
|
||||
CI today catches typos, postgres regressions, and the 2-file Tier 1 mechanical suite. The other 34 E2E files in `test/e2e/` only run nightly, and your unit suite never runs against a real Postgres + pgvector locally. This release ships a Docker-based local CI gate that runs every check CI runs (3000+ unit tests + 36 E2E files + gitleaks + typecheck) in **~100s warm wall-time** on a 16-core host. Four pgvector services + a single bun runner; xargs -P4 fans 4 shards each running unit + E2E concurrently; PGLite snapshot fixture skips the schema-replay cold start. `bun run ci:local:diff` adds a doc-only fast-path that exits in seconds when the diff only touches markdown / docs / scripts. Fail-closed by design: an unmapped src/ change runs all 36 E2E files, never silently nothing.
|
||||
|
||||
The motivating story: a typical PR cycle is push → wait 8 minutes for GH Actions → fix → push → wait 8 minutes → repeat. Now you push when you're done, not to find out you're not done. The first cold run pulls the bun image, installs deps into a named volume, and runs every check; subsequent runs reuse the warm volumes and complete in 16-20 minutes for the full sequential E2E.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Real laptop run on the M-series host, OrbStack daemon. Reproduce with `bun run ci:local`.
|
||||
|
||||
| Metric | Before (push-and-wait) | After (`bun run ci:local`) | Δ |
|
||||
|---|---|---|---|
|
||||
| E2E files exercised before push | 0 | 36 | full coverage |
|
||||
| **Wall-time, full gate, warm (measured, 16-core)** | n/a | **~100 seconds** | **~13x speedup vs push-and-wait** |
|
||||
| Wall-time, doc-only diff | ~3 min CI | ~5s (host gitleaks only) | ~36× faster |
|
||||
| Time to first failure signal | ~3 min CI | ~30s host gitleaks + 5s smoke | 6× faster |
|
||||
| Container env divergence from CI | unknown | bit-for-bit pgvector + bun base | resolved |
|
||||
| Diff-aware selection on focused PRs | none | 3-9 E2E files for typical scoped change | ~70% fewer files |
|
||||
| PGLite cold init per file (measured) | ~828ms | ~181ms via snapshot | 4.5× faster |
|
||||
|
||||
The lane that matters: when the local gate finds a real bug, you fix it before the PR exists. The release surfaced one such bug as a P1 TODO during verification — `multi-source.test.ts` cascade test isn't isolated; PR CI never runs it.
|
||||
|
||||
### What this means for you
|
||||
|
||||
Run `bun run ci:local` before `gh pr create` to catch what nightly CI would catch. Run `bun run ci:local:diff` for fast iteration during a focused branch. The selector is hand-tuned today via `scripts/e2e-test-map.ts`; if it ever runs the full suite when you wanted a narrower set, add an entry. Fail-closed default means you can never break correctness by leaving a glob out — only optimize over time.
|
||||
|
||||
## To take advantage of v0.23.1
|
||||
|
||||
`gbrain upgrade` is a no-op for this release ... no schema migration, no host-repo edits.
|
||||
|
||||
To use the new local CI gate:
|
||||
|
||||
1. **Install Docker engine** (Docker Desktop, OrbStack, or Colima) and `gitleaks` on host:
|
||||
```bash
|
||||
brew install gitleaks
|
||||
```
|
||||
2. **Run the full local gate before pushing:**
|
||||
```bash
|
||||
bun run ci:local
|
||||
```
|
||||
3. **Run the diff-aware subset for fast iteration:**
|
||||
```bash
|
||||
bun run ci:local:diff
|
||||
```
|
||||
4. **Override the postgres host port** if 5434 collides on your machine:
|
||||
```bash
|
||||
GBRAIN_CI_PG_PORT=5435 bun run ci:local
|
||||
```
|
||||
|
||||
The named volumes `gbrain-ci-node-modules`, `gbrain-ci-bun-cache`, and `gbrain-ci-pg-data` keep the install warm. `--clean` nukes them for cold debugging. `--no-pull` skips the upstream pull when offline.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added — Tier 1: parallel-shard orchestration
|
||||
- `bun run ci:local` orchestrates **4 unit+E2E shards in parallel** inside a single bun runner container, each pinned to its own pgvector service. ~3000 unit tests + 36 E2E files complete in ~100s warm.
|
||||
- `bun run ci:local:diff` runs only the E2E files matched by the diff selector. Falls back to all 36 files when an unmapped src/ path or escape-hatch (schema, package.json, skills/) is touched.
|
||||
- `bun run ci:select-e2e` prints the selector's choice for the current branch — pipe-friendly.
|
||||
- `docker-compose.ci.yml` declares 4 `pgvector/pgvector:pg16` services (postgres-1..4) + `oven/bun:1` runner with named volumes for fast restarts. Host ports 5434-5437; override base via `GBRAIN_CI_PG_PORT`.
|
||||
- `scripts/ci-local.sh` orchestrates the gate with `--diff`, `--no-pull`, `--clean`, `--no-shard` flags. Detects git worktrees (Conductor) and bind-mounts the shared gitdir so in-container `git ls-files` works.
|
||||
- `scripts/run-unit-shard.sh` is the per-shard unit runner. Takes `SHARD=N/M`, splits `find test -name '*.test.ts' -not -path test/e2e/*` evenly across shards. Excludes `*.slow.test.ts` (Tier 4 convention).
|
||||
- `scripts/run-e2e.sh` accepts an optional file list from argv, a `--dry-run-list` flag for the inline smoke check, and a `SHARD=N/M` env that filters every M-th file starting at index N. Sequential within a shard preserves the TRUNCATE CASCADE no-race property; parallel across shards is what makes the gate fast.
|
||||
|
||||
#### Added — Tier 2: doc-only diff fast-path
|
||||
- `scripts/select-e2e.ts --classify-only` emits the diff classification (`EMPTY|DOC_ONLY|SRC`) on stdout. `ci-local.sh --diff` reads it before spinning postgres up: if `DOC_ONLY`, the script runs gitleaks on the host and exits in ~5 seconds. Skips the entire ~100s heavy gate when nothing src/-shaped changed.
|
||||
|
||||
#### Added — Tier 3: PGLite snapshot fixture
|
||||
- `scripts/build-pglite-snapshot.ts` boots a fresh PGLite, runs the full `initSchema()` (forward bootstrap + 30 migrations), and dumps the post-init state to `test/fixtures/pglite-snapshot.tar` plus a SHA-256 schema hash sidecar (`pglite-snapshot.version`). Both are gitignored — built on demand by `bun run build:pglite-snapshot` and cached across runs.
|
||||
- `PGLiteEngine.connect()` now reads `GBRAIN_PGLITE_SNAPSHOT` env: when set, validates the sidecar hash against the in-process MIGRATIONS hash, then loads via PGLite's `loadDataDir` blob. `initSchema()` becomes a no-op when the snapshot was loaded. Measured per-file cold init drops from 828ms → 181ms (4.5×).
|
||||
- Bootstrap-correctness tests (`test/bootstrap.test.ts`, `test/schema-bootstrap-coverage.test.ts`) explicitly `delete process.env.GBRAIN_PGLITE_SNAPSHOT` so they keep exercising the cold init path they're meant to verify.
|
||||
|
||||
#### Added — Tier 4: slow-test convention
|
||||
- `*.slow.test.ts` is the convention for tests excluded from the fast `ci:local` shards. `bun run test:slow` (via `scripts/run-slow-tests.sh`) runs only the slow set; CI's normal `bun run test` includes them. `scripts/profile-tests.sh` extracts the top-N slowest tests from any captured `bun test` output for picking demotion candidates.
|
||||
- One genuinely flaky timing test in `test/progress.test.ts` (`startHeartbeat()` heartbeat-count assertion) gained wider tolerance bounds — 4-way parallel shards inflate `setTimeout` jitter beyond the original 2-6 window. Now accepts 1-20 over a 200ms window.
|
||||
|
||||
#### Added — Other
|
||||
- `test/select-e2e.test.ts` covers all 4 selector branches plus 3 codex regression guards (skills/, untracked files, unmapped src/) — 24 cases.
|
||||
|
||||
#### For contributors
|
||||
- `scripts/select-e2e.ts` exports `selectTests(inputs: SelectInputs): string[]`, `classify(changedFiles: string[]): Classification`, and `matchGlob(glob, path): boolean`. The selector is a pure function — pass arrays in, get test files out — so it's trivial to test and easy to fork for another path-glob shape.
|
||||
- `scripts/e2e-test-map.ts` exports `E2E_TEST_MAP: Record<string, string[]>`. Adding a narrower mapping is safe; the fail-closed default catches anything missed.
|
||||
|
||||
## [0.23.0] - 2026-04-26
|
||||
|
||||
**`gbrain dream` now actually dreams. Conversation transcripts become reflections, originals, and 25-year patterns ... overnight.**
|
||||
|
||||
@@ -124,6 +124,9 @@ strict behavior when unset.
|
||||
- `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only.
|
||||
- `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (`<!-- timeline -->`, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics).
|
||||
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces (a) the `${JSON.stringify(x)}::jsonb` interpolation pattern (postgres.js v3 double-encodes it), or (b) `max_stalled INTEGER NOT NULL DEFAULT 1` in any schema source file (v0.15.1 #219 regression guard — must be DEFAULT 5 to preserve SIGKILL-rescue). Wired into `bun test`.
|
||||
- `docker-compose.ci.yml` + `scripts/ci-local.sh` (v0.23.1) — Local CI gate. `bun run ci:local` spins up `pgvector/pgvector:pg16` + `oven/bun:1` with named volumes (`gbrain-ci-pg-data`, `gbrain-ci-node-modules`, `gbrain-ci-bun-cache`), runs gitleaks on host, smoke-tests `scripts/run-e2e.sh` argv handling, runs unit tests with `DATABASE_URL` unset (matches GH Actions structure), then runs all 29 E2E files sequentially. `--diff` swaps in the diff-aware selector; `--no-pull` skips upstream pulls; `--clean` nukes named volumes. Postgres host port defaults to 5434 (avoids 5432 manual `gbrain-test-pg` and 5433 sibling-project conflict); override with `GBRAIN_CI_PG_PORT=NNNN`. Stronger gate than current PR CI's 2-file Tier 1 set — closes the "push-and-wait" feedback loop pre-push.
|
||||
- `scripts/select-e2e.ts` + `scripts/e2e-test-map.ts` (v0.23.1) — Diff-aware E2E test selector. Reads three git sources (committed `origin/master...HEAD`, working-tree `HEAD`, and `git ls-files --others --exclude-standard` for untracked, NOT-gitignored files), classifies as EMPTY / DOC_ONLY / SRC. Fail-closed by design: EMPTY → all 29 files (clean branch shouldn't run nothing), DOC_ONLY (every path matches the README/CLAUDE/AGENTS/CHANGELOG/TODOS allowlist) → empty stdout, SRC → escape-hatch paths (schema, package.json, skills/) trigger all; otherwise the hand-tuned `E2E_TEST_MAP` glob → tests narrows; an unmapped src/ change still emits ALL files, never silently nothing. Pure-function exports (`selectTests`, `classify`, `matchGlob`) so it's trivial to test and fork. `bun run ci:select-e2e` prints the current selection on stdout, pipe-friendly. `test/select-e2e.test.ts` covers all 4 branches plus 3 codex regression guards (skills/, untracked files, unmapped src/) — 24 cases.
|
||||
- `scripts/run-e2e.sh` (v0.23.1 update) — Sequential E2E runner. Now accepts an optional argv-driven file list (used by `ci:local:diff` to pipe in selector output) and a `--dry-run-list` flag that prints the resolved file list and exits (used by `ci-local.sh`'s startup smoke-test). Falls back to `test/e2e/*.test.ts` when invoked with no args.
|
||||
- `scripts/llms-config.ts` + `scripts/build-llms.ts` — Generator for `llms.txt` (llmstxt.org-spec web index) + `llms-full.txt` (inlined single-fetch bundle). Curated config drives both. Run `bun run build:llms` after adding a new doc. `LLMS_REPO_BASE` env var lets forks regenerate with their own URL base. `FULL_SIZE_BUDGET` (600KB) caps the inline bundle; generator WARNs if exceeded. Committed output is not analogous to `schema-embedded.ts` (no runtime consumer); we commit for GitHub browsing and fork-safe fetching.
|
||||
- `AGENTS.md` — Local-clone entry point for non-Claude agents (Codex, Cursor, OpenClaw, Aider). Mirrors `CLAUDE.md` intent via relative links. Claude Code keeps using `CLAUDE.md`.
|
||||
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks to apply when upgrading. Each release appends a new section. v0.10.3 includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
|
||||
@@ -500,7 +503,21 @@ will detect drift and re-bump on the next run.
|
||||
|
||||
## Pre-ship requirements
|
||||
|
||||
Before shipping (/ship) or reviewing (/review), always run the full test suite:
|
||||
Before shipping (/ship) or reviewing (/review), always run the full test suite.
|
||||
Two equivalent paths:
|
||||
|
||||
**Path A — local CI gate (recommended, v0.23.1+):**
|
||||
- `bun run ci:local` runs the entire stack inside Docker: gitleaks (host), unit
|
||||
tests with `DATABASE_URL` unset, and all 29 E2E files sequentially against a
|
||||
fresh pgvector container. Stronger than PR CI's 2-file Tier 1 set; closer to
|
||||
what nightly Tier 1 catches. Spins up + tears down postgres automatically via
|
||||
`docker-compose.ci.yml`. Override the host port with
|
||||
`GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides.
|
||||
- `bun run ci:local:diff` runs only the E2E files matched by the diff selector
|
||||
(`scripts/select-e2e.ts`), falling back to all 29 on unmapped src/ paths or
|
||||
schema/skills/package.json changes. Fast iteration during a focused branch.
|
||||
|
||||
**Path B — manual lifecycle (still supported):**
|
||||
- `bun test` — unit tests (no database required)
|
||||
- Follow the "E2E test DB lifecycle" steps above to spin up the test DB,
|
||||
run `bun run test:e2e`, then tear it down.
|
||||
|
||||
@@ -63,6 +63,24 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run t
|
||||
DATABASE_URL=postgresql://... bun run test:e2e
|
||||
```
|
||||
|
||||
### Local CI gate (recommended before pushing, v0.23.1+)
|
||||
|
||||
```bash
|
||||
bun run ci:local # full gate: gitleaks + unit + ALL 29 E2E files (sequential)
|
||||
bun run ci:local:diff # gate with diff-aware E2E selector
|
||||
bun run ci:select-e2e # print which E2E files the selector would run
|
||||
```
|
||||
|
||||
`ci:local` spins up `pgvector/pgvector:pg16` + `oven/bun:1` via
|
||||
`docker-compose.ci.yml`, runs everything PR CI runs plus the full E2E suite, then
|
||||
tears down. Named volumes keep the install warm across runs (~16-20 min sequential
|
||||
E2E after the first cold pull). Requires Docker (Docker Desktop, OrbStack, or
|
||||
Colima) and `gitleaks` on host (`brew install gitleaks`). Override the postgres
|
||||
host port with `GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides.
|
||||
|
||||
Fail-closed selector: an unmapped `src/` change runs all 29 E2E files. Hand-tune
|
||||
narrower mappings via `scripts/e2e-test-map.ts`.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
|
||||
@@ -736,7 +736,7 @@ The skills in this repo are those patterns, generalized. What took 11 days to bu
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Run `bun test` for unit tests. E2E tests: spin up Postgres with pgvector, run `bun run test:e2e`, tear down.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Run `bun test` for unit tests. For the full local CI gate (gitleaks + unit + all 29 E2E files in Docker, the same checks GH Actions runs), use `bun run ci:local` ... or `bun run ci:local:diff` for the diff-aware subset during fast iteration.
|
||||
|
||||
PRs welcome for: new enrichment APIs, performance optimizations, additional engine backends, new skills following the conformance standard in `skills/skill-creator/SKILL.md`.
|
||||
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
# TODOS
|
||||
|
||||
## ci-local-mirror
|
||||
|
||||
### CI-skip artifact + signature for stages 1+2 follow-up
|
||||
**Priority:** P0
|
||||
|
||||
**What:** After a successful local CI run via `bun run ci:local`, write `.ci-cache/passed-<commit-sha>.json` containing `{commit, test_set_hash, bun_version, schema_hash, signature}`. Push to a `ci-cache` orphan branch (or GH Releases). CI's first step fetches the artifact for the current SHA and skips the test job if (a) signature matches Garry's GPG/SSH key, and (b) `test_set_hash` matches what CI would have run.
|
||||
|
||||
**Why:** Stages 1+2 (shipped in this branch) give a strong local CI gate, but PR CI still re-runs every test on every push. Stage 3 closes the loop and trades ~10 min of CI wall-time for sub-second artifact verification on Garry's own pushes. External PRs are unaffected because the signature won't match — they hit the normal CI path.
|
||||
|
||||
**Pros:**
|
||||
- ~10 min/PR saved on Garry's own pushes; the local gate becomes the source of truth.
|
||||
- External contributor PRs untouched (no security regression).
|
||||
- Forces a clear test-set-hash contract: any drift in what local-vs-CI run is caught at verification time.
|
||||
|
||||
**Cons:**
|
||||
- Trust model needs careful design: signature scheme, key rotation, what happens when signature verification fails.
|
||||
- Cache invalidation is real — if env or service version drifts between local run and CI, a stale local pass could ship to master.
|
||||
- Adds a `ci-cache` branch / artifact storage surface to maintain.
|
||||
|
||||
**Context:**
|
||||
- Discussed during the eng-review of the local CI mirror plan at `~/.claude/plans/lets-do-1-2-dockerfile-ci-zany-charm.md`.
|
||||
- Don't start until stages 1+2 have been used for ~2 weeks AND the `scripts/e2e-test-map.ts` has stabilized (so test_set_hash is a meaningful identity).
|
||||
- Initial trust-but-verify: run both local and CI in parallel for ~1 week before flipping the skip; alert on any disagreement.
|
||||
|
||||
**Effort:** M (human ~2-3 days + ~1 week trust-but-verify period running both local + CI in parallel; CC ~1 day for the mechanics).
|
||||
|
||||
**Depends on / blocked by:** Stages 1+2 (this PR) landing first.
|
||||
|
||||
### test/e2e/multi-source.test.ts cascade test isn't isolated
|
||||
**Priority:** P1
|
||||
|
||||
**What:** The "sources remove cascades to pages + chunks + timeline + links + files" test in `test/e2e/multi-source.test.ts:281` fails when the file runs after other E2E files in the sequential `bash scripts/run-e2e.sh` order, but passes 20/20 on a fresh Postgres volume. The failing assertion is `SELECT COUNT(*) FROM links WHERE from_page_id = aliceId` expecting 0, getting 1 — so a prior file's setup left a `links` row that references a page id the cascade test happens to reuse. The test's own `setupDB()` truncates but doesn't sweep all referencing rows back when ids collide.
|
||||
|
||||
**Why:** Surfaced when `bun run ci:local` (this PR's local CI gate) ran the full sequential E2E. CI never catches it because `.github/workflows/e2e.yml:40` only runs `mechanical.test.ts + mcp.test.ts` on PRs and nightly Tier 1. So 27 of 29 E2E files including this one aren't actually exercised by CI today. The local gate is stronger and surfaces real cross-file isolation gaps.
|
||||
|
||||
**Pros:**
|
||||
- Fixing isolation makes `bun run ci:local` (full E2E) reliably green.
|
||||
- Same fix likely to harden other E2E files that share id namespaces.
|
||||
- Lets us turn `bun run ci:local` into a real ship gate.
|
||||
|
||||
**Cons:**
|
||||
- Could require a per-file "namespace your test ids" pattern, ~30 min per affected file across the suite.
|
||||
|
||||
**Context:**
|
||||
- Repro: `bash scripts/run-e2e.sh test/e2e/multi-source.test.ts` against a stale DB after other E2E files have run → fails. Same against a fresh `docker compose down -v && up -d postgres` → passes 20/20.
|
||||
- The test inserts a hardcoded `cascadetest` source id and `aliceId` page id; collisions across runs are predictable.
|
||||
- Likely fix: use `mkdtemp`-style randomized source/page ids per test, OR have the test do a deeper reset (DELETE FROM all five tables in beforeEach) instead of relying on `setupDB`'s TRUNCATE behavior.
|
||||
|
||||
**Effort:** S (CC ~30 min for the multi-source.test.ts fix; M if we audit all 29 E2E files for similar id-collision risk).
|
||||
|
||||
**Depends on / blocked by:** Nothing.
|
||||
|
||||
### scripts/run-e2e.sh:71 echo overflows on large-output failing tests
|
||||
**Priority:** P2
|
||||
|
||||
**What:** When an E2E test fails AND prints lots of output (e.g., `multi-source.test.ts` floods postgres NOTICE objects), `scripts/run-e2e.sh:71` does `echo "$output"` against a multi-megabyte shell variable. The host pipe to docker-compose-run hits `EAGAIN` and fails with `echo: write error: Resource temporarily unavailable`. With `set -e`, the script aborts at that point, skipping the remaining E2E files and the final SUMMARY block.
|
||||
|
||||
**Why:** When the local CI gate finds a real failure (per the multi-source.test.ts entry above), the user wants to see it AND see how the rest of the suite did. Currently the failure shadows the rest.
|
||||
|
||||
**Pros:**
|
||||
- See all E2E failures from a single run instead of needing to bisect.
|
||||
- Quick win, ~5 lines.
|
||||
|
||||
**Cons:**
|
||||
- None worth listing.
|
||||
|
||||
**Context:**
|
||||
- Reproduced live during plan verification on 2026-04-29. Previous `multi-source.test.ts` failure killed the script before postgres-bootstrap, postgres-jsonb, etc. could run.
|
||||
- Likely fix: replace `echo "$output"` with `printf '%s\n' "$output"`, or write `$output` to a tmpfile and `cat` it (handles large blobs better than echo over pipes), or pipe through `stdbuf -o0`.
|
||||
- Don't suppress the postgres NOTICE flood at the test layer — that's separate; here we just want the script to not die when bun's stderr is verbose.
|
||||
|
||||
**Effort:** S (human or CC: ~10 min).
|
||||
|
||||
**Depends on / blocked by:** Nothing.
|
||||
|
||||
## claw-test E2E (v0.22.16 follow-ups)
|
||||
|
||||
### Hermes runner — `src/core/claw-test/runners/hermes.ts`
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# docker-compose.ci.yml
|
||||
#
|
||||
# Local CI gate with 4-way E2E sharding. Spins up 4 pgvector services + a bun
|
||||
# runner that bind-mounts the repo. Used by `bun run ci:local` and
|
||||
# `bun run ci:local:diff` (see scripts/ci-local.sh).
|
||||
#
|
||||
# All services are pulled as `image:` (no build) so `docker compose pull`
|
||||
# refreshes everything. The bun version floats with `oven/bun:1` to track CI's
|
||||
# `bun-version: latest`. Named volumes isolate the Linux container's deps from
|
||||
# the host's darwin-arm64 deps and keep bun + postgres data warm across runs.
|
||||
#
|
||||
# Why 4 postgres services: bun's E2E suite shares one DB across 36 files and
|
||||
# uses TRUNCATE CASCADE in setupDB(). Running files in parallel against ONE DB
|
||||
# races (file A's TRUNCATE clobbers file B's fixture import). 4 separate DBs
|
||||
# remove the race; we shard the file list 1/4..4/4 and run shards in parallel.
|
||||
# Within a shard, files still run sequentially. Total wall-time on a 16-core
|
||||
# host: ~6 min sequential -> ~1.5-2 min sharded.
|
||||
#
|
||||
# Postgres host ports default to 5434-5437 (avoid 5432 manual `gbrain-test-pg`
|
||||
# and 5433 sibling-project conflicts). Override BASE port with GBRAIN_CI_PG_PORT;
|
||||
# shards take BASE..BASE+3.
|
||||
|
||||
services:
|
||||
postgres-1:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: gbrain_test
|
||||
ports:
|
||||
- "${GBRAIN_CI_PG_PORT:-5434}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- gbrain-ci-pg-data-1:/var/lib/postgresql/data
|
||||
|
||||
postgres-2:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: gbrain_test
|
||||
ports:
|
||||
- "${GBRAIN_CI_PG_PORT_2:-5435}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- gbrain-ci-pg-data-2:/var/lib/postgresql/data
|
||||
|
||||
postgres-3:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: gbrain_test
|
||||
ports:
|
||||
- "${GBRAIN_CI_PG_PORT_3:-5436}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- gbrain-ci-pg-data-3:/var/lib/postgresql/data
|
||||
|
||||
postgres-4:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: gbrain_test
|
||||
ports:
|
||||
- "${GBRAIN_CI_PG_PORT_4:-5437}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- gbrain-ci-pg-data-4:/var/lib/postgresql/data
|
||||
|
||||
runner:
|
||||
image: oven/bun:1
|
||||
working_dir: /app
|
||||
depends_on:
|
||||
postgres-1:
|
||||
condition: service_healthy
|
||||
postgres-2:
|
||||
condition: service_healthy
|
||||
postgres-3:
|
||||
condition: service_healthy
|
||||
postgres-4:
|
||||
condition: service_healthy
|
||||
# No global DATABASE_URL — scripts/ci-local.sh sets per-shard URL via -e.
|
||||
# Unit phase explicitly unsets DATABASE_URL so test/e2e/* gracefully skip.
|
||||
volumes:
|
||||
- .:/app
|
||||
# Linux container's node_modules MUST be isolated from host darwin-arm64.
|
||||
# Without this, container `bun install` stomps host node_modules and
|
||||
# subsequent `bun test` on host fails with binary-incompat errors.
|
||||
- gbrain-ci-node-modules:/app/node_modules
|
||||
# Warm install cache across runs.
|
||||
- gbrain-ci-bun-cache:/root/.bun/install/cache
|
||||
|
||||
volumes:
|
||||
gbrain-ci-pg-data-1:
|
||||
gbrain-ci-pg-data-2:
|
||||
gbrain-ci-pg-data-3:
|
||||
gbrain-ci-pg-data-4:
|
||||
gbrain-ci-node-modules:
|
||||
gbrain-ci-bun-cache:
|
||||
+29
-5
@@ -56,9 +56,16 @@ writing or reviewing an operation, consult `src/core/operations.ts` for the cont
|
||||
|
||||
## Before shipping
|
||||
|
||||
Run `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin up the test
|
||||
Postgres container, run `bun run test:e2e`, tear it down). Ship via the `/ship` skill,
|
||||
not by hand.
|
||||
Easiest path: `bun run ci:local` runs the full CI gate inside Docker (gitleaks,
|
||||
unit tests with `DATABASE_URL` unset, then all 29 E2E files sequentially against a
|
||||
fresh pgvector container) and tears down. Use `bun run ci:local:diff` for the
|
||||
diff-aware subset during fast iteration on a focused branch. Requires Docker
|
||||
(Docker Desktop / OrbStack / Colima) and `gitleaks` (`brew install gitleaks`).
|
||||
|
||||
Manual path: `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin
|
||||
up the test Postgres container, run `bun run test:e2e`, tear it down).
|
||||
|
||||
Ship via the `/ship` skill, not by hand.
|
||||
|
||||
## Privacy
|
||||
|
||||
@@ -203,6 +210,9 @@ strict behavior when unset.
|
||||
- `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only.
|
||||
- `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (`<!-- timeline -->`, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics).
|
||||
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces (a) the `${JSON.stringify(x)}::jsonb` interpolation pattern (postgres.js v3 double-encodes it), or (b) `max_stalled INTEGER NOT NULL DEFAULT 1` in any schema source file (v0.15.1 #219 regression guard — must be DEFAULT 5 to preserve SIGKILL-rescue). Wired into `bun test`.
|
||||
- `docker-compose.ci.yml` + `scripts/ci-local.sh` (v0.23.1) — Local CI gate. `bun run ci:local` spins up `pgvector/pgvector:pg16` + `oven/bun:1` with named volumes (`gbrain-ci-pg-data`, `gbrain-ci-node-modules`, `gbrain-ci-bun-cache`), runs gitleaks on host, smoke-tests `scripts/run-e2e.sh` argv handling, runs unit tests with `DATABASE_URL` unset (matches GH Actions structure), then runs all 29 E2E files sequentially. `--diff` swaps in the diff-aware selector; `--no-pull` skips upstream pulls; `--clean` nukes named volumes. Postgres host port defaults to 5434 (avoids 5432 manual `gbrain-test-pg` and 5433 sibling-project conflict); override with `GBRAIN_CI_PG_PORT=NNNN`. Stronger gate than current PR CI's 2-file Tier 1 set — closes the "push-and-wait" feedback loop pre-push.
|
||||
- `scripts/select-e2e.ts` + `scripts/e2e-test-map.ts` (v0.23.1) — Diff-aware E2E test selector. Reads three git sources (committed `origin/master...HEAD`, working-tree `HEAD`, and `git ls-files --others --exclude-standard` for untracked, NOT-gitignored files), classifies as EMPTY / DOC_ONLY / SRC. Fail-closed by design: EMPTY → all 29 files (clean branch shouldn't run nothing), DOC_ONLY (every path matches the README/CLAUDE/AGENTS/CHANGELOG/TODOS allowlist) → empty stdout, SRC → escape-hatch paths (schema, package.json, skills/) trigger all; otherwise the hand-tuned `E2E_TEST_MAP` glob → tests narrows; an unmapped src/ change still emits ALL files, never silently nothing. Pure-function exports (`selectTests`, `classify`, `matchGlob`) so it's trivial to test and fork. `bun run ci:select-e2e` prints the current selection on stdout, pipe-friendly. `test/select-e2e.test.ts` covers all 4 branches plus 3 codex regression guards (skills/, untracked files, unmapped src/) — 24 cases.
|
||||
- `scripts/run-e2e.sh` (v0.23.1 update) — Sequential E2E runner. Now accepts an optional argv-driven file list (used by `ci:local:diff` to pipe in selector output) and a `--dry-run-list` flag that prints the resolved file list and exits (used by `ci-local.sh`'s startup smoke-test). Falls back to `test/e2e/*.test.ts` when invoked with no args.
|
||||
- `scripts/llms-config.ts` + `scripts/build-llms.ts` — Generator for `llms.txt` (llmstxt.org-spec web index) + `llms-full.txt` (inlined single-fetch bundle). Curated config drives both. Run `bun run build:llms` after adding a new doc. `LLMS_REPO_BASE` env var lets forks regenerate with their own URL base. `FULL_SIZE_BUDGET` (600KB) caps the inline bundle; generator WARNs if exceeded. Committed output is not analogous to `schema-embedded.ts` (no runtime consumer); we commit for GitHub browsing and fork-safe fetching.
|
||||
- `AGENTS.md` — Local-clone entry point for non-Claude agents (Codex, Cursor, OpenClaw, Aider). Mirrors `CLAUDE.md` intent via relative links. Claude Code keeps using `CLAUDE.md`.
|
||||
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks to apply when upgrading. Each release appends a new section. v0.10.3 includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
|
||||
@@ -579,7 +589,21 @@ will detect drift and re-bump on the next run.
|
||||
|
||||
## Pre-ship requirements
|
||||
|
||||
Before shipping (/ship) or reviewing (/review), always run the full test suite:
|
||||
Before shipping (/ship) or reviewing (/review), always run the full test suite.
|
||||
Two equivalent paths:
|
||||
|
||||
**Path A — local CI gate (recommended, v0.23.1+):**
|
||||
- `bun run ci:local` runs the entire stack inside Docker: gitleaks (host), unit
|
||||
tests with `DATABASE_URL` unset, and all 29 E2E files sequentially against a
|
||||
fresh pgvector container. Stronger than PR CI's 2-file Tier 1 set; closer to
|
||||
what nightly Tier 1 catches. Spins up + tears down postgres automatically via
|
||||
`docker-compose.ci.yml`. Override the host port with
|
||||
`GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides.
|
||||
- `bun run ci:local:diff` runs only the E2E files matched by the diff selector
|
||||
(`scripts/select-e2e.ts`), falling back to all 29 on unmapped src/ paths or
|
||||
schema/skills/package.json changes. Fast iteration during a focused branch.
|
||||
|
||||
**Path B — manual lifecycle (still supported):**
|
||||
- `bun test` — unit tests (no database required)
|
||||
- Follow the "E2E test DB lifecycle" steps above to spin up the test DB,
|
||||
run `bun run test:e2e`, then tear it down.
|
||||
@@ -2048,7 +2072,7 @@ The skills in this repo are those patterns, generalized. What took 11 days to bu
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Run `bun test` for unit tests. E2E tests: spin up Postgres with pgvector, run `bun run test:e2e`, tear down.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Run `bun test` for unit tests. For the full local CI gate (gitleaks + unit + all 29 E2E files in Docker, the same checks GH Actions runs), use `bun run ci:local` ... or `bun run ci:local:diff` for the diff-aware subset during fast iteration.
|
||||
|
||||
PRs welcome for: new enrichment APIs, performance optimizations, additional engine backends, new skills following the conformance standard in `skills/skill-creator/SKILL.md`.
|
||||
|
||||
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
@@ -32,10 +32,16 @@
|
||||
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"build:llms": "bun run scripts/build-llms.ts",
|
||||
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
|
||||
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && bun run typecheck && bun test --timeout=60000",
|
||||
"check:wasm": "scripts/check-wasm-embedded.sh",
|
||||
"check:newlines": "scripts/check-trailing-newline.sh",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
"test:slow": "bash scripts/run-slow-tests.sh",
|
||||
"test:profile": "bash scripts/profile-tests.sh",
|
||||
"ci:local": "bash scripts/ci-local.sh",
|
||||
"ci:local:diff": "bash scripts/ci-local.sh --diff",
|
||||
"ci:select-e2e": "bun run scripts/select-e2e.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:jsonb": "scripts/check-jsonb-pattern.sh",
|
||||
"check:progress": "scripts/check-progress-to-stdout.sh",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bun
|
||||
// scripts/build-pglite-snapshot.ts
|
||||
//
|
||||
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
|
||||
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
|
||||
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
|
||||
// 1-3 seconds of cold init and load the post-schema state directly.
|
||||
//
|
||||
// Output: test/fixtures/pglite-snapshot.tar (binary, gitignored)
|
||||
// test/fixtures/pglite-snapshot.version (hex SHA256 of MIGRATIONS SQL)
|
||||
//
|
||||
// The version file lets the engine detect snapshot staleness — if the tar's
|
||||
// recorded version doesn't match the current MIGRATIONS hash, the engine
|
||||
// ignores the snapshot and runs a normal initSchema.
|
||||
//
|
||||
// Run: bun run scripts/build-pglite-snapshot.ts
|
||||
// (or: bun run build:pglite-snapshot)
|
||||
//
|
||||
// Re-run whenever you touch src/core/migrate.ts or src/schema.sql.
|
||||
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
import { PGLiteEngine, computeSnapshotSchemaHash } from "../src/core/pglite-engine.ts";
|
||||
import { MIGRATIONS } from "../src/core/migrate.ts";
|
||||
import { PGLITE_SCHEMA_SQL } from "../src/core/pglite-schema.ts";
|
||||
|
||||
function computeSchemaHash(): string {
|
||||
return computeSnapshotSchemaHash(MIGRATIONS, PGLITE_SCHEMA_SQL, crypto);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fixturePath = "test/fixtures/pglite-snapshot.tar";
|
||||
const versionPath = "test/fixtures/pglite-snapshot.version";
|
||||
mkdirSync(dirname(fixturePath), { recursive: true });
|
||||
|
||||
const schemaHash = computeSchemaHash();
|
||||
console.log(`[build-pglite-snapshot] schema hash: ${schemaHash.slice(0, 16)}...`);
|
||||
console.log(`[build-pglite-snapshot] booting PGLite (in-memory)...`);
|
||||
const engine = new PGLiteEngine();
|
||||
|
||||
// Bypass the env-aware short-circuit: we WANT a real init here.
|
||||
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
|
||||
await engine.connect({});
|
||||
console.log(`[build-pglite-snapshot] running initSchema (forward bootstrap + ${MIGRATIONS.length} migrations)...`);
|
||||
const t0 = Date.now();
|
||||
await engine.initSchema();
|
||||
console.log(`[build-pglite-snapshot] initSchema completed in ${Date.now() - t0}ms`);
|
||||
|
||||
console.log(`[build-pglite-snapshot] dumping data dir...`);
|
||||
const dump = await engine.db.dumpDataDir("none");
|
||||
const buffer = Buffer.from(await dump.arrayBuffer());
|
||||
|
||||
writeFileSync(fixturePath, buffer);
|
||||
writeFileSync(versionPath, schemaHash + "\n");
|
||||
await engine.disconnect();
|
||||
|
||||
console.log(`[build-pglite-snapshot] wrote ${fixturePath} (${buffer.length} bytes)`);
|
||||
console.log(`[build-pglite-snapshot] wrote ${versionPath}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci-local.sh
|
||||
#
|
||||
# Local CI gate. Runs the same checks GH Actions does (and a stricter superset
|
||||
# of E2E) inside Docker. See docker-compose.ci.yml.
|
||||
#
|
||||
# Modes:
|
||||
# bash scripts/ci-local.sh # full local gate: gitleaks + unit + ALL E2E (4-way sharded)
|
||||
# bash scripts/ci-local.sh --diff # full local gate: gitleaks + unit + selected E2E (4-way sharded)
|
||||
# bash scripts/ci-local.sh --no-pull # skip docker compose pull (offline / debug)
|
||||
# bash scripts/ci-local.sh --clean # nuke named volumes for cold debug
|
||||
# bash scripts/ci-local.sh --no-shard # debug: run E2E sequentially against postgres-1 only
|
||||
#
|
||||
# 4-way E2E sharding: 4 pgvector services on host ports 5434-5437. The 36 E2E
|
||||
# files split N/4 per shard; shards run in parallel. Within a shard, files run
|
||||
# sequentially (TRUNCATE CASCADE no-race property documented in run-e2e.sh).
|
||||
# Wall-time on a 16-core host: ~6 min sequential -> ~1.5-2 min sharded.
|
||||
#
|
||||
# Stronger than PR CI: PR CI runs only Tier 1's 2 files; this runs all 36.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
COMPOSE_FILE="docker-compose.ci.yml"
|
||||
|
||||
DIFF=0
|
||||
NO_PULL=0
|
||||
CLEAN=0
|
||||
NO_SHARD=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--diff) DIFF=1 ;;
|
||||
--no-pull) NO_PULL=1 ;;
|
||||
--clean) CLEAN=1 ;;
|
||||
--no-shard) NO_SHARD=1 ;;
|
||||
*)
|
||||
echo "Usage: $0 [--diff] [--no-pull] [--clean] [--no-shard]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "[ci-local] Tearing down postgres..."
|
||||
docker compose -f "$COMPOSE_FILE" down --remove-orphans 2>&1 | tail -5 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "[ci-local] --clean: removing named volumes..."
|
||||
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans 2>&1 | tail -5 || true
|
||||
fi
|
||||
|
||||
# Tier 2: --diff fast-path. If the diff is doc-only (or empty), skip the
|
||||
# whole heavy gate (postgres + bun install + unit + E2E) and just verify
|
||||
# gitleaks on host. Doc-only diffs go from ~25 min to ~5 seconds.
|
||||
if [ "$DIFF" = "1" ]; then
|
||||
CLASSIFICATION=$(bun run scripts/select-e2e.ts --classify-only 2>/dev/null || echo "ERR")
|
||||
case "$CLASSIFICATION" in
|
||||
DOC_ONLY)
|
||||
echo "[ci-local] --diff: diff is doc-only — skipping postgres + unit + E2E (Tier 2 fast-path)."
|
||||
echo "[ci-local] Running gitleaks on host as the only gate..."
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "[ci-local] WARN: gitleaks not installed; skipping. brew install gitleaks." >&2
|
||||
else
|
||||
gitleaks dir . --redact --no-banner
|
||||
gitleaks git . --redact --no-banner --log-opts="origin/master..HEAD"
|
||||
fi
|
||||
echo "[ci-local] Doc-only fast-path complete. No code paths exercised."
|
||||
trap - EXIT
|
||||
exit 0
|
||||
;;
|
||||
EMPTY)
|
||||
echo "[ci-local] --diff: diff is empty (clean branch) — running full gate per fail-closed contract."
|
||||
;;
|
||||
SRC)
|
||||
echo "[ci-local] --diff: diff touches src/ — running selected E2E + full unit phase."
|
||||
;;
|
||||
*)
|
||||
echo "[ci-local] WARN: select-e2e.ts --classify-only returned '$CLASSIFICATION' — running full gate." >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Pre-flight: postgres host ports for 4 shards. Defaults to 5434-5437 (avoid
|
||||
# 5432 manual gbrain-test-pg, 5433 commonly held by sibling projects).
|
||||
# GBRAIN_CI_PG_PORT defines BASE; shards take BASE..BASE+3.
|
||||
PG_PORT_BASE="${GBRAIN_CI_PG_PORT:-5434}"
|
||||
for shard in 1 2 3 4; do
|
||||
port=$((PG_PORT_BASE + shard - 1))
|
||||
PORT_OWNER=$(docker ps --filter "publish=$port" --format "{{.Names}}" | head -1)
|
||||
if [ -n "$PORT_OWNER" ]; then
|
||||
echo "[ci-local] ERROR: host port $port (shard $shard) is already used by docker container '$PORT_OWNER'." >&2
|
||||
echo "[ci-local] Either stop that container or run with: GBRAIN_CI_PG_PORT=NNNN bun run ci:local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if lsof -iTCP:"$port" -sTCP:LISTEN -P -n >/dev/null 2>&1; then
|
||||
echo "[ci-local] ERROR: host port $port (shard $shard) is held by a non-docker process." >&2
|
||||
echo "[ci-local] Run with: GBRAIN_CI_PG_PORT=NNNN bun run ci:local" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
export GBRAIN_CI_PG_PORT="$PG_PORT_BASE"
|
||||
export GBRAIN_CI_PG_PORT_2=$((PG_PORT_BASE + 1))
|
||||
export GBRAIN_CI_PG_PORT_3=$((PG_PORT_BASE + 2))
|
||||
export GBRAIN_CI_PG_PORT_4=$((PG_PORT_BASE + 3))
|
||||
|
||||
# Step 0: gitleaks on the host (no docker, no postgres, no bun needed).
|
||||
# Mirrors test.yml's separate gitleaks job. Fail loudly if not installed.
|
||||
echo "[ci-local] gitleaks detect (host)..."
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "[ci-local] ERROR: gitleaks not installed on host." >&2
|
||||
echo "[ci-local] macOS: brew install gitleaks" >&2
|
||||
echo "[ci-local] Linux: https://github.com/gitleaks/gitleaks/releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Two scopes for pre-push:
|
||||
# 1. Working-tree files (catch uncommitted secrets sitting in files)
|
||||
# 2. Branch commits vs origin/master (catch secrets committed on this branch)
|
||||
# Full-history scan is ~4 min on this repo's 3700+ commits; not useful pre-push.
|
||||
gitleaks dir . --redact --no-banner
|
||||
gitleaks git . --redact --no-banner --log-opts="origin/master..HEAD"
|
||||
|
||||
# Step 1: pull. Refreshes pgvector + oven/bun:1 (both are `image:` not `build:`).
|
||||
if [ "$NO_PULL" = "0" ]; then
|
||||
echo "[ci-local] Pulling base images (use --no-pull to skip)..."
|
||||
docker compose -f "$COMPOSE_FILE" pull 2>&1 | tail -5
|
||||
fi
|
||||
|
||||
# Step 2: 4 postgres shards up + wait for healthy.
|
||||
echo "[ci-local] Starting 4 postgres shards..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d postgres-1 postgres-2 postgres-3 postgres-4
|
||||
echo "[ci-local] Waiting for all 4 postgres shards healthy..."
|
||||
for i in {1..40}; do
|
||||
all_healthy=1
|
||||
for shard in 1 2 3 4; do
|
||||
status=$(docker compose -f "$COMPOSE_FILE" ps --format json postgres-$shard 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | sed 's/.*":"//;s/"//')
|
||||
if [ "$status" != "healthy" ]; then
|
||||
all_healthy=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$all_healthy" = "1" ]; then
|
||||
echo "[ci-local] All 4 postgres shards healthy."
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "40" ]; then
|
||||
echo "[ci-local] ERROR: not all postgres shards became healthy in 40 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Step 3: smoke-test run-e2e.sh argv + shard handling.
|
||||
echo "[ci-local] Smoke: run-e2e.sh argv + shard..."
|
||||
SMOKE_NO_ARGS=$(bash scripts/run-e2e.sh --dry-run-list | wc -l | tr -d ' ')
|
||||
EXPECTED_ALL=$(ls test/e2e/*.test.ts | wc -l | tr -d ' ')
|
||||
if [ "$SMOKE_NO_ARGS" != "$EXPECTED_ALL" ]; then
|
||||
echo "[ci-local] ERROR: --dry-run-list (no args) printed $SMOKE_NO_ARGS, expected $EXPECTED_ALL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SMOKE_ONE_ARG=$(bash scripts/run-e2e.sh --dry-run-list test/e2e/sync.test.ts)
|
||||
if [ "$SMOKE_ONE_ARG" != "test/e2e/sync.test.ts" ]; then
|
||||
echo "[ci-local] ERROR: --dry-run-list with 1 arg printed '$SMOKE_ONE_ARG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
SHARD_TOTAL=$(( $(SHARD=1/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
|
||||
$(SHARD=2/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
|
||||
$(SHARD=3/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
|
||||
$(SHARD=4/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) ))
|
||||
if [ "$SHARD_TOTAL" != "$EXPECTED_ALL" ]; then
|
||||
echo "[ci-local] ERROR: shards 1-4 covered $SHARD_TOTAL files, expected $EXPECTED_ALL" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-local] Smoke OK ($SMOKE_NO_ARGS files no-arg, 1 single-arg, ${SHARD_TOTAL}=4-shard total)."
|
||||
|
||||
# Step 4: build the runner-side command.
|
||||
# Tier 1: 4-shard parallel UNIT + E2E. Each shard runs ~46 unit files + ~9
|
||||
# E2E files against postgres-N. Guards + typecheck run ONCE before fan-out.
|
||||
# --no-shard runs the legacy unsharded flow (debug aid).
|
||||
if [ "$NO_SHARD" = "1" ]; then
|
||||
if [ "$DIFF" = "1" ]; then
|
||||
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
|
||||
bash scripts/check-jsonb-pattern.sh
|
||||
bash scripts/check-progress-to-stdout.sh
|
||||
bash scripts/check-trailing-newline.sh
|
||||
bash scripts/check-wasm-embedded.sh
|
||||
bun run typecheck
|
||||
echo "[runner] unit (unsharded, DATABASE_URL unset)"
|
||||
env -u DATABASE_URL bash scripts/run-unit-shard.sh
|
||||
echo "[runner] e2e (unsharded, --diff selected)"
|
||||
SELECTED=$(bun run scripts/select-e2e.ts)
|
||||
if [ -z "$SELECTED" ]; then
|
||||
echo "[runner] selector emitted nothing (doc-only diff); skipping E2E."
|
||||
else
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test echo "$SELECTED" | xargs bash scripts/run-e2e.sh
|
||||
fi'
|
||||
else
|
||||
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
|
||||
bash scripts/check-jsonb-pattern.sh
|
||||
bash scripts/check-progress-to-stdout.sh
|
||||
bash scripts/check-trailing-newline.sh
|
||||
bash scripts/check-wasm-embedded.sh
|
||||
bun run typecheck
|
||||
echo "[runner] unit (unsharded, DATABASE_URL unset)"
|
||||
env -u DATABASE_URL bash scripts/run-unit-shard.sh
|
||||
echo "[runner] e2e (unsharded)"
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test bash scripts/run-e2e.sh'
|
||||
fi
|
||||
else
|
||||
# Tier 1 sharded path. Each shard runs unit+E2E sequentially against its
|
||||
# own postgres-N. Shards run in parallel via xargs -P4.
|
||||
if [ "$DIFF" = "1" ]; then
|
||||
DIFF_E2E_PREP='SELECTED=$(bun run scripts/select-e2e.ts)
|
||||
if [ -z "$SELECTED" ]; then
|
||||
echo "" > /tmp/e2e-selected.txt
|
||||
else
|
||||
echo "$SELECTED" | tr " " "\n" | grep -v "^$" > /tmp/e2e-selected.txt
|
||||
fi'
|
||||
else
|
||||
# Empty file -> run-e2e.sh uses default glob (all 36 E2E files).
|
||||
DIFF_E2E_PREP='> /tmp/e2e-selected.txt'
|
||||
fi
|
||||
RUN_PHASES_CMD="echo \"[runner] guards + typecheck (run once before sharding)\"
|
||||
bash scripts/check-jsonb-pattern.sh
|
||||
bash scripts/check-progress-to-stdout.sh
|
||||
bash scripts/check-trailing-newline.sh
|
||||
bash scripts/check-wasm-embedded.sh
|
||||
bun run typecheck
|
||||
echo \"[runner] Tier 3: building PGLite snapshot fixture (cached across reruns)\"
|
||||
if [ ! -f test/fixtures/pglite-snapshot.tar ] || [ ! -f test/fixtures/pglite-snapshot.version ]; then
|
||||
bun run build:pglite-snapshot
|
||||
else
|
||||
echo \"[runner] snapshot fixture exists; engine will validate hash at load time\"
|
||||
fi
|
||||
export GBRAIN_PGLITE_SNAPSHOT=test/fixtures/pglite-snapshot.tar
|
||||
echo \"[runner] resolving E2E file selection (--diff aware)\"
|
||||
${DIFF_E2E_PREP}
|
||||
mkdir -p /tmp/shard-logs
|
||||
echo \"[runner] Tier 1: 4-shard parallel unit + E2E (xargs -P4)\"
|
||||
set +e
|
||||
printf '%s\\n' 1 2 3 4 | xargs -P4 -I{} sh -c '
|
||||
shard=\$1
|
||||
log=/tmp/shard-logs/shard-\${shard}.log
|
||||
echo \"[shard \${shard}] start\" > \$log
|
||||
echo \"[shard \${shard}] unit phase (SHARD=\${shard}/4, DATABASE_URL unset)\" >> \$log
|
||||
env -u DATABASE_URL SHARD=\${shard}/4 bash scripts/run-unit-shard.sh >> \$log 2>&1
|
||||
unit_exit=\$?
|
||||
if [ \$unit_exit -ne 0 ]; then
|
||||
echo \"[shard \${shard}] UNIT FAILED (exit=\$unit_exit)\" >> \$log
|
||||
exit \$unit_exit
|
||||
fi
|
||||
echo \"[shard \${shard}] e2e phase (SHARD=\${shard}/4, DATABASE_URL=postgres-\${shard})\" >> \$log
|
||||
if [ -s /tmp/e2e-selected.txt ]; then
|
||||
SHARD=\${shard}/4 \\
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
|
||||
xargs -a /tmp/e2e-selected.txt bash scripts/run-e2e.sh >> \$log 2>&1
|
||||
else
|
||||
SHARD=\${shard}/4 \\
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
|
||||
bash scripts/run-e2e.sh >> \$log 2>&1
|
||||
fi
|
||||
e2e_exit=\$?
|
||||
if [ \$e2e_exit -ne 0 ]; then
|
||||
echo \"[shard \${shard}] E2E FAILED (exit=\$e2e_exit)\" >> \$log
|
||||
exit \$e2e_exit
|
||||
fi
|
||||
echo \"[shard \${shard}] DONE\" >> \$log
|
||||
' _ {}
|
||||
shard_xargs_exit=\$?
|
||||
set -e
|
||||
echo \"\"
|
||||
echo \"=== SHARD LOGS (last 30 lines each + unit/e2e summaries) ===\"
|
||||
for s in 1 2 3 4; do
|
||||
echo \"\"
|
||||
echo \"--- shard \$s ---\"
|
||||
if [ -f /tmp/shard-logs/shard-\$s.log ]; then
|
||||
# Pull the unit + E2E summary lines explicitly so they survive even if
|
||||
# the file is huge. Match: bun's '<N> pass / <N> fail' pairs, run-e2e.sh's
|
||||
# 'Files: ... / Tests: ...' summary, and our own shard markers.
|
||||
grep -E '^\\[shard|^Files: |^Tests: |Ran [0-9]+ tests|^[[:space:]]+[0-9]+ (pass|fail|skip)\$' /tmp/shard-logs/shard-\$s.log || true
|
||||
echo \" (last 30 lines for context)\"
|
||||
tail -30 /tmp/shard-logs/shard-\$s.log
|
||||
else
|
||||
echo \"(no log file written — shard never started)\"
|
||||
fi
|
||||
done
|
||||
echo \"\"
|
||||
if [ \$shard_xargs_exit -ne 0 ]; then
|
||||
echo \"[runner] One or more shards failed (xargs exit=\$shard_xargs_exit). See SHARD LOGS above.\"
|
||||
exit \$shard_xargs_exit
|
||||
fi
|
||||
echo \"[runner] All 4 shards passed.\""
|
||||
fi
|
||||
|
||||
INNER_CMD=$(cat <<'EOF'
|
||||
set -euo pipefail
|
||||
echo "[runner] bun version: $(bun --version)"
|
||||
# oven/bun:1 omits git; many unit tests use mkdtemp + git init for fixtures.
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[runner] Installing git (debian apt)..."
|
||||
apt-get update -qq >/dev/null
|
||||
apt-get install -y -qq git ca-certificates >/dev/null
|
||||
fi
|
||||
# Container runs as root (uid 0) against a host-uid bind-mount; mark repo +
|
||||
# any worktree gitdir as safe so `git status` etc. don't refuse.
|
||||
git config --global --add safe.directory '*' || true
|
||||
if [ ! -d /app/node_modules ] || [ -z "$(ls -A /app/node_modules 2>/dev/null)" ]; then
|
||||
echo "[runner] First run (or --clean): bun install --frozen-lockfile"
|
||||
bun install --frozen-lockfile
|
||||
fi
|
||||
__RUN_PHASES__
|
||||
EOF
|
||||
)
|
||||
INNER_CMD="${INNER_CMD/__RUN_PHASES__/$RUN_PHASES_CMD}"
|
||||
|
||||
# Conductor / git-worktree support: when `.git` is a file (not a directory),
|
||||
# it points at a host gitdir outside the bind-mount. Without remounting that
|
||||
# path, scripts/check-trailing-newline.sh and any other in-container `git`
|
||||
# call exits 128 ("not a git repository"). Resolve the host gitdir + the
|
||||
# shared common gitdir and bind-mount them at the same absolute paths.
|
||||
EXTRA_MOUNTS=()
|
||||
if [ -f .git ]; then
|
||||
WORKTREE_GITDIR=$(awk '{print $2}' .git)
|
||||
if [ -d "$WORKTREE_GITDIR" ]; then
|
||||
COMMONDIR_FILE="$WORKTREE_GITDIR/commondir"
|
||||
if [ -f "$COMMONDIR_FILE" ]; then
|
||||
COMMON_REL=$(cat "$COMMONDIR_FILE")
|
||||
COMMON_GITDIR=$(cd "$WORKTREE_GITDIR" && cd "$COMMON_REL" && pwd)
|
||||
else
|
||||
COMMON_GITDIR="$WORKTREE_GITDIR"
|
||||
fi
|
||||
# Mount the higher-level common gitdir; covers worktrees/<name> automatically.
|
||||
EXTRA_MOUNTS+=( -v "${COMMON_GITDIR}:${COMMON_GITDIR}:ro" )
|
||||
echo "[ci-local] Worktree detected; mounting shared gitdir: $COMMON_GITDIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[ci-local] Running checks inside runner container..."
|
||||
docker compose -f "$COMPOSE_FILE" run --rm "${EXTRA_MOUNTS[@]:-}" runner bash -c "$INNER_CMD"
|
||||
|
||||
echo ""
|
||||
echo "[ci-local] All checks passed."
|
||||
@@ -0,0 +1,63 @@
|
||||
// scripts/e2e-test-map.ts
|
||||
//
|
||||
// Path-glob -> E2E test files map. Used by scripts/select-e2e.ts.
|
||||
//
|
||||
// CONTRACT: This map can ONLY narrow from "all". When a changed src/ path
|
||||
// matches no glob here, the selector falls back to "run all E2E" (fail-closed).
|
||||
// You can safely add narrowing entries; you cannot break correctness by missing
|
||||
// one. Tune as misses surface (i.e., when ci:local:diff ran more than necessary
|
||||
// and you'd like to narrow that surface area).
|
||||
//
|
||||
// Glob syntax is the minimal subset implemented in select-e2e.ts:
|
||||
// - "**" matches any sequence of path segments (including zero)
|
||||
// - "*" matches any characters within a single path segment
|
||||
// - everything else is literal
|
||||
// No brace expansion, no ?, no [ ].
|
||||
|
||||
export const E2E_TEST_MAP: Record<string, string[]> = {
|
||||
// Source-aware ranking, hybrid search, intent classification.
|
||||
"src/core/search/**": [
|
||||
"test/e2e/search-quality.test.ts",
|
||||
"test/e2e/search-exclude.test.ts",
|
||||
"test/e2e/search-swamp.test.ts",
|
||||
],
|
||||
// Tree-sitter chunkers feed code-indexing E2E.
|
||||
"src/core/chunkers/**": ["test/e2e/code-indexing.test.ts"],
|
||||
// dream.ts is a thin alias over runCycle in cycle.ts.
|
||||
"src/core/cycle.ts": ["test/e2e/cycle.test.ts", "test/e2e/dream.test.ts"],
|
||||
// Multi-source sync writes share the per-source bookmark anchor.
|
||||
"src/core/sync.ts": ["test/e2e/sync.test.ts", "test/e2e/multi-source.test.ts"],
|
||||
// Any minions queue/worker/handler change exercises all minion E2E.
|
||||
"src/core/minions/**": [
|
||||
"test/e2e/minions-concurrency.test.ts",
|
||||
"test/e2e/minions-resilience.test.ts",
|
||||
"test/e2e/minions-shell.test.ts",
|
||||
"test/e2e/minions-shell-pglite.test.ts",
|
||||
"test/e2e/worker-abort-recovery.test.ts",
|
||||
],
|
||||
// postgres.js bind paths + JSONB shapes + parity vs PGLite.
|
||||
"src/core/postgres-engine.ts": [
|
||||
"test/e2e/postgres-bootstrap.test.ts",
|
||||
"test/e2e/postgres-jsonb.test.ts",
|
||||
"test/e2e/jsonb-roundtrip.test.ts",
|
||||
"test/e2e/engine-parity.test.ts",
|
||||
],
|
||||
// PGLite bootstrap path + parity guard.
|
||||
"src/core/pglite-engine.ts": [
|
||||
"test/e2e/postgres-bootstrap.test.ts",
|
||||
"test/e2e/engine-parity.test.ts",
|
||||
],
|
||||
// MCP stdio + HTTP transports share dispatch.
|
||||
"src/mcp/**": ["test/e2e/mcp.test.ts", "test/e2e/http-transport.test.ts"],
|
||||
// Integrity batch-load fast path.
|
||||
"src/commands/integrity.ts": ["test/e2e/integrity-batch.test.ts"],
|
||||
// Upgrade chains migration ledger; touches both runners.
|
||||
"src/commands/upgrade.ts": [
|
||||
"test/e2e/upgrade.test.ts",
|
||||
"test/e2e/migrate-chain.test.ts",
|
||||
"test/e2e/migration-flow.test.ts",
|
||||
],
|
||||
"src/commands/doctor.ts": ["test/e2e/doctor-progress.test.ts"],
|
||||
// Knowledge graph layer feeds graph-quality.
|
||||
"src/core/link-extraction.ts": ["test/e2e/graph-quality.test.ts"],
|
||||
};
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/profile-tests.sh
|
||||
# Tier 4 helper: prints the top N slowest unit tests from a previous run.
|
||||
# Pipe a captured `bun test` output (or a ci:local log) into stdin; we extract
|
||||
# `(pass|fail) ... [Xms|Xs]` lines, convert to ms, sort descending.
|
||||
#
|
||||
# Usage:
|
||||
# bun test --timeout=60000 2>&1 | bash scripts/profile-tests.sh
|
||||
# bash scripts/profile-tests.sh < /path/to/captured.log
|
||||
# bash scripts/profile-tests.sh -n 20 < /path/to/captured.log
|
||||
#
|
||||
# To demote a test as slow: rename its file to *.slow.test.ts. The file
|
||||
# stays discoverable by `bun test` (CI runs everything via `bun run test`)
|
||||
# but is excluded from `bun run ci:local`'s fast unit shard fan-out.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TOP_N=10
|
||||
if [ "${1:-}" = "-n" ] && [ -n "${2:-}" ]; then
|
||||
TOP_N=$2
|
||||
fi
|
||||
|
||||
# Lines look like: (pass) describe > test name [12345.67ms] OR [12.34s]
|
||||
# Single awk pass for performance (input can be tens of MB).
|
||||
awk '{
|
||||
# Find the LAST bracket in the line: [<num><unit>] where unit is ms or s.
|
||||
for (i = length($0); i > 0; i--) {
|
||||
if (substr($0, i, 1) == "]") {
|
||||
# Walk back to matching "["
|
||||
j = i - 1
|
||||
while (j > 0 && substr($0, j, 1) != "[") j--
|
||||
if (j == 0) break
|
||||
bracket = substr($0, j+1, i-j-1)
|
||||
# bracket should match ^[0-9]+(\.[0-9]+)?(ms|s)$
|
||||
if (bracket ~ /^[0-9]+(\.[0-9]+)?(ms|s)$/) {
|
||||
if (bracket ~ /ms$/) {
|
||||
n = substr(bracket, 1, length(bracket) - 2) + 0
|
||||
} else {
|
||||
n = (substr(bracket, 1, length(bracket) - 1) + 0) * 1000
|
||||
}
|
||||
if (n > 0) printf "%.0f\t%s\n", n, $0
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}' | sort -rn | head -n "$TOP_N" | awk -F'\t' '{ printf "%8.0fms %s\n", $1, $2 }'
|
||||
+59
-1
@@ -25,13 +25,71 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# --dry-run-list: print the resolved file list (one per line) and exit. Used
|
||||
# by scripts/ci-local.sh to smoke-test the argv branching at startup.
|
||||
DRY_RUN_LIST=0
|
||||
if [ "${1:-}" = "--dry-run-list" ]; then
|
||||
DRY_RUN_LIST=1
|
||||
shift
|
||||
fi
|
||||
|
||||
# Argv-driven file list (used by `ci:local:diff`); fall back to the full glob.
|
||||
if [ "$#" -gt 0 ]; then
|
||||
files=("$@")
|
||||
else
|
||||
files=(test/e2e/*.test.ts)
|
||||
fi
|
||||
|
||||
# SHARD env (e.g. SHARD=1/4) keeps every M-th file starting at index N (1-indexed).
|
||||
# Used by scripts/ci-local.sh to fan 4 shards in parallel against 4 postgres
|
||||
# containers. Sequential execution within a shard is preserved (the TRUNCATE
|
||||
# CASCADE no-race rationale at the top of this file still holds).
|
||||
if [ -n "${SHARD:-}" ]; then
|
||||
shard_n=${SHARD%/*}
|
||||
shard_m=${SHARD#*/}
|
||||
if ! printf '%s' "$shard_n" | grep -qE '^[0-9]+$' || \
|
||||
! printf '%s' "$shard_m" | grep -qE '^[0-9]+$' || \
|
||||
[ "$shard_n" -lt 1 ] || [ "$shard_m" -lt 1 ] || [ "$shard_n" -gt "$shard_m" ]; then
|
||||
echo "ERROR: invalid SHARD=$SHARD (expected N/M with 1<=N<=M, both integers)" >&2
|
||||
exit 1
|
||||
fi
|
||||
filtered=()
|
||||
i=0
|
||||
for f in "${files[@]}"; do
|
||||
if [ $((i % shard_m + 1)) -eq "$shard_n" ]; then
|
||||
filtered+=("$f")
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
# ${filtered[@]:-} avoids "unbound variable" under `set -u` when no files matched.
|
||||
files=("${filtered[@]:-}")
|
||||
# If the empty placeholder slipped in, drop it.
|
||||
if [ "${#files[@]}" -eq 1 ] && [ -z "${files[0]}" ]; then
|
||||
files=()
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN_LIST" = "1" ]; then
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "${files[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
# Empty shard (e.g. SHARD=4/4 with only 3 files): nothing to do.
|
||||
echo "No files for shard ${SHARD:-(unsharded)}; exiting clean."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pass_files=0
|
||||
fail_files=0
|
||||
fail_list=()
|
||||
total_pass=0
|
||||
total_fail=0
|
||||
|
||||
for f in test/e2e/*.test.ts; do
|
||||
for f in "${files[@]}"; do
|
||||
name=$(basename "$f")
|
||||
echo ""
|
||||
echo "=== $name ==="
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-slow-tests.sh
|
||||
# Tier 4 sister to run-unit-shard.sh: runs ONLY *.slow.test.ts files.
|
||||
# CI runs both; bun run ci:local skips slow tests via run-unit-shard.sh.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
slow_files=()
|
||||
while IFS= read -r f; do
|
||||
slow_files+=("$f")
|
||||
done < <(find test -name '*.slow.test.ts' -not -path 'test/e2e/*' | sort)
|
||||
|
||||
if [ "${#slow_files[@]}" -eq 0 ]; then
|
||||
echo "[run-slow-tests] no *.slow.test.ts files; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[run-slow-tests] running ${#slow_files[@]} slow files (CI runs these as part of bun run test)"
|
||||
exec bun test --timeout=60000 "${slow_files[@]}"
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-unit-shard.sh
|
||||
#
|
||||
# Runs the unit suite for a single shard. Excludes test/e2e/* (those are run
|
||||
# by scripts/run-e2e.sh in the E2E phase). When SHARD=N/M is set, keeps every
|
||||
# M-th file starting at index N (1-indexed); otherwise runs the full unit set.
|
||||
#
|
||||
# Used by scripts/ci-local.sh to fan 4 unit-shard workers in parallel inside
|
||||
# the runner container, each pinned to its own postgres shard for the
|
||||
# downstream E2E phase.
|
||||
#
|
||||
# Sequential bun processes within a shard (one bun test invocation with the
|
||||
# shard's file list); parallel across shards (4 of these run concurrently).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# All non-E2E test files, sorted for deterministic shard splits.
|
||||
# Tier 4: *.slow.test.ts is the convention for "always-slow" tests (e.g.,
|
||||
# bootstrap correctness checks that intentionally exercise the cold init
|
||||
# path and can't benefit from Tier 3's snapshot). They're excluded from the
|
||||
# fast loop and run via `bun run test:slow` (or in CI where everything runs).
|
||||
# Use while-read to stay portable to macOS bash 3.2 (no mapfile).
|
||||
all_files=()
|
||||
while IFS= read -r f; do
|
||||
all_files+=("$f")
|
||||
done < <(find test -name '*.test.ts' -not -path 'test/e2e/*' -not -name '*.slow.test.ts' | sort)
|
||||
|
||||
files=()
|
||||
if [ -n "${SHARD:-}" ]; then
|
||||
shard_n=${SHARD%/*}
|
||||
shard_m=${SHARD#*/}
|
||||
if ! printf '%s' "$shard_n" | grep -qE '^[0-9]+$' || \
|
||||
! printf '%s' "$shard_m" | grep -qE '^[0-9]+$' || \
|
||||
[ "$shard_n" -lt 1 ] || [ "$shard_m" -lt 1 ] || [ "$shard_n" -gt "$shard_m" ]; then
|
||||
echo "ERROR: invalid SHARD=$SHARD (expected N/M with 1<=N<=M, both integers)" >&2
|
||||
exit 1
|
||||
fi
|
||||
i=0
|
||||
for f in "${all_files[@]}"; do
|
||||
if [ $((i % shard_m + 1)) -eq "$shard_n" ]; then
|
||||
files+=("$f")
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
else
|
||||
files=("${all_files[@]}")
|
||||
fi
|
||||
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "[unit-shard ${SHARD:-(unsharded)}] no files; exiting clean."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --dry-run-list mirrors scripts/run-e2e.sh for inline smoke checks.
|
||||
if [ "${1:-}" = "--dry-run-list" ]; then
|
||||
printf '%s\n' "${files[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[unit-shard ${SHARD:-(unsharded)}] running ${#files[@]} files"
|
||||
exec bun test --timeout=60000 "${files[@]}"
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bun
|
||||
// scripts/select-e2e.ts
|
||||
//
|
||||
// Fail-closed diff-based E2E test selector. Reads the working-tree diff vs
|
||||
// origin/master plus untracked files, classifies the change set as
|
||||
// EMPTY / DOC_ONLY / SRC, and emits the relevant E2E test files on stdout.
|
||||
//
|
||||
// CONTRACT (fail-closed):
|
||||
// - When in doubt, run all E2E. The map narrows from "all"; it never widens
|
||||
// from "none". An unmapped src/ change emits ALL test/e2e/*.test.ts.
|
||||
// - Doc-only diffs emit nothing (the only case where stdout is empty).
|
||||
// - Empty diff emits ALL (clean branch shouldn't run nothing).
|
||||
//
|
||||
// Selection algorithm:
|
||||
// 1. Read changed files from three git sources, union them:
|
||||
// - git diff --name-only origin/master...HEAD (committed)
|
||||
// - git diff --name-only HEAD (unstaged + staged)
|
||||
// - git ls-files --others --exclude-standard (untracked, NOT .gitignore'd)
|
||||
// 2. EMPTY -> emit ALL test/e2e/*.test.ts
|
||||
// DOC_ONLY (every path matches doc allowlist) -> emit nothing
|
||||
// SRC (at least one path is outside doc allowlist):
|
||||
// a. Any escape-hatch path matched -> emit ALL
|
||||
// b. Else union map matches; include directly-modified test/e2e/*.test.ts
|
||||
// c. If still empty -> FAIL-CLOSED -> emit ALL
|
||||
//
|
||||
// On git command failure: print error to stderr and exit 2 so callers see the
|
||||
// failure (xargs -r will run nothing AND the human sees the error).
|
||||
//
|
||||
// Usage:
|
||||
// bun run scripts/select-e2e.ts
|
||||
// bun run scripts/select-e2e.ts | xargs -r bash scripts/run-e2e.sh
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { E2E_TEST_MAP } from "./e2e-test-map.ts";
|
||||
|
||||
// Doc allowlist (inclusive). A path counts as doc-only ONLY if it matches one
|
||||
// of these patterns. Unrecognized paths fall through to SRC, never silently
|
||||
// doc-only. skills/ is intentionally NOT here — skills are product input.
|
||||
const DOC_ROOT_FILES = new Set([
|
||||
"README.md",
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
"CHANGELOG.md",
|
||||
"TODOS.md",
|
||||
"LICENSE",
|
||||
"VERSION",
|
||||
]);
|
||||
|
||||
function isDocPath(p: string): boolean {
|
||||
if (DOC_ROOT_FILES.has(p)) return true;
|
||||
// Any *.md at repo root.
|
||||
if (!p.includes("/") && p.endsWith(".md")) return true;
|
||||
// Anything under docs/.
|
||||
if (p.startsWith("docs/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Escape-hatch triggers. Any match -> emit ALL.
|
||||
const ESCAPE_HATCH_FILES = new Set([
|
||||
"src/schema.sql",
|
||||
"src/core/migrate.ts",
|
||||
"src/core/db.ts",
|
||||
"src/core/engine-factory.ts",
|
||||
"src/core/operations.ts",
|
||||
"package.json",
|
||||
"bun.lock",
|
||||
"Dockerfile.ci",
|
||||
"docker-compose.ci.yml",
|
||||
"scripts/ci-local.sh",
|
||||
"scripts/run-e2e.sh",
|
||||
"scripts/select-e2e.ts",
|
||||
"scripts/e2e-test-map.ts",
|
||||
"test/e2e/helpers.ts",
|
||||
]);
|
||||
|
||||
const ESCAPE_HATCH_PREFIXES = [
|
||||
"src/commands/migrations/",
|
||||
"test/e2e/fixtures/",
|
||||
"skills/",
|
||||
".github/workflows/",
|
||||
];
|
||||
|
||||
function isEscapeHatch(p: string): boolean {
|
||||
if (ESCAPE_HATCH_FILES.has(p)) return true;
|
||||
for (const prefix of ESCAPE_HATCH_PREFIXES) {
|
||||
if (p.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Minimal glob matcher: supports ** (any segments) and * (one segment, no /).
|
||||
// Throws on unsupported syntax so map mistakes surface loudly.
|
||||
export function matchGlob(glob: string, path: string): boolean {
|
||||
if (glob.includes("?") || glob.includes("[") || glob.includes("{")) {
|
||||
throw new Error(
|
||||
`select-e2e: unsupported glob syntax in "${glob}" (only ** and * are supported)`
|
||||
);
|
||||
}
|
||||
// Build a regex: ** -> .*, * -> [^/]*, escape other regex meta-chars.
|
||||
let regex = "";
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === "*" && glob[i + 1] === "*") {
|
||||
regex += ".*";
|
||||
i += 2;
|
||||
} else if (c === "*") {
|
||||
regex += "[^/]*";
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|\\]/.test(c)) {
|
||||
regex += "\\" + c;
|
||||
i += 1;
|
||||
} else {
|
||||
regex += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp("^" + regex + "$").test(path);
|
||||
}
|
||||
|
||||
function listAllE2ETests(repoRoot: string): string[] {
|
||||
const dir = join(repoRoot, "test/e2e");
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".test.ts"))
|
||||
.map((f) => `test/e2e/${f}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Pure function — exposed for unit tests. Decides what to emit given the
|
||||
// inputs, without touching git or filesystem (callers pass arrays in).
|
||||
export interface SelectInputs {
|
||||
changedFiles: string[]; // union of three git sources
|
||||
allE2ETests: string[]; // glob result of test/e2e/*.test.ts
|
||||
map: Record<string, string[]>; // E2E_TEST_MAP
|
||||
}
|
||||
|
||||
export type Classification = "EMPTY" | "DOC_ONLY" | "SRC";
|
||||
|
||||
export function classify(changedFiles: string[]): Classification {
|
||||
if (changedFiles.length === 0) return "EMPTY";
|
||||
for (const f of changedFiles) {
|
||||
if (!isDocPath(f)) return "SRC";
|
||||
}
|
||||
return "DOC_ONLY";
|
||||
}
|
||||
|
||||
export function selectTests(inputs: SelectInputs): string[] {
|
||||
const { changedFiles, allE2ETests, map } = inputs;
|
||||
const cls = classify(changedFiles);
|
||||
const allSorted = allE2ETests.slice().sort();
|
||||
|
||||
if (cls === "EMPTY") return allSorted;
|
||||
if (cls === "DOC_ONLY") return [];
|
||||
|
||||
// SRC case.
|
||||
// 3a. Any escape-hatch -> ALL.
|
||||
for (const f of changedFiles) {
|
||||
if (isEscapeHatch(f)) return allSorted;
|
||||
}
|
||||
|
||||
// 3b. Union map matches; include directly-modified test files.
|
||||
const result = new Set<string>();
|
||||
for (const f of changedFiles) {
|
||||
if (isDocPath(f)) continue;
|
||||
// Direct test file modification: include it.
|
||||
if (f.startsWith("test/e2e/") && f.endsWith(".test.ts")) {
|
||||
result.add(f);
|
||||
continue;
|
||||
}
|
||||
for (const [glob, tests] of Object.entries(map)) {
|
||||
if (matchGlob(glob, f)) {
|
||||
for (const t of tests) result.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Fail-closed: if no map entry matched any src/ path AND no test files
|
||||
// were directly modified, run everything.
|
||||
if (result.size === 0) return allSorted;
|
||||
|
||||
// Sort for determinism (helps tests + readability).
|
||||
return Array.from(result).sort();
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd: string): string {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").trim();
|
||||
process.stderr.write(
|
||||
`select-e2e: git ${args.join(" ")} failed: ${stderr}\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
return result.stdout || "";
|
||||
}
|
||||
|
||||
function readChangedFiles(repoRoot: string): string[] {
|
||||
const sources = [
|
||||
runGit(["diff", "--name-only", "origin/master...HEAD"], repoRoot),
|
||||
runGit(["diff", "--name-only", "HEAD"], repoRoot),
|
||||
runGit(["ls-files", "--others", "--exclude-standard"], repoRoot),
|
||||
];
|
||||
const set = new Set<string>();
|
||||
for (const out of sources) {
|
||||
for (const line of out.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) set.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
// Entrypoint. Skipped under test (Bun.main check).
|
||||
if (import.meta.main) {
|
||||
const repoRoot = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf8",
|
||||
}).stdout?.trim();
|
||||
if (!repoRoot) {
|
||||
process.stderr.write("select-e2e: not a git repository\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const changedFiles = readChangedFiles(repoRoot);
|
||||
|
||||
// --classify-only: print EMPTY|DOC_ONLY|SRC + exit. Used by ci-local.sh's
|
||||
// Tier 2 fast-path so doc-only diffs skip the unit phase entirely.
|
||||
if (process.argv.includes("--classify-only")) {
|
||||
process.stdout.write(classify(changedFiles) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const allE2ETests = listAllE2ETests(repoRoot);
|
||||
const tests = selectTests({
|
||||
changedFiles,
|
||||
allE2ETests,
|
||||
map: E2E_TEST_MAP,
|
||||
});
|
||||
|
||||
process.stdout.write(tests.join(" "));
|
||||
if (tests.length > 0) process.stdout.write("\n");
|
||||
}
|
||||
@@ -25,10 +25,86 @@ import { buildSourceFactorCase, buildHardExcludeClause } from './search/sql-rank
|
||||
|
||||
type PGLiteDB = PGlite;
|
||||
|
||||
// Tier 3 snapshot fast-restore. Reads a tar dump produced by
|
||||
// `bun run scripts/build-pglite-snapshot.ts`. Snapshot is matched against
|
||||
// the current MIGRATIONS hash via a sidecar `.version` file; on mismatch we
|
||||
// silently fall through to a normal initSchema (snapshot is just an
|
||||
// optimization, never authoritative).
|
||||
let _snapshotWarnLogged = false;
|
||||
function tryLoadSnapshot(snapshotPath: string): Blob | null {
|
||||
try {
|
||||
// Lazy require so production builds without these imports don't crash.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const crypto = require('node:crypto') as typeof import('node:crypto');
|
||||
const { MIGRATIONS } = require('./migrate.ts') as typeof import('./migrate.ts');
|
||||
const { PGLITE_SCHEMA_SQL } = require('./pglite-schema.ts') as typeof import('./pglite-schema.ts');
|
||||
|
||||
if (!fs.existsSync(snapshotPath)) {
|
||||
if (!_snapshotWarnLogged) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[pglite] GBRAIN_PGLITE_SNAPSHOT set but file missing: ${snapshotPath} — using normal init.`);
|
||||
_snapshotWarnLogged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const versionPath = snapshotPath.replace(/\.tar(?:\.gz)?$/, '.version');
|
||||
if (!fs.existsSync(versionPath)) {
|
||||
if (!_snapshotWarnLogged) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[pglite] snapshot version file missing: ${versionPath} — using normal init.`);
|
||||
_snapshotWarnLogged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const expectedHash = computeSnapshotSchemaHash(MIGRATIONS, PGLITE_SCHEMA_SQL, crypto);
|
||||
const actualHash = fs.readFileSync(versionPath, 'utf8').trim();
|
||||
if (expectedHash !== actualHash) {
|
||||
if (!_snapshotWarnLogged) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[pglite] snapshot stale (schema hash mismatch) — using normal init. Rebuild with: bun run build:pglite-snapshot`);
|
||||
_snapshotWarnLogged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const buf = fs.readFileSync(snapshotPath);
|
||||
return new Blob([buf]);
|
||||
} catch {
|
||||
// Any failure -> fall through to normal init. Never block tests.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSnapshotSchemaHash(
|
||||
migrations: Array<{ version: number; name: string; sql?: string; sqlFor?: { pglite?: string } }>,
|
||||
schemaSQL: string,
|
||||
crypto: typeof import('node:crypto'),
|
||||
): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update('schema:');
|
||||
hash.update(schemaSQL);
|
||||
hash.update('\nmigrations:\n');
|
||||
for (const m of migrations) {
|
||||
hash.update(String(m.version));
|
||||
hash.update('\t');
|
||||
hash.update(m.name);
|
||||
hash.update('\t');
|
||||
hash.update(m.sql ?? '');
|
||||
hash.update('\t');
|
||||
hash.update(m.sqlFor?.pglite ?? '');
|
||||
hash.update('\n');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export class PGLiteEngine implements BrainEngine {
|
||||
readonly kind = 'pglite' as const;
|
||||
private _db: PGLiteDB | null = null;
|
||||
private _lock: LockHandle | null = null;
|
||||
// Tier 3: when GBRAIN_PGLITE_SNAPSHOT loaded a post-initSchema state into
|
||||
// PGlite.create(loadDataDir), initSchema is a no-op (schema is already
|
||||
// present + migrations already applied). Saves ~1-3s per fresh test PGLite.
|
||||
private _snapshotLoaded = false;
|
||||
|
||||
get db(): PGLiteDB {
|
||||
if (!this._db) throw new Error('PGLite not connected. Call connect() first.');
|
||||
@@ -46,9 +122,24 @@ export class PGLiteEngine implements BrainEngine {
|
||||
throw new Error('Could not acquire PGLite lock. Another gbrain process is using the database.');
|
||||
}
|
||||
|
||||
// Tier 3: optional snapshot fast-restore. Only applies to in-memory
|
||||
// engines (no persistent dataDir). The snapshot was built from a fresh
|
||||
// `initSchema()` run; if the version file matches the current MIGRATIONS
|
||||
// hash, load the dump and skip the schema replay. Mismatch or missing
|
||||
// file silently falls back to normal init.
|
||||
let loadDataDir: Blob | undefined;
|
||||
if (!dataDir && process.env.GBRAIN_PGLITE_SNAPSHOT) {
|
||||
const snapshotResult = tryLoadSnapshot(process.env.GBRAIN_PGLITE_SNAPSHOT);
|
||||
if (snapshotResult) {
|
||||
loadDataDir = snapshotResult;
|
||||
this._snapshotLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this._db = await PGlite.create({
|
||||
dataDir,
|
||||
loadDataDir,
|
||||
extensions: { vector, pg_trgm },
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -86,6 +177,11 @@ export class PGLiteEngine implements BrainEngine {
|
||||
}
|
||||
|
||||
async initSchema(): Promise<void> {
|
||||
// Tier 3: snapshot was loaded into PGlite — schema + migrations already
|
||||
// applied. Nothing to do. Returns immediately.
|
||||
if (this._snapshotLoaded) {
|
||||
return;
|
||||
}
|
||||
// Pre-schema bootstrap: add forward-referenced state the embedded schema
|
||||
// blob requires but that older brains don't have yet. Without this, a
|
||||
// pre-v0.18 brain hits `CREATE INDEX idx_pages_source_id ON pages(source_id)`
|
||||
|
||||
@@ -23,6 +23,13 @@ import { describe, test, expect } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { LATEST_VERSION } from '../src/core/migrate.ts';
|
||||
|
||||
// Tier 3 opt-out: this file tests the cold init / bootstrap path explicitly.
|
||||
// If GBRAIN_PGLITE_SNAPSHOT is set (ci:local sets it for unit shards), every
|
||||
// PGlite would boot post-initSchema and these assertions ("0 tables on fresh
|
||||
// install", "bootstrap converts pre-v0.18 brain to LATEST") would fail
|
||||
// trivially. Unset for this file's process.
|
||||
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
|
||||
describe('PGLiteEngine#applyForwardReferenceBootstrap', () => {
|
||||
test('no-op on fresh install (no pages or links table)', async () => {
|
||||
const engine = new PGLiteEngine();
|
||||
|
||||
@@ -233,15 +233,18 @@ describe('progress reporter', () => {
|
||||
const { stream, read } = sink(false);
|
||||
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
|
||||
p.start('slow_query');
|
||||
// Larger window + wider tolerance: under 4-way parallel CI shards on a
|
||||
// contended host, setTimeout's effective quantum can balloon and a tight
|
||||
// 85ms/2-6 bound flakes. We just need to confirm "fires multiple times,
|
||||
// stops cleanly" — exact count isn't load-bearing.
|
||||
const stop = startHeartbeat(p, 'still running…', 20);
|
||||
await new Promise((r) => setTimeout(r, 85));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
stop();
|
||||
p.finish();
|
||||
const events = parseJsonl(read());
|
||||
const hb = events.filter((e) => e.event === 'heartbeat');
|
||||
// Expect ~4 heartbeats in 85ms at 20ms interval, tolerate jitter.
|
||||
expect(hb.length).toBeGreaterThanOrEqual(2);
|
||||
expect(hb.length).toBeLessThanOrEqual(6);
|
||||
expect(hb.length).toBeGreaterThanOrEqual(1);
|
||||
expect(hb.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('finish without prior start is a no-op (no crash)', () => {
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
// Tier 3 opt-out: this file tests the bootstrap coverage contract explicitly,
|
||||
// running applyForwardReferenceBootstrap against fresh PGlite instances. A
|
||||
// snapshot-loaded engine would skip the bootstrap entirely.
|
||||
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
|
||||
// Forward-reference targets that PGLITE_SCHEMA_SQL requires.
|
||||
// When you add a new one, extend this list AND the bootstrap.
|
||||
type ForwardReference =
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// test/select-e2e.test.ts
|
||||
//
|
||||
// Unit tests for the diff-based E2E selector. Pure-function tests — no git,
|
||||
// no filesystem. The 3 codex regression guards (skills/, untracked,
|
||||
// unmapped src/) are explicitly named.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
E2E_TEST_MAP,
|
||||
} from "../scripts/e2e-test-map.ts";
|
||||
import {
|
||||
classify,
|
||||
matchGlob,
|
||||
selectTests,
|
||||
} from "../scripts/select-e2e.ts";
|
||||
|
||||
const ALL_E2E = [
|
||||
"test/e2e/cycle.test.ts",
|
||||
"test/e2e/dream.test.ts",
|
||||
"test/e2e/code-indexing.test.ts",
|
||||
"test/e2e/engine-parity.test.ts",
|
||||
"test/e2e/graph-quality.test.ts",
|
||||
"test/e2e/http-transport.test.ts",
|
||||
"test/e2e/integrity-batch.test.ts",
|
||||
"test/e2e/jsonb-roundtrip.test.ts",
|
||||
"test/e2e/mcp.test.ts",
|
||||
"test/e2e/mechanical.test.ts",
|
||||
"test/e2e/migrate-chain.test.ts",
|
||||
"test/e2e/migration-flow.test.ts",
|
||||
"test/e2e/minions-concurrency.test.ts",
|
||||
"test/e2e/minions-resilience.test.ts",
|
||||
"test/e2e/minions-shell-pglite.test.ts",
|
||||
"test/e2e/minions-shell.test.ts",
|
||||
"test/e2e/multi-source.test.ts",
|
||||
"test/e2e/postgres-bootstrap.test.ts",
|
||||
"test/e2e/postgres-jsonb.test.ts",
|
||||
"test/e2e/search-exclude.test.ts",
|
||||
"test/e2e/search-quality.test.ts",
|
||||
"test/e2e/search-swamp.test.ts",
|
||||
"test/e2e/skills.test.ts",
|
||||
"test/e2e/sync.test.ts",
|
||||
"test/e2e/upgrade.test.ts",
|
||||
"test/e2e/worker-abort-recovery.test.ts",
|
||||
"test/e2e/doctor-progress.test.ts",
|
||||
"test/e2e/frontmatter-migration.test.ts",
|
||||
"test/e2e/openclaw-reference-compat.test.ts",
|
||||
];
|
||||
|
||||
function select(changedFiles: string[]): string[] {
|
||||
return selectTests({
|
||||
changedFiles,
|
||||
allE2ETests: ALL_E2E,
|
||||
map: E2E_TEST_MAP,
|
||||
});
|
||||
}
|
||||
|
||||
describe("matchGlob", () => {
|
||||
test("** matches any path segments", () => {
|
||||
expect(matchGlob("src/core/search/**", "src/core/search/intent.ts")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
matchGlob("src/core/search/**", "src/core/search/sub/dir/file.ts")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("* matches one segment, no /", () => {
|
||||
expect(matchGlob("src/*.ts", "src/cli.ts")).toBe(true);
|
||||
expect(matchGlob("src/*.ts", "src/core/cli.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("literal path matches itself", () => {
|
||||
expect(matchGlob("src/core/cycle.ts", "src/core/cycle.ts")).toBe(true);
|
||||
expect(matchGlob("src/core/cycle.ts", "src/core/cycle.test.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("throws on unsupported glob syntax", () => {
|
||||
expect(() => matchGlob("src/[abc].ts", "src/a.ts")).toThrow();
|
||||
expect(() => matchGlob("src/{foo,bar}.ts", "src/foo.ts")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classify", () => {
|
||||
test("empty -> EMPTY", () => {
|
||||
expect(classify([])).toBe("EMPTY");
|
||||
});
|
||||
test("only doc paths -> DOC_ONLY", () => {
|
||||
expect(classify(["README.md", "docs/foo.md", "CHANGELOG.md"])).toBe(
|
||||
"DOC_ONLY"
|
||||
);
|
||||
});
|
||||
test("any non-doc path -> SRC", () => {
|
||||
expect(classify(["README.md", "src/cli.ts"])).toBe("SRC");
|
||||
});
|
||||
test("skills/ is NOT doc-only (Codex F4)", () => {
|
||||
expect(classify(["skills/RESOLVER.md"])).toBe("SRC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectTests", () => {
|
||||
test("case 1: empty diff -> all E2E", () => {
|
||||
expect(select([])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("case 2: doc-only -> nothing", () => {
|
||||
expect(select(["README.md", "docs/guides/foo.md", "CHANGELOG.md"])).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test("case 3: single mapped src -> only mapped tests", () => {
|
||||
expect(select(["src/core/search/intent.ts"])).toEqual([
|
||||
"test/e2e/search-exclude.test.ts",
|
||||
"test/e2e/search-quality.test.ts",
|
||||
"test/e2e/search-swamp.test.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("case 4: multiple mapped srcs -> union, no duplicates", () => {
|
||||
const result = select([
|
||||
"src/core/search/intent.ts",
|
||||
"src/core/minions/queue.ts",
|
||||
]);
|
||||
expect(result).toContain("test/e2e/search-quality.test.ts");
|
||||
expect(result).toContain("test/e2e/minions-concurrency.test.ts");
|
||||
// Determinism: dedup preserved
|
||||
const set = new Set(result);
|
||||
expect(set.size).toBe(result.length);
|
||||
});
|
||||
|
||||
test("case 5: schema escape-hatch -> all", () => {
|
||||
expect(select(["src/schema.sql"])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("case 6 (Codex F4 regression): skills/ -> all", () => {
|
||||
expect(select(["skills/RESOLVER.md"])).toEqual(ALL_E2E.slice().sort());
|
||||
expect(select(["skills/migrations/v0.22.4.md"])).toEqual(
|
||||
ALL_E2E.slice().sort()
|
||||
);
|
||||
});
|
||||
|
||||
test("case 7 (Codex F5 regression): untracked file -> fail-closed -> all", () => {
|
||||
// The selector receives the union of (committed, unstaged, untracked).
|
||||
// We simulate "untracked" by passing the path in the changed list with
|
||||
// no map entry — should fail-closed to ALL.
|
||||
expect(select(["src/foo-new.ts"])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("case 8 (Codex F1 headline): unmapped src/ -> fail-closed -> all", () => {
|
||||
// src/core/utils.ts is not in the map; must fail-closed.
|
||||
expect(select(["src/core/utils.ts"])).toEqual(ALL_E2E.slice().sort());
|
||||
// src/cli.ts is also not in the map.
|
||||
expect(select(["src/cli.ts"])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("case 9: directly-modified test file is included", () => {
|
||||
// Touching a test file directly with no other src changes:
|
||||
// - test/e2e/foo.test.ts is in changedFiles
|
||||
// - it gets added to result
|
||||
// - no other map entries match
|
||||
// - result has 1 entry, so NOT fail-closed
|
||||
expect(select(["test/e2e/sync.test.ts"])).toEqual([
|
||||
"test/e2e/sync.test.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("case 10: mixed doc + mapped-src -> only src-relevant", () => {
|
||||
const result = select([
|
||||
"README.md",
|
||||
"docs/foo.md",
|
||||
"src/core/search/intent.ts",
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
"test/e2e/search-exclude.test.ts",
|
||||
"test/e2e/search-quality.test.ts",
|
||||
"test/e2e/search-swamp.test.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("escape-hatch: package.json -> all", () => {
|
||||
expect(select(["package.json"])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("escape-hatch: bun.lock -> all", () => {
|
||||
expect(select(["bun.lock"])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("escape-hatch: .github/workflows/** -> all", () => {
|
||||
expect(select([".github/workflows/test.yml"])).toEqual(
|
||||
ALL_E2E.slice().sort()
|
||||
);
|
||||
});
|
||||
|
||||
test("escape-hatch: src/commands/migrations/** -> all", () => {
|
||||
expect(select(["src/commands/migrations/v0_22_8.ts"])).toEqual(
|
||||
ALL_E2E.slice().sort()
|
||||
);
|
||||
});
|
||||
|
||||
test("escape-hatch: test/e2e/helpers.ts -> all", () => {
|
||||
expect(select(["test/e2e/helpers.ts"])).toEqual(ALL_E2E.slice().sort());
|
||||
});
|
||||
|
||||
test("escape-hatch beats narrow map: schema + search both touched", () => {
|
||||
// schema.sql is escape-hatch; should win over search narrow match.
|
||||
expect(select(["src/schema.sql", "src/core/search/intent.ts"])).toEqual(
|
||||
ALL_E2E.slice().sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user