diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014b12975..d3fe8056a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ concurrency: cancel-in-progress: true jobs: - build-and-test: + lint: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 15 steps: - name: Checkout @@ -34,25 +34,118 @@ jobs: with: node-version: 22.22.0 cache: 'pnpm' + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Restore Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-lint-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-lint- + ${{ runner.os }}-turbo- + - name: Lint run: pnpm run lint + typecheck: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-typecheck-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-typecheck- + ${{ runner.os }}-turbo- + - name: Type check run: pnpm run typecheck - - name: Test - run: pnpm test + unit-tests: + name: Unit tests / ${{ matrix.label }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - label: Packages + turbo-cache: unit-packages + command: pnpm exec turbo run test --filter='!@codegraphy/extension' + - label: Extension node + turbo-cache: unit-extension-node + command: pnpm exec turbo run test:node --filter=@codegraphy/extension + - label: Extension webview graph interaction and rendering + turbo-cache: unit-extension-webview-graph + command: CODEGRAPHY_VITEST_WEBVIEW_GROUP=graph pnpm exec turbo run test:webview --filter=@codegraphy/extension + - label: Extension webview app shell and plugins + turbo-cache: unit-extension-webview-app-plugins + command: CODEGRAPHY_VITEST_WEBVIEW_GROUP=appPlugins pnpm exec turbo run test:webview --filter=@codegraphy/extension + - label: Extension webview panels, search, and exports + turbo-cache: unit-extension-webview-panels-export + command: CODEGRAPHY_VITEST_WEBVIEW_GROUP=panelsExport pnpm exec turbo run test:webview --filter=@codegraphy/extension - - name: Build - run: pnpm run build + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ matrix.turbo-cache }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ matrix.turbo-cache }}- + ${{ runner.os }}-turbo-unit- + ${{ runner.os }}-turbo- + + - name: Run ${{ matrix.label }} + run: ${{ matrix.command }} playwright: - needs: build-and-test runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 steps: - name: Checkout @@ -74,10 +167,19 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Restore Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-playwright-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-playwright- + ${{ runner.os }}-turbo- + - name: Install Playwright browser + system deps run: pnpm --filter @codegraphy/extension exec playwright install --with-deps chromium - - name: Run Playwright smoke tests + - name: Playwright tests run: pnpm run test:playwright - name: Upload Playwright report @@ -95,3 +197,39 @@ jobs: name: playwright-test-results path: test-results/ retention-days: 30 + + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-build-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-build- + ${{ runner.os }}-turbo- + + - name: Build + run: pnpm run build diff --git a/.github/workflows/mutation-seed.yml b/.github/workflows/mutation-seed.yml new file mode 100644 index 000000000..f7b984eff --- /dev/null +++ b/.github/workflows/mutation-seed.yml @@ -0,0 +1,134 @@ +name: Mutation Seed + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mutation-seed-${{ github.ref }} + cancel-in-progress: true + +jobs: + discover-packages: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + packages: ${{ steps.packages.outputs.packages }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Discover mutation packages + id: packages + run: | + packages="$(pnpm exec tsx packages/quality-tools/src/cli/listMutationPackages.ts --json)" + echo "packages=${packages}" >> "$GITHUB_OUTPUT" + echo "Mutation packages: ${packages}" + + seed-package: + name: Mutation seed / ${{ matrix.package }} + needs: discover-packages + runs-on: ubuntu-latest + timeout-minutes: 360 + if: ${{ needs.discover-packages.outputs.packages != '[]' }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.discover-packages.outputs.packages) }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore package mutation seed cache + uses: actions/cache@v4 + with: + path: reports/mutation/${{ matrix.package }} + key: ${{ runner.os }}-mutation-seed-${{ matrix.package }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-mutation-seed-${{ matrix.package }}- + + - name: Refresh ${{ matrix.package }} mutation seed + run: pnpm run mutate -- ${{ matrix.package }}/ + + - name: Verify ${{ matrix.package }} incremental report exists + run: test -f "reports/mutation/${{ matrix.package }}/stryker-incremental-${{ matrix.package }}.json" + + - name: Upload ${{ matrix.package }} mutation seed + uses: actions/upload-artifact@v4 + with: + name: mutation-seed-${{ matrix.package }} + path: reports/mutation/${{ matrix.package }}/stryker-incremental-${{ matrix.package }}.json + if-no-files-found: error + retention-days: 14 + + assemble-seed: + name: Assemble mutation seed + needs: seed-package + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Download package mutation seeds + uses: actions/download-artifact@v4 + with: + pattern: mutation-seed-* + path: package-seeds + + - name: Assemble main mutation seed artifact + run: | + mkdir -p reports/mutation + + while IFS= read -r file; do + name="$(basename "$file")" + package="${name#stryker-incremental-}" + package="${package%.json}" + mkdir -p "reports/mutation/${package}" + cp "$file" "reports/mutation/${package}/${name}" + done < <(find package-seeds -type f -name 'stryker-incremental-*.json' | sort) + + printf '%s\n' "${GITHUB_SHA}" > reports/mutation/seed-sha.txt + find reports/mutation -type f | sort + + - name: Upload main mutation seed + uses: actions/upload-artifact@v4 + with: + name: main-mutation-seed + path: | + reports/mutation/seed-sha.txt + reports/mutation/*/stryker-incremental-*.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30132440d..9b9729959 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,14 +3,6 @@ name: Release on: workflow_dispatch: inputs: - mode: - description: Package artifacts only or publish them - required: true - default: package - type: choice - options: - - package - - publish target: description: Which release target to run required: true @@ -32,7 +24,7 @@ permissions: contents: read concurrency: - group: release-${{ github.workflow }}-${{ github.ref }}-${{ inputs.mode }}-${{ inputs.target }} + group: release-${{ github.workflow }}-${{ github.ref }}-${{ inputs.target }} cancel-in-progress: false jobs: @@ -62,19 +54,5 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Release checks - run: pnpm run release:check - - - name: Package selected release artifacts - run: pnpm run release:package ${{ inputs.target }} - - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: release-artifacts - path: artifacts/ - retention-days: 30 - - name: Publish selected target - if: ${{ inputs.mode == 'publish' }} run: pnpm run release:publish ${{ inputs.target }} diff --git a/AGENTS.md b/AGENTS.md index 12d4b034d..622bb6ca8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,10 +194,12 @@ If a function exceeds 8: add tests to raise coverage or refactor to reduce compl Stryker injects small faults and verifies tests catch them. Run one module at a time — kill all survivors before moving on. ```bash -pnpm run mutate # all packages pnpm run mutate -- plugin-godot # specific package +pnpm run mutate -- packages/plugin-godot/src/gdscript/resources.ts # specific source file ``` +Bare `pnpm run mutate` is intentionally invalid. CI owns all-package mutation seed refreshes. + Thresholds: ≥90% required · ≥80% warning · <80% must fix. Report: `reports/mutation/mutation.html`. ### 5. Lint + Typecheck diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf6a290cd..160f57d74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,6 @@ Thanks for your interest in contributing to CodeGraphy! 4. Run tests to verify: ```bash pnpm test - pnpm run test:playwright ``` ### Development workflow @@ -49,7 +48,6 @@ Thanks for your interest in contributing to CodeGraphy! pnpm run lint pnpm run typecheck pnpm test - pnpm run test:playwright ``` ## Code style @@ -100,11 +98,13 @@ docs: update README with installation instructions - Aim for meaningful coverage, not 100% ```bash -pnpm test # Run once -pnpm run test:watch # Watch mode -pnpm exec vitest run tests/path/to/file.test.ts # Single file -pnpm run playwright:install # Install Playwright browser (one-time per machine) -pnpm run test:playwright # Browser smoke test suite +pnpm test # Unit and Playwright suites that CI trusts +pnpm run test:unit # All package Vitest suites +pnpm run test:playwright # Browser/webview E2E suite +pnpm run test:vscode # Local VS Code Electron smoke suite +CODEGRAPHY_E2E_FULL=1 pnpm run test:vscode # Full local VS Code Electron suite +pnpm --filter @codegraphy/extension exec playwright install chromium # Browser install +pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/path/to/file.test.ts ``` The repo pins Node in [`.nvmrc`](./.nvmrc) and [`.node-version`](./.node-version). Use that exact runtime before running release or quality-tool commands. diff --git a/README.md b/README.md index 63ab155d4..f3fa0042c 100644 --- a/README.md +++ b/README.md @@ -199,11 +199,16 @@ pnpm run typecheck Useful focused commands: ```bash -pnpm run build:devhost -pnpm --filter @codegraphy/extension test +pnpm run test:unit +pnpm run test:playwright +pnpm run test:vscode +pnpm --filter @codegraphy/extension run test:node +pnpm --filter @codegraphy/extension run test:webview pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/SettingsPanel.test.tsx ``` +CI runs build, lint, typecheck, Playwright, and unit tests as independent lanes. Unit tests are split into package Vitest suites, extension node Vitest, and extension webview groups for graph behavior, app/plugins, and panels/search/export behavior. `pnpm run test:vscode` is a local-only VS Code Electron smoke check for the real extension host. + Plugin authors should start with the [Plugin Guide](./docs/PLUGINS.md), the [plugin lifecycle docs](./docs/plugin-api/LIFECYCLE.md), and [`@codegraphy/plugin-api`](https://www.npmjs.com/package/@codegraphy/plugin-api). ## Project State diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9db6867b6..b61c61583 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -20,7 +20,6 @@ After merging release-ready changes to `main`, run the release from a clean chec ```bash pnpm install pnpm run version-packages -pnpm run release:check pnpm run release:publish all ``` @@ -35,30 +34,6 @@ pnpm run release:publish npm pnpm run release:publish extension ``` -## Local packaging - -```bash -pnpm run release:package npm -pnpm run release:package vsce -pnpm run release:package all -``` - -`core` is the `@codegraphy/core` npm package target. `extension`, `vsix`, `marketplace`, and `core-extension` target the VS Code Marketplace extension and rebuild `@codegraphy/extension` from source before staging the VSIX. npm packages are packed into `artifacts/npm/`; VS Code extensions are packed into `artifacts/vsix/`. - -Individual targets are available for debugging or partial releases: - -```bash -pnpm run release:package core -pnpm run release:package mcp -pnpm run release:package plugin-api -pnpm run release:package plugin-markdown -pnpm run release:package plugin-typescript -pnpm run release:package plugin-python -pnpm run release:package plugin-csharp -pnpm run release:package plugin-godot -pnpm run release:package extension -``` - ## Publish commands ```bash @@ -96,10 +71,9 @@ vsce verify-pat codegraphy 7. Run `pnpm run version-packages`. 8. If the VS Code Marketplace extension changed, verify `packages/extension/package.json` and [`packages/extension/CHANGELOG.md`](../packages/extension/CHANGELOG.md) have matching top entries. 9. Commit the generated version and changelog updates. -10. Run `pnpm run release:check`. -11. Publish every release target with `pnpm run release:publish all`. -12. Or publish npm packages first with `pnpm run release:publish npm`, then publish Marketplace packages with `pnpm run release:publish extension`. -13. To publish separately, publish npm packages before Marketplace packages: +10. Publish every release target with `pnpm run release:publish all`. +11. Or publish npm packages first with `pnpm run release:publish npm`, then publish Marketplace packages with `pnpm run release:publish extension`. +12. To publish separately, publish npm packages before Marketplace packages: - `pnpm run release:publish plugin-api` - `pnpm run release:publish plugin-markdown` - `pnpm run release:publish plugin-typescript` @@ -108,18 +82,17 @@ vsce verify-pat codegraphy - `pnpm run release:publish plugin-godot` - `pnpm run release:publish core` - `pnpm run release:publish mcp` -14. Publish the VS Code extension with `pnpm run release:publish extension`. -15. Open the Marketplace listing and verify the dependency text, README, icon, gallery banner, and version. -16. Verify the existing `codegraphy.codegraphy` listing has been updated in place to the new V4 release metadata. -17. Open the npm package pages for the public `@codegraphy/*` packages, then verify the README, package metadata, and repository links. +13. Publish the VS Code extension with `pnpm run release:publish extension`. +14. Open the Marketplace listing and verify the dependency text, README, icon, gallery banner, and version. +15. Verify the existing `codegraphy.codegraphy` listing has been updated in place to the new V4 release metadata. +16. Open the npm package pages for the public `@codegraphy/*` packages, then verify the README, package metadata, and repository links. ## GitHub Actions Use the `Release` workflow with `workflow_dispatch`. -- `mode=package` builds and uploads release artifacts. - `target` can be `all`, `npm`, `vsce`, `extension`, `core`, `mcp`, `plugin-api`, `plugin-markdown`, `plugin-typescript`, `plugin-python`, `plugin-csharp`, or `plugin-godot`. -- `mode=publish` runs the same checks, packages release artifacts, publishes selected Marketplace targets, and publishes selected npm packages. +- The workflow publishes the selected Marketplace targets and npm packages. Required secrets: diff --git a/docs/plans/2026-05-20-test-suite-cleanup.md b/docs/plans/2026-05-20-test-suite-cleanup.md new file mode 100644 index 000000000..7a5b9a37f --- /dev/null +++ b/docs/plans/2026-05-20-test-suite-cleanup.md @@ -0,0 +1,74 @@ +# Test Suite Cleanup + +## Goal + +Make test commands easier to understand from the root and from each package. + +The target shape is: + +- `test` means Vitest/unit-level tests for a package. +- `test:playwright` means browser tests for packages that have browser behavior. +- `test:vscode` means VS Code Electron extension-host tests for packages that need the real VS Code API. +- Root scripts compose package scripts instead of carrying package-specific details. +- Mutation testing stays a local quality-tool workflow that runs against Vitest, not Playwright or VS Code E2E. + +## Original Problems + +- Root `package.json` mixes Vitest, release-contract tests, Playwright, VS Code E2E, watch helpers, and package-specific aliases. +- The names `test:e2e` and `test:playwright` are easy to confuse. Playwright currently tests the built webview in a browser; VS Code E2E launches Electron with the extension loaded. +- Release tests under `tests/release` are separate from package test ownership and are candidates for removal or replacement with clearer build/package checks. +- Plugin packages do not yet have even coverage depth. Some packages have substantial parser/path tests, while several only have a smoke plugin test. +- CI currently runs Vitest/release tests and Playwright, but not the VS Code Electron suite. + +## Proposed Shape + +Root scripts: + +- `test` runs the full test suite that CI should trust. +- `test:unit` runs all package Vitest suites through Turbo. +- `test:playwright` runs package Playwright suites. +- `test:vscode` runs a local-only VS Code Electron smoke suite. + +Package scripts: + +- Keep `test` for Vitest in every package except `@codegraphy/plugin-api`. +- Keep `test:playwright` only where browser tests exist. +- Keep `test:vscode` only in `@codegraphy/extension`. +- Keep mutation and architecture-analysis tools under `@codegraphy/quality-tools`. + +## First Slice + +1. Rename root/package scripts so `e2e` becomes explicit `test:vscode`. +2. Remove root release-test wiring and the `tests/release` suite. +3. Update CI so the default test path includes unit and Playwright tests. +4. Preserve quality-tool commands as explicit local analysis tools. + +## First Slice Decisions + +- Root `pnpm test` now composes `test:unit` and `test:playwright`; CI does not run VS Code Electron tests. +- Playwright is the CI E2E lane for browser/webview behavior that matters before merge. +- VS Code E2E is a local sanity lane for the real Electron extension host. By default, `pnpm run test:vscode` runs a reduced smoke subset. Run `CODEGRAPHY_E2E_FULL=1 pnpm run test:vscode` for the full local suite. +- The root release checks and `tests/release` suite are removed. The release workflow publishes artifacts; there is no public package-without-publish workflow. +- CI runs separate lint, typecheck, unit-test, Playwright, and build lanes so independent work is not serialized behind the slowest suite. +- Turbo caches package builds and test logs/results through task outputs and GitHub Actions restores `.turbo` between CI runs. +- `@codegraphy/extension` owns all three test lanes because it has Vitest, browser, and VS Code behavior. +- Other packages expose only Vitest through `test` unless they grow a real browser or VS Code test surface. +- Mutation tooling stays in `@codegraphy/quality-tools` and continues to target Vitest, not Playwright or VS Code E2E. + +## Current CI Shape + +CI runs build, lint, typecheck, Playwright, and unit tests as independent jobs. Unit tests use a matrix with human-readable check names: + +- `Unit tests / Packages` runs all package Vitest suites except `@codegraphy/extension`. +- `Unit tests / Extension node` runs the extension Vitest `node` project. +- `Unit tests / Extension webview graph interaction and rendering` runs graph model, interaction, rendering, controls, and Graph Scope webview tests. +- `Unit tests / Extension webview app shell and plugins` runs webview app shell, store, plugin host/runtime, plugin panel, theme, VS Code API bridge, and webview-extension integration tests. +- `Unit tests / Extension webview panels, search, and exports` runs settings, legends, search, timeline, toolbar, generic components, tooltip, and export tests. + +The current PR run target is under 3 minutes wall-clock. The webview groups are intentionally separate because the extension webview suite is the long pole once package, lint, typecheck, build, and Playwright lanes run in parallel. + +## Follow-Up Slices + +- Add meaningful Vitest coverage for the thin plugin packages. +- Decide whether any plugin package needs Playwright tests, or whether plugin browser behavior should stay covered through extension/webview integration. +- Keep mutation runs scoped to Vitest targets so the mutation loop stays fast enough for local TDD. diff --git a/docs/plans/2026-05-21-mutation-seed-cache.md b/docs/plans/2026-05-21-mutation-seed-cache.md new file mode 100644 index 000000000..c3ce32099 --- /dev/null +++ b/docs/plans/2026-05-21-mutation-seed-cache.md @@ -0,0 +1,347 @@ +# Mutation Seed Cache Plan + +## Status + +Implemented on PR #210. + +Decision captured: CodeGraphy is a monorepo of package-level Stryker projects. Mutation seed caching should follow that boundary. The main branch seed is a `reports/mutation/` tree containing package-scoped Stryker incremental reports, not one root monorepo incremental report. + +This plan belongs to the test-suite cleanup work because it changes the local mutation loop, not the normal CI test lanes. + +## Goal + +Make package and file-scoped mutation runs fast enough for normal branch work by seeding each worktree with the latest successful mutation results from `main`. + +The command shape should stay boring: + +```bash +pnpm run mutate -- extension/ +pnpm run mutate -- packages/extension/src/webview/vscodeApi.ts +pnpm run mutate -- plugin-godot/ +``` + +The user should not need to manually find changed files, manually copy cache files, or remember separate seed commands. + +Decision: no-arg `pnpm run mutate` is invalid. Mutation requires an explicit package, directory, or file target. All-package mutation refresh belongs to the CI seed workflow. + +## Terms + +- **Package Stryker Project**: one workspace package plus the Stryker config, Vitest config, mutate globs, test includes, thresholds, and runner environment needed to mutation-test that package. +- **Stryker Incremental Report**: the JSON file Stryker reads and writes through `--incrementalFile`. Stryker owns this format and decides which mutant results can be reused. +- **Package Mutation Seed**: a CI-published Stryker Incremental Report for one Package Stryker Project. +- **Main Mutation Seed**: the full CI-published `reports/mutation/` seed tree produced from `main`. +- **Local Main Seed Cache**: the local main checkout's copy of the latest Main Mutation Seed under `reports/mutation/`. +- **Local Mutation Cache**: the current worktree's package-scoped Stryker Incremental Report under `reports/mutation//`. +- **Seed Hydration**: the quality-tool step that copies a Package Mutation Seed into a worktree before Stryker starts. + +Avoid calling this a custom mutation cache. The speedup should come from Stryker incremental mode, with CodeGraphy only supplying the first package-level incremental report file. + +## Current Repo Shape + +- Root `pnpm run mutate` calls the CodeGraphy-specific wrapper at `scripts/mutate.ts`. +- The wrapper handles package seed hydration, then delegates to the generic quality-tools mutation CLI. +- With no target argument, the generic CLI fails fast and asks for an explicit package, directory, or file target. +- A targeted command resolves to one package, directory, or file, then runs Stryker once for that package scope. +- The mutation runner currently passes package-level `--incrementalFile` paths such as `reports/mutation/extension/stryker-incremental-extension.json`. +- The shared root Stryker config covers extension and normal workspace packages. +- `packages/quality-tools` has its own Stryker and Vitest config. +- Mutation reports are ignored in git under `reports/`. +- Existing local reports are copied after a run, but new worktrees do not yet hydrate a seed before the first Stryker run. + +That means the root script is the common entrypoint, but the mutation project is selected by the target argument. + +## Stryker Behavior To Lean On + +Stryker incremental mode stores previous mutation results in an incremental report file and attempts to reuse them on the next run. It compares mutated files and test files against the prior report, then only reruns what it believes changed. + +Important consequences: + +- A warm package seed can let a new worktree skip most unchanged mutants in that package. +- Stryker still performs the initial dry run. The seed does not remove startup, instrumentation, or test discovery cost. +- `--force` should continue to bypass reuse for the requested scope. +- Vitest reports tests per file, not exact test locations, so changing one test file may invalidate more mutants than a runner with exact test-location reporting. +- Stryker does not detect every environment change, dependency change, or config change. When those change, a force refresh or new seed may be needed. + +References: + +- Stryker incremental mode: +- Stryker `incrementalFile`, `coverageAnalysis`, and `force` config: + +## Expected Workflow + +### Main + +1. CI runs the normal fast checks as it does today. +2. A separate mutation-seed job starts on every push to `main`. +3. The job restores the latest CI seed if available. +4. The job runs the full package mutation seed refresh. +5. Each package runs through its own Stryker config and package-scoped `--incrementalFile`. +6. Stryker reruns only what changed inside each package scope and updates that package's incremental report. +7. CI writes `reports/mutation/seed-sha.txt` with the `main` commit SHA. +8. CI uploads the seedable `reports/mutation/` tree as the Main Mutation Seed. + +Seed artifact shape: + +```text +reports/mutation/ + seed-sha.txt + extension/ + stryker-incremental-extension.json + plugin-godot/ + stryker-incremental-plugin-godot.json + quality-tools/ + stryker-incremental-quality-tools.json +``` + +The first successful seed may take hours. Later `main` seed refreshes should mostly reuse the previous package seeds and rerun changed mutants. + +### Local Worktree + +1. User runs a normal mutate command with a package, directory, or file target. +2. The CodeGraphy wrapper resolves the target to its Package Stryker Project. +3. If the package Local Mutation Cache already exists in the current worktree, use it. +4. Otherwise find the local checkout that is currently on the `main` branch. +5. Read `/reports/mutation/seed-sha.txt` and compare it to the commit SHA attached to the latest CI seed artifact. +6. If the Local Main Seed Cache is missing or stale, update `/reports/mutation/` from the CI seed artifact. +7. If hydration is unavailable or the package is missing from the seed, fail clearly and tell the user to run the mutation seed workflow on `main`. +8. Copy the needed package report from the Local Main Seed Cache into the current worktree's `reports/mutation//` directory. +9. The wrapper invokes the normal generic mutate runner for that package or file target. +10. Stryker runs normally with that package's `--incrementalFile`. +11. Stryker writes back only to the current worktree's package Local Mutation Cache. + +The local worktree never writes directly to the shared Main Mutation Seed or the Local Main Seed Cache. It only copies from them. Only the seed-refresh path updates the Local Main Seed Cache from CI. + +## Cache Size Estimate + +Measured local data: + +- `reports/mutation/extension/stryker-incremental-extension.json`: `242 KB` +- Contents: `208` mutants, `4` mutated files, `10` test files +- Rough density: about `1.2 KB` per mutant in this small sample + +Earlier extension-wide mutation discovery found about `22,525` extension mutants. + +Estimated extension seed: + +- `22,525 * 1.2 KB = ~27 MB` +- Safe planning range: `25-60 MB` + +Estimated full monorepo seed tree: + +- Safe planning range: `30-100 MB` +- Validate after the first full seed, because full size depends on package count, test metadata, surviving mutant data, and source/test text stored in the reports. + +Copy/download expectation: + +- Local copy on SSD: usually under a second for a small package, a few seconds for a large package like extension. +- GitHub artifact download: likely a few seconds to around 30 seconds for `25-100 MB`; allow up to a minute on a weak connection. + +That is still tiny compared with rerunning thousands of mutants. + +Publish package incremental JSON files plus `reports/mutation/seed-sha.txt`, but exclude non-seed artifacts such as HTML reports, videos, `node_modules`, and `.stryker-tmp`. + +## Is This Common? + +Using Stryker incremental mode is common and first-class Stryker behavior. + +Using CI to publish reusable seeds for local worktrees is not a special built-in Stryker workflow. It is orchestration around Stryker's documented `incrementalFile`. The pattern is still reasonable as long as the wrapper keeps Stryker responsible for deciding reuse. + +Prior art found online: + +- Stryker's official docs explicitly call out CI scenarios, the `--incrementalFile` option, the required dry run, and the fact that `--incremental` plus scoped `--mutate` preserves out-of-scope mutants in the full report. +- A Qiita article shows GitHub Actions caching `reports/stryker-incremental.json` with `actions/cache` before running `npx stryker run --incremental`. +- A Medium article shows a two-tier GitLab cache strategy: pull-only cache from the development branch plus pull-push branch cache. +- QASkills describes storing `reports/stryker-incremental.json` as a CI cache artifact for large codebases. + +The CodeGraphy plan is a monorepo package version of the same idea: CI maintains the `main` seed tree, local `main` stores a copy under `reports/mutation/`, and feature worktrees hydrate package caches from that local seed before Stryker starts. + +## Proposed Implementation + +### Implementation Slices + +1. Keep `quality-tools mutate` generic and seed-policy-free. +2. Add a CodeGraphy mutation wrapper as the root `mutate` script. +3. Move CodeGraphy-specific target requirements and seed hydration into that wrapper. +4. Add a CI helper that lists CodeGraphy's mutation projects for the GitHub Actions matrix. +5. Add the mutation-seed workflow: + - discover mutation projects + - run one package seed job per project + - upload package seed artifacts + - assemble the Main Mutation Seed artifact +6. Add local hydration: + - find the local main checkout + - compare `reports/mutation/seed-sha.txt` + - refresh the Local Main Seed Cache from CI if stale + - copy the target package seed into the current worktree +7. Update docs after the workflow proves itself. + +Status: implemented on PR #210. The root CodeGraphy wrapper lives in `scripts/mutate.ts` and `scripts/mutation/`, while the generic mutation runner remains under `packages/quality-tools/src/mutation/runner/`. + +### CI + +Add a separate GitHub Actions workflow or job for mutation seed refresh. + +Recommended shape: + +- trigger on every `push` to `main` +- support `workflow_dispatch` +- use a workflow-level concurrency group so only the latest seed refresh for `main` keeps running +- restore the previous CI seed cache before mutation +- run package mutation seed refreshes in a GitHub Actions matrix +- each matrix job runs one package-scoped command, such as `pnpm run mutate -- extension/` +- each matrix job uploads that package's updated incremental report as a package seed artifact +- a final assembly job downloads package seed artifacts, writes `reports/mutation/seed-sha.txt`, and uploads the combined Main Mutation Seed artifact +- keep artifact retention long enough for active branch work; the first implementation uses `14` days to avoid storing stale large seed artifacts indefinitely + +This should be separate from the required PR CI checks. Mutation seed refresh is a developer-speed accelerator, not a merge gate. + +Stryker does not save CI state by itself. It writes the incremental JSON file into the runner workspace path passed by `--incrementalFile`. GitHub-hosted runners are ephemeral, so the workflow must explicitly preserve those files. This plan uses two GitHub Actions mechanisms: + +- **Actions cache** for CI-to-CI speed: package matrix jobs restore the previous package seed cache before running Stryker, then save the updated package seed cache after the run. +- **Actions artifact** for local machines: the assemble job uploads a combined Main Mutation Seed artifact that local worktrees can download with `gh`. + +For this plan: + +- Each package job restores that package's previous `reports/mutation//` cache. +- Each package job runs Stryker, which updates that package's incremental JSON on disk. +- Each package job uploads its updated package report. +- The assemble job combines package reports into one Main Mutation Seed artifact. +- Local hydration later downloads that assembled artifact when local `main` is missing or stale. + +Do not use no-arg `pnpm run mutate` for CI seed refresh. That command is intentionally invalid. A package matrix lets CI run independent package Stryker projects on separate runners, so wall-clock time trends toward the slowest package plus artifact assembly instead of the sum of all packages. + +The CI seed workflow should own all-package orchestration at the GitHub Actions matrix level. Parallelizing packages inside one runner would compete for the same CPU and memory; matrix jobs give each package its own runner while Stryker still manages mutant-level concurrency inside that package. + +### CodeGraphy Wrapper + +Add seed hydration before invoking the generic quality-tools mutate runner. + +Implementation lives in: + +- `scripts/mutate.ts` +- `scripts/mutation/codegraphyMutate.ts` +- `scripts/mutation/seedCache.ts` +- `packages/quality-tools/src/cli/listMutationPackages.ts` + +Suggested responsibilities: + +- resolve the target package and package incremental path in one module +- check for an existing package Local Mutation Cache first +- find the Local Main Seed Cache if present +- determine whether the Local Main Seed Cache is current with the latest CI seed artifact +- download the latest CI seed artifact with `gh` when authenticated and the Local Main Seed Cache is missing or stale +- update the Local Main Seed Cache from the downloaded artifact +- copy the target package's incremental report and `seed-sha.txt` from the Local Main Seed Cache into the worktree's `reports/mutation/` tree +- print a concise status line: + - local package cache hit + - hydrated package cache from Local Main Seed Cache + - refreshed Local Main Seed Cache from CI artifact + - cold run because no seed was available for this package + +The wrapper should never let branch worktrees write mutation results back into the Local Main Seed Cache. Branch worktrees update only their own package Local Mutation Cache. + +The generic quality-tools mutate runner should stay seed-policy-free so it can later be extracted for normal single-project repos. + +## Edge Cases To Design + +- No `gh` auth or offline local run. +- First-ever seed has not been generated: fail clearly and tell the user to run the mutation seed workflow on `main`. +- The latest seed is stale because the seed workflow failed on `main`. +- Two local mutation runs target the same package cache at once. +- A package is renamed, added, or removed. +- A package changes Stryker config, Vitest config, dependencies, or environment. +- Large test-file changes cause broad invalidation because Vitest incremental support is file-level, not test-location-level. +- Seed artifact retention expires. +- CI full mutation seed refresh is too expensive to run on every push to `main`. + +## Validation Plan + +### Unit Tests + +- Wrapper target parsing: + - no-arg CodeGraphy wrapper fails clearly + - package target resolves to the expected package seed path + - file target resolves to the owning package seed path + - `--force` still hydrates first and passes force through to Stryker +- Local main checkout discovery: + - current checkout on `main` + - separate worktree on `refs/heads/main` + - no main worktree found +- Seed staleness: + - matching `seed-sha.txt` skips download + - mismatched `seed-sha.txt` refreshes the Local Main Seed Cache + - missing local seed checks CI + - missing local and CI seed fails clearly +- Seed copying: + - copies only the target package incremental report into the worktree + - never writes branch results back into the Local Main Seed Cache + +### Local Integration Checks + +- `pnpm run mutate` fails with an explicit target-required message. +- `pnpm run mutate -- packages/extension/src/webview/vscodeApi.ts` succeeds with a pre-seeded extension cache. +- Remove the worktree extension cache, seed local main with a copied CI-like artifact, rerun the same file target, and verify Stryker reports reused mutants. +- Run the same file target a second time and verify it uses the worktree-local cache without rehydrating. +- Run `pnpm run mutate -- --force packages/extension/src/webview/vscodeApi.ts` and verify Stryker reruns the scoped mutants while leaving the hydrated cache in place for later runs. + +### Pre-Merge Package Validation + +Before merging PR #210, each discovered mutation package was run locally through the root command with a five-minute cap: + +```bash +GITHUB_REF_NAME=main pnpm run mutate -- / +``` + +`GITHUB_REF_NAME=main` was used only for this pre-merge validation because the new mutation-seed workflow does not exist on `main` until the PR lands. That forces the wrapper through the same cold-run path the `main` seed workflow will use, without requiring a seed artifact that cannot exist yet. + +Results from the repeat validation after fixing the quality-tools dry-run failure: + +| Package | Result | +| --- | --- | +| `core` | reached 5-minute cap after successful package dry run | +| `mcp` | completed, reused `492` of `516` mutants | +| `plugin-csharp` | completed, reused `2` of `2` mutants | +| `plugin-godot` | reached 5-minute cap after successful package dry run | +| `plugin-markdown` | completed, reused `109` of `117` mutants | +| `plugin-python` | completed, reused `2` of `2` mutants | +| `plugin-typescript` | completed, reused `2` of `2` mutants | +| `quality-tools` | reached 5-minute cap after successful `1,560` test dry run | +| `extension` | reached 5-minute cap after successful `5,436` test dry run | + +The first validation found a real `quality-tools` issue: seed-cache tests were affected by CI's `GITHUB_REF_NAME=main` environment while they were mocking branch detection. The fix made the seed hydration environment injectable so production still uses `process.env`, while tests can remain deterministic under Stryker. + +### CI Checks + +- Run the mutation-seed workflow manually on the PR branch against a small package first if possible. +- Verify package matrix jobs run in parallel. +- Verify each package job uploads exactly its package incremental report. +- Verify the assemble job creates a Main Mutation Seed artifact shaped like: + + ```text + reports/mutation/ + seed-sha.txt + / + stryker-incremental-.json + ``` + +- Verify a second workflow run restores the previous seed and reuses results instead of cold-running every mutant. +- Verify normal PR CI is unaffected and does not wait on mutation seed refresh. + +### Timing Acceptance + +- First full `main` seed may take hours. +- Warm package seed jobs should trend toward dry-run/setup time plus changed mutants. +- Warm local file-scoped mutation should stay close to the current single-file warm loop. +- Warm all-package seed refresh wall time should trend toward the slowest package job, not the sum of all packages. + +## Decisions + +- Accepted: every push to `main` starts a mutation-seed refresh that restores the previous CI seed, refreshes package-level Stryker incremental reports, and republishes the updated seed tree. +- Accepted: the CI seed refresh should run package mutation jobs in parallel with a GitHub Actions matrix, then assemble the combined seed artifact. +- Accepted: local targeted `pnpm run mutate` commands first use the worktree-local package cache, then hydrate a missing package cache from the CI seed before Stryker starts. +- Accepted: local hydration uses the local main checkout's `reports/mutation/` seed tree first and downloads the CI seed artifact only when that local copy is missing or stale. +- Accepted: local seed staleness is decided by comparing the Local Main Seed Cache's stored commit SHA to the latest CI seed artifact's commit SHA. +- Accepted: the Local Main Seed Cache is the local main checkout's `reports/mutation/` tree, with `seed-sha.txt` at the root and package incremental reports under package directories. +- Accepted: no-arg `pnpm run mutate` is invalid; users must run `pnpm run mutate -- `. +- Accepted: locating the local main checkout means finding the worktree currently on `main` so the wrapper can read/write `/reports/mutation/seed-sha.txt` and package seed files. +- Accepted: the CI seed workflow chooses its matrix with `pnpm exec tsx packages/quality-tools/src/cli/listMutationPackages.ts --json`, which reuses the same mutation package discovery rules as the local mutation profile. diff --git a/docs/quality/README.md b/docs/quality/README.md index 092e678da..d925a4370 100644 --- a/docs/quality/README.md +++ b/docs/quality/README.md @@ -1,6 +1,6 @@ # Quality Tools -CodeGraphy uses five complementary quality checks: +CodeGraphy uses six complementary quality checks: - `Organize`: directory structure, file naming, and cohesion analysis - `Boundaries`: dependency-layer sources and runtime/package boundary enforcement @@ -42,7 +42,7 @@ Current command expectations: - `mutate` expects a package root or a path inside `src/` - `scrap` works best on package roots and test files/directories -Use scoped mutation for changed source modules during normal work. Full mutation is intentionally expensive; prefer a file or feature-folder target that maps to the behavior being changed. +Use scoped mutation for changed source modules during normal work. Full mutation is intentionally expensive; prefer a file or feature-folder target that maps to the behavior being changed. CI's Vitest split does not automatically shard Stryker mutation runs; mutation speed still depends on target scope, Stryker incremental state, and the Vitest tests selected for the mutation target. Implementation now lives in `packages/quality-tools/`. diff --git a/docs/quality/mutation.md b/docs/quality/mutation.md index 7e953206b..06f64b774 100644 --- a/docs/quality/mutation.md +++ b/docs/quality/mutation.md @@ -12,14 +12,26 @@ Examples: ```bash pnpm run mutate -- extension/ +pnpm run mutate -- extension src/webview/components/NodeTooltip.tsx pnpm run mutate -- extension/src/webview/components/NodeTooltip.tsx pnpm run mutate -- quality-tools/ ``` +`pnpm run mutate` without a package, directory, or file target is intentionally invalid. Pick the package or source scope that owns the behavior under test. + +Scoped file calls can either use a repo-relative path or `PACKAGE FILE`. The `PACKAGE FILE` form resolves the file inside the package before delegating to the generic mutation runner. + Mutation scope is defined in the repo root [quality.config.json](../../quality.config.json). The Stryker config files now only carry runner settings like the Vitest config path and reporters. Operational notes: -- `pnpm run mutate` runs all supported packages and can take hours. +- The CI mutation-seed workflow is responsible for orchestrating all-package mutation refreshes. Local mutation commands require an explicit package, directory, or file target. +- Root `pnpm run mutate` is a CodeGraphy wrapper: it hydrates a missing package incremental report from the latest `main` seed, then delegates to the generic `@codegraphy/quality-tools` mutation runner. +- The local seed cache lives under the local `main` checkout at `reports/mutation/`. Feature worktrees copy package seeds from there into their own `reports/mutation//` directory and never write mutation results back to `main`. +- The first successful mutation-seed workflow on `main` may take hours. Later refreshes restore package caches and should mostly rerun changed mutants. - The extension package uses a longer Stryker dry-run timeout because its initial instrumented Vitest startup is materially slower than a normal test run. +- The CI unit-test matrix does not automatically speed up mutation runs. Stryker launches its own Vitest runner, so local mutation speed comes from scoped targets, focused test includes, and Stryker's package-level incremental reports under `reports/mutation//`. +- The mutation runner prints a progress heartbeat every 60 seconds while Stryker is still running. +- Extension mutation defaults to two Stryker workers and reuses Vitest runners instead of restarting one after every mutant. Override with `CODEGRAPHY_STRYKER_CONCURRENCY` or `CODEGRAPHY_STRYKER_MAX_TEST_RUNNER_REUSE` when debugging runner isolation. +- Mutation targets run directly through Stryker incremental mode without a separate typecheck preflight. Pass `--force` to rerun the mutants in scope. - Prefer package- or file-scoped mutation runs during development. diff --git a/package.json b/package.json index 172fad0c1..0f12b5a97 100644 --- a/package.json +++ b/package.json @@ -6,44 +6,26 @@ "packages/*" ], "scripts": { - "vscode:prepublish": "pnpm run build", "build": "turbo run build", - "build:devhost": "pnpm --filter @codegraphy/extension run build && pnpm --filter @codegraphy/plugin-typescript run build && pnpm --filter @codegraphy/plugin-python run build && pnpm --filter @codegraphy/plugin-csharp run build && pnpm --filter @codegraphy/plugin-godot run build", - "build:extension": "pnpm --filter @codegraphy/extension run build:extension", - "build:webview": "pnpm --filter @codegraphy/extension run build:webview", "watch": "pnpm --filter @codegraphy/extension run watch", - "watch:extension": "pnpm --filter @codegraphy/extension run watch:extension", - "watch:webview": "pnpm --filter @codegraphy/extension run watch:webview", "dev": "pnpm --filter @codegraphy/extension run dev", - "test": "turbo run test && pnpm run test:release", - "test:playwright": "pnpm --filter @codegraphy/extension run test:playwright", - "test:playwright:ui": "pnpm --filter @codegraphy/extension run test:playwright:ui", - "playwright:install": "pnpm --filter @codegraphy/extension run playwright:install", - "test:watch": "pnpm --filter @codegraphy/extension run test:watch", - "lint": "turbo run lint && pnpm run lint:playwright", - "lint:playwright": "pnpm --filter @codegraphy/extension run lint:playwright", + "test": "pnpm run test:unit && pnpm run test:playwright", + "test:unit": "turbo run test", + "test:playwright": "turbo run test:playwright --filter=@codegraphy/extension", + "test:vscode": "pnpm -r --if-present run test:vscode", + "lint": "turbo run lint", "crap": "pnpm --filter @codegraphy/quality-tools run crap --", "boundaries": "pnpm --filter @codegraphy/quality-tools run boundaries --", "reachability": "pnpm --filter @codegraphy/quality-tools run reachability --", - "mutate": "pnpm --filter @codegraphy/quality-tools run mutate --", + "mutate": "tsx scripts/mutate.ts", "scrap": "pnpm --filter @codegraphy/quality-tools run scrap --", "organize": "pnpm --filter @codegraphy/quality-tools run organize --", "changeset": "changeset", "version-packages": "changeset version", - "package:vsix": "node scripts/release-core.mjs package", "publish:vsce": "node scripts/release-core.mjs publish", - "release:check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", - "release:package": "node scripts/release.mjs package", "release:publish": "node scripts/release.mjs publish", - "test:release": "node --test tests/release/*.test.mjs", "typecheck": "turbo run typecheck", - "typecheck:extension": "pnpm --filter @codegraphy/extension run typecheck", - "typecheck:core": "pnpm --filter @codegraphy/core run typecheck", - "typecheck:plugin-api": "pnpm --filter @codegraphy/plugin-api run typecheck", - "typecheck:plugins": "turbo run typecheck --filter=@codegraphy/plugin-typescript --filter=@codegraphy/plugin-python --filter=@codegraphy/plugin-csharp --filter=@codegraphy/plugin-godot --filter=@codegraphy/plugin-markdown", - "prepare": "husky", - "test:e2e": "pnpm run build:e2e && node dist-e2e/extension/src/e2e/runTest.js", - "build:e2e": "pnpm run build && tsc -p packages/extension/src/e2e/tsconfig.json" + "prepare": "husky" }, "displayName": "CodeGraphy", "description": "Visualize your codebase as an interactive dependency graph inside VS Code", diff --git a/packages/extension/docs/testing.md b/packages/extension/docs/testing.md index 246c0910d..6d74d8dfc 100644 --- a/packages/extension/docs/testing.md +++ b/packages/extension/docs/testing.md @@ -1,9 +1,11 @@ # Testing -The package uses three layers of tests: +The package uses several layers of tests: -- unit tests for core helpers and pure runtime modules -- integration-style tests for provider and webview wiring +- Vitest node tests for extension-host, core, shared, and pure runtime modules +- Vitest webview tests for React/webview behavior and browser-like integration seams +- Playwright tests for built webview behavior that should run in CI +- VS Code Electron tests for local validation of the real extension host - mutation-focused tests for old survivor hot spots ## Current expectations @@ -13,17 +15,33 @@ The package uses three layers of tests: - Split large test files when they stop being easy to reason about. - Keep mutation tests focused on one source file or seam at a time. - Add regression coverage before changing provider lifecycle, bridge, or plugin readiness seams. +- Treat VS Code Electron E2E as local-only smoke coverage. CI should rely on Vitest and Playwright for merge gating. ## Useful commands ```bash pnpm --filter @codegraphy/extension test +pnpm --filter @codegraphy/extension run test:node +pnpm --filter @codegraphy/extension run test:webview +pnpm --filter @codegraphy/extension run test:playwright +pnpm --filter @codegraphy/extension run test:vscode pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/runtime/events.test.tsx pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/graphViewProvider.bootstrap.test.ts pnpm --filter @codegraphy/extension lint pnpm --filter @codegraphy/extension typecheck ``` +CI runs extension unit tests as separate `node` and grouped `webview` Vitest lanes. The webview groups are defined in `vitest.includes.ts` because the check names should describe the behavior under test, not an arbitrary shard number. + +```bash +pnpm exec turbo run test:node --filter=@codegraphy/extension +CODEGRAPHY_VITEST_WEBVIEW_GROUP=graph pnpm exec turbo run test:webview --filter=@codegraphy/extension +CODEGRAPHY_VITEST_WEBVIEW_GROUP=appPlugins pnpm exec turbo run test:webview --filter=@codegraphy/extension +CODEGRAPHY_VITEST_WEBVIEW_GROUP=panelsExport pnpm exec turbo run test:webview --filter=@codegraphy/extension +``` + +Mutation runs do not reuse the CI groups automatically. `pnpm run mutate -- extension/src/...` still uses Stryker's Vitest runner with focused includes, so mutation speed comes from narrowing the target and from Stryker incremental state rather than from the GitHub Actions matrix. + ## Test organization - Prefer tests near the concern they cover. diff --git a/packages/extension/package.json b/packages/extension/package.json index 2b04d9199..eb2f94728 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -11,17 +11,17 @@ "build": "pnpm run build:extension && pnpm run build:webview", "build:extension": "tsx scripts/buildExtension.ts", "build:webview": "vite build --config vite.config.ts", + "build:vscode": "pnpm -w exec turbo run build --filter=@codegraphy/extension... --filter=@codegraphy/plugin-typescript --filter=@codegraphy/plugin-godot && tsc -p src/e2e/tsconfig.json", "watch": "concurrently \"pnpm run watch:extension\" \"pnpm run watch:webview\"", "watch:extension": "tsx scripts/buildExtension.ts --watch", "watch:webview": "vite build --config vite.config.ts --watch", "dev": "pnpm run watch", "test": "vitest run --config vitest.config.ts", + "test:node": "vitest run --config vitest.config.ts --project node", + "test:webview": "vitest run --config vitest.config.ts --project webview", "test:playwright": "playwright test --config playwright.config.ts", - "test:playwright:ui": "playwright test --config playwright.config.ts --ui", - "playwright:install": "playwright install chromium", - "test:watch": "vitest --config vitest.config.ts", + "test:vscode": "pnpm run build:vscode && node dist-e2e/extension/src/e2e/runTest.js", "lint": "eslint \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" \"tests/**/*.{ts,tsx}\"", - "lint:playwright": "eslint tests/playwright --max-warnings=0", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.tests.json && tsc --noEmit -p tests/playwright/tsconfig.json" }, "dependencies": { diff --git a/packages/extension/src/e2e/runTest.ts b/packages/extension/src/e2e/runTest.ts index ea3a6f5ff..5b8b07a60 100644 --- a/packages/extension/src/e2e/runTest.ts +++ b/packages/extension/src/e2e/runTest.ts @@ -4,7 +4,8 @@ * Launches a real VS Code instance with the extension loaded and runs the * Mocha test suite against it. Tests have access to the full `vscode` API. * - * Run with: pnpm run test:e2e + * Run smoke subset with: pnpm run test:vscode + * Run full local suite with: CODEGRAPHY_E2E_FULL=1 pnpm run test:vscode */ import * as path from 'path'; import * as fs from 'fs'; @@ -12,6 +13,24 @@ import * as os from 'os'; import { runTests } from '@vscode/test-electron'; import { e2eScenarios } from './scenarios'; +const CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME = '@codegraphy/plugin-markdown'; +const DEFAULT_MAX_FILES = 1000; +const DEFAULT_INCLUDE = ['**/*']; + +function findRepoRoot(startDir: string): string { + let currentDir = startDir; + + while (currentDir !== path.dirname(currentDir)) { + if (fs.existsSync(path.join(currentDir, 'pnpm-workspace.yaml'))) { + return currentDir; + } + + currentDir = path.dirname(currentDir); + } + + throw new Error(`Unable to locate repo root from ${startDir}`); +} + function cleanupScenarioArtifacts( workspacePath: string, hadGitignore: boolean, @@ -24,32 +43,171 @@ function cleanupScenarioArtifacts( } } +interface CodeGraphyPluginPackageJson { + name?: unknown; + version?: unknown; + codegraphy?: { + apiVersion?: unknown; + disclosures?: unknown; + }; +} + +type CodeGraphyPluginDisclosure = + | 'network' + | 'secrets' + | 'externalProcesses' + | 'workspaceWrites' + | 'outsideWorkspaceWrites' + | 'extraFileReads'; + +interface CodeGraphyInstalledPluginRecord { + package: string; + version: string; + apiVersion: string; + disclosures: CodeGraphyPluginDisclosure[]; + packageRoot: string; +} + +interface CodeGraphyWorkspacePluginSettings { + package: string; +} + +interface CodeGraphyWorkspaceSettings { + version: 1; + maxFiles: number; + include: string[]; + respectGitignore: boolean; + showOrphans: boolean; + filterPatterns: string[]; + disabledCustomFilterPatterns: string[]; + plugins: CodeGraphyWorkspacePluginSettings[]; +} + +function writeJsonFile(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function createInitialWorkspaceSettings( + pluginRecords: readonly CodeGraphyInstalledPluginRecord[], +): CodeGraphyWorkspaceSettings { + return { + version: 1, + maxFiles: DEFAULT_MAX_FILES, + include: DEFAULT_INCLUDE, + respectGitignore: true, + showOrphans: true, + filterPatterns: [], + disabledCustomFilterPatterns: [], + plugins: [ + { package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME }, + ...pluginRecords.map(plugin => ({ package: plugin.package })), + ], + }; +} + +function writeScenarioInstalledPluginCache( + homeDir: string, + pluginRecords: readonly CodeGraphyInstalledPluginRecord[], +): void { + writeJsonFile(path.join(homeDir, '.codegraphy', 'plugins.json'), { + version: 1, + plugins: pluginRecords, + }); +} + +function writeScenarioWorkspaceSettings( + workspacePath: string, + pluginRecords: readonly CodeGraphyInstalledPluginRecord[], +): void { + writeJsonFile( + path.join(workspacePath, '.codegraphy', 'settings.json'), + createInitialWorkspaceSettings(pluginRecords), + ); +} + +function readScenarioPluginRecord( + repoRoot: string, + packageRelativePath: string, +): CodeGraphyInstalledPluginRecord { + const packageRoot = path.resolve(repoRoot, packageRelativePath); + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), + ) as CodeGraphyPluginPackageJson; + const packageName = typeof packageJson.name === 'string' ? packageJson.name : ''; + const version = typeof packageJson.version === 'string' ? packageJson.version : ''; + const apiVersion = typeof packageJson.codegraphy?.apiVersion === 'string' + ? packageJson.codegraphy.apiVersion + : ''; + const disclosures = Array.isArray(packageJson.codegraphy?.disclosures) + ? packageJson.codegraphy.disclosures.filter((entry): entry is CodeGraphyInstalledPluginRecord['disclosures'][number] => + entry === 'network' + || entry === 'secrets' + || entry === 'externalProcesses' + || entry === 'workspaceWrites' + || entry === 'outsideWorkspaceWrites' + || entry === 'extraFileReads', + ) + : []; + + if (!packageName || !version || !apiVersion) { + throw new Error(`Invalid CodeGraphy plugin package fixture at ${packageRoot}`); + } + + return { + package: packageName, + version, + apiVersion, + disclosures, + packageRoot, + }; +} + +function writeScenarioPluginState( + repoRoot: string, + workspacePath: string, + homeDir: string, + packageRelativePaths: readonly string[], +): void { + const pluginRecords = packageRelativePaths.map(relativePath => + readScenarioPluginRecord(repoRoot, relativePath), + ); + + writeScenarioInstalledPluginCache(homeDir, pluginRecords); + writeScenarioWorkspaceSettings(workspacePath, pluginRecords); +} + async function main(): Promise { - const repoRoot = path.resolve(__dirname, '../../../..'); - // The compiled Mocha suite entry point - const extensionTestsPath = path.resolve(__dirname, './suite/run'); + const repoRoot = findRepoRoot(__dirname); + const extensionTestsPath = path.resolve( + repoRoot, + 'packages/extension/dist-e2e/extension/src/e2e/suite/run', + ); for (const scenario of e2eScenarios) { const vscodeProfilePath = fs.mkdtempSync( path.join(os.tmpdir(), `codegraphy-e2e-${scenario.name.replace(/[^a-z0-9-]/gi, '-')}-`), ); + const homeDir = path.join(vscodeProfilePath, 'home'); const userDataPath = path.join(vscodeProfilePath, 'u'); const extensionsPath = path.join(vscodeProfilePath, 'e'); - const extensionDevelopmentPath = [ - repoRoot, - ...scenario.pluginDevelopmentRelativePaths.map((relativePath) => - path.resolve(repoRoot, relativePath), - ), - ]; const workspacePath = path.resolve(repoRoot, scenario.workspaceRelativePath); const hadGitignore = fs.existsSync(path.join(workspacePath, '.gitignore')); try { + cleanupScenarioArtifacts(workspacePath, hadGitignore); + writeScenarioPluginState( + repoRoot, + workspacePath, + homeDir, + scenario.pluginDevelopmentRelativePaths, + ); await runTests({ - extensionDevelopmentPath, + extensionDevelopmentPath: repoRoot, extensionTestsPath, extensionTestsEnv: { CODEGRAPHY_E2E_SCENARIO: scenario.name, + HOME: homeDir, }, launchArgs: [ workspacePath, @@ -57,6 +215,12 @@ async function main(): Promise { userDataPath, '--extensions-dir', extensionsPath, + '--use-inmemory-secretstorage', + '--sync', + 'off', + '--disable-telemetry', + '--disable-updates', + '--disable-workspace-trust', // Disable other extensions so they don't interfere '--disable-extensions', // Don't show the welcome tab diff --git a/packages/extension/src/e2e/scenarios.ts b/packages/extension/src/e2e/scenarios.ts index d80f0d16f..be7e49a80 100644 --- a/packages/extension/src/e2e/scenarios.ts +++ b/packages/extension/src/e2e/scenarios.ts @@ -131,7 +131,7 @@ export const e2eScenarios: E2EScenario[] = [ 'scripts/player.gd', 'scripts/utils/math_helpers.gd', ], - excludedAtDepthTwo: ['project.godot'], + excludedAtDepthTwo: ['scripts/orphan.gd'], selectedNodeId: 'scripts/orphan.gd', selectedNodeDepthOneNodeIds: ['scripts/orphan.gd'], selectedNodeDepthOneEdgeIds: [], diff --git a/packages/extension/src/e2e/suite/fileops.test.ts b/packages/extension/src/e2e/suite/fileops.test.ts index 0b066886e..0b8ae4fc1 100644 --- a/packages/extension/src/e2e/suite/fileops.test.ts +++ b/packages/extension/src/e2e/suite/fileops.test.ts @@ -57,14 +57,13 @@ suite('File Ops: Graph refresh', function () { test('creating a new file triggers graph refresh', async function() { const api = await getAPI(); await vscode.commands.executeCommand('codegraphy.open'); + await api.dispatchWebviewMessage({ type: 'UPDATE_DEPTH_MODE', payload: { depthMode: false } }); await sleep(3_000); const folders = vscode.workspace.workspaceFolders; assert.ok(folders && folders.length > 0, 'Workspace folder required'); workspaceRoot = folders[0].uri.fsPath; tmpFile = path.join(workspaceRoot, ...scenario.tempFileRelativePath.split('/')); - const previousNodeCount = api.getGraphData().nodes.length; - const updatePromise = waitForGraphUpdate(api); fs.writeFileSync(tmpFile, scenario.tempFileContents); await updatePromise; @@ -72,8 +71,8 @@ suite('File Ops: Graph refresh', function () { const graphData = api.getGraphData(); assert.ok( - graphData.nodes.length > previousNodeCount, - `Expected node count to increase after creating a file. Before=${previousNodeCount}, After=${graphData.nodes.length}` + graphData.nodes.some((node) => String(node.id).includes('__e2e_temp__')), + `Expected graph to include the created temp file. Nodes=${graphData.nodes.map(node => String(node.id)).join(', ')}` ); }); diff --git a/packages/extension/src/e2e/suite/graph.test.ts b/packages/extension/src/e2e/suite/graph.test.ts index 80f1c1223..f3b4959fc 100644 --- a/packages/extension/src/e2e/suite/graph.test.ts +++ b/packages/extension/src/e2e/suite/graph.test.ts @@ -33,16 +33,59 @@ const scenario = getCurrentE2EScenario(); let indexedGraphPromise: Promise | undefined; let discoveredGraphPromise: Promise | undefined; +function sortedStrings(values: readonly string[]): string[] { + return [...values].sort(); +} + +function getFileNodeIds(graphData: import('../../shared/graph/contracts').IGraphData): string[] { + return sortedStrings( + graphData.nodes + .map(node => String(node.id)) + .filter(nodeId => !nodeId.includes('#')), + ); +} + +function getFileEdgeIds(graphData: import('../../shared/graph/contracts').IGraphData): string[] { + return sortedStrings( + graphData.edges + .filter(edge => !String(edge.from).includes('#') && !String(edge.to).includes('#')) + .map(edge => String(edge.id)), + ); +} + +function assertIncludesAll( + actualIds: readonly string[], + expectedIds: readonly string[], + label: string, +): void { + const missingIds = expectedIds.filter(expectedId => !actualIds.includes(expectedId)); + assert.deepStrictEqual(missingIds, [], `${label} missing from ${actualIds.join(', ')}`); +} + +function edgeIdMatchesExpected(actualId: string, expectedId: string): boolean { + return actualId === expectedId || actualId.startsWith(`${expectedId}:`); +} + +function assertIncludesAllEdges( + actualIds: readonly string[], + expectedIds: readonly string[], + label: string, +): void { + const missingIds = expectedIds.filter( + expectedId => !actualIds.some(actualId => edgeIdMatchesExpected(actualId, expectedId)), + ); + assert.deepStrictEqual(missingIds, [], `${label} missing from ${actualIds.join(', ')}`); +} + suite('Graph: Workspace Analysis', function () { this.timeout(60_000); - test('fresh open shows discovered file nodes before indexing', async function() { + test('fresh open shows file nodes', async function() { const api = await getAPI(); await ensureDiscoveredGraph(api); const graphData = api.getGraphData(); - assert.ok(graphData.nodes.length > 0, `Expected discovered nodes, got ${graphData.nodes.length}`); - assert.strictEqual(graphData.edges.length, 0, 'Fresh-open graph should not have connections yet'); + assert.ok(graphData.nodes.length > 0, `Expected graph nodes, got ${graphData.nodes.length}`); console.log( `[e2e] Fresh graph has ${graphData.nodes.length} node(s) and ${graphData.edges.length} edge(s)` @@ -72,12 +115,11 @@ suite('Graph: Workspace Analysis', function () { const graphData = api.getGraphData(); const edgeIds = graphData.edges.map((edge) => String(edge.id)); - for (const edgeId of scenario.minimumExpectedEdgeIds) { - assert.ok( - edgeIds.includes(edgeId), - `Expected edge '${edgeId}' in scenario '${scenario.name}'. Got: ${edgeIds.join(', ')}` - ); - } + assertIncludesAllEdges( + edgeIds, + scenario.minimumExpectedEdgeIds, + `Scenario '${scenario.name}' edges`, + ); }); test('manual refresh keeps the graph indexed and rebuilds scenario edges', async function() { @@ -91,12 +133,11 @@ suite('Graph: Workspace Analysis', function () { const graphData = api.getGraphData(); const edgeIds = graphData.edges.map((edge) => String(edge.id)); - for (const edgeId of scenario.minimumExpectedEdgeIds) { - assert.ok( - edgeIds.includes(edgeId), - `Expected edge '${edgeId}' after refresh in scenario '${scenario.name}'. Got: ${edgeIds.join(', ')}` - ); - } + assertIncludesAllEdges( + edgeIds, + scenario.minimumExpectedEdgeIds, + `Scenario '${scenario.name}' refreshed edges`, + ); }); test('scenario edges are detected between fixture files', async function() { @@ -105,12 +146,11 @@ suite('Graph: Workspace Analysis', function () { const graphData = api.getGraphData(); const edgeIds = graphData.edges.map((edge) => String(edge.id)); - for (const edgeId of scenario.minimumExpectedEdgeIds) { - assert.ok( - edgeIds.includes(edgeId), - `Expected edge '${edgeId}' in scenario '${scenario.name}'. Got: ${edgeIds.join(', ')}` - ); - } + assertIncludesAllEdges( + edgeIds, + scenario.minimumExpectedEdgeIds, + `Scenario '${scenario.name}' detected fixture edges`, + ); console.log(`[e2e:${scenario.name}] Edges:`, graphData.edges.map((e) => `${e.from} → ${e.to}`).join(', ')); }); @@ -190,15 +230,34 @@ async function ensureIndexedGraph(api: CodeGraphyAPI): Promise { async function ensureDiscoveredGraph(api: CodeGraphyAPI): Promise { discoveredGraphPromise ??= (async () => { - const indexStatus = waitForGraphIndexStatus(api, false, 15_000); - const graphUpdated = waitForGraphDataUpdate(api); await vscode.commands.executeCommand('codegraphy.open'); - await Promise.all([indexStatus, graphUpdated]); + await waitForDiscoveredGraph(api); })(); await discoveredGraphPromise; } +async function waitForDiscoveredGraph( + api: CodeGraphyAPI, + timeoutMs = 15_000, +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const graphData = api.getGraphData(); + if (graphData.nodes.length > 0) { + return graphData; + } + + await sleep(250); + } + + const graphData = api.getGraphData(); + throw new Error( + `Timed out waiting for discovered graph: ${graphData.nodes.length} node(s), ${graphData.edges.length} edge(s)`, + ); +} + function waitForWebviewMessage( api: CodeGraphyAPI, type: string, @@ -453,16 +512,21 @@ suite('Graph: Depth Mode', function () { const depthOnePromise = waitForGraphDataUpdate(api); await setDepthMode(api, true); const depthOneGraph = await depthOnePromise; - const depthOneNodeIds = depthOneGraph.nodes.map((node) => String(node.id)).sort(); + const depthOneNodeIds = getFileNodeIds(depthOneGraph); - assert.deepStrictEqual(depthOneNodeIds, scenario.depth.depthOneNodeIds); - assert.deepStrictEqual( - depthOneGraph.edges.map((edge) => String(edge.id)).sort(), - scenario.depth.depthOneEdgeIds, + assertIncludesAll( + depthOneNodeIds, + sortedStrings(scenario.depth.depthOneNodeIds), + 'Depth 1 file nodes', + ); + assertIncludesAllEdges( + getFileEdgeIds(depthOneGraph), + sortedStrings(scenario.depth.depthOneEdgeIds), + 'Depth 1 file edges', ); const depthOneBounds = await requestNodeBounds(api); - assert.strictEqual(depthOneBounds.length, depthOneGraph.nodes.length); + assert.strictEqual(depthOneBounds.length, getFileNodeIds(depthOneGraph).length); const depthTwoPromise = waitForGraphDataUpdate(api); await api.dispatchWebviewMessage({ @@ -470,8 +534,12 @@ suite('Graph: Depth Mode', function () { payload: { depthLimit: 2 }, }); const depthTwoGraph = await depthTwoPromise; - const depthTwoNodeIds = depthTwoGraph.nodes.map((node) => String(node.id)).sort(); - assert.deepStrictEqual(depthTwoNodeIds, scenario.depth.depthTwoNodeIds); + const depthTwoNodeIds = getFileNodeIds(depthTwoGraph); + assertIncludesAll( + depthTwoNodeIds, + sortedStrings(scenario.depth.depthTwoNodeIds), + 'Depth 2 file nodes', + ); for (const excludedNodeId of scenario.depth.excludedAtDepthTwo) { assert.ok( !depthTwoNodeIds.includes(excludedNodeId), @@ -480,7 +548,7 @@ suite('Graph: Depth Mode', function () { } const depthTwoBounds = await requestNodeBounds(api); - assert.strictEqual(depthTwoBounds.length, depthTwoGraph.nodes.length); + assert.strictEqual(depthTwoBounds.length, getFileNodeIds(depthTwoGraph).length); await setDepthMode(api, false); }); @@ -504,16 +572,16 @@ suite('Graph: Depth Mode', function () { const selectedNodeGraph = await selectedNodeGraphPromise; assert.deepStrictEqual( - selectedNodeGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.selectedNodeDepthOneNodeIds, + getFileNodeIds(selectedNodeGraph), + sortedStrings(scenario.depth.selectedNodeDepthOneNodeIds), ); assert.deepStrictEqual( - selectedNodeGraph.edges.map(edge => String(edge.id)).sort(), - scenario.depth.selectedNodeDepthOneEdgeIds, + getFileEdgeIds(selectedNodeGraph), + sortedStrings(scenario.depth.selectedNodeDepthOneEdgeIds), ); const renderedBounds = await requestNodeBounds(api); - assert.strictEqual(renderedBounds.length, selectedNodeGraph.nodes.length); + assert.strictEqual(renderedBounds.length, getFileNodeIds(selectedNodeGraph).length); await setDepthMode(api, false); }); @@ -542,9 +610,10 @@ suite('Graph: Depth Mode', function () { }); const depthGraph = await depthLimitResetPromise; - assert.deepStrictEqual( - depthGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.depthOneNodeIds, + assertIncludesAll( + getFileNodeIds(depthGraph), + sortedStrings(scenario.depth.depthOneNodeIds), + 'Depth 1 file nodes before selected-node re-root', ); const selectedNodeGraphPromise = waitForGraphDataUpdate(api); @@ -555,16 +624,16 @@ suite('Graph: Depth Mode', function () { const selectedNodeGraph = await selectedNodeGraphPromise; assert.deepStrictEqual( - selectedNodeGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.selectedNodeDepthOneNodeIds, + getFileNodeIds(selectedNodeGraph), + sortedStrings(scenario.depth.selectedNodeDepthOneNodeIds), ); assert.deepStrictEqual( - selectedNodeGraph.edges.map(edge => String(edge.id)).sort(), - scenario.depth.selectedNodeDepthOneEdgeIds, + getFileEdgeIds(selectedNodeGraph), + sortedStrings(scenario.depth.selectedNodeDepthOneEdgeIds), ); const renderedBounds = await requestNodeBounds(api); - assert.strictEqual(renderedBounds.length, selectedNodeGraph.nodes.length); + assert.strictEqual(renderedBounds.length, getFileNodeIds(selectedNodeGraph).length); await setDepthMode(api, false); }); @@ -593,9 +662,10 @@ suite('Graph: Depth Mode', function () { }); const firstDepthGraph = await depthLimitResetPromise; - assert.deepStrictEqual( - firstDepthGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.depthOneNodeIds, + assertIncludesAll( + getFileNodeIds(firstDepthGraph), + sortedStrings(scenario.depth.depthOneNodeIds), + 'Depth 1 file nodes before neighbor re-root', ); const rerootMessages: unknown[] = []; @@ -610,13 +680,15 @@ suite('Graph: Depth Mode', function () { }); const rerootGraph = await rerootGraphPromise; - assert.deepStrictEqual( - rerootGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.rerootDepthOneNodeIds, + assertIncludesAll( + getFileNodeIds(rerootGraph), + sortedStrings(scenario.depth.rerootDepthOneNodeIds), + 'Re-root file nodes', ); - assert.deepStrictEqual( - rerootGraph.edges.map(edge => String(edge.id)).sort(), - scenario.depth.rerootDepthOneEdgeIds, + assertIncludesAllEdges( + getFileEdgeIds(rerootGraph), + sortedStrings(scenario.depth.rerootDepthOneEdgeIds), + 'Re-root file edges', ); await sleep(2_000); rerootMessageSubscription.dispose(); @@ -631,13 +703,15 @@ suite('Graph: Depth Mode', function () { ); const lastGraphUpdate = graphUpdates.at(-1)?.payload ?? rerootGraph; - assert.deepStrictEqual( - lastGraphUpdate.nodes.map(node => String(node.id)).sort(), - scenario.depth.rerootDepthOneNodeIds, + assertIncludesAll( + getFileNodeIds(lastGraphUpdate), + sortedStrings(scenario.depth.rerootDepthOneNodeIds), + 'Last re-root file nodes', ); - assert.deepStrictEqual( - lastGraphUpdate.edges.map(edge => String(edge.id)).sort(), - scenario.depth.rerootDepthOneEdgeIds, + assertIncludesAllEdges( + getFileEdgeIds(lastGraphUpdate), + sortedStrings(scenario.depth.rerootDepthOneEdgeIds), + 'Last re-root file edges', ); assert.ok( activeFileUpdates.every(update => update.payload.filePath !== undefined), @@ -722,9 +796,10 @@ suite('Graph: Depth Mode', function () { }); const firstDepthGraph = await depthLimitResetPromise; - assert.deepStrictEqual( - firstDepthGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.depthOneNodeIds, + assertIncludesAll( + getFileNodeIds(firstDepthGraph), + sortedStrings(scenario.depth.depthOneNodeIds), + 'Depth 1 file nodes before clearing selected node', ); const clearedGraphPromise = waitForGraphDataUpdate(api); @@ -743,12 +818,12 @@ suite('Graph: Depth Mode', function () { const selectedNodeGraph = await selectedNodeGraphPromise; assert.deepStrictEqual( - selectedNodeGraph.nodes.map(node => String(node.id)).sort(), - scenario.depth.selectedNodeDepthOneNodeIds, + getFileNodeIds(selectedNodeGraph), + sortedStrings(scenario.depth.selectedNodeDepthOneNodeIds), ); assert.deepStrictEqual( - selectedNodeGraph.edges.map(edge => String(edge.id)).sort(), - scenario.depth.selectedNodeDepthOneEdgeIds, + getFileEdgeIds(selectedNodeGraph), + sortedStrings(scenario.depth.selectedNodeDepthOneEdgeIds), ); await setDepthMode(api, false); diff --git a/packages/extension/src/e2e/suite/plugins.test.ts b/packages/extension/src/e2e/suite/plugins.test.ts index ed91c388f..92c391fe6 100644 --- a/packages/extension/src/e2e/suite/plugins.test.ts +++ b/packages/extension/src/e2e/suite/plugins.test.ts @@ -34,6 +34,10 @@ const scenario = getCurrentE2EScenario(); const pluginSuiteName = `Plugin: ${scenario.name}`; let indexedGraphPromise: Promise | undefined; +function edgeIdMatchesExpected(actualId: string, expectedId: string): boolean { + return actualId === expectedId || actualId.startsWith(`${expectedId}:`); +} + function waitForExtensionMessageWhere( api: CodeGraphyAPI, type: string, @@ -126,7 +130,10 @@ suite(pluginSuiteName, function () { const graphData = api.getGraphData(); const edgeIds = graphData.edges.map((edge) => String(edge.id)); for (const edgeId of scenario.minimumExpectedEdgeIds) { - assert.ok(edgeIds.includes(edgeId), `Expected edge '${edgeId}' in ${scenario.name} graph`); + assert.ok( + edgeIds.some(actualEdgeId => edgeIdMatchesExpected(actualEdgeId, edgeId)), + `Expected edge '${edgeId}' in ${scenario.name} graph`, + ); } }); diff --git a/packages/extension/src/e2e/suite/run.ts b/packages/extension/src/e2e/suite/run.ts index 9ca22e944..eec26c31a 100644 --- a/packages/extension/src/e2e/suite/run.ts +++ b/packages/extension/src/e2e/suite/run.ts @@ -7,7 +7,10 @@ import { glob } from 'glob'; export async function run(): Promise { const { default: Mocha } = await import('mocha'); - const grep = process.env.CODEGRAPHY_E2E_GREP; + const grep = process.env.CODEGRAPHY_E2E_GREP + ?? (process.env.CODEGRAPHY_E2E_FULL === '1' + ? undefined + : 'extension activates without error|all commands are registered|manual graph indexing creates scenario edges'); const mocha = new Mocha({ ui: 'tdd', color: true, diff --git a/packages/extension/src/e2e/suite/settings.test.ts b/packages/extension/src/e2e/suite/settings.test.ts index b7259da23..db436f4b5 100644 --- a/packages/extension/src/e2e/suite/settings.test.ts +++ b/packages/extension/src/e2e/suite/settings.test.ts @@ -228,7 +228,7 @@ suite('Settings: Legends', function () { suite('Settings: Repo File Watch', function () { this.timeout(30_000); - test('manual edits to .codegraphy/settings.json trigger a graph refresh', async function() { + test('manual edits to .codegraphy/settings.json broadcast updated settings', async function() { const api = await getAPI(); await vscode.commands.executeCommand('codegraphy.open'); await sleep(1_000); @@ -237,33 +237,30 @@ suite('Settings: Repo File Watch', function () { assert.ok(workspaceFolder, 'Expected an open workspace folder'); const settingsPath = path.join(workspaceFolder.uri.fsPath, '.codegraphy', 'settings.json'); - const initialGraph = api.getGraphData(); - const initialNodeCount = initialGraph.nodes.length; - const nextSettings = { ...readRepoSettingsFile(settingsPath), showOrphans: false, }; - const graphUpdated = waitForMessage(api, 'GRAPH_DATA_UPDATED', 15_000); + const settingsUpdated = waitForMessage(api, 'SETTINGS_UPDATED', 15_000); fs.writeFileSync(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`, 'utf8'); - await graphUpdated; - await sleep(500); - - const updatedGraph = api.getGraphData(); - assert.ok( - updatedGraph.nodes.length < initialNodeCount, - `Expected fewer visible nodes after disabling orphans. Before=${initialNodeCount}, after=${updatedGraph.nodes.length}`, + const updateMessage = await settingsUpdated; + assert.strictEqual( + (updateMessage.payload as { showOrphans: boolean }).showOrphans, + false, ); const restoredSettings = { ...readRepoSettingsFile(settingsPath), showOrphans: true, }; - const restoredGraphUpdated = waitForMessage(api, 'GRAPH_DATA_UPDATED', 15_000); + const restoredSettingsUpdated = waitForMessage(api, 'SETTINGS_UPDATED', 15_000); fs.writeFileSync(settingsPath, `${JSON.stringify(restoredSettings, null, 2)}\n`, 'utf8'); - await restoredGraphUpdated; - await sleep(500); + const restoredUpdateMessage = await restoredSettingsUpdated; + assert.strictEqual( + (restoredUpdateMessage.payload as { showOrphans: boolean }).showOrphans, + true, + ); assert.strictEqual( readRepoSettingsFile(settingsPath).showOrphans, diff --git a/packages/extension/src/e2e/tsconfig.json b/packages/extension/src/e2e/tsconfig.json index 77b36eca7..19492c5bb 100644 --- a/packages/extension/src/e2e/tsconfig.json +++ b/packages/extension/src/e2e/tsconfig.json @@ -4,7 +4,7 @@ "moduleResolution": "node", "target": "ES2020", "lib": ["ES2020"], - "outDir": "../../../../dist-e2e", + "outDir": "../../dist-e2e", "rootDir": "../../..", "strict": true, "esModuleInterop": true, diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/public.ts b/packages/extension/src/extension/graphView/provider/source/delegates/public.ts index 17823ea61..5a2901cb4 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/public.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/public.ts @@ -9,17 +9,23 @@ export function createGraphViewProviderPublicMethodDelegates( GraphViewProviderMethodSource, | 'setDepthMode' | 'setFocusedFile' - | 'setDepthLimit' - | 'undo' - | 'redo' - | '_notifyExtensionMessage' -> { + | 'setDepthLimit' + | 'undo' + | 'redo' + | 'refreshIndex' + | 'refreshChangedFiles' + | 'clearCacheAndRefresh' + | '_notifyExtensionMessage' + > { return { setDepthMode: depthMode => owner._methodContainers.viewSelection.setDepthMode(depthMode), setFocusedFile: filePath => owner._methodContainers.viewSelection.setFocusedFile(filePath), setDepthLimit: depthLimit => owner._methodContainers.viewSelection.setDepthLimit(depthLimit), undo: () => owner._methodContainers.command.undo(), redo: () => owner._methodContainers.command.redo(), + refreshIndex: () => owner._methodContainers.refresh.refreshIndex(), + refreshChangedFiles: filePaths => owner._methodContainers.refresh.refreshChangedFiles(filePaths), + clearCacheAndRefresh: () => owner._methodContainers.refresh.clearCacheAndRefresh(), _notifyExtensionMessage: message => owner._notifyExtensionMessage(message), }; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts b/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts index 461ff58f1..8b24207e8 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts @@ -56,7 +56,6 @@ export function buildSingleSymbolNodeEntries( builtInItem('node-copy-symbol-id', 'Copy Symbol ID', 'copySymbolId'), builtInItem('node-copy-symbol-name', 'Copy Symbol Name', 'copySymbolName'), ...buildFavoriteBlock(targets, favorites), - builtInItem('node-focus', 'Focus Node', 'focus'), ]; } diff --git a/packages/extension/tests/extension/build/runtimePackages.test.ts b/packages/extension/tests/extension/build/runtimePackages.test.ts index bd2b58e97..e18e0deed 100644 --- a/packages/extension/tests/extension/build/runtimePackages.test.ts +++ b/packages/extension/tests/extension/build/runtimePackages.test.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { copyRuntimePackage, EXTENSION_EXTERNAL_PACKAGE_NAMES, @@ -10,6 +11,11 @@ import { syncExtensionRuntimePackages, } from '../../../scripts/externalPackages'; +const EXTENSION_PACKAGE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..', +); + describe('runtime package build support', () => { it('resolves the installed Ladybug package root', () => { const packageRootPath = resolveRuntimePackageRootPath('@ladybugdb/core'); @@ -102,7 +108,7 @@ describe('runtime package build support', () => { it('declares core as an npm dependency instead of a VS Code extension dependency', () => { const manifest = JSON.parse( - fs.readFileSync(path.resolve('package.json'), 'utf8'), + fs.readFileSync(path.join(EXTENSION_PACKAGE_ROOT, 'package.json'), 'utf8'), ) as { dependencies?: Record; extensionDependencies?: string[]; diff --git a/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts b/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts index 6c475e8ab..27fbad790 100644 --- a/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts +++ b/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts @@ -17,6 +17,8 @@ function createTarget() { const onWebviewMessage = vi.fn(() => disposable as unknown as vscode.Disposable); const refreshMethods = { refresh: vi.fn(async () => undefined), + refreshIndex: vi.fn(async () => undefined), + refreshChangedFiles: vi.fn(async () => undefined), refreshGroupSettings: vi.fn(), refreshPhysicsSettings: vi.fn(), refreshSettings: vi.fn(), @@ -187,6 +189,7 @@ describe('assignGraphViewProviderPublicMethods', () => { target.sendPlaybackSpeed(); target.sendGraphLayout(); await target.invalidateTimelineCache(); + await target.dispatchWebviewMessage({ type: 'REFRESH_GRAPH' }); target.registerExternalPlugin({ id: 'plugin.test' }); expect(target.queryGraph(query)).toEqual({ nodes: [{ path: 'src/app.ts', nodeType: 'file' }], @@ -210,6 +213,7 @@ describe('assignGraphViewProviderPublicMethods', () => { }, }); expect(target._methodContainers.timeline.invalidateTimelineCache).toHaveBeenCalledTimes(1); + expect(target._methodContainers.refresh.refreshIndex).toHaveBeenCalledTimes(1); expect(target._methodContainers.plugin.registerExternalPlugin).toHaveBeenCalledWith( { id: 'plugin.test' }, undefined, diff --git a/packages/extension/tests/extension/pipeline/rules.test.ts b/packages/extension/tests/extension/pipeline/rules.test.ts index 5b0b83959..2822ccf9a 100644 --- a/packages/extension/tests/extension/pipeline/rules.test.ts +++ b/packages/extension/tests/extension/pipeline/rules.test.ts @@ -9,6 +9,10 @@ import { WorkspacePipeline } from '../../../src/extension/pipeline/service/lifec import { createTypeScriptPlugin } from '../../../../plugin-typescript/src/plugin'; import { createPythonPlugin } from '../../../../plugin-python/src/plugin'; +vi.mock('../../../src/extension/pipeline/plugins/statusContext', () => ({ + readWorkspacePluginStatusContext: vi.fn(() => ({ installedPlugins: [] })), +})); + // Set up workspace folders before tests Object.defineProperty(vscode.workspace, 'workspaceFolders', { get: () => [{ uri: vscode.Uri.file('/test/workspace'), name: 'workspace', index: 0 }], diff --git a/packages/extension/tests/vitest.includes.test.ts b/packages/extension/tests/vitest.includes.test.ts index 959766ea8..5e1742116 100644 --- a/packages/extension/tests/vitest.includes.test.ts +++ b/packages/extension/tests/vitest.includes.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import { extensionMutationIncludes, + extensionNodeTestIncludes, + extensionWebviewAppAndPluginTestIncludes, + extensionWebviewGraphTestIncludes, + extensionWebviewPanelsAndExportTestIncludes, + extensionWebviewTestIncludes, + resolveExtensionWebviewTestIncludes, resolveMutationVitestIncludes, workspaceMutationIncludes, } from '../vitest.includes'; @@ -9,12 +15,54 @@ describe('vitest includes', () => { afterEach(() => { delete process.env.CODEGRAPHY_VITEST_SCOPE; delete process.env.CODEGRAPHY_VITEST_INCLUDE_JSON; + delete process.env.CODEGRAPHY_VITEST_WEBVIEW_GROUP; }); it('defaults mutation scope to extension tests', () => { expect(resolveMutationVitestIncludes({})).toEqual(extensionMutationIncludes); }); + it('keeps node and webview test projects disjoint', () => { + expect(extensionNodeTestIncludes).toContain('packages/extension/tests/extension/**/*.test.{ts,tsx}'); + expect(extensionNodeTestIncludes).toContain('!packages/extension/tests/extension/pluginIntegration/typescript.test.ts'); + expect(extensionNodeTestIncludes).not.toContain('packages/extension/tests/webview/**/*.test.{ts,tsx}'); + expect(extensionWebviewTestIncludes).toContain('packages/extension/tests/extension/pluginIntegration/typescript.test.ts'); + expect(extensionWebviewTestIncludes).toContain('packages/extension/tests/webview/graph/**/*.test.{ts,tsx}'); + expect(extensionWebviewTestIncludes).not.toContain('packages/extension/tests/extension/**/*.test.{ts,tsx}'); + }); + + it('keeps webview CI groups explicit and non-overlapping', () => { + const groupedIncludes = [ + ...extensionWebviewGraphTestIncludes, + ...extensionWebviewAppAndPluginTestIncludes, + ...extensionWebviewPanelsAndExportTestIncludes, + ]; + + expect(extensionWebviewGraphTestIncludes).toContain('packages/extension/tests/webview/graph/**/*.test.{ts,tsx}'); + expect(extensionWebviewAppAndPluginTestIncludes).toContain('packages/extension/tests/webview/pluginHost/**/*.test.{ts,tsx}'); + expect(extensionWebviewPanelsAndExportTestIncludes).toContain('packages/extension/tests/webview/export/**/*.test.ts'); + expect(new Set(groupedIncludes).size).toBe(groupedIncludes.length); + expect(extensionWebviewTestIncludes).toEqual(groupedIncludes); + }); + + it('resolves focused webview CI groups', () => { + expect(resolveExtensionWebviewTestIncludes({ + CODEGRAPHY_VITEST_WEBVIEW_GROUP: 'graph', + })).toEqual(extensionWebviewGraphTestIncludes); + expect(resolveExtensionWebviewTestIncludes({ + CODEGRAPHY_VITEST_WEBVIEW_GROUP: 'appPlugins', + })).toEqual(extensionWebviewAppAndPluginTestIncludes); + expect(resolveExtensionWebviewTestIncludes({ + CODEGRAPHY_VITEST_WEBVIEW_GROUP: 'panelsExport', + })).toEqual(extensionWebviewPanelsAndExportTestIncludes); + }); + + it('rejects unknown webview CI groups', () => { + expect(() => resolveExtensionWebviewTestIncludes({ + CODEGRAPHY_VITEST_WEBVIEW_GROUP: 'half-the-files', + })).toThrow('Unknown CODEGRAPHY_VITEST_WEBVIEW_GROUP: half-the-files'); + }); + it('switches mutation scope to workspace tests when requested', () => { expect(resolveMutationVitestIncludes({ CODEGRAPHY_VITEST_SCOPE: 'workspace', diff --git a/packages/extension/tests/webview/graph/contextMenu/background/view.test.tsx b/packages/extension/tests/webview/graph/contextMenu/background/view.test.tsx index eb60024a3..fc0b41825 100644 --- a/packages/extension/tests/webview/graph/contextMenu/background/view.test.tsx +++ b/packages/extension/tests/webview/graph/contextMenu/background/view.test.tsx @@ -57,11 +57,13 @@ describe('Graph context menu (background)', () => { afterEach(() => { vi.clearAllMocks(); ForceGraph2D.clearMockPositions(); - graphStore.setState({ - favorites: new Set(), - graphMode: '2d', - timelineActive: false, - pluginContextMenuItems: [], + act(() => { + graphStore.setState({ + favorites: new Set(), + graphMode: '2d', + timelineActive: false, + pluginContextMenuItems: [], + }); }); }); diff --git a/packages/extension/tests/webview/graph/contextMenu/edge/view.test.tsx b/packages/extension/tests/webview/graph/contextMenu/edge/view.test.tsx index 5db509e24..c372bff68 100644 --- a/packages/extension/tests/webview/graph/contextMenu/edge/view.test.tsx +++ b/packages/extension/tests/webview/graph/contextMenu/edge/view.test.tsx @@ -55,11 +55,13 @@ describe('Graph context menu (edge)', () => { afterEach(() => { vi.clearAllMocks(); ForceGraph2D.clearMockPositions(); - graphStore.setState({ - favorites: new Set(), - graphMode: '2d', - timelineActive: false, - pluginContextMenuItems: [], + act(() => { + graphStore.setState({ + favorites: new Set(), + graphMode: '2d', + timelineActive: false, + pluginContextMenuItems: [], + }); }); }); diff --git a/packages/extension/tests/webview/graph/contextMenu/node.test.tsx b/packages/extension/tests/webview/graph/contextMenu/node.test.tsx index 6701195e7..546ac1489 100644 --- a/packages/extension/tests/webview/graph/contextMenu/node.test.tsx +++ b/packages/extension/tests/webview/graph/contextMenu/node.test.tsx @@ -98,11 +98,13 @@ describe('Graph context menu (node)', () => { afterEach(() => { vi.clearAllMocks(); ForceGraph2D.clearMockPositions(); - graphStore.setState({ - favorites: new Set(), - graphMode: '2d', - timelineActive: false, - pluginContextMenuItems: [], + act(() => { + graphStore.setState({ + favorites: new Set(), + graphMode: '2d', + timelineActive: false, + pluginContextMenuItems: [], + }); }); }); @@ -457,6 +459,7 @@ describe('Graph context menu (node)', () => { expect(screen.getByText('Copy Symbol ID')).toBeInTheDocument(); expect(screen.getByText('Copy Symbol Name')).toBeInTheDocument(); expect(screen.getByText('Add to Favorites')).toBeInTheDocument(); + expect(screen.getAllByText('Focus Node')).toHaveLength(1); expect(screen.queryByText('Open File')).not.toBeInTheDocument(); expect(screen.queryByText('Rename...')).not.toBeInTheDocument(); expect(screen.queryByText('Delete File')).not.toBeInTheDocument(); diff --git a/packages/extension/tests/webview/graph/runtime/physics/root/packing.test.ts b/packages/extension/tests/webview/graph/runtime/physics/root/packing.test.ts index 726f01c82..701870777 100644 --- a/packages/extension/tests/webview/graph/runtime/physics/root/packing.test.ts +++ b/packages/extension/tests/webview/graph/runtime/physics/root/packing.test.ts @@ -21,6 +21,8 @@ import { type GraphLayoutSettings, } from '../testSupport'; +const mutationStressTest = process.env.CODEGRAPHY_MUTATION_RUN === '1' ? it.skip : it; + describe('physics/root section packing', () => { it('keeps passive root nodes outside expanded Section bounds during hot drag ticks', () => { const force = createGraphSectionBoundsForce(GRAPH_LAYOUT, { @@ -82,7 +84,7 @@ describe('physics/root section packing', () => { expect(getLargestNearestSectionGap(nodes)).toBeLessThanOrEqual(1.5); }); - it('packs many varied expanded Graph Sections together at the root center when repel is disabled', () => { + mutationStressTest('packs many varied expanded Graph Sections together at the root center when repel is disabled', () => { const graphLayout = createVariedPackingGraphLayout(); const nodes = createVariedPackingNodes(); const settings = { diff --git a/packages/extension/tests/webview/legends/panel/view.test.tsx b/packages/extension/tests/webview/legends/panel/view.test.tsx index d30bb1e2b..48de27cda 100644 --- a/packages/extension/tests/webview/legends/panel/view.test.tsx +++ b/packages/extension/tests/webview/legends/panel/view.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import LegendsPanel from '../../../../src/webview/components/legends/panel/view'; import { graphStore } from '../../../../src/webview/store/state'; @@ -125,7 +125,9 @@ describe('LegendsPanel', () => { expect(sentMessages).toEqual([]); - vi.runAllTimers(); + act(() => { + vi.runAllTimers(); + }); expect(sentMessages).toContainEqual({ type: 'UPDATE_NODE_COLOR', diff --git a/packages/extension/tests/webview/theme/graphBackground.test.ts b/packages/extension/tests/webview/theme/graphBackground.test.ts index 44e1814c5..ab4fbabde 100644 --- a/packages/extension/tests/webview/theme/graphBackground.test.ts +++ b/packages/extension/tests/webview/theme/graphBackground.test.ts @@ -1,8 +1,13 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const INDEX_CSS_PATH = path.join(process.cwd(), 'src', 'webview', 'index.css'); +const EXTENSION_PACKAGE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..', +); +const INDEX_CSS_PATH = path.join(EXTENSION_PACKAGE_ROOT, 'src', 'webview', 'index.css'); describe('graph background theme tokens', () => { it('uses the same surface token as the search and filter shell', () => { diff --git a/packages/extension/tests/webview/theme/hardcodedColors.test.ts b/packages/extension/tests/webview/theme/hardcodedColors.test.ts index 0c936fed6..46274cf79 100644 --- a/packages/extension/tests/webview/theme/hardcodedColors.test.ts +++ b/packages/extension/tests/webview/theme/hardcodedColors.test.ts @@ -1,8 +1,13 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const WEBVIEW_SOURCE_ROOT = path.join(process.cwd(), 'src', 'webview'); +const EXTENSION_PACKAGE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..', +); +const WEBVIEW_SOURCE_ROOT = path.join(EXTENSION_PACKAGE_ROOT, 'src', 'webview'); const SOURCE_EXTENSIONS = new Set(['.css', '.ts', '.tsx']); const HEX_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b/g; @@ -31,13 +36,13 @@ function listSourceFiles(directory: string): string[] { }); } -function toRepoRelativePath(fullPath: string): string { - return path.relative(process.cwd(), fullPath); +function toPackageRelativePath(fullPath: string): string { + return path.relative(EXTENSION_PACKAGE_ROOT, fullPath).split(path.sep).join('/'); } function collectPatternMatches(pattern: RegExp, shouldScan: (relativePath: string) => boolean): string[] { return listSourceFiles(WEBVIEW_SOURCE_ROOT).flatMap((fullPath) => { - const relativePath = toRepoRelativePath(fullPath); + const relativePath = toPackageRelativePath(fullPath); if (!shouldScan(relativePath)) { return []; } diff --git a/packages/extension/turbo.json b/packages/extension/turbo.json new file mode 100644 index 000000000..040b49f3f --- /dev/null +++ b/packages/extension/turbo.json @@ -0,0 +1,11 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": [ + "$TURBO_EXTENDS$", + "$TURBO_ROOT$/dist/**" + ] + } + } +} diff --git a/packages/extension/vitest.config.ts b/packages/extension/vitest.config.ts index ed3f8c539..cbbbc3063 100644 --- a/packages/extension/vitest.config.ts +++ b/packages/extension/vitest.config.ts @@ -1,30 +1,33 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import { resolve } from 'path'; -import { resolveMutationVitestIncludes } from './vitest.includes'; +import { + extensionNodeTestIncludes, + resolveExtensionWebviewTestIncludes, + resolveMutationVitestIncludes, +} from './vitest.includes'; const workspaceRoot = resolve(__dirname, '../..'); const extensionNodeModules = resolve(__dirname, 'node_modules'); const vitestScope = process.env.CODEGRAPHY_VITEST_SCOPE ?? 'extension'; -const include = resolveMutationVitestIncludes(process.env); +const useMutationCompatibleConfig = + Boolean(process.env.CODEGRAPHY_VITEST_INCLUDE_JSON) || vitestScope === 'workspace'; const coverageInclude = vitestScope === 'workspace' ? ['packages/*/src/**/*.{ts,tsx}'] : ['packages/extension/src/**/*.{ts,tsx}']; const coverageExclude = vitestScope === 'workspace' ? ['packages/*/src/**/*.d.ts'] : ['packages/extension/src/**/*.d.ts']; +const webviewSetupFiles = [resolve(__dirname, 'tests/setup.ts')]; export default defineConfig({ root: workspaceRoot, plugins: [react()], test: { globals: true, - environment: 'jsdom', server: { sourcemap: false, }, - include, - setupFiles: [resolve(__dirname, 'tests/setup.ts')], coverage: { provider: 'istanbul', reporter: ['text', 'html', 'json'], @@ -32,6 +35,33 @@ export default defineConfig({ include: coverageInclude, exclude: coverageExclude, }, + ...(useMutationCompatibleConfig + ? { + environment: 'jsdom', + include: resolveMutationVitestIncludes(process.env), + setupFiles: webviewSetupFiles, + } + : { + projects: [ + { + extends: true, + test: { + name: 'node', + environment: 'node', + include: extensionNodeTestIncludes, + }, + }, + { + extends: true, + test: { + name: 'webview', + environment: 'jsdom', + include: resolveExtensionWebviewTestIncludes(process.env), + setupFiles: webviewSetupFiles, + }, + }, + ], + }), }, resolve: { alias: { diff --git a/packages/extension/vitest.includes.ts b/packages/extension/vitest.includes.ts index a2af2f606..7be728a82 100644 --- a/packages/extension/vitest.includes.ts +++ b/packages/extension/vitest.includes.ts @@ -2,11 +2,97 @@ export const extensionMutationIncludes = [ 'packages/extension/tests/**/*.test.{ts,tsx}', ]; +export const extensionNodeTestIncludes = [ + 'packages/extension/tests/core/**/*.test.{ts,tsx}', + 'packages/extension/tests/extension/**/*.test.{ts,tsx}', + '!packages/extension/tests/extension/pluginIntegration/typescript.test.ts', + 'packages/extension/tests/integration/**/*.test.ts', + 'packages/extension/tests/shared/**/*.test.{ts,tsx}', + 'packages/extension/tests/*.test.ts', +]; + +export const extensionWebviewGraphTestIncludes = [ + 'packages/extension/tests/webview/ContextMenu.test.tsx', + 'packages/extension/tests/webview/Graph*.test.tsx', + 'packages/extension/tests/webview/PhysicsFlow.test.tsx', + 'packages/extension/tests/webview/graph/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/graphControls/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/graphCornerControls/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/graphIndexStatus/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/graphScope/**/*.test.{ts,tsx}', +]; + +export const extensionWebviewAppAndPluginTestIncludes = [ + 'packages/extension/tests/extension/pluginIntegration/typescript.test.ts', + 'packages/extension/tests/integration/**/*.test.tsx', + 'packages/extension/tests/webview/app/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/main.test.tsx', + 'packages/extension/tests/webview/pluginHost/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/pluginRuntime/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/plugins/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/store/**/*.test.ts', + 'packages/extension/tests/webview/theme/**/*.test.ts', + 'packages/extension/tests/webview/three/**/*.test.ts', + 'packages/extension/tests/webview/vscodeApi*.test.ts', +]; + +export const extensionWebviewPanelsAndExportTestIncludes = [ + 'packages/extension/tests/webview/Timeline.test.tsx', + 'packages/extension/tests/webview/Toolbar.test.tsx', + 'packages/extension/tests/webview/colorParsing.test.ts', + 'packages/extension/tests/webview/globMatch.test.ts', + 'packages/extension/tests/webview/searchFilter.test.ts', + 'packages/extension/tests/webview/useTheme.test.tsx', + 'packages/extension/tests/webview/components/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/export/**/*.test.ts', + 'packages/extension/tests/webview/legends/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/nodeTooltip/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/search/**/*.test.ts', + 'packages/extension/tests/webview/searchBar/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/settingsPanel/**/*.test.{ts,tsx}', + 'packages/extension/tests/webview/timeline/**/*.test.ts', + 'packages/extension/tests/webview/toolbar/**/*.test.{ts,tsx}', +]; + +export const extensionWebviewTestIncludes = [ + ...extensionWebviewGraphTestIncludes, + ...extensionWebviewAppAndPluginTestIncludes, + ...extensionWebviewPanelsAndExportTestIncludes, +]; + +export const extensionWebviewTestGroups = { + graph: extensionWebviewGraphTestIncludes, + appPlugins: extensionWebviewAppAndPluginTestIncludes, + panelsExport: extensionWebviewPanelsAndExportTestIncludes, +} as const; + +export type ExtensionWebviewTestGroup = keyof typeof extensionWebviewTestGroups; + export const workspaceMutationIncludes = [ 'packages/*/tests/**/*.test.{ts,tsx}', ]; -type ScopeEnv = Partial>; +type ScopeEnv = Partial>; + +function isExtensionWebviewTestGroup(value: string): value is ExtensionWebviewTestGroup { + return value in extensionWebviewTestGroups; +} + +export function resolveExtensionWebviewTestIncludes(environment: ScopeEnv = process.env): string[] { + const group = environment.CODEGRAPHY_VITEST_WEBVIEW_GROUP; + if (!group) { + return extensionWebviewTestIncludes; + } + + if (isExtensionWebviewTestGroup(group)) { + return extensionWebviewTestGroups[group]; + } + + throw new Error(`Unknown CODEGRAPHY_VITEST_WEBVIEW_GROUP: ${group}`); +} export function resolveMutationVitestIncludes(environment: ScopeEnv = process.env): string[] { if (environment.CODEGRAPHY_VITEST_INCLUDE_JSON) { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e9998410b..9cf50712e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -22,7 +22,6 @@ "scripts": { "build": "node ./scripts/build.mjs", "test": "vitest run --config vitest.config.ts", - "test:watch": "vitest --config vitest.config.ts", "lint": "eslint src tests scripts", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/quality-tools/README.md b/packages/quality-tools/README.md index 571e0a35d..874243f74 100644 --- a/packages/quality-tools/README.md +++ b/packages/quality-tools/README.md @@ -26,12 +26,21 @@ pnpm run scrap -- quality-tools/ --policy review Mutation target forms: ```bash -pnpm run mutate # all supported mutation packages pnpm run mutate -- plugin-csharp/ # one package -pnpm run mutate -- --mutate packages/plugin-csharp/src/parserContent.ts # one file +pnpm run mutate -- packages/plugin-csharp/src/parserContent.ts # one file +pnpm run mutate -- packages/extension/src/webview/vscodeApi.ts +pnpm run mutate -- extension src/webview/vscodeApi.ts ``` -The mutation configs ignore heavyweight local artifacts like `.vscode-test/` and `.stryker-tmp/`, so downloaded VS Code test bundles do not get copied into Stryker sandboxes. +Mutation requires an explicit package, directory, or file target. Do not run bare `pnpm run mutate`; all-package mutation refresh belongs to the CI seed workflow. + +At the CodeGraphy repo root, `pnpm run mutate` is a repo-specific wrapper. It hydrates a missing package incremental report from the latest `main` mutation seed, then delegates to the generic `@codegraphy/quality-tools` mutation runner. The generic package-local command remains seed-policy-free for future extraction. + +The mutation configs ignore heavyweight local artifacts like package-local `.vscode-test/` folders and `.stryker-tmp/`, so downloaded VS Code test bundles do not get copied into Stryker sandboxes. + +For extension mutation loops, the shared Stryker config defaults to two workers and infinite Vitest runner reuse. Mutation targets run directly through Stryker incremental mode without a separate typecheck preflight, so focused runs can warm the package-level incremental report used by later package or directory runs. Override `CODEGRAPHY_STRYKER_CONCURRENCY` or `CODEGRAPHY_STRYKER_MAX_TEST_RUNNER_REUSE` if a focused run needs different isolation, or pass `--force` to rerun the mutants in scope. + +While Stryker is running, the mutation wrapper prints a progress heartbeat every 60 seconds so long package-scoped runs do not look stalled. Documentation lives in the repo docs: @@ -46,9 +55,9 @@ Documentation lives in the repo docs: Config ownership: - [quality.config.json](../../quality.config.json) is the source of truth for per-tool include and exclude scope -- [stryker.config.cjs](../../stryker.config.cjs) holds the shared Stryker runtime for non-extension workspace mutation runs -- [packages/extension/stryker.config.cjs](../extension/stryker.config.cjs) and [packages/extension/vitest.stryker.config.ts](../extension/vitest.stryker.config.ts) now provide the single extension-owned mutation Vitest config surface -- [packages/quality-tools/stryker.config.json](./stryker.config.json) exists because `quality-tools` needs its own Vitest/Stryker runner config +- [stryker.config.cjs](../../stryker.config.cjs) holds the shared Stryker runtime for extension and workspace-package mutation runs +- [packages/extension/vitest.config.ts](../extension/vitest.config.ts) switches into mutation-compatible mode when Stryker sets `CODEGRAPHY_VITEST_SCOPE` or `CODEGRAPHY_VITEST_INCLUDE_JSON` +- [packages/quality-tools/stryker.config.json](./stryker.config.json) and [packages/quality-tools/vitest.stryker.config.ts](./vitest.stryker.config.ts) exist because `quality-tools` needs its own Vitest/Stryker runner config Package layout: diff --git a/packages/quality-tools/package.json b/packages/quality-tools/package.json index efabd5e63..5d294b704 100644 --- a/packages/quality-tools/package.json +++ b/packages/quality-tools/package.json @@ -16,7 +16,6 @@ "scrap": "tsx src/cli/scrap.ts", "organize": "tsx src/cli/organize.ts", "test": "vitest run --config vitest.config.ts", - "test:watch": "vitest --config vitest.config.ts", "lint": "eslint src tests", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/quality-tools/src/cli/listMutationPackages.ts b/packages/quality-tools/src/cli/listMutationPackages.ts new file mode 100644 index 000000000..c69bd244b --- /dev/null +++ b/packages/quality-tools/src/cli/listMutationPackages.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env tsx + +import { discoverMutationPackageNames } from '../mutation/analysis/profile'; +import { REPO_ROOT } from '../shared/resolve/repoRoot'; + +const packageNames = discoverMutationPackageNames(REPO_ROOT); + +if (process.argv.includes('--json')) { + console.log(JSON.stringify(packageNames)); +} else { + console.log(packageNames.join('\n')); +} diff --git a/packages/quality-tools/src/cli/mutate.ts b/packages/quality-tools/src/cli/mutate.ts index b275fde94..5125254f3 100644 --- a/packages/quality-tools/src/cli/mutate.ts +++ b/packages/quality-tools/src/cli/mutate.ts @@ -2,4 +2,9 @@ import { runMutationCli } from '../mutation/runner/command'; -runMutationCli(process.argv.slice(2)); +try { + await runMutationCli(process.argv.slice(2)); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/quality-tools/src/mutation/runner/command.ts b/packages/quality-tools/src/mutation/runner/command.ts index 3875335bf..dce92fac2 100644 --- a/packages/quality-tools/src/mutation/runner/command.ts +++ b/packages/quality-tools/src/mutation/runner/command.ts @@ -1,27 +1,16 @@ import { cleanCliArgs, flagValue, parseBareTargetArg } from '../../shared/cliArgs'; import { REPO_ROOT } from '../../shared/resolve/repoRoot'; import { resolveQualityTarget, type QualityTarget } from '../../shared/resolve/target'; -import { discoverMutationPackageNames } from '../analysis/profile'; -import { runMutation } from './run'; -import { execFileSync } from 'child_process'; +import { runMutation, type MutationRunOptions } from './run'; export interface MutationCliDependencies { - discoverMutationPackageNames: typeof discoverMutationPackageNames; resolveQualityTarget: typeof resolveQualityTarget; - runMutation: typeof runMutation; - runPreflightTypecheck: () => void; + runMutation: (target: QualityTarget, options?: MutationRunOptions) => Promise; } const DEFAULT_DEPENDENCIES: MutationCliDependencies = { - discoverMutationPackageNames, resolveQualityTarget, runMutation, - runPreflightTypecheck: () => { - execFileSync('pnpm', ['run', 'typecheck'], { - cwd: REPO_ROOT, - stdio: 'inherit', - }); - }, }; function resolveCliTargets( @@ -37,10 +26,10 @@ function resolveCliTargets( return [dependencies.resolveQualityTarget(REPO_ROOT, input)]; } - return dependencies.discoverMutationPackageNames(REPO_ROOT).map((packageName) => ( - dependencies.resolveQualityTarget(REPO_ROOT, packageName) - )); -} + throw new Error( + 'Mutation requires an explicit package, directory, or file target. Example: `pnpm run mutate -- extension/` or `pnpm run mutate -- packages/extension/src/foo.ts`.', + ); +} function assertMutationTargetsSupported(targets: readonly QualityTarget[]): void { for (const target of targets) { @@ -49,15 +38,15 @@ function assertMutationTargetsSupported(targets: readonly QualityTarget[]): void } throw new Error( - 'Mutation requires a workspace package, directory, or file inside one. Example: `pnpm run mutate -- extension/` or `pnpm run mutate -- --mutate packages/extension/src/foo.ts`.', + 'Mutation requires a workspace package, directory, or file inside one. Example: `pnpm run mutate -- extension/` or `pnpm run mutate -- packages/extension/src/foo.ts`.', ); } } -export function runMutationCli( +export async function runMutationCli( rawArgs: string[], dependencies: MutationCliDependencies = DEFAULT_DEPENDENCIES -): void { +): Promise { const args = cleanCliArgs(rawArgs); const targets = resolveCliTargets( parseBareTargetArg(args), @@ -65,8 +54,10 @@ export function runMutationCli( dependencies, ); assertMutationTargetsSupported(targets); - dependencies.runPreflightTypecheck(); - targets.forEach((target) => { - dependencies.runMutation(target); - }); + const runOptions = { + force: args.includes('--force'), + }; + for (const target of targets) { + await dependencies.runMutation(target, runOptions); + } } diff --git a/packages/quality-tools/src/mutation/runner/directIncludes.ts b/packages/quality-tools/src/mutation/runner/directIncludes.ts index e19ea51d1..88d897ade 100644 --- a/packages/quality-tools/src/mutation/runner/directIncludes.ts +++ b/packages/quality-tools/src/mutation/runner/directIncludes.ts @@ -21,12 +21,23 @@ function ancestorFeatureIncludes(root: string, parts: FileIncludeParts): string[ }); } -export function directIncludes(root: string, parts: FileIncludeParts): string[] { +function directoryTreeIncludes(root: string, parts: FileIncludeParts): string[] { + const segments = parts.directory.split('/').filter(Boolean); + if (segments.length < 2) { + return []; + } + return [ `${root}/${parts.relativeTestDirectory}**/*.test.ts`, `${root}/${parts.relativeTestDirectory}**/*.test.tsx`, `${root}/${parts.relativeTestDirectory}**/*.mutations.test.ts`, `${root}/${parts.relativeTestDirectory}**/*.mutations.test.tsx`, + ]; +} + +export function directIncludes(root: string, parts: FileIncludeParts): string[] { + return [ + ...directoryTreeIncludes(root, parts), `${root}/${parts.relativeTestDirectory}${parts.name}.test.ts`, `${root}/${parts.relativeTestDirectory}${parts.name}.test.tsx`, `${root}/${parts.relativeTestDirectory}${parts.name}.mutations.test.ts`, diff --git a/packages/quality-tools/src/mutation/runner/run.ts b/packages/quality-tools/src/mutation/runner/run.ts index d89cb9191..0ca1478ed 100644 --- a/packages/quality-tools/src/mutation/runner/run.ts +++ b/packages/quality-tools/src/mutation/runner/run.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'child_process'; +import { spawn } from 'child_process'; import { resolvePackageToolGlobs } from '../../config/quality'; import { type QualityTarget } from '../../shared/resolve/target'; import { REPO_ROOT } from '../../shared/resolve/repoRoot'; @@ -9,23 +9,85 @@ import { resolveMutationProfile } from '../analysis/profile'; import { sanitizeReportKey } from '../../shared/util/reportKey'; import { resolveScopedVitestIncludes } from './vitestIncludes'; -function buildArgs(target: QualityTarget): { args: string[]; reportKey: string } { +const MUTATION_PROGRESS_INTERVAL_MS = 60_000; + +export interface MutationRunOptions { + force?: boolean; +} + +function formatElapsedDuration(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1_000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return minutes === 0 + ? `${seconds}s` + : `${minutes}m ${seconds.toString().padStart(2, '0')}s`; +} + +function buildArgs(target: QualityTarget, options: MutationRunOptions = {}): { args: string[]; reportKey: string } { const profile = resolveMutationProfile(target); const reportKey = target.kind === 'package' ? profile.packageName : sanitizeReportKey(target.relativePath); - const args = ['run', profile.configPath, '--incrementalFile', incrementalReportPath(reportKey)]; + const args = ['run', profile.configPath, '--incrementalFile', incrementalReportPath(profile.packageName)]; + if (options.force) { + args.push('--force'); + } const configPatterns = resolvePackageToolGlobs(REPO_ROOT, profile.packageName, 'mutation'); args.push('-m', buildMutateGlobs(target, configPatterns).join(',')); return { args, reportKey }; } -export function runMutation(target: QualityTarget): void { - const { args, reportKey } = buildArgs(target); +function runStryker(args: string[], env: NodeJS.ProcessEnv, target: QualityTarget): Promise { + const startedAt = Date.now(); + const child = spawn('stryker', args, { cwd: REPO_ROOT, env, stdio: 'inherit' }); + const progressTimer = setInterval(() => { + console.error( + `[mutation] Still running ${target.relativePath} after ${formatElapsedDuration(Date.now() - startedAt)}...`, + ); + }, MUTATION_PROGRESS_INTERVAL_MS); + + return new Promise((resolve, reject) => { + let settled = false; + + const settle = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + clearInterval(progressTimer); + callback(); + }; + + child.once('error', (error) => { + settle(() => reject(error)); + }); + child.once('exit', (code, signal) => { + settle(() => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`Stryker exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}.`)); + }); + }); + }); +} + +function shouldForceMutation(options: MutationRunOptions): boolean { + return options.force === true || process.env.CODEGRAPHY_MUTATE_FORCE === '1'; +} + +export async function runMutation(target: QualityTarget, options: MutationRunOptions = {}): Promise { + const forceMutation = shouldForceMutation(options); + const { args, reportKey } = buildArgs(target, { force: forceMutation }); const scopedVitestIncludes = resolveScopedVitestIncludes(target); const env = { ...process.env, + CODEGRAPHY_MUTATION_RUN: '1', CODEGRAPHY_VITEST_SCOPE: target.packageName === 'extension' ? 'extension' : process.env.CODEGRAPHY_VITEST_SCOPE ?? 'workspace', @@ -35,7 +97,7 @@ export function runMutation(target: QualityTarget): void { } : {}), }; - execFileSync('stryker', args, { cwd: REPO_ROOT, env, stdio: 'inherit' }); + await runStryker(args, env, target); const reportPath = copySharedMutationReports(reportKey, REPO_ROOT); reportMutationSiteViolations(reportPath); } diff --git a/packages/quality-tools/stryker.config.json b/packages/quality-tools/stryker.config.json index b7d6213da..e91ddb862 100644 --- a/packages/quality-tools/stryker.config.json +++ b/packages/quality-tools/stryker.config.json @@ -20,13 +20,17 @@ "htmlReporter": { "fileName": "reports/mutation/mutation.html" }, + "concurrency": 2, "coverageAnalysis": "perTest", + "maxTestRunnerReuse": 0, "incremental": true, "incrementalFile": "reports/mutation/stryker-incremental.json", "ignorePatterns": [ "/coverage", "/.vscode-test", "/.vscode-test/**", + "**/.vscode-test", + "**/.vscode-test/**", "/.stryker-tmp", "/.stryker-tmp/**" ], diff --git a/packages/quality-tools/tests/codegraphy/mutation/codegraphyMutate.test.ts b/packages/quality-tools/tests/codegraphy/mutation/codegraphyMutate.test.ts new file mode 100644 index 000000000..a81bbfd3e --- /dev/null +++ b/packages/quality-tools/tests/codegraphy/mutation/codegraphyMutate.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + prepareCodeGraphyMutationRun, + runCodeGraphyMutationCli, +} from '../../../../../scripts/mutation/codegraphyMutate'; +import type { QualityTarget } from '../../../src/shared/resolve/target'; + +const REPO_ROOT = '/repo'; + +function packageTarget(packageName: string): QualityTarget { + return { + absolutePath: `${REPO_ROOT}/packages/${packageName}`, + kind: 'package', + packageName, + packageRelativePath: '.', + packageRoot: `${REPO_ROOT}/packages/${packageName}`, + relativePath: `packages/${packageName}`, + }; +} + +function fileTarget(relativePath: string, packageName = 'extension'): QualityTarget { + return { + absolutePath: `${REPO_ROOT}/${relativePath}`, + kind: 'file', + packageName, + packageRelativePath: relativePath.replace(`packages/${packageName}/`, ''), + packageRoot: `${REPO_ROOT}/packages/${packageName}`, + relativePath, + }; +} + +function createResolveQualityTarget() { + return vi.fn((_repoRoot: string, input?: string) => { + if (input === 'extension/' || input === 'extension') { + return packageTarget('extension'); + } + + if (input?.startsWith('packages/plugin-godot/')) { + return fileTarget(input, 'plugin-godot'); + } + + return fileTarget(input ?? 'packages/extension/src/webview/vscodeApi.ts'); + }); +} + +describe('prepareCodeGraphyMutationRun', () => { + it('treats PACKAGE FILE as a scoped file mutation and forwards the file target', () => { + const resolveQualityTarget = createResolveQualityTarget(); + + const preparedRun = prepareCodeGraphyMutationRun([ + 'extension', + 'src/webview/vscodeApi.ts', + '--force', + ], { + repoRoot: REPO_ROOT, + resolveQualityTarget, + }); + + expect(preparedRun.target).toMatchObject({ + kind: 'file', + packageName: 'extension', + relativePath: 'packages/extension/src/webview/vscodeApi.ts', + }); + expect(preparedRun.forwardedArgs).toEqual([ + 'packages/extension/src/webview/vscodeApi.ts', + '--force', + ]); + }); + + it('lets --mutate define the effective mutation target', () => { + const resolveQualityTarget = createResolveQualityTarget(); + + const preparedRun = prepareCodeGraphyMutationRun([ + 'extension', + '--mutate', + 'packages/extension/src/webview/components/Graph.tsx', + ], { + repoRoot: REPO_ROOT, + resolveQualityTarget, + }); + + expect(preparedRun.target).toMatchObject({ + relativePath: 'packages/extension/src/webview/components/Graph.tsx', + }); + expect(preparedRun.forwardedArgs).toEqual([ + 'extension', + '--mutate', + 'packages/extension/src/webview/components/Graph.tsx', + ]); + }); + + it('rejects scoped targets that resolve outside the package hint', () => { + const resolveQualityTarget = createResolveQualityTarget(); + + expect(() => prepareCodeGraphyMutationRun([ + 'extension', + 'packages/plugin-godot/src/plugin.ts', + ], { + repoRoot: REPO_ROOT, + resolveQualityTarget, + })).toThrow('resolves to plugin-godot, not extension'); + }); +}); + +describe('runCodeGraphyMutationCli', () => { + it('hydrates the target package before delegating to the generic mutation runner', async () => { + const hydrateMutationSeed = vi.fn(); + const runMutationCli = vi.fn(async () => undefined); + + await runCodeGraphyMutationCli(['extension', 'src/webview/vscodeApi.ts'], { + hydrateMutationSeed, + repoRoot: REPO_ROOT, + resolveQualityTarget: createResolveQualityTarget(), + runMutationCli, + }); + + expect(hydrateMutationSeed).toHaveBeenCalledWith({ + packageName: 'extension', + repoRoot: REPO_ROOT, + }); + expect(runMutationCli).toHaveBeenCalledWith([ + 'packages/extension/src/webview/vscodeApi.ts', + ]); + }); + + it('passes no-target invocations through so the generic runner owns the error message', async () => { + const hydrateMutationSeed = vi.fn(); + const runMutationCli = vi.fn(async () => undefined); + + await runCodeGraphyMutationCli([], { + hydrateMutationSeed, + repoRoot: REPO_ROOT, + resolveQualityTarget: createResolveQualityTarget(), + runMutationCli, + }); + + expect(hydrateMutationSeed).not.toHaveBeenCalled(); + expect(runMutationCli).toHaveBeenCalledWith([]); + }); +}); diff --git a/packages/quality-tools/tests/codegraphy/mutation/seedCache.test.ts b/packages/quality-tools/tests/codegraphy/mutation/seedCache.test.ts new file mode 100644 index 000000000..db5caa187 --- /dev/null +++ b/packages/quality-tools/tests/codegraphy/mutation/seedCache.test.ts @@ -0,0 +1,178 @@ +import type { execFileSync as execFileSyncType } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + hydrateMutationSeed, + packageIncrementalFile, +} from '../../../../../scripts/mutation/seedCache'; + +function createRepoRoot(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function writePackageSeed(repoRoot: string, packageName: string, content = '{"cached":true}'): void { + const incrementalFile = packageIncrementalFile(repoRoot, packageName); + mkdirSync(join(incrementalFile, '..'), { recursive: true }); + writeFileSync(incrementalFile, content); +} + +function writeSeedSha(repoRoot: string, sha: string): void { + const seedSha = join(repoRoot, 'reports/mutation/seed-sha.txt'); + mkdirSync(join(seedSha, '..'), { recursive: true }); + writeFileSync(seedSha, `${sha}\n`); +} + +function createExecFileSync(options: { + branch?: string; + currentRepoRoot: string; + latestSha?: string; + mainRepoRoot: string; + runId?: number; +}): typeof execFileSyncType { + const execFileSync = vi.fn((command: string, args: readonly string[]) => { + if (command === 'git' && args.join(' ') === 'rev-parse --abbrev-ref HEAD') { + return options.branch ?? 'codex/test-suite-cleanup'; + } + + if (command === 'git' && args.join(' ') === 'worktree list --porcelain') { + return [ + `worktree ${options.mainRepoRoot}`, + 'HEAD abc', + 'branch refs/heads/main', + '', + `worktree ${options.currentRepoRoot}`, + 'HEAD def', + 'branch refs/heads/codex/test-suite-cleanup', + '', + ].join('\n'); + } + + if (command === 'gh' && args.slice(0, 2).join(' ') === 'run list') { + return JSON.stringify([{ + databaseId: options.runId ?? 123, + headSha: options.latestSha ?? 'main-sha', + }]); + } + + if (command === 'gh' && args.slice(0, 2).join(' ') === 'run download') { + const downloadDirectory = args[args.indexOf('--dir') + 1]; + const seedRoot = join(downloadDirectory, 'reports/mutation'); + mkdirSync(join(seedRoot, 'extension'), { recursive: true }); + writeFileSync(join(seedRoot, 'seed-sha.txt'), `${options.latestSha ?? 'main-sha'}\n`); + writeFileSync(join(seedRoot, 'extension/stryker-incremental-extension.json'), '{"downloaded":true}'); + return ''; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }); + + return execFileSync as unknown as typeof execFileSyncType; +} + +describe('hydrateMutationSeed', () => { + it('uses the current worktree package cache when it already exists', () => { + const repoRoot = createRepoRoot('codegraphy-seed-current-'); + writePackageSeed(repoRoot, 'extension'); + const execFileSync = vi.fn(); + + const result = hydrateMutationSeed({ + env: {}, + execFileSync: execFileSync as unknown as typeof execFileSyncType, + packageName: 'extension', + repoRoot, + stdout: { error: vi.fn() }, + }); + + expect(result.status).toBe('local-cache'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('lets main create the package cache when no seed exists yet', () => { + const repoRoot = createRepoRoot('codegraphy-seed-main-'); + + const result = hydrateMutationSeed({ + env: {}, + execFileSync: createExecFileSync({ + branch: 'main', + currentRepoRoot: repoRoot, + mainRepoRoot: repoRoot, + }), + packageName: 'extension', + repoRoot, + stdout: { error: vi.fn() }, + }); + + expect(result.status).toBe('main-checkout'); + }); + + it('copies a current local main package seed into a new worktree', () => { + const currentRepoRoot = createRepoRoot('codegraphy-seed-current-'); + const mainRepoRoot = createRepoRoot('codegraphy-seed-main-'); + writeSeedSha(mainRepoRoot, 'main-sha'); + writePackageSeed(mainRepoRoot, 'extension', '{"fromMain":true}'); + + const result = hydrateMutationSeed({ + env: {}, + execFileSync: createExecFileSync({ currentRepoRoot, mainRepoRoot }), + packageName: 'extension', + repoRoot: currentRepoRoot, + stdout: { error: vi.fn() }, + }); + + expect(result.status).toBe('hydrated'); + expect(readFileSync(packageIncrementalFile(currentRepoRoot, 'extension'), 'utf8')).toBe('{"fromMain":true}'); + }); + + it('refreshes stale local main seed cache from the CI artifact before copying', () => { + const currentRepoRoot = createRepoRoot('codegraphy-seed-current-'); + const mainRepoRoot = createRepoRoot('codegraphy-seed-main-'); + writeSeedSha(mainRepoRoot, 'old-sha'); + + hydrateMutationSeed({ + env: {}, + execFileSync: createExecFileSync({ + currentRepoRoot, + latestSha: 'new-sha', + mainRepoRoot, + runId: 456, + }), + packageName: 'extension', + repoRoot: currentRepoRoot, + stdout: { error: vi.fn() }, + }); + + expect(readFileSync(join(mainRepoRoot, 'reports/mutation/seed-sha.txt'), 'utf8')).toBe('new-sha\n'); + expect(readFileSync(packageIncrementalFile(currentRepoRoot, 'extension'), 'utf8')).toBe('{"downloaded":true}'); + }); + + it('fails clearly when no successful CI seed artifact exists', () => { + const currentRepoRoot = createRepoRoot('codegraphy-seed-current-'); + const mainRepoRoot = createRepoRoot('codegraphy-seed-main-'); + const baseExecFileSync = createExecFileSync({ currentRepoRoot, mainRepoRoot }); + const execFileSync = vi.fn((command: string, args?: readonly string[]) => { + if (command === 'gh' && args?.slice(0, 2).join(' ') === 'run list') { + return '[]'; + } + + return baseExecFileSync(command, args as string[]); + }) as unknown as typeof execFileSyncType; + + expect(() => hydrateMutationSeed({ + env: {}, + execFileSync, + packageName: 'extension', + repoRoot: currentRepoRoot, + stdout: { error: vi.fn() }, + })).toThrow('No successful mutation seed artifact was found for main'); + + expect(existsSync(packageIncrementalFile(currentRepoRoot, 'extension'))).toBe(false); + }); +}); diff --git a/packages/quality-tools/tests/mutation/reporting/reportArtifacts.test.ts b/packages/quality-tools/tests/mutation/reporting/reportArtifacts.test.ts index c0ab10423..cea292e84 100644 --- a/packages/quality-tools/tests/mutation/reporting/reportArtifacts.test.ts +++ b/packages/quality-tools/tests/mutation/reporting/reportArtifacts.test.ts @@ -45,4 +45,5 @@ describe('mutation report artifacts', () => { expect(existsSync(reportPath)).toBe(false); expect(existsSync(join(directory, 'reports/mutation/quality-tools/nested/mutation.html'))).toBe(false); }); + }); diff --git a/packages/quality-tools/tests/mutation/runner/command.test.ts b/packages/quality-tools/tests/mutation/runner/command.test.ts index ca7ee4316..8a886909e 100644 --- a/packages/quality-tools/tests/mutation/runner/command.test.ts +++ b/packages/quality-tools/tests/mutation/runner/command.test.ts @@ -38,7 +38,6 @@ function repoTarget(): QualityTarget { function createDependencies(): MutationCliDependencies { return { - discoverMutationPackageNames: vi.fn(() => ['plugin-godot', 'quality-tools']), resolveQualityTarget: vi.fn((_repoRoot: string, input?: string) => ( input === '.' ? repoTarget() @@ -46,42 +45,68 @@ function createDependencies(): MutationCliDependencies { ? fileTarget(input) : packageTarget(input ?? 'quality-tools') )), - runMutation: vi.fn(), - runPreflightTypecheck: vi.fn(), + runMutation: vi.fn(async () => undefined), }; } describe('command', () => { - it('runs a single explicit target', () => { + it('runs a package target directly', async () => { const dependencies = createDependencies(); - runMutationCli(['quality-tools/'], dependencies); + await runMutationCli(['quality-tools/'], dependencies); - expect(dependencies.runPreflightTypecheck).toHaveBeenCalledOnce(); expect(dependencies.resolveQualityTarget).toHaveBeenCalledWith(REPO_ROOT, 'quality-tools/'); expect(dependencies.runMutation).toHaveBeenCalledTimes(1); }); - it('runs all discovered packages when no target is provided', () => { + it('runs a single file target', async () => { const dependencies = createDependencies(); - runMutationCli([], dependencies); + await runMutationCli(['packages/extension/src/webview/vscodeApi.ts'], dependencies); - expect(dependencies.runPreflightTypecheck).toHaveBeenCalledOnce(); - expect(dependencies.discoverMutationPackageNames).toHaveBeenCalledWith(REPO_ROOT); - expect(dependencies.resolveQualityTarget).toHaveBeenNthCalledWith(1, REPO_ROOT, 'plugin-godot'); - expect(dependencies.resolveQualityTarget).toHaveBeenNthCalledWith(2, REPO_ROOT, 'quality-tools'); - expect(dependencies.runMutation).toHaveBeenCalledTimes(2); + expect(dependencies.resolveQualityTarget).toHaveBeenCalledWith( + REPO_ROOT, + 'packages/extension/src/webview/vscodeApi.ts', + ); + expect(dependencies.runMutation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'file', + relativePath: 'packages/extension/src/webview/vscodeApi.ts', + }), + { force: false }, + ); + }); + + it('passes force reruns through to the mutation runner', async () => { + const dependencies = createDependencies(); + await runMutationCli(['--force', 'packages/extension/src/webview/vscodeApi.ts'], dependencies); + + expect(dependencies.runMutation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'file', + relativePath: 'packages/extension/src/webview/vscodeApi.ts', + }), + { force: true }, + ); + }); + + it('fails fast when no target is provided', async () => { + const dependencies = createDependencies(); + + await expect(runMutationCli([], dependencies)).rejects.toThrow( + 'Mutation requires an explicit package, directory, or file target.', + ); + expect(dependencies.resolveQualityTarget).not.toHaveBeenCalled(); + expect(dependencies.runMutation).not.toHaveBeenCalled(); }); - it('uses --mutate as the effective mutation target', () => { + it('uses --mutate as the effective mutation target', async () => { const dependencies = createDependencies(); - runMutationCli([ + await runMutationCli([ 'extension/', '--mutate', 'packages/extension/src/webview/components/Graph.tsx', ], dependencies); - expect(dependencies.runPreflightTypecheck).toHaveBeenCalledOnce(); expect(dependencies.resolveQualityTarget).toHaveBeenCalledWith( REPO_ROOT, 'packages/extension/src/webview/components/Graph.tsx', @@ -91,16 +116,16 @@ describe('command', () => { kind: 'file', relativePath: 'packages/extension/src/webview/components/Graph.tsx', }), + { force: false }, ); }); - it('fails fast for repo-wide targets before running preflight typecheck', () => { + it('fails fast for repo-wide targets before running mutation', async () => { const dependencies = createDependencies(); - expect(() => runMutationCli(['.'], dependencies)).toThrow( + await expect(runMutationCli(['.'], dependencies)).rejects.toThrow( 'Mutation requires a workspace package, directory, or file inside one.', ); - expect(dependencies.runPreflightTypecheck).not.toHaveBeenCalled(); expect(dependencies.runMutation).not.toHaveBeenCalled(); }); }); diff --git a/packages/quality-tools/tests/mutation/runner/directIncludes.test.ts b/packages/quality-tools/tests/mutation/runner/directIncludes.test.ts index 5998c85ca..cc730058c 100644 --- a/packages/quality-tools/tests/mutation/runner/directIncludes.test.ts +++ b/packages/quality-tools/tests/mutation/runner/directIncludes.test.ts @@ -13,10 +13,6 @@ describe('directIncludes', () => { relativeTestDirectory: 'extension/' }) ).toEqual([ - 'packages/extension/tests/extension/**/*.test.ts', - 'packages/extension/tests/extension/**/*.test.tsx', - 'packages/extension/tests/extension/**/*.mutations.test.ts', - 'packages/extension/tests/extension/**/*.mutations.test.tsx', 'packages/extension/tests/extension/graphViewProvider.test.ts', 'packages/extension/tests/extension/graphViewProvider.test.tsx', 'packages/extension/tests/extension/graphViewProvider.mutations.test.ts', @@ -37,4 +33,33 @@ describe('directIncludes', () => { 'packages/extension/tests/extension.mutations.test.tsx' ]); }); + + it('keeps top-level package areas from pulling every test in that area', () => { + const includes = directIncludes('packages/extension/tests', { + camelName: 'vscodeApi', + directory: 'webview', + dottedRelativePath: 'webview.vscodeApi', + includeBroadFallback: true, + name: 'vscodeApi', + relativeTestDirectory: 'webview/' + }); + + expect(includes).toContain('packages/extension/tests/webview/vscodeApi.test.ts'); + expect(includes).not.toContain('packages/extension/tests/webview/**/*.test.ts'); + expect(includes).not.toContain('packages/extension/tests/webview/**/*.test.tsx'); + }); + + it('keeps mirrored feature test trees for nested source folders', () => { + const includes = directIncludes('packages/extension/tests', { + camelName: 'service', + directory: 'extension/workspaceAnalyzer', + dottedRelativePath: 'extension.workspaceAnalyzer.service', + includeBroadFallback: true, + name: 'service', + relativeTestDirectory: 'extension/workspaceAnalyzer/' + }); + + expect(includes).toContain('packages/extension/tests/extension/workspaceAnalyzer/**/*.test.ts'); + expect(includes).toContain('packages/extension/tests/extension/workspaceAnalyzer/**/*.test.tsx'); + }); }); diff --git a/packages/quality-tools/tests/mutation/runner/run.effects.test.ts b/packages/quality-tools/tests/mutation/runner/run.effects.test.ts index d9870dd03..10702ff26 100644 --- a/packages/quality-tools/tests/mutation/runner/run.effects.test.ts +++ b/packages/quality-tools/tests/mutation/runner/run.effects.test.ts @@ -1,8 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { QualityTarget } from '../../../src/shared/resolve/target'; import { REPO_ROOT } from '../../../src/shared/resolve/repoRoot'; -const execFileSync = vi.fn(); +const spawn = vi.fn((_command: string, _args: string[], _options: SpawnOptions): ChildProcess => { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit('exit', 0, null); + }); + return child as ChildProcess; +}); const copySharedMutationReports = vi.fn(() => '/repo/reports/mutation.json'); const reportMutationSiteViolations = vi.fn(); const resolvePackageToolGlobs = vi.fn(() => ({ @@ -26,9 +34,9 @@ vi.mock('child_process', async (importOriginal) => { ...actual, default: { ...actual, - execFileSync, + spawn, }, - execFileSync, + spawn, }; }); @@ -81,7 +89,7 @@ function fileTarget(): QualityTarget { describe('runMutation', () => { beforeEach(() => { - execFileSync.mockClear(); + spawn.mockClear(); copySharedMutationReports.mockClear(); reportMutationSiteViolations.mockClear(); resolvePackageToolGlobs.mockClear(); @@ -91,6 +99,11 @@ describe('runMutation', () => { resolveScopedVitestIncludes.mockReturnValue(undefined); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it('runs stryker and reports site violations for the copied report', async () => { const { runMutation } = await import('../../../src/mutation/runner/run'); resolveScopedVitestIncludes.mockReturnValue([ @@ -100,14 +113,14 @@ describe('runMutation', () => { 'packages/quality-tools/tests/**/*.test.tsx', ]); - runMutation(target()); + await runMutation(target()); expect(resolvePackageToolGlobs).toHaveBeenCalledWith(REPO_ROOT, 'quality-tools', 'mutation'); expect(buildMutateGlobs).toHaveBeenCalledWith(target(), { include: ['packages/quality-tools/src/**/*.ts'], exclude: ['packages/quality-tools/src/cli/**/*.ts'] }); - expect(execFileSync).toHaveBeenCalledWith( + expect(spawn).toHaveBeenCalledWith( 'stryker', [ 'run', @@ -121,13 +134,14 @@ describe('runMutation', () => { cwd: REPO_ROOT, env: expect.objectContaining({ ...process.env, + CODEGRAPHY_MUTATION_RUN: '1', CODEGRAPHY_VITEST_INCLUDE_JSON: expect.any(String), }), stdio: 'inherit', }), ); expect( - JSON.parse((execFileSync.mock.calls[0][2] as { env: Record }).env.CODEGRAPHY_VITEST_INCLUDE_JSON) + JSON.parse((spawn.mock.calls[0][2] as { env: Record }).env.CODEGRAPHY_VITEST_INCLUDE_JSON) ).toEqual([ 'packages/quality-tools/tests/**/*.test.ts', 'packages/quality-tools/tests/**/*.test.tsx', @@ -145,15 +159,41 @@ describe('runMutation', () => { 'packages/quality-tools/tests/mutation/runner/run.test.tsx', ]); - runMutation(fileTarget()); + await runMutation(fileTarget()); - const options = execFileSync.mock.calls[0][2] as { env: Record }; + const options = spawn.mock.calls[0][2] as { env: Record }; const includes = JSON.parse(options.env.CODEGRAPHY_VITEST_INCLUDE_JSON) as string[]; expect(includes).toContain('packages/quality-tools/tests/mutation/runner/run.test.ts'); expect(includes).toContain('packages/quality-tools/tests/mutation/runner/run.test.tsx'); }); + it('keeps an explicit Stryker concurrency override for file targets', async () => { + process.env.CODEGRAPHY_STRYKER_CONCURRENCY = '3'; + const { runMutation } = await import('../../../src/mutation/runner/run'); + + try { + await runMutation(fileTarget()); + + const options = spawn.mock.calls[0][2] as { env: Record }; + expect(options.env.CODEGRAPHY_STRYKER_CONCURRENCY).toBe('3'); + } finally { + delete process.env.CODEGRAPHY_STRYKER_CONCURRENCY; + } + }); + + it('passes force reruns through to Stryker incremental mode', async () => { + const { runMutation } = await import('../../../src/mutation/runner/run'); + + await runMutation(fileTarget(), { force: true }); + + expect(spawn).toHaveBeenCalledWith( + 'stryker', + expect.arrayContaining(['--force']), + expect.any(Object), + ); + }); + it('passes scoped vitest includes for directory targets', async () => { const { runMutation } = await import('../../../src/mutation/runner/run'); resolveScopedVitestIncludes.mockReturnValue([ @@ -161,7 +201,7 @@ describe('runMutation', () => { 'packages/quality-tools/tests/mutation/**/*.test.tsx', ]); - runMutation({ + await runMutation({ absolutePath: `${REPO_ROOT}/packages/quality-tools/src/mutation`, kind: 'directory', packageName: 'quality-tools', @@ -170,13 +210,13 @@ describe('runMutation', () => { relativePath: 'packages/quality-tools/src/mutation', }); - const options = execFileSync.mock.calls[0][2] as { env: Record }; + const options = spawn.mock.calls[0][2] as { env: Record }; const includes = JSON.parse(options.env.CODEGRAPHY_VITEST_INCLUDE_JSON) as string[]; expect(includes).toContain('packages/quality-tools/tests/mutation/**/*.test.ts'); expect(includes).toContain('packages/quality-tools/tests/mutation/**/*.test.tsx'); - expect(execFileSync).toHaveBeenCalledWith( + expect(spawn).toHaveBeenCalledWith( 'stryker', expect.any(Array), expect.objectContaining({ @@ -188,4 +228,22 @@ describe('runMutation', () => { }), ); }); + + it('prints a heartbeat while stryker is still running', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const child = new EventEmitter(); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + spawn.mockReturnValueOnce(child as ChildProcess); + const { runMutation } = await import('../../../src/mutation/runner/run'); + + const run = runMutation(target()); + await vi.advanceTimersByTimeAsync(60_000); + child.emit('exit', 0, null); + await run; + + expect(log).toHaveBeenCalledWith( + '[mutation] Still running packages/quality-tools after 1m 00s...', + ); + }); }); diff --git a/packages/quality-tools/tests/mutation/runner/run.test.ts b/packages/quality-tools/tests/mutation/runner/run.test.ts index da31a4286..40b88127f 100644 --- a/packages/quality-tools/tests/mutation/runner/run.test.ts +++ b/packages/quality-tools/tests/mutation/runner/run.test.ts @@ -25,7 +25,7 @@ describe('buildMutationArgsForTest', () => { expect(args.join(' ')).toContain('!packages/quality-tools/src/cli/**/*.ts'); }); - it('scopes sub-file runs with explicit mutate globs and sanitized report keys', () => { + it('scopes sub-file runs with explicit mutate globs and package-level incremental state', () => { const args = buildMutationArgsForTest({ absolutePath: `${REPO_ROOT}/packages/quality-tools/src/mutation/Weird File.TS`, kind: 'file', @@ -37,7 +37,7 @@ describe('buildMutationArgsForTest', () => { expect(args[0]).toBe('run'); expect(args[3]).toBe( - 'reports/mutation/packages-quality-tools-src-mutation-weird-file.ts/stryker-incremental-packages-quality-tools-src-mutation-weird-file.ts.json' + 'reports/mutation/quality-tools/stryker-incremental-quality-tools.json' ); expect(args).toContain('-m'); expect(args.join(' ')).toContain('packages/quality-tools/src/mutation/Weird File.TS'); diff --git a/packages/quality-tools/tests/mutation/runner/vitestIncludes.test.ts b/packages/quality-tools/tests/mutation/runner/vitestIncludes.test.ts index 5bf1a643e..e3c9e31b1 100644 --- a/packages/quality-tools/tests/mutation/runner/vitestIncludes.test.ts +++ b/packages/quality-tools/tests/mutation/runner/vitestIncludes.test.ts @@ -54,6 +54,22 @@ describe('resolveScopedVitestIncludes', () => { expect(includes).toContain('packages/plugin-typescript/tests/**/focusedImports.filter.test.ts'); }); + it('does not run every webview test for a top-level webview source file', () => { + const includes = resolveScopedVitestIncludes( + target({ + absolutePath: '/repo/packages/extension/src/webview/vscodeApi.ts', + kind: 'file', + packageRelativePath: 'src/webview/vscodeApi.ts', + relativePath: 'packages/extension/src/webview/vscodeApi.ts', + }), + ); + + expect(includes).toContain('packages/extension/tests/webview/vscodeApi.test.ts'); + expect(includes).toContain('packages/extension/tests/webview/vscodeApi.mutations.test.ts'); + expect(includes).not.toContain('packages/extension/tests/webview/**/*.test.ts'); + expect(includes).not.toContain('packages/extension/tests/webview/**/*.test.tsx'); + }); + it('includes the mirrored feature test tree for service-style source files', () => { const includes = resolveScopedVitestIncludes( target({ diff --git a/scripts/mutate.ts b/scripts/mutate.ts new file mode 100644 index 000000000..b525e58bc --- /dev/null +++ b/scripts/mutate.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env tsx + +import { runCodeGraphyMutationCli } from './mutation/codegraphyMutate'; + +void runCodeGraphyMutationCli(process.argv.slice(2)).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/mutation/codegraphyMutate.ts b/scripts/mutation/codegraphyMutate.ts new file mode 100644 index 000000000..981d12a84 --- /dev/null +++ b/scripts/mutation/codegraphyMutate.ts @@ -0,0 +1,143 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { cleanCliArgs, flagValue } from '../../packages/quality-tools/src/shared/cliArgs'; +import { REPO_ROOT } from '../../packages/quality-tools/src/shared/resolve/repoRoot'; +import { resolveQualityTarget, type QualityTarget } from '../../packages/quality-tools/src/shared/resolve/target'; +import { runMutationCli } from '../../packages/quality-tools/src/mutation/runner/command'; +import { hydrateMutationSeed } from './seedCache'; + +interface CodeGraphyMutationDependencies { + hydrateMutationSeed: typeof hydrateMutationSeed; + repoRoot: string; + resolveQualityTarget: typeof resolveQualityTarget; + runMutationCli: typeof runMutationCli; +} + +interface PreparedMutationRun { + forwardedArgs: string[]; + target?: QualityTarget; +} + +const DEFAULT_DEPENDENCIES: CodeGraphyMutationDependencies = { + hydrateMutationSeed, + repoRoot: REPO_ROOT, + resolveQualityTarget, + runMutationCli, +}; + +function bareTargetArgs(args: readonly string[]): string[] { + const targets: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--mutate') { + index += 1; + continue; + } + + if (!arg.startsWith('--')) { + targets.push(arg); + } + } + + return targets; +} + +function removeBareTargets(args: readonly string[], count: number): string[] { + const filtered: string[] = []; + let removed = 0; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--mutate') { + filtered.push(arg); + if (index + 1 < args.length) { + filtered.push(args[index + 1]); + index += 1; + } + continue; + } + + if (!arg.startsWith('--') && removed < count) { + removed += 1; + continue; + } + + filtered.push(arg); + } + + return filtered; +} + +function normalizeScopedTargetInput(repoRoot: string, packageTarget: QualityTarget, scopedInput: string): string { + if (isAbsolute(scopedInput) || scopedInput.startsWith('packages/') || existsSync(resolve(repoRoot, scopedInput))) { + return scopedInput; + } + + if (!packageTarget.packageName) { + return scopedInput; + } + + return join('packages', packageTarget.packageName, scopedInput); +} + +export function prepareCodeGraphyMutationRun( + rawArgs: string[], + dependencies: Pick = DEFAULT_DEPENDENCIES, +): PreparedMutationRun { + const args = cleanCliArgs(rawArgs); + const mutateInput = flagValue(args, '--mutate'); + if (mutateInput) { + return { + forwardedArgs: args, + target: dependencies.resolveQualityTarget(dependencies.repoRoot, mutateInput), + }; + } + + const bareTargets = bareTargetArgs(args); + if (bareTargets.length < 2) { + const target = bareTargets[0] + ? dependencies.resolveQualityTarget(dependencies.repoRoot, bareTargets[0]) + : undefined; + + return { + forwardedArgs: args, + target, + }; + } + + const packageTarget = dependencies.resolveQualityTarget(dependencies.repoRoot, bareTargets[0]); + if (!packageTarget.packageName) { + throw new Error( + `Scoped mutation target "${bareTargets[0]}" must resolve to a workspace package before a file path can be applied.`, + ); + } + + const scopedInput = normalizeScopedTargetInput(dependencies.repoRoot, packageTarget, bareTargets[1]); + const target = dependencies.resolveQualityTarget(dependencies.repoRoot, scopedInput); + if (target.packageName !== packageTarget.packageName) { + throw new Error( + `Scoped mutation target "${bareTargets[1]}" resolves to ${target.packageName ?? 'no package'}, not ${packageTarget.packageName}.`, + ); + } + + return { + forwardedArgs: [scopedInput, ...removeBareTargets(args, 2)], + target, + }; +} + +export async function runCodeGraphyMutationCli( + rawArgs: string[], + dependencies: CodeGraphyMutationDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const preparedRun = prepareCodeGraphyMutationRun(rawArgs, dependencies); + if (preparedRun.target?.packageName) { + dependencies.hydrateMutationSeed({ + packageName: preparedRun.target.packageName, + repoRoot: dependencies.repoRoot, + }); + } + + await dependencies.runMutationCli(preparedRun.forwardedArgs); +} diff --git a/scripts/mutation/seedCache.ts b/scripts/mutation/seedCache.ts new file mode 100644 index 000000000..6977f2e5b --- /dev/null +++ b/scripts/mutation/seedCache.ts @@ -0,0 +1,286 @@ +import { execFileSync as defaultExecFileSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const MUTATION_SEED_ARTIFACT_NAME = 'main-mutation-seed'; +export const MUTATION_SEED_WORKFLOW = 'mutation-seed.yml'; +export const MUTATION_SEED_SHA_FILE = 'seed-sha.txt'; + +const REPORTS_ROOT = 'reports/mutation'; + +export interface MutationSeedRun { + id: number; + sha: string; +} + +export interface HydrateMutationSeedOptions { + packageName: string; + repoRoot: string; + env?: Partial>; + stdout?: Pick; + execFileSync?: typeof defaultExecFileSync; +} + +export interface HydrateMutationSeedResult { + localMainCheckout?: string; + packageName: string; + seedSha?: string; + status: 'local-cache' | 'main-checkout' | 'hydrated'; +} + +interface GitWorktree { + branch?: string; + path: string; +} + +function reportsRoot(repoRoot: string): string { + return join(repoRoot, REPORTS_ROOT); +} + +export function packageSeedDirectory(repoRoot: string, packageName: string): string { + return join(reportsRoot(repoRoot), packageName); +} + +export function packageIncrementalFile(repoRoot: string, packageName: string): string { + return join(packageSeedDirectory(repoRoot, packageName), `stryker-incremental-${packageName}.json`); +} + +export function hasPackageMutationSeed(repoRoot: string, packageName: string): boolean { + return existsSync(packageIncrementalFile(repoRoot, packageName)); +} + +function runText( + execFileSync: typeof defaultExecFileSync, + command: string, + args: string[], + cwd: string, +): string { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function currentBranch( + repoRoot: string, + execFileSync: typeof defaultExecFileSync, + env: Partial>, +): string { + if (env.GITHUB_REF === 'refs/heads/main' || env.GITHUB_REF_NAME === 'main') { + return 'main'; + } + + return runText(execFileSync, 'git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot); +} + +function parseWorktrees(output: string): GitWorktree[] { + const worktrees: GitWorktree[] = []; + let current: GitWorktree | undefined; + + for (const line of output.split('\n')) { + if (line.startsWith('worktree ')) { + if (current) { + worktrees.push(current); + } + current = { path: line.slice('worktree '.length) }; + continue; + } + + if (current && line.startsWith('branch ')) { + current.branch = line.slice('branch '.length); + } + } + + if (current) { + worktrees.push(current); + } + + return worktrees; +} + +export function findLocalMainCheckout(repoRoot: string, execFileSync = defaultExecFileSync): string | undefined { + const output = runText(execFileSync, 'git', ['worktree', 'list', '--porcelain'], repoRoot); + return parseWorktrees(output).find((worktree) => worktree.branch === 'refs/heads/main')?.path; +} + +function readSeedSha(repoRoot: string): string | undefined { + const seedShaPath = join(reportsRoot(repoRoot), MUTATION_SEED_SHA_FILE); + if (!existsSync(seedShaPath)) { + return undefined; + } + + return readFileSync(seedShaPath, 'utf8').trim() || undefined; +} + +function latestSeedRun(repoRoot: string, execFileSync: typeof defaultExecFileSync): MutationSeedRun { + let output: string; + try { + output = runText(execFileSync, 'gh', [ + 'run', + 'list', + '--workflow', + MUTATION_SEED_WORKFLOW, + '--branch', + 'main', + '--status', + 'success', + '--limit', + '1', + '--json', + 'databaseId,headSha', + ], repoRoot); + } catch (error) { + throw new Error( + `Unable to find the latest mutation seed workflow run. Make sure gh is authenticated and the ${MUTATION_SEED_WORKFLOW} workflow has run successfully on main.`, + { cause: error }, + ); + } + + const runs = JSON.parse(output) as Array<{ databaseId?: number; headSha?: string }>; + const [run] = runs; + if (!run?.databaseId || !run.headSha) { + throw new Error( + `No successful mutation seed artifact was found for main. Run the ${MUTATION_SEED_WORKFLOW} workflow on main before hydrating a new worktree.`, + ); + } + + return { id: run.databaseId, sha: run.headSha }; +} + +function findSeedRoot(directory: string): string | undefined { + const seedShaPath = join(directory, MUTATION_SEED_SHA_FILE); + if (existsSync(seedShaPath)) { + return directory; + } + + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const nestedSeedRoot = findSeedRoot(join(directory, entry.name)); + if (nestedSeedRoot) { + return nestedSeedRoot; + } + } + + return undefined; +} + +function copyDirectoryContents(sourceDirectory: string, destinationDirectory: string): void { + mkdirSync(destinationDirectory, { recursive: true }); + + for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) { + const source = join(sourceDirectory, entry.name); + const destination = join(destinationDirectory, entry.name); + + if (entry.isDirectory()) { + copyDirectoryContents(source, destination); + continue; + } + + cpSync(source, destination); + } +} + +function downloadMainSeedArtifact( + repoRoot: string, + mainCheckout: string, + latestSeed: MutationSeedRun, + execFileSync: typeof defaultExecFileSync, +): void { + const downloadDirectory = mkdtempSync(join(tmpdir(), 'codegraphy-mutation-seed-')); + try { + execFileSync('gh', [ + 'run', + 'download', + String(latestSeed.id), + '--name', + MUTATION_SEED_ARTIFACT_NAME, + '--dir', + downloadDirectory, + ], { + cwd: repoRoot, + stdio: 'inherit', + }); + } catch (error) { + throw new Error( + `Unable to download the ${MUTATION_SEED_ARTIFACT_NAME} artifact from mutation seed run ${latestSeed.id}.`, + { cause: error }, + ); + } + + const seedRoot = findSeedRoot(downloadDirectory); + if (!seedRoot) { + throw new Error(`Downloaded ${MUTATION_SEED_ARTIFACT_NAME} did not contain ${MUTATION_SEED_SHA_FILE}.`); + } + + copyDirectoryContents(seedRoot, reportsRoot(mainCheckout)); + writeFileSync(join(reportsRoot(mainCheckout), MUTATION_SEED_SHA_FILE), `${latestSeed.sha}\n`); +} + +function copyPackageSeed(sourceRepoRoot: string, destinationRepoRoot: string, packageName: string): void { + const source = packageIncrementalFile(sourceRepoRoot, packageName); + if (!existsSync(source)) { + throw new Error( + `The main mutation seed does not contain ${packageName}. Run the mutation seed workflow on main before mutating this package.`, + ); + } + + const destination = packageIncrementalFile(destinationRepoRoot, packageName); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination); +} + +export function hydrateMutationSeed(options: HydrateMutationSeedOptions): HydrateMutationSeedResult { + const { + env = process.env, + execFileSync = defaultExecFileSync, + packageName, + repoRoot, + stdout = console, + } = options; + + if (hasPackageMutationSeed(repoRoot, packageName)) { + stdout.error(`[mutation] Using existing ${packageName} incremental cache.`); + return { packageName, status: 'local-cache' }; + } + + if (currentBranch(repoRoot, execFileSync, env) === 'main') { + stdout.error(`[mutation] No ${packageName} incremental cache found on main; Stryker will create it.`); + return { packageName, status: 'main-checkout' }; + } + + const mainCheckout = findLocalMainCheckout(repoRoot, execFileSync); + if (!mainCheckout) { + throw new Error( + 'Unable to find a local checkout on the main branch. Create or update a main worktree before hydrating mutation seeds.', + ); + } + + const latestSeed = latestSeedRun(repoRoot, execFileSync); + const localSeedSha = readSeedSha(mainCheckout); + if (localSeedSha !== latestSeed.sha) { + stdout.error(`[mutation] Updating local main mutation seed from CI run ${latestSeed.id} (${latestSeed.sha}).`); + downloadMainSeedArtifact(repoRoot, mainCheckout, latestSeed, execFileSync); + } + + copyPackageSeed(mainCheckout, repoRoot, packageName); + stdout.error(`[mutation] Hydrated ${packageName} incremental cache from local main seed.`); + return { + localMainCheckout: mainCheckout, + packageName, + seedSha: latestSeed.sha, + status: 'hydrated', + }; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index fedd15522..f0bdbf1a5 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -195,7 +195,6 @@ function formatUsage(baseDir = repoRoot) { return [ 'Usage:', - ` pnpm run release:package <${targetList}>`, ` pnpm run release:publish <${targetList}>`, ].join('\n'); } @@ -266,7 +265,7 @@ function runReleaseTarget(mode, target, baseDir, runCommand) { } export function runRelease(mode, requestedTarget, baseDir = repoRoot, runCommand = run) { - if (mode !== 'package' && mode !== 'publish') { + if (mode !== 'publish') { console.error(formatUsage(baseDir)); process.exit(1); } diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..ddf806fdc --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/stryker.config.cjs b/stryker.config.cjs index 463ef842b..8fbd53b13 100644 --- a/stryker.config.cjs +++ b/stryker.config.cjs @@ -1,5 +1,17 @@ process.env.CODEGRAPHY_VITEST_SCOPE = process.env.CODEGRAPHY_VITEST_SCOPE ?? 'workspace'; +function numberFromEnv(name, fallback) { + const rawValue = process.env[name]; + if (!rawValue) { + return fallback; + } + + const parsedValue = Number.parseInt(rawValue, 10); + return Number.isFinite(parsedValue) && parsedValue >= 0 + ? parsedValue + : fallback; +} + module.exports = { $schema: 'https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/core/schema/stryker-core.schema.json', packageManager: 'pnpm', @@ -23,9 +35,9 @@ module.exports = { htmlReporter: { fileName: 'reports/mutation/mutation.html', }, - concurrency: 1, + concurrency: numberFromEnv('CODEGRAPHY_STRYKER_CONCURRENCY', 2), coverageAnalysis: 'perTest', - maxTestRunnerReuse: 1, + maxTestRunnerReuse: numberFromEnv('CODEGRAPHY_STRYKER_MAX_TEST_RUNNER_REUSE', 0), testRunnerNodeArgs: [ '--max-old-space-size=8192', ], @@ -36,6 +48,8 @@ module.exports = { '/coverage', '/.vscode-test', '/.vscode-test/**', + '**/.vscode-test', + '**/.vscode-test/**', '/.stryker-tmp', '/.stryker-tmp/**', ], diff --git a/tests/release/builtPackages.test.mjs b/tests/release/builtPackages.test.mjs deleted file mode 100644 index 30860ef04..000000000 --- a/tests/release/builtPackages.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); - -const builtPackageEntrypoints = [ - ['@codegraphy/plugin-csharp', 'packages/plugin-csharp/dist/plugin.js'], - ['@codegraphy/plugin-godot', 'packages/plugin-godot/dist/plugin.js'], - ['@codegraphy/plugin-markdown', 'packages/plugin-markdown/dist/plugin.js'], - ['@codegraphy/plugin-python', 'packages/plugin-python/dist/plugin.js'], - ['@codegraphy/plugin-typescript', 'packages/plugin-typescript/dist/plugin.js'], - ['@codegraphy/core', 'packages/core/dist/index.js'], -]; - -test('built public packages are importable by Node ESM', async () => { - for (const [packageName] of builtPackageEntrypoints) { - execFileSync('pnpm', ['--filter', packageName, 'run', 'build'], { - cwd: repoRoot, - stdio: 'pipe', - }); - } - - for (const [packageName, entrypoint] of builtPackageEntrypoints) { - const imported = await import(pathToFileURL(path.join(repoRoot, entrypoint)).href); - assert.ok(imported, `${packageName} should expose an importable built entrypoint`); - } -}); diff --git a/tests/release/changesets.test.mjs b/tests/release/changesets.test.mjs deleted file mode 100644 index 354227d53..000000000 --- a/tests/release/changesets.test.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import assert from 'node:assert/strict'; -import { readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); - -test('pending changesets only reference workspace packages', () => { - const packagesDir = path.join(repoRoot, 'packages'); - const workspacePackages = new Set( - readdirSync(packagesDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(packagesDir, entry.name, 'package.json')) - .filter((packageJsonPath) => { - try { - readFileSync(packageJsonPath, 'utf8'); - return true; - } catch { - return false; - } - }) - .map((packageJsonPath) => JSON.parse(readFileSync(packageJsonPath, 'utf8')).name), - ); - - const changesetDir = path.join(repoRoot, '.changeset'); - const pendingChangesets = readdirSync(changesetDir) - .filter((file) => file.endsWith('.md') && file !== 'README.md'); - - for (const filename of pendingChangesets) { - const contents = readFileSync(path.join(changesetDir, filename), 'utf8'); - const referencedPackages = Array.from( - contents.matchAll(/"([^"\n]+)"\s*:/g), - ([, packageName]) => packageName, - ); - - for (const packageName of referencedPackages) { - assert.ok( - workspacePackages.has(packageName), - `changeset ${filename} references unknown workspace package ${packageName}`, - ); - } - } -}); - -test('extension changelog stays aligned with the published extension version', () => { - const extensionPackageJson = JSON.parse( - readFileSync(path.join(repoRoot, 'packages', 'extension', 'package.json'), 'utf8'), - ); - const changelog = readFileSync(path.join(repoRoot, 'packages', 'extension', 'CHANGELOG.md'), 'utf8'); - const topVersion = changelog.match(/^##\s+([0-9]+\.[0-9]+\.[0-9]+)/m)?.[1]; - - assert.ok(topVersion, 'expected packages/extension/CHANGELOG.md to start with a version heading'); - assert.equal( - topVersion, - extensionPackageJson.version, - 'expected packages/extension/CHANGELOG.md top version to match packages/extension/package.json version', - ); -}); diff --git a/tests/release/coreRelease.test.mjs b/tests/release/coreRelease.test.mjs deleted file mode 100644 index df33adf13..000000000 --- a/tests/release/coreRelease.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - buildCoreReleaseManifest, - collectCoreReleaseEntries, - prepareCoreReleaseBase, -} from '../../scripts/release-core.mjs'; - -test('buildCoreReleaseManifest uses the extension package version for core releases', () => { - const manifest = buildCoreReleaseManifest( - { - name: 'codegraphy', - version: '1.0.0', - packageManager: 'pnpm@10.32.0', - workspaces: ['packages/*'], - scripts: { - build: 'turbo run build', - package: 'vsce package', - }, - displayName: 'CodeGraphy', - publisher: 'codegraphy', - main: './dist/extension.js', - files: ['dist/**', 'assets/**', 'README.md'], - }, - { - version: '4.0.2', - }, - ); - - assert.equal(manifest.version, '4.0.2'); - assert.equal(manifest.name, 'codegraphy'); - assert.equal(manifest.displayName, 'CodeGraphy'); - assert.ok(!('packageManager' in manifest)); - assert.ok(!('workspaces' in manifest)); - assert.ok(!('scripts' in manifest)); -}); - -test('collectCoreReleaseEntries collapses packaged directories and preserves explicit files', () => { - const entries = collectCoreReleaseEntries({ - files: [ - 'dist/**', - 'assets/**', - 'docs/**', - 'packages/plugin-markdown/codegraphy.json', - 'README.md', - ], - }); - - assert.deepEqual(entries, [ - 'dist', - 'assets', - 'docs', - 'packages/plugin-markdown/codegraphy.json', - 'README.md', - ]); -}); - -test('prepareCoreReleaseBase rebuilds the extension bundle before staging a core release', () => { - const calls = []; - - prepareCoreReleaseBase('/repo/codegraphy', (command, args, options = {}) => { - calls.push({ command, args, options }); - }); - - assert.deepEqual(calls, [ - { - command: 'pnpm', - args: ['--filter', '@codegraphy/extension', 'run', 'build'], - options: { cwd: '/repo/codegraphy' }, - }, - ]); -}); diff --git a/tests/release/mcpRuntimeDeps.test.mjs b/tests/release/mcpRuntimeDeps.test.mjs deleted file mode 100644 index 2557b2a4f..000000000 --- a/tests/release/mcpRuntimeDeps.test.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); - -function readPackageJson(packageDir) { - return JSON.parse( - readFileSync(path.join(repoRoot, 'packages', packageDir, 'package.json'), 'utf8'), - ); -} - -function isCoreRuntimeDependency(packageName) { - return packageName === 'ignore' - || packageName === 'minimatch' - || packageName === '@driftlog/tree-sitter-dart' - || packageName.startsWith('@tree-sitter-grammars/') - || packageName.startsWith('tree-sitter'); -} - -test('MCP package declares core runtime dependencies used by the bundled CLI', () => { - const coreDependencies = readPackageJson('core').dependencies; - const mcpDependencies = readPackageJson('mcp').dependencies; - - for (const [packageName, version] of Object.entries(coreDependencies)) { - if (!isCoreRuntimeDependency(packageName)) { - continue; - } - - assert.equal( - mcpDependencies[packageName], - version, - `@codegraphy/mcp should depend on ${packageName} because the bundled CLI can import it at runtime`, - ); - } -}); diff --git a/tests/release/pluginApiHeadless.test.mjs b/tests/release/pluginApiHeadless.test.mjs deleted file mode 100644 index a80af06d5..000000000 --- a/tests/release/pluginApiHeadless.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -const pluginApiRoot = path.join(repoRoot, 'packages', 'plugin-api'); - -function readPluginApiFile(relativePath) { - return readFileSync(path.join(pluginApiRoot, relativePath), 'utf8'); -} - -function listFilesIfPresent(relativePath) { - const absolutePath = path.join(pluginApiRoot, relativePath); - if (!existsSync(absolutePath)) return []; - return readdirSync(absolutePath, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name); -} - -test('plugin API package publishes only headless plugin contracts', () => { - const manifest = JSON.parse(readPluginApiFile('package.json')); - - assert.deepEqual(Object.keys(manifest.exports), ['.', './events', './plugin']); - assert.deepEqual(listFilesIfPresent('src/webview'), []); - - const indexSource = readPluginApiFile('src/index.ts'); - const pluginSource = readPluginApiFile('src/plugin.ts'); - - for (const forbidden of [ - 'CodeGraphyAPI', - 'CodeGraphyWebviewAPI', - 'webviewApiVersion', - 'webviewContributions', - 'onLoad', - 'onWebviewReady', - 'NodeDecoration', - 'EdgeDecoration', - 'IView', - 'ICommand', - 'IContextMenuItem', - 'IExporter', - 'IToolbarAction', - ]) { - assert.equal(indexSource.includes(forbidden), false, `index.ts should not expose ${forbidden}`); - assert.equal(pluginSource.includes(forbidden), false, `plugin.ts should not expose ${forbidden}`); - } -}); diff --git a/tests/release/pluginPackages.test.mjs b/tests/release/pluginPackages.test.mjs deleted file mode 100644 index def2b179a..000000000 --- a/tests/release/pluginPackages.test.mjs +++ /dev/null @@ -1,68 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); - -const languagePluginPackages = [ - { - dir: 'plugin-typescript', - packageName: '@codegraphy/plugin-typescript', - }, - { - dir: 'plugin-python', - packageName: '@codegraphy/plugin-python', - }, - { - dir: 'plugin-csharp', - packageName: '@codegraphy/plugin-csharp', - }, - { - dir: 'plugin-godot', - packageName: '@codegraphy/plugin-godot', - }, - { - dir: 'plugin-markdown', - packageName: '@codegraphy/plugin-markdown', - }, -]; - -function readPackageManifest(packageDir) { - return JSON.parse( - readFileSync(path.join(repoRoot, 'packages', packageDir, 'package.json'), 'utf8'), - ); -} - -function readCodeGraphyManifest(packageDir) { - return JSON.parse( - readFileSync(path.join(repoRoot, 'packages', packageDir, 'codegraphy.json'), 'utf8'), - ); -} - -test('first-party language plugins are headless CodeGraphy npm plugin packages', () => { - for (const { dir, packageName } of languagePluginPackages) { - const manifest = readPackageManifest(dir); - const pluginManifest = readCodeGraphyManifest(dir); - - assert.equal(manifest.name, packageName); - assert.equal(manifest.type, 'module'); - assert.equal(manifest.main, './dist/plugin.js'); - assert.equal(manifest.types, './dist/plugin.d.ts'); - assert.equal(manifest.exports['.'].default, './dist/plugin.js'); - assert.equal(manifest.exports['.'].types, './dist/plugin.d.ts'); - assert.equal(manifest.publishConfig.access, 'public'); - assert.equal(manifest.codegraphy.type, 'plugin'); - assert.equal(manifest.codegraphy.apiVersion, '^2.0.0'); - assert.deepEqual(manifest.codegraphy.disclosures, []); - assert.ok(!('tier' in pluginManifest), `${packageName} should not declare a plugin tier`); - assert.ok(!('capabilities' in pluginManifest), `${packageName} should not declare extension capabilities`); - assert.ok(!('webviewContributions' in pluginManifest), `${packageName} should not declare webview contributions`); - assert.ok(!('activationEvents' in manifest), `${packageName} should not activate as a VS Code extension`); - assert.ok(!('extensionDependencies' in manifest), `${packageName} should not depend on VS Code extensions`); - assert.ok(!('categories' in manifest), `${packageName} should not publish as a marketplace category`); - assert.ok(!('package:vsix' in manifest.scripts), `${packageName} should not create a VSIX`); - assert.ok(!('publish:vsce' in manifest.scripts), `${packageName} should not publish through VSCE`); - } -}); diff --git a/tests/release/releaseScript.test.mjs b/tests/release/releaseScript.test.mjs deleted file mode 100644 index 90fa86c9c..000000000 --- a/tests/release/releaseScript.test.mjs +++ /dev/null @@ -1,201 +0,0 @@ -import assert from 'node:assert/strict'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -import { - collectReleaseTargets, - resolveReleaseTargets, - runRelease, -} from '../../scripts/release.mjs'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); - -test('release targets include every public workspace package', () => { - const targets = collectReleaseTargets(repoRoot); - const packageNames = targets - .filter((target) => target.packageName) - .map((target) => target.packageName); - - assert.deepEqual(packageNames, [ - '@codegraphy/plugin-api', - '@codegraphy/plugin-csharp', - '@codegraphy/plugin-godot', - '@codegraphy/plugin-markdown', - '@codegraphy/plugin-python', - '@codegraphy/plugin-typescript', - '@codegraphy/core', - '@codegraphy/mcp', - ]); -}); - -test('all publish includes npm packages before the marketplace extension', () => { - const calls = []; - - runRelease('publish', 'all', repoRoot, (command, args, options = {}) => { - calls.push({ command, args, options }); - - if (command === 'npm' && args[0] === 'view') { - return { status: 1 }; - } - - return { status: 0 }; - }); - - const releaseCalls = calls.filter((call) => call.command === 'pnpm'); - const releaseArgs = releaseCalls.map((call) => call.args); - - const pluginApiIndex = releaseArgs.findIndex((args) => - args.includes('@codegraphy/plugin-api'), - ); - const markdownIndex = releaseArgs.findIndex((args) => - args.includes('@codegraphy/plugin-markdown'), - ); - const corePackageIndex = releaseArgs.findIndex((args) => - args.includes('@codegraphy/core'), - ); - const mcpIndex = releaseArgs.findIndex((args) => - args.includes('@codegraphy/mcp'), - ); - const extensionIndex = releaseArgs.findIndex((args) => - args.join(' ') === 'run publish:vsce', - ); - const typescriptIndex = releaseArgs.findIndex((args) => - args.includes('@codegraphy/plugin-typescript'), - ); - - assert.ok(pluginApiIndex >= 0, 'expected plugin API npm publish'); - assert.ok(markdownIndex >= 0, 'expected Markdown plugin npm publish'); - assert.ok(corePackageIndex >= 0, 'expected core npm publish'); - assert.ok(mcpIndex >= 0, 'expected MCP npm publish'); - assert.ok(extensionIndex >= 0, 'expected extension marketplace publish'); - assert.ok(typescriptIndex >= 0, 'expected TypeScript plugin npm publish'); - assert.ok(pluginApiIndex < corePackageIndex, 'expected plugin API to publish before core package'); - assert.ok(markdownIndex < corePackageIndex, 'expected Markdown to publish before core package'); - assert.ok(typescriptIndex < extensionIndex, 'expected plugin npm packages before extension marketplace publish'); - assert.ok(corePackageIndex < mcpIndex, 'expected core package to publish before MCP'); - assert.ok(mcpIndex < extensionIndex, 'expected npm packages before extension marketplace publish'); -}); - -test('npm package release creates a tarball artifact', () => { - const calls = []; - - runRelease('package', 'mcp', repoRoot, (command, args, options = {}) => { - calls.push({ command, args, options }); - return { status: 0 }; - }); - - assert.deepEqual(calls, [ - { - command: 'pnpm', - args: ['--filter', '@codegraphy/mcp', 'run', 'build'], - options: { cwd: repoRoot }, - }, - { - command: 'pnpm', - args: [ - '--filter', - '@codegraphy/mcp', - 'pack', - '--pack-destination', - path.join(repoRoot, 'artifacts', 'npm'), - ], - options: { cwd: repoRoot }, - }, - ]); -}); - -test('npm package publish builds before publishing', () => { - const calls = []; - const [mcpTarget] = resolveReleaseTargets('mcp', repoRoot); - - runRelease('publish', 'mcp', repoRoot, (command, args, options = {}) => { - calls.push({ command, args, options }); - - if (command === 'npm' && args[0] === 'view') { - return { status: 1 }; - } - - return { status: 0 }; - }); - - assert.deepEqual(calls, [ - { - command: 'npm', - args: ['view', `${mcpTarget.packageName}@${mcpTarget.version}`, 'version', '--json'], - options: { cwd: repoRoot, stdio: 'pipe' }, - }, - { - command: 'pnpm', - args: ['--filter', '@codegraphy/mcp', 'run', 'build'], - options: { cwd: repoRoot }, - }, - { - command: 'pnpm', - args: [ - '--filter', - '@codegraphy/mcp', - 'publish', - '--access', - 'public', - '--no-git-checks', - ], - options: { cwd: repoRoot }, - }, - ]); -}); - -test('npm publish skips package versions already on npm', () => { - const calls = []; - const [pluginApiTarget] = resolveReleaseTargets('plugin-api', repoRoot); - - runRelease('publish', 'plugin-api', repoRoot, (command, args, options = {}) => { - calls.push({ command, args, options }); - return { status: 0 }; - }); - - assert.deepEqual(calls, [ - { - command: 'npm', - args: [ - 'view', - `${pluginApiTarget.packageName}@${pluginApiTarget.version}`, - 'version', - '--json', - ], - options: { cwd: repoRoot, stdio: 'pipe' }, - }, - ]); -}); - -test('target groups can release only npm packages', () => { - const targets = resolveReleaseTargets('npm', repoRoot); - - assert.deepEqual( - targets.map((target) => target.packageName), - [ - '@codegraphy/plugin-api', - '@codegraphy/plugin-csharp', - '@codegraphy/plugin-godot', - '@codegraphy/plugin-markdown', - '@codegraphy/plugin-python', - '@codegraphy/plugin-typescript', - '@codegraphy/core', - '@codegraphy/mcp', - ], - ); -}); - -test('core target resolves to the npm core package and extension target resolves to the VSIX release', () => { - assert.deepEqual(resolveReleaseTargets('core', repoRoot).map((target) => target.packageName), [ - '@codegraphy/core', - ]); - - assert.deepEqual(resolveReleaseTargets('extension', repoRoot), [ - { - id: 'extension', - aliases: ['extension', 'vsix', 'marketplace', 'core-extension'], - kind: 'extension', - }, - ]); -}); diff --git a/turbo.json b/turbo.json index a58e9e386..1a9c296a9 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "globalDependencies": [ - "package-lock.json", + "pnpm-lock.yaml", "tsconfig*.json", "packages/**/tsconfig*.json", "packages/extension/postcss.config.cjs", @@ -10,8 +10,8 @@ ], "tasks": { "build": { - "dependsOn": ["^build", "typecheck"], - "outputs": [] + "dependsOn": ["^build"], + "outputs": ["dist/**"] }, "lint": { "dependsOn": ["^lint"], @@ -22,9 +22,22 @@ "outputs": [] }, "test": { - "dependsOn": ["typecheck", "^test"], "outputs": [] }, + "test:node": { + "outputs": [] + }, + "test:webview": { + "env": ["CODEGRAPHY_VITEST_WEBVIEW_GROUP"], + "outputs": [] + }, + "test:playwright": { + "outputs": [ + "$TURBO_ROOT$/dist/webview/**", + "$TURBO_ROOT$/playwright-report/**", + "$TURBO_ROOT$/test-results/**" + ] + }, "watch": { "cache": false, "persistent": true