diff --git a/AGENTS.md b/AGENTS.md index 8b0fcf01e3..cc9851178c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,17 +163,25 @@ it binds you regardless of which mechanism is within reach. ```bash bun install bun run typecheck # bun x tsc --noEmit (strict) -bun run test # full tests/ suite +bun run test:changed # import-graph tests against the resolved `dev` merge base +bun run test # full tests/ suite (PR-ready / explicit ask only) bun run lint:gui # GUI eslint bun run privacy:scan # credential/privacy scan used by CI bun run build:gui # Vite GUI build ``` During implementation, use the smallest focused checks that directly cover the -changed subsystem. Do not run repository-wide `bun run typecheck` or -`bun run test` for a scoped change unless the change affects shared runtime, -routing, config, server behavior, a focused result is failed or ambiguous, or -the user explicitly asks for full validation. +changed subsystem. Prefer `bun test tests/.test.ts` for a known file, or +`bun run test:changed` when the touch set is broader than one file. Do **not** +run repository-wide `bun run test` or a bare `bun test` with no file arguments +for a scoped change by default. `bun run test:changed` follows Bun's parsed module graph: it +selects test files that import changed modules, but it cannot see dependencies +expressed through subprocesses, source files read as data, or golden/derived +files. Run the relevant focused tests explicitly for those paths; if no reliable +focused set covers them, the full suite is required even for a scoped change. +That indirect-dependency case is the explicit exception to the scoped-change +default. The full suite is ~850 files, so otherwise reserve it for a failed or +ambiguous focused result, an explicit user request, or the PR-ready gate below. Before creating or updating a non-trivial PR as review-ready, or before approving such a PR, run `bun run typecheck` and `bun run test`. CI runs these @@ -274,8 +282,9 @@ reviewers (Codex, CodeRabbit). assumptions about a compile step, or code paths that break `bun run typecheck` / `bun run test`. - **Tests:** behavior changes in `src/` need a focused regression test near - the existing tests for that subsystem. Shared routing, adapter, config, or - server changes need the full suite green. + the existing tests for that subsystem. During implementation, run the relevant + focused files and use `bun run test:changed` for import-connected coverage as + described above; the full suite is the PR-ready gate. - **Docs sync:** user-facing behavior changes should update `docs-site/` (and keep translated locales from contradicting the English source). - **Privacy:** `bun run privacy:scan` must stay green; never introduce logging diff --git a/bunfig.toml b/bunfig.toml index 00cbc1231e..318845b44a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,6 +5,7 @@ # so a bare `bun test` — or `bun test tests/` (a substring filter that also matches # devlog/opencode-cursor/tests/) — drags them in and reports hundreds of spurious failures. # `root` pins discovery to ./tests so every invocation stays on the real suite. +# File-level `--parallel` has no bunfig key; `scripts/test.ts` passes it for `bun run test`. # The npm script already uses `bun test ./tests/`; this makes a bare `bun test` behave the same. [test] root = "tests" diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 093aac9d26..9dac4255fb 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -15,7 +15,9 @@ bun install bun run dev:proxy # proxy API in dev mode bun run dev:gui # dashboard dev server (another terminal) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` remains an alias for `bun run dev:proxy`. The dashboard dev server is `bun run dev:gui`; @@ -28,17 +30,25 @@ scripts so local commands match CI: ```bash bun run typecheck # strict TypeScript check -bun run test # complete tests/ suite +bun run test:changed # import-graph tests against the resolved dev merge base +bun run test # complete tests/ suite (PR-ready / explicit ask) bun test tests/router.test.ts # focused test file bun run build:gui # Vite GUI build + package preparation bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` +`test:changed` selects the first comparison ref that exists, in order: `upstream/dev`, +`origin/dev`, then local `dev`. It reports that ref and the exact `git merge-base HEAD ` +commit, then passes the merge-base SHA to Bun. + Most tests are flat `tests/*.test.ts` Bun tests. `tests/helpers/` contains shared fixtures and `tests/e2e-style/` contains broader native-parity scenarios. Keep a focused regression near the -existing tests for the subsystem you change; run the full suite for shared routing, adapters, config, -or server behavior. +existing tests for the subsystem you change. `test:changed` follows Bun's parsed module graph: it +selects test files that import changed modules, but it cannot see dependencies exercised through +subprocesses, source files read as data, or golden/derived files. Run the relevant focused tests +explicitly for those paths; if no reliable focused set covers them, run the complete suite. In all +cases, run the complete suite with `bun run test` before marking a PR review-ready. The docs site you're reading lives in `docs-site/` (Astro + Starlight): diff --git a/docs-site/src/content/docs/fr/contributing.md b/docs-site/src/content/docs/fr/contributing.md index 408191cdbf..580d200908 100644 --- a/docs-site/src/content/docs/fr/contributing.md +++ b/docs-site/src/content/docs/fr/contributing.md @@ -15,7 +15,9 @@ bun install bun run dev:proxy # proxy API in dev mode bun run dev:gui # dashboard dev server (another terminal) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # sélection courante via le graphe d’import +bun test tests/router.test.ts # test ciblé courant +bun run test # suite complète (PR-ready / demande explicite) ``` `bun run dev` reste un alias pour `bun run dev:proxy`. Le serveur de développement du tableau de bord est `bun run dev:gui` ; @@ -28,17 +30,25 @@ distincte. Utilisez les scripts enregistrés afin que les commandes locales corr ```bash bun run typecheck # strict TypeScript check -bun run test # complete tests/ suite +bun run test:changed # tests liés au merge-base dev résolu +bun run test # complete tests/ suite (PR-ready / explicit ask) bun test tests/router.test.ts # focused test file bun run build:gui # Vite GUI build + package preparation bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` +`test:changed` choisit la première ref de comparaison existante, dans cet ordre : `upstream/dev`, +`origin/dev`, puis la ref locale `dev`. Il indique cette ref et le commit exact obtenu par +`git merge-base HEAD `, puis transmet le SHA du merge-base à Bun. + La plupart des tests Bun sont des fichiers plats `tests/*.test.ts`. `tests/helpers/` contient les fixtures partagées et `tests/e2e-style/` des scénarios plus larges de parité native. Placez une régression ciblée près -des tests existants du sous-système modifié. Exécutez la suite complète pour le routage partagé, les adaptateurs, -la configuration ou le comportement du serveur. +des tests existants du sous-système modifié. `test:changed` suit le graphe de modules analysé par Bun : il +sélectionne les fichiers de test qui importent un module modifié, mais ne voit pas les dépendances exercées par +des sous-processus, les fichiers source lus comme données ni les fichiers golden/dérivés. Exécutez explicitement +les tests ciblés pour ces chemins ; si aucun ensemble ciblé fiable ne les couvre, exécutez la suite complète. +Dans tous les cas, lancez `bun run test` avant de marquer une PR comme review-ready. Le site de documentation que vous lisez se trouve dans `docs-site/` (Astro + Starlight) : diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 2d74d52f71..e0a6fc7d19 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 開発モードのプロキシ API bun run dev:gui # ダッシュボード dev サーバー(別ターミナル) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # 通常の import graph 選択 +bun test tests/router.test.ts # 通常の集中テスト +bun run test # 全体スイート (PR review-ready / 明示時) ``` `bun run dev` は引き続き `bun run dev:proxy` のエイリアスとして動作します。ダッシュボード dev サーバーは @@ -26,17 +28,25 @@ bun run test # bun test ./tests/ ```bash bun run typecheck # 厳密な TypeScript 検査 -bun run test # tests/ の全体スイート +bun run test:changed # 解決済み dev merge-base との差分テスト +bun run test # tests/ の全体スイート (PR review-ready / 明示時) bun test tests/router.test.ts # 特定テストファイル bun run build:gui # Vite GUI ビルド + パッケージ準備 bun run privacy:scan # CI で使う資格情報/個人情報検査 bun run prepare:package # パッケージランチャー/asset 更新 ``` +`test:changed` は、`upstream/dev`、`origin/dev`、ローカルの `dev` の順に最初に存在する +比較 ref を選びます。その ref と `git merge-base HEAD ` で得た正確な commit を出力し、 +merge-base の SHA を Bun に渡します。 + ほとんどのテストは `tests/*.test.ts` に並んで配置された Bun テストです。共有 fixture は `tests/helpers/`、範囲の広いネイティブ等価性シナリオは `tests/e2e-style/` にあります。変更した -サブシステムの既存テストの近くに集中した回帰テストを追加してください。共有ルーティング、アダプター、設定、サーバー -動作を触った場合は全体スイートも実行します。 +サブシステムの既存テストの近くに集中した回帰テストを追加してください。`test:changed` が追跡するのは Bun が解析した +module graph です。変更した module を import するテストファイルは選択しますが、subprocess 経由で実行される依存関係、 +データとして読み込まれるソースファイル、golden/派生ファイルへの依存関係は検出できません。これらについては該当する +集中テストを明示的に実行し、信頼できる集中テストの組み合わせがない場合は全体スイートを実行してください。いずれの場合も、 +PR を review-ready にする前に `bun run test` で全体スイートを実行します。 いま読んでいるドキュメントサイトは `docs-site/` にあります(Astro + Starlight)。 diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 285445d8c0..42c6b846b7 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 개발 모드 프록시 API bun run dev:gui # 대시보드 dev 서버(다른 터미널) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # 일반 import graph 선택 +bun test tests/router.test.ts # 일반 집중 테스트 +bun run test # 전체 스위트 (PR review-ready / 명시 요청 시) ``` `bun run dev`는 계속 `bun run dev:proxy`의 별칭으로 동작합니다. 대시보드 dev 서버는 @@ -26,17 +28,25 @@ bun run test # bun test ./tests/ ```bash bun run typecheck # 엄격한 TypeScript 검사 -bun run test # tests/ 전체 스위트 +bun run test:changed # 결정된 dev merge-base와 연결된 테스트 +bun run test # tests/ 전체 스위트 (PR review-ready / 명시 요청 시) bun test tests/router.test.ts # 특정 테스트 파일 bun run build:gui # Vite GUI 빌드 + 패키지 준비 bun run privacy:scan # CI에서 쓰는 자격 증명/개인정보 검사 bun run prepare:package # 패키지 런처/asset 갱신 ``` +`test:changed`는 `upstream/dev`, `origin/dev`, 로컬 `dev` 순으로 처음 존재하는 비교 ref를 +선택합니다. 그 ref와 `git merge-base HEAD `로 구한 정확한 commit을 출력하고, +merge-base SHA를 Bun에 전달합니다. + 대부분의 테스트는 `tests/*.test.ts`에 나란히 놓인 Bun 테스트입니다. 공용 fixture는 `tests/helpers/`, 범위가 넓은 네이티브 동등성 시나리오는 `tests/e2e-style/`에 있습니다. 바꾼 -subsystem의 기존 테스트 근처에 집중된 회귀 테스트를 추가하세요. 공용 라우팅, 어댑터, 설정, 서버 -동작을 건드렸다면 전체 스위트도 실행합니다. +subsystem의 기존 테스트 근처에 집중된 회귀 테스트를 추가하세요. `test:changed`는 Bun이 파싱한 module graph를 따라 +변경된 module을 import하는 테스트 파일을 선택하지만, subprocess를 통해 실행되는 의존성, 데이터로 읽는 소스 파일, +golden/파생 파일 의존성은 찾지 못합니다. 이런 경로는 관련 집중 테스트를 명시적으로 실행하고, 신뢰할 수 있는 집중 테스트 +집합으로 다룰 수 없으면 전체 스위트를 실행하세요. 어떤 경우든 PR을 review-ready로 만들기 전에는 `bun run test`로 +전체 스위트를 실행합니다. 지금 읽고 있는 문서 사이트는 `docs-site/`에 있습니다(Astro + Starlight). diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 6f71de7f8b..7740f02d8f 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # прокси-API в режиме разработки bun run dev:gui # dev-сервер дашборда (другой терминал) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # обычный выбор по графу импортов +bun test tests/router.test.ts # обычный сфокусированный тест +bun run test # полный набор (PR review-ready / явная просьба) ``` `bun run dev` остаётся псевдонимом для `bun run dev:proxy`. Dev-сервер дашборда — `bun run dev:gui`; @@ -25,17 +27,25 @@ bun run test # bun test ./tests/ ```bash bun run typecheck # строгая проверка TypeScript -bun run test # полный набор tests/ +bun run test:changed # тесты относительно выбранной dev merge-base +bun run test # полный набор tests/ (PR review-ready / явная просьба) bun test tests/router.test.ts # отдельный тестовый файл bun run build:gui # сборка GUI на Vite + подготовка пакета bun run privacy:scan # проверка учётных данных/приватности, используемая в CI bun run prepare:package # обновление лаунчеров/ресурсов пакета ``` +`test:changed` выбирает первую существующую ref для сравнения в порядке: `upstream/dev`, +`origin/dev`, затем локальную `dev`. Команда сообщает эту ref и точный commit из +`git merge-base HEAD `, после чего передаёт SHA merge-base в Bun. + Большинство тестов — плоские Bun-тесты `tests/*.test.ts`. В `tests/helpers/` лежат общие fixtures, а в `tests/e2e-style/` — более широкие сценарии нативного паритета. Добавляйте сфокусированный -регрессионный тест рядом с существующими тестами изменяемой подсистемы; если затронуты общая -маршрутизация, адаптеры, конфигурация или поведение сервера, запускайте полный набор. +регрессионный тест рядом с существующими тестами изменяемой подсистемы. `test:changed` следует по графу +модулей, разобранному Bun: он выбирает тестовые файлы, импортирующие изменённые модули, но не видит зависимости, +запускаемые через подпроцессы, исходники, читаемые как данные, и golden/производные файлы. Для таких путей явно +запускайте соответствующие сфокусированные тесты; если надёжного сфокусированного набора нет, запускайте полный +набор. В любом случае перед пометкой PR как review-ready выполните `bun run test`. Сайт документации, который вы сейчас читаете, находится в `docs-site/` (Astro + Starlight): diff --git a/docs-site/src/content/docs/tr/contributing.md b/docs-site/src/content/docs/tr/contributing.md index a6691f02b3..e2572b2e50 100644 --- a/docs-site/src/content/docs/tr/contributing.md +++ b/docs-site/src/content/docs/tr/contributing.md @@ -17,7 +17,9 @@ bun install bun run dev:proxy # geliştirme modunda proxy API bun run dev:gui # kontrol paneli geliştirme sunucusu (başka bir terminalde) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # rutin import graph seçimi +bun test tests/router.test.ts # rutin odaklanmış test +bun run test # tam paket (PR review-ready / açık istek) ``` `bun run dev`, `bun run dev:proxy` komutunun bir takma adıdır. Kontrol paneli @@ -32,19 +34,26 @@ Yerel komutların CI ile eşleşmesi için depodaki betikleri kullanın: ```bash bun run typecheck # katı TypeScript denetimi -bun run test # tests/ paketinin tamamı +bun run test:changed # çözümlenen dev merge-base farkına bağlı testler +bun run test # tests/ paketinin tamamı (PR review-ready / açık istek) bun test tests/router.test.ts # odaklanmış test dosyası bun run build:gui # Vite GUI derlemesi + paket hazırlığı bun run privacy:scan # CI tarafından kullanılan kimlik/gizlilik taraması bun run prepare:package # paket başlatıcılarını ve varlıklarını yenileme ``` +`test:changed`, karşılaştırma için sırasıyla `upstream/dev`, `origin/dev`, ardından yerel `dev` +ref'lerinden var olan ilkini seçer. Bu ref'i ve `git merge-base HEAD ` ile bulunan kesin +commit'i bildirir, ardından merge-base SHA'sını Bun'a geçirir. + Testlerin çoğu düz `tests/*.test.ts` Bun testleridir. `tests/helpers/` paylaşılan test ortamlarını (fixtures) ve `tests/e2e-style/` daha geniş yerel parite senaryolarını içerir. Değiştirdiğiniz alt sistemin mevcut testlerinin -yakınında odaklanmış bir regresyon testi bulundurun; paylaşılan yönlendirme, -adaptörler, yapılandırma veya sunucu davranışları için test paketinin tamamını -çalıştırın. +yakınında odaklanmış bir regresyon testi bulundurun. `test:changed`, Bun'ın ayrıştırdığı module grafiğini izler: +değişen module'leri import eden test dosyalarını seçer; ancak subprocess üzerinden kullanılan bağımlılıkları, +veri olarak okunan kaynak dosyalarını veya golden/türetilmiş dosya bağımlılıklarını göremez. Bu yollar için ilgili +odaklanmış testleri açıkça çalıştırın; güvenilir bir odaklanmış test kümesi yoksa tam paketi çalıştırın. Her durumda, +PR'ı review-ready olarak işaretlemeden önce `bun run test` komutunu çalıştırın. Okumakta olduğunuz dokümantasyon sitesi `docs-site/` (Astro + Starlight) dizinindedir: @@ -252,5 +261,3 @@ Değişikliğinizi kanıtlayan en dar komutu çalıştırın — tipler için `b typecheck`, davranış için odaklanmış bir `bun test tests/.test.ts` veya çalışma zamanı probu, ardından etkilenen yüzeye uygun daha geniş kapılar. opencodex büyük partiler yerine küçük, doğrulanabilir commit'leri tercih eder. - - diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index 1559fbe015..a81470c961 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 开发模式代理 API bun run dev:gui # 仪表盘 dev 服务器(另一个终端) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # 日常 import graph 选择 +bun test tests/router.test.ts # 日常聚焦测试 +bun run test # 完整 suite(PR review-ready / 明确要求时) ``` `bun run dev` 继续作为 `bun run dev:proxy` 的别名。仪表盘 dev 服务器使用 `bun run dev:gui`; @@ -25,16 +27,24 @@ bun run test # bun test ./tests/ ```bash bun run typecheck # 严格 TypeScript 检查 -bun run test # 完整 tests/ suite +bun run test:changed # 针对已解析 dev merge-base 的 import graph 测试 +bun run test # 完整 tests/ suite(PR review-ready / 明确要求时) bun test tests/router.test.ts # 聚焦单个测试文件 bun run build:gui # Vite GUI 构建 + package 准备 bun run privacy:scan # CI 使用的 credential/privacy 扫描 bun run prepare:package # 刷新 package launcher/asset ``` +`test:changed` 会按 `upstream/dev`、`origin/dev`、本地 `dev` 的顺序选择第一个实际存在的 +比较 ref,并报告该 ref 及 `git merge-base HEAD ` 得到的精确 commit,然后把 merge-base +SHA 传给 Bun。 + 大多数测试是平铺在 `tests/*.test.ts` 下的 Bun test。`tests/helpers/` 存放共享 fixture, `tests/e2e-style/` 存放范围更广的原生一致性场景。请在对应 subsystem 的现有测试附近加入聚焦的 -回归测试;若改动涉及共享 routing、adapter、config 或 server 行为,还应运行完整 suite。 +回归测试。`test:changed` 只沿 Bun 解析出的 module graph 选择 import 了变更 module 的测试文件,无法发现 +通过 subprocess 执行、作为数据读取的 source file,或 golden/derived file 形成的依赖。对这些路径要明确运行 +对应的聚焦测试;如果没有可靠的聚焦测试集合能覆盖,就运行完整 suite。无论如何,在把 PR 标记为 review-ready +之前都要运行 `bun run test`。 你正在阅读的文档站点位于 `docs-site/`(Astro + Starlight): diff --git a/docs-site/src/content/docs/zh-tw/contributing.md b/docs-site/src/content/docs/zh-tw/contributing.md index 66e04e0c3b..3b8a484e1d 100644 --- a/docs-site/src/content/docs/zh-tw/contributing.md +++ b/docs-site/src/content/docs/zh-tw/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 開發模式代理 API bun run dev:gui # 儀表板 dev 伺服器(另一個終端) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # 日常 import graph 選擇 +bun test tests/router.test.ts # 日常聚焦測試 +bun run test # 完整 suite(PR review-ready / 明確要求時) ``` `bun run dev` 繼續作為 `bun run dev:proxy` 的別名。儀表板 dev 伺服器使用 `bun run dev:gui`; @@ -25,16 +27,24 @@ bun run test # bun test ./tests/ ```bash bun run typecheck # 嚴格 TypeScript 檢查 -bun run test # 完整 tests/ suite +bun run test:changed # 針對已解析 dev merge-base 的 import graph 測試 +bun run test # 完整 tests/ suite(PR review-ready / 明確要求時) bun test tests/router.test.ts # 聚焦單個測試檔案 bun run build:gui # Vite GUI 建置 + package 準備 bun run privacy:scan # CI 使用的 credential/privacy 掃描 bun run prepare:package # 重新整理 package launcher/asset ``` +`test:changed` 會依 `upstream/dev`、`origin/dev`、本機 `dev` 的順序選擇第一個實際存在的 +比較 ref,並回報該 ref 與 `git merge-base HEAD ` 得到的精確 commit,再把 merge-base +SHA 傳給 Bun。 + 大多數測試是平鋪在 `tests/*.test.ts` 下的 Bun test。`tests/helpers/` 存放共享 fixture, `tests/e2e-style/` 存放範圍更廣的原生一致性場景。請在對應 subsystem 的現有測試附近加入聚焦的 -迴歸測試;若改動涉及共享 routing、adapter、config 或 server 行為,還應執行完整 suite。 +迴歸測試。`test:changed` 只沿 Bun 解析出的 module graph 選擇 import 了變更 module 的測試檔案,無法發現 +透過 subprocess 執行、當成資料讀取的 source file,或 golden/derived file 形成的依賴。對這些路徑要明確執行 +對應的聚焦測試;若沒有可靠的聚焦測試集合能涵蓋,就執行完整 suite。無論如何,在把 PR 標記為 review-ready +之前都要執行 `bun run test`。 你正在閱讀的文件站點位於 `docs-site/`(Astro + Starlight): diff --git a/package.json b/package.json index 6793a45f48..4bcf6009d8 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dev:gui": "cd gui && bun run dev", "start": "bun run src/cli/index.ts start", "test": "bun scripts/test.ts", + "test:changed": "bun scripts/test.ts --changed=dev", "typecheck": "bun x tsc --noEmit", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", diff --git a/scripts/test.ts b/scripts/test.ts index 5297a17722..db83966785 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -59,6 +59,257 @@ export function createIsolatedTestEnvironment( }; } +function hasCliFlag(requested: string[], name: string): boolean { + const delimiterIndex = requested.indexOf("--"); + const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); + return wrapperArgs.some(arg => arg === name || arg.startsWith(`${name}=`)); +} + +// Bun 1.4.0 builds `bun test` options from its test, runtime, transpiler, and base tables. +// Only required values consume the next argument. Optional values such as `--parallel=2` +// must stay attached so a bare option cannot hide the positional filter that follows it. +const BUN_TEST_OPTIONS_REQUIRING_VALUES = new Set([ + // Test options. + "--timeout", + "--rerun-each", + "--retry", + "--seed", + "--coverage-reporter", + "--coverage-dir", + "-t", + "--test-name-pattern", + "--grep", + "--reporter", + "--reporter-outfile", + "--max-concurrency", + "--path-ignore-patterns", + "--parallel-delay", + "--shard", + "--timings", + // Runtime options accepted by `bun test`. + "--watch-kill-signal", + "-r", + "--preload", + "--require", + "--import", + "--cpu-prof-name", + "--cpu-prof-dir", + "--cpu-prof-interval", + "--heap-prof-name", + "--heap-prof-dir", + "--heap-prof-interval", + "--install", + "-e", + "--eval", + "-p", + "--print", + "--port", + "--origin", + "--conditions", + "--fetch-preconnect", + "--max-http-header-size", + "--dns-result-order", + "--redirect-warnings", + "--disable-warning", + "--title", + "--unhandled-rejections", + "--console-depth", + "--user-agent", + "--cron-title", + "--cron-period", + "--trace-event-categories", + "--trace-event-file-pattern", + "--stack-trace-limit", + // Transpiler and base options accepted by `bun test`. + "--main-fields", + "--extension-order", + "--tsconfig-override", + "-d", + "--define", + "--drop", + "--feature", + "-l", + "--loader", + "--jsx-factory", + "--jsx-fragment", + "--jsx-import-source", + "--jsx-runtime", + "--env-file", + "--cwd", + "-c", + "--config", +]); + +export interface ChangedRunPreflight { + comparisonRef: string; + comparisonCommit: string; + changedFiles: string[]; +} + +const changedComparisonRefs = ["upstream/dev", "origin/dev", "dev"] as const; + +/** Choose the highest-priority conventional dev ref without assuming which remote is canonical. */ +export function selectChangedComparisonRef(refExists: (ref: string) => boolean): string | null { + return changedComparisonRefs.find(refExists) ?? null; +} + +function decodeOutput(output: Uint8Array | undefined): string { + return output ? new TextDecoder().decode(output) : ""; +} + +function changedComparisonRef(requested: string[]): string | null { + const delimiterIndex = requested.indexOf("--"); + const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); + const changedArg = wrapperArgs.find(arg => arg === "--changed" || arg.startsWith("--changed=")); + if (!changedArg) return null; + if (changedArg === "--changed" || changedArg === "--changed=") { + throw new Error( + "[test] changed mode requires an explicit comparison ref; use --changed= so the selection can be validated.", + ); + } + return changedArg.slice("--changed=".length); +} + +function gitRefExists( + ref: string, + cwd: string, + env: Record, +): boolean { + const result = Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + cwd, + env, + stdout: "ignore", + stderr: "ignore", + }); + return result.exitCode === 0; +} + +function gitOutput( + args: string[], + cwd: string, + env: Record, +): string { + const result = Bun.spawnSync(["git", ...args], { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + const detail = decodeOutput(result.stderr).trim() || `exit ${result.exitCode ?? "unknown"}`; + throw new Error(`[test] git ${args[0]} failed while validating changed mode: ${detail}`); + } + return decodeOutput(result.stdout); +} + +/** Resolve changed mode and inventory the diff against that commit before invoking Bun. */ +export function inspectChangedRun( + requested: string[], + cwd: string = process.cwd(), + env: Record = process.env, +): ChangedRunPreflight | null { + const requestedComparisonRef = changedComparisonRef(requested); + if (!requestedComparisonRef) return null; + if (requestedComparisonRef.startsWith("-")) { + throw new Error( + `[test] --changed comparison ref ${JSON.stringify(requestedComparisonRef)} is invalid.`, + ); + } + + const comparisonRef = requestedComparisonRef === "dev" + ? selectChangedComparisonRef(ref => gitRefExists(ref, cwd, env)) + : requestedComparisonRef; + if (!comparisonRef) { + throw new Error( + `[test] --changed=dev could not resolve a comparison ref; none of ${changedComparisonRefs.join(", ")} exists.`, + ); + } + + if (!gitRefExists(comparisonRef, cwd, env)) { + throw new Error( + `[test] --changed comparison ref ${JSON.stringify(comparisonRef)} does not resolve to a commit.`, + ); + } + + const comparisonCommit = gitOutput(["merge-base", "HEAD", comparisonRef], cwd, env).trim(); + if (!comparisonCommit) { + throw new Error( + `[test] --changed comparison ref ${JSON.stringify(comparisonRef)} has no merge base with HEAD.`, + ); + } + + const diff = gitOutput(["diff", "--name-only", comparisonCommit, "--"], cwd, env); + const changedFiles = [...new Set(diff.split("\n").filter(Boolean))]; + return { comparisonRef, comparisonCommit, changedFiles }; +} + +/** Refuse a successful changed-mode run when Bun silently selected no tests for a real diff. */ +export function changedSelectionFailure( + preflight: ChangedRunPreflight, + output: string, +): string | null { + if (preflight.changedFiles.length === 0) return null; + const summary = output + .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "") + .match(/Ran\s+(\d+)\s+tests?\s+across\s+(\d+)\s+files?\b/i); + if (!summary) { + return `[test] could not validate --changed=${preflight.comparisonCommit} (${preflight.comparisonRef} merge base): Bun did not emit a recognizable selection summary for a diff containing ${preflight.changedFiles.length} changed file(s).`; + } + if (Number(summary[1]) !== 0 || Number(summary[2]) !== 0) return null; + return `[test] --changed=${preflight.comparisonCommit} (${preflight.comparisonRef} merge base) selected 0 tests across 0 files, but the diff contains ${preflight.changedFiles.length} changed file(s). Bun follows only the parsed module graph; run the relevant focused tests for subprocess, read-as-data, or golden-file dependencies, or run the full suite.`; +} + +/** + * True for a filter-less `bun run test`: no file arguments and no `--changed`. + * `--timeout` / `--dots` / `--parallel=N` still count as full. + */ +function isFullSuiteRun(requested: string[]): boolean { + const delimiterIndex = requested.indexOf("--"); + const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); + const passedThrough = delimiterIndex === -1 ? [] : requested.slice(delimiterIndex + 1); + if (passedThrough.length > 0) return false; + if (hasCliFlag(requested, "--changed")) return false; + + for (let index = 0; index < wrapperArgs.length; index++) { + const arg = wrapperArgs[index]; + if (arg === "-" || !arg.startsWith("-")) return false; + if (!arg.includes("=") && BUN_TEST_OPTIONS_REQUIRING_VALUES.has(arg)) index++; + } + return true; +} + +/** + * Default `bun test` argv for this repo. + * + * `--isolate` keeps a fresh global per file, and is the substring the exclusive-run pgrep + * matches. `--parallel` is what makes the suite finishable: with isolate alone Bun re-evaluates + * the module graph once per file on a single core, so past ~900 files the run stops looking slow + * and starts looking hung — measured here at 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS, + * against ~110-190 s for the identical suite with `--parallel`. A caller-supplied `--parallel=N` + * is left alone. + */ +export function resolveBunTestArgs( + requested: string[], + comparisonCommit?: string, +): string[] { + const delimiterIndex = requested.indexOf("--"); + const effectiveRequested = comparisonCommit + ? requested.map((arg, index) => ( + (delimiterIndex === -1 || index < delimiterIndex) + && (arg === "--changed" || arg.startsWith("--changed=")) + ? `--changed=${comparisonCommit}` + : arg + )) + : requested; + const args = ["--isolate"]; + if (!hasCliFlag(effectiveRequested, "--parallel")) args.push("--parallel"); + args.push(...effectiveRequested); + // An explicit graph filter is not a filter-less full-suite invocation, so it does not need + // the wrapper's default suite path. Supplying the path would be redundant, not incorrect. + if (isFullSuiteRun(effectiveRequested)) args.push("./tests/"); + return args; +} + /** * Other `bun test` runners already on this machine. * @@ -134,30 +385,61 @@ async function waitForExclusiveRun(selfPid: number): Promise { } } -if (import.meta.main) { +async function main(): Promise { const isolated = createIsolatedTestEnvironment(); try { const requestedTests = process.argv.slice(2); + let changedRun: ChangedRunPreflight | null; + try { + changedRun = inspectChangedRun(requestedTests, process.cwd(), isolated.env); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } + if (changedRun) { + console.log( + `[test] changed mode comparison ref: ${changedRun.comparisonRef}; merge base: ${changedRun.comparisonCommit}`, + ); + } await waitForExclusiveRun(process.pid); const startedAt = Date.now(); const child = Bun.spawnSync( - [process.execPath, "test", "--isolate", ...(requestedTests.length > 0 ? requestedTests : ["./tests/"])], + [ + process.execPath, + "test", + ...resolveBunTestArgs(requestedTests, changedRun?.comparisonCommit), + ], { env: isolated.env, stdin: "inherit", - stdout: "inherit", - stderr: "inherit", + stdout: changedRun ? "pipe" : "inherit", + stderr: changedRun ? "pipe" : "inherit", }, ); + const stdout = decodeOutput(child.stdout); + const stderr = decodeOutput(child.stderr); + if (changedRun) { + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + } const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); if (requestedTests.length === 0 && elapsedSeconds > 600) { console.warn( - `[test] the suite took ${elapsedSeconds}s; it normally runs in about 210s on an idle machine. ` + `[test] the suite took ${elapsedSeconds}s; with --parallel it should finish in a few minutes on an idle machine. ` + "Check for another test runner, a busy CPU, or a test that started polling something real.", ); } - process.exitCode = child.exitCode ?? 1; + const exitCode = child.exitCode ?? 1; + if (exitCode !== 0 || !changedRun) return exitCode; + const selectionFailure = changedSelectionFailure(changedRun, `${stdout}\n${stderr}`); + if (selectionFailure) { + console.error(selectionFailure); + return 1; + } + return 0; } finally { isolated.cleanup(); } } + +if (import.meta.main) process.exitCode = await main(); diff --git a/src/AGENTS.md b/src/AGENTS.md index ab7a5fef46..8ce63b03f2 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -23,6 +23,9 @@ This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.m - Place focused regression coverage near the existing tests for the affected subsystem. - For focused behavior, run the relevant `bun test tests/.test.ts` and `bun run typecheck`. -- For shared routing, adapters, config, OAuth, or server behavior, also run `bun run test`. +- For broader import-connected changes, `bun run test:changed` selects importers from Bun's parsed module graph. + It does not cover dependencies exercised through subprocesses, source files read as data, or golden/derived + files. Run those focused tests explicitly, and run `bun run test` if no reliable focused set covers them. +- Also run `bun run test` before marking a PR review-ready, or when the user explicitly asks for the full suite. - For logging, requests, credentials, account data, or fixtures, also run `bun run privacy:scan`. - Update `docs-site/` when the change affects user-visible behavior or configuration. diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index ef48654f15..0ddd326998 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -1,13 +1,51 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; -import { createIsolatedTestEnvironment } from "../scripts/test"; +import { + changedSelectionFailure, + createIsolatedTestEnvironment, + inspectChangedRun, + resolveBunTestArgs, + selectChangedComparisonRef, +} from "../scripts/test"; import { decodeWindowsIdentityPowerShellOutputForTests, windowsIdentityPowerShellCommandForTests, windowsIdentityPowerShellSpawnOptionsForTests, } from "../src/codex/user-identity"; +function runGit(cwd: string, ...args: string[]): string { + const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(new TextDecoder().decode(result.stderr)); + } + return new TextDecoder().decode(result.stdout).trim(); +} + +function commitFixture(cwd: string, path: string, contents: string, message: string): string { + writeFileSync(join(cwd, path), contents); + runGit(cwd, "add", path); + runGit( + cwd, + "-c", + "user.name=OpenCodex Test", + "-c", + "user.email=test@opencodex.invalid", + "commit", + "-m", + message, + ); + return runGit(cwd, "rev-parse", "HEAD"); +} + +function initChangedRunFixture(): { cwd: string; base: string } { + const cwd = mkdtempSync(join(tmpdir(), "opencodex-changed-ref-")); + runGit(cwd, "init", "--quiet"); + const base = commitFixture(cwd, "base.txt", "base\n", "base"); + return { cwd, base }; +} + describe("test runner isolation", () => { test("redirects user homes to a disposable root", () => { const isolated = createIsolatedTestEnvironment({ PATH: "/test/bin", HOME: "/real/home" }); @@ -68,3 +106,207 @@ describe("test runner isolation", () => { }, ); }); + +/** + * Without `--parallel`, `--isolate` re-evaluates the module graph once per file on a single + * core. Past ~900 files that stops reading as slow and starts reading as hung: measured at + * 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS, against ~110-190 s for the identical + * suite with the flag. These pin the argv so the flag cannot be dropped again silently. + */ +describe("bun test argv", () => { + test("a filter-less run gets isolate, parallel and the suite path", () => { + expect(resolveBunTestArgs([])).toEqual(["--isolate", "--parallel", "./tests/"]); + }); + + test("a file filter keeps isolate and parallel but no suite path", () => { + expect(resolveBunTestArgs(["tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel", "tests/foo.test.ts"]); + expect(resolveBunTestArgs(["-"])) + .toEqual(["--isolate", "--parallel", "-"]); + }); + + test("a caller-supplied concurrency is left alone", () => { + expect(resolveBunTestArgs(["--parallel=2"])) + .toEqual(["--isolate", "--parallel=2", "./tests/"]); + expect(resolveBunTestArgs(["--parallel"])) + .toEqual(["--isolate", "--parallel", "./tests/"]); + expect(resolveBunTestArgs(["--parallel", "tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel", "tests/foo.test.ts"]); + expect(resolveBunTestArgs(["--parallel=2", "tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel=2", "tests/foo.test.ts"]); + }); + + test("changed-mode stays explicitly filtered without redundant arguments", () => { + expect(resolveBunTestArgs(["--changed=dev"])) + .toEqual(["--isolate", "--parallel", "--changed=dev"]); + const mergeBase = "0123456789abcdef0123456789abcdef01234567"; + expect(resolveBunTestArgs(["--changed=dev"], mergeBase)) + .toEqual(["--isolate", "--parallel", `--changed=${mergeBase}`]); + }); + + test("changed-mode prefers the first existing conventional dev ref", () => { + const selectFrom = (...existing: string[]) => { + const probed: string[] = []; + const selected = selectChangedComparisonRef(ref => { + probed.push(ref); + return existing.includes(ref); + }); + return { selected, probed }; + }; + + expect(selectFrom("upstream/dev", "origin/dev", "dev")).toEqual({ + selected: "upstream/dev", + probed: ["upstream/dev"], + }); + expect(selectFrom("origin/dev", "dev")).toEqual({ + selected: "origin/dev", + probed: ["upstream/dev", "origin/dev"], + }); + expect(selectFrom("dev")).toEqual({ + selected: "dev", + probed: ["upstream/dev", "origin/dev", "dev"], + }); + expect(selectFrom()).toEqual({ + selected: null, + probed: ["upstream/dev", "origin/dev", "dev"], + }); + }); + + test("changed-mode requires an explicit, resolvable comparison ref", () => { + expect(() => inspectChangedRun(["--changed"])).toThrow("requires an explicit comparison ref"); + expect(() => inspectChangedRun(["--changed=refs/heads/definitely-missing-test-ref"])) + .toThrow("does not resolve to a commit"); + const inspected = inspectChangedRun(["--changed=HEAD"]); + expect(inspected?.comparisonRef).toBe("HEAD"); + expect(inspected?.comparisonCommit).toBe(runGit(process.cwd(), "rev-parse", "HEAD")); + }); + + test("changed-mode uses the shared merge base for behind, ahead, and diverged refs", () => { + const fixtures: string[] = []; + try { + const behind = initChangedRunFixture(); + fixtures.push(behind.cwd); + runGit(behind.cwd, "branch", "candidate", behind.base); + commitFixture(behind.cwd, "head.txt", "head\n", "head ahead of candidate"); + expect(inspectChangedRun(["--changed=candidate"], behind.cwd)).toMatchObject({ + comparisonRef: "candidate", + comparisonCommit: behind.base, + changedFiles: ["head.txt"], + }); + + const ahead = initChangedRunFixture(); + fixtures.push(ahead.cwd); + const candidateTip = commitFixture(ahead.cwd, "candidate.txt", "candidate\n", "candidate ahead"); + runGit(ahead.cwd, "branch", "candidate", candidateTip); + runGit(ahead.cwd, "checkout", "--quiet", "--detach", ahead.base); + expect(inspectChangedRun(["--changed=candidate"], ahead.cwd)).toMatchObject({ + comparisonRef: "candidate", + comparisonCommit: ahead.base, + changedFiles: [], + }); + + const diverged = initChangedRunFixture(); + fixtures.push(diverged.cwd); + runGit(diverged.cwd, "checkout", "--quiet", "-b", "candidate"); + commitFixture(diverged.cwd, "candidate.txt", "candidate\n", "candidate side"); + runGit(diverged.cwd, "checkout", "--quiet", "--detach", diverged.base); + commitFixture(diverged.cwd, "head.txt", "head\n", "head side"); + expect(inspectChangedRun(["--changed=candidate"], diverged.cwd)).toMatchObject({ + comparisonRef: "candidate", + comparisonCommit: diverged.base, + changedFiles: ["head.txt"], + }); + } finally { + for (const fixture of fixtures) rmSync(fixture, { recursive: true, force: true }); + } + }); + + test("rejects an empty changed selection when the diff is non-empty", () => { + expect(changedSelectionFailure( + { comparisonRef: "upstream/dev", comparisonCommit: "base-sha", changedFiles: ["src/router.ts"] }, + "Ran 0 tests across 0 files.", + )).toContain("--changed=base-sha (upstream/dev merge base) selected 0 tests across 0 files"); + expect(changedSelectionFailure( + { comparisonRef: "dev", comparisonCommit: "base-sha", changedFiles: ["src/router.ts"] }, + "Ran 9 tests across 1 file.", + )).toBeNull(); + expect(changedSelectionFailure( + { comparisonRef: "HEAD", comparisonCommit: "head-sha", changedFiles: [] }, + "Ran 0 tests across 0 files.", + )).toBeNull(); + }); + + test("rejects an unrecognized changed-mode summary for a non-empty diff", () => { + expect(changedSelectionFailure( + { comparisonRef: "dev", comparisonCommit: "base-sha", changedFiles: ["src/router.ts"] }, + "0 pass\n0 fail", + )).toContain("did not emit a recognizable selection summary"); + }); + + test("option-only arguments still count as a full suite run", () => { + expect(resolveBunTestArgs(["--timeout=30000"])) + .toEqual(["--isolate", "--parallel", "--timeout=30000", "./tests/"]); + expect(resolveBunTestArgs(["--timeout", "30000"])) + .toEqual(["--isolate", "--parallel", "--timeout", "30000", "./tests/"]); + expect(resolveBunTestArgs(["--timeout", "30000", "tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel", "--timeout", "30000", "tests/foo.test.ts"]); + expect(resolveBunTestArgs(["--timings", ".bun-test-timings/current.json"])) + .toEqual([ + "--isolate", + "--parallel", + "--timings", + ".bun-test-timings/current.json", + "./tests/", + ]); + for (const configFlag of ["-c", "--config"]) { + expect(resolveBunTestArgs([configFlag, "ci.bunfig.toml"])) + .toEqual(["--isolate", "--parallel", configFlag, "ci.bunfig.toml", "./tests/"]); + } + expect(resolveBunTestArgs(["-t", "serial test"])).toEqual([ + "--isolate", + "--parallel", + "-t", + "serial test", + "./tests/", + ]); + }); + + test("arguments after the delimiter are passed through instead of parsed as wrapper flags", () => { + expect(resolveBunTestArgs(["--", "--parallel=2"])) + .toEqual(["--isolate", "--parallel", "--", "--parallel=2"]); + const mergeBase = "0123456789abcdef0123456789abcdef01234567"; + expect(resolveBunTestArgs(["--", "--changed=fixture"], mergeBase)) + .toEqual(["--isolate", "--parallel", "--", "--changed=fixture"]); + expect(inspectChangedRun(["--", "--changed=fixture"])).toBeNull(); + }); + + test("the wrapper passes parallel execution through to bun", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "opencodex-test-runner-")); + const fixturePath = join(fixtureRoot, "parallel-smoke.test.ts"); + const markerPath = join(fixtureRoot, "executed.marker"); + writeFileSync( + fixturePath, + `import { test } from "bun:test"; import { writeFileSync } from "node:fs"; test("smoke", () => writeFileSync(${JSON.stringify(markerPath)}, "executed"));\n`, + ); + try { + const result = Bun.spawnSync([ + process.execPath, + join(import.meta.dir, "../scripts/test.ts"), + fixturePath, + ], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, OCX_TEST_NO_QUEUE: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const output = new TextDecoder().decode(result.stdout) + + new TextDecoder().decode(result.stderr); + expect(result.exitCode).toBe(0); + expect(output).toContain("PARALLEL"); + expect(existsSync(markerPath)).toBe(true); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +});