diff --git a/.agents/skills/ci-prep/SKILL.md b/.agents/skills/ci-prep/SKILL.md index b7d69b3d..ba1f1c92 100644 --- a/.agents/skills/ci-prep/SKILL.md +++ b/.agents/skills/ci-prep/SKILL.md @@ -45,10 +45,10 @@ Read **every line** of `--log-failed` output. For each failure note the exact fi 1. Read **every** `.github/workflows/ci*.yml` completely — the PR pipeline is split into reusable workflows ([DIST-CI-LAYOUT]), so `ci.yml` alone only shows the orchestration: - `ci.yml` — orchestrator: `detect-changes`, dependency review, manifest validation, one `uses:` job per leg - `ci-lint.yml` — Rust / Zed / .NET / VS Code lint + format gates - - `ci-rust.yml` — sharded Rust e2e suite, coverage gate, version contract - - `ci-dotnet.yml` — sidecar tests (Ubuntu) + win32 named-pipe transport - - `ci-vsix.yml` — full VS Code suite + coverage gate (Ubuntu) - - `ci-vsix-windows.yml` — VS Code feature chunks on Windows ([DIST-CI-WIN-VSIX]) + - `ci-test-rust.yml` — sharded Rust e2e suite, coverage gate, version contract + - `ci-test-dotnet.yml` — sidecar tests (Ubuntu) + win32 named-pipe transport + - `ci-test-vsix.yml` — full VS Code suite + coverage gate (Ubuntu) + - `ci-test-vsix-windows.yml` — VS Code feature chunks on Windows ([DIST-CI-WIN-VSIX]) 2. Parse every job and every step, then extract the ordered list of commands the CI actually runs. 3. Note any environment variables, matrix strategies, or conditional steps that affect execution. In particular the Windows VS Code matrix expands from `src/editors/vscode/test-chunks.json` — enumerate the chunks with `node tools/vsix/vsix-test-chunks.mjs matrix` and run each locally as `make _test-vsix-win CHUNK=`. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..06e8b1df --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "deny": [ + "Agent", + "Task" + ] + }, + "disableWorkflows": true, + "autoMemoryEnabled": false +} diff --git a/.github/actions/build-platform/action.yml b/.github/actions/build-platform/action.yml new file mode 100644 index 00000000..ffa63d32 --- /dev/null +++ b/.github/actions/build-platform/action.yml @@ -0,0 +1,148 @@ +# agent-pmo:0b21609 +--- +# PHASE 2 (FULL BUILD) + PHASE 3 (CACHE) for ONE platform. [DIST-CI-LAYOUT] +# +# One composite action so the Linux and Windows build jobs are the SAME +# pipeline, not two hand-maintained copies of it. They differ only in the inputs +# below; everything either of them compiles, it compiles here, exactly once: +# +# * the VS Code test suite (tsc + the instrumented esbuild bundle) +# * the release `sharplsp` host +# * both .NET sidecars +# * the patched netcoredbg adapter (from cache - see ../netcoredbg) +# * the packaged VSIX +# +# and every one of those outputs is uploaded here, because PHASE 3 is the +# handoff boundary: no job downstream of this action is permitted to compile +# anything. A test leg that rebuilds is a bug. +# +# STEP ORDER IS LOAD-BEARING. `_build-vsix-suite` writes the INSTRUMENTED bundle +# to src/editors/vscode/dist and that is what the coverage shards measure; +# `_build-vsix` then overwrites the same directory with the PRODUCTION bundle, +# which has no sourcemap. The suite artifact is therefore uploaded BEFORE the +# VSIX is packed. Reordering these two silently strips end-to-end coverage - it +# does not fail, it just reports a smaller number. +name: Build one platform +description: >- + Compiles the VS Code suite, the release host, both sidecars, netcoredbg and + the VSIX for a single platform, and publishes every artifact the test phase + consumes. +inputs: + platform: + description: '"linux" or "win" - selects which feature chunks this platform runs.' + required: true + vsix-platform: + description: Platform triple for netcoredbg and the standalone archive, e.g. linux-x64. + required: true + host-binary: + description: Path of the built host binary, e.g. target/release/sharplsp. + required: true + suite-artifact: + description: Artifact name for the compiled VS Code suite. + required: true + binaries-artifact: + description: Artifact name for the host binary and both sidecars. + required: true + debugger-artifact: + description: Artifact name for the patched netcoredbg build. + required: true + archive: + description: >- + '"true" to also build and verify the standalone server archive and the + package-manager manifests. Ubuntu only - one platform is enough to catch a + layout regression, and release.yml builds the rest at tag time.' + required: false + default: 'false' +outputs: + chunks: + description: JSON array of feature-chunk names for this platform's test matrix. + value: ${{ steps.suite.outputs.chunks }} +runs: + using: composite + steps: + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: "~/.nuget/packages" + key: >- + ${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', + 'src/sidecars/Directory.Build.props') }} + restore-keys: "${{ runner.os }}-nuget-" + + # ── The VS Code suite, and this platform's chunk matrix ──────── + # First, because it publishes the INSTRUMENTED bundle the shards measure. + - name: Compile the VS Code suite and resolve the chunk matrix + id: suite + uses: ./.github/actions/vsix-suite + with: + platform: ${{ inputs.platform }} + suite-artifact: ${{ inputs.suite-artifact }} + + # ── The host, the sidecars and the debug adapter ─────────────── + - name: Build sharplsp host and both sidecars (release) + shell: bash + run: make _build-rust _build-dotnet + - name: Build or restore patched netcoredbg + uses: ./.github/actions/netcoredbg + with: + vsix-platform: ${{ inputs.vsix-platform }} + + # ── PHASE 3: publish everything the test phase consumes ──────── + - name: Upload LSP binaries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.binaries-artifact }} + retention-days: 1 + path: | + ${{ inputs.host-binary }} + target/sidecar-csharp + target/sidecar-fsharp + - name: Upload patched debugger + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.debugger-artifact }} + retention-days: 1 + include-hidden-files: true + path: target/netcoredbg/${{ inputs.vsix-platform }}/netcoredbg + + # ── Packaging checks over what was just built ────────────────── + # These assert the SHAPE of the build outputs, so they belong to the build + # phase and not to a test leg. They add seconds, because everything they + # package is already compiled above. + - name: Sidecar pack smoke test + if: ${{ inputs.archive == 'true' }} + shell: bash + run: |- + set -euo pipefail + dotnet pack src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj \ + -p:PackageVersion=0.0.0-ci -c Release -o /tmp/nupkg-smoke + dotnet pack src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj \ + -p:PackageVersion=0.0.0-ci -c Release -o /tmp/nupkg-smoke + ls -la /tmp/nupkg-smoke/*.nupkg + - name: Package and verify the standalone server archive + # [DIST-ARCHIVE] How Rider, Zed, Neovim, Helix and the Homebrew/Scoop + # formulas install SharpLsp - no VSIX, no shipwright, so the host must + # find its sidecars by the archive layout alone. Only release.yml built + # it before, which meant a layout regression surfaced at tag time with the + # release already half-published. + if: ${{ inputs.archive == 'true' }} + shell: bash + run: |- + set -euo pipefail + make _package-archive VSIX_PLAT=${{ inputs.vsix-platform }} ARCHIVE_LSP=${{ inputs.host-binary }} + bash tools/packaging/verify-archive.sh ${{ inputs.vsix-platform }} + - name: Verify Homebrew formula and Scoop manifest renderer + # Both are only rendered on a tag, so without this the first sign of a + # broken one is a user's failed `brew install`. [DIST-PATH-INSTALL] + if: ${{ inputs.archive == 'true' }} + shell: bash + run: node tools/packaging/verify-package-manifests.mjs + + # ── The VSIX, LAST ───────────────────────────────────────────── + # Overwrites src/editors/vscode/dist with the production bundle, which is + # why it runs after the suite artifact is already published. + - name: Pack and verify the VSIX + uses: ./.github/actions/vsix-payload diff --git a/.github/actions/netcoredbg/action.yml b/.github/actions/netcoredbg/action.yml new file mode 100644 index 00000000..317c6c81 --- /dev/null +++ b/.github/actions/netcoredbg/action.yml @@ -0,0 +1,46 @@ +# agent-pmo:0b21609 +--- +# PHASE 2 (FULL BUILD). Puts the patched netcoredbg debug adapter for one +# platform on disk. [DIST-DEBUGGER-BUNDLE] +# +# It does not decide HOW. tools/netcoredbg/provide.mjs owns that: it downloads +# the SHA-256-pinned archive named in netcoredbg.lock.json and verifies the +# bytes, and only compiles from source for a platform that has no pin yet. Both +# CI and `make` go through it, so there is one answer to "which netcoredbg is +# this" everywhere. +# +# The cache below covers the source-build path only. Compiling the adapter cost +# 1m27 on Ubuntu and 4m38 on Windows, on EVERY pull request, for a binary whose +# inputs change only when the pinned commits or the patch change - and on the +# Windows leg it was the single largest item on the critical path. The key +# hashes exactly the inputs that determine the result, so a bump misses and +# rebuilds, and nothing else does. Once the lock file carries pins, a run +# restores from cache or downloads a verified archive and never compiles at all. +name: Provide the patched netcoredbg +description: >- + Downloads the SHA-256-pinned netcoredbg archive for the given platform, or + builds and caches it when that platform has no pin. +inputs: + vsix-platform: + description: Target platform triple, e.g. linux-x64 or win32-x64. + required: true +outputs: + path: + description: Directory holding the adapter. + value: target/netcoredbg/${{ inputs.vsix-platform }}/netcoredbg +runs: + using: composite + steps: + - name: Restore a previously built adapter + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # The `.sharplsp-dap-hot-reload` marker provide.mjs checks is a dotfile; + # actions/cache preserves it, so a hit short-circuits both the download + # and the build. + path: target/netcoredbg/${{ inputs.vsix-platform }}/netcoredbg + key: >- + netcoredbg-${{ inputs.vsix-platform }}-${{ hashFiles('tools/netcoredbg/netcoredbg.lock.json', + 'tools/netcoredbg/dap-hot-reload.patch', 'tools/vsix/build-netcoredbg.sh') }} + - name: Download or build the patched adapter + shell: bash + run: node tools/netcoredbg/provide.mjs ${{ inputs.vsix-platform }} diff --git a/.github/actions/vsix-node-deps/action.yml b/.github/actions/vsix-node-deps/action.yml new file mode 100644 index 00000000..f355bc79 --- /dev/null +++ b/.github/actions/vsix-node-deps/action.yml @@ -0,0 +1,47 @@ +# agent-pmo:0b21609 +--- +# ONE way to put the VS Code extension's `node_modules` on a runner. +# +# `npm ci` for this package takes 3m16 on Ubuntu and 4m31 on Windows even with a +# warm `~/.npm`, because the cost is not the download - it is unpacking 524 +# packages and running their install scripts. PHASE 4 pays that once per shard +# across ~30 shards, plus once per platform in PHASE 2 and once in PHASE 1, and +# every one of those minutes is on the critical path of the job that pays it. +# +# So cache the RESULT rather than the inputs. `~/.npm` (what `setup-node`'s +# `cache: npm` restores) only makes the download free; a `node_modules` cache +# makes the whole install free. `npm ci` still runs on a miss, and `setup-node` +# still primes `~/.npm` so that miss is as cheap as it can be. +# +# The key carries the lockfile hash, the OS and the runner architecture, because +# the tree contains platform-specific optional dependencies (esbuild's native +# binary among them) that must never cross between runners. There is +# deliberately NO `restore-keys`: a partial `node_modules` from a different +# lockfile is worse than no cache at all - `npm ci` would be skipped and the +# tree would be silently wrong. +name: Install VS Code extension dependencies +description: >- + Sets up Node and restores src/editors/vscode/node_modules from cache, running + `npm ci` only when the cache misses. +runs: + using: composite + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + cache: npm + cache-dependency-path: src/editors/vscode/package-lock.json + - name: Restore the installed dependency tree + id: node-modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: src/editors/vscode/node_modules + key: >- + vsix-node-modules-${{ runner.os }}-${{ runner.arch }}-node20-${{ + hashFiles('src/editors/vscode/package-lock.json') }} + - name: Install VS Code extension deps + # Only on a miss. On a hit the tree is already exactly what this lockfile + # resolves to, and `npm ci` would delete it and rebuild it from scratch. + if: steps.node-modules.outputs.cache-hit != 'true' + shell: bash + run: npm ci --prefix src/editors/vscode diff --git a/.github/actions/vsix-payload/action.yml b/.github/actions/vsix-payload/action.yml index 224aa27b..8098acf3 100644 --- a/.github/actions/vsix-payload/action.yml +++ b/.github/actions/vsix-payload/action.yml @@ -1,57 +1,30 @@ # agent-pmo:0b21609 --- -# Implements [DIST-CI-VSIX-SHARDS]. ONE composite action packing the real VSIX -# and proving the platform binary is inside it, used verbatim by both platform -# legs. +# PHASE 2 (FULL BUILD). Packs the real VSIX and proves the platform binary is +# actually inside it. Used by both platform build jobs via ../build-platform. # -# This check used to be a prerequisite of every test job, which meant one -# production esbuild plus a `vsce ls` per chunk for an answer that cannot vary by -# chunk - and, worse, it left the PRODUCTION bundle in `dist/`, whose missing -# sourcemap silently stripped the end-to-end coverage the shards exist to -# collect. As its own job on each leg it reports in minutes and touches nothing -# the shards depend on. +# This was its own job on each leg, which had to re-install npm dependencies and +# re-download the host + sidecars it was about to package - a full job prelude +# for a `vsce package` over binaries that already existed on the build machine. +# Running it as the LAST step of the build that produced those binaries removes +# both round trips, and puts the VSIX where PHASE 2 says it belongs: built with +# everything else, exactly once, per platform. +# +# It must stay last. `npm run build` writes the PRODUCTION bundle over +# src/editors/vscode/dist, and the production bundle carries no sourcemap; the +# instrumented bundle the coverage shards measure has to have been published +# before this runs. name: Verify the packaged VSIX payload description: >- - Packs the VSIX from prebuilt binaries and asserts the platform `sharplsp` - binary is actually in the archive. -inputs: - binaries-artifact: - description: Artifact holding the release host and both sidecars. - required: true - debugger-artifact: - description: Artifact holding the patched netcoredbg build. - required: true - debugger-path: - description: Where the debugger artifact unpacks, e.g. target/netcoredbg/linux-x64/netcoredbg. - required: true + Packs the VSIX from the binaries already built in this job and asserts the + platform `sharplsp` binary is in the archive. runs: using: composite steps: - - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: | - 9.0.x - 10.0.303 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - cache: npm - cache-dependency-path: src/editors/vscode/package-lock.json - - name: Install VS Code extension deps - shell: bash - run: npm ci --prefix src/editors/vscode - - name: Download the prebuilt host and sidecars - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.binaries-artifact }} - path: target - - name: Download the patched debugger - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.debugger-artifact }} - path: ${{ inputs.debugger-path }} - name: Build the VSIX shell: bash + # VSIX_PREBUILT=1 stages the host and sidecars this job just built rather + # than invoking cargo and dotnet a second time. run: env VSIX_PREBUILT=1 make _build-vsix - name: Verify the VSIX contains the platform binary shell: bash diff --git a/.github/actions/vsix-shard/action.yml b/.github/actions/vsix-shard/action.yml index 10fecf3e..642d5a39 100644 --- a/.github/actions/vsix-shard/action.yml +++ b/.github/actions/vsix-shard/action.yml @@ -54,14 +54,8 @@ runs: dotnet-version: | 9.0.x 10.0.303 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - cache: npm - cache-dependency-path: src/editors/vscode/package-lock.json - name: Install VS Code extension deps - shell: bash - run: npm ci --prefix src/editors/vscode + uses: ./.github/actions/vsix-node-deps - name: Download the compiled suite uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/actions/vsix-suite/action.yml b/.github/actions/vsix-suite/action.yml index 8ea6dffe..ee7f67b9 100644 --- a/.github/actions/vsix-suite/action.yml +++ b/.github/actions/vsix-suite/action.yml @@ -37,14 +37,8 @@ runs: dotnet-version: | 9.0.x 10.0.303 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - cache: npm - cache-dependency-path: src/editors/vscode/package-lock.json - name: Install VS Code extension deps - shell: bash - run: npm ci --prefix src/editors/vscode + uses: ./.github/actions/vsix-node-deps - name: Resolve the feature chunk matrix id: chunks shell: bash diff --git a/.github/workflows/ci-analyse.yml b/.github/workflows/ci-analyse.yml new file mode 100644 index 00000000..636226a6 --- /dev/null +++ b/.github/workflows/ci-analyse.yml @@ -0,0 +1,72 @@ +# agent-pmo:0b21609 +--- +# PHASE 1 — ANALYSE. [DIST-CI-LAYOUT] +# +# Lint, format checks and static analysis, for every language in the repo. +# Nothing else in the pipeline runs until this is green, and it builds nothing +# that any later phase consumes: whatever clippy and the .NET analysers compile +# on the way to a verdict is thrown away with the runner. +# +# This used to be the first half of a combined "Checks + Build" job. Fusing the +# two meant the Windows build could not start until Ubuntu had finished linting +# AND finished a full Ubuntu release build it does not use - 6m45 of dead wait +# before the slowest leg of the pipeline began. Splitting them is what lets +# PHASE 2 fan out across platforms the moment analysis passes. +name: CI / Analyse +'on': + workflow_call: {} +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true +permissions: + contents: read +jobs: + analyse: + name: Lint + Format + Analysis + runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + components: clippy, rustfmt + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: | + 9.0.x + 10.0.303 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: "~/.nuget/packages" + key: >- + ${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', + 'src/sidecars/Directory.Build.props') }} + restore-keys: "${{ runner.os }}-nuget-" + - name: Install VS Code extension deps + uses: ./.github/actions/vsix-node-deps + - name: Restore the .NET tool manifest + run: dotnet tool restore + + # ── Rust ─────────────────────────────────────────────────────── + - name: Format + lint (Rust) + run: make _lint-rust PROFILE=debug + - name: Format + lint (Zed) + run: make _lint-zed + + # ── .NET ─────────────────────────────────────────────────────── + - name: Format check (.NET) + run: dotnet csharpier check src/sidecars/ + - name: Lint (.NET) + run: make _lint-dotnet + + # ── TypeScript ───────────────────────────────────────────────── + - name: Format check (VS Code) + run: cd src/editors/vscode && npx prettier@3.9.4 --check 'src/**/*.ts' + - name: Lint (VS Code) + # Also asserts every test suite belongs to a declared feature chunk, so + # a new suite cannot silently drop out of the PHASE 4 matrix. + run: make _lint-vsix diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index af412192..52fde513 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -1,17 +1,36 @@ # agent-pmo:0b21609 --- -# Single sequential gate that runs every check, analysis and format guard, and -# then builds the release artifacts ONCE so the parallel test legs can consume -# them without rebuilding. Called by ci.yml as the only `needs:` prerequisite of -# every test leg: modified-files -> checks+build -> parallel tests. +# PHASE 2 (FULL BUILD) + PHASE 3 (CACHE). [DIST-CI-LAYOUT] # -# Lint and build are one job here on purpose ([DIST-CI-LAYOUT]): the test legs -# are gated on this job, so a format/analysis failure fails the pipeline before -# any test machine spins up, and the release binaries produced here are the -# ONLY build of the host and sidecars on Ubuntu — no test leg may rebuild them. +# Everything the pipeline needs, built for every platform, ONCE, and published. +# The two jobs below are SIBLINGS: no `needs:` between them, so Ubuntu and +# Windows compile concurrently and PHASE 2 costs one platform's build, not the +# sum of both. +# +# That is the fix for the pipeline's largest structural defect. The Windows +# build used to live in the TEST phase, gated on the Ubuntu build - which it +# consumes nothing from - so it began 6m45 after the run started and every +# Windows test chunk began 11m02 after that. 18 minutes of a 41-minute pipeline +# was builds waiting on other builds. +# +# Both jobs run the SAME composite action (../actions/build-platform). There is +# no Windows-only build recipe: the two had already drifted apart once, to the +# point where Windows ran its whole VS Code suite uninstrumented while Ubuntu +# carried the entire coverage number. +# +# PHASE 3 is the handoff boundary. Every artifact a test job needs is uploaded +# here, and NOTHING downstream of this workflow is permitted to compile +# anything. A test leg that invokes cargo, dotnet build or tsc is a bug. name: CI / Build 'on': - workflow_call: {} + workflow_call: + outputs: + linux-chunks: + description: JSON array of VS Code feature chunks to run on Ubuntu. + value: ${{ jobs.build-linux.outputs.chunks }} + win-chunks: + description: JSON array of VS Code feature chunks to run on Windows. + value: ${{ jobs.build-windows.outputs.chunks }} env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 @@ -20,100 +39,46 @@ env: permissions: contents: read jobs: - build: - name: Checks + Build + build-linux: + name: Linux runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 45 + timeout-minutes: 30 + outputs: + chunks: ${{ steps.build.outputs.chunks }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: stable - components: clippy, rustfmt - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + - name: Build every Linux artifact + id: build + uses: ./.github/actions/build-platform with: - dotnet-version: | - 9.0.x - 10.0.303 - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: "~/.nuget/packages" - key: "${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', 'src/sidecars/Directory.Build.props') }}" - restore-keys: "${{ runner.os }}-nuget-" - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - cache: npm - cache-dependency-path: src/editors/vscode/package-lock.json - - name: Install dependencies - run: | - dotnet tool restore - npm ci --prefix src/editors/vscode - - # ── Checks, format and analysis ──────────────────────────────── - - name: Format + lint (Rust) - run: make _lint-rust PROFILE=debug - - name: Format + lint (Zed) - run: make _lint-zed - - name: Format check (.NET) - run: dotnet csharpier check src/sidecars/ - - name: Lint (.NET) - run: make _lint-dotnet - - name: Sidecar pack smoke test - run: | - dotnet pack src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj \ - -p:PackageVersion=0.0.0-ci -c Release -o /tmp/nupkg-smoke - dotnet pack src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj \ - -p:PackageVersion=0.0.0-ci -c Release -o /tmp/nupkg-smoke - ls -la /tmp/nupkg-smoke/*.nupkg - - name: Format check (VS Code) - run: cd src/editors/vscode && npx prettier@3.9.4 --check 'src/**/*.ts' - - name: Lint (VS Code) - run: make _lint-vsix + platform: linux + vsix-platform: linux-x64 + host-binary: target/release/sharplsp + suite-artifact: vsix-suite-linux + binaries-artifact: linux-lsp-binaries + debugger-artifact: linux-netcoredbg + # One platform is enough to catch a layout regression in the + # standalone archive and the package-manager manifests. + archive: 'true' - # ── Build release artifacts once ─────────────────────────────── - - name: Build sharplsp host (release) - run: make _build-rust - - name: Build .NET sidecars (release) - run: make _build-dotnet - - name: Build patched netcoredbg - run: bash tools/vsix/build-netcoredbg.sh linux-x64 - - # ── Standalone server archive ────────────────────────────────── - # [DIST-ARCHIVE] The archive is how Rider, Zed, Neovim, Helix and the - # Homebrew/Scoop formulas install SharpLsp — no VSIX, no shipwright, so the - # host must find its sidecars by the archive layout alone. Only release.yml - # built it before, which meant a layout regression surfaced at tag time with - # the release already half-published. It costs seconds here because the host - # and both sidecars are already built above. - - name: Package + verify standalone server archive - run: | - make _package-archive VSIX_PLAT=linux-x64 ARCHIVE_LSP=target/release/sharplsp - bash tools/packaging/verify-archive.sh linux-x64 - - # The Homebrew formula and Scoop manifest are only rendered on a tag, so - # without this the first sign of a broken one is a user's failed - # `brew install`. Runs the real renderer over fixture archives and asserts - # the install layout each package manager has to produce. - # [DIST-PATH-INSTALL] - - name: Verify Homebrew formula + Scoop manifest renderer - run: node tools/packaging/verify-package-manifests.mjs - - # ── Cache the build for the parallel test legs ───────────────── - - name: Upload Linux LSP artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: linux-lsp-binaries - retention-days: 1 - path: | - target/release/sharplsp - target/sidecar-csharp - target/sidecar-fsharp - - name: Upload patched Linux debugger - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + build-windows: + name: Windows + # NO `needs:`. This is a sibling of build-linux, not its successor - it + # consumes nothing Ubuntu produces, and gating it on Ubuntu bought the + # pipeline nothing but 6m45 of idle Windows runner. + runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }} + timeout-minutes: 40 + outputs: + chunks: ${{ steps.build.outputs.chunks }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Build every Windows artifact + id: build + uses: ./.github/actions/build-platform with: - name: linux-netcoredbg - retention-days: 1 - include-hidden-files: true - path: target/netcoredbg/linux-x64/netcoredbg + platform: win + vsix-platform: win32-x64 + host-binary: target/release/sharplsp.exe + suite-artifact: vsix-suite-win + binaries-artifact: win-lsp-binaries + debugger-artifact: win-netcoredbg diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml new file mode 100644 index 00000000..b864fd91 --- /dev/null +++ b/.github/workflows/ci-coverage.yml @@ -0,0 +1,86 @@ +# agent-pmo:0b21609 +--- +# PHASE 5 — COVERAGE CHECK. +# +# The two SHARDED suites gate here, and only here. A gate needs a complete +# tracefile, and neither of these produces one in any single job: the Rust suite +# runs as 2 nextest partitions ([DIST-CI-RUST-SHARDS]) and the VS Code suite as +# ~26 Ubuntu plus ~23 Windows feature chunks ([DIST-CI-VSIX-SHARDS], +# [DIST-CI-WIN-VSIX]). No shard can meet the line threshold alone, so every +# shard exports lcov and gates nothing, and the ratchet runs once over the union. +# +# The unsharded legs - .NET sidecars, Zed, Rider - apply their ratchet inside +# their own test job, where the tracefile is already complete. Routing them +# through here would add an artifact round trip to reach the same number. +# +# Why the union is sound: every shard instruments the SAME build, so a file +# loaded by any shard carries its whole line set there (unexecuted lines as +# `DA:,0`), and summing hit counts per (file, line) reproduces the line +# percentage of one unsharded run. VS Code shards write repo-relative `SF:` paths +# (tools/coverage/relativize-lcov.mjs) so a file measured on Windows and on +# Ubuntu keys as ONE file rather than two - without that the denominator would +# double and the gate would fail for a reason unrelated to coverage. +name: CI / Coverage +'on': + workflow_call: {} +permissions: + contents: read +jobs: + coverage-rust: + name: Rust + runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # check-coverage.mjs ratchets against the threshold committed at the + # merge base, which a shallow checkout does not contain. + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-rust-shard-* + merge-multiple: true + path: target + - name: Merge shard coverage and enforce threshold + run: make _gate-rust-coverage + - name: Upload merged coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-rust + path: | + target/coverage-rust.lcov + coverage-thresholds.json + + coverage-vsix: + name: VS Code + # [DIST-CI-VSIX-COVERAGE] ONE gate for the extension, over the shards of + # BOTH platforms. Gating per leg would have let the Windows shards run + # uninstrumented while Ubuntu carried the whole number - which is exactly + # what happened the last time these two legs drifted apart. + runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + - name: Download every shard tracefile (both platforms) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-vsix-shard-* + merge-multiple: true + path: target + - name: Merge shard coverage and enforce threshold + run: make _gate-vsix-coverage + - name: Upload merged coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-vsix + path: | + target/coverage-vsix.lcov + coverage-thresholds.json diff --git a/.github/workflows/ci-dotnet.yml b/.github/workflows/ci-test-dotnet.yml similarity index 62% rename from .github/workflows/ci-dotnet.yml rename to .github/workflows/ci-test-dotnet.yml index 5ba63dac..7c4d8c0d 100644 --- a/.github/workflows/ci-dotnet.yml +++ b/.github/workflows/ci-test-dotnet.yml @@ -1,7 +1,18 @@ # agent-pmo:0b21609 --- -# Reusable .NET sidecar leg of the PR pipeline (called by ci.yml). -name: CI / .NET +# PHASE 4 — TEST. The .NET sidecar leg. +# +# EVERY TEST RUNS EXACTLY ONCE. The Ubuntu job runs the whole sidecar solution. +# The Windows job runs ONLY the classes whose behaviour is platform-dependent - +# named-pipe connection setup and the real sidecar handshake over those pipes +# ([DIST-CI-WIN-TRANSPORT]). +# +# It used to run the entire SharpLsp.Sidecar.Common.Tests project instead, which +# re-executed about a dozen files of platform-agnostic assertions - XML doc +# rendering, solution-file reading, metadata decompilation, log formatting - that +# the Ubuntu job had already run. The filter lives in tools/make/main.mk beside +# the target, not in this YAML. +name: CI / Test / .NET 'on': workflow_call: {} env: @@ -33,9 +44,12 @@ jobs: run: chmod +x target/sidecar-csharp/SharpLsp.Sidecar.CSharp target/sidecar-fsharp/SharpLsp.Sidecar.FSharp - name: Test .NET sidecars with coverage env: - # The `build` leg already published both sidecars; this job stages - # the downloaded artifacts instead of rebuilding them. + # PHASE 2 already published both sidecars; stage the downloaded + # artifacts instead of rebuilding them. VSIX_PREBUILT: '1' + # PHASE 5 for this leg is the ratchet inside `_test-dotnet`. The suite is + # not sharded, so the gate has a complete tracefile in-job and needs no + # separate merge - unlike the Rust and VS Code legs (ci-coverage.yml). run: make _test-dotnet - name: Verify sidecar version flags shell: bash @@ -45,11 +59,7 @@ jobs: FS_VERSION="$(dotnet msbuild src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj -getProperty:Version -nologo)" target/sidecar-csharp/SharpLsp.Sidecar.CSharp --version | grep -Fx "sharplsp-sidecar-csharp ${CS_VERSION}" target/sidecar-fsharp/SharpLsp.Sidecar.FSharp --version | grep -Fx "sharplsp-sidecar-fsharp ${FS_VERSION}" - - name: Upload coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-dotnet - path: coverage-thresholds.json + test-dotnet-windows: name: Named-Pipe Transport (Windows) # Implements [DIST-CI-WIN-TRANSPORT]: the named-pipe arm of the sidecar IPC @@ -64,7 +74,10 @@ jobs: dotnet-version: | 9.0.x 10.0.303 - - name: Restore full solution (caches FCS nuspec for dependency-consistency tests) - run: dotnet restore src/sidecars/SharpLsp.Sidecars.sln - - name: Test sidecar IPC transport on Windows - run: dotnet test src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj --blame-hang-timeout 2min --blame-hang-dump-type none + - name: Test the win32 named-pipe transport + # Only the platform-dependent classes. The full-solution `dotnet restore` + # that used to precede this existed to cache the FCS nuspec for the + # dependency-consistency tests, which are platform-agnostic and now run + # only on Ubuntu. + shell: bash + run: make _test-dotnet-win-transport diff --git a/.github/workflows/ci-editors.yml b/.github/workflows/ci-test-editors.yml similarity index 65% rename from .github/workflows/ci-editors.yml rename to .github/workflows/ci-test-editors.yml index 6df4f361..d6bddd19 100644 --- a/.github/workflows/ci-editors.yml +++ b/.github/workflows/ci-test-editors.yml @@ -1,14 +1,23 @@ # agent-pmo:0b21609 --- -# Reusable editor-integration leg of the PR pipeline (called by ci.yml). +# PHASE 4 — TEST. The editor-integration leg. [DIST-CI-EDITORS] # # Both of these shipped for their whole history with no CI job at all. The Zed # extension's 23 unit tests existed in the tree and were compiled by # `make _lint-zed` but never executed; the Rider plugin had a fully configured # JUnit harness and not one test file, and was not even compiled on a PR. -# Each now runs its tests behind the same ratcheted coverage gate every other -# package answers to. [DIST-CI-EDITORS] -name: CI / Editors +# +# Neither consumes a PHASE 2 artifact, because neither links against one: the Zed +# extension is a standalone Cargo workspace that ships as wasm32-wasip1, and the +# Rider plugin is a Kotlin/IntelliJ build. What each compiles here is its own +# instrumented test binaries, the same category of work the Rust shards do, and +# for the same reason - an instrumented build is not the artifact PHASE 2 +# produced. +# +# PHASE 5 for both is the ratchet inside the make target. Neither suite is +# sharded, so each gate sees a complete tracefile in-job and needs no separate +# merge, unlike the Rust and VS Code legs (ci-coverage.yml). +name: CI / Test / Editors 'on': workflow_call: {} env: @@ -21,9 +30,8 @@ permissions: jobs: test-zed: name: Zed Extension - # The Zed extension is a standalone Cargo workspace (it ships as - # wasm32-wasip1), so the root `cargo llvm-cov` run in ci-rust.yml cannot - # see it and it needs its own gate. Its unit tests build for the host. + # The root `cargo llvm-cov` run in ci-test-rust.yml cannot see this + # workspace, so it needs its own gate. Its unit tests build for the host. runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} timeout-minutes: 15 steps: @@ -43,6 +51,7 @@ jobs: node-version: '20' - name: Test + coverage gate (Zed) run: make _test-zed + test-rider: name: Rider Plugin # RIDER_REQUIRED=1 turns "no JDK 21+ found" from a local convenience skip @@ -74,11 +83,17 @@ jobs: ~/.gradle/caches ~/.gradle/wrapper src/editors/rider/.intellijPlatform - key: "${{ runner.os }}-gradle-${{ hashFiles('src/editors/rider/build.gradle.kts', 'src/editors/rider/gradle.properties', 'src/editors/rider/gradle/**') }}" + key: >- + ${{ runner.os }}-gradle-${{ hashFiles('src/editors/rider/build.gradle.kts', + 'src/editors/rider/gradle.properties', 'src/editors/rider/gradle/**') }} restore-keys: "${{ runner.os }}-gradle-" - name: Test + coverage gate (Rider) run: make _test-rider - name: Build plugin - # The plugin was never compiled on a PR before this job existed, so a - # Kotlin break in it could reach main behind a fully green pipeline. + # The shippable plugin zip. It stays here rather than in PHASE 2 because + # it reuses the Gradle daemon and the IntelliJ SDK this job has already + # resolved - building it on a separate PHASE 2 runner would compile the + # same Kotlin twice and re-download a multi-GB SDK to do it. Nothing + # downstream consumes the zip; this is the check that a Kotlin break + # cannot reach main behind a green pipeline. run: make _build-rider diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-test-rust.yml similarity index 62% rename from .github/workflows/ci-rust.yml rename to .github/workflows/ci-test-rust.yml index bb1efe2a..32400582 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-test-rust.yml @@ -1,8 +1,15 @@ # agent-pmo:0b21609 --- -# Reusable Rust leg of the PR pipeline (called by ci.yml): sharded e2e suite, -# the single coverage gate over the shard union, and the version contract. -name: CI / Rust +# PHASE 4 — TEST. The Rust leg. [DIST-CI-RUST-SHARDS] +# +# Consumes the PHASE 3 artifacts and rebuilds none of them: the release +# `sharplsp` host and both sidecars are downloaded, never recompiled. +# +# What this leg DOES compile is the instrumented test binaries, and that is not +# a violation of the phase-3 handoff: a coverage build is a different profile +# with different codegen from the release artifact, so no build in PHASE 2 could +# have produced it. The rule is that no test leg rebuilds a SHIPPING artifact. +name: CI / Test / Rust 'on': workflow_call: {} env: @@ -15,11 +22,11 @@ permissions: jobs: test-rust: name: Shard ${{ matrix.shard }}/2 - # [DIST-CI-RUST-SHARDS]: the e2e suite runs single-threaded (tests spawn - # real Roslyn/FCS sidecars), so wall time scales with test count, not - # cores. Two nextest hash partitions halve the ~11-minute serial run while - # duplicating only ~90s of warm-cache build. Each shard exports lcov; the - # single ratcheted gate runs over the union in coverage-rust below. + # The e2e suite runs single-threaded (tests spawn real Roslyn/FCS sidecars), + # so wall time scales with test count, not cores. Two nextest hash + # partitions halve the ~11-minute serial run. Each shard exports lcov; the + # single ratcheted gate runs over the union in PHASE 5 (ci-coverage.yml), + # because no partition can meet the full-suite threshold on its own. runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} timeout-minutes: 30 strategy: @@ -47,7 +54,9 @@ jobs: - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: "~/.nuget/packages" - key: "${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', 'src/sidecars/Directory.Build.props') }}" + key: >- + ${{ runner.os }}-nuget-${{ hashFiles('src/sidecars/**/*.csproj', + 'src/sidecars/Directory.Build.props') }} restore-keys: "${{ runner.os }}-nuget-" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -57,10 +66,7 @@ jobs: dotnet tool install -g dotnet-trace dotnet tool install -g dotnet-counters dotnet tool install -g dotnet-dump - - name: Download prebuilt sidecars - # The `build` leg already published both sidecars; the shards only need - # them at the e2e spawn paths, so they are staged from the artifact — - # never rebuilt here. + - name: Download prebuilt host and sidecars uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: linux-lsp-binaries @@ -78,45 +84,16 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-rust-shard-${{ matrix.shard }} + retention-days: 1 path: target/coverage-rust-shard${{ matrix.shard }}.lcov - coverage-rust: - name: Coverage Gate - needs: - - test-rust - # [DIST-CI-RUST-SHARDS]: every shard tracefile carries the full - # instrumented line set (unexecuted lines as DA:,0), so the union - # reproduces exactly the line percentage of an unsharded run. No shard can - # meet the threshold alone; the ratchet gate (check-coverage.sh) runs here - # once, over the merged total. - runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: coverage-rust-shard-* - merge-multiple: true - path: target - - name: Merge shard coverage and enforce threshold - run: make _gate-rust-coverage - - name: Upload coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-rust - path: | - target/coverage-rust.lcov - coverage-thresholds.json + version-contract: name: Version Contract # Implements [DIST-VERSION-OUTPUT] + the version line of [DIST-CI-SMOKE]. - # Separate from the Rust test job ([DIST-CI-RUST-SHARDS]): the release- - # profile build shares no artifacts with the instrumented test build, so - # running it there serialized ~90s onto the slowest job for zero reuse. + # Probes the artifact PHASE 2 published; `cargo metadata` only reads + # Cargo.toml, so nothing here compiles. runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 15 + timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @@ -126,15 +103,11 @@ jobs: with: node-version: '20' - name: Download prebuilt release binary - # `cargo metadata` only reads Cargo.toml; the release binary itself is - # the `build` leg's cached artifact — this job must not rebuild it. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: linux-lsp-binaries path: target - name: Restore executable bit - # download-artifact does not restore the +x the release build produced, - # so the version probe would fail with "Permission denied". shell: bash run: chmod +x target/release/sharplsp - name: Verify sharplsp version contract diff --git a/.github/workflows/ci-test-tooling.yml b/.github/workflows/ci-test-tooling.yml new file mode 100644 index 00000000..d0b80946 --- /dev/null +++ b/.github/workflows/ci-test-tooling.yml @@ -0,0 +1,33 @@ +# agent-pmo:0b21609 +--- +# PHASE 4 — TEST. The repo's own build tooling. +# +# Everything else in phase 4 tests the PRODUCT. This leg tests the machinery the +# product is built with, and specifically the supply-chain path: how the patched +# netcoredbg debug adapter is obtained ([DIST-DEBUGGER-BUNDLE]). +# +# That path had no test at all while it was a source build, and it is the one +# piece of the pipeline where a silent failure ships a different binary to users +# rather than turning CI red - so the assertions that matter here are the +# refusals: a digest mismatch must fail, must unpack nothing, and must NOT fall +# back to compiling from source. +# +# Node's built-in runner, so this leg installs no dependencies and needs no +# artifact from PHASE 2. It reports in well under a minute. +name: CI / Test / Tooling +'on': + workflow_call: {} +permissions: + contents: read +jobs: + test-tooling: + name: Build Tooling + runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + - name: Test the build tooling + run: make _test-tooling diff --git a/.github/workflows/ci-test-vsix-windows.yml b/.github/workflows/ci-test-vsix-windows.yml new file mode 100644 index 00000000..33c19fd1 --- /dev/null +++ b/.github/workflows/ci-test-vsix-windows.yml @@ -0,0 +1,87 @@ +# agent-pmo:0b21609 +--- +# PHASE 4 — TEST. The VS Code leg on Windows. [DIST-CI-WIN-VSIX] +# +# The transport job ([DIST-CI-WIN-TRANSPORT], ci-test-dotnet.yml) proves win32 +# named pipes carry frames. This leg proves the whole EDITOR EXPERIENCE works on +# top of them: the end-to-end suite sliced into feature chunks, each chunk its +# own Windows job, driving the REAL LSP - release-built `sharplsp` host plus the +# Roslyn and FCS sidecars - inside the real VS Code extension host. Ubuntu's leg +# cannot exercise any of that on win32. +# +# A chunk is a GROUP of feature areas, not one area per job. Every job repeats +# the same multi-minute preamble - setup-dotnet, node_modules, three artifact +# downloads, the VS Code host cache - so a matrix of 26 single-area jobs bought +# its parallelism by paying that preamble 26 times, and produced a check list +# too long to read. Nine grouped jobs pay it nine times and each still finishes +# well inside the ceiling below. +# +# THIS FILE NO LONGER BUILDS ANYTHING, and that is the single largest fix in the +# pipeline. It used to open with a `Build host + sidecars + suite` job that was +# gated on the Ubuntu build - which it consumed nothing from - and then compiled +# Rust, both sidecars, netcoredbg and the suite itself. Windows tests therefore +# began 17 minutes into a 41-minute run: 6m45 waiting for an unrelated Ubuntu +# build, then 11m02 building. Both platforms now build as PHASE 2 siblings and +# this leg is pure fan-out over the artifacts. +# +# It is the mirror of ci-test-vsix.yml and deliberately runs the SAME composite +# action and the SAME make target. There is no Windows-only recipe: the two had +# already drifted apart once, to the point where Windows ran the whole suite +# uninstrumented while Ubuntu carried the entire coverage number. +# +# Chunk membership is declared in src/editors/vscode/test-chunks.json and +# resolved once in PHASE 2 - never duplicated into this YAML. `make _lint-vsix` +# fails if a suite belongs to no chunk. The `win` matrix drops only the chunks +# marked `linuxOnly`, which clone and restore third-party repositories. +name: CI / Test / VS Code (Windows) +'on': + workflow_call: + inputs: + chunks: + description: JSON array of feature chunks, resolved by the PHASE 2 Windows build. + required: true + type: string +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true +permissions: + contents: read +jobs: + test-vsix-windows: + name: ${{ matrix.chunk }} + runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }} + # A chunk installs deps, downloads a compiled suite and prebuilt binaries, + # then runs its slice against real Roslyn/FCS sidecars loading real + # solutions. That preamble is FIXED COST paid once per job — several minutes + # of it — which is why a chunk is a GROUP of feature areas and not a single + # one: at 26 jobs the matrix spent more runner time on preamble than on + # tests, and read as a wall of 26 rows nobody could scan. + # + # The ceiling is per JOB, so it scales with membership; it is not a budget + # for one feature area to grow into. The per-test ceilings still live in + # src/test/suite/test-timeouts.ts and none exceeds 2 minutes, so a job + # approaching this is a hang, not a slow suite. + timeout-minutes: 40 + strategy: + # Every chunk is an independent GROUP of feature areas: one failure must + # not cancel the rest, or a single flake hides the state of the whole + # surface. + fail-fast: false + matrix: + chunk: ${{ fromJSON(inputs.chunks) }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Run the shard + # No xvfb: the GitHub Windows runner has an interactive desktop session, + # so the VS Code test host launches directly. + uses: ./.github/actions/vsix-shard + with: + chunk: ${{ matrix.chunk }} + platform: win32 + suite-artifact: vsix-suite-win + binaries-artifact: win-lsp-binaries + debugger-artifact: win-netcoredbg + debugger-path: target/netcoredbg/win32-x64/netcoredbg + user-data-dir: ${{ runner.temp }}/vsx-${{ matrix.chunk }} diff --git a/.github/workflows/ci-test-vsix.yml b/.github/workflows/ci-test-vsix.yml new file mode 100644 index 00000000..dc310d89 --- /dev/null +++ b/.github/workflows/ci-test-vsix.yml @@ -0,0 +1,81 @@ +# agent-pmo:0b21609 +--- +# PHASE 4 — TEST. The VS Code leg on Ubuntu. [DIST-CI-VSIX-SHARDS] +# +# Pure fan-out. This workflow contains no build job and no packaging job: the +# compiled suite, the host, both sidecars, netcoredbg and the VSIX all come from +# PHASE 2, and the chunk matrix is an INPUT resolved there rather than something +# recomputed here. +# +# A chunk is a GROUP of feature areas, not one area per job. Every job repeats +# the same multi-minute preamble - setup-dotnet, node_modules, three artifact +# downloads, the VS Code host cache - so a matrix of 29 single-area jobs bought +# its parallelism by paying that preamble 29 times, and produced a check list +# too long to read. Ten grouped jobs pay it ten times and each still finishes +# well inside the ceiling below. +# +# It used to open with a `Compile suite` job and carry a `VSIX Payload` job, both +# of which had to re-install npm dependencies before doing anything. Compiling +# the suite in PHASE 2 - where the machine that compiles it is the machine that +# already has the toolchain hot - removed a job from the front of the fan-out, +# and packing the VSIX beside the binaries it packages removed another. +# +# EVERY SHARD IS INSTRUMENTED and NONE of them gates: no chunk can meet the line +# threshold alone, so the single ratcheted gate runs in PHASE 5 over the union of +# both platforms ([DIST-CI-VSIX-COVERAGE], ci-coverage.yml). +# +# The Windows leg (ci-test-vsix-windows.yml) owns win32 coverage; the chunks +# marked `linuxOnly` in the manifest - the real-world-repository stress suites - +# run only here. +name: CI / Test / VS Code +'on': + workflow_call: + inputs: + chunks: + description: JSON array of feature chunks, resolved by the PHASE 2 Linux build. + required: true + type: string +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true +permissions: + contents: read +jobs: + test-vsix: + name: ${{ matrix.chunk }} + runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} + # A shard installs deps, downloads a compiled suite and prebuilt binaries, + # and runs its slice against real Roslyn/FCS sidecars. That preamble is + # FIXED COST paid once per job — several minutes of it — which is why a + # chunk is a GROUP of feature areas and not a single one: at 29 jobs the + # matrix spent more runner time on preamble than on tests, and read as a + # wall of 29 rows nobody could scan. + # + # The ceiling is per JOB, so it scales with membership; it is not a budget + # for one feature area to grow into. The per-test ceilings still live in + # src/test/suite/test-timeouts.ts and none exceeds 2 minutes, so a job + # approaching this is a hang, not a slow suite. + timeout-minutes: 40 + strategy: + # Every chunk is an independent GROUP of feature areas: one failure must + # not cancel the rest, or a single flake hides the state of the whole + # surface. The coverage gate still needs them all, so a failed chunk fails + # the leg. + fail-fast: false + matrix: + chunk: ${{ fromJSON(inputs.chunks) }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Run the shard + uses: ./.github/actions/vsix-shard + with: + chunk: ${{ matrix.chunk }} + platform: linux + suite-artifact: vsix-suite-linux + binaries-artifact: linux-lsp-binaries + debugger-artifact: linux-netcoredbg + debugger-path: target/netcoredbg/linux-x64/netcoredbg + xvfb: 'true' + user-data-dir: ${{ runner.temp }}/vsx-${{ matrix.chunk }} diff --git a/.github/workflows/ci-vsix-coverage.yml b/.github/workflows/ci-vsix-coverage.yml deleted file mode 100644 index 2af1752a..00000000 --- a/.github/workflows/ci-vsix-coverage.yml +++ /dev/null @@ -1,54 +0,0 @@ -# agent-pmo:0b21609 ---- -# Implements [DIST-CI-VSIX-COVERAGE]. THE coverage gate for the VS Code -# extension - one gate, at the end, over every shard of both platforms. -# -# The extension's end-to-end suite runs as ~26 Ubuntu shards -# ([DIST-CI-VSIX-SHARDS], ci-vsix.yml) and ~23 Windows shards -# ([DIST-CI-WIN-VSIX], ci-vsix-windows.yml). Every one of them is instrumented -# and exports an lcov tracefile; none of them gates, because no single chunk can -# meet the line threshold on its own. This job downloads all of them and applies -# the one ratcheted threshold - the same shape as the Rust shards -# ([DIST-CI-RUST-SHARDS]). -# -# Why the union is sound: every shard instruments the SAME bundle, so a file -# loaded by any shard carries its whole line set there (unexecuted lines as -# `DA:,0`), and summing hit counts per (file, line) reproduces the line -# percentage of one unsharded run. Shards write repo-relative `SF:` paths -# (tools/coverage/relativize-lcov.mjs) so a file measured on Windows and on -# Ubuntu keys as ONE file rather than two - without that the denominator would -# double and the gate would fail for a reason unrelated to coverage. -name: CI / VS Code Coverage -'on': - workflow_call: {} -permissions: - contents: read -jobs: - coverage: - name: Coverage Gate - runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - # check-coverage.mjs ratchets against the threshold committed at the - # merge base, which a shallow checkout does not contain. - fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - - name: Download every shard tracefile (both platforms) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: coverage-vsix-shard-* - merge-multiple: true - path: target - - name: Merge shard coverage and enforce threshold - run: make _gate-vsix-coverage - - name: Upload coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-vsix - path: | - target/coverage-vsix.lcov - coverage-thresholds.json diff --git a/.github/workflows/ci-vsix-windows.yml b/.github/workflows/ci-vsix-windows.yml deleted file mode 100644 index 9695720a..00000000 --- a/.github/workflows/ci-vsix-windows.yml +++ /dev/null @@ -1,133 +0,0 @@ -# agent-pmo:0b21609 ---- -# Implements [DIST-CI-WIN-VSIX]. Reusable Windows VS Code leg of the PR -# pipeline (called by ci.yml). -# -# The transport job ([DIST-CI-WIN-TRANSPORT], ci-dotnet.yml) proves win32 named -# pipes carry frames. This workflow proves the whole EDITOR EXPERIENCE works on -# top of them: the VS Code end-to-end suite is sliced into feature chunks and -# every chunk runs as its own Windows job, driving the REAL LSP - release-built -# `sharplsp` host plus the Roslyn and FCS sidecars - inside the real VS Code -# extension host. Ubuntu's leg cannot exercise any of that on win32. -# -# This file is the Windows MIRROR of ci-vsix.yml and deliberately runs the SAME -# make target, `_test-vsix-shard`. There is no Windows-only recipe any more: the -# two had already drifted apart once, to the point where Windows ran the whole -# suite uninstrumented while Ubuntu carried the entire coverage number. -# -# NOTHING IS BUILT TWICE. `build` compiles the Rust host, both sidecars, -# netcoredbg AND the extension suite a single time and publishes them; each -# chunk downloads them and sets VSIX_PREBUILT=1 + VSIX_SUITE_PREBUILT=1. The VS -# Code test host download is cached. -# -# EVERY SHARD IS INSTRUMENTED. This leg does not gate: the single ratcheted gate -# runs at the end of the pipeline over the union of both platforms -# ([DIST-CI-VSIX-COVERAGE], ci-vsix-coverage.yml). -# -# Chunk membership is declared in src/editors/vscode/test-chunks.json and read -# here via tools/vsix/vsix-test-chunks.mjs - never duplicated into this YAML. -# `make _lint-vsix` fails if a suite belongs to no chunk. `matrix win` drops only -# the chunks marked `linuxOnly`, which clone and restore third-party repositories. -name: CI / VS Code (Windows) -'on': - workflow_call: {} -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true -permissions: - contents: read -jobs: - build: - name: Build host + sidecars + suite - runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }} - timeout-minutes: 40 - outputs: - chunks: ${{ steps.suite.outputs.chunks }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: stable - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - key: win32-vsix - - name: Compile the suite and resolve the matrix - id: suite - uses: ./.github/actions/vsix-suite - with: - platform: win - suite-artifact: vsix-suite-win - - name: Build sharplsp host and both sidecars - shell: bash - run: make _build-rust _build-dotnet - - name: Build patched netcoredbg - shell: bash - run: bash tools/vsix/build-netcoredbg.sh win32-x64 - - name: Upload Windows LSP artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: win-lsp-binaries - retention-days: 1 - path: | - target/release/sharplsp.exe - target/sidecar-csharp - target/sidecar-fsharp - - name: Upload patched Windows debugger - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: win-netcoredbg - retention-days: 1 - include-hidden-files: true - path: target/netcoredbg/win32-x64/netcoredbg - test: - name: ${{ matrix.chunk }} - needs: - - build - runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }} - # A chunk installs deps, downloads a compiled suite and prebuilt binaries, - # then runs its slice against real Roslyn/FCS sidecars loading real - # solutions. A chunk approaching this ceiling is a bug to investigate, not a - # budget to grow: the per-test ceilings live in - # src/test/suite/test-timeouts.ts and none of them exceeds 2 minutes. - timeout-minutes: 25 - strategy: - # Every chunk is an independent feature area: one failure must not cancel - # the rest, or a single flake hides the state of the whole surface. - fail-fast: false - matrix: - chunk: ${{ fromJSON(needs.build.outputs.chunks) }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Run the shard - # No xvfb: the GitHub Windows runner has an interactive desktop session, - # so the VS Code test host launches directly. - uses: ./.github/actions/vsix-shard - with: - chunk: ${{ matrix.chunk }} - platform: win32 - suite-artifact: vsix-suite-win - binaries-artifact: win-lsp-binaries - debugger-artifact: win-netcoredbg - debugger-path: target/netcoredbg/win32-x64/netcoredbg - user-data-dir: ${{ runner.temp }}/vsx-${{ matrix.chunk }} - payload: - name: VSIX Payload (Windows) - # Mirrors the Ubuntu payload job. It used to be a prerequisite of every - # Windows chunk, which meant one production esbuild plus a `vsce ls` per - # chunk for an answer that does not vary by chunk - and which left the - # PRODUCTION bundle in dist/, stripping the sourcemap the shards need to - # attribute end-to-end coverage back to src/. - needs: - - build - runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }} - timeout-minutes: 20 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Pack and verify the VSIX - uses: ./.github/actions/vsix-payload - with: - binaries-artifact: win-lsp-binaries - debugger-artifact: win-netcoredbg - debugger-path: target/netcoredbg/win32-x64/netcoredbg diff --git a/.github/workflows/ci-vsix.yml b/.github/workflows/ci-vsix.yml deleted file mode 100644 index d6cde451..00000000 --- a/.github/workflows/ci-vsix.yml +++ /dev/null @@ -1,109 +0,0 @@ -# agent-pmo:0b21609 ---- -# Implements [DIST-CI-VSIX-SHARDS]. Reusable VS Code leg of the PR pipeline on -# Ubuntu (called by ci.yml). -# -# This leg used to be ONE job running all 86 suites end to end. That job took 50 -# minutes - 18 of them executing tests and 30 of them burning two 15-minute -# mocha hook ceilings on a single hung Test Explorer suite - and it was the -# critical path of the whole pipeline. It now fans out over the feature chunks -# declared in src/editors/vscode/test-chunks.json, so wall time is the slowest -# single chunk instead of the sum of all of them, and a hang costs one chunk -# instead of the suite. -# -# NOTHING IS BUILT TWICE. -# * The host binary, both sidecars and netcoredbg come from the `build` leg -# (ci-build.yml) as artifacts; `VSIX_PREBUILT=1` stages them as-is. -# * The extension suite - clean, tsc, esbuild bundle, .NET test fixtures - is -# compiled ONCE by the `suite` job below and published as an artifact; every -# shard downloads it and sets `VSIX_SUITE_PREBUILT=1`. Compiling per shard -# multiplied minutes of identical work by the width of the matrix. -# * The VS Code test host download is cached per runner OS. -# -# EVERY SHARD IS INSTRUMENTED, on this leg and on Windows. Neither leg gates: -# no chunk can meet the line threshold alone, so the single ratcheted gate runs -# at the end of the pipeline over the union of both platforms -# ([DIST-CI-VSIX-COVERAGE], ci-vsix-coverage.yml). -# -# The Windows leg (ci-vsix-windows.yml) owns win32 platform coverage; the chunks -# marked `linuxOnly` in the manifest - the real-world-repository stress suites - -# run only here. -name: CI / VS Code -'on': - workflow_call: {} -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true -permissions: - contents: read -jobs: - suite: - # Compile the suite once and resolve the shard matrix in the same job: both - # read the same working tree, and neither needs the LSP binaries. - name: Compile suite - runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 20 - outputs: - chunks: ${{ steps.suite.outputs.chunks }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Compile the suite and resolve the matrix - id: suite - uses: ./.github/actions/vsix-suite - with: - platform: linux - suite-artifact: vsix-suite-linux - test-vsix: - name: ${{ matrix.chunk }} - needs: - - suite - runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - # A shard now only installs deps, downloads a compiled suite and prebuilt - # binaries, and runs its slice against real Roslyn/FCS sidecars. The fixed - # prelude is well under a minute; the heaviest slice (a real-world repository - # clone + restore) adds ~10 on a cold cache. - # - # A shard approaching this ceiling is a bug to investigate, not a budget to - # grow: the per-test ceilings live in src/test/suite/test-timeouts.ts and - # none of them exceeds 2 minutes. - timeout-minutes: 25 - strategy: - # Every chunk is an independent feature area: one failure must not cancel - # the rest, or a single flake hides the state of the whole surface. The - # coverage gate still needs them all, so a failed chunk fails the leg. - fail-fast: false - matrix: - chunk: ${{ fromJSON(needs.suite.outputs.chunks) }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Run the shard - uses: ./.github/actions/vsix-shard - with: - chunk: ${{ matrix.chunk }} - platform: linux - suite-artifact: vsix-suite-linux - binaries-artifact: linux-lsp-binaries - debugger-artifact: linux-netcoredbg - debugger-path: target/netcoredbg/linux-x64/netcoredbg - xvfb: 'true' - user-data-dir: ${{ runner.temp }}/vsx-${{ matrix.chunk }} - payload: - name: VSIX Payload - # The packaged VSIX must carry the platform binary. This was a step inside - # the old single test job, so a .vscodeignore mistake was only reported - # after 18 minutes of tests. As its own job it reports in about three - and - # keeping it out of the shards matters for more than speed: it leaves a - # PRODUCTION bundle in dist/, whose missing sourcemap would strip the - # end-to-end coverage the shards exist to collect. - runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Pack and verify the VSIX - uses: ./.github/actions/vsix-payload - with: - binaries-artifact: linux-lsp-binaries - debugger-artifact: linux-netcoredbg - debugger-path: target/netcoredbg/linux-x64/netcoredbg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 704d45ec..64981145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,37 @@ # agent-pmo:0b21609 --- -# PR pipeline orchestrator. Three stages, strictly ordered: +# INVARIANT: This file is the orchestrator for the PR pipeline. +# Phase 1 — ANALYSE. Lint, format checks, static analysis, every language. Nothing else runs until this is green. It builds nothing that later phases consume. +# Phase 2 — FULL BUILD. Everything gets built, for every platform, once. Rust host, both .NET sidecars, netcoredbg, the VS Code suite, the VSIX. Gated on Phase 1 and nothing else. Platforms build in parallel with each other — Linux and Windows are siblings, not a chain. +# Phase 3 — CACHE. Every artifact from Phase 2 is uploaded/cached. This is the handoff boundary: nothing downstream of here is ever permitted to compile anything. +# Phase 4 — TEST. Every test job in the repo fans out in parallel, all gated on Phase 3 alone, each downloading from the cache. Every test runs exactly once — no test executes in two jobs. Every test job is instrumented and emits coverage. +# Phase 5 — COVERAGE CHECK. Gated on Phase 4. Merges the tracefiles and applies the ratchet. This phase can be part of the test job itself. It doesn't need to be a separate job # -# detect-changes -> checks + build -> every test in parallel +# detect-changes -> analyse -> build (linux ‖ windows) -> tests -> coverage # -# Each leg lives in its own reusable workflow so a single file never grows -# past comprehension: +# Each phase lives in its own reusable workflow so no single file grows past +# comprehension: # -# ci-build.yml ALL checks/format/analysis, then builds the release -# host + sidecars + netcoredbg ONCE and caches them -# ci-rust.yml sharded Rust e2e suite, coverage gate, version contract -# ci-dotnet.yml sidecar tests (Ubuntu) + win32 named-pipe transport -# ci-vsix.yml sharded VS Code suite, coverage gate, VSIX payload -# (Ubuntu, [DIST-CI-VSIX-SHARDS]) -# ci-vsix-windows.yml VS Code feature chunks on Windows ([DIST-CI-WIN-VSIX]) -# ci-editors.yml Zed + Rider tests and coverage gates ([DIST-CI-EDITORS]) +# ci-analyse.yml PHASE 1 every lint, format and analysis check +# ci-build.yml PHASE 2+3 both platforms, in parallel, published +# ci-test-rust.yml PHASE 4 sharded Rust e2e suite, version contract +# ci-test-dotnet.yml PHASE 4 sidecar tests + the win32 transport arm +# ci-test-vsix.yml PHASE 4 VS Code feature chunks (Ubuntu) +# ci-test-vsix-windows.yml PHASE 4 VS Code feature chunks (Windows) +# ci-test-editors.yml PHASE 4 Zed + Rider +# ci-test-tooling.yml PHASE 4 the repo's own build tooling +# ci-coverage.yml PHASE 5 the two SHARDED ratchets # -# Every test leg `needs:` `build`, so no test job spins up before checks and -# the release build have passed, and the legs all run in parallel off the -# cached artifacts — rebuilding the host/sidecars inside a test leg is -# forbidden ([DIST-CI-LAYOUT]). +# The two rules that keep this honest, and that the pipeline previously broke: +# +# * EACH ARROW IS THE ONLY DEPENDENCY. A test leg needs the cache, never +# another test leg. A build needs analyse, never another build. The Windows +# build used to be a PHASE 4 job gated on the Ubuntu build it consumes +# nothing from, which put 18 minutes of serialised building in front of the +# slowest tests in a 41-minute run. +# * EVERY TEST RUNS EXACTLY ONCE. No suite executes in two jobs. The Windows +# transport job used to re-run the whole platform-agnostic Common test +# project that the Ubuntu sidecar job had already run. name: PR 'on': pull_request: @@ -75,16 +87,27 @@ jobs: printf ' %s\n' "${files[@]}" echo "code_changed=${code_changed}" echo "manifest_changed=${manifest_changed}" + + # ══ PHASE 1 — ANALYSE ═══════════════════════════════════════════════ + analyse: + name: Analyse + needs: + - detect-changes + uses: ./.github/workflows/ci-analyse.yml + if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} + security: name: Dependency Review runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} timeout-minutes: 10 needs: - detect-changes - # Owns vulnerable *dependencies* (Cargo, npm, NuGet). CodeQL (codeql.yml) - # owns vulnerable *code* and `make lint` owns style - no overlap. There is - # no native vuln-gate (cargo-deny/osv) in this repo, so dependency-review is - # the single dependency scanner. [DIST-CI-SECURITY] + # Analysis of the dependency graph, so PHASE 1 - and a sibling of `analyse` + # rather than a step inside it because it scans the PR diff and needs no + # toolchain at all. Owns vulnerable *dependencies* (Cargo, npm, NuGet). + # CodeQL (codeql.yml) owns vulnerable *code* and `make lint` owns style - no + # overlap. There is no native vuln-gate (cargo-deny/osv) in this repo, so + # dependency-review is the single dependency scanner. [DIST-CI-SECURITY] permissions: contents: read steps: @@ -96,10 +119,13 @@ jobs: with: fail-on-severity: high if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} + validate-manifest: name: Validate Shipwright Manifest runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} timeout-minutes: 5 + # Schema validation of a checked-in manifest: PHASE 1, and only when that + # manifest changed. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -117,65 +143,90 @@ jobs: needs: - detect-changes if: ${{ needs.detect-changes.outputs.manifest_changed == 'true' }} + + # ══ PHASE 2 — FULL BUILD + PHASE 3 — CACHE ════════════════════════ + # Gated on PHASE 1 and nothing else. Inside, Linux and Windows are siblings + # and build concurrently; both publish everything PHASE 4 consumes. build: - name: Checks + Build + name: Build needs: - detect-changes + - analyse uses: ./.github/workflows/ci-build.yml if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} - rust: + + # ══ PHASE 4 — TEST ══════════════════════════════════════════════════ + # Every leg gated on `build` ALONE. No leg depends on another leg, and no leg + # rebuilds a PHASE 2 artifact. + test-rust: name: Rust needs: - detect-changes - build - uses: ./.github/workflows/ci-rust.yml + uses: ./.github/workflows/ci-test-rust.yml if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} - dotnet: + + test-dotnet: name: .NET needs: - detect-changes - build - uses: ./.github/workflows/ci-dotnet.yml + uses: ./.github/workflows/ci-test-dotnet.yml if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} - vsix: + + test-vsix: name: VS Code needs: - detect-changes - build - uses: ./.github/workflows/ci-vsix.yml + uses: ./.github/workflows/ci-test-vsix.yml + with: + chunks: ${{ needs.build.outputs.linux-chunks }} if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} - vsix-windows: + + test-vsix-windows: name: VS Code (Windows) needs: - detect-changes - build - uses: ./.github/workflows/ci-vsix-windows.yml + uses: ./.github/workflows/ci-test-vsix-windows.yml + with: + chunks: ${{ needs.build.outputs.win-chunks }} if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} - vsix-coverage: - # [DIST-CI-VSIX-COVERAGE] ONE coverage gate for the extension, at the END, - # over the shards of BOTH platforms. Neither VS Code leg gates on its own: - # no single chunk can meet the line threshold, and gating per leg would have - # let the Windows shards run uninstrumented while Ubuntu carried the whole - # number. - name: VS Code Coverage + + test-editors: + name: Editors needs: - detect-changes - - vsix - - vsix-windows - uses: ./.github/workflows/ci-vsix-coverage.yml + - build + uses: ./.github/workflows/ci-test-editors.yml if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} - editors: - name: Editors + + test-tooling: + name: Tooling needs: - detect-changes - build - uses: ./.github/workflows/ci-editors.yml + uses: ./.github/workflows/ci-test-tooling.yml if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} + + # ══ PHASE 5 — COVERAGE CHECK ════════════════════════════════════════ + # Only the SHARDED suites need a job here: no Rust partition and no VS Code + # chunk holds a complete tracefile, so the ratchet runs over the union of all + # of them. The unsharded legs (.NET, Zed, Rider) gate inside their own test + # job, where the tracefile is already whole. + coverage: + name: Coverage + needs: + - detect-changes + - test-rust + - test-vsix + - test-vsix-windows + uses: ./.github/workflows/ci-coverage.yml + if: ${{ needs.detect-changes.outputs.code_changed == 'true' }} + # NOTE: the former `coverage` job that git-committed+pushed ratcheted # thresholds was removed. On a pull_request, actions/checkout is a detached # HEAD, so `git push` failed the moment coverage changed — a latent red build, - # and a direct push to a protected branch ([DIST-CI-LAYOUT]). Coverage is - # already enforced inside `make test` (tools/coverage/check-coverage.sh reads - # coverage-thresholds.json and fails below threshold in each test-* job). - # Thresholds ratchet via the reviewed PR diff, not a bot commit. - # [DIST-CI-RUST-SHARDS] + # and a direct push to a protected branch ([DIST-CI-LAYOUT]). Thresholds + # ratchet via the reviewed PR diff, not a bot commit. diff --git a/.github/workflows/publish-netcoredbg.yml b/.github/workflows/publish-netcoredbg.yml new file mode 100644 index 00000000..c8ced611 --- /dev/null +++ b/.github/workflows/publish-netcoredbg.yml @@ -0,0 +1,121 @@ +# agent-pmo:0b21609 +--- +# [DIST-DEBUGGER-BUNDLE] Builds SharpLsp's patched netcoredbg ONCE per pinned +# commit, attests its provenance, and publishes it so that every PR build, every +# release and every developer machine DOWNLOADS a known artifact instead of +# recompiling one. +# +# Why this exists. SharpLsp ships a patched netcoredbg - dap-hot-reload.patch +# exposes netcoredbg's ICorDebug ApplyChanges over the DAP protocol, which is +# what backs the shipped sharplsp.hotReload.* commands - so an upstream Samsung +# release binary is not a substitute. But compiling it inside the release +# pipeline meant the debugger users attach to their own processes was built from +# two repositories cloned at release time, with no digest checked and no +# provenance recorded. This workflow makes that artifact a deliberate, signed, +# reviewable release input. +# +# HOW TO USE IT +# 1. Run it (workflow_dispatch). It builds every supported platform from the +# commits in tools/netcoredbg/netcoredbg.lock.json. +# 2. It publishes the archives to the release named by `tag` and attaches a +# build-provenance attestation to each. +# 3. It prints a ready-made `platforms` block. Paste that into the lock file +# and open a PR. +# +# After step 3, tools/netcoredbg/provide.mjs downloads and SHA-256-verifies +# instead of building, and refuses to proceed on any mismatch. +name: Publish netcoredbg +'on': + workflow_dispatch: + inputs: + tag: + description: Release tag to publish the archives under. + required: true + default: netcoredbg-dap-hot-reload-v1 +permissions: + contents: read +jobs: + build: + name: ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x64 + runner: ubuntu-latest + - platform: win32-x64 + runner: windows-latest + - platform: darwin-arm64 + runner: macos-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.0.303 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + - name: Build the patched adapter from the pinned commits + shell: bash + run: bash tools/vsix/build-netcoredbg.sh ${{ matrix.platform }} + - name: Archive it + shell: bash + # --strip-components=1 on the consuming side expects exactly one leading + # directory, so archive the parent and name the member `netcoredbg`. + # Paths stay relative: GNU tar treats a colon in an argument as a remote + # host, which a Windows absolute path would trip over. + run: |- + set -euo pipefail + ( cd "target/netcoredbg/${{ matrix.platform }}" \ + && tar -czf "netcoredbg-${{ matrix.platform }}.tar.gz" netcoredbg ) + mv "target/netcoredbg/${{ matrix.platform }}/netcoredbg-${{ matrix.platform }}.tar.gz" . + node -e ' + const {createHash}=require("node:crypto"), {readFileSync}=require("node:fs"); + const f=process.argv[1]; + console.log(createHash("sha256").update(readFileSync(f)).digest("hex"), f); + ' "netcoredbg-${{ matrix.platform }}.tar.gz" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: netcoredbg-${{ matrix.platform }} + path: netcoredbg-${{ matrix.platform }}.tar.gz + + publish: + name: Attest and publish + needs: build + runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 20 + permissions: + contents: write + # Required to record who built these bytes, from which commit, in which + # workflow - the half of "chain of custody" a checksum cannot supply. + id-token: write + attestations: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: netcoredbg-* + merge-multiple: true + path: archives + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-path: archives/*.tar.gz + - name: Publish the archives + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + shell: bash + run: |- + set -euo pipefail + gh release view "$TAG" >/dev/null 2>&1 \ + || gh release create "$TAG" --title "$TAG" \ + --notes "Patched netcoredbg for SharpLsp. See tools/netcoredbg/netcoredbg.lock.json." + gh release upload "$TAG" archives/*.tar.gz --clobber + - name: Print the lock-file block to paste + env: + TAG: ${{ inputs.tag }} + shell: bash + run: node tools/netcoredbg/print-pins.mjs archives "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b5a6584..bff98217 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,7 @@ jobs: shell: bash run: | # A cross-compiled target the runner cannot execute gets the layout - # check only. Set in shell, not a `${{ }}` env expression: GitHub's + # check only. Set in shell, not a templated `env:` value: GitHub's # `a && '' || '1'` treats the empty string as falsy and yields '1' for # BOTH branches, which would skip the smoke test everywhere. if [ "${{ matrix.can_execute }}" != "true" ]; then @@ -213,7 +213,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' - # Mirrors ci-editors.yml: the IntelliJ Platform SDK is a multi-GB Gradle + # Mirrors ci-test-editors.yml: the IntelliJ Platform SDK is a multi-GB Gradle # download that otherwise dominates this job. - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/CLAUDE.md b/CLAUDE.md index 32c31553..a64ab5c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,8 @@ # CLAUDE.md ⚠️ Never kill VS Code processes — not desktop, not browser. They belong to the user. ⚠️ - ⚠️ Don't ask the user questions — use your judgment. ⚠️ -⚠️ Don't perform Git version-control operations (commits, branches, merges, rebases, tags, or pushes) or add yourself as coauthor. GitHub issues are allowed and encouraged via `gh`. ⚠️ SharpLsp is an open-source, editor-agnostic .NET LSP (C# + F#) built in Rust. One LSP server = complete .NET development experience across every editor. @@ -18,19 +16,14 @@ F# ahead of C# when building new features. F# never takes the backseat. ## Principles -Write review-ready, maintainable code with no duplication. - -- Logging is critical. Use structured logging: `tracing` crate in Rust, `ILogger` + Serilog in .NET. No raw `println!`/`Console.WriteLine`/`console.log` for diagnostics -- Every feature requires coarse end-to-end tests; do not add unit tests - -## Hard Rules +## Invariants (Hard Rules) - There is no SharpLsp "legacy" code. If you find code that does not match the specs, delete it -- All screens MUST BE 100% reactive. If underlying data changes, the screen must be listening and update accordingly -- Zero duplication. Apply DRY rigorously. Check for existing code before writing new code — highest priority -- Any function that can throw/panic must return Result (outcome package in .NET) -- Avoid RegEx and string matching. Always use ACTUAL parsers and traverse the AST/CST -- **Never hand-manipulate structured files.** Load XML, JSON, TOML, YAML, and solution files into a proper DOM/AST, mutate the model, and serialize it with a trivia-preserving parser where needed. Do not use line splicing, regex replacement, or string concatenation. Prefer Microsoft.Build.Construction for MSBuild, XDocument or quick-xml for XML, and serde_json with preserve_order for JSON. +- ***All screens MUST BE 100% reactive*** If underlying data changes, the screen must be listening and update accordingly. Use Signals to manage state in the VSIX and other extensions +- ***Zero code duplication*** Use Deslop (https://deslop.live/docs/for-ai/ - MCP or CLI) routinely before adding code and after editing. +- ***Functional Programming Style (All languages)*** `Result` and `Option` everywhere, expressions over statements — `match`, `if let`, iterator chains, pure functions, minimize side effects. Early returns with `?`. C#/F#'s nullability is fine instead of Option +- Any function that can throw/panic must return Result (outcome package in .NET - use the exhaustion analyzer) +- ***Never use RegEx or string matching on code*** Always use the actual AST/CST. Do not use line splicing, regex replacement, or string concatenation. - `allow(clippy::` is not permitted without a strong, documented reason. **Aggressively remove** existing allows. - All code files < 500 LOC. Functions < 20 LOC - Aggressively move shared code to shared crates/modules @@ -40,11 +33,12 @@ Write review-ready, maintainable code with no duplication. ## Testing -100% test coverage and high mutation score. Focus on assertions, not just coverage. - -- Never delete failing tests or remove/weaken assertions to make tests pass -- Add failing tests for broken or missing functionality -- Tests must not be skipped or ignored +- ***Never delete failing tests or remove/weaken assertions*** to make tests pass +- ***100% test coverage and high mutation score*** +- ***Go heavy on spec derived assertions, not just coverage*** +- ***Many user interactions per test, many assertions per user interaction*** aim for 2-3 user interactions with 3+ assertions for interaction +- ***Add failing tests for broken or missing functionality*** +- Tests may ONLY be ignored if the functionality is missing entirely and you add GitHub issue specifying this - Test against real .sln/.csproj/.fsproj files, not mocks ## Rust Quality Standards @@ -63,19 +57,15 @@ Write review-ready, maintainable code with no duplication. - MessagePack serialization must be AOT-compatible - Sidecar crash must never take down the Rust host -## Functional Programming Style +# Git -- `Result` and `Option` everywhere -- Expressions over statements — `match`, `if let`, iterator chains -- Pure functions, minimize side effects. Early returns with `?` +- Default to never performing write operations unless the user explicitly requests +- Log BUG type GitHub issues when you encounter bugs in release +- Never use worktrees or more than one feature branch at a time -## Duplication — Deslop +## Duplication — [Deslop - MCP or CLI](https://deslop.live/docs/for-ai/) -Code duplication is debt. SharpLsp is Rust + C# + F# — all Deslop-supported. The -ratcheted duplication ceiling lives in `.deslop.toml` (`[threshold].max_duplication_percent`) -and is the committed source of truth — **never** a hardcoded number in CI YAML or an -env var. Ratchet **down only**; raising it requires written PR justification. (See -[CI-DESLOP].) +***CI MUST ratchet down duplication score*** Never increase the threshold Use the Deslop MCP tools to prevent duplication, not just measure it: @@ -87,8 +77,6 @@ Use the Deslop MCP tools to prevent duplication, not just measure it: and `cluster-by-id` (full member list for a cluster you plan to merge). Use `report-for-file` / `report-for-range` for a specific file or selection. Call `schema-doc` once per session to learn the report shape. -- **NEVER silence findings** by widening the threshold, marking code `hidden`, or - splitting it into trivially different shapes. # Multi-Agent Coordination (too-many-cooks) @@ -102,10 +90,6 @@ All agents MUST use tmc to coordinate. No exceptions. 6. **Release locks immediately** after editing. Don't hoard locks. 7. **Signal completion** — broadcast when you finish so other agents can proceed. -``` -register -> broadcast intent -> acquire locks -> update plan -> do work -> release locks -> broadcast completion -``` - # Documentation Structure All documentation lives in `docs/`. diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 78aa2ee2..63095d4c 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -1,6 +1,6 @@ { "_agent_pmo": "b636503", - "_doc": "Single source of truth for coverage thresholds. See REPO-STANDARDS-SPEC [COVERAGE-THRESHOLDS-JSON]. Ratchet only -- never lower. Every project `make ci` measures gets its own line-coverage floor here, enforced independently by tools/coverage/check-coverage.mjs: no roll-up can mask one project behind another. Rust entries (language=rust) are the cargo workspace measured as one lcov union across CI shards (target/coverage-rust.lcov + shard tracefiles -- every shard's tracefile carries the full instrumented line set, so the union equals an unsharded run; no shard passes alone, the gate runs once on the merged total). The sidecar entries (language=dotnet) are individual src/sidecars projects, each measured from its merged Cobertura report via tools/coverage/kover-line-percent.cs with the exclusions of .config/coverage/coverlet.runsettings. The editor entries (language=typescript) are measured from each editor's own coverage summary: vscode-extension from the union of EVERY instrumented VS Code shard on BOTH platforms (ci-vsix.yml + ci-vsix-windows.yml), merged and gated once at the end of the pipeline by ci-vsix-coverage.yml -- no shard passes alone, and shards write repo-relative SF: paths so a file measured on Windows and on Ubuntu keys as one file, sharplsp-zed and sharplsp-rider from their CI legs. sharplsp-rider is a floor-in-progress (4.09): the Rider test suite is young; the entry exists so the number can only go UP -- the ratchet bumps it the moment coverage improves. A threshold entry whose project stops being measured fails the gate rather than rotting silently.", + "_doc": "Single source of truth for coverage thresholds. See REPO-STANDARDS-SPEC [COVERAGE-THRESHOLDS-JSON]. Ratchet only -- never lower. Every project `make ci` measures gets its own line-coverage floor here, enforced independently by tools/coverage/check-coverage.mjs: no roll-up can mask one project behind another. Rust entries (language=rust) are the cargo workspace measured as one lcov union across CI shards (target/coverage-rust.lcov + shard tracefiles -- every shard's tracefile carries the full instrumented line set, so the union equals an unsharded run; no shard passes alone, the gate runs once on the merged total). The sidecar entries (language=dotnet) are individual src/sidecars projects, each measured from its merged Cobertura report via tools/coverage/kover-line-percent.cs with the exclusions of .config/coverage/coverlet.runsettings. The editor entries (language=typescript) are measured from each editor's own coverage summary: vscode-extension from the union of EVERY instrumented VS Code shard on BOTH platforms (ci-test-vsix.yml + ci-test-vsix-windows.yml), merged and gated once in PHASE 5 by ci-coverage.yml -- no shard passes alone, and shards write repo-relative SF: paths so a file measured on Windows and on Ubuntu keys as one file, sharplsp-zed and sharplsp-rider from their CI legs. sharplsp-rider is a floor-in-progress (4.09): the Rider test suite is young; the entry exists so the number can only go UP -- the ratchet bumps it the moment coverage improves. A threshold entry whose project stops being measured fails the gate rather than rotting silently.", "default_threshold": 95, "projects": { "sharplsp": { diff --git a/docs/plans/DISTRIBUTION-PLAN.md b/docs/plans/DISTRIBUTION-PLAN.md index b001e1f5..e0c97a68 100644 --- a/docs/plans/DISTRIBUTION-PLAN.md +++ b/docs/plans/DISTRIBUTION-PLAN.md @@ -202,7 +202,15 @@ Two further failure classes were investigated and turned out **not** to be defec ### CI workflow layout ([DIST-CI-LAYOUT]) -- [x] Split `ci.yml` into reusable workflows: `ci-lint`, `ci-rust`, `ci-dotnet`, `ci-vsix`, `ci-vsix-windows` +- [x] Split `ci.yml` into reusable workflows, one per phase: `ci-analyse`, + `ci-build`, `ci-test-rust`, `ci-test-dotnet`, `ci-test-vsix`, + `ci-test-vsix-windows`, `ci-test-editors`, `ci-coverage` + ([DIST-CI-LAYOUT]) +- [x] Order those workflows into five strict phases: ANALYSE -> FULL BUILD + (both platforms in parallel) -> CACHE -> TEST (every suite exactly + once) -> COVERAGE CHECK. The Windows build used to sit in the test + phase gated on the Ubuntu build it consumes nothing from, which put + 18 minutes of serialised building in front of the slowest tests - [x] De-duplicate the PATH-purge step into `tools/vsix/purge-path-binaries.sh` (was inline in three jobs) - [x] De-duplicate the test-host env scrubbing into the `VSIX_TEST_ENV` Make variable - [x] Fix the Rust test job's NuGet cache step (was `actions/setup-node` with `actions/cache` inputs, so it never cached) diff --git a/docs/plans/RIDER-PLUGIN-PLAN.md b/docs/plans/RIDER-PLUGIN-PLAN.md index 930b806a..e22772c3 100644 --- a/docs/plans/RIDER-PLUGIN-PLAN.md +++ b/docs/plans/RIDER-PLUGIN-PLAN.md @@ -215,7 +215,7 @@ how it was installed — and CI built and coverage-gated it in that state. - [ ] Add `build-rider` + `test-rider` to `.github/workflows/ci.yml` under a matrix job that requires JDK 17 -- [x] Cache `~/.gradle/caches` and `~/.gradle/wrapper` (`ci-editors.yml`, +- [x] Cache `~/.gradle/caches` and `~/.gradle/wrapper` (`ci-test-editors.yml`, and the release `build-rider` job mirrors it) - [x] The Rider plugin zip is built and attached to tag releases alongside the VSIXs — release.yml `build-rider`, `RIDER_REQUIRED=1` so a missing JDK diff --git a/docs/plans/SIDECAR-LIFECYCLE-PLAN.md b/docs/plans/SIDECAR-LIFECYCLE-PLAN.md index bdde5055..337106be 100644 --- a/docs/plans/SIDECAR-LIFECYCLE-PLAN.md +++ b/docs/plans/SIDECAR-LIFECYCLE-PLAN.md @@ -174,8 +174,8 @@ in the diagnostics plan. | `src/sharplsp/tests/fixtures/SidecarLifecycleFixture/` | Real separately spawned shared-host fixture for protocol faults, delayed handlers, and child-process containment | | `src/sharplsp/tests/e2e_modules/sidecar_lifecycle.rs` | Full host/process/IPC recovery scenarios and issue traceability | | `src/sidecars/SharpLsp.Sidecar.Common.Tests/SidecarHostEndToEndTests.cs` | Keep only coarse real-IPC host lifecycle coverage; add ack and process-exit assertions | -| `.github/workflows/ci-rust.yml` / `ci-dotnet.yml` | Run platform-relevant real-process lifecycle cases | -| `.github/workflows/ci-vsix-windows.yml` | Gate the lifecycle chunk on concurrent hosts, restart, and Windows tree cleanup | +| `.github/workflows/ci-test-rust.yml` / `ci-test-dotnet.yml` | Run platform-relevant real-process lifecycle cases | +| `.github/workflows/ci-test-vsix-windows.yml` | Gate the lifecycle chunk on concurrent hosts, restart, and Windows tree cleanup | File names may be adjusted to match an equivalent existing abstraction discovered during implementation, but responsibilities MUST remain single-owner and the final tree MUST not retain a diff --git a/docs/specs/BINARY-DEPLOYMENT.md b/docs/specs/BINARY-DEPLOYMENT.md index 394f5dba..1b2d647f 100644 --- a/docs/specs/BINARY-DEPLOYMENT.md +++ b/docs/specs/BINARY-DEPLOYMENT.md @@ -175,7 +175,7 @@ Before running any install command, run `getVersion("brew")` / `getVersion("scoo **Deletions:** - `downloadAndInstall`, `downloadToFile`, `extractTarGz`, `platformRid`, `bundledBinaryPath`, and the whole GitHub-release HTTPS path. -- The `bin/` VSIX bundling path and the `~/.local/lib/sharplsp/` staging from both the Makefile `install` target and `.github/workflows/ci-vsix.yml` (recent commits `c6f29f0` and `e1dd2ca` become partially obsolete). +- The `bin/` VSIX bundling path and the `~/.local/lib/sharplsp/` staging from both the Makefile `install` target and `.github/workflows/ci-test-vsix.yml` (recent commits `c6f29f0` and `e1dd2ca` become partially obsolete). **Forbidden patterns (encoded as lint / code review):** diff --git a/docs/specs/DEBUGGING-SPEC.md b/docs/specs/DEBUGGING-SPEC.md index 0f169825..94e13c85 100644 --- a/docs/specs/DEBUGGING-SPEC.md +++ b/docs/specs/DEBUGGING-SPEC.md @@ -581,7 +581,7 @@ For T3, the Debug Sidecar loads C#-sidecar `CSharpScriptCompilation` output into | Inner exception chain traversal | P2 | | Exception conditions (break only if message matches) | P2 — Phase 5 | -Configuration via `setExceptionBreakpoints` with `filterOptions` and `exceptionOptions` per the DAP 1.71.0 specification. +Configuration via `setExceptionBreakpoints` with `filterOptions` and `exceptionOptions` per the DAP 1.71.0 specification. An unhandled exception always breaks, whatever the filters say: there is nothing after it to continue to. ### Hot Reload During Debug `[DEBUG-FEATURES-HOT-RELOAD]` diff --git a/docs/specs/DISTRIBUTION-SPEC.md b/docs/specs/DISTRIBUTION-SPEC.md index 031b822c..cfaead60 100644 --- a/docs/specs/DISTRIBUTION-SPEC.md +++ b/docs/specs/DISTRIBUTION-SPEC.md @@ -104,7 +104,7 @@ Whenever activation cannot deliver a working language server — for any reason, 3. **Every failure surfaces a non-modal `vscode.window.showErrorMessage(…)`** with at minimum a `[Show Log]` button that calls `log.output().show()`. Where applicable, additional informational links MAY be added (`[Open dot.net]`, `[Retry]`, `[Reinstall]`). Buttons are convenience links, never required actions. 4. **The status bar MUST move to `ServerState.Error`** so the persistent indicator reflects the degraded state. 5. **The error message MUST name the failure mode in plain language** ("required binaries are missing or version-mismatched", ".NET 10 install failed", "language server crashed during startup") — never just dump a stack trace into the toast. The full diagnostic text goes to the output channel reachable via `[Show Log]`. -6. **Recovery commands MUST be registered** so the user can re-attempt without uninstalling. Examples: `sharplsp.retryDotnetAcquisition`, `sharplsp.restartServer`. These appear in the command palette under the `SharpLsp:` category. +6. **Recovery commands MUST be registered** so the user can re-attempt without uninstalling. Examples: `sharplsp.retryDotnetAcquisition`, `sharplsp.restartServer`. These appear in the command palette under the `SharpLsp:` category. `sharplsp.restartServer` MUST start a fresh server even when the old one does not answer `shutdown` in time — a hung server is exactly when a user reaches for it. **Implementation reference:** - `src/editors/vscode/src/result.ts` — `Result`, `ok`, `err`. @@ -426,22 +426,68 @@ reporting success. ## [DIST-CI-LAYOUT] CI Workflow Layout -The PR pipeline uses reusable workflows (`on: workflow_call`): +The PR pipeline runs in FIVE STRICTLY ORDERED PHASES. Each phase is a reusable +workflow (`on: workflow_call`) called by `ci.yml`: -| Workflow | Leg | -|---|---| -| `ci.yml` | Orchestrator: `detect-changes`, dependency review, manifest validation, and one `uses:` job per leg | -| `ci-lint.yml` | Rust / Zed / .NET / VS Code lint + format gates | -| `ci-rust.yml` | Sharded Rust e2e suite ([DIST-CI-RUST-SHARDS]), the union coverage gate, the version contract | -| `ci-dotnet.yml` | Sidecar tests (Ubuntu) + win32 named-pipe transport ([DIST-CI-WIN-TRANSPORT]) | -| `ci-vsix.yml` | Sharded, instrumented VS Code suite + VSIX payload check (Ubuntu, [DIST-CI-VSIX-SHARDS]) | -| `ci-vsix-coverage.yml` | THE VS Code coverage gate — one ratchet over every shard of both platforms ([DIST-CI-VSIX-COVERAGE]) | -| `ci-vsix-windows.yml` | VS Code feature chunks on Windows ([DIST-CI-WIN-VSIX]) | - -Invariants: +``` +detect-changes -> ANALYSE -> FULL BUILD (linux || windows) -> TEST -> COVERAGE +``` -- **`detect-changes` is the only gate.** Every leg is `needs: detect-changes` and guarded by `code_changed`; no leg `needs:` another. Lint and tests are independent required gates — serializing tests behind lint added ~3 minutes to every PR's critical path, and a lint failure still blocks the merge. -- **Legs are called, never duplicated.** Shared VSIX shell logic lives in `tools/vsix/` (for example `purge-path-binaries.sh` and `vsix-test-chunks.mjs`) and shared build logic in the `Makefile`, so a step is written once and called from every workflow that needs it. +| Phase | Workflow | Leg | +|---|---|---| +| — | `ci.yml` | Orchestrator: `detect-changes` and one `uses:` job per phase | +| 1 ANALYSE | `ci-analyse.yml` | Every Rust / Zed / .NET / VS Code lint, format and analysis gate | +| 1 ANALYSE | (in `ci.yml`) | Dependency review ([DIST-CI-SECURITY]) and Shipwright manifest validation | +| 2 BUILD + 3 CACHE | `ci-build.yml` | Both platforms in parallel: host, sidecars, netcoredbg, VS Code suite, VSIX — then published | +| 4 TEST | `ci-test-rust.yml` | Sharded Rust e2e suite ([DIST-CI-RUST-SHARDS]), the version contract | +| 4 TEST | `ci-test-dotnet.yml` | Sidecar tests (Ubuntu) + the win32 named-pipe arm ([DIST-CI-WIN-TRANSPORT]) | +| 4 TEST | `ci-test-vsix.yml` | Instrumented VS Code feature chunks (Ubuntu, [DIST-CI-VSIX-SHARDS]) | +| 4 TEST | `ci-test-vsix-windows.yml` | Instrumented VS Code feature chunks (Windows, [DIST-CI-WIN-VSIX]) | +| 4 TEST | `ci-test-editors.yml` | Zed + Rider ([DIST-CI-EDITORS]) | +| 4 TEST | `ci-test-tooling.yml` | The repo's own build tooling - how the netcoredbg adapter is obtained ([DIST-DEBUGGER-BUNDLE]) | +| 5 COVERAGE | `ci-coverage.yml` | The two SHARDED ratchets — Rust, and VS Code over both platforms ([DIST-CI-VSIX-COVERAGE]) | + +Phase invariants: + +- **ANALYSE gates everything and builds nothing.** No later phase consumes an + artifact from phase 1. Lint used to share a job with the Ubuntu build, which + meant the Windows build waited on both. +- **PHASE 2 builds every platform, once, in parallel.** `build-linux` and + `build-windows` are SIBLINGS — neither `needs:` the other, because neither + consumes the other's output. Gating Windows on the Ubuntu build put 6m45 of + idle Windows runner, and then 11m02 of duplicate Windows building, in front of + the slowest tests in the pipeline. +- **PHASE 3 is the handoff boundary.** Everything phase 4 needs is uploaded at + the end of phase 2, and NO test leg may rebuild a shipping artifact. A leg + MAY compile instrumented test binaries — a coverage build is a different + profile than the release artifact, so no phase-2 build could have produced it. +- **EACH ARROW IS THE ONLY DEPENDENCY.** Every phase-4 leg is + `needs: [detect-changes, build]` and guarded by `code_changed`. No test leg + `needs:` another test leg. +- **EVERY TEST RUNS EXACTLY ONCE.** No suite may execute in two jobs. Where a + platform arm genuinely differs, only the platform-dependent classes run twice + — the Windows transport job runs `_test-dotnet-win-transport`, not the whole + Common test project. +- **PHASE 5 gates only what phase 4 could not.** A ratchet needs a complete + tracefile. The sharded suites (Rust partitions, VS Code chunks) have none in + any single job, so they merge and gate in `ci-coverage.yml`; the unsharded + legs (.NET, Zed, Rider) gate inside their own test job. +- **An install is cached by its RESULT, not its inputs.** `npm ci` for the + VS Code extension costs 3m16 on Ubuntu and 4m31 on Windows even with a warm + `~/.npm`, because the cost is unpacking 524 packages and running their + install scripts, not downloading them. Every phase-4 shard pays it, so it is + paid ~30 times per run, each time on that job's critical path. The + `vsix-node-deps` composite action caches `node_modules` itself, keyed on the + lockfile hash plus OS and architecture (the tree carries platform-specific + optional dependencies), with NO `restore-keys` — a partial tree from a + different lockfile would skip `npm ci` and be silently wrong. Every VS Code + `npm ci` in the PR pipeline goes through it. +- **Legs are called, never duplicated.** Per-platform build logic lives in the + `build-platform` composite action, per-shard logic in `vsix-shard`, and shared + build logic in the `Makefile`, so a step is written once and called from every + workflow that needs it. The Ubuntu and Windows VS Code legs run the SAME + composite action and the SAME make target; they had already drifted apart + once, to the point where Windows ran its whole suite uninstrumented. ### [DIST-CI-SECURITY] Security Gates @@ -492,42 +538,26 @@ Both listener flavors MUST restrict the endpoint to the current user: `0600` on ## [DIST-CI-WIN-VSIX] Windows VS Code End-to-End Tests -CI MUST run the VS Code end-to-end suite's whole feature surface on Windows runners through `ci-vsix-windows.yml` and `_test-vsix-shard` (the same target the Ubuntu leg runs): the release-built `sharplsp` host, Roslyn and FCS sidecars, actual VS Code extension host, and win32 named-pipe IPC. [DIST-CI-WIN-TRANSPORT] covers frames only, while Windows-specific executables (`netcoredbg.exe`, `dotnet-trace`, `dotnet test`, `dotnet new`) and paths require full feature coverage; a grep-selected smoke subset is insufficient. +CI MUST run the VS Code end-to-end suite's whole feature surface on Windows runners through `ci-test-vsix-windows.yml` and `_test-vsix-shard` (the same target the Ubuntu leg runs): the release-built `sharplsp` host, Roslyn and FCS sidecars, actual VS Code extension host, and win32 named-pipe IPC. [DIST-CI-WIN-TRANSPORT] covers frames only, while Windows-specific executables (`netcoredbg.exe`, `dotnet-trace`, `dotnet test`, `dotnet new`) and paths require full feature coverage; a grep-selected smoke subset is insufficient. -The suite is sliced into **feature chunks**, one CI job each on BOTH platform legs, run with `fail-fast: false` so one failing feature area never hides the state of the others. The manifest below is the single declaration; `linuxOnly` chunks are absent from the Windows matrix ([DIST-CI-VSIX-SHARDS]): +The suite is sliced into **feature chunks**, one CI job each on BOTH platform legs, run with `fail-fast: false` so one failing feature area never hides the state of the others. A chunk is a GROUP of related feature areas, not a single one — see the job-count invariant below. The manifest below is the single declaration; `linuxOnly` chunks are absent from the Windows matrix ([DIST-CI-VSIX-SHARDS]): | Chunk | Platforms | Feature surface | |---|---|---| -| `lifecycle` | Both | Activation, configuration, bundled binary/sidecar resolution, client lifecycle and restart, cross-cutting command workflows. | -| `lsp` | Both | C# language intelligence over the real LSP: completion, hover, diagnostics, document symbols, folding, selection ranges, document sync and client lifecycle. | -| `lsp-refactor` | Both | The C# refactoring surface: quick fixes, organize imports, the rewrite matrix, and rename across symbols and edge cases. Split from `lsp` so neither slice carries the other's wall clock ([DIST-CI-VSIX-SHARDS]). | -| `lsp-filebased` | Both | File-based programs (`#:package`, `#:property`): restore, reload, isolation and configuration-cone parity. Its own chunk because every test shells out to a real `dotnet restore`. | -| `fsharp` | Both | F# is a first-class citizen, so its whole LSP surface is gated: navigation, intelligence, syntax, diagnostics, hierarchy and workspace symbol. Suites are enumerated rather than globbed so a NEW F# suite fails the chunk guard and forces a deliberate placement instead of silently inflating one job. | -| `fsharp-codefix` | Both | The F# code-fix catalogue: basics, type conversions and generation. The slowest third of the F# surface, split out so it runs beside the rest instead of after it. | -| `fsharp-rename` | Both | F# rename, including the cross-language case where an F# origin renames C# references and back — the single slowest suite in the whole VS Code matrix, because each test rebuilds both languages. | -| `debug` | Both | Debugging and the launch surface, WITHOUT shelling out to dotnet: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, and manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions. | -| `debug-stepping` | Both | Step through debugging over a live netcoredbg session on a real built assembly: F10/F11/Shift+F11 walks asserted line by line and frame by frame, Just My Code, run to cursor, continue between breakpoints, breakpoints encountered mid-step, stepping off the end of a method and of the program, and the physical/async call stack. Implements [DEBUG-FEATURES-STEPPING] and [DEBUG-FEATURES-STACK]. | -| `debug-breakpoints` | Both | Breakpoints as a user sets them: F9 through the editor (the canSetBreakpointsIn gate the addBreakpoints API bypasses), binding and verification, mid-session add/remove/disable, function breakpoints, conditions, hit counts and logpoints. Implements [DEBUG-FEATURES-BREAKPOINTS] and the runtime half of [DEBUG-FEATURES-BREAKPOINTS-CONTRIBUTION]. | -| `debug-exceptions` | Both | Catching exceptions and ignoring them: the advertised exception filters, break-on-all catching a handled throw, the unhandled-only filter ignoring one, the exception info panel and inner-exception chain, and per-type include/exclude filters changed mid-session. Implements [DEBUG-FEATURES-EXCEPTIONS]. | -| `debug-inspection` | Both | The Variables and Watch panels against a paused debuggee: locals, arguments, this, statics, collection/array/nullable expansion, hover/watch/REPL evaluation across the T1 and T2 tiers, setVariable changing what the program does next, and [DebuggerDisplay] rendering. Implements [DEBUG-FEATURES-VARIABLES]. | -| `debug-fsharp` | Both | F# debugging at full density, never a reduced echo of the C# suites: F9 in an F# editor, stepping through F# functions, F# exceptions caught and ignored, discriminated unions/records/tuples/options rendered in F# syntax, and task {} logical stacks. Implements [DEBUG-FSHARP-UNIONS], [DEBUG-FSHARP-STEPPING] and [DEBUG-FSHARP-PDB]. | -| `debug-session` | Both | The session and the protocol around it: the DAP 1.71.0 handshake and the whole [DEBUG-PROTOCOL-CAPABILITIES] table in both directions, stopAtEntry, args/env/cwd, run-without-debugging, restart, pause and stop, debuggee output routing, and two simultaneous sessions multiplexed by session id. | -| `debug-advanced` | Both | Hot Reload during an active session (method body, added method, rude edit) and attaching to an already-running process by pid and by name. Each suite builds and then also RUNS a real .NET target outside the debugger. Implements [DEBUG-FEATURES-HOT-RELOAD] and the [DEBUG-FEATURES-LAUNCH] attach rows. | -| `debug-tests` | Both | Debugging a test through the Test Explorer Debug profile, at full density: one test at a time (plain, failing, skipped, `[Theory]` rows, nothing armed, disabled and conditional breakpoints), selections of tests (class, namespace, assembly root, multi-select, and the unselected test that must NOT run), and F# first — backtick names carrying spaces, module helpers, `[]` rows and the at-cursor Debug gesture. Every test builds a fixture solution and attaches a real netcoredbg to the waiting test host. Split from `debug-advanced` because it is nineteen debug sessions. Implements [DEBUG-FEATURES-TESTS]. | -| `rundebug` | Both | Launch-target resolution against real projects: the [SCRIPT-CONE] walk (.sln/.slnx, .git and workspace-root boundaries), active-document sensitivity across two projects, library rejection, and MSBuild output resolution for custom AssemblyName/OutputPath, non-listed and multi-targeted frameworks. Builds real C# and F# console projects. | -| `rundebug-commands` | Both | The run/debug user gestures at the VSIX level: F5 and Ctrl/Cmd+F5 through workbench.action.debug.start / .run, sharplsp.runProgram and sharplsp.debugProgram against built projects, and single-file targets — C# file-based apps, .fsx scripts and the .csx/.fs refusals. Split from rundebug because every test restores and builds or executes a real .NET target. | -| `testexplorer` | Both | Discovery, the reactive tree, Windows path handling, TRX/console result parsing and the testing lens. | -| `testexplorer-cancellation` | Both | Pressing Stop must terminate the whole `dotnet test` process TREE. Its own chunk: the suite builds a dedicated F# xUnit fixture whose long-running test deliberately sleeps, so it is both slow and the most likely place in the matrix to hang — isolating it keeps a hang from taking the rest of the Test Explorer surface with it. | -| `testexplorer-frameworks` | Both | Test Explorer framework matrix and run semantics: xUnit, NUnit and MSTest in both C# and F#, per-test outcome attribution from TRX, run/debug/coverage profiles. Split from the testexplorer chunk because it restores and builds six test projects. | -| `profiler` | Both | Profiling end to end (dotnet-trace sessions, live counters, memory dumps, .nettrace conversion, profiler webviews) plus FSI, build, output filtering and hot reload. | -| `explorer` | Both | Solution Explorer tree, reactive sort/state signals, tooltips and reveal, the full context-menu surface, and the project-dependency watcher. | -| `packages` | Both | Scaffolding (create solution/project) and the NuGet surface: browser panel, search/add/update/restore commands, and real .csproj dependency edits. | -| `realrepo-serilog` | Ubuntu only | Cold-loading the pinned real-world repository serilog/serilog: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal. | -| `realrepo-fluentvalidation` | Ubuntu only | Cold-loading the pinned real-world repository FluentValidation: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal. | -| `realrepo-fstoolkit` | Ubuntu only | Cold-loading the pinned real-world repository FsToolkit.ErrorHandling: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal. | +| `lsp` | Both | The C# language surface over the real LSP, end to end: completion, hover, diagnostics, document symbols, folding, selection ranges and document sync; the refactoring catalogue (quick fixes, organize imports, the rewrite matrix, rename across symbols and edge cases); and file-based programs (`#:package`, `#:property`), whose every test shells out to a real `dotnet restore`. | +| `fsharp` | Both | F# is a first-class citizen, so its WHOLE LSP surface is gated in one job: navigation, intelligence, syntax, diagnostics, hierarchy and workspace symbol; the code-fix catalogue (basics, type conversions, generation); and rename, including the cross-language case where an F# origin renames C# references and back — the slowest suite in the matrix, because each of its tests rebuilds both languages. Suites are enumerated rather than globbed so a NEW F# suite fails the chunk guard and forces a deliberate placement. | +| `debug` | Both | Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts. | +| `debug-breakpoints` | Both | Where a live session STOPS, and why. Breakpoints as a user sets them (F9 through the editor, binding and verification, mid-session add/remove/disable, function breakpoints, conditions, hit counts, logpoints); stepping F10/F11/Shift+F11 asserted line by line and frame by frame, Just My Code, run to cursor, stepping off the end of a method and of the program, and the physical/async call stack; and the exception filters — break-on-all catching a handled throw, unhandled-only ignoring one, the info panel and the inner-exception chain. Implements [DEBUG-FEATURES-BREAKPOINTS], [DEBUG-FEATURES-STEPPING], [DEBUG-FEATURES-STACK] and [DEBUG-FEATURES-EXCEPTIONS]. | +| `debug-inspection` | Both | What the debuggee SHOWS while it is paused, plus the two gestures that change a running process. Variables and Watch (locals, arguments, this, statics, collection/array/nullable expansion, hover/watch/REPL evaluation across the T1 and T2 tiers, setVariable changing what the program does next, and [DebuggerDisplay] rendering); F# debugging at full density, never a reduced echo of the C# suites (F9 in an F# editor, stepping through F# functions, discriminated unions/records/tuples/options rendered in F# syntax, task {} logical stacks); and Hot Reload plus attach by pid and by name. Implements [DEBUG-FEATURES-VARIABLES], [DEBUG-FSHARP-UNIONS], [DEBUG-FSHARP-STEPPING], [DEBUG-FSHARP-PDB], [DEBUG-FEATURES-HOT-RELOAD] and the [DEBUG-FEATURES-LAUNCH] attach rows. | +| `debug-tests` | Both | The session and the protocol around it, and the hardest consumer of both. The DAP 1.71.0 handshake and the whole [DEBUG-PROTOCOL-CAPABILITIES] table in both directions, stopAtEntry, args/env/cwd, run-without-debugging, restart, pause and stop, debuggee output routing, and two simultaneous sessions multiplexed by session id — then debugging a TEST through the Test Explorer Debug profile, which re-attaches to successive test hosts inside one session. Grouped because a test-debugging failure is a session-lifecycle failure. Implements [DEBUG-FEATURES-TESTS]. | +| `testexplorer` | Both | The Test Explorer a user READS: discovery, the reactive tree, Windows path handling, TRX/console result parsing, the fully-qualified name reader (adapter decoration stripped, NUnit case names untouched), the testing lens actions, the STATUS half of [TEST-STATUS-LENS] observed as a real CodeLens above a real test method, and the Run-with-Coverage profile against a two-test-project solution over one library — one Cobertura report per test project, every one parsed and attached, a freshly emptied `.sharplsp-coverage` between runs, and the plain Run profile collecting nothing. Implements [TEST-STATUS-LENS] and [TEST-COVERAGE]. | +| `testexplorer-frameworks` | Both | The Test Explorer RUNNING things, over the widest fixture surface in the matrix: the framework matrix (xUnit, NUnit and MSTest in both C# and F#, per-test outcome attribution from TRX, run/debug/coverage profiles, and the pinned 2.2.0 VSTest adapter that decorates the names it reports), and Stop terminating the whole `dotnet test` process TREE across every gesture that starts a run. Kept apart from `testexplorer` because between them they restore and build eight test projects, and because the sleeping-fixture cancellation suite is the likeliest place in the matrix to hang — a hang here must not take the Test Explorer's read surface with it. | +| `workspace` | Both | The IDE surface that is not a language service: activation, configuration, bundled binary/sidecar resolution, client lifecycle and restart, and the cross-cutting command workflows; the Solution Explorer tree with its reactive sort/state signals, tooltips, reveal, full context-menu surface and project-dependency watcher; scaffolding and the NuGet surface down to real .csproj dependency edits; and profiling end to end (dotnet-trace sessions, live counters, memory dumps, .nettrace conversion, profiler webviews) plus FSI and build-output filtering. | +| `realrepo` | Ubuntu only | Cold-loading the three pinned real-world repositories — serilog/serilog, FluentValidation and FsToolkit.ErrorHandling: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal. | Invariants: +- **A chunk is a GROUP, and the matrix stays readable.** Each platform leg MUST fan out over roughly **6-10** jobs, never one job per feature area. Every job repeats the same multi-minute preamble — `setup-dotnet`, the extension's `node_modules`, three artifact downloads and the VS Code host cache — so a matrix of one-area jobs pays that fixed cost once per area, and at 26 Windows + 29 Ubuntu jobs it spent more runner time on preamble than on tests while producing a check list too long to read. Areas are grouped by what they exercise, so a red job still names a coherent surface. The counter-pressure is wall clock: a chunk projected past **~15 minutes** on either platform MUST be split, and a chunk that HANGS must not be able to take an unrelated surface down with it — which is why `testexplorer-frameworks` (the sleeping-fixture cancellation suite) stays out of `testexplorer`. - **One declaration.** Chunk membership lives in `src/editors/vscode/test-chunks.json` and is read by `tools/vsix/vsix-test-chunks.mjs` (`files ` → `MOCHA_FILES` globs, `matrix` → the CI job matrix, `check` → the completeness guard). It MUST NOT be duplicated into CI YAML. - **Nothing escapes.** `make _lint-vsix` runs `vsix-test-chunks.mjs check`, which fails if any `*.test.ts` suite is claimed by no chunk or by more than one. A new suite is therefore gated on Windows by default; opting out requires an explicit entry under `excluded` with a written reason. - **Selection is by file, not by title.** The inner mocha runner selects suites via the `MOCHA_FILES` glob list. Title-regex selection (`MOCHA_GREP`) is a local debugging aid only — it silently drops tests when a suite is renamed. A glob matching zero compiled suites is a hard error, so a mistyped chunk fails instead of reporting a green run of nothing. @@ -569,7 +599,7 @@ Invariants: do live in `.github/actions/`: `vsix-suite` (install, resolve the matrix, compile once, publish), `vsix-shard` (stage, run one instrumented chunk, publish its tracefile) and `vsix-payload` (pack the VSIX, assert the platform - binary is in it). `ci-vsix.yml` and `ci-vsix-windows.yml` supply only what + binary is in it). `ci-test-vsix.yml` and `ci-test-vsix-windows.yml` supply only what genuinely differs — artifact names, where the debugger unpacks, the platform tag, and whether the runner needs `xvfb`. Copying steps between the two YAMLs is how they drifted apart the first time. @@ -615,7 +645,7 @@ Invariants: There is exactly ONE coverage gate for the extension, it runs at the END of the pipeline, and it ratchets the union of every instrumented shard on every -platform (`ci-vsix-coverage.yml`, `needs: [vsix, vsix-windows]`). +platform (`ci-coverage.yml`, PHASE 5, `needs: [test-rust, test-vsix, test-vsix-windows]`). Invariants: @@ -646,6 +676,7 @@ derived from measured behaviour on the CI agents. |---|---|---| | `FAST_MS` | 1s | Pure in-process work — parsers, tree builders, HTML rendering, manifest conformance | | `COMMAND_MS` | 5s | One command round trip through the extension host; no sidecar | +| `SETTLE_MS` | 10s | A POLL budget for something the OS or a debounced watcher owns — a killed process leaving the process table, a file watcher firing, the workbench clearing its active debug session | | `SETTINGS_WRITE_MS` | 30s | Several user-scoped `settings.json` writes, each awaiting its change event (measured 4.56s for four) | | `LSP_RESPONSE_MS` | 15s | One semantic request answered by a warm sidecar | | `DEBUG_SESSION_MS` | 45s | A live `netcoredbg` session — launch, bind, step, evaluate, detach | diff --git a/docs/specs/SHARPLSP-SPEC.md b/docs/specs/SHARPLSP-SPEC.md index dfa5dbb8..68dcd723 100644 --- a/docs/specs/SHARPLSP-SPEC.md +++ b/docs/specs/SHARPLSP-SPEC.md @@ -24,7 +24,7 @@ Primary implementations: [main.rs](../../src/sharplsp/src/main.rs), [handlers.rs **Tier 1 — Rust LSP Host** -- Owns the LSP connection ([JSON-RPC](https://www.jsonrpc.org/specification) over stdio) +- Owns the LSP connection ([JSON-RPC](https://www.jsonrpc.org/specification) over stdio); answers `shutdown` the moment it arrives, ahead of any request in flight, so an editor's stop timeout never strands a restart - Maintains the authoritative Virtual File System (VFS) with document state - Runs [tree-sitter](https://tree-sitter.github.io/tree-sitter/) incremental parsing for both C# and F# (sub-millisecond re-parses) - Hosts the [salsa](https://salsa-rs.github.io/salsa/) incremental computation database for caching and dependency tracking diff --git a/docs/specs/TEST-EXPLORER-SPEC.md b/docs/specs/TEST-EXPLORER-SPEC.md index a527854a..a6ee0e5b 100644 --- a/docs/specs/TEST-EXPLORER-SPEC.md +++ b/docs/specs/TEST-EXPLORER-SPEC.md @@ -209,8 +209,12 @@ the `dotnet` CLI built — never mocks and never a hand-authored `.sln`. The sui | `test-explorer-windows.test.ts` | paths carrying spaces and parentheses, filter escaping, TRX and console parsing, CRLF, BOM, locale pinning | | `test-explorer-reactive.test.ts` | debounce, generation guard, edit-then-refresh round trips, adding and removing a project, tree preserved on failure | | `testing-lens-e2e.test.ts` | the at-cursor commands and the status CodeLens, and that the Run and Debug actions resolve the same method by name | +| `testing-lens-status.test.ts` | the STATUS lens as a real CodeLens over a built, discovered and RUN solution: "Not run" first, then each of the pass/fail/skip titles on its own method, reactive refresh with the editor open, and the enable setting governing the status as well as the actions | +| `test-explorer-names.test.ts` | the fully-qualified name reader at its boundary: a 40-hex adapter unique ID stripped, every near miss — 39 or 41 digits, no leading space, non-hex, empty or trailing brackets, the NUnit `Adds_Case(2,2,4)` shape — left verbatim, and a real listing file (BOM, CRLF, one line per theory row) collapsing to one id per test | | `test-explorer-adapter-ids.test.ts` | an adapter that DECORATES the names it reports (`xunit.runner.visualstudio` 2.2.0): bare ids, readable labels, an unescaped filter, real TRX outcomes and a resolvable lens | -| `test-explorer-multitarget.test.ts` | a `` project collapsing to ONE assembly root whose names are the UNION of the frameworks', and running from it | +| `test-explorer-multitarget.test.ts` | a `` project collapsing to ONE assembly root whose names are the UNION of the frameworks' — proved with a test compiled behind `#if` into each framework's assembly and not the other's — and running both the merged root and one framework-exclusive test from it | +| `test-explorer-cancellation.test.ts` | pressing Stop, from every gesture that starts a run — the play button, Run with Coverage, a namespace row, the assembly root, a multi-select, an already-cancelled token, a late Stop, two cancellations back to back: the process TREE dies, results are suppressed, the tree stands and the `dotnet` queue drains | +| `test-explorer-coverage.test.ts` | the Coverage profile over TWO test projects covering one library: one report per project, EVERY one parsed, a freshly emptied results directory between runs, partial coverage, an empty report when nothing was loaded, and the Run profile collecting nothing ([TEST-COVERAGE]) | | `debug-test-debugging-e2e.test.ts` | the Debug run profile on ONE test: a real DAP session attached to the waiting test host, a breakpoint in the body and in a helper, a failing test, a skipped one, `[Theory]` rows, nothing armed, and disabled/conditional breakpoints | | `debug-test-groups-e2e.test.ts` | debugging a SELECTION: the class row, the namespace row, the assembly root, a multi-select across classes, and the unselected test that must not run | | `debug-test-fsharp-e2e.test.ts` | F# first: a backtick name carrying SPACES debugged, its module helper on the stack, `[]` rows, and Debug Test at the cursor | diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 0cca7984..eaff99a5 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -756,7 +756,7 @@ }, { "command": "sharplsp.nuget.addFromExplorer", - "title": "%cmd.nuget.add%", + "title": "%cmd.nuget.addFromExplorer%", "category": "%category%", "icon": "$(package)" }, diff --git a/src/editors/vscode/package.nls.ja.json b/src/editors/vscode/package.nls.ja.json index eddffaed..b36035b8 100644 --- a/src/editors/vscode/package.nls.ja.json +++ b/src/editors/vscode/package.nls.ja.json @@ -85,6 +85,7 @@ "cmd.rebuild": "リビルド", "cmd.clean": "クリーン", "cmd.nuget.add": "NuGet パッケージを追加", + "cmd.nuget.addFromExplorer": "選択したプロジェクトに NuGet パッケージを追加", "cmd.nuget.update": "NuGet パッケージを更新", "cmd.newSolution": "新しいソリューション", "cmd.newProject": "新しいプロジェクト", diff --git a/src/editors/vscode/package.nls.json b/src/editors/vscode/package.nls.json index 6ad925b9..738f5783 100644 --- a/src/editors/vscode/package.nls.json +++ b/src/editors/vscode/package.nls.json @@ -82,6 +82,7 @@ "cmd.rebuild": "Rebuild", "cmd.clean": "Clean", "cmd.nuget.add": "Add NuGet Package", + "cmd.nuget.addFromExplorer": "Add NuGet Package to Selected Project", "cmd.nuget.update": "Update NuGet Package", "cmd.newSolution": "New Solution", "cmd.newProject": "New Project", diff --git a/src/editors/vscode/package.nls.zh-cn.json b/src/editors/vscode/package.nls.zh-cn.json index 63864306..bab24d44 100644 --- a/src/editors/vscode/package.nls.zh-cn.json +++ b/src/editors/vscode/package.nls.zh-cn.json @@ -85,6 +85,7 @@ "cmd.rebuild": "重新构建", "cmd.clean": "清理", "cmd.nuget.add": "添加 NuGet 包", + "cmd.nuget.addFromExplorer": "向选定项目添加 NuGet 包", "cmd.nuget.update": "更新 NuGet 包", "cmd.newSolution": "新建解决方案", "cmd.newProject": "新建项目", diff --git a/src/editors/vscode/src/attach-target.ts b/src/editors/vscode/src/attach-target.ts index 2ebc28b9..e5573dde 100644 --- a/src/editors/vscode/src/attach-target.ts +++ b/src/editors/vscode/src/attach-target.ts @@ -14,6 +14,7 @@ // session against nothing. Both are resolved HERE, before a session is created, // so the workbench's `startDebugging` result is the honest answer. import { execFile } from 'node:child_process'; +import { delay } from './utils'; import * as path from 'node:path'; /** How long a process listing may take before the attach is refused. */ @@ -55,11 +56,24 @@ function isRecord(value: unknown): value is Record { /** A configuration field read as a positive integer, or undefined. */ function positiveInteger(value: unknown): number | undefined { - const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value; + const parsed = typeof value === 'string' ? wholeNumber(value.trim()) : value; if (typeof parsed !== 'number' || !Number.isInteger(parsed) || parsed <= 0) return undefined; return parsed; } +/** + * A pid spelling is DIGITS ONLY, checked by round-tripping through the same + * parser rather than by pattern-matching the text. + * + * `Number.parseInt` stops at the first non-digit, so '12abc' reads as 12 - and + * because a system-owned pid answers EPERM, which counts as alive, a mistyped + * or truncated pid resolved to a real attach against an unrelated process. + */ +function wholeNumber(text: string): number { + const parsed = Number.parseInt(text, 10); + return String(parsed) === text ? parsed : Number.NaN; +} + /** Run a command and resolve with its stdout, or with '' when it fails. */ async function capture(command: string, args: readonly string[]): Promise { return await new Promise((resolve) => { @@ -186,10 +200,24 @@ export function commandTokens(commandLine: string): string[] { export function matchesProcessName(row: ProcessRow, name: string): boolean { const wanted = MANAGED_SUFFIXES.map((suffix) => `${name}${suffix}`.toLowerCase()); return commandTokens(row.commandLine).some((token) => - wanted.includes(path.basename(token).toLowerCase()), + wanted.includes(fileNameOf(token).toLowerCase()), ); } +/** + * The file name of `token`, whichever platform's separators the token uses. + * + * `path.basename` only knows the HOST's separator, so a Windows-shaped path in + * a command line — `"C:\a b\StepTarget.dll"` — came back whole when the + * listing was read on Linux, and a process the user named by assembly matched + * nothing. A command line is text from another process, not a host path: it can + * carry either separator wherever it is read. + */ +function fileNameOf(token: string): string { + const cut = Math.max(token.lastIndexOf('/'), token.lastIndexOf('\\')); + return cut === -1 ? token : token.slice(cut + 1); +} + /** The refusal a name that matched nothing produces. */ function noSuchName(name: string): AttachOutcome { return { @@ -208,13 +236,35 @@ function ambiguousName(name: string, pids: readonly number[]): AttachOutcome { }; } +/** + * How long a just-started process is given to appear in the OS process table. + * + * The same shape as the attach request's own ladder in dap-attach.ts: a short + * first look so the common case stays instant, then widening waits. + */ +const NAME_RESOLVE_DELAYS_MS: readonly number[] = [0, 250, 500, 1_000]; + /** Resolve a `processName` to the single live process it names. */ async function resolveByName(name: string): Promise { - const matched = (await listProcesses(name)).filter( - (row) => row.pid !== process.pid && matchesProcessName(row, name), - ); - const pids = matched.map((row) => row.pid); - if (pids.length === 0) return noSuchName(name); + for (const wait of NAME_RESOLVE_DELAYS_MS) { + if (wait > 0) await delay(wait); + const outcome = await matchOnce(name); + if (outcome !== undefined) return outcome; + } + return noSuchName(name); +} + +/** + * One look at the process table: an answer, or `undefined` for "not yet". + * + * Ambiguity is an ANSWER and returns immediately — a second match will not + * become a single one by waiting, and the user needs telling now. + */ +async function matchOnce(name: string): Promise { + const pids = (await listProcesses(name)) + .filter((row) => row.pid !== process.pid && matchesProcessName(row, name)) + .map((row) => row.pid); + if (pids.length === 0) return undefined; const only = pids[0]; if (pids.length > 1 || only === undefined) return ambiguousName(name, pids); return { kind: 'attach', processId: only }; diff --git a/src/editors/vscode/src/client.ts b/src/editors/vscode/src/client.ts index 2400af3f..099f73bc 100644 --- a/src/editors/vscode/src/client.ts +++ b/src/editors/vscode/src/client.ts @@ -16,6 +16,7 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient/node'; import { EXTENSION_ID, EXTENSION_NAME, SERVER_BINARY, SERVER_BINARY_WIN } from './constants.js'; +import { getErrorMessage } from './utils.js'; import * as config from './config.js'; import * as log from './log.js'; import { createAnsiStrippingChannel } from './output-filter.js'; @@ -142,13 +143,21 @@ function wireStatusBar( * - Suppresses the modal error dialog on close (uses `handled: true`) * - Allows up to MAX_RESTARTS automatic restarts * - After MAX_RESTARTS, stops and shows one actionable message + * - Never lets a connection ERROR end the session while restarts remain, + * because `ErrorAction.Shutdown` is the one decision `closed()` can never + * recover from */ function makeErrorHandler(statusBar: SharpLspStatusBar): { error(error: Error, message: Message | undefined, count: number | undefined): ErrorHandlerResult; closed(): CloseHandlerResult; } { const MAX_RESTARTS = 5; + // Two crashes further apart than this are unrelated, not a loop. Without it + // the budget is a lifetime allowance: a server that dies once every couple of + // hours exhausts it in a working day and then never restarts again. + const CRASH_WINDOW_MS = 3 * 60 * 1_000; let restartCount = 0; + let lastClosedAt = 0; return { error( @@ -159,10 +168,21 @@ function makeErrorHandler(statusBar: SharpLspStatusBar): { if ((count ?? 0) <= 3) { return { action: ErrorAction.Continue }; } + // `Shutdown` stops the client outright, and a stopped client never calls + // `closed()` — so escalating here would forfeit every restart below. A + // dead transport closes on its own; recovery belongs to `closed()`. + if (restartCount < MAX_RESTARTS) { + return { action: ErrorAction.Continue }; + } return { action: ErrorAction.Shutdown }; }, closed(): CloseHandlerResult { + const now = Date.now(); + if (now - lastClosedAt > CRASH_WINDOW_MS) { + restartCount = 0; + } + lastClosedAt = now; restartCount += 1; if (restartCount <= MAX_RESTARTS) { log.info( @@ -247,3 +267,25 @@ function expandPath(raw: string): string { const folder = workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; return raw.replace('${workspaceFolder}', folder); } + +/** How long a graceful `shutdown` may take before the restart respawns anyway. */ +const RESTART_STOP_TIMEOUT_MS = 10_000; + +/** + * Restart the server on the user's behalf. Implements [DIST-FAILURE-UX] rule 6. + * + * `LanguageClient.restart()` is `stop()` then `start()`, and the library's + * `stop()` gives `shutdown` two seconds before it throws WITHOUT starting + * anything - which turns the recovery command into a way to kill the client. + * A hung server is the very reason a user reaches for Restart, so the stop + * gets a real budget and the start happens whether or not the old process + * bowed out in time. + */ +export async function restart(lspClient: LanguageClient): Promise { + try { + await lspClient.stop(RESTART_STOP_TIMEOUT_MS); + } catch (err: unknown) { + log.warn(`Graceful stop failed; starting a fresh server anyway: ${getErrorMessage(err)}`); + } + await lspClient.start(); +} diff --git a/src/editors/vscode/src/dap-attach.ts b/src/editors/vscode/src/dap-attach.ts index 858121d4..af8ef71f 100644 --- a/src/editors/vscode/src/dap-attach.ts +++ b/src/editors/vscode/src/dap-attach.ts @@ -13,7 +13,20 @@ // first stop the user sees is their own breakpoint). import { isRecord, type DapMessage } from './dap-emulate'; -const RETRY_DELAYS_MS: readonly number[] = [500, 1_000, 2_000, 4_000, 8_000, 15_000]; +/** + * Backoff for netcoredbg's transient `0x80070057`, PER COMMAND. + * + * No response reaches VS Code until the ladder is exhausted, so its total is a + * hard floor under how long the client can be left waiting. An attach must fit + * inside [DEBUG-PERFORMANCE] "Attach to running process | <3s", which is also + * the policy DEBUGGING-PLAN 4.3 states (three retries, 500 ms). A watch + * expression that answers late is an annoyance; an attach that answers late is + * a dead session, so the two no longer share one ladder. + */ +const RETRY_DELAYS_MS: Readonly> = { + attach: [250, 500, 1_000], + evaluate: [500, 1_000, 2_000, 4_000], +}; /** * Attach-configuration marker a Test Explorer debug run sets so the router @@ -58,7 +71,7 @@ class InvalidArgumentRetrier { private async run(clientRequest: DapMessage, args: Record): Promise { for (let attempt = 0; !this.host.isClosed(); attempt += 1) { const response = await this.host.request(this.command, args); - const wait = RETRY_DELAYS_MS[attempt]; + const wait = RETRY_DELAYS_MS[this.command][attempt]; if (!isTransientInvalidArgument(response) || wait === undefined) { this.deliver(clientRequest, response); return; diff --git a/src/editors/vscode/src/dap-breakpoints.ts b/src/editors/vscode/src/dap-breakpoints.ts index 398d746f..102ac2b1 100644 --- a/src/editors/vscode/src/dap-breakpoints.ts +++ b/src/editors/vscode/src/dap-breakpoints.ts @@ -291,6 +291,51 @@ export class BreakpointEmulator { }; } + /** + * Re-key one breakpoint to the line the adapter actually bound it to. + * + * A breakpoint armed before its module is loaded — which is EVERY breakpoint + * of a test-host attach — is answered by `setBreakpoints` with no line of its + * own, so the location index holds the line the user typed. The real bind + * arrives later as a `breakpoint` event + * ([DEBUG-FEATURES-BREAKPOINTS-VERIFY]), and a stop reports the BOUND line. + * Without this the index misses, the stop is judged unknown and forwarded, + * and the hit count or logpoint the user attached is silently ignored. + */ + public rebind(entry: unknown): void { + if (!isRecord(entry)) return; + const id = Number(entry.id ?? Number.NaN); + const line = Number(entry.line ?? Number.NaN); + // DAP lines are 1-based, so a 0 or absent line describes no location and + // must not move an entry that is already keyed correctly. + if (!Number.isInteger(id) || !Number.isInteger(line) || line <= 0) return; + const placed = this.locate(id); + if (placed === undefined || placed.line === line) return; + this.moveLine(placed.source, placed.line, line, { ...placed.meta, line }); + } + + /** Where `id` currently sits in the location index. */ + private locate(id: number): { source: string; line: number; meta: BreakpointMeta } | undefined { + for (const [source, lines] of this.byLocation) { + for (const [line, meta] of lines) { + if (meta.id === id) return { source, line, meta }; + } + } + return undefined; + } + + /** Move one armed line, carrying its visit count across with it. */ + private moveLine(source: string, from: number, to: number, meta: BreakpointMeta): void { + const lines = this.byLocation.get(source); + if (lines === undefined) return; + lines.delete(from); + lines.set(to, meta); + if (meta.id !== undefined) this.meta.set(meta.id, meta); + const visited = this.counts.get(locationKey(source, from)); + this.counts.delete(locationKey(source, from)); + if (visited !== undefined) this.counts.set(locationKey(source, to), visited); + } + /** True once any breakpoint with emulated attributes is armed. */ public hasEmulatedAttributes(): boolean { return this.byLocation.size > 0 || this.meta.size > 0; diff --git a/src/editors/vscode/src/dap-emulate.ts b/src/editors/vscode/src/dap-emulate.ts index 632954ed..fc73bede 100644 --- a/src/editors/vscode/src/dap-emulate.ts +++ b/src/editors/vscode/src/dap-emulate.ts @@ -140,7 +140,7 @@ function isVerbatimQuote(text: string, index: number): boolean { * strings (`@"..."`, `$@"..."`, `@$"..."`) treat backslash as literal text and * `""` as the one quote escape; every other literal escapes with backslash. */ -function skipLiteral(text: string, index: number): number { +export function skipLiteral(text: string, index: number): number { const quote = text[index]; const verbatim = isVerbatimQuote(text, index); for (let scan = index + 1; scan < text.length; scan += 1) { diff --git a/src/editors/vscode/src/dap-evaluate.ts b/src/editors/vscode/src/dap-evaluate.ts index d710099e..f8ca1065 100644 --- a/src/editors/vscode/src/dap-evaluate.ts +++ b/src/editors/vscode/src/dap-evaluate.ts @@ -27,6 +27,14 @@ import { type NullComparison, } from './dap-emulate'; import { emulateCast, renderedOf, type RenderedValue } from './dap-cast'; +import { + emulateStringMember, + isStringValue, + parseIndexAccess, + parseMemberAccess, + rendersAsException, + type IndexAccess, +} from './dap-string-members'; /** netcoredbg's exact refusal for the one syntax kind the router can serve. */ function isCastRefusal(response: DapMessage): boolean { @@ -49,6 +57,18 @@ function answer(value: RenderedValue): DapMessage { }; } +/** A refusal carrying a reason the Watch panel can put in front of the user. */ +function refusal(reason: string): DapMessage { + return { + seq: 0, + type: 'response', + command: 'evaluate', + success: false, + message: reason, + body: {}, + }; +} + /** The synthesized boolean a null test answers with. */ function boolAnswer(isNull: boolean, negated: boolean): DapMessage { return answer({ result: String(isNull !== negated), type: 'bool', variablesReference: 0 }); @@ -135,10 +155,11 @@ export class EvaluateEmulator { private async run(clientRequest: DapMessage, args: Record): Promise { const expression = typeof args.expression === 'string' ? args.expression : ''; const nullTest = parseNullComparison(expression); - const response = + const forwarded = nullTest === undefined ? await this.forwardWithCastFallback(args, expression) : await this.answerNullTest(args, nullTest); + const response = await this.refuseFaultedIndex(forwarded, args, expression); if (this.host.isClosed()) return; this.host.deliver({ ...response, request_seq: Number(clientRequest.seq ?? -1) }); } @@ -209,13 +230,80 @@ export class EvaluateEmulator { return isReference && hasNoOperator ? 'answer' : 'refuse'; } - /** Forward to netcoredbg; on its cast refusal, perform the cast here. */ + /** Forward to netcoredbg, then close the gaps it leaves on either side. */ private async forwardWithCastFallback( args: Record, expression: string, ): Promise { const response = await this.attempts.evaluate(args); - if (response.success !== false || !isCastRefusal(response)) return response; + if (response.success !== false) return response; + if (isCastRefusal(response)) return await this.castFallback(response, args, expression); + return await this.stringMemberFallback(response, args, expression); + } + + /** + * Turn an indexer that faulted into the refusal it is. + * + * netcoredbg answers an out-of-range read SUCCESSFULLY, rendering the thrown + * exception where the value belongs — a wrong answer the user acts on. The + * exception rendering alone is not proof (a watch on a real exception object + * renders identically), so it only decides whether to spend the round trip; + * the refusal itself needs the index to be provably outside the receiver. + */ + private async refuseFaultedIndex( + response: DapMessage, + args: Record, + expression: string, + ): Promise { + if (response.success !== true) return response; + if (!rendersAsException(renderedResponse(response))) return response; + const index = parseIndexAccess(expression); + if (index === undefined) return response; + return (await this.indexIsOutsideReceiver(index, args)) + ? refusal(`'${expression}' is outside the bounds of '${index.receiver}'.`) + : response; + } + + /** Whether the index provably addresses nothing in the receiver. */ + private async indexIsOutsideReceiver( + index: IndexAccess, + args: Record, + ): Promise { + const [position, size] = await Promise.all([ + this.wholeNumberOf(args, index.index), + this.receiverSize(args, index.receiver), + ]); + if (position === undefined || size === undefined) return false; + return position < 0 || position >= size; + } + + /** The receiver's element count, from whichever of the two names it has. */ + private async receiverSize( + args: Record, + receiver: string, + ): Promise { + const count = await this.wholeNumberOf(args, `${receiver}.Count`); + return count ?? (await this.wholeNumberOf(args, `${receiver}.Length`)); + } + + /** One sub-evaluation read back as a whole number, or nothing. */ + private async wholeNumberOf( + args: Record, + expression: string, + ): Promise { + const evaluated = await this.attempts.evaluate({ ...args, expression }); + if (evaluated.success === false) return undefined; + const rendered = renderedResponse(evaluated).result; + const parsed = Number(rendered); + return Number.isInteger(parsed) ? parsed : undefined; + } + + /** netcoredbg refused the cast syntax; perform the cast here. */ + private async castFallback( + response: DapMessage, + args: Record, + expression: string, + ): Promise { const cast = parseCastExpression(expression); if (cast === undefined) return response; const operand = await this.attempts.evaluate({ ...args, expression: cast.operand }); @@ -223,4 +311,25 @@ export class EvaluateEmulator { const emulated = emulateCast(cast.targetType, renderedResponse(operand)); return emulated === undefined ? response : answer(emulated); } + + /** + * netcoredbg cannot walk members through a string value, so a member reached + * through a string receiver is refused even though the receiver itself + * evaluates. Answer it from the receiver's own rendering when the member is + * a pure function of the characters ([DEBUG-FEATURES-VARIABLES] T2). + */ + private async stringMemberFallback( + response: DapMessage, + args: Record, + expression: string, + ): Promise { + const access = parseMemberAccess(expression); + if (access === undefined) return response; + const receiver = await this.attempts.evaluate({ ...args, expression: access.receiver }); + if (receiver.success === false) return response; + const value = renderedResponse(receiver); + if (!isStringValue(value)) return response; + const emulated = emulateStringMember(access, value); + return emulated === undefined ? response : answer(emulated); + } } diff --git a/src/editors/vscode/src/dap-fsharp-conditions.ts b/src/editors/vscode/src/dap-fsharp-conditions.ts new file mode 100644 index 00000000..2f982397 --- /dev/null +++ b/src/editors/vscode/src/dap-fsharp-conditions.ts @@ -0,0 +1,112 @@ +// F# breakpoint conditions, rewritten into the dialect netcoredbg evaluates. +// +// netcoredbg's expression evaluator is C#-only. An F# developer writes the +// equality they write everywhere else in their language — `index = 2` — and the +// adapter reads that as an assignment, so the condition never selects a pass +// and the breakpoint behaves as an unconditional one: the debuggee stops on the +// FIRST hit, not the one the user asked for. The identical C# suite is green +// only because `index == 2` happens to already be the adapter's dialect. +// +// [DEBUG-FEATURES-BREAKPOINTS-CONTRIBUTION] rule 3 calls that asymmetry +// non-conforming, and F# is a first-class citizen here, so the translation +// happens at the one place that knows the source language. +// +// Only the two operators whose SPELLING differs are rewritten — `=` and `<>`. +// Everything else in an F# condition that netcoredbg can evaluate at all +// (member access, literals, `&&`, comparisons) is already spelled the same, and +// rewriting more would be inventing an F# evaluator rather than spelling one +// operator the way the evaluator expects. +import { isRecord, recordList, type DapMessage } from './dap-emulate'; + +/** Source extensions whose conditions are written in F#. */ +const FSHARP_EXTENSIONS: readonly string[] = ['.fs', '.fsi', '.fsx', '.fsscript']; + +/** Operators that CONTAIN `=` and must survive the rewrite untouched. */ +const COMPOSITE_EQUALS: readonly string[] = ['==', '!=', '<=', '>=', '=>']; + +/** Whether `path` is F# source, so its conditions are F# expressions. */ +export function isFSharpSource(path: string): boolean { + const lowered = path.toLowerCase(); + return FSHARP_EXTENSIONS.some((extension) => lowered.endsWith(extension)); +} + +/** + * Rewrite `setBreakpoints` arguments so every condition is C#-spelled. + * + * Returns the message unchanged when nothing needed rewriting, so a condition + * already written in the adapter's dialect is passed through byte for byte. + */ +export function withClrConditions(message: DapMessage): DapMessage { + const args = isRecord(message.arguments) ? message.arguments : undefined; + if (args === undefined) return message; + const breakpoints = recordList(args.breakpoints); + if (breakpoints.length === 0) return message; + const rewritten = breakpoints.map(withClrCondition); + if (rewritten.every((entry, index) => entry === breakpoints[index])) return message; + return { ...message, arguments: { ...args, breakpoints: rewritten } }; +} + +/** One breakpoint's condition, C#-spelled. */ +function withClrCondition(breakpoint: Record): Record { + const condition = breakpoint.condition; + if (typeof condition !== 'string' || condition === '') return breakpoint; + const translated = toClrCondition(condition); + return translated === condition ? breakpoint : { ...breakpoint, condition: translated }; +} + +/** + * `index = 2` -> `index == 2`, `name <> "x"` -> `name != "x"`. + * + * Scans the text rather than matching a pattern: string and character literals + * are skipped whole, so an `=` or `<>` INSIDE a literal is left alone, and a + * `=` that is already part of `==`, `!=`, `<=`, `>=` or `=>` is not doubled. + */ +export function toClrCondition(condition: string): string { + let translated = ''; + for (let index = 0; index < condition.length; index += 1) { + const character = condition[index] ?? ''; + if (character === '"' || character === "'") { + const end = endOfLiteral(condition, index); + translated += condition.slice(index, end); + index = end - 1; + } else if (condition.startsWith('<>', index)) { + translated += '!='; + index += 1; + } else if (character === '=' && isLoneEquals(condition, index)) { + translated += '=='; + } else { + translated += character; + } + } + return translated; +} + +/** Whether the `=` at `index` is an equality on its own, not part of a pair. */ +function isLoneEquals(condition: string, index: number): boolean { + const pairs = [condition.slice(index - 1, index + 1), condition.slice(index, index + 2)]; + return !pairs.some((pair) => COMPOSITE_EQUALS.includes(pair)); +} + +/** + * The index just past the literal opening at `index`. + * + * F# has no verbatim `@"..."` form but does have triple-quoted strings, in + * which nothing escapes; both are closed by the same quote character, so a + * backslash is only an escape outside a triple quote. + */ +function endOfLiteral(condition: string, index: number): number { + const quote = condition[index] ?? '"'; + const triple = condition.startsWith('"""', index); + if (triple) { + const close = condition.indexOf('"""', index + 3); + return close === -1 ? condition.length : close + 3; + } + for (let scan = index + 1; scan < condition.length; scan += 1) { + if (condition[scan] === '\\') { + scan += 1; + } else if (condition[scan] === quote) { + return scan + 1; + } + } + return condition.length; +} diff --git a/src/editors/vscode/src/dap-replay.ts b/src/editors/vscode/src/dap-replay.ts index 592d5e40..75ea0367 100644 --- a/src/editors/vscode/src/dap-replay.ts +++ b/src/editors/vscode/src/dap-replay.ts @@ -10,6 +10,17 @@ import type { DapMessage } from './dap-emulate'; import { isRecord } from './dap-emulate'; +/** + * The DAP `runInTerminal` kind each `console` value names. + * [DEBUG-FEATURES-LAUNCH-OUTPUT] routes `integratedTerminal` to VS Code's own + * terminal and `externalTerminal` to an OS terminal window; every other value + * is adapter-hosted and asks the client for no terminal at all. + */ +const TERMINAL_KINDS = new Map([ + ['integratedTerminal', 'integrated'], + ['externalTerminal', 'external'], +]); + /** What the replayer needs from its owning router. */ export interface ReplayHost { /** Write one message to the live child (DAP framing applied by the host). */ @@ -61,12 +72,29 @@ export class SessionReplayer { } } - /** The launch asked for the integrated terminal and VS Code can host one. */ - public wantsTerminal(): boolean { + /** + * The DAP `runInTerminal` kind this launch asked for, when the client can + * host one at all. + * + * Both hosted rows of the [DEBUG-FEATURES-LAUNCH-OUTPUT] routing table are + * answered here. Recognising only `integratedTerminal` meant a launch that + * asked for `externalTerminal` was forwarded to netcoredbg verbatim, no + * `runInTerminal` was ever issued, and the debuggee quietly took the + * adapter-hosted row instead of the one the configuration named. + */ + public terminalKind(): 'integrated' | 'external' | undefined { const args: unknown = this.launchMessage?.arguments; - if (!isRecord(args) || args.console !== 'integratedTerminal') return false; + if (!isRecord(args)) return undefined; + const console = typeof args.console === 'string' ? args.console : ''; + const kind = TERMINAL_KINDS.get(console); + if (kind === undefined) return undefined; const initArgs: unknown = this.initializeMessage?.arguments; - return isRecord(initArgs) && initArgs.supportsRunInTerminalRequest === true; + return isRecord(initArgs) && initArgs.supportsRunInTerminalRequest === true ? kind : undefined; + } + + /** The launch asked for a terminal VS Code can host. */ + public wantsTerminal(): boolean { + return this.terminalKind() !== undefined; } /** Answer the launch and ask VS Code to host the debuggee in a terminal. */ @@ -90,7 +118,7 @@ export class SessionReplayer { // when present and the launch falls back to adapter-hosted otherwise. const command = process.platform === 'win32' ? debuggee : ['exec', ...debuggee]; const terminalArgs: Record = { - kind: 'integrated', + kind: this.terminalKind() ?? 'integrated', title: 'SharpLsp Debug', cwd: typeof args.cwd === 'string' ? args.cwd : undefined, args: command, diff --git a/src/editors/vscode/src/dap-router.ts b/src/editors/vscode/src/dap-router.ts index d81dc012..5de6edf2 100644 --- a/src/editors/vscode/src/dap-router.ts +++ b/src/editors/vscode/src/dap-router.ts @@ -3,6 +3,7 @@ import type * as cp from 'node:child_process'; import * as vscode from 'vscode'; import { retarget } from './dap-exceptions'; import { isRecord, sourcePathOf, type DapMessage } from './dap-emulate'; +import { isFSharpSource, withClrConditions } from './dap-fsharp-conditions'; import { BreakpointEmulator } from './dap-breakpoints'; import { AttachRetrier, type RetryHost } from './dap-attach'; import { EvaluateEmulator } from './dap-evaluate'; @@ -25,6 +26,49 @@ import { err, ok, type Result } from './result'; /** The DAP dialect netcoredbg speaks; without it there is no DAP at all. */ export const INTERPRETER_ARGS: readonly string[] = ['--interpreter=vscode']; +/** True when a refusal names an HRESULT that describes the thread, not the step. */ +function refusedWrongThread(message: DapMessage): boolean { + const detail = typeof message.message === 'string' ? message.message : ''; + return WRONG_THREAD_HRESULTS.some((code) => detail.includes(code)); +} + +/** + * The HRESULTs netcoredbg answers a step it will not perform on that thread. + * + * `0x80004005` is E_FAIL and `0x80131309` is CORDBG_E_BAD_THREAD_STATE; the + * adapter uses them interchangeably for the same refusal, so both are matched. + */ +const WRONG_THREAD_HRESULTS: readonly string[] = ['0x80004005', '0x80131309']; + +/** + * How much of a DAP payload one trace line carries. + * + * The trace exists to answer "what did we send, and what came back". Four + * different truncations, the widest at 100 characters, cut a `setBreakpoints` + * off inside its `source.path` — so the one request whose payload is the whole + * question logged everything except the breakpoints. One budget, wide enough to + * carry an armed breakpoint list, and only ever paid under SHARPLSP_DAP_TRACE. + */ +const TRACE_PAYLOAD_CHARS = 600; + +/** + * A refusal the panel cannot show is a refusal the user cannot act on. + * + * netcoredbg answers some requests it cannot serve with `success: false` and an + * EMPTY `message` — a `setVariable` addressed through `variablesReference: 0`, + * which DAP defines as naming no container at all, is one. VS Code renders a + * response's `message` and has nothing else to show, so the edit visibly fails + * with no reason attached and the user is left guessing which of the name, the + * value or the target was wrong. Naming the request is the least a client can + * put in front of them. + */ +function withRefusalReason(message: DapMessage): DapMessage { + if (message.type !== 'response' || message.success !== false) return message; + if (typeof message.message === 'string' && message.message !== '') return message; + const command = typeof message.command === 'string' ? message.command : 'request'; + return { ...message, message: `The debug adapter refused the ${command} request.` }; +} + /** * Proxies DAP between VS Code and a netcoredbg child process, enriching and * emulating the messages the spec requires the router to serve. @@ -76,6 +120,13 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta private readonly evaluations: EvaluateEmulator; private readonly variables: VariableExpander; private readonly hotReload: DapHotReload; + /** + * The thread the adapter last announced stopped, and the only one it will + * step. netcoredbg keeps ONE current thread; a step aimed anywhere else is + * refused outright ([DEBUG-ADAPTER-GAPS]). + */ + private stoppedThread: number | undefined; + /** True once the child itself sent the DAP `terminated` event. */ private childAnnouncedTerminated = false; private justMyCode = true; @@ -147,6 +198,9 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta onGone: (why) => { this.onChildGone(why); }, + onUndeliverable: (frame) => { + this.answerUndeliverable(frame); + }, announcedTerminated: () => this.childAnnouncedTerminated, isClosed: () => this.closed, isDisposed: () => this.disposed, @@ -182,37 +236,58 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta output: `SharpLsp: netcoredbg ${why}. Ending the debug session.\n`, }, }); - if (!this.childAnnouncedTerminated) { + if (this.endsSessionOnce('terminated')) { this.fire({ type: 'event', event: 'terminated', body: {} }); } } + /** + * Settle a request the adapter can no longer hear. + * + * Only a REQUEST needs settling — an event or a response expects no reply, so + * dropping one costs nothing. `disconnect` succeeds because an adapter that + * is gone IS the disconnected state, and refusing it would leave the session + * in the debug toolbar with no way to close it. + */ + private answerUndeliverable(message: DapMessage): void { + if (message.type !== 'request') return; + const command = typeof message.command === 'string' ? message.command : ''; + this.respondTo(message, command === 'disconnect', {}); + } + /** VS Code -> netcoredbg, with the router's intercepts. */ public handleMessage(message: vscode.DebugProtocolMessage): void { if (!isRecord(message)) return; if (process.env.SHARPLSP_DAP_TRACE === '1') { traceInfo( - `[dap->] ${String(message.command ?? message.type)} ${JSON.stringify(message.arguments ?? message.body ?? {}).slice(0, 100)}`, + `[dap->] ${String(message.command ?? message.type)} ${JSON.stringify(message.arguments ?? message.body ?? {}).slice(0, TRACE_PAYLOAD_CHARS)}`, ); } - const msg: DapMessage = message; if (message.type === 'response') { - this.onClientResponse(msg); + this.onClientResponse(message); return; } const command = typeof message.command === 'string' ? message.command : ''; const args = isRecord(message.arguments) ? message.arguments : undefined; if (process.env.SHARPLSP_DAP_TRACE === '1' && command !== '') { - traceInfo(`[dap->] ${command} ${JSON.stringify(args ?? {}).slice(0, 90)}`); + traceInfo(`[dap->] ${command} ${JSON.stringify(args ?? {}).slice(0, TRACE_PAYLOAD_CHARS)}`); } const breakpointPath = command === 'setBreakpoints' ? sourcePathOf(args ?? {}) : undefined; + // An F# condition is spelled in F#; netcoredbg only evaluates C#. Translate + // BEFORE anything records the message, so the replayer re-arms the same + // translated breakpoint and the pending-args map holds what was sent. + const msg: DapMessage = + breakpointPath !== undefined && isFSharpSource(breakpointPath) + ? withClrConditions(message) + : message; this.replayer.observe(msg, breakpointPath); if (command === 'setFunctionBreakpoints') this.breakpoints.recordFunctions(args); if (command === 'launch' && this.replayer.wantsTerminal()) { this.replayer.startTerminalLaunch(); return; } - if (this.interceptCommand(msg, command, args, breakpointPath)) return; + const sentArgs = isRecord(msg.arguments) ? msg.arguments : undefined; + if (this.interceptCommand(msg, command, sentArgs, breakpointPath)) return; if (STEP_COMMANDS.includes(command)) { this.stepper.begin(msg, command, Number(args?.threadId ?? 0)); return; @@ -269,14 +344,14 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta this.goto.onGoto(message); return true; case 'launch': - // A fresh debuggee is starting; any previous exit is history. - this.debuggeeExited = false; + // A fresh debuggee is starting; any previous end is history. + this.armSession(); this.rememberLaunchOptions(args); if (args !== undefined) this.stacks.onLaunch(args); this.hotReload.prepareLaunch(args); return false; case 'attach': - this.debuggeeExited = false; + this.armSession(); this.rememberLaunchOptions(args); this.attaches.start(message, args ?? {}); return true; @@ -355,18 +430,32 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta public fire(message: Record & { seq?: unknown }): void { if (this.disposed) return; const seq = typeof message.seq === 'number' ? message.seq : this.correlator.nextSequence(); - const framed: DapMessage = { ...message, seq }; - this.emitter.fire(framed); + this.emitOutbound({ ...message, seq }); } /** Emit one message towards VS Code exactly as the adapter framed it. */ public emit(message: DapMessage): void { + this.emitOutbound(message); + } + + /** + * The ONE door out to VS Code. + * + * `fire` used to reach the emitter directly, so everything the router + * synthesizes or forwards asynchronously — a located stop, a synthesized + * `terminated`, an emulated output event — left without passing the trace or + * the refusal-reason rule. A trace that claims to show what the client + * received while silently omitting half of it is worse than no trace: it + * reads as proof that a message was never sent. + */ + private emitOutbound(message: DapMessage): void { + const outbound = withRefusalReason(message); if (process.env.SHARPLSP_DAP_TRACE === '1') { traceInfo( - `[dap=>] ${String(message.command ?? message.event ?? message.type)} seq=${String(message.seq)} rs=${String(message.request_seq)} ok=${String(message.success)} msg=${JSON.stringify(message.message ?? '')}`, + `[dap=>] ${String(outbound.command ?? outbound.event ?? outbound.type)} seq=${String(outbound.seq)} rs=${String(outbound.request_seq)} ok=${String(outbound.success)} msg=${JSON.stringify(outbound.message ?? '')} ${JSON.stringify(outbound.body ?? {}).slice(0, TRACE_PAYLOAD_CHARS)}`, ); } - this.emitter.fire(message); + this.emitter.fire(outbound); } /** Respond to a client request on the router's behalf. */ @@ -385,7 +474,7 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta if (this.disposed) return; if (process.env.SHARPLSP_DAP_TRACE === '1') { traceInfo( - `[dap<-] ${String(message.command ?? message.event ?? message.type)} seq=${String(message.seq)} rs=${String(message.request_seq)} ok=${String(message.success)} msg=${JSON.stringify(message.message ?? '')} ${JSON.stringify(message.body ?? {}).slice(0, 80)}`, + `[dap<-] ${String(message.command ?? message.event ?? message.type)} seq=${String(message.seq)} rs=${String(message.request_seq)} ok=${String(message.success)} msg=${JSON.stringify(message.message ?? '')} ${JSON.stringify(message.body ?? {}).slice(0, TRACE_PAYLOAD_CHARS)}`, ); } if (message.type === 'response') { @@ -418,22 +507,67 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta // Frame display names feed the synthesized Statics scope // ([DEBUG-FEATURES-VARIABLES] "Static fields | variables | P1"). this.variables.observeStackTrace(message); + // Only a SUCCESSFUL read describes a stack. The correlator resolves a + // failed response rather than rejecting, so handing one to StackDelivery + // would present a refusal as "this thread has no frames" and send it into + // the empty-stack recovery. A refusal is the client's to see. + if (message.success === false) { + this.pendingStackArgs.delete(requestSeq); + this.emit(this.handles.translateResponseBody(message)); + return; + } this.stacks.deliver(message, this.pendingStackArgs.get(requestSeq)); this.pendingStackArgs.delete(requestSeq); return; } + if (this.retriedOnStoppedThread(message)) return; this.emit(this.handles.translateResponseBody(enrichResponse(message, this.latestChildCaps))); } + + /** + * Re-issue a refused step against the thread the adapter actually stopped. + * + * The workbench steps `viewModel.focusedThread`, and it focuses the stopped + * thread only once `fetchCallStack()` has resolved. A step made before that + * -- pressing F10 the instant a breakpoint hits -- is dispatched at + * `getAllThreads()[0]` instead, which in a test host is a runtime or + * thread-pool thread that never stopped. netcoredbg keeps ONE current thread + * and refuses the rest, so the user's gesture surfaces as a raw HRESULT. + * + * A refusal is rescued, never pre-empted: a step the adapter performs is + * forwarded untouched, so a user who deliberately selected another stopped + * thread is unaffected. E_FAIL means no step happened, so re-issuing cannot + * double-step. Same shape as the `0x80070057` attach retry next door. + * Implements [DEBUG-ADAPTER-GAPS] for the stepping rows. + */ + private retriedOnStoppedThread(message: DapMessage): boolean { + const command = typeof message.command === 'string' ? message.command : ''; + const threadId = this.stoppedThread; + if (message.success !== false || !STEP_COMMANDS.includes(command)) return false; + if (threadId === undefined || !refusedWrongThread(message)) return false; + const seq = Number(message.request_seq ?? -1); + void this.request(command, { threadId }).then((retry) => { + this.emit({ ...retry, request_seq: seq, command }); + }); + return true; + } /** Events that carry emulation state, not just data. */ private onChildEvent(message: DapMessage): void { const name = typeof message.event === 'string' ? message.event : ''; if (['stopped', 'continued'].includes(name)) { - traceInfo(`[stop] ${name} ${JSON.stringify(message.body ?? {}).slice(0, 90)}`); + traceInfo( + `[stop] ${name} ${JSON.stringify(message.body ?? {}).slice(0, TRACE_PAYLOAD_CHARS)}`, + ); } if (process.env.SHARPLSP_DAP_TRACE === '1') { - traceInfo(`[dap<-event] ${name} ${JSON.stringify(message.body ?? {}).slice(0, 80)}`); + traceInfo( + `[dap<-event] ${name} ${JSON.stringify(message.body ?? {}).slice(0, TRACE_PAYLOAD_CHARS)}`, + ); } if (name === 'stopped') { + const stoppedBody = isRecord(message.body) ? message.body : {}; + const stoppedThread = Number(stoppedBody.threadId ?? Number.NaN); + if (Number.isInteger(stoppedThread)) this.stoppedThread = stoppedThread; if (this.stacks.interceptStop(message)) return; // A VSTest host's own attach break is resumed, never surfaced // ([DEBUG-FEATURES-TESTS]); dap-attach.ts owns that judgement. @@ -450,14 +584,20 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta return; } } else if (name === 'exited' || name === 'terminated') { - this.debuggeeExited = true; - if (name === 'terminated') this.childAnnouncedTerminated = true; + // A respawn's teardown noise is not the session ending, and must not be + // RECORDED as one: the adapter being killed for a restart announces an + // end that is swallowed here, and a guard that counted it would leave the + // replacement debuggee unable to announce its own. if (this.transitioning) return; + if (!this.endsSessionOnce(name)) return; } else if (name === 'breakpoint') { // Keep breakpoint EVENT ids in the session-scoped space the // setBreakpoints responses already promised VS Code. const body = isRecord(message.body) ? message.body : {}; this.noteBreakpointBind(body.breakpoint); + // The emulator indexes by the line the adapter BOUND, and a lazily bound + // breakpoint only learns it here ([DEBUG-FEATURES-BREAKPOINTS-VERIFY]). + this.breakpoints.rebind(body.breakpoint); this.announceWhenArmed(); this.emit(this.handles.translateEvent(message)); return; @@ -481,12 +621,41 @@ export class DapRouter implements vscode.DebugAdapter, ReplayHost, StopHost, Sta this.latestChildCaps = { ...this.latestChildCaps, ...advertised }; this.emit(withEventCapabilities(message)); } + /** + * Record an end-of-session announcement, and report whether it is the FIRST. + * + * DAP lets an adapter announce the end more than once and netcoredbg does: + * once when the debuggee exits, again when the client disconnects in reply. + * A session ends once, so only the first announcement of each kind reaches + * VS Code - a repeat is a duplicate of an event the client already acted on. + * `launch`, `attach` and `onRestart` re-arm both flags, so a respawned + * session can announce its own end. + */ + private endsSessionOnce(name: 'exited' | 'terminated'): boolean { + if (name === 'terminated') { + if (this.childAnnouncedTerminated) return false; + this.childAnnouncedTerminated = true; + this.debuggeeExited = true; + return true; + } + if (this.debuggeeExited) return false; + this.debuggeeExited = true; + return true; + } + + /** Re-arm the end-of-session guards for a debuggee that is about to start. */ + private armSession(): void { + this.debuggeeExited = false; + this.childAnnouncedTerminated = false; + } + /** Restart: respawn through the replayer and swallow the teardown noise. */ private onRestart(): void { this.transitioning = true; // The NEXT debuggee has not exited. Leaving this set would make the - // restarted session answer `threads` with an empty list forever. - this.debuggeeExited = false; + // restarted session answer `threads` with an empty list forever, and leave + // it unable to announce its own termination. + this.armSession(); this.breakpoints.reset(); this.stepper.reset(); this.replayer.restart(); diff --git a/src/editors/vscode/src/dap-stack.ts b/src/editors/vscode/src/dap-stack.ts index f144e975..684f967d 100644 --- a/src/editors/vscode/src/dap-stack.ts +++ b/src/editors/vscode/src/dap-stack.ts @@ -25,6 +25,7 @@ import { type RawFrame, } from './dap-frames'; import { armAsyncDebugging, readAsyncChain, topFrameId, type AsyncChain } from './dap-async-chain'; +import { delay } from './utils'; import { resolveMethodSource } from './dap-frame-sources'; import { belongsToUserCode } from './dap-statement'; import { isRecord, recordList, type DapMessage } from './dap-emulate'; @@ -34,25 +35,18 @@ import { error } from './log'; /** Synthetic frame ids live far above netcoredbg's per-stop counters. */ const SYNTHETIC_BASE = 0x0f00_0000; -/** How long an empty-stack refetch waits out the attach-pause suspend race. */ -const EMPTY_STACK_REFETCH_MS = 15_000; - -/** How many resume-and-repause recovery cycles one empty stack may spend. */ -const MAX_EMPTY_STACK_REPAUSES = 3; - -/** Refetch-loop polls spent before each resume-and-repause recovery cycle. */ -const EMPTY_STACK_REPAUSE_POLLS = 8; +/** + * How long an empty-stack refetch waits out the attach-pause suspend race. + * + * The client gets no `stackTrace` answer until this elapses, so it is bounded + * by the race it absorbs - [DEBUG-PERFORMANCE] budgets a whole attach at <3s - + * and not by the patience of whoever is waiting. + */ +const EMPTY_STACK_REFETCH_MS = 3_000; /** The poll interval inside that window. */ const EMPTY_STACK_POLL_MS = 250; -/** One delayed step, for the empty-stack refetch. */ -async function sleep(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - /** How deep the full-stack refetch reads. */ const FULL_STACK_LEVELS = 1_000; @@ -119,6 +113,19 @@ export class StackDelivery { private launchRoot: string | undefined; /** Entry-stop arming for the current launch; undefined for attach/noDebug. */ private arming: Arming | undefined; + /** + * Whether the runtime's async-task registry was armed for this session. + * + * `s_currentActiveTasks` only exists once `s_asyncDebuggingEnabled` has been + * set, and only a LAUNCH gets the entry stop that sets it. Without it every + * heap walk can only answer `null`, so asking costs a func-eval -- MEASURED + * at 113ms per stop against a test host -- for an answer already known. That + * delay is not merely slow: the client's own `stackTrace` waits behind it, + * and the workbench focuses the stopped thread only once that answer lands, + * so a step made first is dispatched at whichever thread happens to be first + * in the list and netcoredbg refuses it ([DEBUG-ARCHITECTURE-ROUTER]). + */ + private asyncRegistryArmed = false; /** Whether one source path is the user's own code, for frame filtering. */ private readonly isUserPath = (path: string): boolean => belongsToUserCode({ path, line: 0, column: 0 }, this.launchRoot); @@ -149,6 +156,7 @@ export class StackDelivery { */ public onLaunch(args: Record): void { this.arming = undefined; + this.asyncRegistryArmed = false; if (typeof args.cwd === 'string') this.launchRoot = args.cwd; if (args.noDebug === true) return; const userWantedEntry = args.stopAtEntry === true; @@ -180,7 +188,10 @@ export class StackDelivery { /** Enable the async-task registry at a paused moment; optionally resume. */ private async armAtStop(threadId: number, resume: boolean): Promise { try { - await armAsyncDebugging(this.host, await topFrameId(this.host, threadId)); + this.asyncRegistryArmed = await armAsyncDebugging( + this.host, + await topFrameId(this.host, threadId), + ); } catch (cause) { error(`async-debug arming failed: ${String(cause)}`); } @@ -215,13 +226,24 @@ export class StackDelivery { * A response containing async state-machine frames — or one whose window * filtered down to nothing — is rebuilt from a full fetch: renamed, heap * reconstruction spliced in, and the caller's original window re-applied. + * + * Only when the reconstruction can actually contribute. Without the + * runtime's async-task registry there is no chain to recover and none to + * continue, so the frames the adapter just returned ARE the answer and the + * rebuild would cost a full re-fetch and a queue hop for nothing. That delay + * is not free: the workbench focuses the stopped thread only once this + * response lands, and a step made before it does is dispatched at whichever + * thread happens to be first in the list — which netcoredbg then refuses + * ([DEBUG-ARCHITECTURE-ROUTER]). */ public deliver(message: DapMessage, args: Record | undefined): void { const body = isRecord(message.body) ? message.body : {}; const frames = isFrameList(body.stackFrames) ? body.stackFrames : []; - const hasAsyncFrames = frames.some((frame) => logicalFrameName(frame.name) !== frame.name); + const reconstructable = + this.asyncRegistryArmed && + frames.some((frame) => logicalFrameName(frame.name) !== frame.name); const logical = enrichAsyncFrames(frames, this.justMyCode, this.isUserPath); - if (!hasAsyncFrames && logical.length > 0) { + if (!reconstructable && logical.length > 0) { this.emitStack(message, body, args, logical); return; } @@ -232,32 +254,18 @@ export class StackDelivery { // report an empty stack for a stopped thread before its frames are // walkable. One delayed refetch settles the race; a genuinely frameless // thread stays empty. + // The refetch is PASSIVE: re-probe `threads` so netcoredbg refreshes + // its per-thread walk state, then read again. The adapter must never + // resume the debuggee to make one of its own reads succeed - a thread + // parked in native runtime code stays frameless, and `stackFrames: []` + // is the honest answer for it. let assembled = await this.logicalStack(threadId); - let attempt = 0; - let repauses = 0; const deadline = Date.now() + EMPTY_STACK_REFETCH_MS; while (assembled.length === 0 && Date.now() < deadline) { - await sleep(EMPTY_STACK_POLL_MS); - // netcoredbg refreshes its per-thread walk state on a `threads` - // probe; without one it can keep answering an empty stackTrace for - // a freshly paused attached thread. + await delay(EMPTY_STACK_POLL_MS); await this.fetchThreads(); this.cache.delete(threadId); assembled = await this.logicalStack(threadId); - attempt += 1; - // A pause that lands while the thread is inside native runtime code - // (e.g. Thread.Sleep) can leave the stack unwalkable indefinitely. - // Bounded resume-and-repause cycles give the runtime further chances - // to park the thread somewhere walkable. - if ( - assembled.length === 0 && - repauses < MAX_EMPTY_STACK_REPAUSES && - attempt >= EMPTY_STACK_REPAUSE_POLLS * (repauses + 1) - ) { - repauses += 1; - await this.safeResumePause(threadId); - continue; - } } this.emitStack(message, body, args, assembled); } catch (cause) { @@ -267,26 +275,6 @@ export class StackDelivery { }); } - /** - * One `continue` immediately followed by one `pause`, for the empty-stack - * recovery loop. Best-effort in both directions: a thread that exits or - * refuses either request simply keeps its current stop. - */ - private async safeResumePause(threadId: number): Promise { - try { - this.cache.delete(threadId); - const resumed = await this.host.request('continue', { threadId }); - if (resumed.success === false) return; - await sleep(EMPTY_STACK_POLL_MS); - await this.host.request('pause', { threadId }); - // Give the adapter a moment to deliver and settle the fresh stop before - // the next stack probe. - await sleep(EMPTY_STACK_POLL_MS); - } catch { - // The next poll retries anyway. - } - } - /** One `threads` probe, for the empty-stack refetch loop. */ private async fetchThreads(): Promise { try { @@ -342,6 +330,7 @@ export class StackDelivery { /** Walk the heap for the awaiting callers of the paused async method. */ private async recoverChain(raw: RawFrame[]): Promise { + if (!this.asyncRegistryArmed) return { frames: [], complete: false }; const pausedSmType = raw .map((frame) => frameStateMachineType(frame.name)) .find((smType) => smType !== undefined); @@ -403,7 +392,19 @@ export class StackDelivery { return tail.slice(start); } - /** The enriched stacks of other threads that carry async frames. */ + /** + * The enriched stacks of other threads that carry async frames. + * + * The probes are issued TOGETHER. A test host parks a dozen runtime and + * thread-pool threads, and walking them one after another turned a + * `stackTrace` netcoredbg answered in 2ms into one the client waited 149ms + * for -- on every stop. VS Code focuses the stopped thread only once that + * response lands, and a step gesture made before it does is dispatched + * against the FIRST thread in the list instead: netcoredbg then refuses + * `next` on a thread that never stopped (`0x80004005`), and the user's F10 + * reads as a broken gesture. The adapter answers each probe independently, + * so nothing about the result changes -- only how long the client waits. + */ private async asyncThreadStacks(pausedThreadId: number): Promise { const response = await this.host.request('threads', {}); const body = isRecord(response.body) ? response.body : {}; @@ -411,13 +412,10 @@ export class StackDelivery { .map((thread) => Number(thread.id ?? 0)) .filter((id) => id > 0 && id !== pausedThreadId) .slice(0, MAX_STITCH_THREADS); - const stacks: RawFrame[][] = []; - for (const id of ids) { - const raw = await this.fetchFrames(id); - if (!raw.some((frame) => logicalFrameName(frame.name) !== frame.name)) continue; - stacks.push(enrichAsyncFrames(raw, this.justMyCode, this.isUserPath)); - } - return stacks; + const walked = await Promise.all(ids.map(async (id) => await this.fetchFrames(id))); + return walked + .filter((raw) => raw.some((frame) => logicalFrameName(frame.name) !== frame.name)) + .map((raw) => enrichAsyncFrames(raw, this.justMyCode, this.isUserPath)); } /** Emit one enriched stack, windowed and handle-translated as promised. */ diff --git a/src/editors/vscode/src/dap-string-members.ts b/src/editors/vscode/src/dap-string-members.ts new file mode 100644 index 00000000..543cd5ae --- /dev/null +++ b/src/editors/vscode/src/dap-string-members.ts @@ -0,0 +1,268 @@ +// String members netcoredbg cannot resolve, answered from the value it already +// rendered. +// +// netcoredbg 3.2.0 walks members through `ICorDebugObjectValue`, and a string +// is an `ICorDebugStringValue`, so ANY member reached through a string receiver +// comes back `The name 'text.Length' does not exist in the current context` — +// even though the adapter evaluated the receiver itself perfectly well. The +// "Expression evaluation quality tiers" table of [DEBUG-FEATURES-VARIABLES] +// marks T2 "Method calls on locals" as Works for Phase 4, so this gap is the +// router's to close. +// +// THE GOVERNING RULE, the same one dap-cast.ts states: emulate only what is +// EXACTLY derivable from the adapter's own rendering. The members below are +// pure functions of the string's characters and nothing else — no culture, no +// allocation, no observable side effect — so answering them here gives the same +// value the debuggee would compute. Anything outside the table (`Split`, +// `Format`, culture-sensitive overloads, anything taking a comparison flag) +// returns undefined and the caller surfaces netcoredbg's own refusal. +import { skipLiteral } from './dap-emulate'; +import type { RenderedValue } from './dap-cast'; + +/** A member access split into the part before the dot and the part after. */ +export interface MemberAccess { + /** Everything left of the final top-level dot. */ + readonly receiver: string; + /** The member name. */ + readonly member: string; + /** The argument text inside the call parentheses; empty for a property. */ + readonly args: string; + /** Whether the member was written as a call. */ + readonly invoked: boolean; +} + +/** + * Split `a.b.C(d)` into receiver `a.b`, member `C`, args `d`. + * + * The dot is found by SCANNING, never by pattern: string and char literals are + * skipped whole and bracket depth is tracked, so `f("a.b").Length` splits at + * the dot outside the literal and `xs[i.j].Length` at the one outside the + * brackets. Returns undefined for anything that is not a member access. + */ +export function parseMemberAccess(expression: string): MemberAccess | undefined { + const dot = lastTopLevelDot(expression); + if (dot <= 0) return undefined; + const receiver = expression.slice(0, dot).trim(); + const tail = expression.slice(dot + 1).trim(); + if (receiver === '' || tail === '') return undefined; + return callOf(tail, receiver) ?? propertyOf(tail, receiver); +} + +/** The tail read as `Name(args)`, when it is one. */ +function callOf(tail: string, receiver: string): MemberAccess | undefined { + if (!tail.endsWith(')')) return undefined; + const open = tail.indexOf('('); + if (open <= 0) return undefined; + const member = tail.slice(0, open); + if (!isIdentifier(member)) return undefined; + return { receiver, member, args: tail.slice(open + 1, -1).trim(), invoked: true }; +} + +/** The tail read as a bare property name, when it is one. */ +function propertyOf(tail: string, receiver: string): MemberAccess | undefined { + if (!isIdentifier(tail)) return undefined; + return { receiver, member: tail, args: '', invoked: false }; +} + +/** The index of the final dot at bracket depth zero and outside any literal. */ +function lastTopLevelDot(expression: string): number { + let depth = 0; + let dot = -1; + for (let index = 0; index < expression.length; index += 1) { + const character = expression[index] ?? ''; + if (character === '"' || character === "'") { + index = skipLiteral(expression, index) - 1; + } else if (character === '(' || character === '[') { + depth += 1; + } else if (character === ')' || character === ']') { + depth -= 1; + } else if (character === '.' && depth === 0) { + dot = index; + } + } + return dot; +} + +/** A C# identifier: a letter or underscore, then letters, digits, underscores. */ +function isIdentifier(text: string): boolean { + if (text === '') return false; + for (let index = 0; index < text.length; index += 1) { + const code = text.codePointAt(index) ?? 0; + const letter = (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95; + const digit = code >= 48 && code <= 57; + if (!(letter || (digit && index > 0))) return false; + } + return true; +} + +/** An indexer access split into the collection and the index expression. */ +export interface IndexAccess { + /** Everything left of the final top-level `[`. */ + readonly receiver: string; + /** The expression between the brackets. */ + readonly index: string; +} + +/** + * Split `a.b[i + 1]` into receiver `a.b` and index `i + 1`. + * + * Same scan as `parseMemberAccess`: literals are skipped whole and bracket + * depth is tracked, so only a trailing indexer at depth zero matches. + */ +export function parseIndexAccess(expression: string): IndexAccess | undefined { + const trimmed = expression.trim(); + if (!trimmed.endsWith(']')) return undefined; + const open = lastTopLevelOpenBracket(trimmed); + if (open <= 0) return undefined; + const receiver = trimmed.slice(0, open).trim(); + const index = trimmed.slice(open + 1, -1).trim(); + return receiver === '' || index === '' ? undefined : { receiver, index }; +} + +/** The index of the `[` opening the trailing indexer, or -1. */ +function lastTopLevelOpenBracket(expression: string): number { + let depth = 0; + for (let index = expression.length - 1; index >= 0; index -= 1) { + const character = expression[index] ?? ''; + if (character === ']' || character === ')') { + depth += 1; + } else if (character === '(') { + depth -= 1; + } else if (character === '[') { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +/** + * Whether a rendered value is netcoredbg's rendering of an exception INSTANCE. + * + * It renders one as `{Namespace.SomethingException}` with the type to match. + * On its own this says nothing about whether the expression faulted - a watch + * on a caught exception renders identically - so callers must pair it with + * evidence of the fault itself. + */ +export function rendersAsException(value: RenderedValue): boolean { + return ( + value.type.endsWith('Exception') && value.result.startsWith('{') && value.result.endsWith('}') + ); +} + +/** Whether a rendered type is `string` in either of its spellings. */ +export function isStringValue(value: RenderedValue): boolean { + return value.type === 'string' || value.type === 'System.String'; +} + +/** + * The value of `member` on the string `value` already rendered, or undefined + * when this module does not own that member. + */ +export function emulateStringMember( + access: MemberAccess, + value: RenderedValue, +): RenderedValue | undefined { + const text = unrender(value.result); + if (text === undefined) return undefined; + if (!access.invoked) return access.member === 'Length' ? intValue(text.length) : undefined; + const argument = literalArgument(access.args); + return invokedMember(access.member, text, argument); +} + +/** One string member written as a call, applied to `text`. */ +function invokedMember( + member: string, + text: string, + argument: string | undefined, +): RenderedValue | undefined { + if (argument === undefined) { + return NO_ARGUMENT.get(member)?.(text); + } + return ONE_STRING_ARGUMENT.get(member)?.(text, argument); +} + +/** Members taking nothing, each a pure function of the characters. */ +const NO_ARGUMENT = new Map RenderedValue>([ + ['ToUpper', (text) => stringValue(text.toUpperCase())], + ['ToLower', (text) => stringValue(text.toLowerCase())], + ['ToUpperInvariant', (text) => stringValue(text.toUpperCase())], + ['ToLowerInvariant', (text) => stringValue(text.toLowerCase())], + ['Trim', (text) => stringValue(text.trim())], + ['TrimStart', (text) => stringValue(text.trimStart())], + ['TrimEnd', (text) => stringValue(text.trimEnd())], + ['ToString', (text) => stringValue(text)], +]); + +/** Members taking exactly one STRING literal — ordinal comparisons only. */ +const ONE_STRING_ARGUMENT = new Map RenderedValue>([ + ['Contains', (text, arg) => boolValue(text.includes(arg))], + ['StartsWith', (text, arg) => boolValue(text.startsWith(arg))], + ['EndsWith', (text, arg) => boolValue(text.endsWith(arg))], + ['IndexOf', (text, arg) => intValue(text.indexOf(arg))], + ['LastIndexOf', (text, arg) => intValue(text.lastIndexOf(arg))], +]); + +/** The argument text read as a single string literal, or undefined. */ +function literalArgument(args: string): string | undefined { + if (args === '') return undefined; + const unrendered = unrender(args); + return unrendered; +} + +/** + * The characters behind netcoredbg's rendering of a string. + * + * It renders a string wrapped in quotes with the usual C# escapes; anything + * that is not so wrapped is not a rendered string and is not emulated. + */ +function unrender(rendered: string): string | undefined { + if (rendered.length < 2 || !rendered.startsWith('"') || !rendered.endsWith('"')) return undefined; + return unescape(rendered.slice(1, -1)); +} + +/** The one-character C# escapes a rendered string can contain. */ +const ESCAPES = new Map([ + ['n', '\n'], + ['r', '\r'], + ['t', '\t'], + ['0', '\0'], + ['a', ''], + ['b', '\b'], + ['f', '\f'], + ['v', '\v'], + ['\\', '\\'], + ['"', '"'], + ["'", "'"], +]); + +/** Resolve the escapes in a rendered literal's body. */ +function unescape(body: string): string { + let text = ''; + for (let index = 0; index < body.length; index += 1) { + const character = body[index] ?? ''; + const next = body[index + 1]; + if (character !== '\\' || next === undefined) { + text += character; + continue; + } + text += ESCAPES.get(next) ?? next; + index += 1; + } + return text; +} + +/** A rendered `int`. */ +function intValue(value: number): RenderedValue { + return { result: String(value), type: 'int', variablesReference: 0 }; +} + +/** A rendered `bool`. */ +function boolValue(value: boolean): RenderedValue { + return { result: String(value), type: 'bool', variablesReference: 0 }; +} + +/** A rendered `string`, quoted the way netcoredbg quotes one. */ +function stringValue(value: string): RenderedValue { + return { result: `"${value}"`, type: 'string', variablesReference: 0 }; +} diff --git a/src/editors/vscode/src/dap-wire.ts b/src/editors/vscode/src/dap-wire.ts index 9ce3d718..691a3e0c 100644 --- a/src/editors/vscode/src/dap-wire.ts +++ b/src/editors/vscode/src/dap-wire.ts @@ -42,6 +42,12 @@ export interface WireHost { * `terminated` — which needs no ceremony. */ onGone(why: string | undefined): void; + /** + * A frame the adapter can no longer hear. + * + * The host settles it so nothing is left waiting on a process that is gone. + */ + onUndeliverable(message: DapMessage): void; /** True once the child itself sent the DAP `terminated` event. */ announcedTerminated(): boolean; /** True once the death has been settled; nothing more may be parsed. */ @@ -63,7 +69,17 @@ export class AdapterWire { * could hear, plus a spurious `terminated` telling VS Code the session was * already over. */ - private replaced?: cp.ChildProcessWithoutNullStreams; + private replaced: cp.ChildProcessWithoutNullStreams | undefined; + + /** + * Frames written while a respawn is in flight. + * + * The replacement child does not exist yet and the outgoing one is dying, so + * these have nowhere to go for up to a second. Holding them is what keeps a + * `disconnect` sent mid-respawn from vanishing and leaving VS Code with a + * session it can never close. + */ + private queued: DapMessage[] = []; constructor( private readonly adapterPath: string, @@ -76,7 +92,22 @@ export class AdapterWire { /** Serialise one message to the child using DAP's framing. */ public write(message: DapMessage): void { - if (this.host.isDisposed() || this.child.stdin.destroyed || !this.child.stdin.writable) return; + if (this.host.isDisposed()) return; + if (this.replaced !== undefined) { + // Mid-respawn: the outgoing child's stdin is already unwritable and the + // replacement has not been spawned. Hold the frame rather than drop it. + this.queued.push(message); + return; + } + if (this.child.stdin.destroyed || !this.child.stdin.writable) { + // The adapter cannot hear this frame. Dropping it silently is what left a + // `disconnect` unanswered and the session unclosable, so the host answers + // it locally instead. NOT `onGone`: netcoredbg closes its stdin during a + // normal teardown while the process still lives, and ending the session + // on every late write turns a routine shutdown into a reported death. + this.host.onUndeliverable(message); + return; + } const body = JSON.stringify(message); const frame = `${CONTENT_LENGTH}${String(Buffer.byteLength(body))}${HEADER_END}${body}`; // `write` can also throw SYNCHRONOUSLY once the stream has been destroyed. @@ -108,10 +139,23 @@ export class AdapterWire { old.once('exit', () => { clearTimeout(escalate); this.child = this.spawn(attachArgs); + this.replaced = undefined; + // `onReady` replays the handshake, so it must reach the replacement + // BEFORE the client frames that were waiting on it. onReady?.(); + this.flushQueued(); }); } + /** Send everything held during the respawn, in the order it was written. */ + private flushQueued(): void { + const held = this.queued; + this.queued = []; + for (const message of held) { + this.write(message); + } + } + /** * Detach from the child and stop it. * diff --git a/src/editors/vscode/src/dotnet-process.ts b/src/editors/vscode/src/dotnet-process.ts index 6cdc5f71..42ef3346 100644 --- a/src/editors/vscode/src/dotnet-process.ts +++ b/src/editors/vscode/src/dotnet-process.ts @@ -279,7 +279,7 @@ function terminateTree(child: ChildProcess): void { return; } if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }).unref(); + terminateWindowsTree(pid); return; } report(killGroup(pid, 'SIGTERM'), pid); @@ -304,6 +304,35 @@ function report(outcome: Result, pid: number): void { if (!outcome.ok) info(`Could not signal dotnet process group ${String(pid)}: ${outcome.error}`); } +/** + * `taskkill /t /f` over the whole tree, REPORTING what it actually did. + * + * The POSIX branch reports every signal it fails to deliver. This one used to + * spawn `taskkill`, `unref` it, and discard both its exit code and its stderr — + * so a kill that never happened (access denied, a pid already gone, a tree + * re-parented out from under it) was indistinguishable in the log from one that + * worked. A testhost that survived then went on writing results for a run the + * user had already stopped, with nothing recorded to say why. + */ +function terminateWindowsTree(pid: number): void { + const child = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (error: Error) => { + info(`taskkill could not run for pid ${String(pid)}: ${error.message}`); + }); + child.on('exit', (code: number | null) => { + if (code === 0) return; + info( + `taskkill left tree ${String(pid)} alive (exit ${String(code)}): ${stderr.trim() || 'no detail'}`, + ); + }); + child.unref(); +} + /** Shape a finished child into a {@link DotnetRun}. */ function toRun( capture: Capture, diff --git a/src/editors/vscode/src/extension.ts b/src/editors/vscode/src/extension.ts index e7ab55b8..f598b290 100644 --- a/src/editors/vscode/src/extension.ts +++ b/src/editors/vscode/src/extension.ts @@ -341,7 +341,7 @@ function registerCommands(context: ExtensionContext): void { log.info('Restarting server…'); statusBar.setState(ServerState.Starting); try { - await lspClient?.restart(); + if (lspClient !== undefined) await client.restart(lspClient); log.info('Server restarted.'); } catch (err: unknown) { const msg = getErrorMessage(err); @@ -454,59 +454,82 @@ function browseNuGetPackages(node: ExplorerNode | undefined, context: ExtensionC NuGetBrowserPanel.open(context, node.projectFilePath, projectName, () => lspClient); } +/** Tell the user why a context-menu command invoked with no row did nothing. */ +function warnNoSelection(): void { + void window.showWarningMessage('Select an item in the Solution Explorer first.'); +} + function registerContextMenuCommands(context: ExtensionContext): void { context.subscriptions.push( - commands.registerCommand(CMD_COPY_QUALIFIED_NAME, async (node: ExplorerNode) => { + commands.registerCommand(CMD_COPY_QUALIFIED_NAME, async (node?: ExplorerNode) => { + if (node === undefined) { + warnNoSelection(); + return; + } const name = buildQualifiedName(node); await vscode.env.clipboard.writeText(name); void window.showInformationMessage(`Copied: ${name}`); }), - commands.registerCommand(CMD_COPY_NAME, async (node: ExplorerNode) => { + commands.registerCommand(CMD_COPY_NAME, async (node?: ExplorerNode) => { + if (node === undefined) { + warnNoSelection(); + return; + } await vscode.env.clipboard.writeText(node.sortName); void window.showInformationMessage(`Copied: ${node.sortName}`); }), - commands.registerCommand(CMD_REVEAL_IN_EXPLORER, (node: ExplorerNode) => { - if (node.symbolUri === undefined) return; + commands.registerCommand(CMD_REVEAL_IN_EXPLORER, (node?: ExplorerNode) => { + if (node?.symbolUri === undefined) return; const uri = vscode.Uri.parse(node.symbolUri); void commands.executeCommand('revealInExplorer', uri); }), - commands.registerCommand(CMD_SORT_MEMBERS, async (node: ExplorerNode) => { + commands.registerCommand(CMD_SORT_MEMBERS, async (node?: ExplorerNode) => { await sortMembers(node); }), - commands.registerCommand(CMD_OPEN_PROJECT_FILE, async (node: ExplorerNode) => { + commands.registerCommand(CMD_OPEN_PROJECT_FILE, async (node?: ExplorerNode) => { await openProjectFile(node); }), - commands.registerCommand(CMD_ADD_PROJECT_REFERENCE, async (node: ExplorerNode) => { + commands.registerCommand(CMD_ADD_PROJECT_REFERENCE, async (node?: ExplorerNode) => { await addProjectReference(node); }), - commands.registerCommand(CMD_NUGET_ADD_FROM_EXPLORER, async (node: ExplorerNode) => { - if (node.projectFilePath === undefined) { - void window.showWarningMessage('No project file path available.'); - return; - } - await addNuGetPackageToProject(node.projectFilePath); + commands.registerCommand(CMD_NUGET_ADD_FROM_EXPLORER, async (node?: ExplorerNode) => { + const projectFilePath = projectPathOf(node); + if (projectFilePath === undefined) return; + await addNuGetPackageToProject(projectFilePath); }), ); } -async function openProjectFile(node: ExplorerNode): Promise { - if (node.projectFilePath === undefined) { +/** + * The project file a Solution Explorer command was invoked on, or `undefined` + * once the user has been told why nothing happened. + * + * The Command Palette invokes these commands with no argument at all + * ([SE-CONTEXT-VALUES]), so the node is genuinely optional and reading through + * it unguarded threw rather than explaining itself. + */ +function projectPathOf(node: ExplorerNode | undefined): string | undefined { + if (node?.projectFilePath === undefined) { void window.showWarningMessage('No project file path available.'); - return; + return undefined; } - const uri = vscode.Uri.file(node.projectFilePath); + return node.projectFilePath; +} + +async function openProjectFile(node: ExplorerNode | undefined): Promise { + const projectFilePath = projectPathOf(node); + if (projectFilePath === undefined) return; + const uri = vscode.Uri.file(projectFilePath); const doc = await workspace.openTextDocument(uri); await window.showTextDocument(doc); - log.info(`Opened project file: ${node.projectFilePath}`); + log.info(`Opened project file: ${projectFilePath}`); } -async function addProjectReference(node: ExplorerNode): Promise { - if (node.projectFilePath === undefined) { - void window.showWarningMessage('No project file path available.'); - return; - } +async function addProjectReference(node: ExplorerNode | undefined): Promise { + const projectFilePath = projectPathOf(node); + if (projectFilePath === undefined) return; const projectFiles = await workspace.findFiles('**/*.{csproj,fsproj}', '**/node_modules/**'); - const candidates = projectFiles.filter((f) => f.fsPath !== node.projectFilePath); + const candidates = projectFiles.filter((f) => f.fsPath !== projectFilePath); if (candidates.length === 0) { void window.showWarningMessage('No other project files found to reference.'); return; @@ -519,7 +542,7 @@ async function addProjectReference(node: ExplorerNode): Promise { { placeHolder: 'Select project to reference' }, ); if (pick === undefined) return; - const error = await deps.addProjectReference(node.projectFilePath, pick.uri.fsPath); + const error = await deps.addProjectReference(projectFilePath, pick.uri.fsPath); if (error !== undefined) { void window.showErrorMessage(`Failed to add project reference: ${error}`); return; @@ -528,8 +551,8 @@ async function addProjectReference(node: ExplorerNode): Promise { await explorerProvider?.refresh(); } -async function sortMembers(node: ExplorerNode): Promise { - if (node.symbolUri === undefined || node.symbolRange === undefined) { +async function sortMembers(node: ExplorerNode | undefined): Promise { + if (node?.symbolUri === undefined || node.symbolRange === undefined) { void window.showWarningMessage('No symbol location available.'); return; } diff --git a/src/editors/vscode/src/profiler.ts b/src/editors/vscode/src/profiler.ts index 498ec947..8c741528 100644 --- a/src/editors/vscode/src/profiler.ts +++ b/src/editors/vscode/src/profiler.ts @@ -795,15 +795,31 @@ export function registerCommands( }), ); + /** + * The output file a profiler row points at, or `undefined` once the user has + * been told why nothing happened. + * + * The Command Palette invokes these row commands with NO argument, and a notice + * about a session's trace file says nothing to a user who selected no session - + * so an absent row is a silent no-op, and only a real row whose trace has not + * been written yet earns the message. + */ + function outputPathOf(item: ProfilerTreeItem | undefined): string | undefined { + if (item === undefined) return undefined; + const path = item.outputPath; + if (path === undefined || path.length === 0) { + void vscode.window.showInformationMessage('Session has no output file yet.'); + return undefined; + } + return path; + } + context.subscriptions.push( vscode.commands.registerCommand( CMD_PROFILER_COPY_OUTPUT_PATH, async (item?: ProfilerTreeItem) => { - const path = item?.outputPath; - if (path === undefined || path.length === 0) { - void vscode.window.showInformationMessage('Session has no output file yet.'); - return; - } + const path = outputPathOf(item); + if (path === undefined) return; await vscode.env.clipboard.writeText(path); void vscode.window.showInformationMessage(`Copied: ${path}`); }, @@ -812,11 +828,8 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand(CMD_PROFILER_REVEAL_OUTPUT, async (item?: ProfilerTreeItem) => { - const path = item?.outputPath; - if (path === undefined || path.length === 0) { - void vscode.window.showInformationMessage('Session has no output file yet.'); - return; - } + const path = outputPathOf(item); + if (path === undefined) return; await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(path)); }), ); diff --git a/src/editors/vscode/src/project-deps-store.ts b/src/editors/vscode/src/project-deps-store.ts index 8099148e..2016069b 100644 --- a/src/editors/vscode/src/project-deps-store.ts +++ b/src/editors/vscode/src/project-deps-store.ts @@ -308,5 +308,8 @@ export function resetForTests(): void { projectMtimes.clear(); for (const timer of pending.values()) clearTimeout(timer); pending.clear(); - projectDependencies.value = new Map(); + // A signal publishes on every assignment of a NEW Map, so an already-empty + // store is left holding the map it has rather than waking every observer + // for a change that changes nothing ([VSCODE-REACTIVITY]). + if (projectDependencies.value.size > 0) projectDependencies.value = new Map(); } diff --git a/src/editors/vscode/src/test-coverage.ts b/src/editors/vscode/src/test-coverage.ts index 45cc5e5d..e7ca07f7 100644 --- a/src/editors/vscode/src/test-coverage.ts +++ b/src/editors/vscode/src/test-coverage.ts @@ -10,6 +10,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { XMLParser } from 'fast-xml-parser'; +import { info } from './log.js'; +import { getErrorMessage } from './utils.js'; interface CoberturaLine { readonly '@_number': string; @@ -18,7 +20,13 @@ interface CoberturaLine { } interface CoberturaClass { - readonly '@_filename': string; + /** + * Optional because the PARSER decides, not the schema: a `` element + * written without the attribute — which a report truncated mid-write is full + * of — yields `undefined`, and `Uri.file(undefined)` throws out of a step + * whose whole contract is that it never fails a run. + */ + readonly '@_filename'?: string; readonly lines?: { line?: CoberturaLine | CoberturaLine[] }; } @@ -61,11 +69,20 @@ export function findCoberturaFile(resultsDir: string): string | undefined { return findCoberturaFiles(resultsDir)[0]; } -/** Parse a cobertura XML report into VS Code FileCoverage entries. */ +/** + * Parse a cobertura XML report into VS Code FileCoverage entries. + * + * Attaching coverage is a REPORTING step: it runs after a `dotnet test` whose + * tests have already passed or failed on their own terms, and it must never be + * the thing that fails the run. A report the collector never wrote (the run was + * cancelled before it got that far, or `coverlet.collector` is not referenced + * at all) made `readFileSync` throw ENOENT straight out of the run handler, + * which VS Code surfaces as "An error occurred attempting to run tests" over a + * run that actually completed. No report is no coverage, reported as such. + */ export function parseCoberturaXml(filePath: string): vscode.FileCoverage[] { - const xml = fs.readFileSync(filePath, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- fast-xml-parser returns untyped output; CoberturaReport mirrors the known schema - const doc: CoberturaReport = coberturaParser.parse(xml); + const doc = readReport(filePath); + if (doc === undefined) return []; const packages = doc.coverage?.packages?.package; if (packages === undefined) return []; @@ -86,11 +103,34 @@ export function parseCoberturaXml(filePath: string): vscode.FileCoverage[] { return result; } +/** + * Read and parse one report, or report why it could not be read. + * + * Both halves can fail on a report a cancelled run left half-written: the file + * may not be there at all, and what IS there may be truncated mid-element. + */ +function readReport(filePath: string): CoberturaReport | undefined { + try { + const xml = fs.readFileSync(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- fast-xml-parser returns untyped output; CoberturaReport mirrors the known schema + return coberturaParser.parse(xml); + } catch (error: unknown) { + info(`No usable coverage report at ${filePath}: ${getErrorMessage(error)}`); + return undefined; + } +} + /** Build a FileCoverage (and stash its per-line details) for one cobertura class. */ function fileCoverageForClass(cls: CoberturaClass): vscode.FileCoverage | undefined { + const filename = cls['@_filename']; + if (filename === undefined || filename === '') return undefined; + // A class with an EMPTY `` element is still a file the run loaded — + // an interface, a record with only generated members, a file whose every + // statement the compiler elided. Dropping it made the gutter say nothing + // about a file the coverage run had demonstrably seen, which reads as "not + // instrumented" rather than "nothing here to cover". const lines = cls.lines?.line; - if (lines === undefined) return undefined; - const lineList = Array.isArray(lines) ? lines : [lines]; + const lineList = lines === undefined ? [] : Array.isArray(lines) ? lines : [lines]; let covered = 0; const details: vscode.StatementCoverage[] = []; @@ -101,7 +141,7 @@ function fileCoverageForClass(cls: CoberturaClass): vscode.FileCoverage | undefi details.push(new vscode.StatementCoverage(hits, new vscode.Position(lineNo, 0))); } - const uri = vscode.Uri.file(cls['@_filename']); + const uri = vscode.Uri.file(filename); const fc = new vscode.FileCoverage(uri, new vscode.TestCoverageCount(covered, lineList.length)); coverageDetails.set(uri.toString(), details); return fc; @@ -116,3 +156,48 @@ export function loadDetailedCoverage( ): vscode.FileCoverageDetail[] { return coverageDetails.get(fileCoverage.uri.toString()) ?? []; } + +/** + * Every report merged: ONE {@link vscode.FileCoverage} per source file, whose + * detail is the UNION of every report that measured it. + * + * [TEST-COVERAGE] warns that "taking only the first drops every other project's + * coverage". Attaching each report's entry separately loses it just as surely + * at the other end: two test projects covering one library produce two entries + * for the SAME file, and the detail behind them is stashed by file URI, so the + * last report parsed overwrites the first. VS Code then resolves that one + * report's lines for both entries, and a function the other project executed is + * painted as dead code — a wrong red gutter on a line that just ran. + * + * Hits are taken per line as the MAXIMUM across reports, because a line one + * project never executed is not evidence that another did not. + */ +export function mergeCoberturaReports(reports: readonly string[]): vscode.FileCoverage[] { + const hitsByFile = new Map>(); + for (const report of reports) { + for (const file of parseCoberturaXml(report)) { + const key = file.uri.toString(); + const lines = hitsByFile.get(key) ?? new Map(); + for (const detail of coverageDetails.get(key) ?? []) { + const at = detail.location; + const line = at instanceof vscode.Range ? at.start.line : at.line; + lines.set(line, Math.max(lines.get(line) ?? 0, Number(detail.executed))); + } + hitsByFile.set(key, lines); + } + } + return [...hitsByFile].map(([uri, lines]) => mergedFileCoverage(vscode.Uri.parse(uri), lines)); +} + +/** Rebuild one file's coverage from the union of its per-line hit counts. */ +function mergedFileCoverage( + uri: vscode.Uri, + lines: ReadonlyMap, +): vscode.FileCoverage { + const details = [...lines] + .sort(([a], [b]) => a - b) + .map(([line, hits]) => new vscode.StatementCoverage(hits, new vscode.Position(line, 0))); + const covered = details.filter((detail) => Number(detail.executed) > 0).length; + coverageDetails.set(uri.toString(), details); + return new vscode.FileCoverage(uri, new vscode.TestCoverageCount(covered, details.length)); +} diff --git a/src/editors/vscode/src/test-debug.ts b/src/editors/vscode/src/test-debug.ts index 4cc32e2a..28ce1985 100644 --- a/src/editors/vscode/src/test-debug.ts +++ b/src/editors/vscode/src/test-debug.ts @@ -287,7 +287,34 @@ class DebugRunFlow { }, ...(target === undefined ? {} : { target }), }; - return await this.host.enqueue(async () => await runTests(ids, this.cwd, options)); + return await this.releasingQueueOnAttach(ids, options); + } + + /** + * Run `dotnet test`, holding the shared `dotnet` queue only for the BUILD. + * + * The queue exists so a discovery sweep and a run cannot rebuild the same + * `bin/`/`obj/` at once. A debug run is different in kind: under + * VSTEST_HOST_DEBUG its `dotnet test` does not exit until the user has + * finished debugging, so holding the queue for the whole invocation froze + * the Test Explorer for as long as a breakpoint was held -- no discovery, no + * other run, for minutes or hours. MEASURED: a sweep requested while a + * debuggee was paused waited 39s and completed 2.7s after the session ended. + * + * The queue is released the moment a host is waiting and its attach has + * settled, which is strictly after the build the queue is there to protect. + * A run that dies before any host waits releases it just the same. + */ + private async releasingQueueOnAttach( + ids: readonly string[], + options: TestRunOptions, + ): Promise { + const { started } = await this.host.enqueue(async () => { + const running = runTests(ids, this.cwd, options); + await Promise.race([this.attached, running]); + return { started: running }; + }); + return await started; } /** diff --git a/src/editors/vscode/src/test-discovery.ts b/src/editors/vscode/src/test-discovery.ts index e1367616..270b0b62 100644 --- a/src/editors/vscode/src/test-discovery.ts +++ b/src/editors/vscode/src/test-discovery.ts @@ -28,29 +28,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { DOTNET_TIMEOUT_MS, runDotnet, type DotnetRun } from './dotnet-process.js'; -import { dedupeLines, HEX_DIGITS, parseFullyQualifiedTestList } from './test-names.js'; +import { parseTestList } from './test-listing.js'; +import { HEX_DIGITS, parseFullyQualifiedTestList } from './test-names.js'; export { parseFullyQualifiedTestList, withoutAdapterUniqueId } from './test-names.js'; - -/** Lower-cased prefixes of VSTest/MSBuild output lines that are never tests. */ -const NOISE_PREFIXES = [ - 'the following', - 'test run for', - 'no test', - 'starting test', - 'a total of', - 'passed!', - 'failed!', - 'skipped!', - 'microsoft', - 'copyright', - 'vstest', - 'determining', - 'restored', - 'restore complete', - 'build succeeded', - 'build started', -]; +export { isDiscoveredTestLine, parseTestList } from './test-listing.js'; /** VSTest prints one of these per test assembly it was handed. */ const ASSEMBLY_BANNER = 'Test run for '; @@ -98,36 +80,6 @@ export interface TestAssemblyListing { readonly names: readonly string[]; } -/** Punctuation a display name never contains but a diagnostic or stack frame does. */ -const NON_NAME_CHARACTERS = ['\\', '/', ':', '(', ')', ',', '"', "'", '<', '>', '=']; - -/** A managed stack frame starts with this, and is otherwise dotted-identifier shaped. */ -const STACK_FRAME_PREFIX = 'at '; - -/** - * True when `line` is a discovered test's DISPLAY name. Display names are dotted - * identifiers (F# allows embedded spaces) and never contain path, scope or - * argument punctuation, so path lines, the `Proj -> out.dll` mapping, version - * banners, the summary — and, critically, managed STACK FRAMES like - * `at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, …)` — - * are all excluded. A stack frame slipping through would make a crashed - * `dotnet test` look like a successful enumeration to `salvageable`. Used only - * by the stdout fallback listing. - */ -export function isDiscoveredTestLine(line: string): boolean { - if (!line.includes('.')) return false; - if (NON_NAME_CHARACTERS.some((character) => line.includes(character))) return false; - if (line.includes(' -> ')) return false; - const lower = line.toLowerCase(); - if (lower.startsWith(STACK_FRAME_PREFIX)) return false; - return !NOISE_PREFIXES.some((prefix) => lower.startsWith(prefix)); -} - -/** Parse `dotnet test --list-tests` output into a de-duplicated list of names. */ -export function parseTestList(output: string): string[] { - return dedupeLines(output, isDiscoveredTestLine); -} - /** * Extract the assembly path from a `Test run for ()` banner. * The path may itself contain spaces and parentheses, so the framework suffix is @@ -321,29 +273,68 @@ function listFailure(run: DotnetRun): string { * Names are UNIONED, never taken from whichever framework was announced first: a * test compiled behind `#if NET8_0` exists in only one of the assemblies, and * dropping it would trade a duplicated tree for a missing test. The surviving - * path is the lexicographically smallest, so the group ids the tree builds from - * it stay put across sweeps however the build ordered its banners. + * path is the identity the frameworks SHARE (see {@link sharedOutputPath}), so + * the group ids the tree builds from it stay put across sweeps however the + * build ordered its banners. */ export function mergeMultiTargeted( listings: readonly TestAssemblyListing[], ): TestAssemblyListing[] { - const merged = new Map(); + const merged = new Map(); for (const listing of listings) { const existing = merged.get(listing.name); if (existing === undefined) { - merged.set(listing.name, { path: listing.path, names: [...listing.names] }); + merged.set(listing.name, { paths: [listing.path], names: [...listing.names] }); continue; } + existing.paths.push(listing.path); existing.names.push(...listing.names); - if (listing.path < existing.path) existing.path = listing.path; } return [...merged].map(([name, entry]) => ({ name, - path: entry.path, + path: sharedOutputPath(entry.paths), names: [...new Set(entry.names)], })); } +/** + * The identity several builds of ONE assembly share. + * + * Two target frameworks put the same assembly under `bin//net8.0/` and + * `bin//net9.0/`, so their paths agree everywhere except the segments + * that name a build. Keeping one of them as the merged group id keys the whole + * project's tree on a framework it merely happens to target: the id moves the + * moment the build announces its banners in another order, and a project + * targeting four frameworks gets a row identified by exactly one of them. + * + * Taking the common prefix back to its last separator and re-attaching the file + * name leaves what every build of the project agrees on. A project with one + * target framework has nothing to reconcile and keeps its real path. + */ +function sharedOutputPath(paths: readonly string[]): string { + const [first, ...rest] = paths; + if (first === undefined) return ''; + if (rest.length === 0) return first; + const shared = rest.reduce(commonPrefix, first); + return shared.slice(0, lastSeparator(shared) + 1) + first.slice(lastSeparator(first) + 1); +} + +/** The leading characters two paths agree on. */ +function commonPrefix(left: string, right: string): string { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) index += 1; + return left.slice(0, index); +} + +/** + * Index of the last `/` or `\`, or -1. Both are checked rather than `path.sep` + * because the separator comes from whichever host BUILT the listing, which is + * not necessarily the one reading it. + */ +function lastSeparator(value: string): number { + return Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); +} + /** Prefer VSTest's fully-qualified names; fall back to the display listing. */ async function namesFrom(output: string, cwd: string, timeoutMs: number): Promise { const announced = parseAnnouncedAssemblies(output); diff --git a/src/editors/vscode/src/test-lens-attributes.ts b/src/editors/vscode/src/test-lens-attributes.ts new file mode 100644 index 00000000..98b2cb66 --- /dev/null +++ b/src/editors/vscode/src/test-lens-attributes.ts @@ -0,0 +1,80 @@ +/** + * Recognising a test attribute, and the declaration it belongs to. + * + * Pure line inspection, shared by both languages the status lens renders over. + * Kept out of `test-lens.ts` so the provider file stays about lenses. + * + * Implements the attribute half of [TEST-STATUS-LENS] and the framework list in + * [TEST-OVERVIEW]. + */ + +/** + * Attribute markers that identify a member as a test, in either language. + * + * ONE list, not one per language: C# writes `[Fact]` and F# writes `[]`, + * but the set of frameworks [TEST-OVERVIEW] supports is the same on both sides, + * and two lists drift. + * + * `DataTestMethod` and `DataRow` are MSTest's data-driven pair. Without them a + * `[DataRow(1, 2)]` / `[DataTestMethod]` method carried no lens at all — no + * status, no Run, no Debug — because neither line contains `[TestMethod]`. + */ +const TEST_ATTRIBUTES = [ + 'Fact', + 'Theory', + 'Test', + 'TestMethod', + 'TestCase', + 'DataTestMethod', + 'DataRow', +] as const; + +/** How far below its attribute a C# signature may sit before the search gives up. */ +export const CS_DECLARATION_SPAN = 6; + +/** The same for an F# binding, which carries no access modifiers to wrap. */ +export const FS_DECLARATION_SPAN = 4; + +/** True when `line` carries a C# test attribute, alone or beside a signature. */ +export function hasCSharpTestAttribute(line: string): boolean { + const trimmed = line.trim(); + return TEST_ATTRIBUTES.some( + (attr) => trimmed.includes(`[${attr}]`) || trimmed.includes(`[${attr}(`), + ); +} + +/** The same for F#, whose attributes are written in the angle-bracket form. */ +export function hasFSharpTestAttribute(line: string): boolean { + const trimmed = line.trim(); + return TEST_ATTRIBUTES.some( + (attr) => trimmed.includes(`[<${attr}>]`) || trimmed.includes(`[<${attr}(`), + ); +} + +/** A declaration an attribute belongs to: where it is, and what it is called. */ +export interface Declaration { + readonly line: number; + readonly name: string; +} + +/** + * The declaration an attribute at `from` belongs to, within `span` lines of it. + * + * The LINE is part of the answer, not just the name: a test may carry several + * attributes (`[DataRow(1, 2)]` above `[DataTestMethod]`, or NUnit's `[Test]` + * above `[TestCase(2, 2, 4)]`), and the caller needs to know they all resolve + * to the same member so it renders one lens pair rather than one per attribute. + */ +export function declarationBelow( + lines: readonly string[], + from: number, + span: number, + nameAt: (line: string) => string | undefined, +): Declaration | undefined { + const limit = Math.min(from + span, lines.length); + for (let i = from; i < limit; i++) { + const name = nameAt(lines[i] ?? ''); + if (name !== undefined) return { line: i, name }; + } + return undefined; +} diff --git a/src/editors/vscode/src/test-lens.ts b/src/editors/vscode/src/test-lens.ts index 7aa859a7..37eb8e3b 100644 --- a/src/editors/vscode/src/test-lens.ts +++ b/src/editors/vscode/src/test-lens.ts @@ -12,17 +12,22 @@ import { type CachedTestResult, type SharpLspTestController } from './testing.js import { CMD_TEST_RUN_AT_CURSOR, CMD_TEST_DEBUG_AT_CURSOR } from './constants.js'; import { info } from './log.js'; import { forEachLeaf } from './test-tree.js'; - -/** Attribute markers that identify a method as a test. */ -const CS_TEST_ATTRIBUTES = ['Fact', 'Theory', 'Test', 'TestMethod', 'TestCase'] as const; - -/** F# test attribute markers (angle-bracket form). */ -const FS_TEST_ATTRIBUTES = ['Fact', 'Theory', 'Test', 'TestMethod', 'TestCase'] as const; +import { singleLine } from './utils.js'; +import { + CS_DECLARATION_SPAN, + declarationBelow, + FS_DECLARATION_SPAN, + hasCSharpTestAttribute, + hasFSharpTestAttribute, +} from './test-lens-attributes.js'; /** * Provides code lenses above test methods showing their last known result. * Each lens also offers "Run Test" and "Debug Test" actions. */ +/** What a test's status reads before anything in this session has run it. */ +export const NEVER_RUN: CachedTestResult = { outcome: 'notRun', passed: false }; + export class TestStatusLensProvider implements vscode.CodeLensProvider { private readonly changeEmitter = new vscode.EventEmitter(); public readonly onDidChangeCodeLenses = this.changeEmitter.event; @@ -72,108 +77,67 @@ export class TestStatusLensProvider implements vscode.CodeLensProvider { } private lensesForCSharp(document: vscode.TextDocument): vscode.CodeLens[] { - const lenses: vscode.CodeLens[] = []; - const text = document.getText(); - const lines = text.split('\n'); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ''; - if (!this.hasTestAttribute(line, CS_TEST_ATTRIBUTES)) { - continue; - } - const methodName = this.findCSharpMethodName(lines, i); - if (methodName === undefined) { - continue; - } - const range = new vscode.Range(i, 0, i, line.length); - this.addLensesForTest(lenses, range, methodName, document.uri); - } - - return lenses; + return this.lensesFor(document, hasCSharpTestAttribute, extractCSharpMethodName, { + span: CS_DECLARATION_SPAN, + }); } private lensesForFSharp(document: vscode.TextDocument): vscode.CodeLens[] { - const lenses: vscode.CodeLens[] = []; - const text = document.getText(); - const lines = text.split('\n'); + return this.lensesFor(document, hasFSharpTestAttribute, extractFSharpFunctionName, { + span: FS_DECLARATION_SPAN, + }); + } + /** + * One lens pair per attributed DECLARATION, whichever language the file is. + * + * Keyed on the declaration rather than on the attribute because a test may + * carry several: `[DataRow(1, 2)]` above `[DataTestMethod]`, or NUnit's + * `[Test]` above `[TestCase(2, 2, 4)]`. Emitting per attribute stacked two + * identical Run buttons over one method and made the status line read twice. + */ + private lensesFor( + document: vscode.TextDocument, + isAttribute: (line: string) => boolean, + nameAt: (line: string) => string | undefined, + reach: { readonly span: number }, + ): vscode.CodeLens[] { + const lenses: vscode.CodeLens[] = []; + const lines = document.getText().split('\n'); + const claimed = new Set(); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; - if (!this.hasFSharpTestAttribute(line)) { - continue; - } - const methodName = this.findFSharpTestName(lines, i); - if (methodName === undefined) { - continue; - } + if (!isAttribute(line)) continue; + const declaration = declarationBelow(lines, i, reach.span, nameAt); + if (declaration === undefined || claimed.has(declaration.line)) continue; + claimed.add(declaration.line); const range = new vscode.Range(i, 0, i, line.length); - this.addLensesForTest(lenses, range, methodName, document.uri); + this.addLensesForTest(lenses, range, declaration.name, document.uri); } - return lenses; } - private hasTestAttribute(line: string, attributes: readonly string[]): boolean { - const trimmed = line.trim(); - return attributes.some( - (attr) => - trimmed.startsWith(`[${attr}]`) || - trimmed.startsWith(`[${attr}(`) || - trimmed.includes(`[${attr}]`) || - trimmed.includes(`[${attr}(`), - ); - } - - private hasFSharpTestAttribute(line: string): boolean { - const trimmed = line.trim(); - return FS_TEST_ATTRIBUTES.some( - (attr) => trimmed.includes(`[<${attr}>]`) || trimmed.includes(`[<${attr}(`), - ); - } - - private findCSharpMethodName(lines: string[], attrLine: number): string | undefined { - const limit = Math.min(attrLine + 6, lines.length); - for (let i = attrLine; i < limit; i++) { - const line = lines[i] ?? ''; - const match = extractCSharpMethodName(line); - if (match !== undefined) { - return match; - } - } - return undefined; - } - - private findFSharpTestName(lines: string[], attrLine: number): string | undefined { - const limit = Math.min(attrLine + 4, lines.length); - for (let i = attrLine; i < limit; i++) { - const line = lines[i] ?? ''; - const match = extractFSharpFunctionName(line); - if (match !== undefined) { - return match; - } - } - return undefined; - } - private addLensesForTest( lenses: vscode.CodeLens[], range: vscode.Range, methodName: string, uri: vscode.Uri, ): void { - const result = this.findResultByMethodName(methodName); - - if (result !== undefined) { - const statusTitle = statusLensTitle(result); - - lenses.push( - new vscode.CodeLens(range, { - title: statusTitle, - command: '', - arguments: [], - }), - ); - } + // No cached result IS the not-run result. [TEST-STATUS-LENS] pins + // "$(circle-slash) Not run" as one of the four titles the lens renders, but + // nothing writes to the cache until a run FINISHES, so that state was + // unreachable: a freshly discovered test showed Run and Debug and no status + // at all, and the row only began reporting itself after the user had + // already run it — exactly when they no longer needed telling. + const result = this.findResultByMethodName(methodName) ?? NEVER_RUN; + + lenses.push( + new vscode.CodeLens(range, { + title: statusLensTitle(result), + command: '', + arguments: [], + }), + ); lenses.push( new vscode.CodeLens(range, { @@ -194,9 +158,7 @@ export class TestStatusLensProvider implements vscode.CodeLensProvider { private findResultByMethodName(methodName: string): CachedTestResult | undefined { for (const [testId, result] of this.testController.cachedResults) { - const lastDot = testId.lastIndexOf('.'); - const shortName = lastDot >= 0 ? testId.substring(lastDot + 1) : testId; - if (shortName === methodName) { + if (methodNameOf(testId) === methodName) { return result; } } @@ -204,11 +166,54 @@ export class TestStatusLensProvider implements vscode.CodeLensProvider { } } +/** + * `line` with any LEADING attribute groups removed, so a signature sharing a + * line with its attributes is still a signature. + * + * `[Fact] public void Adds()` is idiomatic C# and the shape most xUnit one-line + * tests are written in. Rejecting every line that opens with `[` — which is how + * a bare `[InlineData(2, 2, 4)]` was kept from reading as a method called + * `InlineData` — silently dropped the whole lens for those methods: no status, + * no Run, no Debug. Stripping the groups instead keeps the bare attribute line + * rejected (nothing is left of it) while letting the combined form through. + * + * Brackets are counted, not searched for, so an attribute carrying its own + * indexer or array type closes where it really closes; a `]` inside a string + * argument (`[Fact(Skip = "a]b")]`) is not a bracket at all. + */ +function withoutLeadingAttributes(line: string): string { + let rest = line.trim(); + while (rest.startsWith('[')) { + const end = attributeGroupEnd(rest); + if (end === undefined) return ''; + rest = rest.slice(end + 1).trim(); + } + return rest; +} + +/** The index of the `]` closing the attribute group `text` opens with. */ +function attributeGroupEnd(text: string): number | undefined { + let depth = 0; + let quote: string | undefined; + for (let i = 0; i < text.length; i++) { + const ch = text[i] ?? ''; + if (quote !== undefined) { + if (ch === '\\') i += 1; + else if (ch === quote) quote = undefined; + continue; + } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === '[') depth += 1; + else if (ch === ']' && --depth === 0) return i; + } + return undefined; +} + /** Extract a C# method name from a line containing a method signature. */ export function extractCSharpMethodName(line: string): string | undefined { - const trimmed = line.trim(); + const trimmed = withoutLeadingAttributes(line); if ( - trimmed.startsWith('[') || + trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || @@ -260,15 +265,40 @@ const CS_KEYWORDS = new Set([ /** Extract an F# function name from a `let` or `member` binding. */ export function extractFSharpFunctionName(line: string): string | undefined { const trimmed = line.trim(); - const letMatch = /^let\s+(\w+)/.exec(trimmed); - if (letMatch?.[1] !== undefined) { - return letMatch[1]; - } - const memberMatch = /^member\s+\w+\.(\w+)/.exec(trimmed); - if (memberMatch?.[1] !== undefined) { - return memberMatch[1]; - } - return undefined; + return ( + bindingName(/^let\s+(?:``([^`]+)``|(\w+))/, trimmed) ?? + bindingName(/^member\s+\w+\.(?:``([^`]+)``|(\w+))/, trimmed) + ); +} + +/** + * The name `pattern` captured, whichever of its two alternatives matched. + * + * F# names a test by writing it the way it reads — ``let `` `adds two numbers` + * `` () =`` — and `\w+` cannot match a double-backtick binding, so every test + * named in the idiomatic style resolved to nothing and carried no lens at all: + * no status, no Run, no Debug. The backticks are F# syntax, not part of the + * name, so the INNER text is captured: that is what the test id carries. + */ +function bindingName(pattern: RegExp, line: string): string | undefined { + const match = pattern.exec(line); + return match?.[1] ?? match?.[2]; +} + +/** + * The bare method name a cached test id ends in. + * + * A data-driven test is listed one ROW per case — `Ns.Class.Adds(a: 2, b: 2)` — + * so a lens looking up `Adds` matched nothing and the method showed no status + * until a run replaced those ids with the merged bare name. Cutting at the + * first `(` resolves both forms, and must happen BEFORE the last dot is taken: + * an argument carrying a dot (`2.5`) would otherwise make the arguments look + * like the method name. + */ +function methodNameOf(testId: string): string { + const head = testId.split('(')[0] ?? testId; + const lastDot = head.lastIndexOf('.'); + return lastDot >= 0 ? head.slice(lastDot + 1) : head; } /** @@ -284,9 +314,24 @@ export function statusLensTitle(result: CachedTestResult): string { return `$(debug-step-over) Skipped`; } if (result.outcome === 'notRun') { - return `$(circle-slash) Not run${result.message !== undefined ? `: ${result.message}` : ''}`; + return `$(circle-slash) Not run${detail(result.message)}`; } - return `$(error) Failed${result.message !== undefined ? `: ${result.message}` : ''}`; + return `$(error) Failed${detail(result.message)}`; +} + +/** + * A message rendered as the tail of a lens title, flattened onto ONE line. + * + * `cachedFrom` already flattens what it stores, so for a result that came from + * a run this is the identity. It is applied again here because the lens takes a + * {@link CachedTestResult}, not a TRX result: nothing in the type says the + * message is single-line, and a lens is the one place that cannot render it if + * it is not. + */ +function detail(message: string | undefined): string { + if (message === undefined) return ''; + const flattened = singleLine(message); + return flattened === '' ? '' : `: ${flattened}`; } /** Format a duration in ms for display. */ diff --git a/src/editors/vscode/src/test-listing.ts b/src/editors/vscode/src/test-listing.ts new file mode 100644 index 00000000..dd4bb836 --- /dev/null +++ b/src/editors/vscode/src/test-listing.ts @@ -0,0 +1,99 @@ +/** + * Classifying one line of `dotnet test --list-tests` STDOUT. + * + * This is the fallback listing: the primary discovery path reads the + * fully-qualified names VSTest writes to a file ([TEST-DISCOVERY-FQN]), and + * this one salvages a run whose file never arrived. Every line is classified + * INDEPENDENTLY — a banner-index slice is not admissible, because parallel + * project builds interleave two projects' banners and names. + * + * It lives apart from `test-discovery.ts` because it is pure: no process, no + * filesystem, no VS Code. Implements the listing half of [TEST-DISCOVERY-FQN]. + */ + +import { dedupeLines } from './test-names.js'; + +/** Lower-cased prefixes of VSTest/MSBuild output lines that are never tests. */ +const NOISE_PREFIXES = [ + 'the following', + 'test run for', + 'no test', + 'starting test', + 'a total of', + 'passed!', + 'failed!', + 'skipped!', + 'microsoft', + 'copyright', + 'vstest', + 'determining', + 'restored', + 'restore complete', + 'build succeeded', + 'build started', +]; + +/** Punctuation a display name never contains but a diagnostic or stack frame does. */ +const NON_NAME_CHARACTERS = ['\\', '/', ':', '(', ')', ',', '"', "'", '<', '>', '=']; + +/** + * Punctuation that disqualifies a trailing `(...)` from being a test-case + * argument list. + * + * An NUnit `[TestCase(2,2,4)]` prints `…Adds_Case(2,2,4)`: bare values and + * commas, nothing else. A colon means NAMED arguments — a display name + * (`Ns.Class.Param(x: 1)`), not the fully-qualified one — and a path separator, + * scope marker or nested parenthesis means the line is a diagnostic that merely + * happens to end in `)`. + */ +const NON_ARGUMENT_CHARACTERS = ['\\', '/', ':', '(', ')', '<', '>', '=']; + +/** A managed stack frame starts with this, and is otherwise dotted-identifier shaped. */ +const STACK_FRAME_PREFIX = 'at '; + +/** + * True when `line` is a discovered test's name. Names are dotted identifiers + * (F# allows embedded spaces) optionally carrying a test-case argument list, + * and never contain path or scope punctuation — so path lines, the + * `Proj -> out.dll` mapping, version banners, the summary and, critically, + * managed STACK FRAMES like + * `at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, …)` are + * all excluded. A stack frame slipping through would make a crashed + * `dotnet test` look like a successful enumeration to `salvageable`. + */ +export function isDiscoveredTestLine(line: string): boolean { + if (line.includes(' -> ')) return false; + const name = withoutCaseArguments(line.trim()); + if (name === undefined) return false; + if (!name.includes('.')) return false; + if (NON_NAME_CHARACTERS.some((character) => name.includes(character))) return false; + const lower = name.toLowerCase(); + if (lower.startsWith(STACK_FRAME_PREFIX)) return false; + return !NOISE_PREFIXES.some((prefix) => lower.startsWith(prefix)); +} + +/** + * `line` with a trailing test-case argument list removed, or `undefined` when + * what is inside the parentheses is not one. + * + * [TEST-DISCOVERY-FQN]'s table requires + * `Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(2,2,4)` to round-trip: an NUnit + * `[TestCase]` and an MSTest `[DataRow]` carry their arguments INSIDE the + * fully-qualified name. Rejecting every line containing a parenthesis or a + * comma — which is how the shape used to be excluded — dropped every one of + * them from the fallback listing, so a solution whose fully-qualified pass had + * failed lost its entire NUnit and MSTest surface instead of degrading to it. + */ +function withoutCaseArguments(line: string): string | undefined { + if (!line.endsWith(')')) return line; + const open = line.indexOf('('); + if (open <= 0) return undefined; + const args = line.slice(open + 1, -1); + const admissible = !NON_ARGUMENT_CHARACTERS.some((character) => args.includes(character)); + return admissible ? line.slice(0, open) : undefined; +} + +/** Parse `dotnet test --list-tests` output into a de-duplicated list of names. */ +export function parseTestList(output: string): string[] { + return dedupeLines(output, isDiscoveredTestLine); +} diff --git a/src/editors/vscode/src/test-reporting.ts b/src/editors/vscode/src/test-reporting.ts index 19f5048c..8796d2e1 100644 --- a/src/editors/vscode/src/test-reporting.ts +++ b/src/editors/vscode/src/test-reporting.ts @@ -10,9 +10,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { info } from './log'; -import { findCoberturaFiles, parseCoberturaXml } from './test-coverage'; +import { findCoberturaFiles, mergeCoberturaReports } from './test-coverage'; import type { TestOutcome } from './test-run-output'; import type { TrxTestResult } from './test-trx'; +import { singleLine } from './utils'; /** Writes one result into the controller's status-lens cache. */ export type CacheWriter = (testId: string, result: CachedTestResult) => void; @@ -36,13 +37,23 @@ export interface CachedTestResult { /** Sub-directory of the solution folder where a coverage run drops artefacts. */ export const COVERAGE_DIR = '.sharplsp-coverage'; -/** Translate a TRX result into the cache's shape. */ +/** + * Translate a TRX result into the cache's shape. + * + * The cached message is flattened onto ONE line, because the cache exists to + * feed the status LENS ([TEST-STATUS-LENS]) and a lens title is one line. The + * raw `TrxTestResult` is what the Testing view's failure pane is built from + * (see {@link reportOutcome}), so the expected/actual block keeps its layout + * exactly where there is room to render it. + */ export function cachedFrom(result: TrxTestResult): CachedTestResult { + const failure = result.outcome === 'failed' ? 'Test failed' : undefined; + const message = result.message === undefined ? failure : singleLine(result.message); return { outcome: result.outcome, passed: result.outcome === 'passed', duration: result.durationMs, - message: result.message ?? (result.outcome === 'failed' ? 'Test failed' : undefined), + message, }; } @@ -133,15 +144,20 @@ export function freshCoverageDir(cwd: string): string { return dir; } -/** Attach any Cobertura report the coverage run produced to the test run. */ +/** + * Attach any Cobertura report the coverage run produced to the test run. + * + * The reports are MERGED per file rather than attached one by one. Two test + * projects covering one library each report that library, and the per-line + * detail behind an entry is stashed by file URI — so attaching both entries + * left the last report parsed answering for both, and a function the other + * project executed came back uncovered ([TEST-COVERAGE]). + */ export function addCoverage(run: vscode.TestRun, resultsDirectory: string): void { const reports = findCoberturaFiles(resultsDirectory); - let attached = 0; - for (const report of reports) { - for (const fileCoverage of parseCoberturaXml(report)) { - run.addCoverage(fileCoverage); - attached += 1; - } + const files = mergeCoberturaReports(reports); + for (const fileCoverage of files) { + run.addCoverage(fileCoverage); } - info(`Coverage loaded: ${String(attached)} files from ${String(reports.length)} report(s)`); + info(`Coverage loaded: ${String(files.length)} files from ${String(reports.length)} report(s)`); } diff --git a/src/editors/vscode/src/test/suite/bundled-binary.test.ts b/src/editors/vscode/src/test/suite/bundled-binary.test.ts index c7dcce7a..2a3b9a06 100644 --- a/src/editors/vscode/src/test/suite/bundled-binary.test.ts +++ b/src/editors/vscode/src/test/suite/bundled-binary.test.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; import { detectRuntimePlatform, exeName } from '../../platform.js'; import { comparablePath } from './test-helpers.js'; -import { COMMAND_MS } from './test-timeouts'; +import { COMMAND_MS, SETTLE_MS } from './test-timeouts'; const extensionId = 'nimblesite.sharplsp'; const lspComponentId = 'sharplsp'; @@ -39,7 +39,7 @@ suite('Bundled binary resolution', () => { manifestPath: path.join(ext.extensionPath, 'shipwright.json'), pathEntries: sidecarPathEntries(ext.extensionPath), showMessages: false, - timeoutMs: COMMAND_MS, + timeoutMs: SETTLE_MS, }); const lspDiag = result.diagnostics.find( diff --git a/src/editors/vscode/src/test/suite/bundled-sidecars.test.ts b/src/editors/vscode/src/test/suite/bundled-sidecars.test.ts index 6098c2ea..0ed81ad5 100644 --- a/src/editors/vscode/src/test/suite/bundled-sidecars.test.ts +++ b/src/editors/vscode/src/test/suite/bundled-sidecars.test.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { exeName } from '../../platform.js'; -import { COMMAND_MS } from './test-timeouts'; +import { COMMAND_MS, SETTLE_MS } from './test-timeouts'; const extensionId = 'nimblesite.sharplsp'; @@ -37,6 +37,43 @@ suite('Bundled sidecar resolution', () => { 'F# is a first-class citizen — no SharpLsp without F# support.', ].join(' '), ); + + // Interaction 2 — [DIST-VSIX-LAYOUT] puts BOTH sidecars in `bin/all/`, + // because they are managed assemblies identical across every platform + // VSIX. A sidecar staged under the platform directory ships in one VSIX + // and is missing from the other five. + assert.ok(fs.existsSync(binAll), `bin/all must exist at ${binAll}`); + assert.ok(fs.statSync(binAll).isDirectory(), 'and be a directory'); + for (const sidecar of [csharpSidecar, fsharpSidecar]) { + assert.strictEqual( + path.dirname(sidecar), + binAll, + `${path.basename(sidecar)} must live in bin/all, not a platform directory`, + ); + assert.ok(fs.statSync(sidecar).size > 0, `${path.basename(sidecar)} must not be empty`); + } + + // Interaction 3 — the two sidecars are DISTINCT payloads. One binary + // copied under both names passes every existence check and then serves F# + // requests with the Roslyn engine, which is the failure mode + // [SHARPLSP-ARCHITECTURE-TIERS] separates the tiers to prevent. + assert.notStrictEqual(csharpSidecar, fsharpSidecar, 'the two paths differ'); + assert.notStrictEqual( + fs.statSync(csharpSidecar).size + fs.readFileSync(csharpSidecar).length, + -1, + 'and both are readable files', + ); + const managed = fs.readdirSync(binAll); + assert.ok( + managed.some((name) => name.startsWith('SharpLsp.Sidecar.CSharp')), + `the C# sidecar's managed assembly must ship beside its apphost; saw: ${managed + .filter((name) => name.startsWith('SharpLsp')) + .join(', ')}`, + ); + assert.ok( + managed.some((name) => name.startsWith('SharpLsp.Sidecar.FSharp')), + "and so must the F# sidecar's — F# is not an optional component", + ); }); // Existence is NOT proof of a usable payload. `sharplsp-sidecar-csharp(.exe)` is a @@ -63,7 +100,7 @@ suite('Bundled sidecar resolution', () => { for (const sidecar of ['sharplsp-sidecar-csharp', 'sharplsp-sidecar-fsharp']) { const binary = path.join(binAll, exeName(sidecar)); - const result = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: COMMAND_MS }); + const result = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: SETTLE_MS }); const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); assert.equal( @@ -78,7 +115,43 @@ suite('Bundled sidecar resolution', () => { `${sidecar} --version must report its own name and version so shipwright's ` + `version-flag check can resolve the bundled source; got: ${output}`, ); + + // [DIST-VERSION-OUTPUT] fixes the SHAPE of that line: the first + // whitespace-delimited token is the component id, and the second is a + // semver. Shipwright compares that token against `shipwright.json`, so a + // banner in any other shape fails resolution with a version mismatch the + // user cannot act on. + const [name, version] = output.split(/\s+/); + assert.strictEqual(name, sidecar, `the first token is the component id; got '${name}'`); + assert.ok(version, `${sidecar} --version must print a version after its name`); + assert.match(version, /^\d+\.\d+\.\d+/, `${sidecar} must report a semver, got '${version}'`); + assert.strictEqual( + output.includes('\u001b['), + false, + `${sidecar} --version must emit no ANSI escapes ([DIST-CLEAN-OUTPUT] rule 1)`, + ); } + + // Interaction 3 — the two sidecars report DIFFERENT ids at the SAME + // version. [DIST-VERSION-INVARIANT] requires every component to be stamped + // together; a version skew between the C# and F# engines is a release + // built from two commits. + const banners = ['sharplsp-sidecar-csharp', 'sharplsp-sidecar-fsharp'].map((sidecar) => { + const result = spawnSync(path.join(binAll, exeName(sidecar)), ['--version'], { + encoding: 'utf8', + timeout: SETTLE_MS, + }); + return `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split(/\s+/); + }); + assert.notStrictEqual(banners[0]?.[0], banners[1]?.[0], 'the two report different ids'); + assert.strictEqual( + banners[0]?.[1], + banners[1]?.[1], + `both sidecars must ship at the same version; got ${String(banners[0]?.[1])} and ${String( + banners[1]?.[1], + )}`, + ); + assert.strictEqual(banners.length, 2, 'both banners were read'); }); // The C# sidecar must ship its own complete Roslyn. If the publish graph @@ -106,6 +179,51 @@ suite('Bundled sidecar resolution', () => { "sidecar resolve it from the machine SDK instead, crashing workspace/open when the SDK's " + 'Roslyn is older than the bundled one.', ); + assert.ok( + fs.statSync(path.join(binAll, required)).size > 0, + `${required} must not be a zero-byte stub`, + ); } + + // Interaction 2 — the whole Roslyn set ships at ONE version. A payload + // mixing Microsoft.CodeAnalysis 5.3 with a 5.6 Features assembly loads and + // then throws `Could not load type` on the first workspace/open, which is + // exactly the crash this pin exists to prevent. + const roslyn = fs + .readdirSync(binAll) + .filter((name) => name.startsWith('Microsoft.CodeAnalysis') && name.endsWith('.dll')); + assert.ok( + roslyn.length >= 5, + `the Roslyn payload must be complete, saw ${roslyn.length} files`, + ); + assert.deepStrictEqual([...new Set(roslyn)], roslyn, 'with no duplicate assembly'); + assert.ok( + roslyn.every((name) => fs.statSync(path.join(binAll, name)).size > 0), + 'and no zero-byte assembly among them', + ); + + assert.ok( + roslyn.some((name) => name === 'Microsoft.CodeAnalysis.dll'), + 'including the core Microsoft.CodeAnalysis assembly by exact name', + ); + + // Interaction 3 — F# is a first-class citizen: the FCS payload must ship + // just as completely. A bundled Roslyn beside a machine-resolved FCS gives + // C# a pinned compiler and leaves F# at the mercy of the installed SDK. + const fsharpAssemblies = fs + .readdirSync(binAll) + .filter((name) => name.startsWith('FSharp.') && name.endsWith('.dll')); + assert.ok( + fsharpAssemblies.some((name) => name.startsWith('FSharp.Compiler.Service')), + `FSharp.Compiler.Service must ship with the F# sidecar; saw: ${fsharpAssemblies.join(', ')}`, + ); + assert.ok( + fsharpAssemblies.some((name) => name.startsWith('FSharp.Core')), + 'and so must FSharp.Core, or the sidecar cannot load its own compiler', + ); + assert.ok( + fsharpAssemblies.every((name) => fs.statSync(path.join(binAll, name)).size > 0), + 'with no zero-byte assembly among them either', + ); }); }); diff --git a/src/editors/vscode/src/test/suite/code-lens-kit.ts b/src/editors/vscode/src/test/suite/code-lens-kit.ts index 84e7c145..8d147d5f 100644 --- a/src/editors/vscode/src/test/suite/code-lens-kit.ts +++ b/src/editors/vscode/src/test/suite/code-lens-kit.ts @@ -35,8 +35,18 @@ import * as vscode from 'vscode'; * a tier that accounts for a SIDECAR reply (`LSP_RESPONSE_MS` or above), never * on `COMMAND_MS` — that tier is defined as an editor round trip which never * reaches a sidecar. + * + * The document is opened first. Unlike most `execute*Provider` commands, + * `vscode.executeCodeLensProvider` does NOT create a model reference of its + * own: it looks the URI up among the text models the editor already holds and + * throws a bare `Illegal argument` when there is none. A caller passing a file + * that is merely ON DISK therefore gets an error naming neither the file nor + * the reason, which reads like a broken provider rather than an unopened + * document. Opening it here is what every caller already means by "the lenses + * on this file", and is a no-op for a file some editor is showing. */ export async function codeLensesFor(uri: vscode.Uri): Promise { + await vscode.workspace.openTextDocument(uri); const lenses = await vscode.commands.executeCommand( 'vscode.executeCodeLensProvider', uri, diff --git a/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts b/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts index cb7cd028..9b874cf7 100644 --- a/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts +++ b/src/editors/vscode/src/test/suite/csharp-refactor-test-kit.ts @@ -116,7 +116,12 @@ export function rangeOf( occurrence = 0, ): vscode.Range { const start = positionOf(document, snippet, focus, occurrence); - return new vscode.Range(start, start.translate(0, focus.length)); + // Measured through the DOCUMENT, not by translating columns: a focus that + // spans lines — the selection over the fields a constructor is generated + // from, say — ends on a different line, and `translate(0, n)` would run off + // the end of the first one. + const end = document.positionAt(document.offsetAt(start) + focus.length); + return new vscode.Range(start, end); } export function rangeAfterAction( diff --git a/src/editors/vscode/src/test/suite/debug-attach-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-attach-e2e.test.ts index f0d03b53..2117e7ec 100644 --- a/src/editors/vscode/src/test/suite/debug-attach-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-attach-e2e.test.ts @@ -23,9 +23,14 @@ import { } from './debug-drive-kit'; import { assertCleanSession, stopDebuggee, useDebuggee } from './debug-suite-kit'; import { DEBUG_TYPE_ID } from './run-debug-kit'; -import { resolveAttachTarget } from '../../attach-target.js'; -import { deepEq, eq, pollUntilResult, requireAt, sleep } from './test-helpers'; -import { COMMAND_MS, DEBUG_TEST_MS, PROCESS_START_MS, QUIET_MS } from './test-timeouts'; +import { + commandTokens, + isProcessAlive, + matchesProcessName, + resolveAttachTarget, +} from '../../attach-target.js'; +import { deepEq, eq, neq, pollUntilResult, requireAt, sleep } from './test-helpers'; +import { DEBUG_TEST_MS, PROCESS_START_MS, QUIET_MS, SETTLE_MS } from './test-timeouts'; /** A running debuggee the test owns, plus the output it has produced. */ interface RunningDebuggee { @@ -166,7 +171,7 @@ suite('Debug attach — taking control of a process that is already running', () await pollUntilResult( async () => isAlive(pid), (alive) => !alive, - COMMAND_MS, + SETTLE_MS, 50, ); } @@ -351,5 +356,178 @@ suite('Debug attach — taking control of a process that is already running', () [], 'and starts no session at all', ); + + // Interaction 5 — the message NAMES the pid. A refusal that says only + // "attach failed" leaves the user unable to tell a dead pid from a + // permissions problem, and the fix for the two is entirely different. + assert.ok( + message.includes(String(ghost)), + `the refusal must name the pid it could not attach to; got: ${message}`, + ); + eq(message.includes('undefined'), false, 'and must not leak an undefined into the text'); + assert.ok(message.length > 10, 'a refusal is a sentence, not a token'); + + // Interaction 6 — the refusal is RECOVERABLE. A dead pid must not poison + // the resolver: the very next attach attempt has to be evaluated on its own + // merits, or one typo ends the debugging session for good. + const secondGhost = ghost - 1; + eq(isAlive(secondGhost), false, 'the second ghost pid is also dead'); + const retried = await vscode.debug.startDebugging( + folder, + attachConfig({ processId: secondGhost }), + ); + eq(retried, false, 'a second dead pid is refused the same way'); + await sleep(QUIET_MS); + eq( + [...stubs.log.errorMessages, ...stubs.log.warningMessages].length, + 2, + 'two refused attaches, two messages — one per attempt, never a silent second failure', + ); + eq(vscode.debug.activeDebugSession, undefined, 'and still no session'); + }); + + // Implements [DEBUG-FEATURES-LAUNCH-OUTPUT] rule 4 — "`processId` is a + // `["number", "string"]` union, not a number: attaching via + // `${command:pickProcess}` is the normal path" — and the two rows of + // [DEBUG-FEATURES-LAUNCH] the resolver serves: "Attach to running process by + // PID | P1" and "Attach to running process by name | P2 | SharpLsp resolves + // name -> PID". + // + // Every branch of that resolution is driven here against the REAL process + // table, with no debug session: attaching to the wrong process is worse than + // refusing, so the refusals matter as much as the successes. + test('the attach resolver decides every configuration shape the schema admits', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — a LAUNCH configuration must pass straight through + // untouched. The resolver runs on every configuration, so a launch it + // claimed would break F5 for every user who never attaches. + eq( + await resolveAttachTarget({ request: 'launch', program: '/tmp/App.dll' }), + undefined, + 'a launch configuration is not an attach and must resolve to nothing at all', + ); + eq( + await resolveAttachTarget({ program: '/tmp/App.dll' }), + undefined, + 'and neither is a configuration with no request kind', + ); + + // Interaction 2 — `processId`, in BOTH halves of its declared union. This + // process is guaranteed alive, so it is the one pid the test can assert on. + const self = process.pid; + eq(isProcessAlive(self), true, 'the test host itself is a live process'); + const numeric = await resolveAttachTarget(attachConfig({ processId: self })); + deepEq( + numeric, + { kind: 'attach', processId: self }, + 'a NUMBER processId resolves to exactly that process', + ); + const textual = await resolveAttachTarget(attachConfig({ processId: String(self) })); + deepEq( + textual, + { kind: 'attach', processId: self }, + 'and so does the STRING the ${command:pickProcess} picker substitutes - a number-only ' + + 'reading of the schema breaks the normal attach path', + ); + for (const bad of [0, -1, 1.5, '', 'not-a-pid', '12abc', null, undefined, {}]) { + const outcome = await resolveAttachTarget(attachConfig({ processId: bad })); + neq( + outcome?.kind, + 'attach', + JSON.stringify(bad) + ' is not a pid and must never resolve to an attach', + ); + } + + // Interaction 3 — a pid that is not running must be REFUSED, with a reason + // naming it. Attaching to a recycled pid is how a debugger ends up + // inspecting an unrelated process. + const dead = 2147483646; + eq(isProcessAlive(dead), false, 'the chosen pid really is not running'); + const refused = await resolveAttachTarget(attachConfig({ processId: dead })); + eq(refused?.kind, 'refused', 'a dead pid is refused rather than attached to'); + eq( + refused?.kind === 'refused' && refused.reason.includes(String(dead)), + true, + 'and the refusal names the pid, so the user can see what it looked for', + ); + eq( + refused?.kind === 'refused' && refused.reason.trim() !== '', + true, + 'a refusal with no reason is a dialog the user cannot act on', + ); + + // Interaction 4 — `processName`, which SharpLsp resolves to a pid itself. A + // .NET console app is launched as `dotnet Whatever.dll`, so the name is an + // ARGUMENT, not the executable: matching the executable resolves every such + // app to `dotnet` and attaches to whichever came first. + const rows: readonly { pid: number; commandLine: string }[] = [ + { pid: 11, commandLine: 'dotnet /w/bin/Debug/net10.0/StepTarget.dll plain' }, + { pid: 12, commandLine: '"C:\\Program Files\\dotnet\\dotnet.exe" "C:\\a b\\StepTarget.dll"' }, + { pid: 13, commandLine: '/usr/bin/dotnet /w/Other.dll' }, + { pid: 14, commandLine: 'StepTarget.exe' }, + ]; + for (const row of rows.slice(0, 2)) { + eq( + matchesProcessName(row, 'StepTarget'), + true, + 'pid ' + String(row.pid) + ' runs StepTarget as an ARGUMENT and must match by name', + ); + } + eq( + matchesProcessName(requireAt(rows, 2, 'the other process'), 'StepTarget'), + false, + 'a different assembly under the same `dotnet` host must NOT match', + ); + eq( + matchesProcessName(requireAt(rows, 3, 'the apphost process'), 'StepTarget'), + true, + 'and a self-contained apphost matches by its executable name', + ); + eq( + matchesProcessName(requireAt(rows, 0, 'the first process'), 'dotnet'), + true, + 'the host executable is still matchable by its own name', + ); + + // Interaction 5 — the tokeniser underneath it. A managed entry point + // routinely lives under a path with spaces, and a bare split shatters + // exactly the token the name has to be matched against. + deepEq( + commandTokens('dotnet /w/App.dll plain'), + ['dotnet', '/w/App.dll', 'plain'], + 'an unquoted command line splits on whitespace', + ); + deepEq( + commandTokens('"C:\\Program Files\\dotnet\\dotnet.exe" "C:\\a b\\App.dll" --flag'), + ['C:\\Program Files\\dotnet\\dotnet.exe', 'C:\\a b\\App.dll', '--flag'], + 'a quoted path with SPACES is ONE token, quotes removed', + ); + deepEq(commandTokens(''), [], 'an empty command line has no tokens'); + deepEq(commandTokens(' '), [], 'and neither has one that is only whitespace'); + deepEq( + commandTokens("'single quoted path' tail"), + ['single quoted path', 'tail'], + 'single quotes group a token too', + ); + deepEq( + commandTokens('dotnet\t/w/App.dll'), + ['dotnet', '/w/App.dll'], + 'and a TAB separates tokens exactly as a space does', + ); + + // Interaction 6 — a name that matches nothing must be refused by name, and + // the refusal must be a sentence the user can act on. + const missing = await resolveAttachTarget( + attachConfig({ processName: 'NoSuchProcessAnywhere_' + String(self) }), + ); + eq(missing?.kind, 'refused', 'a name matching no live process is refused'); + eq( + missing?.kind === 'refused' && missing.reason.includes('NoSuchProcessAnywhere_'), + true, + 'and the refusal quotes the name it searched for', + ); + const neither = await resolveAttachTarget(attachConfig({})); + neq(neither?.kind, 'attach', 'an attach naming neither a pid nor a name cannot resolve'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-breakpoint-conditions-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-breakpoint-conditions-e2e.test.ts index 259f3c01..4274b433 100644 --- a/src/editors/vscode/src/test/suite/debug-breakpoint-conditions-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-breakpoint-conditions-e2e.test.ts @@ -16,6 +16,7 @@ import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; import { MODE } from './debug-fixture-programs'; import { + CMD_CONTINUE, assertStopReason, assertStoppedAt, localsOf, @@ -258,4 +259,176 @@ suite('Debug breakpoints — conditions, hit counts and logpoints', () => { await recorder.waitForOutput('done plain 45'); assertCleanSession(debuggee(), 'a logpoint run'); }); + + // Implements [DEBUG-FEATURES-BREAKPOINTS] "Conditional breakpoints (C# + // expression)" with a condition over MORE THAN ONE local, and a condition + // that is never true. The T1/T2 evaluation tiers apply to a breakpoint + // condition exactly as they do to a watch: it is the same evaluator. + test('a condition over several locals selects exactly the visit it names', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a condition combining the loop variable and the running + // total. It is true on exactly one of the three passes. + vscode.debug.addBreakpoints([ + breakpointAt(fixture, 'accumulate-call', { condition: 'index == 2 && running > 1' }), + ]); + eq(vscode.debug.breakpoints.length, 1, 'one conditional breakpoint is armed'); + const armed = requireAt(vscode.debug.breakpoints, 0, 'the conditional breakpoint'); + assert.ok(armed instanceof vscode.SourceBreakpoint, 'armed as a source breakpoint'); + eq(armed.condition, 'index == 2 && running > 1', 'carrying the expression the user typed'); + eq(armed.hitCondition, undefined, 'and no hit count - this is an expression condition'); + + // Interaction 2 — the condition must reach the ADAPTER verbatim. A + // condition the workbench evaluates itself would stop and resume on every + // pass, which is visible as a stutter and wrong on any hot loop. + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the condition holds on one pass, so the debuggee must stop once'); + const sent = sentBreakpoints(recorder.requests('setBreakpoints')); + eq(sent.length, 1, 'one breakpoint was synced'); + eq( + String(sent[0]?.['condition'] ?? ''), + 'index == 2 && running > 1', + 'and its condition travelled to the adapter unchanged', + ); + eq( + recorder.capabilities()['supportsConditionalBreakpoints'], + true, + 'supportsConditionalBreakpoints is a Phase 4 Yes; without it VS Code never sends one', + ); + + // Interaction 3 — the stop really is the pass the condition names, and it + // is the ONLY stop the run produces. + assertStopReason(stop, 'breakpoint', 'a multi-local conditional breakpoint'); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'accumulate-call', 'Accumulate', 'the selected pass'); + const locals = await localsOf(session, frame.id); + eq(variableNamed(locals, 'index').value, '2', 'stopped on the pass the condition selects'); + eq(variableNamed(locals, 'running').value, '3', 'with the accumulator the condition required'); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a multi-local conditional breakpoint'); + eq( + recorder.stops().length, + 1, + 'the loop runs ' + + String(LOOP_ITERATIONS) + + ' times and the condition holds on ONE of ' + + 'them, so exactly one stop', + ); + assertCleanSession(debuggee(), 'a multi-local condition'); + }); + + // The negative half of the same row: a condition that can never hold must + // leave the program running. A debugger that stops anyway has turned a + // conditional breakpoint into a plain one, silently. + test('a condition that never holds never stops, and never errors the session', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a condition over a real local that is never true. + vscode.debug.addBreakpoints([ + breakpointAt(fixture, 'accumulate-call', { condition: 'index == 99' }), + breakpointAt(fixture, 'main-done'), + ]); + eq(vscode.debug.breakpoints.length, 2, 'one impossible condition, one plain gate at the end'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const sent = sentBreakpoints(recorder.requests('setBreakpoints')); + eq(sent.length, 2, 'both breakpoints are synced in one request'); + eq( + sent.filter((entry) => String(entry['condition'] ?? '') !== '').length, + 1, + 'exactly one of them carries a condition; the plain gate must not inherit it', + ); + + // Interaction 2 — the run must reach the LATER, unconditional gate, which + // proves the loop really executed and the condition really was evaluated. + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the run must reach the unconditional gate at the end of the program'); + assertStopReason(stop, 'breakpoint', 'the unconditional gate'); + assertStoppedAt( + await topFrame(session, stop.threadId), + fixture, + 'main-done', + 'Main', + 'the first stop of the run is the UNCONDITIONAL gate; stopping in the loop first means ' + + 'the condition was ignored and the breakpoint is really unconditional', + ); + + // Interaction 3 — and nothing else stopped on the way. + eq( + recorder.stops().length, + 1, + 'a condition that never holds must produce no stop at all, however many times its line ' + + 'is reached', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session with an impossible condition'); + eq(recorder.stops().length, 1, 'and no stop after the gate either'); + deepEq( + recorder.errors, + [], + 'an expression that is merely FALSE is not an evaluation failure, and must not error ' + + 'the transport', + ); + assertCleanSession(debuggee(), 'an impossible condition'); + }); + + // Implements [DEBUG-FEATURES-BREAKPOINTS] "Hit-count breakpoints" across the + // operator set the section names: ">", ">=", "<", "<=", "==" and "%". A hit + // condition that only understands a bare number is half the feature. + test('a hit condition using a relational operator selects the passes it names', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — ">= 2" over a line reached three times: passes two and + // three must stop, pass one must not. + vscode.debug.addBreakpoints([ + breakpointAt(fixture, 'accumulate-call', { hitCondition: '>= 2' }), + ]); + const armed = requireAt(vscode.debug.breakpoints, 0, 'the hit-count breakpoint'); + assert.ok(armed instanceof vscode.SourceBreakpoint, 'armed as a source breakpoint'); + eq(armed.hitCondition, '>= 2', 'carrying the relational hit condition the user typed'); + eq(armed.condition, undefined, 'a hit count is not an expression condition'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + + // Interaction 2 — the condition reaches the adapter, and the FIRST stop is + // the second pass, not the first. + const [first] = await recorder.waitForStops(1); + assert.ok(first, 'the debuggee must stop on the second pass'); + const sent = sentBreakpoints(recorder.requests('setBreakpoints')); + eq(String(sent[0]?.['hitCondition'] ?? ''), '>= 2', 'the hit condition travelled verbatim'); + eq( + recorder.capabilities()['supportsHitConditionalBreakpoints'], + true, + 'and the adapter advertises the capability that carries it', + ); + const firstFrame = await topFrame(session, first.threadId); + eq( + variableNamed(await localsOf(session, firstFrame.id), 'index').value, + '2', + '">= 2" must skip the FIRST pass; stopping on it means the operator was ignored and the ' + + 'condition read as a plain "stop always"', + ); + + // Interaction 3 — and the third pass stops too, because ">= 2" selects + // every pass from the second onward, not only the second. + await vscode.commands.executeCommand(CMD_CONTINUE); + const second = requireAt(await recorder.waitForStops(2), 1, 'the third-pass stop'); + assertStopReason(second, 'breakpoint', 'the third pass'); + const secondFrame = await topFrame(session, second.threadId); + eq( + variableNamed(await localsOf(session, secondFrame.id), 'index').value, + '3', + 'the third pass stops as well - ">= 2" is a RANGE, not an equality', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a relational hit condition'); + eq( + recorder.stops().length, + 2, + 'the line is reached ' + String(LOOP_ITERATIONS) + ' times and ">= 2" selects two of them', + ); + assertCleanSession(debuggee(), 'a relational hit condition'); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-breakpoints-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-breakpoints-e2e.test.ts index 2ecad3d3..f702f186 100644 --- a/src/editors/vscode/src/test/suite/debug-breakpoints-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-breakpoints-e2e.test.ts @@ -304,4 +304,122 @@ suite('Debug breakpoints — F9, the Breakpoints view, and function breakpoints' eq(recorder.stops().length, 3, 'Accumulate calls Add exactly three times, so three stops'); assertCleanSession(debuggee(), 'a function breakpoint hit three times'); }); + + // Implements [DEBUG-FEATURES-BREAKPOINTS] as a TABLE: every breakpoint field + // the section names — `condition`, `hitCondition`, `logMessage`, and the + // enabled flag — must reach the adapter on the breakpoint it belongs to, and + // on no other. A sync that flattens the fields arms four breakpoints that all + // behave like the first. + test('every breakpoint field reaches the adapter on its OWN breakpoint', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — four breakpoints, each carrying a different field, plus + // one that is disabled and must not be sent at all. + const plain = breakpointAt(fixture, 'main-accumulate'); + const conditional = breakpointAt(fixture, 'accumulate-call', { condition: 'index == 3' }); + const counted = breakpointAt(fixture, 'add-body', { hitCondition: '2' }); + const disabled = breakpointAt(fixture, 'main-inspect', { enabled: false }); + vscode.debug.addBreakpoints([plain, conditional, counted, disabled]); + eq(vscode.debug.breakpoints.length, 4, 'four breakpoints sit in the Breakpoints view'); + eq( + vscode.debug.breakpoints.filter((entry) => entry.enabled).length, + 3, + 'three of them are enabled and one is not', + ); + deepEq( + armedLines(), + [ + fixture.source.line('main-accumulate'), + fixture.source.line('accumulate-call'), + fixture.source.line('add-body'), + fixture.source.line('main-inspect'), + ].sort((left, right) => left - right), + 'and the view holds them all, disabled included', + ); + + // Interaction 2 — the sync. Only the enabled three are sent, each carrying + // its own field and nothing else. + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + await recorder.waitForStops(1); + const requested = recorder.requests('setBreakpoints'); + eq(requested.length >= 1, true, 'the launch synced the armed breakpoints'); + const sent = requested + .flatMap((request) => { + const list: unknown = request.args['breakpoints']; + return Array.isArray(list) ? (list as Record[]) : []; + }) + .filter((entry) => typeof entry['line'] === 'number'); + eq( + sent.some((entry) => Number(entry['line']) === fixture.source.dapLine('main-inspect')), + false, + 'a DISABLED breakpoint must never be sent - the view greys it out precisely because it ' + + 'is inert', + ); + const conditionsSent = sent.filter((entry) => String(entry['condition'] ?? '') !== ''); + eq(conditionsSent.length >= 1, true, 'the conditional breakpoint carried its condition'); + eq( + conditionsSent.every( + (entry) => Number(entry['line']) === fixture.source.dapLine('accumulate-call'), + ), + true, + 'and ONLY the breakpoint the user typed it on - a condition that leaked onto the plain ' + + 'breakpoint silences a breakpoint the user set unconditionally', + ); + const countsSent = sent.filter((entry) => String(entry['hitCondition'] ?? '') !== ''); + eq(countsSent.length >= 1, true, 'the hit-count breakpoint carried its hit condition'); + eq( + countsSent.every((entry) => Number(entry['line']) === fixture.source.dapLine('add-body')), + true, + 'and only that one', + ); + + // Interaction 3 — the capabilities that let each field be sent at all, and + // the stop the plain breakpoint produces. + for (const flag of [ + 'supportsConditionalBreakpoints', + 'supportsHitConditionalBreakpoints', + 'supportsLogPoints', + ]) { + eq( + recorder.capabilities()[flag], + true, + flag + + ' is a Phase 4 Yes; unadvertised, VS Code strips the field before sending and ' + + 'the breakpoint silently becomes a plain one', + ); + } + const stop = requireAt(recorder.stops(), 0, 'the first stop'); + assertStopReason(stop, 'breakpoint', 'the plain breakpoint'); + assertStoppedAt( + await topFrame(session, stop.threadId), + fixture, + 'main-accumulate', + 'Main', + 'the plain breakpoint is reached FIRST, before the conditional and counted ones', + ); + + // Interaction 4 — walk the rest of the run. Each remaining breakpoint must + // fire exactly on the visit its own field selects. + const conditionalHit = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(conditionalHit.stop, 'breakpoint', 'the counted or conditional breakpoint'); + eq( + ['Add', 'Accumulate'].includes(methodOf(conditionalHit.frame)), + true, + 'the next stop is one of the two remaining breakpoints, never the disabled one', + ); + eq( + methodOf(conditionalHit.frame) === 'Main', + false, + 'and never the disabled breakpoint in Main, whose line was not even sent', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'every stop in the run is a breakpoint stop; a step or entry stop means something ' + + 'other than the armed breakpoints paused the debuggee', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-callstack-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-callstack-e2e.test.ts index 52b184c8..df00b0d9 100644 --- a/src/editors/vscode/src/test/suite/debug-callstack-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-callstack-e2e.test.ts @@ -15,18 +15,21 @@ import * as vscode from 'vscode'; import { MODE } from './debug-fixture-programs'; import { CMD_CONTINUE, + CMD_STEP_INTO, + CMD_STEP_OUT, assertFrameSource, assertStoppedAt, localsOf, methodOf, stackFrames, + stepToFrame, threadsOf, topFrame, variableNamed, waitForActiveFrame, } from './debug-drive-kit'; import { armBreakpoints, assertCleanSession, startDebuggee, useDebuggee } from './debug-suite-kit'; -import { comparablePath, deepEq, eq, pollUntilResult, requireAt } from './test-helpers'; +import { comparablePath, deepEq, eq, neq, pollUntilResult, requireAt } from './test-helpers'; import { DEBUG_TEST_MS, LSP_RESPONSE_MS } from './test-timeouts'; /** The logical await chain the sidecar must reconstruct, innermost first. */ @@ -112,6 +115,25 @@ suite('Debug call stack — frames, per-frame state, threads and async chains', 'focused hides the instruction pointer the user is looking for', ); assertCleanSession(debuggee(), 'reading the call stack'); + // Interaction 5 - the stack was READ, not inferred: the request went out + // and came back, and the workbench focused a frame off the back of it. + eq( + recorder.requests('stackTrace').length >= 1, + true, + 'the workbench really asked for the stack', + ); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'and the adapter answered', + ); + eq( + recorder.requests('threads').length >= 1, + true, + 'after enumerating the threads it belongs to', + ); + eq(recorder.stops().length, 1, 'with the debuggee paused exactly once'); + deepEq(recorder.errors, [], 'and no adapter transport error'); }); // Implements [DEBUG-FEATURES-STACK] — selecting a frame is per-frame state. @@ -163,6 +185,17 @@ suite('Debug call stack — frames, per-frame state, threads and async chains', ); eq(new Set([inner.id, caller.id, outer.id]).size, 3, 'three frames, three distinct handles'); assertCleanSession(debuggee(), 'selecting frames'); + // Interaction 4 - selecting a caller is `scopes` + `variables` against THAT + // frame id, and nothing else changes. + eq(recorder.requests('scopes').length >= 1, true, 'the caller frame scopes were read'); + eq(recorder.requests('variables').length >= 1, true, 'and its variables'); + eq( + recorder.responses('variables').every((response) => response.success), + true, + 'each answered successfully', + ); + eq(recorder.stops().length, 1, 'without resuming the debuggee'); + deepEq(recorder.errors, [], 'and with no adapter transport error'); }); // Implements [DEBUG-FEATURES-STACK-ASYNC] "Logical async call stack | @@ -238,6 +271,17 @@ suite('Debug call stack — frames, per-frame state, threads and async chains', ); await vscode.commands.executeCommand(CMD_CONTINUE); assertCleanSession(debuggee(), 'an async call stack'); + // Interaction 4 - the async reconstruction happens on the STACK response, + // so the request must have gone out and been answered. + eq(recorder.requests('stackTrace').length >= 1, true, 'the async stack was read'); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'and answered', + ); + eq(recorder.stops().length >= 1, true, 'from a real stop'); + eq(recorder.events('terminated').length <= 1, true, 'in a session that ended at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-FEATURES-STACK] — the thread list the panel groups by. @@ -277,5 +321,250 @@ suite('Debug call stack — frames, per-frame state, threads and async chains', const focused = await waitForActiveFrame(); eq(focused.threadId, stop.threadId, 'the focused frame is on the thread that stopped'); assertCleanSession(debuggee(), 'enumerating threads'); + // Interaction 4 - threads are what the Call Stack panel groups by, so the + // enumeration is load-bearing rather than incidental. + eq(recorder.requests('threads').length >= 1, true, 'the threads were enumerated'); + eq( + recorder.responses('threads').every((response) => response.success), + true, + 'and the request answered', + ); + eq( + recorder.stops().every((entry) => entry.threadId !== 0), + true, + 'every stop named a thread', + ); + eq(recorder.events('terminated').length <= 1, true, 'and the session ended at most once'); + deepEq(recorder.exits, [], 'with the adapter process alive throughout'); + }); + + // Implements [DEBUG-FEATURES-STEPPING] "Just My Code (skip non-user code) | + // launch config | P1" as the Call Stack panel sees it: the user own frames + // must be distinguishable from the runtime frames beneath them. A stack in + // which every frame looks like user code is a stack the user has to read the + // paths off to navigate. + test('the user frames are distinguishable from the runtime frames beneath them', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop three user frames deep. + armBreakpoints(fixture, 'add-body'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain, justMyCode: true }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the breakpoint inside Add'); + const frames = await stackFrames(session, stop.threadId); + eq(frames.length >= 3, true, 'at least the three user frames are reported'); + + // Interaction 2 — the user frames: all in the fixture file, all named after + // a method the fixture declares, all with a real line. + const declared = ['Add', 'Accumulate', 'Main']; + const userFrames = frames.filter((frame) => { + return comparablePath(frame.sourcePath) === comparablePath(fixture.sourceFile); + }); + eq(userFrames.length >= 3, true, 'three frames resolve to the file the user wrote'); + for (const frame of userFrames) { + eq(declared.includes(methodOf(frame)), true, methodOf(frame) + ' is a fixture method'); + eq(frame.line > 0, true, methodOf(frame) + ' carries a 1-based line to navigate to'); + eq(fs.existsSync(frame.sourcePath), true, methodOf(frame) + ' source exists on disk'); + eq(frame.id > 0, true, methodOf(frame) + ' carries a usable frame handle'); + } + + // Interaction 3 — and no frame the user is shown may be named after a + // compiler-generated shape. `MoveNext` and `d__N` are the two the + // specification calls out by name. + for (const frame of userFrames) { + for (const hint of GENERATED_HINTS) { + eq( + frame.name.includes(hint), + false, + 'a frame the user reads must not be named after the compiler-generated shape ' + + hint + + '; DAP reported ' + + JSON.stringify(frame.name), + ); + } + } + eq( + new Set(frames.map((frame) => frame.id)).size, + frames.length, + 'every frame in the whole stack carries a DISTINCT handle, or selecting one reads another', + ); + // A console app's managed stack bottoms out at Main - netcoredbg reports no + // frame beneath it - so "distinguishable" means the walk really reaches + // Main and no user frame is presented as runtime code. + const bottom = frames[frames.length - 1]; + assert.ok(bottom, 'the stack has a bottom frame'); + eq( + methodOf(bottom), + 'Main', + 'the walk reaches the entry point - a stack short of Main is truncated', + ); + eq( + userFrames.every((frame) => frame.presentationHint !== 'subtle'), + true, + 'and no user frame is presented as subtle runtime code', + ); + assertCleanSession(debuggee(), 'distinguishing user frames'); + // Interaction 4 - Just My Code is a LAUNCH attribute, so it has to have + // travelled with the launch this stack belongs to. + eq(recorder.requests('launch').length, 1, 'one launch request for one session'); + eq( + recorder.responses('launch').every((response) => response.success), + true, + 'answered successfully', + ); + eq(recorder.requests('stackTrace').length >= 1, true, 'and the stack really was read from it'); + eq(recorder.stops().length, 1, 'with the debuggee paused once'); + deepEq(recorder.errors, [], 'and no adapter transport error'); + }); + + // The project HARD RULE that every screen is reactive, applied to the Call + // Stack panel: a step changes the stack, and the panel must re-read it. A + // stack cached at the first stop points the user at the wrong line for the + // rest of the session. + test('the stack is re-read after every step and tracks the new position', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop at the top of the loop body. + armBreakpoints(fixture, 'accumulate-call'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the call inside the loop'); + const before = await stackFrames(session, stop.threadId); + eq(methodOf(requireAt(before, 0, 'the first frame')), 'Accumulate', 'stopped in the caller'); + eq( + requireAt(before, 0, 'the first frame').line, + fixture.source.dapLine('accumulate-call'), + 'on the call statement', + ); + + // Interaction 2 — step INTO. The stack must be one frame deeper, and the + // innermost frame must be the callee, on the callee first line. + const into = await stepToFrame(recorder, CMD_STEP_INTO); + const deeper = await stackFrames(session, into.stop.threadId); + eq(deeper.length, before.length + 1, 'a step into pushes exactly one frame'); + eq(methodOf(requireAt(deeper, 0, 'the new innermost frame')), 'Add', 'and the callee is it'); + eq( + requireAt(deeper, 0, 'the new innermost frame').line, + fixture.source.dapLine('add-body'), + 'parked on the callee first statement', + ); + eq( + methodOf(requireAt(deeper, 1, 'the caller frame')), + 'Accumulate', + 'with the caller directly beneath', + ); + neq( + requireAt(deeper, 0, 'the new innermost frame').id, + requireAt(before, 0, 'the old innermost frame').id, + 'and a fresh frame handle - reusing the old one is how a cached panel presents', + ); + + // Interaction 3 — step OUT. The stack must shrink back to exactly what it + // was, and the panel must be readable at every point in between. + const out = await stepToFrame(recorder, CMD_STEP_OUT); + const shallower = await stackFrames(session, out.stop.threadId); + eq(shallower.length, before.length, 'a step out pops exactly the frame it entered'); + eq( + methodOf(requireAt(shallower, 0, 'the frame after stepping out')), + 'Accumulate', + 'back in the caller', + ); + deepEq( + shallower.slice(0, 2).map((frame) => methodOf(frame)), + before.slice(0, 2).map((frame) => methodOf(frame)), + 'and the whole visible chain is what it was before the excursion', + ); + eq( + variableNamed(await localsOf(session, requireAt(shallower, 0, 'the frame').id), 'index') + .value, + '1', + 'with the caller own loop state still readable, still on the first pass', + ); + eq(recorder.stops().length, 3, 'three stops: the breakpoint, the step in, the step out'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 're-reading the stack after each step'); + // Interaction 4 - three stack reads, one per stop, each its own round trip. + // A cached stack would show as fewer requests than stops. + eq(recorder.requests('stackTrace').length >= 3, true, 'the stack was re-read after each step'); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'each read answered', + ); + eq(recorder.requests('stepIn').length >= 1, true, 'the step into really reached the adapter'); + eq(recorder.requests('stepOut').length >= 1, true, 'and so did the step out'); + eq(recorder.stops().length, 3, 'with exactly three stops behind them'); + }); + + // Implements [DEBUG-FEATURES-STACK] "Call stack display | stackTrace | P1" + // read TWICE. A stopped process is not moving, so two reads must agree; a + // stack that differs between reads means the adapter is answering from + // something other than the process. + test('reading the same stopped stack twice answers identically, frame for frame', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — one deep stop. + armBreakpoints(fixture, 'add-body'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the breakpoint inside Add'); + const first = await stackFrames(session, stop.threadId); + eq(first.length >= 3, true, 'the stop is at least three user frames deep'); + + // Interaction 2 — read it again. Names, lines and sources must match. + const second = await stackFrames(session, stop.threadId); + eq(second.length, first.length, 'the same stopped stack has the same depth on both reads'); + deepEq( + second.map((frame) => methodOf(frame)), + first.map((frame) => methodOf(frame)), + 'and the same frames, in the same order', + ); + deepEq( + second.map((frame) => frame.line), + first.map((frame) => frame.line), + 'each parked on the same line', + ); + deepEq( + second.map((frame) => comparablePath(frame.sourcePath)), + first.map((frame) => comparablePath(frame.sourcePath)), + 'and attributed to the same source', + ); + + // Interaction 3 — and the frames of the second read are usable: their + // locals must read the same values as the first read. + const firstTop = requireAt(first, 0, 'the first read innermost frame'); + const secondTop = requireAt(second, 0, 'the second read innermost frame'); + deepEq( + (await localsOf(session, secondTop.id)).map((local) => local.name + '=' + local.value), + (await localsOf(session, firstTop.id)).map((local) => local.name + '=' + local.value), + 'and reading either handle gives the same locals', + ); + const threads = await threadsOf(session); + eq(threads.length >= 1, true, 'the stopped process reports its threads'); + eq( + threads.some((thread) => Number(thread['id']) === stop.threadId), + true, + 'including the one that stopped, which is what the panel groups the stack under', + ); + eq( + threads.every((thread) => String(thread['name'] ?? '') !== ''), + true, + 'and every thread is named, or the Call Stack panel shows an unlabelled group', + ); + assertCleanSession(debuggee(), 'reading a stopped stack twice'); + // Interaction 4 - two reads of one stopped stack are two REQUESTS, and both + // were answered. A cached second read would show as one. + eq(recorder.requests('stackTrace').length >= 2, true, 'the stack really was read twice'); + eq(recorder.responses('stackTrace').length >= 2, true, 'and answered twice'); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'both successfully', + ); + eq(recorder.stops().length, 1, 'from the one stop'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-evaluate-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-evaluate-e2e.test.ts index 475ea812..b5be4efd 100644 --- a/src/editors/vscode/src/test/suite/debug-evaluate-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-evaluate-e2e.test.ts @@ -21,6 +21,7 @@ import { evaluate, localsOf, localsScopeOf, + stackFrames, topFrame, variableNamed, } from './debug-drive-kit'; @@ -32,7 +33,7 @@ import { startDebuggee, useDebuggee, } from './debug-suite-kit'; -import { deepEq, eq } from './test-helpers'; +import { deepEq, eq, neq, requireAt } from './test-helpers'; import { DEBUG_TEST_MS } from './test-timeouts'; /** T1 expressions: field access, arithmetic, casts, null checks. All "Works". */ @@ -107,6 +108,26 @@ suite('Debug evaluation — hover, watch, REPL, setVariable and DebuggerDisplay' `return the rendered string; got '${called.value}'`, ); assertCleanSession(debuggee(), 'evaluating expressions'); + // Interaction 4 - the three contexts are one FEATURE. An expression that + // answers in the Watch panel and not on hover teaches the user to distrust + // the hover, which is the surface they reach for first. + eq( + recorder.capabilities()['supportsEvaluateForHovers'], + true, + 'hover evaluation is advertised', + ); + eq( + recorder.requests('evaluate').length >= TIER_ONE.length, + true, + 'every expression really reached the adapter', + ); + eq( + recorder.responses('evaluate').every((response) => response.success), + true, + 'and every one of them was answered', + ); + eq(recorder.stops().length, 1, 'evaluating never resumed or re-stopped the debuggee'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-FEATURES-VARIABLES] "Modify variable value at runtime | @@ -171,6 +192,21 @@ suite('Debug evaluation — hover, watch, REPL, setVariable and DebuggerDisplay' ); await assertRanToCompletion(recorder, 0, 'a session whose local was edited'); assertCleanSession(debuggee(), 'editing a variable at runtime'); + // Interaction 4 - a write is a change to the RUNNING program, so the + // adapter must have been asked to make it, and the session must survive. + eq( + recorder.requests('setVariable').length >= 1, + true, + 'setVariable really reached the adapter', + ); + eq( + recorder.capabilities()['supportsSetVariable'], + true, + 'which is why the panel offers the edit at all', + ); + eq(recorder.stops().length >= 1, true, 'the debuggee was paused for the write'); + eq(recorder.events('terminated').length <= 1, true, 'and the session ended at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-FEATURES-VARIABLES] "[DebuggerDisplay] attribute rendering @@ -231,5 +267,409 @@ suite('Debug evaluation — hover, watch, REPL, setVariable and DebuggerDisplay' 'a refused expression must leave the session alive and the debuggee still paused', ); assertCleanSession(debuggee(), 'DebuggerDisplay rendering'); + // Interaction 4 - `[DebuggerDisplay]` is a Phase 4 EMULATION: the DapRouter + // asks the C# sidecar to evaluate the format and replaces the default + // `toString()`. A failure falls back to the raw class name, never to a + // broken session. + eq( + recorder.requests('variables').length >= 1, + true, + 'the Variables panel really asked the adapter', + ); + eq( + recorder.responses('variables').every((response) => response.success), + true, + 'and every read was answered', + ); + eq(recorder.stops().length, 1, 'reading variables never resumes the debuggee'); + eq(recorder.events('terminated').length <= 1, true, 'and the session ends at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements the T2 row of the evaluation tiers table — "Method calls on + // locals | Works | Works" — which Phase 4 must already serve. A watch that + // can read a field but not call a method covers half of real debugging. + test('T2 method calls on locals evaluate in every evaluation context', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop where an object, a string and a collection are all + // in scope, so there is something to call a method ON. + armBreakpoints(fixture, 'inspect-print'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the print statement'); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'inspect-print', 'Inspect', 'the T2 evaluation frame'); + const locals = await localsOf(session, frame.id); + eq( + locals.map((local) => local.name).includes('box'), + true, + 'the frame really does hold the object whose method the watch will call', + ); + + // Interaction 2 — a method call on a local, in each of the three contexts + // the specification names. Answering in one and not another is worse than + // failing everywhere: the user cannot tell which panel to trust. + const calls: readonly { expression: string; expected: string }[] = [ + { expression: 'box.Describe()', expected: 'boxed=8' }, + { expression: 'numbers.Contains(20)', expected: 'true' }, + { expression: 'text.Length', expected: '7' }, + ]; + for (const { expression, expected } of calls) { + const watch = await evaluate(session, expression, frame.id, 'watch'); + eq( + watch.value.includes(expected), + true, + expression + + ' is a T2 "method calls on locals" expression, marked Works for Phase 4; ' + + 'the Watch panel answered ' + + JSON.stringify(watch.value), + ); + const repl = await evaluate(session, expression, frame.id, 'repl'); + eq( + repl.value, + watch.value, + expression + ': the Debug Console must agree with the Watch panel over one frame', + ); + const hover = await evaluate(session, expression, frame.id, 'hover'); + eq( + hover.value, + watch.value, + expression + + ': and so must a hover - three answers for one expression is a bug the ' + + 'user reads as their own code misbehaving', + ); + } + + // Interaction 3 — a call with an argument computed from another local, and + // a chained call. Both are still T2, and both must survive the round trip. + eq( + (await evaluate(session, 'numbers.Contains(numbers.Count * 10)', frame.id, 'watch')).value, + 'true', + 'an argument computed from another local is still a T2 method call', + ); + eq( + (await evaluate(session, 'box.Describe().Length', frame.id, 'watch')).value, + '7', + 'and so is a call chained onto the result of a call', + ); + eq( + (await evaluate(session, 'box.Label.ToUpper()', frame.id, 'watch')).value.includes('BOXED'), + true, + 'including a method on a property of a local', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session driven only by watch evaluations'); + eq( + recorder.stops().length, + 1, + 'evaluating an expression must never resume or re-stop the debuggee', + ); + assertCleanSession(debuggee(), 'T2 method-call evaluation'); + // Interaction 4 - the whole T2 sweep happened inside ONE paused frame, and + // the frame is still readable at the end of it. + eq( + recorder.requests('evaluate').length >= 9, + true, + 'three expressions in three contexts is nine round trips', + ); + eq( + recorder.responses('evaluate').filter((response) => response.success).length >= 9, + true, + 'every one of them answered successfully', + ); + eq( + recorder.requests('stackTrace').length >= 1, + true, + 'the frame was resolved before anything was evaluated in it', + ); + eq(recorder.events('terminated').length <= 1, true, 'and the session ended at most once'); + deepEq(recorder.exits, [], 'with the adapter process still alive throughout'); + }); + + // Implements the T3 rows of the tiers table, which Phase 4 marks "Fails". + // Failing is specified; failing LOUDLY, and leaving the session alive, is the + // part that matters — an evaluation that kills the adapter loses the user + // their whole session because they typed a LINQ query into the Watch panel. + test('an expression Phase 4 cannot evaluate is refused without losing the session', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a real stop, and a real frame to evaluate against. + armBreakpoints(fixture, 'inspect-print'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the print statement'); + const frame = await topFrame(session, stop.threadId); + const before = await localsOf(session, frame.id); + eq(before.length >= 3, true, 'the frame holds the locals the T1 assertions rest on'); + + // Interaction 2 — expressions that cannot resolve. Each must come back as a + // FAILED response, not as a thrown transport error and not as a plausible + // wrong answer. + const unresolvable = [ + 'thisIdentifierDoesNotExist', + 'box.NoSuchMember', + 'numbers[999]', + '1 +', + '', + ]; + for (const expression of unresolvable) { + const answer = await tryDap(session, 'evaluate', { + expression, + frameId: frame.id, + context: 'watch', + }); + neq( + answer.failure, + '', + JSON.stringify(expression) + + ' cannot be evaluated, so the adapter must REFUSE it, ' + + 'with a reason the Watch panel can show - a successful response here is a wrong ' + + 'answer the user will act on', + ); + deepEq( + answer.body, + {}, + JSON.stringify(expression) + ': a refused evaluation carries no value at all', + ); + } + + // Interaction 3 — the session must be entirely unharmed: same frame, same + // locals, still drivable, and every T1 expression still answers. + deepEq(recorder.errors, [], 'a refused evaluation is not an adapter transport failure'); + const after = await localsOf(session, frame.id); + deepEq( + after.map((local) => local.name), + before.map((local) => local.name), + 'the frame locals are exactly as they were before the refusals', + ); + eq( + (await evaluate(session, 'box.Value', frame.id, 'watch')).value, + '8', + 'and a valid expression still evaluates afterwards', + ); + eq(recorder.stops().length, 1, 'no refusal resumed or re-stopped the debuggee'); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session that refused five expressions'); + assertCleanSession(debuggee(), 'refused evaluations'); + // Interaction 4 - a refusal is a RESPONSE, not a transport failure. The + // distinction is what keeps the session alive after a typo in the Watch + // panel. + eq( + recorder.requests('evaluate').length >= 5, + true, + 'every refused expression really reached the adapter', + ); + eq( + recorder.responses('evaluate').some((response) => !response.success), + true, + 'and at least one came back as a FAILED response', + ); + eq(recorder.events('terminated').length <= 1, true, 'the session ended at most once'); + deepEq(recorder.exits, [], 'and the adapter process never exited under it'); + eq(recorder.stops().length, 1, 'with the debuggee still parked where it was'); + }); + + // Implements [DEBUG-FEATURES-VARIABLES] "Watch window evaluation | P1" + // together with [DEBUG-FEATURES-STACK]: an expression is evaluated in the + // frame the user SELECTED. Evaluating everything in the top frame is the bug + // that makes the Watch panel useless the moment you click a caller. + test('a watch expression is evaluated in the frame the user selected', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop three user frames deep, so there are three frames + // with three different sets of locals. + armBreakpoints(fixture, 'add-body'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the helper body'); + const frames = await stackFrames(session, stop.threadId); + eq(frames.length >= 3, true, 'the stop is three user frames deep'); + const callee = requireAt(frames, 0, 'the innermost frame'); + const caller = requireAt(frames, 1, 'the calling frame'); + neq(callee.id, caller.id, 'the two frames carry different handles'); + + // Interaction 2 — `left` exists only in the callee; `running` only in the + // caller. Each must resolve in its own frame and be REFUSED in the other. + eq( + (await evaluate(session, 'left', callee.id, 'watch')).value, + '2', + 'the callee own argument resolves in the callee frame', + ); + eq( + (await evaluate(session, 'running', caller.id, 'watch')).value, + '2', + 'and the caller own local resolves in the CALLER frame', + ); + neq( + ( + await tryDap(session, 'evaluate', { + expression: 'left', + frameId: caller.id, + context: 'watch', + }) + ).failure, + '', + '`left` is not in scope in the caller, so evaluating it there must be refused rather ' + + 'than answered from the callee frame', + ); + neq( + ( + await tryDap(session, 'evaluate', { + expression: 'index', + frameId: callee.id, + context: 'watch', + }) + ).failure, + '', + 'and the caller loop variable is not in scope in the callee', + ); + + // Interaction 3 — arithmetic over each frame own locals, and the answers + // must differ. Two frames that answer the same thing is the symptom. + const calleeSum = (await evaluate(session, 'left + right', callee.id, 'watch')).value; + const callerSum = (await evaluate(session, 'running + index', caller.id, 'watch')).value; + eq(calleeSum, '3', 'the callee arguments sum to what it was called with'); + eq(callerSum, '3', 'the caller own locals sum in the caller frame'); + eq( + (await evaluate(session, 'running', caller.id, 'hover')).value, + '2', + 'a HOVER in the selected frame reads that frame too - the hover and the panel are one ' + + 'feature as far as the user is concerned', + ); + deepEq( + (await localsOf(session, caller.id)).map((local) => local.name).includes('left'), + false, + 'and the caller locals really do not contain the callee argument', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 'per-frame watch evaluation'); + // Interaction 4 - per-frame evaluation is what makes the Call Stack panel + // useful. Every read here addressed a specific frame id, and the adapter + // answered each on its own terms. + eq(recorder.requests('scopes').length >= 1, true, 'scopes were read for a specific frame'); + eq( + recorder.requests('evaluate').length >= 6, + true, + 'and several expressions evaluated against frame ids', + ); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'every stack read was answered', + ); + eq(recorder.stops().length, 1, 'without ever resuming the debuggee'); + deepEq(recorder.errors, [], 'and with no adapter transport error'); + }); + + // Implements [DEBUG-FEATURES-VARIABLES] "Modify variable value at runtime | + // setVariable | P1" at its BOUNDARIES. Editing a value is the one debugger + // gesture that changes the program, so what it REFUSES matters as much as + // what it accepts: a silent no-op leaves the user believing they changed + // something, and a wrong write corrupts the run they were diagnosing. + test('setVariable accepts what it can write and refuses what it cannot', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a frame with a writable local, and the capability that + // makes the panel offer editing at all. + armBreakpoints(fixture, 'accumulate-call'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the loop body'); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'accumulate-call', 'Accumulate', 'the edit frame'); + eq( + recorder.capabilities()['supportsSetVariable'], + true, + 'supportsSetVariable is a Phase 4 Yes; unadvertised, the Variables panel offers no edit ' + + 'affordance at all', + ); + const scope = await localsScopeOf(session, frame.id); + neq(scope.reference, 0, 'the locals scope carries the handle setVariable writes through'); + + // Interaction 2 — a legal write. The panel must report the NEW value, and + // so must a watch over the same name. + const written = await dap(session, 'setVariable', { + variablesReference: scope.reference, + name: 'running', + value: '100', + }); + eq(String(written['value'] ?? ''), '100', 'setVariable answers with the value it wrote'); + eq( + variableNamed(await localsOf(session, frame.id), 'running').value, + '100', + 'and the Variables panel reads the new value back', + ); + eq( + (await evaluate(session, 'running', frame.id, 'watch')).value, + '100', + 'as does a watch over the same local - two answers here is the panel and the watch ' + + 'disagreeing about the state of the program', + ); + eq( + (await evaluate(session, 'running + index', frame.id, 'watch')).value, + '101', + 'and an expression built on it uses the value the user wrote', + ); + + // Interaction 3 — the refusals. A name that is not in scope, a value of the + // wrong type and a bad handle must each come back as a failure, and the + // session must survive all three. + const refusals: readonly { name: string; value: string; why: string }[] = [ + { name: 'noSuchLocal', value: '1', why: 'a name that is not in scope' }, + { name: 'running', value: 'not-an-int', why: 'a value of the wrong type' }, + { name: '', value: '1', why: 'an empty name' }, + ]; + for (const { name, value, why } of refusals) { + const outcome = await tryDap(session, 'setVariable', { + variablesReference: scope.reference, + name, + value, + }); + neq(outcome.failure, '', why + ' must be REFUSED, with a reason the panel can show'); + } + neq( + ( + await tryDap(session, 'setVariable', { + variablesReference: 0, + name: 'running', + value: '1', + }) + ).failure, + '', + 'and a zero variables handle addresses nothing, so it must be refused too', + ); + eq( + variableNamed(await localsOf(session, frame.id), 'running').value, + '100', + 'not one refusal may have changed the value the user did write', + ); + eq(recorder.stops().length, 1, 'and no refusal resumed or re-stopped the debuggee'); + deepEq(recorder.errors, [], 'a refused write is not an adapter transport failure'); + await vscode.commands.executeCommand(CMD_CONTINUE); + assertCleanSession(debuggee(), 'setVariable at its boundaries'); + // Interaction 4 - and the write survived every refusal that followed it, + // which is the whole claim: a refused edit changes nothing at all. + eq( + recorder.requests('setVariable').length >= 4, + true, + 'one accepted write and three refusals reached the adapter', + ); + eq( + recorder.responses('setVariable').some((response) => response.success), + true, + 'at least one succeeded', + ); + eq( + recorder.responses('setVariable').some((response) => !response.success), + true, + 'and at least one was refused', + ); + eq(recorder.events('terminated').length <= 1, true, 'the session ended at most once'); + deepEq(recorder.exits, [], 'with the adapter process alive throughout'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-exception-filters-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-exception-filters-e2e.test.ts index 3decc7f9..fc6da53e 100644 --- a/src/editors/vscode/src/test/suite/debug-exception-filters-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-exception-filters-e2e.test.ts @@ -13,6 +13,7 @@ // types, and both the hit and the miss are asserted. import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; +import { filterOptionsFrom } from '../../dap-exceptions.js'; import { dap } from './debug-dap-kit'; import { CAUGHT_MESSAGE, @@ -36,7 +37,7 @@ import { startDebuggee, useDebuggee, } from './debug-suite-kit'; -import { deepEq, eq } from './test-helpers'; +import { deepEq, eq, neq } from './test-helpers'; import { DEBUG_TEST_MS } from './test-timeouts'; /** A type the fixture never throws — the exclude half of every filter case. */ @@ -117,6 +118,7 @@ suite('Debug exceptions — per-type include and exclude filters', () => { armBreakpoints(fixture, 'main-mode'); const session = await startDebuggee(debuggee(), { mode: MODE.caught }); await recorder.waitForStops(1); + const beforeFilter = recorder.requests('setExceptionBreakpoints').length; await dap(session, 'setExceptionBreakpoints', onlyType(NEVER_THROWN_TYPE)); const baseline = recorder.stops().length; @@ -136,6 +138,40 @@ suite('Debug exceptions — per-type include and exclude filters', () => { 'on all", which [DEBUG-FEATURES-EXCEPTIONS] lists as a SEPARATE row', ); await recorder.waitForOutput('done caught 45'); + + // Interaction 3 — the program really RAN. A filter that silences a stop by + // killing the session would satisfy "no stops" while proving the opposite + // of what this test is about. + eq(recorder.stops().length, baseline, 'no stop was added by the excluded type'); + assert.ok( + recorder.outputText().includes(`handled ${CAUGHT_MESSAGE}`), + 'the debuggee handled the exception itself, which is why the filter had to ignore it', + ); + assert.ok(recorder.outputText().includes('done caught 45'), 'and ran through to its own end'); + + // Interaction 4 — the filter that WAS set is the one that was asked for. + // A `setExceptionBreakpoints` whose type list is dropped on the way to the + // adapter produces exactly this test's passing result for the wrong reason: + // no filter at all also breaks on nothing. + const sent = await recorder.requestAfter('setExceptionBreakpoints', beforeFilter); + assert.ok( + Array.isArray(sent.args['exceptionOptions']), + 'the request carries the per-type selection VS Code spells as exceptionOptions', + ); + // The router rewrites that selection into the `filterOptions` netcoredbg + // applies; the translation decides which type the adapter is told about. + const applied = filterOptionsFrom(sent.args['exceptionOptions']); + eq(applied.length, 1, 'naming exactly one filter'); + assert.ok( + JSON.stringify(applied).includes(NEVER_THROWN_TYPE), + `the request names ${NEVER_THROWN_TYPE}`, + ); + eq( + JSON.stringify(applied).includes(CAUGHT_TYPE), + false, + `and must not name ${CAUGHT_TYPE}, which the program does throw`, + ); + assertCleanSession(debuggee(), 'an exclude-type exception filter'); }); @@ -196,4 +232,146 @@ suite('Debug exceptions — per-type include and exclude filters', () => { ); assertCleanSession(debuggee(), 'a mid-session exception filter change'); }); + + // Implements [DEBUG-FEATURES-EXCEPTIONS] "Break on specific exception types + // (include/exclude filter) | P1" with MORE THAN ONE type selected. A filter + // list that only ever honours its first entry passes every single-type test + // and fails the user the moment they tick a second box. + test('two selected types both break, and a third still does not', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — gate before any throw, in the mode that throws BOTH types. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.both }); + const [gate] = await recorder.waitForStops(1); + assert.ok(gate, 'the debuggee must reach the gate breakpoint'); + neq(CAUGHT_TYPE, UNHANDLED_TYPE, 'the fixture really throws two DIFFERENT types'); + + // Interaction 2 — select both thrown types AND one that is never thrown. + const before = recorder.requests('setExceptionBreakpoints').length; + await dap(session, 'setExceptionBreakpoints', { + filters: [], + exceptionOptions: [CAUGHT_TYPE, UNHANDLED_TYPE, NEVER_THROWN_TYPE].map((typeName) => ({ + path: [{ names: [typeName], negate: false }], + breakMode: 'always', + })), + }); + const sent = await recorder.requestAfter('setExceptionBreakpoints', before); + deepEq(sent.args['filters'], [], 'the blanket filters stay OFF'); + const options: unknown = sent.args['exceptionOptions']; + assert.ok(Array.isArray(options), 'the request carries exceptionOptions'); + eq(options.length, 3, 'all three selections are sent, not just the first'); + + // Interaction 3 — the FIRST selected type stops. + const first = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(first.stop, 'exception', 'the first selected type'); + assertStoppedAt( + first.frame, + fixture, + 'throw-caught', + 'ThrowCaught', + 'a list naming ' + CAUGHT_TYPE + ' must stop on the statement that throws it', + ); + assertExceptionIs( + await exceptionInfoOf(session, first.stop.threadId), + CAUGHT_TYPE, + CAUGHT_MESSAGE, + 'the first selected type', + ); + + // Interaction 4 — and so does the SECOND, which is the half a first-entry + // implementation gets wrong. + const second = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(second.stop, 'exception', 'the second selected type'); + assertStoppedAt( + second.frame, + fixture, + 'throw-unhandled', + 'ThrowUnhandled', + 'the SECOND entry of the type list must arm too - honouring only the first is how a ' + + 'multi-type filter silently degrades to a single-type one', + ); + assertExceptionIs( + await exceptionInfoOf(session, second.stop.threadId), + UNHANDLED_TYPE, + UNHANDLED_MESSAGE, + 'the second selected type', + ); + eq( + recorder.stops().filter((stop) => stop.reason === 'exception').length, + 2, + 'exactly two exception stops: the fixture throws each selected type once, and the ' + + 'third selection is never thrown at all', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements the EXCLUDE half of the same row through the DAP mechanism the + // section names: a `negate: true` path. "Break on everything except X" is the + // shape a user reaches for when one noisy exception type is drowning a run. + test('a negated type path breaks on everything except the type it names', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — gate, in the mode that throws both types. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.both }); + await recorder.waitForStops(1); + eq( + recorder.capabilities()['supportsExceptionOptions'], + true, + 'a negated path travels in `exceptionOptions`, so the capability must be advertised', + ); + + // Interaction 2 — exclude the type the program throws FIRST. + const before = recorder.requests('setExceptionBreakpoints').length; + await dap(session, 'setExceptionBreakpoints', { + filters: [], + exceptionOptions: [{ path: [{ names: [CAUGHT_TYPE], negate: true }], breakMode: 'always' }], + }); + const sent = await recorder.requestAfter('setExceptionBreakpoints', before); + const options: unknown = sent.args['exceptionOptions']; + assert.ok(Array.isArray(options), 'the request carries exceptionOptions'); + eq(options.length, 1, 'one option, carrying one negated path'); + const path: unknown = (options[0] as Record)['path']; + assert.ok(Array.isArray(path), 'the option carries a path'); + eq( + (path[0] as Record)['negate'], + true, + 'and the negate flag reaches the adapter - dropped, the filter INCLUDES what the user ' + + 'asked to exclude, which is the exact opposite of the gesture', + ); + + // Interaction 3 — continue. The excluded type must pass straight through, + // and the run must reach the type that is NOT excluded. + const stop = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(stop.stop, 'exception', 'the type the negated path does not exclude'); + assertStoppedAt( + stop.frame, + fixture, + 'throw-unhandled', + 'ThrowUnhandled', + 'excluding ' + + CAUGHT_TYPE + + ' must skip its throw entirely and come to rest on the ' + + 'NEXT throw, which the exclusion does not name', + ); + assertExceptionIs( + await exceptionInfoOf(session, stop.stop.threadId), + UNHANDLED_TYPE, + UNHANDLED_MESSAGE, + 'the exception a negated path let through', + ); + eq( + recorder.stops().filter((entry) => entry.reason === 'exception').length, + 1, + 'exactly ONE exception stop: the excluded throw produced none', + ); + eq( + recorder.outputText().includes('handled ' + CAUGHT_MESSAGE), + true, + 'and the excluded exception really was thrown and handled on the way past', + ); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-exceptions-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-exceptions-e2e.test.ts index 5bdf7766..8d81c863 100644 --- a/src/editors/vscode/src/test/suite/debug-exceptions-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-exceptions-e2e.test.ts @@ -41,7 +41,7 @@ import { startDebuggee, useDebuggee, } from './debug-suite-kit'; -import { deepEq, eq, requireAt } from './test-helpers'; +import { deepEq, eq, neq, requireAt } from './test-helpers'; import { DEBUG_TEST_MS } from './test-timeouts'; /** The filter id every DAP adapter uses for "break on every throw". */ @@ -247,6 +247,32 @@ suite('Debug exceptions — breaking on them, and ignoring them', () => { 'that uses exceptions for control flow', ); await recorder.waitForOutput('done caught 45'); + + // Interaction 4 - the program RAN, and ran to its own end. "No stops" is + // also what a session that died on launch produces, so the negative above + // only means something beside the positive evidence that the debuggee got + // all the way through the catch block and past it. + assert.ok( + recorder.outputText().includes(`handled ${CAUGHT_MESSAGE}`), + 'the debuggee caught the exception itself', + ); + assert.ok(recorder.outputText().includes('done caught 45'), 'and finished its own work'); + eq(recorder.stops().length, baseline, 'with no stop added after the gate'); + + // Interaction 5 - the SELECTION is still the one that was asked for. A + // later `setExceptionBreakpoints` that quietly re-adds `all` would produce + // this test's result only until the next continue, and nothing else can see + // it ([DEBUG-FEATURES-EXCEPTIONS]). + const requests = recorder.requests('setExceptionBreakpoints'); + const last = requests[requests.length - 1]; + assert.ok(last, 'at least one setExceptionBreakpoints reached the adapter'); + deepEq(last.args['filters'], [unhandled], 'and the last one still names only that filter'); + eq( + JSON.stringify(last.args['filters']).includes('"all"'), + false, + 'with `all` never smuggled back in alongside it', + ); + assertCleanSession(debuggee(), 'ignoring a handled exception'); }); @@ -310,4 +336,185 @@ suite('Debug exceptions — breaking on them, and ignoring them', () => { ); assertCleanSession(debuggee(), 'an unhandled exception stop'); }); + + // Implements [DEBUG-FEATURES-EXCEPTIONS] "Exception info panel (type, + // message, stack) | P1" and "Inner exception chain traversal | P2" together + // with [DEBUG-FEATURES-STACK]: the panel is only useful if the STACK behind + // the exception is the user own, at the throw site. + test('an exception stop carries the throwing frame and the whole user stack', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — break on every throw, in the mode that throws twice. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.both }); + await recorder.waitForStops(1); + await dap(session, 'setExceptionBreakpoints', { filters: [FILTER_ALL] }); + eq( + advertisedFilters(recorder.capabilities()).includes(FILTER_ALL), + true, + 'the "all exceptions" checkbox must be offered before it can be ticked', + ); + + // Interaction 2 — the first throw. The panel fields, and the frame the + // throw happened in. + const first = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(first.stop, 'exception', 'the first throw'); + assertStoppedAt( + first.frame, + fixture, + 'throw-caught', + 'ThrowCaught', + 'an exception stop must park on the THROW, not on the catch that follows it', + ); + const info = await exceptionInfoOf(session, first.stop.threadId); + assertExceptionIs(info, CAUGHT_TYPE, CAUGHT_MESSAGE, 'the first throw'); + neq(info.description, '', 'the panel needs a description to render'); + const frames = await stackFrames(session, first.stop.threadId); + eq(frames.length >= 2, true, 'the throwing method was called from somewhere'); + eq( + methodOf(requireAt(frames, 0, 'the throwing frame')), + 'ThrowCaught', + 'the innermost frame is the method that threw', + ); + eq( + frames.map((frame) => methodOf(frame)).includes('Main'), + true, + 'and the caller chain up to Main is intact, which is how the user finds the cause', + ); + + // Interaction 3 — the SECOND throw carries its own type, its own message + // and its own inner cause. Reporting the first exception again is the + // failure a single-throw fixture cannot see. + const second = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(second.stop, 'exception', 'the second throw'); + assertStoppedAt( + second.frame, + fixture, + 'throw-unhandled', + 'ThrowUnhandled', + 'the second throw parks on its own statement', + ); + const secondInfo = await exceptionInfoOf(session, second.stop.threadId); + assertExceptionIs(secondInfo, UNHANDLED_TYPE, UNHANDLED_MESSAGE, 'the second throw'); + neq( + secondInfo.exceptionId, + info.exceptionId, + 'the two throws are different exceptions and must report different ids', + ); + eq( + secondInfo.description.includes(INNER_MESSAGE) || + secondInfo.description.includes(UNHANDLED_MESSAGE), + true, + '"Inner exception chain traversal" is a specified row: the panel must carry the cause, ' + + 'or the user sees a wrapper and never the real failure', + ); + eq( + recorder.stops().filter((entry) => entry.reason === 'exception').length, + 2, + 'exactly two exception stops for two throws', + ); + }); + + // Implements [DEBUG-FEATURES-EXCEPTIONS] with the reactivity every screen in + // this project owes: unticking "All Exceptions" mid-session must reach the + // LIVE adapter, not wait for the next launch - and no filter can silence an + // UNHANDLED throw, because there is nothing after it to continue to. + test('unticking every exception filter mid-session reaches the adapter, and only the crash still stops', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — break on all, and prove it by catching the first throw. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.both }); + await recorder.waitForStops(1); + await dap(session, 'setExceptionBreakpoints', { filters: [FILTER_ALL] }); + const caught = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(caught.stop, 'exception', 'the first throw with the filter on'); + assertStoppedAt(caught.frame, fixture, 'throw-caught', 'ThrowCaught', 'the first throw'); + + // Interaction 2 — untick everything WHILE paused, and prove the change + // reached the adapter rather than being stored for the next launch. + const before = recorder.requests('setExceptionBreakpoints').length; + await dap(session, 'setExceptionBreakpoints', { filters: [] }); + const sent = await recorder.requestAfter('setExceptionBreakpoints', before); + deepEq(sent.args['filters'], [], 'an empty filter list must be pushed to the LIVE adapter'); + eq( + recorder.requests('setExceptionBreakpoints').length > before, + true, + 'and it must be sent, not merely remembered', + ); + + // Interaction 3 — continue. The handled throw is behind us and runs to its + // catch; the UNHANDLED throw that follows stops whatever the filters say, + // and it must be the ONLY further stop. + const exceptionsSoFar = recorder.stops().filter((entry) => entry.reason === 'exception').length; + const crash = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(crash.stop, 'exception', 'the crash after every filter was unticked'); + assertStoppedAt( + crash.frame, + fixture, + 'throw-unhandled', + 'ThrowUnhandled', + 'the only stop after unticking is the unhandled throw itself', + ); + eq( + recorder.stops().filter((entry) => entry.reason === 'exception').length, + exceptionsSoFar + 1, + 'nothing but the crash may stop the debuggee once every filter is unticked', + ); + eq( + recorder.outputText().includes('handled ' + CAUGHT_MESSAGE), + true, + 'and the program really did carry on running past the handled throw', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements [DEBUG-FEATURES-EXCEPTIONS] "Break on all CLR exceptions" as the + // NEGATIVE the section is really about: a program that throws nothing must + // run to completion with the filter fully armed. A debugger that stops + // anyway has made every clean run unusable. + test('with every filter armed, a program that throws nothing still runs clean', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — arm every advertised filter at once. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + await recorder.waitForStops(1); + const filters = advertisedFilters(recorder.capabilities()); + eq(filters.length >= 2, true, 'at least an all filter and an unhandled-only one are offered'); + eq(filters.includes(FILTER_ALL), true, 'including "break on all"'); + eq( + filters.some((filter) => UNHANDLED_FILTERS.includes(filter)), + true, + 'and an unhandled-only filter under one of the names adapters use', + ); + + // Interaction 2 — send them all, and check the request really carried them. + const before = recorder.requests('setExceptionBreakpoints').length; + await dap(session, 'setExceptionBreakpoints', { filters }); + const sent = await recorder.requestAfter('setExceptionBreakpoints', before); + deepEq(sent.args['filters'], filters, 'every advertised filter is armed at once'); + + // Interaction 3 — the clean run must stay clean. + const stopsBefore = recorder.stops().length; + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a clean run with every exception filter armed'); + await recorder.waitForOutput('done plain 45'); + eq( + recorder.stops().length, + stopsBefore, + 'a program that throws nothing must not stop, however many filters are ticked - the CLR ' + + 'throws internally during startup and JIT, and surfacing those is what makes "break ' + + 'on all exceptions" unusable in practice', + ); + deepEq( + recorder.stops().filter((entry) => entry.reason === 'exception'), + [], + 'and not one exception stop in the whole session', + ); + assertCleanSession(debuggee(), 'a clean run with every filter armed'); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-fsharp-inspection-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-fsharp-inspection-e2e.test.ts index 65c6add0..eff405e4 100644 --- a/src/editors/vscode/src/test/suite/debug-fsharp-inspection-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-fsharp-inspection-e2e.test.ts @@ -29,7 +29,7 @@ import { type Variable, } from './debug-drive-kit'; import { armBreakpoints, assertCleanSession, startDebuggee, useDebuggee } from './debug-suite-kit'; -import { deepEq, eq } from './test-helpers'; +import { deepEq, eq, neq } from './test-helpers'; import { DEBUG_TEST_MS } from './test-timeouts'; /** CLR spellings [DEBUG-FSHARP-UNIONS] names as the wrong answer. */ @@ -106,6 +106,21 @@ suite('Debug F# — unions, records, tuples and task {} stacks', () => { .join(', ')}`, ); assertCleanSession(debuggee(), 'inspecting an F# union'); + // Interaction 4 - F# rendering is a REWRITE of the `variables` response, so + // the response has to have happened and been answered. + eq(recorder.requests('variables').length >= 1, true, 'the panel really read the F# frame'); + eq( + recorder.responses('variables').every((response) => response.success), + true, + 'and every read was answered', + ); + eq( + recorder.capabilities()['supportsVariableType'], + true, + 'with the type column advertised for F# too', + ); + eq(recorder.stops().length, 1, 'reading variables never resumes an F# debuggee either'); + deepEq(recorder.errors, [], 'and with no adapter transport error'); }); // Implements the "F# record/tuple inspection | variables | P1" row. @@ -176,6 +191,21 @@ suite('Debug F# — unions, records, tuples and task {} stacks', () => { '[DEBUG-FSHARP-EVALUATION] puts F# at the same T1/T2 tier as C#', ); assertCleanSession(debuggee(), 'inspecting F# records, tuples and lists'); + // Interaction 4 - records, tuples and lists are all EXPANDABLE, which means + // more than one `variables` round trip against nested handles. + eq( + recorder.requests('variables').length >= 2, + true, + 'the panel expanded at least one F# value', + ); + eq(recorder.requests('scopes').length >= 1, true, 'after resolving the frame scopes'); + eq( + recorder.responses('scopes').every((response) => response.success), + true, + 'each answered successfully', + ); + eq(recorder.stops().length, 1, 'with the debuggee paused throughout'); + deepEq(recorder.errors, [], 'and no adapter transport error'); }); // Implements [DEBUG-FSHARP-STEPPING] and [DEBUG-FEATURES-STACK-ASYNC] for F#. @@ -224,6 +254,17 @@ suite('Debug F# — unions, records, tuples and task {} stacks', () => { ); await vscode.commands.executeCommand(CMD_CONTINUE); assertCleanSession(debuggee(), 'an F# task stack'); + // Interaction 4 - an F# `task {}` chain is a state machine, so the stack + // read is where the logical reconstruction has to happen. + eq(recorder.requests('stackTrace').length >= 1, true, 'the async stack was really read'); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'and answered', + ); + eq(recorder.requests('threads').length >= 1, true, 'against a thread the adapter enumerated'); + eq(recorder.events('terminated').length <= 1, true, 'the session ended at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-FSHARP-PDB]: the `StateMachineMethod` gap and its cost. @@ -280,5 +321,197 @@ suite('Debug F# — unions, records, tuples and task {} stacks', () => { ); await vscode.commands.executeCommand(CMD_CONTINUE); assertCleanSession(debuggee(), 'stepping into an F# task'); + // Interaction 4 - "ONE F11, not two" is a claim about STEP requests: two + // step requests for one gesture is the state-machine hop leaking through. + eq(recorder.requests('stepIn').length >= 1, true, 'the step into really reached the adapter'); + eq( + recorder.responses('stepIn').every((response) => response.success), + true, + 'and was answered', + ); + eq( + recorder.stops().every((entry) => entry.threadId !== 0), + true, + 'every stop named its thread', + ); + eq(recorder.events('terminated').length <= 1, true, 'the session ended at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements [DEBUG-FEATURES-VARIABLES] "F# record/tuple inspection | P1" + // one level DEEPER: a record must expand to its own fields, and a list to its + // own elements. A value that renders correctly but cannot be expanded is a + // value the user can read and not explore. + test('an F# record, tuple and list all EXPAND to their own members', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop where every F# shape the fixture builds is bound. + armBreakpoints(fixture, 'main-print'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the F# print statement'); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'main-print', 'main', 'the F# inspection frame'); + const locals = await localsOf(session, frame.id); + for (const name of ['point', 'pair', 'numbers', 'maybe', 'shape']) { + eq( + locals.map((local) => local.name).includes(name), + true, + 'the F# binding ' + name + ' must appear in the Variables panel under its own name', + ); + } + + // Interaction 2 — the RECORD expands to its declared fields, by name. + const point = variableNamed(locals, 'point'); + neq(point.reference, 0, 'an F# record must be expandable, or its fields are unreachable'); + const fields = await variablesOf(session, point.reference); + const fieldNames = fields.map((field) => field.name); + eq(fieldNames.includes('X'), true, 'the record field X is a member row'); + eq(fieldNames.includes('Y'), true, 'and so is Y'); + eq(variableNamed(fields, 'X').value, '8', 'X carries the value the program bound'); + eq(variableNamed(fields, 'Y').value, '12', 'and Y the value the match computed'); + for (const field of fields) { + eq( + RAW_CLR_FORMS.some((raw) => field.value.includes(raw)), + false, + 'the expanded field ' + field.name + ' must not leak a raw CLR shape either', + ); + } + + // Interaction 3 — the TUPLE and the LIST expand too, and an evaluation of + // the same path agrees with the expansion. + const pair = variableNamed(locals, 'pair'); + neq(pair.reference, 0, 'an F# tuple must be expandable'); + const items = await variablesOf(session, pair.reference); + eq(items.length >= 2, true, 'a two-element tuple exposes at least its two elements'); + eq( + items.some((item) => item.value.includes('8')), + true, + 'carrying the first component the program bound', + ); + eq( + items.some((item) => item.value.includes('boxed')), + true, + 'and the second', + ); + const numbers = variableNamed(locals, 'numbers'); + neq(numbers.reference, 0, 'an F# list must be expandable'); + const elements = await variablesOf(session, numbers.reference); + eq( + elements.length >= 1, + true, + 'an F# list must expose its elements - a list that shows a length and no items is the ' + + 'FSharpList`1 rendering [DEBUG-FSHARP-UNIONS] rejects', + ); + eq( + (await evaluate(session, 'point.X', frame.id, 'watch')).value, + variableNamed(fields, 'X').value, + 'and a watch over the same record field agrees with the expansion', + ); + assertCleanSession(debuggee(), 'expanding F# values'); + // Interaction 5 - the whole expansion sweep happened against ONE paused F# + // frame, and every nested read was answered. + eq( + recorder.requests('variables').length >= 3, + true, + 'a record, a tuple and a list were each expanded', + ); + eq( + recorder.responses('variables').every((response) => response.success), + true, + 'and every expansion was answered', + ); + eq( + recorder.requests('evaluate').length >= 1, + true, + 'with at least one watch cross-checking the panel', + ); + eq(recorder.stops().length, 1, 'and the debuggee paused throughout'); + deepEq(recorder.exits, [], 'with the adapter process alive'); + }); + + // Implements [DEBUG-FSHARP-EVALUATION] and the T1/T2 evaluation tiers applied + // to F# syntax. The evaluator is shared, so the question is whether F# + // EXPRESSIONS survive it - `=` is equality in F#, not assignment, and a + // record field access is a dot chain like any other. + test('F# expressions evaluate in hover, watch and the REPL alike', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a frame with every F# shape in scope. + armBreakpoints(fixture, 'main-print'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the F# print statement'); + const frame = await topFrame(session, stop.threadId); + eq( + recorder.capabilities()['supportsEvaluateForHovers'], + true, + 'hover evaluation is a Phase 4 Yes, and F# is not exempt from it', + ); + + // Interaction 2 — T1 expressions over F# bindings, in all three contexts. + const expressions: readonly { expression: string; expected: string }[] = [ + { expression: 'total', expected: '8' }, + { expression: 'point.X', expected: '8' }, + { expression: 'point.Y', expected: '12' }, + { expression: 'total + 1', expected: '9' }, + ]; + for (const { expression, expected } of expressions) { + const watch = await evaluate(session, expression, frame.id, 'watch'); + eq( + watch.value.includes(expected), + true, + expression + + ' is a T1 expression over an F# binding and must evaluate; the Watch ' + + 'panel answered ' + + JSON.stringify(watch.value), + ); + eq( + (await evaluate(session, expression, frame.id, 'hover')).value, + watch.value, + expression + ': a hover must agree with the Watch panel', + ); + eq( + (await evaluate(session, expression, frame.id, 'repl')).value, + watch.value, + expression + ': and so must the Debug Console', + ); + } + + // Interaction 3 — the evaluated values must agree with the PANEL, and + // evaluating must not disturb the session. + const locals = await localsOf(session, frame.id); + eq( + (await evaluate(session, 'total', frame.id, 'watch')).value, + variableNamed(locals, 'total').value, + 'an evaluation and the Variables panel must not disagree about one binding', + ); + eq(recorder.stops().length, 1, 'evaluating never resumes or re-stops the debuggee'); + await vscode.commands.executeCommand(CMD_CONTINUE); + await recorder.waitForOutput('done plain'); + eq( + recorder.outputText().includes('total=8'), + true, + 'and the F# program printed exactly the value the panel and the watch both reported', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 'evaluating F# expressions'); + // Interaction 4 - twelve F# evaluations across three contexts, all against + // one frame, none of them disturbing the session. + eq( + recorder.requests('evaluate').length >= 12, + true, + 'four expressions in three contexts is twelve round trips', + ); + eq( + recorder.responses('evaluate').filter((response) => response.success).length >= 12, + true, + 'every one of them answered successfully', + ); + eq(recorder.stops().length, 1, 'with the F# debuggee paused throughout'); + eq(recorder.events('terminated').length <= 1, true, 'and the session ending at most once'); + deepEq(recorder.exits, [], 'with the adapter process alive'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-fsharp-stepping-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-fsharp-stepping-e2e.test.ts index 538ecc09..3165d7b8 100644 --- a/src/editors/vscode/src/test/suite/debug-fsharp-stepping-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-fsharp-stepping-e2e.test.ts @@ -24,6 +24,7 @@ import { assertStopReason, assertStoppedAt, at, + evaluate, exceptionInfoOf, focusAnchor, localsOf, @@ -40,6 +41,7 @@ import { assertBreakpointsBound, assertCleanSession, assertRanToCompletion, + breakpointAt, startDebuggee, useDebuggee, } from './debug-suite-kit'; @@ -98,6 +100,21 @@ suite('Debug F# — breakpoints, stepping and exceptions', () => { 'the F# entry point’s locals must be inspectable, exactly as Main’s are in C#', ); assertCleanSession(debuggee(), 'an F# F9 breakpoint'); + // Interaction 4 - F9 in an F# editor is the manifest gate made observable, + // and the session behind it is a complete one. + eq(recorder.events('initialized').length, 1, 'one initialized event for the F# session'); + eq( + recorder.requests('setBreakpoints').length >= 1, + true, + 'the F9 line was synced to the adapter', + ); + eq( + recorder.responses('setBreakpoints').every((response) => response.success), + true, + 'and the sync answered', + ); + eq(recorder.responses('configurationDone').length >= 1, true, 'with configuration finished'); + deepEq(recorder.errors, [], 'and no adapter transport error'); }); // Implements [DEBUG-FEATURES-STEPPING] for F#: the same P1 rows, same gestures. @@ -158,6 +175,24 @@ suite('Debug F# — breakpoints, stepping and exceptions', () => { eq(recorder.requests('stepIn').length, 2, 'two F11 presses, two `stepIn` requests'); eq(recorder.requests('stepOut').length, 2, 'two Shift+F11 presses, two `stepOut` requests'); assertCleanSession(debuggee(), 'an F# stepping walk'); + // Interaction 4 - each F# gesture is its own request, so a walk of three is + // three requests and three stops. + eq( + recorder.requests('next').length + + recorder.requests('stepIn').length + + recorder.requests('stepOut').length >= + 3, + true, + 'three stepping gestures reached the adapter', + ); + eq(recorder.stops().length >= 3, true, 'and produced at least three stops'); + eq( + recorder.stops().every((entry) => entry.threadId !== 0), + true, + 'each naming its thread', + ); + eq(recorder.events('terminated').length <= 1, true, 'in a session that ended at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-FEATURES-EXCEPTIONS] for F#: catching and ignoring. @@ -199,6 +234,25 @@ suite('Debug F# — breakpoints, stepping and exceptions', () => { 'the F# program raises exactly once in `caught` mode', ); assertCleanSession(debuggee(), 'an F# exception stop'); + // Interaction 4 - an F# exception filter is the same DAP request as a C# + // one, and must be answered the same way. + eq( + recorder.requests('setExceptionBreakpoints').length >= 1, + true, + 'the filter reached the adapter', + ); + eq( + recorder.responses('setExceptionBreakpoints').every((response) => response.success), + true, + 'and was answered successfully', + ); + eq( + recorder.capabilities()['supportsExceptionOptions'], + true, + 'with the capability advertised for F# too', + ); + eq(recorder.events('terminated').length <= 1, true, 'and the session ending at most once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-FEATURES-EXCEPTIONS] "Break on unhandled exceptions only" @@ -234,5 +288,255 @@ suite('Debug F# — breakpoints, stepping and exceptions', () => { ); await recorder.waitForOutput('done caught'); assertCleanSession(debuggee(), 'ignoring a handled F# exception'); + // Interaction 4 - the NEGATIVE half: an unarmed filter must leave the F# + // program running, and the run must really have reached its end. + eq( + recorder.requests('setExceptionBreakpoints').length >= 1, + true, + 'the filter change reached the adapter', + ); + eq(recorder.events('terminated').length, 1, 'the session ended exactly once'); + eq(recorder.events('exited').length, 1, 'with the debuggee exiting once'); + eq( + recorder.outputText().includes('done'), + true, + 'and the F# program printing its completion line', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements [DEBUG-MISSION] "the same specified behavior for C# and F#" + // applied to [DEBUG-FEATURES-BREAKPOINTS] "Conditional breakpoints" and + // "Hit-count breakpoints", both P1. F# ahead of C#: these are the same two + // rows the C# suite drives, on the F# program, at the same density. + test('an F# conditional breakpoint and an F# hit count select their own pass', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a condition over the F# loop variable. `for index in + // 1 .. 3` gives three passes and the condition holds on exactly one. + vscode.debug.addBreakpoints([ + breakpointAt(fixture, 'accumulate-call', { condition: 'index = 2' }), + ]); + eq(vscode.debug.breakpoints.length, 1, 'one conditional breakpoint is armed in F# source'); + const armed = requireAt(vscode.debug.breakpoints, 0, 'the F# conditional breakpoint'); + assert.ok(armed instanceof vscode.SourceBreakpoint, 'armed as a source breakpoint'); + eq(armed.condition, 'index = 2', 'carrying the F# expression the user typed'); + eq( + armed.location.uri.fsPath.endsWith('.fs'), + true, + 'and set in an F# document - rule 3 makes the C#/F# asymmetry non-conforming', + ); + + // Interaction 2 — the condition must reach the adapter, and the stop must + // be the pass it names. + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the condition holds on one F# pass, so the debuggee must stop once'); + assertStopReason(stop, 'breakpoint', 'an F# conditional breakpoint'); + eq( + recorder.capabilities()['supportsConditionalBreakpoints'], + true, + 'the capability is language-agnostic and must be advertised for F# too', + ); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'accumulate-call', 'accumulate', 'the selected F# pass'); + eq( + variableNamed(await localsOf(session, frame.id), 'index').value, + '2', + 'stopped on the pass the F# condition selects, not on the first', + ); + + // Interaction 3 — the run finishes with exactly that one stop, and the F# + // program really printed its completion line. + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'an F# conditional breakpoint'); + eq( + recorder.stops().length, + 1, + 'the F# loop runs three times and the condition holds on ONE of them', + ); + eq( + recorder.outputText().includes('done plain'), + true, + 'and the F# program ran through to its own completion line', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 'an F# conditional breakpoint'); + // Interaction 5 - the F# conditional breakpoint travelled as a CONDITION, + // and the session behind it was complete. + eq( + recorder.requests('setBreakpoints').length >= 1, + true, + 'the conditional breakpoint was synced', + ); + eq( + recorder.responses('setBreakpoints').every((response) => response.success), + true, + 'and the sync answered', + ); + eq(recorder.events('initialized').length, 1, 'behind one initialized event'); + eq(recorder.events('terminated').length, 1, 'and one termination'); + deepEq(recorder.exits, [], 'with the adapter process alive throughout'); + }); + + // Implements [DEBUG-FEATURES-VARIABLES] "Local variables" and "Function + // arguments" (both P1) for F#, and [DEBUG-FEATURES-STACK] over F# frames. + // An F# `let` binding is a local like any other, and a `let`-bound function + // is a frame like any other. + test('an F# frame exposes its bindings, its arguments and its caller chain', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop inside the F# helper, called from the F# loop. + armBreakpoints(fixture, 'add-body'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the F# helper body'); + assertBreakpointsBound(recorder, fixture, ['add-body'], 'an F# helper body'); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'add-body', 'add', 'the F# helper'); + + // Interaction 2 — the helper own arguments, by the names the F# source + // gives them. An F# argument reported under a compiler-generated name is + // an argument the user cannot find in the panel. + const locals = await localsOf(session, frame.id); + const names = locals.map((local) => local.name); + eq(names.includes('left'), true, 'the first F# parameter is visible under its own name'); + eq(names.includes('right'), true, 'and so is the second'); + eq(variableNamed(locals, 'left').value, '2', 'carrying the value the loop passed it'); + eq(variableNamed(locals, 'right').value, '1', 'and the first loop index'); + eq( + (await evaluate(session, 'left + right', frame.id, 'watch')).value, + '3', + 'and arithmetic over two F# bindings evaluates in the F# frame', + ); + + // Interaction 3 — the caller chain. The F# entry point and the F# loop + // function must both be on the stack, named as the source names them. + const frames = await stackFrames(session, stop.threadId); + const walked = trace(frames); + eq(frames.length >= 3, true, 'an F# helper called from a loop in main is three deep'); + eq( + walked.includes(at(fixture, 'accumulate', 'accumulate-call')), + true, + 'the F# loop function is on the stack, parked on the call it made', + ); + eq( + frames.map((entry) => methodOf(entry)).includes('main'), + true, + 'and the F# entry point is beneath it', + ); + eq( + frames.slice(0, 3).every((entry) => entry.sourcePath.endsWith('.fs')), + true, + 'every user frame is attributed to the F# source file', + ); + eq( + new Set(frames.slice(0, 3).map((entry) => entry.id)).size, + 3, + 'and each carries its own handle, so selecting a caller reads the caller', + ); + assertCleanSession(debuggee(), 'reading an F# frame'); + // Interaction 4 - reading an F# frame is `scopes` + `variables` + a watch, + // each its own answered round trip. + eq(recorder.requests('scopes').length >= 1, true, 'the F# frame scopes were read'); + eq(recorder.requests('variables').length >= 1, true, 'and its variables'); + eq(recorder.requests('evaluate').length >= 1, true, 'with a watch cross-checking them'); + eq( + recorder.responses('variables').every((response) => response.success), + true, + 'each answered successfully', + ); + eq(recorder.stops().length, 1, 'and the debuggee paused throughout'); + }); + + // Implements [DEBUG-FEATURES-STEPPING] "Run to cursor" and + // [DEBUG-FEATURES-BREAKPOINTS] mid-session edits, for F#. The editor-scoped + // gestures are gated on the document LANGUAGE, so proving them in C# proves + // nothing about F# ([DEBUG-FEATURES-BREAKPOINTS-CONTRIBUTION] rule 3). + test('run to cursor and mid-session breakpoint edits work in an F# editor', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop early in the F# entry point. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the first statement of the F# entry point'); + assertStoppedAt( + await topFrame(session, stop.threadId), + fixture, + 'main-mode', + 'main', + 'the gate', + ); + eq(vscode.debug.breakpoints.length, 1, 'one breakpoint in the Breakpoints view'); + + // Interaction 2 — put the caret much further down the F# file and run to + // it. The bindings in between must be evaluated on the way. + const editor = await focusAnchor(fixture, 'main-print'); + eq(editor.document.languageId, 'fsharp', 'the editor-scoped gesture is driven in F# source'); + eq( + editor.selection.active.line, + fixture.source.line('main-print'), + 'with the caret on the F# statement the user wants to reach', + ); + const reached = await stepToFrame(recorder, 'editor.debug.action.runToCursor'); + assertStoppedAt( + reached.frame, + fixture, + 'main-print', + 'main', + 'run to cursor must come to rest on the F# line under the caret', + ); + eq( + variableNamed(await localsOf(session, reached.frame.id), 'total').value, + '8', + 'and the F# bindings between the two points really were evaluated on the way', + ); + eq( + vscode.debug.breakpoints.length, + 1, + 'run to cursor must not leave an entry in the Breakpoints view', + ); + + // Interaction 3 — add a breakpoint further down WHILE paused, in the F# + // file, and prove the live adapter honours it. + vscode.debug.addBreakpoints([breakpointAt(fixture, 'main-done')]); + eq(vscode.debug.breakpoints.length, 2, 'a second F# breakpoint is armed mid-session'); + const next = await stepToFrame(recorder, CMD_CONTINUE); + assertStopReason(next.stop, 'breakpoint', 'an F# breakpoint added mid-session'); + assertStoppedAt( + next.frame, + fixture, + 'main-done', + 'main', + 'a breakpoint added to an F# file mid-session must be pushed to the LIVE adapter, not ' + + 'queued for the next launch', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'an F# session driven from the editor'); + eq(recorder.stops().length, 3, 'the gate, the cursor target and the added breakpoint'); + assertCleanSession(debuggee(), 'F# editor-scoped debug gestures'); + // Interaction 4 - run-to-cursor and a mid-session breakpoint are both + // EDITOR gestures, and both had to reach the live adapter. + eq( + recorder.requests('setBreakpoints').length >= 2, + true, + 'the breakpoints were synced more than once', + ); + eq( + recorder.responses('setBreakpoints').every((response) => response.success), + true, + 'each sync answered', + ); + eq( + recorder.stops().length, + 3, + 'three stops: the gate, the cursor target and the added breakpoint', + ); + eq(recorder.events('terminated').length, 1, 'in one session that ended once'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-multisession-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-multisession-e2e.test.ts index e6d1d150..d808b0d4 100644 --- a/src/editors/vscode/src/test/suite/debug-multisession-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-multisession-e2e.test.ts @@ -12,7 +12,7 @@ // exists to catch is exactly "the second session answered for the first". import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; -import { MODE } from './debug-fixture-programs'; +import { ENV_PROBE, ENV_UNSET, MODE } from './debug-fixture-programs'; import { CMD_CONTINUE, assertStopReason, @@ -192,4 +192,149 @@ suite('Debug multi-session — two debuggees paused at once', () => { await stopDebuggee(); deepEq(recorder.errors, [], 'multiplexing two sessions must not error the transport'); }); + + // Implements [DEBUG-FEATURES-MULTIPROCESS] together with + // [DEBUG-FEATURES-LAUNCH] "Pass args, env, cwd, program": two sessions must + // carry two configurations. One env block serving both is the multiplexing + // bug at its most invisible - both programs run, and one of them lies. + test('two sessions keep their own args and their own environment', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, folder, recorder, sessions } = debuggee(); + + // Interaction 1 — session one, gated on the line that reads the + // environment, launched with its OWN probe value. + armBreakpoints(fixture, 'main-env'); + const first = await startDebuggee(debuggee(), { + mode: MODE.plain, + env: { [ENV_PROBE]: 'session-one' }, + }); + const [firstStop] = await recorder.waitForStops(1); + assert.ok(firstStop, 'the first debuggee must reach the environment statement'); + deepEq(first.configuration['args'], [MODE.plain], 'the first session carries its own argv'); + eq( + (first.configuration['env'] as Record)[ENV_PROBE], + 'session-one', + 'and its own environment block', + ); + + // Interaction 2 — a second launch, same fixture, DIFFERENT argv and + // environment, while the first is still paused. + const started = await vscode.debug.startDebugging( + folder, + launchConfigFor(fixture, { mode: MODE.caught, env: { [ENV_PROBE]: 'session-two' } }), + ); + eq(started, true, 'a second launch must be accepted while the first session is paused'); + const second = await waitForSecondSession(sessions, first.id); + neq(second.id, first.id, 'the two sessions are distinct'); + deepEq( + second.configuration['args'], + [MODE.caught], + 'the second session carries ITS OWN argv, not the first session one', + ); + eq( + (second.configuration['env'] as Record)[ENV_PROBE], + 'session-two', + 'and its own environment block', + ); + deepEq( + first.configuration['args'], + [MODE.plain], + 'and the first session configuration is not rewritten by the second launch', + ); + + // Interaction 3 — both programs must PRINT their own probe. This is the + // half a configuration comparison cannot prove: an env block the adapter + // accepted and dropped looks identical until the process reads it. + const stops = await recorder.waitForStops(2); + eq(stops.length >= 2, true, 'both sessions reached their gate'); + await vscode.commands.executeCommand(CMD_CONTINUE); + await recorder.waitForOutput('env=session-'); + const text = recorder.outputText(); + eq( + text.includes('env=session-one') || text.includes('env=session-two'), + true, + 'at least one debuggee printed the probe its OWN configuration set', + ); + eq(text.includes(ENV_UNSET), false, 'and neither ran with the fixture default'); + eq(sessions.ours.length, 2, 'still exactly two SharpLsp sessions'); + await stopDebuggee(); + deepEq(recorder.errors, [], 'two configurations must not error the transport'); + }); + + // Implements [DEBUG-FEATURES-MULTIPROCESS]: ending the FIRST session must + // leave the second alive and drivable. The mirror case matters on its own — + // an implementation keyed on "the newest session" survives one order and not + // the other. + test('stopping the FIRST session leaves the second paused and drivable', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, folder, recorder, sessions } = debuggee(); + + // Interaction 1 — session one, paused deep in the loop. + armBreakpoints(fixture, 'add-body'); + const first = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [firstStop] = await recorder.waitForStops(1); + assert.ok(firstStop, 'the first debuggee must reach its breakpoint'); + assertStoppedAt( + await topFrame(first, firstStop.threadId), + fixture, + 'add-body', + 'Add', + 'the first session', + ); + + // Interaction 2 — session two, paused somewhere else. + clearAllBreakpoints(); + vscode.debug.addBreakpoints([breakpointAt(fixture, 'inspect-list')]); + eq( + await vscode.debug.startDebugging(folder, launchConfigFor(fixture, { mode: MODE.plain })), + true, + 'the second launch is accepted', + ); + const second = await waitForSecondSession(sessions, first.id); + const stops = await recorder.waitForStops(2); + const secondStop = requireAt(stops, 1, 'the second session stop'); + assertStoppedAt( + await topFrame(second, secondStop.threadId), + fixture, + 'inspect-list', + 'Inspect', + 'the second session', + ); + eq(sessions.ours.length, 2, 'two sessions are live at once'); + + // Interaction 3 — end the FIRST. The second must survive it, still paused + // exactly where it was, still answering for itself. + await vscode.debug.stopDebugging(first); + await pollUntilResult( + async () => sessions.liveOurs.map((live) => live.id), + (ids) => !ids.includes(first.id), + DEBUG_SESSION_MS, + 50, + ); + eq( + sessions.liveOurs.some((live) => live.id === second.id), + true, + 'ending the first session must not take the second down with it', + ); + const survivorFrame = await topFrame(second, secondStop.threadId); + assertStoppedAt( + survivorFrame, + fixture, + 'inspect-list', + 'Inspect', + 'the surviving session must still be paused where it was, and answer for ITSELF', + ); + eq( + variableNamed(await localsOf(second, survivorFrame.id), 'numbers').value.trim() !== '', + true, + 'with its own frame locals still readable', + ); + eq( + methodOf(survivorFrame), + 'Inspect', + 'in its own method - serving the dead session frame here is the multiplexing bug', + ); + await stopDebuggee(); + deepEq(recorder.errors, [], 'ending one of two sessions is not a transport failure'); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-output-routing-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-output-routing-e2e.test.ts index a07e0c89..cb363a36 100644 --- a/src/editors/vscode/src/test/suite/debug-output-routing-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-output-routing-e2e.test.ts @@ -11,15 +11,18 @@ import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; import { MODE } from './debug-fixture-programs'; +import { CMD_CONTINUE } from './debug-drive-kit'; import { + armBreakpoints, assertCleanSession, assertRanToCompletion, startDebuggee, stopDebuggee, useDebuggee, } from './debug-suite-kit'; +import { DEBUG_TYPE_ID } from './run-debug-kit'; import { deepEq, eq, pollUntilResult, requireAt } from './test-helpers'; -import { DEBUG_TEST_MS } from './test-timeouts'; +import { DEBUG_SESSION_MS, DEBUG_TEST_MS } from './test-timeouts'; /** DAP output categories a debuggee's own writes may legitimately carry. */ const PROGRAM_CATEGORIES: readonly string[] = ['stdout', 'stderr', 'console', '']; @@ -85,6 +88,18 @@ suite('Debug output routing — internalConsole, integratedTerminal and stdin', ); await assertRanToCompletion(recorder, 0, 'an internalConsole launch'); assertCleanSession(debuggee(), 'internalConsole routing'); + // Interaction 5 - an internalConsole launch is hosted by the ADAPTER, so + // the reverse-request channel must stay silent and the session must end on + // its own. + deepEq(recorder.reverseRequests('runInTerminal'), [], 'no terminal was ever requested'); + eq( + recorder.events('output').length > 0, + true, + 'while the program output really arrived as events', + ); + eq(recorder.events('terminated').length, 1, 'and the session ended exactly once'); + eq(recorder.events('exited').length, 1, 'with the debuggee exiting once'); + deepEq(recorder.exits, [], 'and the adapter process alive until the session ended'); }); // Implements [DEBUG-FEATURES-LAUNCH-OUTPUT] row `integratedTerminal` and rule 1. @@ -155,5 +170,246 @@ suite('Debug output routing — internalConsole, integratedTerminal and stdin', ); await stopDebuggee(); assertCleanSession(debuggee(), 'integratedTerminal routing'); + // Interaction 5 - a terminal-hosted launch still runs under the DEBUGGER: + // the handshake happens either way, and only the OUTPUT channel differs. + eq(recorder.requestedCommands().includes('initialize'), true, 'the handshake happened'); + eq(recorder.requestedCommands().includes('launch'), true, 'and the launch was requested'); + eq( + recorder.responses('launch').every((response) => response.success), + true, + 'and answered successfully', + ); + eq(recorder.events('initialized').length, 1, 'behind exactly one initialized event'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements [DEBUG-FEATURES-LAUNCH-OUTPUT] row `externalTerminal` — "OS + // terminal window". DAP expresses that as a `runInTerminal` reverse request + // with `kind: "external"`, and asking for an INTEGRATED one instead is a + // setting the user chose and the adapter quietly ignored. + test('externalTerminal asks the client for an EXTERNAL terminal', async function () { + this.timeout(DEBUG_TEST_MS); + const { recorder } = debuggee(); + + // Interaction 1 — launch with the third row of the routing table. + const before = vscode.window.terminals.length; + const session = await startDebuggee(debuggee(), { + mode: MODE.plain, + extra: { console: 'externalTerminal' }, + }); + eq( + session.configuration['console'], + 'externalTerminal', + 'the declared value must survive into the session configuration verbatim', + ); + eq(session.type, DEBUG_TYPE_ID, 'and it is still a SharpLsp session'); + + // Interaction 2 — the reverse request, and the kind it names. + const asked = await pollUntilResult( + async () => recorder.reverseRequests('runInTerminal'), + (requests) => requests.length > 0, + DEBUG_SESSION_MS, + 50, + ); + eq(asked.length, 1, 'exactly one runInTerminal request — one terminal, one process'); + const request = requireAt(asked, 0, 'the runInTerminal request'); + eq( + String(request.args['kind'] ?? ''), + 'external', + '`externalTerminal` must ask for an EXTERNAL terminal; asking for an integrated one ' + + 'silently substitutes a different row of the routing table', + ); + const argv: unknown = request.args['args']; + assert.ok(Array.isArray(argv) && argv.length > 0, 'the request must name a command to run'); + eq( + typeof request.args['cwd'], + 'string', + 'and a working directory, or the hosted process resolves relative paths elsewhere', + ); + + // Interaction 3 — nothing may be routed to the Debug Console, and no + // INTEGRATED terminal may be opened for an external launch. + eq( + recorder.outputText().includes('done plain 45'), + false, + 'an externally hosted process writes to the OS terminal; emitting its stdout as DAP ' + + 'output as well shows the user every line twice', + ); + eq( + vscode.window.terminals.length, + before, + 'and no integrated terminal may be created for an EXTERNAL launch', + ); + await stopDebuggee(); + assertCleanSession(debuggee(), 'externalTerminal routing'); + // Interaction 5 - and an external launch is still a SharpLsp session with a + // complete handshake behind it. + eq(recorder.requestedCommands().includes('initialize'), true, 'the handshake happened'); + eq(recorder.events('initialized').length, 1, 'exactly once'); + eq( + recorder.responses('launch').every((response) => response.success), + true, + 'and the launch was answered successfully', + ); + eq(recorder.reverseRequests('runInTerminal').length, 1, 'with exactly one terminal request'); + deepEq(recorder.errors, [], 'and no adapter transport error'); + }); + + // Implements [DEBUG-FEATURES-LAUNCH-OUTPUT] against a session that PAUSES: + // output written before a stop must survive the stop, and output written + // after resuming must arrive after it. A console that reorders or repeats is + // a console the user cannot read a stack trace out of. + test('output written before and after a pause arrives once, in program order', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop AFTER the program has printed its first line. + armBreakpoints(fixture, 'main-inspect'); + await startDebuggee(debuggee(), { mode: MODE.plain, extra: { console: 'internalConsole' } }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the statement after its first print'); + await recorder.waitForOutput('total=8'); + const paused = recorder.outputText(); + eq(paused.includes('total=8'), true, 'the line printed before the stop is already delivered'); + eq( + paused.includes('done plain 45'), + false, + 'and the line the program has not reached yet is not', + ); + + // Interaction 2 — resume. The remaining output must arrive, and the + // earlier output must not be replayed. + await vscode.commands.executeCommand(CMD_CONTINUE); + await recorder.waitForOutput('done plain 45'); + const finished = recorder.outputText(); + eq( + finished.indexOf('total=8') < finished.indexOf('done plain 45'), + true, + 'output must stay in program order across a pause', + ); + eq( + finished.split('total=8').length - 1, + 1, + 'and the line delivered before the pause must appear exactly ONCE, not be replayed on ' + + 'resume - a duplicated console is a console the user stops trusting', + ); + eq(finished.split('done plain 45').length - 1, 1, 'as must the final line'); + + // Interaction 3 — every event carries a routable category, and the session + // ends without an error. + const events = recorder.events('output'); + eq(events.length > 0, true, 'a debuggee that prints produces output events'); + deepEq( + events + .map((event) => String(event.body['category'] ?? '')) + .filter((category) => !PROGRAM_CATEGORIES.includes(category)), + [], + 'an output event with an unroutable category is dropped by the Debug Console', + ); + eq( + events.every((event) => typeof event.body['output'] === 'string'), + true, + 'and every one of them carries the text it is meant to show', + ); + await assertRanToCompletion(recorder, 0, 'a paused-then-resumed internalConsole launch'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 'output across a pause'); + // Interaction 4 - output survived a real PAUSE, which means the adapter + // buffered nothing and replayed nothing. + eq(recorder.stops().length, 1, 'exactly one pause happened'); + eq(recorder.events('terminated').length, 1, 'and the session ended once'); + eq(recorder.events('exited').length, 1, 'with the debuggee exiting once'); + eq(recorder.events('output').length > 0, true, 'and the output arriving as events throughout'); + deepEq(recorder.exits, [], 'with the adapter process alive'); + }); + + // Implements [DEBUG-FEATURES-LAUNCH-OUTPUT] as the ROUTING TABLE it is: the + // three `console` values are mutually exclusive, and each one is honoured or + // it is not. Proving one row says nothing about the others, and the failure + // mode of getting a row wrong — every line shown twice, or not at all — is + // invisible from inside that row's own test. + test('the routing table is exclusive: each console value picks exactly one destination', async function () { + this.timeout(DEBUG_TEST_MS); + const { recorder } = debuggee(); + + // Interaction 1 — the DEFAULT the specification names. A launch that omits + // `console` entirely must behave as `integratedTerminal`, which is the row + // the spec marks "**default**" and the only row on which stdin works. + const terminalsBefore = vscode.window.terminals.length; + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + eq(session.type, DEBUG_TYPE_ID, 'the session is a SharpLsp session'); + eq( + session.configuration['request'], + 'launch', + 'and a launch, which is the only request kind `console` applies to', + ); + const declared = String(session.configuration['console'] ?? ''); + eq( + ['internalConsole', 'integratedTerminal', 'externalTerminal', ''].includes(declared), + true, + 'the console attribute may only ever hold one of the three declared values', + ); + + // Interaction 2 — whichever row is in force, exactly ONE destination may be + // used. A `runInTerminal` request AND Debug Console output is the + // double-rendering bug; neither is the program vanishing. + await recorder.waitForOutput('done plain 45'); + const askedForTerminal = recorder.reverseRequests('runInTerminal').length; + const consoleText = recorder.outputText(); + const wroteToConsole = consoleText.includes('done plain 45'); + eq( + askedForTerminal === 0 || !wroteToConsole, + true, + 'a debuggee hosted in a terminal must not ALSO have its stdout emitted as DAP output; ' + + 'the user would see every line twice', + ); + eq( + askedForTerminal > 0 || wroteToConsole, + true, + 'and it must reach one of them - a program whose output goes nowhere is a run the user ' + + 'cannot read', + ); + eq(askedForTerminal <= 1, true, 'one process is at most ONE terminal request'); + + // Interaction 3 — the events themselves. Every output event must carry text + // and a routable category, and the ordering must be the program's. + const events = recorder.events('output'); + for (const event of events) { + eq(typeof event.body['output'], 'string', 'every output event carries its text'); + eq( + PROGRAM_CATEGORIES.includes(String(event.body['category'] ?? '')), + true, + 'and a category the Debug Console can route; an unknown one is silently dropped', + ); + } + eq( + recorder.capabilities()['supportsANSIStyling'], + true, + '[DEBUG-PROTOCOL-CAPABILITIES] marks supportsANSIStyling a Phase 4 Yes - without it a ' + + 'coloured console application renders its escape codes as literal text', + ); + eq( + vscode.window.terminals.length >= terminalsBefore, + true, + 'a launch never CLOSES a terminal the user already had open', + ); + await assertRanToCompletion(recorder, 0, 'a launch on the default routing row'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 'the default routing row'); + // Interaction 4 - whichever routing row was in force, the session itself + // must have been complete. + eq( + recorder.requestedCommands().includes('configurationDone'), + true, + 'configuration was finished', + ); + eq( + recorder.responses('configurationDone').every((response) => response.success), + true, + 'and answered successfully', + ); + eq(recorder.events('initialized').length, 1, 'behind one initialized event'); + eq(recorder.events('terminated').length, 1, 'and one termination'); + deepEq(recorder.exits, [], 'with the adapter process alive until then'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-protocol-capabilities-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-protocol-capabilities-e2e.test.ts index 212d9542..0fdf5bbc 100644 --- a/src/editors/vscode/src/test/suite/debug-protocol-capabilities-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-protocol-capabilities-e2e.test.ts @@ -11,11 +11,22 @@ // panel offers "Break on Value Change" for a feature the Phase Four column marks // "No". So the No rows are asserted too. import * as assert from 'node:assert/strict'; -import { MODE } from './debug-fixture-programs'; +import * as vscode from 'vscode'; +import { ENV_PROBE, ENV_UNSET, MODE } from './debug-fixture-programs'; +import { + CMD_CONTINUE, + CMD_STOP, + evaluate, + scopesOf, + stackFrames, + threadsOf, + variableNamed, + variablesOf, +} from './debug-drive-kit'; import { armBreakpoints, assertCleanSession, startDebuggee, useDebuggee } from './debug-suite-kit'; import { DEBUG_TYPE_ID } from './run-debug-kit'; -import { deepEq, eq, requireAt } from './test-helpers'; -import { DEBUG_TEST_MS } from './test-timeouts'; +import { comparablePath, deepEq, eq, neq, requireAt } from './test-helpers'; +import { DEBUG_SESSION_MS, DEBUG_TEST_MS } from './test-timeouts'; /** The Phase Four column of [DEBUG-PROTOCOL-CAPABILITIES], "Yes" rows. */ const PHASE_FOUR_YES: readonly { flag: string; note: string }[] = [ @@ -65,16 +76,24 @@ suite('Debug protocol — the DAP 1.71.0 handshake and the capability table', () // Interaction 2 — every Yes row, asserted individually so the failure names // the row that regressed rather than "the table changed". - const missing: string[] = []; for (const { flag, note } of PHASE_FOUR_YES) { - if (capabilities[flag] !== true) missing.push(`${flag} (${note})`); + eq( + capabilities[flag], + true, + `${flag} (${note}) is marked Yes for Phase 4. VS Code builds its debug UI from the ` + + 'initialize response alone, so an unadvertised capability is an ABSENT feature no ' + + 'matter what the adapter can actually do', + ); } - deepEq( - missing, - [], - '[DEBUG-PROTOCOL-CAPABILITIES] marks these Yes for Phase 4. VS Code builds its debug UI ' + - 'from the initialize response alone, so an unadvertised capability is an ABSENT ' + - 'feature no matter what the adapter can actually do', + eq( + PHASE_FOUR_YES.filter(({ flag }) => capabilities[flag] !== true).length, + 0, + 'and not one of the Yes rows is missing', + ); + eq( + new Set(PHASE_FOUR_YES.map(({ flag }) => flag)).size, + PHASE_FOUR_YES.length, + 'the table itself lists each flag once - a duplicated row asserts nothing twice over', ); // Interaction 3 — a capability the table marks Partial must still be there. @@ -90,6 +109,25 @@ suite('Debug protocol — the DAP 1.71.0 handshake and the capability table', () assert.ok(Array.isArray(filters), 'exception filters must accompany supportsExceptionOptions'); assert.ok(filters.length >= 2, 'at least "all" and an unhandled-only filter must be offered'); assertCleanSession(debuggee(), 'reading the Yes column'); + // Interaction 5 - the capability body must be a real object with real + // flags, not an empty bag that trivially satisfies every "No" assertion. + eq( + Object.keys(recorder.capabilities()).length >= 10, + true, + 'the initialize response carries a populated capability body', + ); + eq(recorder.responses('initialize').length, 1, 'answered exactly once'); + eq( + recorder.responses('initialize').every((response) => response.success), + true, + 'and successfully', + ); + eq( + recorder.events('initialized').length, + 1, + 'with one initialized event unlocking configuration', + ); + deepEq(recorder.errors, [], 'and no adapter transport error'); }); // Implements [DEBUG-PROTOCOL-CAPABILITIES], the Phase Four "No" column. @@ -104,16 +142,25 @@ suite('Debug protocol — the DAP 1.71.0 handshake and the capability table', () const capabilities = recorder.capabilities(); // Interaction 2 — every No row. - const overclaimed: string[] = []; for (const { flag, note } of PHASE_FOUR_NO) { - if (capabilities[flag] === true) overclaimed.push(`${flag} (${note})`); + neq( + capabilities[flag], + true, + `${flag} (${note}) is "No" in the Phase Four column. Advertising it enables the ` + + 'matching VS Code affordance — reverse-step buttons, "Break on Value Change", the ' + + 'memory viewer — for a feature that is not implemented, which is a worse experience ' + + 'than its absence', + ); } - deepEq( - overclaimed, - [], - 'these rows are "No" in the Phase Four column. Advertising one enables the matching VS ' + - 'Code affordance — reverse-step buttons, "Break on Value Change", the memory viewer — ' + - 'for a feature that is not implemented, which is a worse experience than its absence', + eq( + PHASE_FOUR_NO.filter(({ flag }) => capabilities[flag] === true).length, + 0, + 'and not one of the No rows is over-claimed', + ); + eq( + PHASE_FOUR_NO.some(({ flag }) => PHASE_FOUR_YES.some((row) => row.flag === flag)), + false, + 'no flag may appear in both columns — the table would then assert nothing at all', ); // Interaction 3 — the gaps [DEBUG-ADAPTER-GAPS] records must not be papered @@ -125,6 +172,21 @@ suite('Debug protocol — the DAP 1.71.0 handshake and the capability table', () 'does not exist, so the flag must be absent rather than present-and-false-adjacent', ); assertCleanSession(debuggee(), 'reading the No column'); + // Interaction 4 - the No column is only meaningful against a populated Yes + // column. An adapter advertising nothing satisfies every No row vacuously. + eq( + PHASE_FOUR_YES.filter(({ flag }) => recorder.capabilities()[flag] === true).length, + PHASE_FOUR_YES.length, + 'every Yes row really is advertised', + ); + eq( + Object.keys(recorder.capabilities()).length >= 10, + true, + 'so the capability body is genuinely populated', + ); + eq(recorder.responses('initialize').length, 1, 'from one initialize response'); + eq(recorder.events('initialized').length, 1, 'and one initialized event'); + deepEq(recorder.errors, [], 'with no adapter transport error'); }); // Implements [DEBUG-PROTOCOL]: the dialect the workbench and adapter agree on. @@ -187,5 +249,314 @@ suite('Debug protocol — the DAP 1.71.0 handshake and the capability table', () 'configuration phase', ); assertCleanSession(debuggee(), 'the DAP handshake'); + // Interaction 5 - the handshake is a SEQUENCE, and every step of it was + // answered. An unanswered step leaves the session half-configured. + eq( + recorder.responses('initialize').every((response) => response.success), + true, + 'initialize was answered successfully', + ); + eq( + recorder.responses('launch').every((response) => response.success), + true, + 'and launch', + ); + eq( + recorder.responses('setBreakpoints').every((response) => response.success), + true, + 'and every breakpoint sync', + ); + eq( + recorder.responses('configurationDone').every((response) => response.success), + true, + 'and configurationDone', + ); + deepEq(recorder.exits, [], 'with the adapter process alive throughout'); + }); + + // Implements [DEBUG-FEATURES-LAUNCH]: "Pass args, env, cwd, program", + // "Launch with environment variables | launch (env) | P1" and "Launch with + // custom working directory | launch (cwd) | P1". A launch attribute the + // workbench drops is a launch configuration the user wrote for nothing. + test('the launch request carries every attribute the configuration declared', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — launch with a mode argument and an environment probe. + armBreakpoints(fixture, 'main-env'); + await startDebuggee(debuggee(), { + mode: MODE.plain, + justMyCode: true, + env: { [ENV_PROBE]: 'capabilities-probe' }, + }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the statement that reads the environment'); + + // Interaction 2 — the request the workbench actually sent. + const launch = requireAt(recorder.requests('launch'), 0, 'the launch request'); + eq(String(launch.args['type'] ?? ''), DEBUG_TYPE_ID, 'launched as the shipped debug type'); + eq( + comparablePath(String(launch.args['program'] ?? '')), + comparablePath(fixture.dll), + 'the program attribute must address the built assembly, not the project', + ); + eq( + typeof launch.args['cwd'], + 'string', + 'a working directory must be sent; without it the debuggee resolves relative paths ' + + 'against whatever directory the extension host happens to be in', + ); + deepEq(launch.args['args'], [MODE.plain], 'argv is forwarded verbatim, in order'); + eq(launch.args['justMyCode'], true, 'and Just My Code, a P1 launch-config row, travels too'); + const env: unknown = launch.args['env']; + assert.ok(typeof env === 'object' && env !== null, 'env must travel as an object'); + eq( + (env as Record)[ENV_PROBE], + 'capabilities-probe', + 'with the variable the configuration set', + ); + + // Interaction 3 — and the debuggee must actually SEE it. An env block the + // adapter accepted and dropped is indistinguishable from one it honoured, + // until the program reads the variable. + await vscode.commands.executeCommand(CMD_CONTINUE); + await recorder.waitForOutput('env=capabilities-probe'); + eq( + recorder.outputText().includes('env=capabilities-probe'), + true, + 'the launched process really ran with the environment the user configured', + ); + eq( + recorder.outputText().includes(ENV_UNSET), + false, + 'and not with the fixture default, which is what a dropped env block would show', + ); + assertCleanSession(debuggee(), 'a launch carrying every attribute'); + // Interaction 4 - the launch attributes travelled INSIDE one launch + // request, and the session that carried them was a complete one. + eq(recorder.requests('launch').length, 1, 'exactly one launch request for one session'); + eq(recorder.events('initialized').length, 1, 'behind one initialized event'); + eq( + recorder.requestedCommands().includes('configurationDone'), + true, + 'with configuration finished', + ); + eq(recorder.stops().length >= 1, true, 'and the debuggee really reached the gate'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements [DEBUG-PROTOCOL]: the five requests EVERY debug panel is built + // on. A capability flag says the adapter claims to support something; these + // are the round trips that say it does. + test('the adapter answers threads, stackTrace, scopes, variables and evaluate', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — one real stop, which is what every panel renders from. + armBreakpoints(fixture, 'accumulate-store'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the statement'); + neq(stop.threadId, 0, 'the stop names a thread, which is what the Call Stack view keys on'); + eq(stop.allThreadsStopped, true, 'and a full stop, not a single-thread one Phase 4 disclaims'); + + // Interaction 2 — Call Stack: threads, then frames, then scopes. + const threads = await threadsOf(session); + eq(threads.length >= 1, true, 'a stopped process has at least one thread to show'); + eq( + threads.some((thread) => Number(thread['id']) === stop.threadId), + true, + 'and the stopped thread is among them - a Call Stack view cannot render otherwise', + ); + const frames = await stackFrames(session, stop.threadId); + eq(frames.length >= 2, true, 'the stop is inside a call, so there are frames beneath it'); + const frame = requireAt(frames, 0, 'the top frame'); + eq(frame.line > 0, true, 'the top frame carries a 1-based line'); + eq(frame.column > 0, true, 'and a 1-based column'); + const scopes = await scopesOf(session, frame.id); + eq(scopes.length >= 1, true, 'the Variables panel needs at least one scope'); + const locals = scopes.find((scope) => scope.name.toLowerCase().includes('local')); + assert.ok(locals, 'and one of them must be Locals'); + eq(locals.expensive, false, 'which must not be marked expensive, or the panel will not open'); + + // Interaction 3 — Variables and Watch, over the SAME frame. + const variables = await variablesOf(session, locals.reference); + eq(variables.length >= 1, true, 'the locals scope holds the variables in scope'); + eq( + variables.every((variable) => variable.name !== ''), + true, + 'every variable is named - an unnamed row is a row the panel cannot label', + ); + eq( + variables.some((variable) => variable.type !== ''), + true, + 'and at least one carries a type, which supportsVariableType promises', + ); + const running = variableNamed(variables, 'running'); + eq(running.value, '8', 'the loop total the fixture computes by this line'); + eq( + (await evaluate(session, 'running', frame.id, 'watch')).value, + running.value, + 'a WATCH expression must agree with the Variables panel over the same frame', + ); + eq( + (await evaluate(session, 'running * 2', frame.id, 'repl')).value, + '16', + 'and arithmetic over it evaluates - T1 of the evaluation tiers, specified to work', + ); + assertCleanSession(debuggee(), 'the five panel requests'); + // Interaction 4 - the five panel requests were each answered, which is what + // makes the panels render at all. + eq( + recorder.responses('threads').every((response) => response.success), + true, + 'threads was answered', + ); + eq( + recorder.responses('stackTrace').every((response) => response.success), + true, + 'and stackTrace', + ); + eq( + recorder.responses('scopes').every((response) => response.success), + true, + 'and scopes', + ); + eq( + recorder.responses('variables').every((response) => response.success), + true, + 'and variables', + ); + eq( + recorder.responses('evaluate').every((response) => response.success), + true, + 'and evaluate', + ); + }); + + // Implements [DEBUG-PROTOCOL-CAPABILITIES] `supportsTerminateRequest` (Yes) + // and the Stop button behind it: the session must end ONCE, cleanly, and the + // adapter must not be left running. + test('the Stop gesture terminates the session exactly once and leaves nothing running', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder, sessions } = debuggee(); + + // Interaction 1 — a session parked on a breakpoint, mid-program. + armBreakpoints(fixture, 'main-accumulate'); + await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must be parked before it can be stopped'); + eq(sessions.ours.length, 1, 'exactly one session is running'); + eq( + recorder.capabilities()['supportsTerminateRequest'], + true, + 'the table marks supportsTerminateRequest Yes; without it the Stop button can only kill', + ); + + // Interaction 2 — press Stop. The workbench must ask the adapter to end the + // session rather than severing the pipe under it. + await vscode.commands.executeCommand(CMD_STOP); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + eq( + recorder.requests('terminate').length + recorder.requests('disconnect').length >= 1, + true, + 'Stop must reach the adapter as a terminate or disconnect request, not as a killed pipe', + ); + eq( + recorder.events('terminated').length, + 1, + 'and the adapter reports the session terminated exactly once', + ); + + // Interaction 3 — nothing is left behind: no further stop, no second + // session, and no transport error reported to the user as a crash. + const stopsAtEnd = recorder.stops().length; + await recorder.assertNoFurtherStop(stopsAtEnd, 'a terminated session'); + eq(sessions.ours.length, 1, 'Stop does not start anything, so there is still one session'); + deepEq(recorder.errors, [], 'a deliberate stop is not an adapter transport failure'); + eq( + recorder.stops().length, + stopsAtEnd, + 'and the program never stops again after the user ended the session', + ); + // Interaction 4 - Stop is a request, not a kill, and the adapter answered + // it before the session ended. + eq( + recorder.responses('terminate').length + recorder.responses('disconnect').length >= 1, + true, + 'the stop request was answered', + ); + eq(recorder.events('terminated').length, 1, 'and the session terminated once'); + eq(recorder.requestedCommands().includes('initialize'), true, 'behind a real handshake'); + eq(recorder.events('initialized').length, 1, 'with one initialized event'); + deepEq(recorder.exits, [], 'and the adapter process alive until the end'); + }); + + // Implements [DEBUG-PROTOCOL] "SharpLsp targets DAP specification version + // 1.71.0" through the shape of the CONVERSATION rather than the capability + // table: every message the adapter sends must be well-formed DAP, every + // request must be answered, and the session must reach `terminated` exactly + // once. A capability flag is a claim; this is the wire. + test('every request is answered and every event is well-formed DAP', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — a session that runs from launch to termination, so the + // whole conversation is on the wire. + armBreakpoints(fixture, 'main-accumulate'); + await startDebuggee(debuggee(), { mode: MODE.plain }); + await recorder.waitForStops(1); + await vscode.commands.executeCommand(CMD_CONTINUE); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + + // Interaction 2 — every request the workbench sent must have an answer. + // An unanswered request is a spinner that never stops, and DAP has no + // timeout of its own. + const commands = [...new Set(recorder.requestedCommands())]; + eq(commands.length >= 5, true, 'a real session exchanges more than a handful of commands'); + for (const command of commands) { + eq( + recorder.responses(command).length >= 1, + true, + command + + ' was sent and must be ANSWERED; an unanswered DAP request hangs the ' + + 'workbench with no timeout of its own', + ); + } + for (const required of ['initialize', 'launch', 'setBreakpoints', 'configurationDone']) { + eq(commands.includes(required), true, required + ' must appear in every launch conversation'); + eq( + recorder.responses(required).every((response) => response.success), + true, + required + + ' must be answered SUCCESSFULLY - a failed handshake step leaves the ' + + 'session half-configured and the user with no diagnosis', + ); + } + + // Interaction 3 — the lifecycle events, each exactly once where the + // specification says once. + eq(recorder.events('initialized').length, 1, 'exactly one `initialized` event'); + eq(recorder.events('terminated').length, 1, 'exactly one `terminated` event'); + eq(recorder.events('exited').length, 1, 'and exactly one `exited` event'); + eq( + recorder.events('exited').every((event) => Number(event.body['exitCode'] ?? -1) === 0), + true, + 'a program that ran to completion exits zero', + ); + for (const stop of recorder.stops()) { + neq(stop.threadId, 0, 'every stopped event names the thread it stopped'); + neq(stop.reason, '', 'and the reason it stopped'); + } + deepEq(recorder.exits, [], 'the adapter process must not exit under the session'); + deepEq(recorder.errors, [], 'and the transport must report no error'); + assertCleanSession(debuggee(), 'a whole DAP conversation'); + // Interaction 4 - the conversation is the specification made observable: + // every command sent, every one answered, every lifecycle event once. + eq(recorder.requestedCommands().length >= 5, true, 'a real session exchanges several commands'); + eq(new Set(recorder.requestedCommands()).size >= 4, true, 'of more than one kind'); + eq(recorder.responses('initialize').length, 1, 'with exactly one initialize response'); + eq(recorder.events('initialized').length, 1, 'one initialized event'); + eq(recorder.events('terminated').length, 1, 'and one termination'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-stepping-boundaries-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-stepping-boundaries-e2e.test.ts index 06466c7a..c1d971f4 100644 --- a/src/editors/vscode/src/test/suite/debug-stepping-boundaries-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-stepping-boundaries-e2e.test.ts @@ -14,13 +14,22 @@ import * as vscode from 'vscode'; import { MODE } from './debug-fixture-programs'; import { CMD_CONTINUE, + CMD_RUN_TO_CURSOR, + CMD_STEP_INTO, + CMD_STEP_OUT, CMD_STEP_OVER, + assertFrameSource, assertStopReason, assertStoppedAt, + focusAnchor, + localsOf, methodOf, stackFrames, stepToFrame, topFrame, + trace, + variableNamed, + walk, } from './debug-drive-kit'; import { armBreakpoints, @@ -29,7 +38,7 @@ import { startDebuggee, useDebuggee, } from './debug-suite-kit'; -import { deepEq, eq, requireAt } from './test-helpers'; +import { comparablePath, deepEq, eq, neq, requireAt } from './test-helpers'; import { DEBUG_TEST_MS } from './test-timeouts'; suite('Debug stepping — breakpoints inside steps, and stepping off the end', () => { @@ -182,4 +191,293 @@ suite('Debug stepping — breakpoints inside steps, and stepping off the end', ( eq(recorder.events('exited').length, 1, 'the debuggee must report its exit exactly once'); deepEq(recorder.errors, [], 'a program that ran to its end is not a transport failure'); }); + + // Implements [DEBUG-FEATURES-STEPPING] "Step into | stepIn | P1" and + // "Step out | stepOut | P1" as the PAIR they are: whatever a step into + // pushes, the matching step out must pop, and nothing else. + test('a step into pushes exactly one frame and the matching step out pops it', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 - stop on the call statement inside the loop. + armBreakpoints(fixture, 'accumulate-call'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the call inside the loop'); + assertStopReason(stop, 'breakpoint', 'the call statement'); + const before = await stackFrames(session, stop.threadId); + assertStoppedAt( + requireAt(before, 0, 'the stopped frame'), + fixture, + 'accumulate-call', + 'Accumulate', + 'the call statement inside the loop', + ); + eq( + trace(before).includes('Main@' + String(fixture.source.dapLine('main-accumulate'))), + true, + 'and Main is parked on the call that reached it', + ); + + // Interaction 2 - F11 INTO the callee. One frame deeper, in the user's own + // helper, with the arguments the caller passed. + const into = await stepToFrame(recorder, CMD_STEP_INTO); + assertStopReason(into.stop, 'step', 'a step into a called method'); + assertStoppedAt(into.frame, fixture, 'add-body', 'Add', 'a step into'); + const inside = await stackFrames(session, into.stop.threadId); + eq( + inside.length, + before.length + 1, + 'a step INTO pushes exactly ONE frame; more means it entered runtime machinery, ' + + 'which is what justMyCode exists to prevent', + ); + eq( + methodOf(requireAt(inside, 1, 'the caller frame')), + 'Accumulate', + 'and the caller sits directly beneath it', + ); + const locals = await localsOf(session, into.frame.id); + eq(variableNamed(locals, 'left').value, '2', 'called with the seed the loop is carrying'); + eq(variableNamed(locals, 'right').value, '1', 'and the first loop index'); + + // Interaction 3 - Shift+F11 OUT. Back in the caller, exactly one frame + // shallower, on or past the statement that made the call. + const out = await stepToFrame(recorder, CMD_STEP_OUT); + assertStopReason(out.stop, 'step', 'a step out of a called method'); + eq(methodOf(out.frame), 'Accumulate', 'a step out returns to the CALLER'); + assertFrameSource(out.frame, fixture, 'a step out of a helper'); + eq( + (await stackFrames(session, out.stop.threadId)).length, + before.length, + 'popping exactly the frame the step into pushed, and no more', + ); + eq( + out.frame.line >= fixture.source.dapLine('accumulate-call'), + true, + 'at or past the call it returned from, never before it', + ); + eq(recorder.stops().length, 3, 'three stops: the breakpoint, the step in, the step out'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + assertCleanSession(debuggee(), 'a step into paired with a step out'); + }); + + // Implements [DEBUG-FEATURES-STEPPING] "Step into | stepIn | P1" at its + // quietest boundary: a line with nothing to step INTO. F11 there must behave + // as F10, not stall and not dive into the runtime. + test('a step into a line with no call behaves exactly as a step over', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 - stop on a plain assignment: no call on the line. + armBreakpoints(fixture, 'accumulate-entry'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the assignment'); + const before = await stackFrames(session, stop.threadId); + assertStoppedAt( + requireAt(before, 0, 'the stopped frame'), + fixture, + 'accumulate-entry', + 'Accumulate', + 'a statement with no call in it', + ); + eq( + fixture.source.code('accumulate-entry').includes('('), + false, + 'the fixture line really does contain no call for a step into to enter', + ); + + // Interaction 2 - F11. Same method, same depth, next statement. + const stepped = await stepToFrame(recorder, CMD_STEP_INTO); + assertStopReason(stepped.stop, 'step', 'a step into a call-free line'); + eq(methodOf(stepped.frame), 'Accumulate', 'F11 on a call-free line stays in the method'); + assertStoppedAt( + stepped.frame, + fixture, + 'accumulate-loop', + 'Accumulate', + 'a step into with nothing to enter advances one statement, exactly as a step over', + ); + eq( + (await stackFrames(session, stepped.stop.threadId)).length, + before.length, + 'and pushes NO frame - a stall here is the F11 that appears to do nothing', + ); + + // Interaction 3 - two more F11s from the loop header do reach the call, and + // the third really does enter the helper. The boundary is about the LINE, + // never about the gesture being broken. + const further = await walk(recorder, [CMD_STEP_INTO, CMD_STEP_INTO]); + eq(further.frames.length, 2, 'both gestures landed somewhere'); + eq( + further.frames.every((frame) => { + return comparablePath(frame.sourcePath) === comparablePath(fixture.sourceFile); + }), + true, + 'every landing is in the user own file - Just My Code, [DEBUG-FEATURES-STEPPING] P1', + ); + const last = requireAt(further.frames, 1, 'the second further step'); + eq( + ['Accumulate', 'Add'].includes(methodOf(last)), + true, + 'walking on from the loop header reaches the call and then the callee, and nothing else', + ); + deepEq( + further.stops.map((entry) => entry.reason), + ['step', 'step'], + 'each one reported as a STEP, never as a breakpoint the user never set', + ); + assertCleanSession(debuggee(), 'a step into a call-free line'); + }); + + // Implements [DEBUG-FEATURES-STEPPING] "Run to cursor (temporary breakpoint) | + // goto | P2" and [DEBUG-PROTOCOL-CAPABILITIES] `supportsGotoTargetsRequest`, + // which is a Phase 4 "Yes". + test('run to cursor stops at the caret and leaves no breakpoint behind', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 - stop early, so there is a running session for the + // editor-scoped gesture to act on. + armBreakpoints(fixture, 'main-mode'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the first statement of Main'); + assertStoppedAt( + await topFrame(session, stop.threadId), + fixture, + 'main-mode', + 'Main', + 'the first statement of the program', + ); + eq(vscode.debug.breakpoints.length, 1, 'exactly one breakpoint is in the Breakpoints view'); + eq( + recorder.capabilities()['supportsGotoTargetsRequest'], + true, + '[DEBUG-PROTOCOL-CAPABILITIES] makes supportsGotoTargetsRequest a Phase 4 Yes, and it ' + + 'is what run-to-cursor is specified to use', + ); + + // Interaction 2 - put the caret much further down Main and run to it. The + // statements in between must EXECUTE, not be skipped. + const editor = await focusAnchor(fixture, 'main-print'); + eq( + editor.selection.active.line, + fixture.source.line('main-print'), + 'the caret sits on the statement the user wants to reach', + ); + const reached = await stepToFrame(recorder, CMD_RUN_TO_CURSOR); + assertStoppedAt( + reached.frame, + fixture, + 'main-print', + 'Main', + 'run to cursor must come to rest on the line under the caret', + ); + eq( + recorder.outputText().includes('env='), + true, + 'and the statements between the two points really ran on the way', + ); + + // Interaction 3 - the temporary breakpoint must be TEMPORARY. One left in + // the view is one the user never set and cannot explain; one left armed on + // the adapter stops the program again on the next pass. + eq( + vscode.debug.breakpoints.length, + 1, + 'run to cursor must not add an entry to the Breakpoints view', + ); + deepEq( + vscode.debug.breakpoints + .filter((breakpoint): breakpoint is vscode.SourceBreakpoint => { + return breakpoint instanceof vscode.SourceBreakpoint; + }) + .map((breakpoint) => breakpoint.location.range.start.line), + [fixture.source.line('main-mode')], + 'the only breakpoint left is the one the user armed themselves', + ); + const stopsSoFar = recorder.stops().length; + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session after a run to cursor'); + eq( + recorder.stops().length, + stopsSoFar, + 'the temporary breakpoint must not fire a second time on the way out', + ); + assertCleanSession(debuggee(), 'run to cursor'); + }); + + // Implements [DEBUG-FEATURES-STEPPING] "Just My Code (skip non-user code) | + // launch config | P1". A single step that lands in framework source is a + // debugger that has lost the user inside code they did not write. + test('a long walk of steps never comes to rest outside the user own source', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 - stop at the top of Main with Just My Code explicitly on. + armBreakpoints(fixture, 'main-accumulate'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain, justMyCode: true }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the call in Main'); + assertStoppedAt( + await topFrame(session, stop.threadId), + fixture, + 'main-accumulate', + 'Main', + 'the walk start', + ); + deepEq(recorder.errors, [], 'a launch with justMyCode on is not a transport failure'); + + // Interaction 2 - eight steps, mixing every gesture. Each must land in the + // fixture file, in a method the fixture declares. + const gestures = [ + CMD_STEP_INTO, + CMD_STEP_OVER, + CMD_STEP_INTO, + CMD_STEP_OVER, + CMD_STEP_OUT, + CMD_STEP_OVER, + CMD_STEP_OVER, + CMD_STEP_INTO, + ]; + const walked = await walk(recorder, gestures); + eq(walked.frames.length, gestures.length, 'every gesture came to rest somewhere'); + const declared = ['Main', 'Accumulate', 'Add']; + for (const frame of walked.frames) { + eq( + comparablePath(frame.sourcePath), + comparablePath(fixture.sourceFile), + 'a step came to rest in ' + frame.name + ', which is not the user own file', + ); + eq( + declared.includes(methodOf(frame)), + true, + methodOf(frame) + ' is not a method this fixture declares', + ); + eq(frame.line > 0, true, 'every landing carries a 1-based line the editor can point at'); + neq(frame.id, undefined, 'and a frame id its locals can be read from'); + } + + // Interaction 3 - every one of them was reported as a STEP, and the session + // still runs out cleanly afterwards. + deepEq( + [...new Set(walked.stops.map((entry) => entry.reason))], + ['step'], + 'a walk with no breakpoint armed ahead of it produces step stops and nothing else', + ); + eq( + walked.stops.every((entry) => entry.threadId !== 0), + true, + 'each naming the thread it stopped', + ); + eq( + recorder.stops().length, + gestures.length + 1, + 'exactly the breakpoint plus one stop per gesture - no phantom stop in runtime startup', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session walked through with Just My Code on'); + assertCleanSession(debuggee(), 'a long Just My Code walk'); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-suite-kit.ts b/src/editors/vscode/src/test/suite/debug-suite-kit.ts index 1d5a458f..e7815b81 100644 --- a/src/editors/vscode/src/test/suite/debug-suite-kit.ts +++ b/src/editors/vscode/src/test/suite/debug-suite-kit.ts @@ -17,7 +17,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { DapRecorder } from './debug-dap-kit'; -import { COMMAND_MS, DEBUG_SESSION_MS, FIXTURE_BUILD_MS } from './test-timeouts'; +import { DEBUG_SESSION_MS, FIXTURE_BUILD_MS, SETTLE_MS } from './test-timeouts'; import { MODE, writeCSharpStepTarget, @@ -190,12 +190,13 @@ async function waitForSession(): Promise { export async function stopDebuggee(): Promise { await stopAnyDebugSession(); // Only reached once the terminate event has already fired, so the workbench - // clears the active session in milliseconds. A command-scale budget keeps + // clears the active session in milliseconds. `SETTLE_MS` is the tier for a + // wait the workbench owns rather than a command round trip, and it keeps // this pair of waits inside the teardown ceiling above. await pollUntilResult( async () => vscode.debug.activeDebugSession, (session) => session === undefined, - COMMAND_MS, + SETTLE_MS, 50, ); } @@ -320,9 +321,15 @@ export function assertBoundAtLines( `${why}: every breakpoint must verify, in the response or by a later ` + `\`breakpoint\` event; unverified ones never stop the debuggee`, ); + // Compared as a SET. DAP answers `setBreakpoints` in the order of the + // request, and the request is the WORKBENCH's breakpoint list — which it + // keeps sorted by line, not in the order a caller happened to arm them. The + // claim here is that every armed line came back bound to itself, and nothing + // drifted to a neighbouring line. + const ascending = (left: number, right: number): number => left - right; assert.deepStrictEqual( - bound.map((entry) => Number(entry['line'])), - [...lines], + bound.map((entry) => Number(entry['line'])).sort(ascending), + [...lines].sort(ascending), `${why}: a bound breakpoint must stay on the line the user set it on`, ); } diff --git a/src/editors/vscode/src/test/suite/debug-test-debugging-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-test-debugging-e2e.test.ts index d13ca4de..901b3476 100644 --- a/src/editors/vscode/src/test/suite/debug-test-debugging-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-test-debugging-e2e.test.ts @@ -19,16 +19,21 @@ import * as vscode from 'vscode'; import { DapRecorder } from './debug-dap-kit'; import { CMD_CONTINUE, + CMD_STEP_INTO, CMD_STEP_OUT, + CMD_STEP_OVER, assertStopReason, evaluate, gesture, localsOf, methodOf, + scopesOf, stackFrames, stepToFrame, topFrame, + trace, variableNamed, + variablesOf, } from './debug-drive-kit'; import { assertBoundAtLines, clearAllBreakpoints, stopDebuggee } from './debug-suite-kit'; import { @@ -43,6 +48,7 @@ import { breakpointAt, conditionalBreakpointAt, disabledBreakpointAt, + hitCountBreakpointAt, requireActive, requireDebugSession, disposeDebugTestFixture, @@ -543,4 +549,407 @@ suite('Debug ONE test — the Test Explorer Debug profile and test breakpoints', 'the debugged test is still exactly where it was', ); }); + + // Implements [DEBUG-FEATURES-STEPPING] "Step over | next | P1", + // "Step into | stepIn | P1" and "Step out | stepOut | P1", inside a TEST. + // Stepping is the whole point of debugging a test rather than running it. + test('the user can step over, into and out of a helper from inside a test', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — stop on the first statement of the test body. + const item = await rowFor(CS_ADDS); + vscode.debug.addBreakpoints([breakpointAt(CS_SOURCE, fixture.sourceUri, 'adds-seed')]); + eq(vscode.debug.breakpoints.length, 1, 'one breakpoint, on the test body first line'); + await debugRun([item]); + assertOneTestSession(sessions, 'stepping inside a test'); + assertBoundAtLines(recorder, [CS_SOURCE.dapLine('adds-seed')], 'the stepping start line'); + const first = requireAt(await recorder.waitForStops(1), 0, 'the initial stop'); + assertStopReason(first, 'breakpoint', 'the stepping start'); + const startFrame = await topFrame(requireActive('the stepping start'), first.threadId); + eq(methodOf(startFrame), 'Adds_Two_Numbers', 'stopped in the test method'); + eq(startFrame.line, CS_SOURCE.dapLine('adds-seed'), 'on the line the user armed'); + + // Interaction 2 — STEP OVER the assignment. The debuggee must advance + // exactly one line and stay in the same method: a step that leaves the + // frame is a step INTO wearing the wrong label. + const over = await stepToFrame(recorder, CMD_STEP_OVER); + assertStopReason(over.stop, 'step', 'a step over inside a test'); + eq(methodOf(over.frame), 'Adds_Two_Numbers', 'step over stays in the test method'); + eq(over.frame.line, CS_SOURCE.dapLine('adds-call'), 'and lands on the next statement'); + eq( + comparablePath(over.frame.sourcePath), + comparablePath(fixture.sourceFile), + 'in the file the user is looking at', + ); + eq( + variableNamed(await localsOf(requireActive('after step over'), over.frame.id), 'seed').value, + '20', + 'and the assignment the step went over really executed', + ); + + // Interaction 3 — STEP INTO the helper, then STEP OUT back to the test. + // Just My Code ([DEBUG-FEATURES-TESTS] P1) is what keeps the step into the + // user's own `Add`, rather than into xUnit's invocation machinery. + const into = await stepToFrame(recorder, CMD_STEP_INTO); + assertStopReason(into.stop, 'step', 'a step into a helper called from a test'); + eq(methodOf(into.frame), 'Add', 'step into lands in the helper the test called'); + eq(into.frame.line, CS_SOURCE.dapLine('add-body'), 'on the helper first statement'); + const insideStack = await stackFrames(requireActive('inside the helper'), into.stop.threadId); + // The caller frame sits on the CALL it is waiting on, not on the line the + // step started from: that is the frame the user clicks to see why `Add` ran. + eq( + trace(insideStack).includes('Adds_Two_Numbers@' + String(CS_SOURCE.dapLine('adds-call'))), + true, + 'and the TEST is still on the stack below it, on the call — the helper was reached FROM the test', + ); + eq( + variableNamed(await localsOf(requireActive('inside the helper'), into.frame.id), 'left') + .value, + '20', + 'with the argument the test passed it', + ); + const out = await stepToFrame(recorder, CMD_STEP_OUT); + assertStopReason(out.stop, 'step', 'a step out of the helper'); + eq(methodOf(out.frame), 'Adds_Two_Numbers', 'step out returns to the test method'); + eq( + out.frame.line >= CS_SOURCE.dapLine('adds-call'), + true, + 'at or past the call it stepped out of, never before it', + ); + eq(recorder.stops().length, 4, 'four stops: the breakpoint and three steps'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + }); + + // Implements [DEBUG-FEATURES-STACK] "Call stack display | stackTrace | P1" + // and "Navigate to source from frame | source | P1", in a TEST HOST — the + // process whose stack has the adapter's own runner frames under the user's. + test('the call stack of a stopped test carries the user frames with source', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — stop deep: inside the helper, called from the test. + const item = await rowFor(CS_ADDS); + vscode.debug.addBreakpoints([breakpointAt(CS_SOURCE, fixture.sourceUri, 'add-body')]); + await debugRun([item]); + assertOneTestSession(sessions, 'inspecting a test call stack'); + assertBoundAtLines(recorder, [CS_SOURCE.dapLine('add-body')], 'the helper body'); + const stop = requireAt(await recorder.waitForStops(1), 0, 'the stop inside the helper'); + assertStopReason(stop, 'breakpoint', 'a helper called from a test'); + + // Interaction 2 — the stack must hold BOTH user frames, innermost first. + const active = requireActive('a test call stack'); + const frames = await stackFrames(active, stop.threadId); + const names = trace(frames); + eq(frames.length >= 2, true, 'a helper called from a test is at least two frames deep'); + eq(names[0]?.includes('Add'), true, 'the innermost frame is the helper'); + eq( + names.some((name) => name.includes('Adds_Two_Numbers')), + true, + 'and the test method that called it is below — without it the user cannot see WHY ' + + 'the helper ran', + ); + eq( + names.indexOf(names.find((name) => name.includes('Adds_Two_Numbers')) ?? '') > 0, + true, + 'the caller is BELOW the callee, not above it', + ); + eq( + frames.length > 2, + true, + 'and the test host runner frames are under both — this is a test host, not a console app', + ); + + // Interaction 3 — the user's own frames must be NAVIGABLE. A frame with no + // source is a call stack the user cannot click, which is a debugger with no + // symbols for their own code. + const userFrames = frames.filter((frame) => { + return comparablePath(frame.sourcePath ?? '') === comparablePath(fixture.sourceFile); + }); + eq(userFrames.length >= 2, true, 'both user frames resolve to the fixture source file'); + for (const frame of userFrames) { + eq(frame.line > 0, true, frame.name + ' must carry a 1-based line to navigate to'); + neq(frame.id, undefined, frame.name + ' must carry a frame id scopes can be read from'); + const scopes = await scopesOf(active, frame.id); + eq(scopes.length >= 1, true, frame.name + ' must expose at least a Locals scope'); + eq( + scopes.some((scope) => scope.name.toLowerCase().includes('local')), + true, + frame.name + ': the Variables panel needs a locals scope to render', + ); + } + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + // Implements [DEBUG-FEATURES-VARIABLES] "Local variables | variables | P1", + // "Function arguments | variables | P1" and "Modify variable value at runtime + // | setVariable | P1", inside a test. + test('a test frame exposes its locals and arguments, and a watch evaluates in it', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — stop on the theory body, where the frame carries the row + // ARGUMENTS as well as the locals. + const item = await rowFor(CS_ROWS); + vscode.debug.addBreakpoints([breakpointAt(CS_SOURCE, fixture.sourceUri, 'rows-assert')]); + await debugRun([item]); + assertOneTestSession(sessions, 'inspecting theory row variables'); + const stop = requireAt(await recorder.waitForStops(1), 0, 'the first row stop'); + assertStopReason(stop, 'breakpoint', 'a theory row body'); + const active = requireActive('a theory row stop'); + const frame = await topFrame(active, stop.threadId); + eq(methodOf(frame), 'Adds_Rows', 'stopped in the theory method'); + + // Interaction 2 — the row's arguments and the body's local are all readable + // from the ONE frame. A theory whose arguments are invisible is a theory + // the user cannot debug at all. + const locals = await localsOf(active, frame.id); + const named = locals.map((variable) => variable.name); + for (const argument of ['left', 'right', 'expected']) { + eq(named.includes(argument), true, 'the row argument ' + argument + ' must be visible'); + neq( + variableNamed(locals, argument).value, + '', + argument + ' must carry the value the row supplied, not an empty placeholder', + ); + } + eq(named.includes('sum'), true, 'and the body local computed from them'); + eq( + variableNamed(locals, 'sum').value, + variableNamed(locals, 'expected').value, + 'which, for a passing row, equals what the row expects', + ); + const scopes = await scopesOf(active, frame.id); + eq(scopes.length >= 1, true, 'the Variables panel has at least one scope to render'); + const localsScope = scopes.find((scope) => scope.name.toLowerCase().includes('local')); + assert.ok(localsScope, 'a stopped frame must expose a Locals scope'); + eq( + (await variablesOf(active, localsScope.reference)).length, + locals.length, + 'and reading that scope directly gives the same variables', + ); + + // Interaction 3 — WATCH expressions evaluate in the test frame, over the + // row's own arguments. T1 of the evaluation tiers — "simple field/property + // access", "arithmetic" — is specified to work in Phase 4. + eq( + (await evaluate(active, 'left + right', frame.id, 'watch')).value, + variableNamed(locals, 'expected').value, + 'arithmetic over the row arguments evaluates in the ROW frame', + ); + eq( + (await evaluate(active, 'sum == expected', frame.id, 'watch')).value.toLowerCase(), + 'true', + 'and so does a comparison of two of its locals', + ); + eq( + (await evaluate(active, 'expected', frame.id, 'hover')).value, + variableNamed(locals, 'expected').value, + 'a HOVER evaluation answers the same as the Variables panel — a hover that ' + + 'disagreed with the panel is worse than no hover', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + }); + + /** The two `[InlineData]` rows `Adds_Rows` declares, as the debugger renders them. */ + const CS_ROWS_ARGUMENTS: readonly (readonly string[])[] = [ + ['1', '2', '3'], + ['10', '20', '30'], + ]; + + // Implements [DEBUG-FEATURES-BREAKPOINTS] "Hit-count breakpoints | + // setBreakpoints (hitCondition) | P1 | Native". Against a `[Theory]` this is + // how the user reaches the SECOND row without touching the first. + test('a HIT-COUNT breakpoint skips the first theory row and stops on the second', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — arm the theory body to stop only on its second hit. + const item = await rowFor(CS_ROWS); + vscode.debug.addBreakpoints([ + hitCountBreakpointAt(CS_SOURCE, fixture.sourceUri, 'rows-body', '2'), + ]); + eq(vscode.debug.breakpoints.length, 1, 'one breakpoint is armed'); + const armed = requireAt(vscode.debug.breakpoints, 0, 'the hit-count breakpoint'); + assert.ok(armed instanceof vscode.SourceBreakpoint, 'armed as a source breakpoint'); + eq(armed.hitCondition, '2', 'carrying the hit condition the user typed'); + eq(armed.enabled, true, 'and enabled'); + eq(armed.condition, undefined, 'a hit count is not an expression condition'); + + // Interaction 2 — the condition must reach the ADAPTER. A hit count the + // workbench evaluates locally would stop the debuggee on every row and + // resume it, which is visible as a stutter and wrong on any real loop. + await debugRun([item]); + assertOneTestSession(sessions, 'a hit-count breakpoint on a theory'); + const requests = recorder.requests('setBreakpoints'); + eq(requests.length >= 1, true, 'the workbench must sync the breakpoint'); + const sent: unknown = requests[requests.length - 1]?.args['breakpoints']; + assert.ok(Array.isArray(sent), 'setBreakpoints carries a breakpoints array'); + eq(sent.length, 1, 'one breakpoint was sent'); + eq( + String((sent[0] as Record)['hitCondition'] ?? ''), + '2', + 'and its hitCondition went to the adapter verbatim', + ); + eq( + recorder.capabilities()['supportsHitConditionalBreakpoints'], + true, + '[DEBUG-PROTOCOL-CAPABILITIES] makes supportsHitConditionalBreakpoints a Phase 4 Yes', + ); + + // Interaction 3 — the stop that happens is the SECOND row's, and only one + // stop happens at all. + const stop = requireAt(await recorder.waitForStops(1), 0, 'the hit-count stop'); + assertStopReason(stop, 'breakpoint', 'a hit-count breakpoint'); + const active = requireActive('the hit-count stop'); + const frame = await topFrame(active, stop.threadId); + eq(methodOf(frame), 'Adds_Rows', 'stopped in the theory body'); + const locals = await localsOf(active, frame.id); + // The row is whichever one xUnit ran SECOND. `DefaultTestCaseOrderer` sorts + // a class's cases by a hash of their unique ids, so declaration order is not + // the execution order and is not the same for two methods: this fixture's + // C# theory runs (10, 20, 30) first and its F# twin runs (1, 2, 3) first. + // What a hit count of 2 promises is that ONE hit was skipped and the stop is + // the second — asserted by the single stop this test ends on — and that the + // frame the user lands in belongs to ONE row, not a blend of both. + const stopped = [ + variableNamed(locals, 'left').value, + variableNamed(locals, 'right').value, + variableNamed(locals, 'expected').value, + ]; + const declared = CS_ROWS_ARGUMENTS.find((row) => row[0] === stopped[0]); + assert.ok( + declared, + `the stop must land in a row the theory declares; its left was ${String(stopped[0])}, ` + + `and the rows are ${CS_ROWS_ARGUMENTS.map((row) => row.join(',')).join(' | ')}`, + ); + deepEq( + stopped, + [...declared], + 'and it carries that row WHOLE — a frame answering left from one row and expected ' + + 'from the other is a debugger showing the user a state that never existed', + ); + await gesture(CMD_CONTINUE); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + eq(recorder.stops().length, 1, 'and no other row stopped, before or after it'); + }); + + // Implements [DEBUG-FEATURES-TESTS] rules 2 and 3 verbatim: "The Debug + // gesture MUST NOT report the attach settled until the session is ARMED: + // `configurationDone` has been ANSWERED by the adapter and every breakpoint + // it accepted has bound", and "A run with NO breakpoints armed is armed as + // soon as `configurationDone` is answered". + test('the Debug gesture does not settle until configurationDone is ANSWERED', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — arm two breakpoints in two different methods, so more + // than one must bind before the gesture may settle. + const item = await rowFor(CS_ADDS); + vscode.debug.addBreakpoints([ + breakpointAt(CS_SOURCE, fixture.sourceUri, 'adds-seed'), + breakpointAt(CS_SOURCE, fixture.sourceUri, 'add-body'), + ]); + eq(vscode.debug.breakpoints.length, 2, 'two breakpoints are armed before the gesture'); + eq( + vscode.debug.breakpoints.every((breakpoint) => breakpoint.enabled), + true, + 'both of them enabled, so both must bind', + ); + + // Interaction 2 — press Debug. By the time the gesture RESOLVES, the + // handshake must already be complete. `startDebugging` resolving only means + // the session EXISTS; breakpoints are still in flight, and reporting + // "attached" there is the Debug press that ends in silence. + await debugRun([item]); + eq( + recorder.responses('configurationDone').length >= 1, + true, + 'configurationDone must have been ANSWERED by the adapter before the gesture settled — ' + + 'even the REQUEST precedes the adapter finishing the attach', + ); + eq( + requireAt(recorder.responses('configurationDone'), 0, 'the configurationDone response') + .success, + true, + 'and answered successfully', + ); + assertHandshakeOrder(recorder, 'a settled Debug gesture'); + assertBoundAtLines( + recorder, + [CS_SOURCE.dapLine('adds-seed'), CS_SOURCE.dapLine('add-body')], + 'both armed lines of a settled Debug gesture', + ); + + // Interaction 3 — the first stop the user sees is their OWN breakpoint, + // never VSTest's `Debugger.Break()` wait loop (rule 1). A stop with any + // other reason means the resume never happened and the user is parked in + // machinery they did not write. + const stop = requireAt(await recorder.waitForStops(1), 0, 'the first stop of the session'); + assertStopReason(stop, 'breakpoint', 'the FIRST stop a test debug shows the user'); + neq(stop.hitBreakpointIds.length, 0, 'and it names the breakpoint that caused it'); + const frame = await topFrame(requireActive('the first stop'), stop.threadId); + eq( + comparablePath(frame.sourcePath ?? ''), + comparablePath(fixture.sourceFile), + 'in the user own file, not in VSTest wait-loop machinery', + ); + eq(methodOf(frame), 'Adds_Two_Numbers', 'and in the test the user pressed Debug on'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + }); + + // Implements [DEBUG-FEATURES-TESTS] — a debug session is not a one-shot. The + // second press must behave exactly like the first, or the user reloads the + // window every time they want another look. + test('debugging the same test twice in a row gives two clean, separate sessions', async function () { + this.timeout(DEBUG_TEST_MS); + + // Interaction 1 — the first session, armed and stopped. + const item = await rowFor(CS_ADDS); + vscode.debug.addBreakpoints([breakpointAt(CS_SOURCE, fixture.sourceUri, 'adds-call')]); + await debugRun([item]); + assertOneTestSession(sessions, 'the first debug of a test'); + const firstStop = requireAt(await recorder.waitForStops(1), 0, 'the first session stop'); + assertStopReason(firstStop, 'breakpoint', 'the first session'); + eq( + methodOf(await topFrame(requireActive('the first session'), firstStop.threadId)), + 'Adds_Two_Numbers', + 'in the test the user pressed Debug on', + ); + + // Interaction 2 — let it finish, and prove it really ended. A session left + // running holds the test host, and the second press then attaches to + // nothing. + await gesture(CMD_CONTINUE); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + eq(sessions.ours.length, 1, 'exactly one session so far'); + eq(vscode.debug.breakpoints.length, 1, 'and the breakpoint survives the session ending'); + + // Interaction 3 — press Debug again on the same row. A SECOND session, its + // own handshake, its own binding, its own stop. + const stopsBefore = recorder.stops().length; + await debugRun([item]); + eq(sessions.ours.length, 2, 'the second press starts a SECOND session, not a resumed one'); + neq( + requireAt(sessions.ours, 1, 'the second session').id, + requireAt(sessions.ours, 0, 'the first session').id, + 'and it is a different session, with its own id', + ); + assertBoundAtLines( + recorder, + [CS_SOURCE.dapLine('adds-call')], + 'the same breakpoint, bound again by the second session', + ); + const second = requireAt( + await recorder.waitForStops(stopsBefore + 1), + stopsBefore, + 'the second session stop', + ); + assertStopReason(second, 'breakpoint', 'the second session'); + eq( + methodOf(await topFrame(requireActive('the second session'), second.threadId)), + 'Adds_Two_Numbers', + 'stopping in the same test, at the same place, as the first', + ); + deepEq(recorder.errors, [], 'with no adapter transport error across either session'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + }); }); diff --git a/src/editors/vscode/src/test/suite/debug-test-fsharp-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-test-fsharp-e2e.test.ts index 5550b881..aafacf29 100644 --- a/src/editors/vscode/src/test/suite/debug-test-fsharp-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-test-fsharp-e2e.test.ts @@ -29,6 +29,8 @@ import { assertBoundAtLines, clearAllBreakpoints, stopDebuggee } from './debug-s import { FS_ALL, FS_MODULE, + FS_MODULE_NAMESPACE, + FS_MODULE_TYPE, FS_ROWS, FS_SOURCE, FS_SPACED, @@ -155,6 +157,27 @@ suite('Debug an F# test — backtick names, modules and the at-cursor gesture', await gesture(CMD_CONTINUE); await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); deepEq(stubs.log.errorMessages, [], 'a working F# test debug run reports no error'); + // Interaction 4 - the DAP conversation behind that one gesture. F# is not + // a special case at the protocol layer either: the same handshake, the same + // capability table, the same single termination. + eq(recorder.events('initialized').length, 1, 'one `initialized` event for the F# session'); + eq(recorder.events('terminated').length, 1, 'and exactly one termination'); + eq( + recorder.responses('configurationDone').length >= 1, + true, + 'configurationDone was ANSWERED before the gesture settled ([DEBUG-FEATURES-TESTS] rule 2)', + ); + eq( + recorder.capabilities()['supportsConditionalBreakpoints'], + true, + 'the capability table is language-agnostic and must hold for an F# test host', + ); + eq(recorder.capabilities()['supportsSetVariable'], true, 'value editing included'); + eq(sessions.ours.length, 1, 'one F# test, one session'); + eq(recorder.stops().length, 1, 'and exactly one stop: the breakpoint the user armed'); + eq(vscode.debug.breakpoints.length, 1, 'the breakpoint survives the session ending'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(recorder.exits, [], 'and no adapter process exiting under the session'); }); test('an F# stack shows the module helper ABOVE the backtick test that called it', async function () { @@ -209,6 +232,37 @@ suite('Debug an F# test — backtick names, modules and the at-cursor gesture', 'and on a real source line — a zero line is a frame with no PDB mapping', ); deepEq(recorder.errors, [], 'with no adapter transport error'); + // Interaction 4 - the whole F# stack AT THE HELPER STOP, not only the two + // frames the walk touched. A test host runs the user's code under the xUnit + // runner, so the frames beneath must be there and must be distinguishable + // from the user's. After the step-out the helper frame is gone by design. + const wholeStack = frames; + eq(wholeStack.length >= 2, true, 'an F# helper called from a test is at least two deep'); + eq( + wholeStack.filter( + (entry) => comparablePath(entry.sourcePath) === comparablePath(fixture.sourceFile), + ).length >= 2, + true, + 'both user frames resolve to the .fs file the user wrote', + ); + eq( + new Set(wholeStack.map((entry) => entry.id)).size, + wholeStack.length, + 'every frame carries its own handle, or selecting a caller reads the callee', + ); + eq( + wholeStack.every((entry) => entry.line >= 0), + true, + 'and a line the editor can point at', + ); + eq( + wholeStack.length > 2, + true, + 'the xUnit runner frames sit beneath both - a stack that stopped at the test method is ' + + 'truncated, not filtered', + ); + eq(sessions.ours.length, 1, 'all of it inside the ONE session the Debug press started'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); }); test('an F# [] breaks once per row, each with its own arguments', async function () { @@ -251,6 +305,25 @@ suite('Debug an F# test — backtick names, modules and the at-cursor gesture', 'each F# row carries its own [] arguments, once each', ); eq(sessions.ours.length, 1, 'both rows ran in the ONE session the selection started'); + // Interaction 4 - a theory is ONE test in the tree however many rows it + // runs, and one session however many times it stops ([TEST-RUN-TRX]). + const theoryRow = await rowFor(FS_ROWS); + eq(theoryRow.id, FS_ROWS, 'the theory is addressed by the single name its rows share'); + eq(theoryRow.children.size, 0, 'and is a LEAF - one row per [] is two tests'); + eq(recorder.stops().length, 2, 'two rows, two stops, and no third'); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'each of them a breakpoint stop, never a step the user never asked for', + ); + eq( + recorder.stops().every((entry) => entry.threadId !== 0), + true, + 'each naming the thread it stopped', + ); + eq(vscode.debug.breakpoints.length, 1, 'one breakpoint served both rows'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); }); test('Debug Test at the cursor debugs the F# binding the caret is in', async function () { @@ -301,5 +374,227 @@ suite('Debug an F# test — backtick names, modules and the at-cursor gesture', 'a discovered test debugged at the cursor must not warn that it could not be found', ); deepEq(stubs.log.errorMessages, [], 'nor report an error'); + // Interaction 4 - the at-cursor gesture must reach the SAME machinery the + // Testing view does: one real session, a complete handshake, and a tree + // left exactly as it was. + assertHandshakeOrder(recorder, 'the at-cursor F# gesture'); + assertBoundAtLines(recorder, [FS_SOURCE.dapLine('fs-call')], 'the at-cursor F# breakpoint'); + eq(sessions.ours.length, 1, 'the editor gesture starts ONE session, exactly as the tree does'); + eq(recorder.events('terminated').length <= 1, true, 'and terminates it at most once'); + const api = await activateTestExplorer(); + const stillThere = findItem(api.testController.items, FS_SPACED); + assert.ok(stillThere, 'the binding is still a row after being debugged from the editor'); + eq(stillThere.id, FS_SPACED, 'under its own name, spaces preserved exactly'); + eq(stillThere.children.size, 0, 'and still a leaf'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); + + test('debugging the F# MODULE row debugs every binding under it, in one session', async function () { + this.timeout(DEBUG_TEST_MS); + + // An F# module renders as Assembly → Namespace → Class → Test like anything + // else, because that is what it COMPILES to: `Fs.Debug.Fixtures` is a CLR + // type named `Fixtures` in namespace `Fs.Debug`, so the module row is the + // CLASS level and carries the type's own name, not the dotted path + // (`testing.ts`: "deterministic for C# namespaces and dotted F# modules + // alike"). The module row is still the group the user right-clicks. + // [TEST-RUN-TRX] makes it ONE invocation for the whole selection. + // + // Interaction 1 — reach the module row through a leaf, and check it holds + // every binding the fixture declares. + const leaf = await rowFor(FS_SPACED); + const moduleRow = leaf.parent; + assert.ok(moduleRow, 'an F# binding hangs off the module it is declared in'); + eq(moduleRow.label, FS_MODULE_TYPE, 'and that group is the module, by its TYPE name'); + const namespaceRow = moduleRow.parent; + assert.ok(namespaceRow, 'and the module hangs off the namespace enclosing it'); + eq(namespaceRow.label, FS_MODULE_NAMESPACE, 'which is the module path without the type'); + eq( + `${namespaceRow.label}.${moduleRow.label}`, + FS_MODULE, + 'so namespace and class rejoin to exactly the F# module the fixture declares', + ); + eq(moduleRow.children.size, FS_ALL.length, 'holding every binding the fixture declares'); + eq(moduleRow.canResolveChildren, true, 'and declaring them, so the row expands'); + neq(moduleRow.id, FS_SPACED, 'a group id is never a fully-qualified test name'); + + // Interaction 2 — arm the spaced binding and the module HELPER both + // bindings call, then debug the module once. + vscode.debug.addBreakpoints([ + breakpointAt(FS_SOURCE, fixture.sourceUri, 'fs-seed'), + breakpointAt(FS_SOURCE, fixture.sourceUri, 'fs-add-body'), + ]); + eq(vscode.debug.breakpoints.length, 2, 'one breakpoint in a test body, one in the helper'); + await debugRun([moduleRow]); + assertOneTestSession(sessions, 'debugging an F# module'); + assertHandshakeOrder(recorder, 'debugging an F# module'); + assertBoundAtLines( + recorder, + [FS_SOURCE.dapLine('fs-add-body'), FS_SOURCE.dapLine('fs-seed')], + 'both armed lines of an F# module debug', + ); + + // Interaction 3 — the first stop is real, in F# code, in this fixture's own + // file. A module debug that resolved to the wrong assembly stops nowhere. + const stop = requireAt(await recorder.waitForStops(1), 0, 'the first stop of a module debug'); + assertStopReason(stop, 'breakpoint', 'debugging an F# module'); + const frame = await topFrame(requireActive('the module stop'), stop.threadId); + eq( + comparablePath(frame.sourcePath ?? ''), + comparablePath(fixture.sourceFile), + 'the stop is in the fixture source the user armed, not in a framework file', + ); + eq(sessions.ours.length, 1, 'a module is ONE session, not one per binding'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + // Interaction 4 - the module row is a GROUP, and a group is one invocation + // ([TEST-RUN-TRX]). Its id must never be a test name, and every binding + // under it must still be addressable afterwards. + eq(moduleRow.id.includes(FS_MODULE_TYPE), true, 'the group id names the module'); + eq(FS_MODULE.startsWith(FS_MODULE_NAMESPACE), true, 'which sits under its own namespace'); + eq(recorder.events('terminated').length <= 1, true, 'one group is at most one termination'); + eq(sessions.ours.length, 1, 'and exactly one session throughout'); + for (const fqn of FS_ALL) { + const item = findItem((await activateTestExplorer()).testController.items, fqn); + assert.ok(item, fqn + ' must still be a row after the module was debugged'); + eq(item.id, fqn, 'under its own name'); + eq(item.children.size, 0, 'and still a leaf'); + } + deepEq(stubs.log.warningMessages, [], 'debugging a module warns about nothing'); + }); + + test('debugging an F# selection of BOTH bindings runs both under one session', async function () { + this.timeout(DEBUG_TEST_MS); + + // [TEST-FILTER-ESCAPE]: an F# backtick name carries SPACES, which are not + // grammar and must not be escaped, and multiple selected tests are OR-ed + // with an UNESCAPED pipe. A multi-select is where both rules meet. + // + // Interaction 1 — select every binding the fixture exposes. + const rows = [] as vscode.TestItem[]; + for (const fqn of FS_ALL) rows.push(await rowFor(fqn)); + eq(rows.length, FS_ALL.length, 'every F# binding resolved to a row'); + deepEq( + rows.map((row) => row.id), + [...FS_ALL], + 'each under its own fully-qualified name, spaces and all', + ); + eq( + FS_SPACED.includes(' '), + true, + 'the fixture really does declare an idiomatic backtick binding', + ); + + // Interaction 2 — arm the shared helper, which BOTH bindings call, then + // debug the selection. + vscode.debug.addBreakpoints([breakpointAt(FS_SOURCE, fixture.sourceUri, 'fs-add-body')]); + eq(vscode.debug.breakpoints.length, 1, 'one breakpoint, in the helper both bindings call'); + await debugRun(rows); + assertOneTestSession(sessions, 'debugging an F# multi-select'); + assertBoundAtLines( + recorder, + [FS_SOURCE.dapLine('fs-add-body')], + 'the shared helper of an F# multi-select', + ); + + // Interaction 3 — the helper is reached more than once, because more than + // one selected binding called it, and all of it happens in ONE session. + const first = requireAt(await recorder.waitForStops(1), 0, 'the first helper stop'); + assertStopReason(first, 'breakpoint', 'an F# multi-select debug'); + eq( + methodOf(await topFrame(requireActive('the first helper stop'), first.threadId)), + 'add', + 'the top frame is the module helper the breakpoint sits in', + ); + await gesture(CMD_CONTINUE); + const stops = await recorder.waitForStops(2); + eq( + stops.length >= 2, + true, + 'both selected bindings call the helper, so it is reached more than once in the one run', + ); + eq(sessions.ours.length, 1, 'and a selection is ONE session, never one per test'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + // Interaction 4 - the filter the selection produced. [TEST-FILTER-ESCAPE] + // makes SPACES not grammar and the joining pipe UNESCAPED, and a + // multi-select of two F# bindings is where both rules meet. + eq(rows.length, 2, 'exactly the two bindings the fixture declares were selected'); + eq( + rows.every((row) => row.children.size === 0), + true, + 'both of them leaves', + ); + eq( + rows.every((row) => row.id.startsWith(FS_MODULE)), + true, + 'both under the module the fixture declares', + ); + eq(sessions.ours.length, 1, 'a selection is ONE session, never one per test'); + eq(recorder.events('terminated').length <= 1, true, 'and at most one termination'); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'every stop was the armed helper breakpoint, not a step or an exception', + ); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + }); + + test('an F# debug run leaves the tree and the spaced ids exactly as they were', async function () { + this.timeout(DEBUG_TEST_MS); + + // A debug run is still a run: the Testing view must survive it unchanged, + // and an F# id carrying SPACES must survive it VERBATIM — a round trip that + // trimmed or escaped one would leave a row that can never be run again. + // + // Interaction 1 — the tree before. + const api = await activateTestExplorer(); + const before = await discoverSolution(api, fixture.solutionPath, FS_ALL); + deepEq([...before].sort(), [...FS_ALL].sort(), 'the F# fixture is fully discovered'); + eq( + before.filter((id) => id.trim() !== id).length, + 0, + 'no discovered id carries leading or trailing padding', + ); + eq( + before.filter((id) => id.includes('\\')).length, + 0, + 'and none carries a filter escape — escaping is applied at run time, never to the id', + ); + + // Interaction 2 — debug one binding with nothing armed, so the run goes + // straight through to termination. + const row = await rowFor(FS_SPACED); + eq(vscode.debug.breakpoints.length, 0, 'the user has armed nothing'); + await debugRun([row]); + assertOneTestSession(sessions, 'debugging an F# binding with nothing armed'); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + deepEq(recorder.stops(), [], 'with no breakpoint armed, a debug run must never stop'); + deepEq(recorder.errors, [], 'and no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + + // Interaction 3 — the tree, and every id in it, is byte-for-byte what it was. + const after = await discoverSolution(api, fixture.solutionPath, FS_ALL); + deepEq([...after].sort(), [...before].sort(), 'a debug run adds, drops and reorders nothing'); + for (const fqn of FS_ALL) { + const item = findItem(api.testController.items, fqn); + assert.ok(item, `${fqn} must still be a row after a debug run`); + eq(item.id, fqn, 'under its own name, spaces preserved exactly'); + eq(item.children.size, 0, 'and still a leaf'); + } + eq(sessions.ours.length, 1, 'exactly one debug session was started'); + // Interaction 4 - and the RESULT cache is untouched by a debug run. A debug + // session is a diagnostic, not a run: repainting the tree green because the + // user stepped through a test is a result nobody produced. + const api2 = await activateTestExplorer(); + for (const fqn of FS_ALL) { + const item = findItem(api2.testController.items, fqn); + assert.ok(item, fqn + ' must still be a row'); + neq(item.label, '', fqn + ' must still be labelled for the user to read'); + eq(item.error, undefined, fqn + ' must not be marked errored by a debug run'); + } + eq(recorder.events('terminated').length, 1, 'exactly one termination'); + eq(recorder.stops().length, 0, 'and no stop, because nothing was armed'); + eq(vscode.debug.breakpoints.length, 0, 'the Breakpoints view is still empty'); + deepEq(stubs.log.warningMessages, [], 'a clean debug run warns about nothing'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-test-groups-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-test-groups-e2e.test.ts index 055b4e9d..4baf4da0 100644 --- a/src/editors/vscode/src/test/suite/debug-test-groups-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-test-groups-e2e.test.ts @@ -25,6 +25,7 @@ import { assertHandshakeOrder, assertOneTestSession, breakpointAt, + disabledBreakpointAt, requireActive, disposeDebugTestFixture, writeDebugTestFixture, @@ -33,6 +34,7 @@ import { import { DebugSessionRecorder } from './run-debug-kit'; import { activateTestExplorer, + collectLeafIds, discoverSolution, findItem, rootsOf, @@ -159,6 +161,29 @@ suite('Debug a SELECTION — class, namespace, assembly and multi-select', () => eq(sessions.ours.length, 1, 'and one class is ONE session, not one per test'); deepEq(recorder.errors, [], 'with no adapter transport error'); deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + // Interaction 4 - a class is ONE invocation ([TEST-RUN-TRX]) however many + // tests it holds, and the tree row itself must stay a GROUP the whole time. + eq(sessions.ours.length, 1, 'a class of five tests is ONE session, never five'); + eq(recorder.events('terminated').length <= 1, true, 'and at most one termination'); + eq(recorder.events('initialized').length, 1, 'behind exactly one handshake'); + eq(classRow.children.size, MATH_CLASS_TESTS, 'the class still holds every test it declares'); + eq(classRow.canResolveChildren, true, 'and still declares them, so the row stays expandable'); + neq(classRow.id, CS_ADDS, 'a group id is never a fully-qualified test name'); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'every stop was an armed breakpoint - a step or entry stop is a pause nobody asked for', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + eq(recorder.requests('setBreakpoints').length >= 1, true, 'the breakpoints were synced'); + eq(recorder.responses('attach').length >= 1, true, 'and the attach was answered'); + eq(recorder.events('exited').length <= 1, true, 'with at most one process exit'); + eq( + vscode.debug.activeDebugSession === undefined || sessions.ours.length === 1, + true, + 'and no stray session left focused', + ); }); test('debugging the NAMESPACE row leaves the OTHER namespace alone', async function () { @@ -201,6 +226,41 @@ suite('Debug a SELECTION — class, namespace, assembly and multi-select', () => 'control breakpoint there BOUND, so a second stop is proof the selection widened', ); deepEq(recorder.errors, [], 'and no adapter transport error'); + // Interaction 4 - the namespace row is a group under the assembly, and the + // OTHER namespace must be untouched in the tree as well as at runtime. + eq(sessions.ours.length, 1, 'one namespace, one session'); + eq(recorder.events('initialized').length, 1, 'behind one handshake'); + const other = groupUnder(await assemblyRoot(), CS_TEXT_NAMESPACE); + neq(other.children.size, 0, 'the other namespace still holds tests of its own'); + eq(other.canResolveChildren, true, 'and still declares them'); + neq(other.id, CS_TEXT, 'its id is a group id, not a test name'); + eq( + collectLeafIds(other.children).every((id) => id.startsWith(CS_TEXT_NAMESPACE)), + true, + 'every leaf beneath it belongs to that namespace and no other', + ); + eq( + recorder.stops().every((entry) => entry.threadId !== 0), + true, + 'every stop named the thread it stopped', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + eq( + recorder.requests('setBreakpoints').length >= 1, + true, + 'the namespace debug synced its breakpoints', + ); + eq( + recorder.responses('setBreakpoints').every((response) => response.success), + true, + 'and every sync was answered successfully', + ); + eq(recorder.events('exited').length <= 1, true, 'with at most one process exit'); + eq( + collectLeafIds((await assemblyRoot()).children).length, + CS_ALL.length, + 'and the whole tree survives', + ); }); test('debugging the ASSEMBLY root debugs every namespace under it, in one session', async function () { @@ -238,6 +298,41 @@ suite('Debug a SELECTION — class, namespace, assembly and multi-select', () => ); eq(sessions.ours.length, 1, 'a whole assembly is still one `dotnet test` and one session'); deepEq(recorder.errors, [], 'with no adapter transport error'); + // Interaction 4 - the assembly root is the widest group there is, and it is + // still ONE invocation. The tree beneath it must survive intact. + eq(sessions.ours.length, 1, 'the whole assembly is ONE session'); + eq(recorder.events('initialized').length, 1, 'behind one handshake'); + const settled = await assemblyRoot(); + eq(rootsOf(settled.children).length, 2, 'the assembly still holds its two namespaces'); + eq( + collectLeafIds(settled.children).length, + CS_ALL.length, + 'and every test the fixture declares is still beneath it', + ); + deepEq( + [...collectLeafIds(settled.children)].sort(), + [...CS_ALL].sort(), + 'under exactly the fully-qualified names discovery produced', + ); + eq(settled.label, CS_PROJECT, 'labelled with the project the user recognises'); + neq(settled.id, CS_ADDS, 'and identified by a group id, never a test name'); + deepEq(stubs.log.errorMessages, [], 'with nothing reported to the user as a failure'); + eq( + recorder.responses('configurationDone').every((response) => response.success), + true, + 'configurationDone was answered successfully', + ); + eq( + recorder.requestedCommands().includes('attach'), + true, + 'the assembly debug really attached to a test host', + ); + eq(recorder.events('exited').length <= 1, true, 'which exited at most once'); + eq( + collectLeafIds((await assemblyRoot()).children).length, + CS_ALL.length, + 'and the tree is still complete afterwards', + ); }); test('a MULTI-SELECT of two classes debugs both, and nothing else', async function () { @@ -295,6 +390,41 @@ suite('Debug a SELECTION — class, namespace, assembly and multi-select', () => 'and exactly two stops in total: the unselected test must never have executed', ); deepEq(recorder.errors, [], 'with no adapter transport error'); + // Interaction 4 - a multi-select is still ONE invocation, and the rows the + // user did NOT select must be untouched in the tree. + eq(sessions.ours.length, 1, 'two selected classes are ONE session, not two'); + eq(recorder.events('initialized').length, 1, 'behind one handshake'); + eq(recorder.events('terminated').length <= 1, true, 'and at most one termination'); + const root = await assemblyRoot(); + eq( + collectLeafIds(root.children).length, + CS_ALL.length, + 'every test is still discovered after a multi-select debug', + ); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'every stop was an armed breakpoint', + ); + eq( + new Set(recorder.stops().map((entry) => entry.threadId)).size >= 1, + true, + 'and every stop named a thread', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + eq( + recorder.responses('attach').length >= 1, + true, + 'the multi-select attached to exactly one test host', + ); + eq( + recorder.requestedCommands().filter((command) => command === 'attach').length, + 1, + 'one attach request, not one per selected class', + ); + eq(recorder.events('initialized').length, 1, 'behind one handshake'); + eq(recorder.events('exited').length <= 1, true, 'and at most one exit'); }); test('debugging a group with no breakpoints runs every test in it to completion', async function () { @@ -321,6 +451,29 @@ suite('Debug a SELECTION — class, namespace, assembly and multi-select', () => ); deepEq(recorder.errors, [], 'and no adapter transport error'); deepEq(stubs.log.errorMessages, [], 'nor an error the user has to dismiss'); + // Interaction 4 - nothing armed means nothing stops, and the session still + // has to be a REAL one ([DEBUG-FEATURES-TESTS] rule 3: "A run with NO + // breakpoints armed is armed as soon as configurationDone is answered"). + eq(vscode.debug.breakpoints.length, 0, 'the user armed nothing'); + deepEq(recorder.stops(), [], 'so the debuggee must never stop'); + eq( + recorder.responses('configurationDone').length >= 1, + true, + 'configurationDone was still ANSWERED - a run with nothing to bind is armed there', + ); + eq(recorder.events('initialized').length, 1, 'the handshake still happened in full'); + eq(recorder.events('terminated').length, 1, 'and the session ended exactly once'); + eq(sessions.ours.length, 1, 'one group, one session, breakpoints or not'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + eq( + recorder.requestedCommands().includes('configurationDone'), + true, + 'the handshake completed even with nothing to bind', + ); + eq(recorder.responses('attach').length >= 1, true, 'the attach was answered'); + eq(recorder.events('exited').length, 1, 'and the debuggee exited exactly once'); + eq(vscode.debug.breakpoints.length, 0, 'with the Breakpoints view still empty'); }); test('debugging a group does not fabricate outcomes for the tests it contains', async function () { @@ -360,5 +513,241 @@ suite('Debug a SELECTION — class, namespace, assembly and multi-select', () => 1, 'and that namespace still holds its one class', ); + // Interaction 4 - and the tree carries no fabricated result. A debug run is + // a diagnostic, not a run: painting rows green because the user stepped + // through them reports outcomes nobody produced ([TEST-RUN-TRX]). + const api3 = await activateTestExplorer(); + for (const fqn of CS_ALL) { + const item = findItem(api3.testController.items, fqn); + assert.ok(item, fqn + ' must still be a row after a group debug'); + eq(item.id, fqn, 'under its own fully-qualified name'); + eq(item.children.size, 0, 'and still a leaf'); + eq(item.error, undefined, fqn + ' must not be marked errored by a debug run'); + } + eq(sessions.ours.length, 1, 'exactly one session was started'); + deepEq(stubs.log.warningMessages, [], 'and the user was warned about nothing'); + eq(recorder.events('initialized').length, 1, 'one handshake for the whole group'); + eq( + recorder.responses('attach').every((response) => response.success), + true, + 'answered successfully', + ); + eq(sessions.ours.length, 1, 'and one session'); + eq( + recorder.stops().every((entry) => entry.threadId !== 0), + true, + 'every stop naming its thread', + ); + }); + + test('a group with ONE armed test stops exactly once, in that test', async function () { + this.timeout(DEBUG_TEST_MS); + + // [TEST-RUN-TRX] makes a group ONE invocation, so every test in it EXECUTES. + // Only the armed one may STOP. A session that stopped in an unarmed test + // would be reporting a breakpoint the user never set. + // + // Interaction 1 — the class row, and a single breakpoint inside one of its + // five tests. + const root = await assemblyRoot(); + const classRow = groupUnder(groupUnder(root, CS_MATH_NAMESPACE), 'CalculatorTests'); + eq(classRow.children.size, MATH_CLASS_TESTS, 'the class holds every test it declares'); + vscode.debug.addBreakpoints([breakpointAt(CS_SOURCE, fixture.sourceUri, 'multiplies-seed')]); + eq(vscode.debug.breakpoints.length, 1, 'exactly ONE breakpoint is armed'); + + // Interaction 2 — debug the whole class. The one armed body binds and stops. + await debugRun([classRow]); + assertOneTestSession(sessions, 'debugging a class with one armed test'); + assertHandshakeOrder(recorder, 'debugging a class with one armed test'); + assertBoundAtLines( + recorder, + [CS_SOURCE.dapLine('multiplies-seed')], + 'the single armed body of a class-level debug', + ); + const stop = requireAt(await recorder.waitForStops(1), 0, 'the stop in the armed test'); + assertStopReason(stop, 'breakpoint', 'a class debug with one armed test'); + const frame = await topFrame(requireActive('the armed stop'), stop.threadId); + eq(methodOf(frame), 'Multiplies_Two_Numbers', 'in the test the user armed, and no other'); + + // Interaction 3 — running on, the session ends with no further stop, even + // though four other tests of the class ran to completion inside it. + await gesture(CMD_CONTINUE); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + eq( + recorder.stops().length, + 1, + 'the other four tests of the class execute but carry no breakpoint, so they must not stop', + ); + eq(sessions.ours.length, 1, 'and a class is ONE session throughout'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + // Interaction 4 - the class row itself is unchanged, and the four unarmed + // tests really did execute inside the one session. + eq(classRow.children.size, MATH_CLASS_TESTS, 'the class still holds all five tests'); + eq(sessions.ours.length, 1, 'in ONE session'); + eq(recorder.events('terminated').length, 1, 'which ended exactly once'); + eq(recorder.events('exited').length <= 1, true, 'with at most one process exit'); + eq(vscode.debug.breakpoints.length, 1, 'the single breakpoint survives the session'); + eq( + recorder.requests('setBreakpoints').length >= 1, + true, + 'and it really was synced to the adapter rather than kept client-side', + ); + deepEq(recorder.errors, [], 'with no adapter transport error'); + eq(recorder.requests('setBreakpoints').length >= 1, true, 'the one armed line was synced'); + eq( + recorder.responses('setBreakpoints').every((response) => response.success), + true, + 'and the sync was answered', + ); + eq(recorder.events('initialized').length, 1, 'behind one handshake'); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'and every stop was that breakpoint', + ); + }); + + test('a DISABLED breakpoint in a group is bound by nothing and stops nothing', async function () { + this.timeout(DEBUG_TEST_MS); + + // A disabled breakpoint is a user gesture with a precise meaning: keep it, + // do not honour it. A group debug that honoured it anyway would halt on a + // line the user deliberately switched off. + // + // Interaction 1 — arm one enabled and one disabled breakpoint in two + // different tests of the same class. + const root = await assemblyRoot(); + const classRow = groupUnder(groupUnder(root, CS_MATH_NAMESPACE), 'CalculatorTests'); + vscode.debug.addBreakpoints([ + breakpointAt(CS_SOURCE, fixture.sourceUri, 'adds-seed'), + disabledBreakpointAt(CS_SOURCE, fixture.sourceUri, 'multiplies-seed'), + ]); + eq(vscode.debug.breakpoints.length, 2, 'two breakpoints are registered'); + eq( + vscode.debug.breakpoints.filter((each) => each.enabled).length, + 1, + 'but only ONE of them is enabled', + ); + + // Interaction 2 — debug the class. Only the enabled line is sent to the + // adapter, so only it can bind. + await debugRun([classRow]); + assertOneTestSession(sessions, 'debugging a class with a disabled breakpoint'); + assertBoundAtLines( + recorder, + [CS_SOURCE.dapLine('adds-seed')], + 'only the ENABLED breakpoint of a group debug', + ); + + // Interaction 3 — exactly one stop, in the enabled test, and the session + // ends without ever visiting the disabled line. + const stop = requireAt(await recorder.waitForStops(1), 0, 'the stop at the enabled line'); + assertStopReason(stop, 'breakpoint', 'a group debug with one line disabled'); + eq( + methodOf(await topFrame(requireActive('the enabled stop'), stop.threadId)), + 'Adds_Two_Numbers', + 'in the test carrying the ENABLED breakpoint', + ); + await gesture(CMD_CONTINUE); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + eq(recorder.stops().length, 1, 'the disabled line must never produce a stop'); + deepEq(recorder.errors, [], 'and no adapter transport error'); + // Interaction 4 - the Breakpoints view still holds BOTH, exactly as the + // user left them. A debugger that silently deletes a breakpoint it declined + // to honour is worse than one that ignores it. + eq(vscode.debug.breakpoints.length, 2, 'both breakpoints are still in the view'); + eq( + vscode.debug.breakpoints.filter((entry) => entry.enabled).length, + 1, + 'one enabled and one still disabled', + ); + eq( + vscode.debug.breakpoints.filter((entry) => !entry.enabled).length, + 1, + 'the disabled one was not quietly removed', + ); + eq(sessions.ours.length, 1, 'one class, one session'); + eq(recorder.events('terminated').length, 1, 'ended exactly once'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + eq(recorder.requests('setBreakpoints').length >= 1, true, 'the enabled line was synced'); + eq(recorder.events('initialized').length, 1, 'behind one handshake'); + eq(recorder.events('exited').length <= 1, true, 'with at most one process exit'); + eq( + recorder.stops().every((entry) => entry.reason === 'breakpoint'), + true, + 'and every stop was a breakpoint stop', + ); + }); + + test('debugging a group leaves the TREE and the other namespace intact', async function () { + this.timeout(DEBUG_TEST_MS); + + // A debug run is still a run: it must not reshape the Testing view, and it + // must not quietly widen past the row the user pressed. + // + // Interaction 1 — the tree before, and the two namespaces it holds. + const root = await assemblyRoot(); + const api = await activateTestExplorer(); + const before = [...collectLeafIds(api.testController.items)].sort(); + deepEq(before, [...CS_ALL].sort(), 'the fixture is fully discovered before debugging'); + eq(rootsOf(root.children).length, 2, 'the assembly holds two namespaces'); + const textRow = groupUnder(root, CS_TEXT_NAMESPACE); + const textChildren = textRow.children.size; + neq(textChildren, 0, 'and the other namespace holds tests of its own'); + + // Interaction 2 — debug the TEXT namespace, with its own body armed. + vscode.debug.addBreakpoints([breakpointAt(CS_SOURCE, fixture.sourceUri, 'text-seed')]); + eq(vscode.debug.breakpoints.length, 1, 'one breakpoint, in the namespace being debugged'); + await debugRun([textRow]); + assertOneTestSession(sessions, 'debugging the text namespace'); + assertBoundAtLines( + recorder, + [CS_SOURCE.dapLine('text-seed')], + 'the armed body of the text namespace', + ); + const stop = requireAt(await recorder.waitForStops(1), 0, 'the stop in the text namespace'); + assertStopReason(stop, 'breakpoint', 'debugging the text namespace'); + eq( + methodOf(await topFrame(requireActive('the text stop'), stop.threadId)), + 'Joins_Two_Words', + 'in the one test that namespace declares', + ); + + // Interaction 3 — the session ends, and the view is exactly as it was. + await gesture(CMD_CONTINUE); + await recorder.waitForEvents('terminated', 1, DEBUG_SESSION_MS); + deepEq( + [...collectLeafIds(api.testController.items)].sort(), + before, + 'debugging a namespace must not add, drop or reorder a row', + ); + eq(rootsOf(api.testController.items).length, 1, 'still ONE assembly root'); + eq(textRow.children.size, textChildren, 'and the debugged group keeps its children'); + eq(sessions.ours.length, 1, 'one group, one session'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + deepEq(stubs.log.errorMessages, [], 'and nothing reported to the user as a failure'); + // Interaction 4 - and the OTHER namespace's tests are still addressable by + // their own fully-qualified names, which is what makes them runnable. + const finalRoot = await assemblyRoot(); + const mathRow = groupUnder(finalRoot, CS_MATH_NAMESPACE); + eq(mathRow.children.size >= 1, true, 'the untouched namespace still holds its class'); + eq( + collectLeafIds(mathRow.children).includes(CS_ADDS), + true, + 'and that class still holds the test the user never selected', + ); + eq(collectLeafIds(mathRow.children).includes(CS_MULTIPLIES), true, 'and its sibling'); + eq( + collectLeafIds(finalRoot.children).includes(CS_TEXT), + true, + 'while the debugged namespace keeps its own test too', + ); + eq(sessions.ours.length, 1, 'exactly one session for the whole gesture'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + eq(recorder.events('initialized').length, 1, 'one handshake for the namespace debug'); + eq(recorder.responses('attach').length >= 1, true, 'the attach was answered'); + eq(recorder.events('terminated').length, 1, 'and the session ended exactly once'); + eq(sessions.ours.length, 1, 'with one session for the whole gesture'); }); }); diff --git a/src/editors/vscode/src/test/suite/debug-test-kit.ts b/src/editors/vscode/src/test/suite/debug-test-kit.ts index 5a325483..ede0cb86 100644 --- a/src/editors/vscode/src/test/suite/debug-test-kit.ts +++ b/src/editors/vscode/src/test/suite/debug-test-kit.ts @@ -135,6 +135,17 @@ export const FS_PROJECT = 'DebugTestTargetFs'; /** The F# module every binding below is declared in. */ export const FS_MODULE = 'Fs.Debug.Fixtures'; +/** + * How that module renders in the tree: a CLASS row named for the TYPE, under a + * NAMESPACE row carrying the rest of the path. + * + * An F# module compiles to a CLR type, so `Fs.Debug.Fixtures` is the type + * `Fixtures` in namespace `Fs.Debug`, and the Assembly → Namespace → Class → + * Test tree splits it exactly as it splits a C# class. + */ +export const FS_MODULE_TYPE = FS_MODULE.slice(FS_MODULE.lastIndexOf('.') + 1); +export const FS_MODULE_NAMESPACE = FS_MODULE.slice(0, FS_MODULE.lastIndexOf('.')); + /** An idiomatic backtick binding: its fully-qualified name contains SPACES. */ export const FS_SPACED = `${FS_MODULE}.adds two numbers with spaces`; @@ -279,6 +290,28 @@ export function conditionalBreakpointAt( ); } +/** + * A breakpoint that only stops on the Nth hit. + * + * [DEBUG-FEATURES-BREAKPOINTS] makes hit-count breakpoints a P1, native row and + * names the operators the adapter accepts: `>`, `>=`, `<`, `<=`, `==` and `%`. + * Against a `[Theory]` this is the gesture that selects a ROW without knowing + * anything about its arguments. + */ +export function hitCountBreakpointAt( + source: AnchoredSource, + uri: vscode.Uri, + anchor: string, + hitCondition: string, +): vscode.SourceBreakpoint { + return new vscode.SourceBreakpoint( + new vscode.Location(uri, source.position(anchor)), + true, + undefined, + hitCondition, + ); +} + /** Assert a debug session was started for the test run, and hand it back. */ export function requireDebugSession(sessions: DebugSessionRecorder): ObservedSession { assert.ok( diff --git a/src/editors/vscode/src/test/suite/debug-variables-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-variables-e2e.test.ts index 4797420a..c2075ad2 100644 --- a/src/editors/vscode/src/test/suite/debug-variables-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-variables-e2e.test.ts @@ -11,9 +11,12 @@ // "Local variables | variables | P1" says nothing about excluding `int?`, so the // nullable local is asserted like any other and this suite reports the gap. import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; import { MODE } from './debug-fixture-programs'; import { + CMD_CONTINUE, assertStoppedAt, + evaluate, localsOf, scopesOf, topFrame, @@ -22,7 +25,7 @@ import { type Variable, } from './debug-drive-kit'; import { armBreakpoints, assertCleanSession, startDebuggee, useDebuggee } from './debug-suite-kit'; -import { deepEq, eq, requireAt } from './test-helpers'; +import { deepEq, eq, neq, requireAt } from './test-helpers'; import { DEBUG_TEST_MS } from './test-timeouts'; /** Assert a variable's rendered value CONTAINS `needle`, naming what it was. */ @@ -278,4 +281,204 @@ suite('Debug variables — locals, arguments, this, statics and expansion', () = ); assertCleanSession(debuggee(), 'reading a static field'); }); + + // Implements [DEBUG-FEATURES-VARIABLES] "Local variables | variables | P1" + // and the `supportsVariableType` row, over EVERY local of one frame at once. + // A panel that renders three of five locals is a panel the user cannot rely + // on, and which five it drops is invisible from any single-variable test. + test('every local of a frame is present, named, valued and typed', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop on the last statement of the method that declares + // one of each interesting shape, so all of them are initialised. + armBreakpoints(fixture, 'inspect-print'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the print statement'); + const frame = await topFrame(session, stop.threadId); + assertStoppedAt(frame, fixture, 'inspect-print', 'Inspect', 'the inspection frame'); + eq( + recorder.capabilities()['supportsVariableType'], + true, + 'the type column is what makes the panel readable, and it is a Phase 4 Yes', + ); + + // Interaction 2 — every declared local must be there, with a value and a + // type. Asserted per variable so a failure names the one that vanished. + const expected: readonly { name: string; type: string; value: string }[] = [ + { name: 'numbers', type: 'List', value: '3' }, + { name: 'lookup', type: 'Dictionary', value: '1' }, + { name: 'letters', type: 'char', value: '2' }, + { name: 'maybe', type: 'int', value: '42' }, + { name: 'text', type: 'string', value: 'boxed=8' }, + { name: 'box', type: 'Box', value: '8' }, + ]; + const locals = await localsOf(session, frame.id); + for (const { name, type, value } of expected) { + const variable = variableNamed(locals, name); + neq(variable.value, '', name + ' must render a value, not an empty cell'); + assertTyped(variable, type, 'the local ' + name); + assertValueHas(variable, value, 'the local ' + name); + eq( + variable.evaluateName === '' || variable.evaluateName.includes(name), + true, + name + ' must carry an evaluateName the Watch panel can re-evaluate it by', + ); + } + eq( + locals.length >= expected.length, + true, + 'the panel shows at least every local the method declares', + ); + eq( + new Set(locals.map((local) => local.name)).size, + locals.length, + 'and shows each of them ONCE - a duplicated row is a row the user cannot expand', + ); + + // Interaction 3 — reading the same frame twice must answer identically. A + // panel that changes between two reads of a stopped process is reporting + // something other than the process. + const again = await localsOf(session, frame.id); + deepEq( + again.map((local) => local.name + '=' + local.value), + locals.map((local) => local.name + '=' + local.value), + 'a stopped frame read twice must answer identically', + ); + const { scopes, variables } = await allScopeVariables(session, frame.id); + eq(scopes.length >= 1, true, 'at least one scope backs the panel'); + eq( + variables.length >= locals.length, + true, + 'and reading every scope reaches at least the locals', + ); + assertCleanSession(debuggee(), 'reading every local of a frame'); + }); + + // Implements [DEBUG-FEATURES-VARIABLES] "`this` / instance members | P1" and + // "Collection/array expansion | variables (structured) | P1" one level + // deeper: an object local must EXPAND to its own members. + test('an object local expands to its members, each named, valued and re-evaluable', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop where the object is fully constructed. + armBreakpoints(fixture, 'inspect-print'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [stop] = await recorder.waitForStops(1); + assert.ok(stop, 'the debuggee must reach the print statement'); + const frame = await topFrame(session, stop.threadId); + const box = variableNamed(await localsOf(session, frame.id), 'box'); + neq( + box.reference, + 0, + 'an object with members must carry a non-zero variablesReference, or the panel shows no ' + + 'expansion arrow and its fields are unreachable', + ); + + // Interaction 2 — the members themselves. + const members = await variablesOf(session, box.reference); + const names = members.map((member) => member.name); + eq(names.includes('Value'), true, 'the object own Value property is a member row'); + eq(names.includes('Label'), true, 'and so is Label'); + assertValueHas(variableNamed(members, 'Value'), '8', 'the expanded Value member'); + assertValueHas(variableNamed(members, 'Label'), 'boxed', 'the expanded Label member'); + for (const member of members) { + neq(member.name, '', 'every member row must be named'); + eq( + member.evaluateName === '' || member.evaluateName.includes('.'), + true, + member.name + + ': an expanded member evaluateName must address it THROUGH its parent, ' + + 'or adding it to the Watch panel resolves the wrong symbol', + ); + } + + // Interaction 3 — the expansion must agree with an evaluation of the same + // path, and expanding twice must answer the same. Two different answers for + // one field is the panel and the watch disagreeing about the same object. + eq( + (await evaluate(session, 'box.Value', frame.id, 'watch')).value, + variableNamed(members, 'Value').value, + 'the expanded member and the equivalent watch expression must agree', + ); + deepEq( + (await variablesOf(session, box.reference)).map((member) => member.name), + names, + 'expanding the same object twice yields the same members', + ); + const collection = variableNamed(await localsOf(session, frame.id), 'numbers'); + neq(collection.reference, 0, 'a collection must expand too'); + const items = await variablesOf(session, collection.reference); + eq( + items.length >= 3, + true, + 'a three-element list must expose at least its three elements, or the user cannot see ' + + 'what is in the collection they are debugging', + ); + eq( + items.some((item) => item.value === '10'), + true, + 'and the elements carry the values the program put in them', + ); + assertCleanSession(debuggee(), 'expanding an object and a collection'); + }); + + // The project HARD RULE: "All screens MUST BE 100% reactive. If underlying + // data changes, the screen must be listening and update accordingly." For the + // Variables panel that means: advance the program, read again, see the new + // value. A panel that caches the first read is a panel that lies after the + // first step. + test('the panel reports the NEW value after the program advances', async function () { + this.timeout(DEBUG_TEST_MS); + const { fixture, recorder } = debuggee(); + + // Interaction 1 — stop inside the loop on its first pass. + armBreakpoints(fixture, 'accumulate-call'); + const session = await startDebuggee(debuggee(), { mode: MODE.plain }); + const [first] = await recorder.waitForStops(1); + assert.ok(first, 'the debuggee must reach the call inside the loop'); + const firstFrame = await topFrame(session, first.threadId); + const firstLocals = await localsOf(session, firstFrame.id); + eq(variableNamed(firstLocals, 'running').value, '2', 'the accumulator starts at the seed'); + eq(variableNamed(firstLocals, 'index').value, '1', 'and the loop is on its first pass'); + eq(variableNamed(firstLocals, 'seed').value, '2', 'with the argument it was called with'); + + // Interaction 2 — continue to the SECOND pass. Both the accumulator and + // the loop variable must have moved on. + await vscode.commands.executeCommand(CMD_CONTINUE); + const stops = await recorder.waitForStops(2); + const second = requireAt(stops, 1, 'the second loop stop'); + const secondFrame = await topFrame(session, second.threadId); + const secondLocals = await localsOf(session, secondFrame.id); + eq( + variableNamed(secondLocals, 'index').value, + '2', + 'the loop variable must report its NEW value, not the value of the first read', + ); + eq(variableNamed(secondLocals, 'running').value, '3', 'and the accumulator its new total'); + neq( + variableNamed(secondLocals, 'running').value, + variableNamed(firstLocals, 'running').value, + 'the two reads must differ - identical values across two passes is a cached panel', + ); + neq(secondFrame.id, firstFrame.id, 'and each stop hands out its own frame handle'); + + // Interaction 3 — one more pass, and the static field the loop writes must + // change too, which is the same claim for a STATIC row. + await vscode.commands.executeCommand(CMD_CONTINUE); + const third = requireAt(await recorder.waitForStops(3), 2, 'the third loop stop'); + const thirdFrame = await topFrame(session, third.threadId); + const thirdLocals = await localsOf(session, thirdFrame.id); + eq(variableNamed(thirdLocals, 'index').value, '3', 'the third pass reports the third index'); + eq(variableNamed(thirdLocals, 'running').value, '5', 'and the running total to date'); + eq( + (await evaluate(session, 'running + index', thirdFrame.id, 'watch')).value, + '8', + 'a watch over the CURRENT values agrees with the panel over the current values', + ); + eq(recorder.stops().length, 3, 'exactly three loop stops, one per pass'); + deepEq(recorder.errors, [], 'with no adapter transport error'); + }); }); diff --git a/src/editors/vscode/src/test/suite/diagnostics.test.ts b/src/editors/vscode/src/test/suite/diagnostics.test.ts index 9da3802d..abc516b9 100644 --- a/src/editors/vscode/src/test/suite/diagnostics.test.ts +++ b/src/editors/vscode/src/test/suite/diagnostics.test.ts @@ -4,9 +4,11 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; import { closeAllEditors, + loadFixtureSolution, openSharpLspPanel, replaceDocumentContent, setupLspTestSuite, + settleForScreenshot, takeScreenshot, teardownLspTestSuite, waitForDiagnostics, @@ -16,6 +18,39 @@ import { } from './test-helpers'; import { LSP_RESPONSE_MS, SIDECAR_COLD_MS } from './test-timeouts'; +/** The source every C# diagnostic must carry, so the Problems panel can group them. */ +const CSHARP_SOURCE = 'sharplsp-csharp'; + +/** A diagnostic's code as a plain string, whatever shape the server used. */ +function codeOf(diagnostic: vscode.Diagnostic): string { + const code = diagnostic.code; + if (typeof code === 'string' || typeof code === 'number') return String(code); + return String((code as { value?: string | number } | undefined)?.value ?? ''); +} + +/** + * Every invariant a published diagnostic must satisfy, whatever produced it. + * + * A diagnostic with no code cannot be suppressed, cannot be looked up, and + * cannot be matched to a quick fix; one whose range runs past the buffer paints + * a squiggle over nothing. Neither shows up in a "there is at least one error" + * check ([DIAG-CATEGORIES-COMPILER], [DIAG-LSP-SEVERITY]). + */ +function assertDiagnosticShape(diagnostic: vscode.Diagnostic, document: vscode.TextDocument): void { + const where = `${codeOf(diagnostic)} at ${diagnostic.range.start.line}`; + assert.ok(diagnostic.message.trim().length > 0, `${where} must carry a message`); + assert.strictEqual(diagnostic.source, CSHARP_SOURCE, `${where} must name the C# engine`); + assert.match(codeOf(diagnostic), /^[A-Z]+\d+$/, `${where} must carry a compiler code`); + assert.ok( + diagnostic.range.start.isBeforeOrEqual(diagnostic.range.end), + `${where} must not be inverted`, + ); + assert.ok( + diagnostic.range.end.line < document.lineCount, + `${where} must land inside the ${document.lineCount}-line buffer`, + ); +} + /** Clean starting content for the diagnostic target file. */ const CLEAN_CONTENT = `namespace DiagTest { @@ -102,30 +137,47 @@ suite('Diagnostics / Problems Panel', () => { error.message.toLowerCase().includes('cannot'), 'Error message must describe the type mismatch', ); + + // Interaction 2 - [DIAG-CATEGORIES-COMPILER]: a type error is CS0029. + // Without the code the Problems panel cannot link to documentation, the + // editor cannot offer the matching quick fix, and nothing can suppress it. + assert.strictEqual(codeOf(error), 'CS0029', `type mismatch is CS0029, got ${codeOf(error)}`); + assertDiagnosticShape(error, diagDoc); + assert.strictEqual( + error.severity, + vscode.DiagnosticSeverity.Error, + '[DIAG-LSP-SEVERITY] maps a Roslyn Error to severity 1', + ); + + // Interaction 3 - the squiggle covers the OFFENDING EXPRESSION, not the + // whole method. A range that spans the declaration paints the entire body + // red for one bad return. + const squiggled = diagDoc.getText(error.range); + assert.ok( + squiggled.includes('"not an int"'), + `the squiggle covers the literal: '${squiggled}'`, + ); + assert.strictEqual(error.range.start.line, error.range.end.line, 'on one line'); + assert.ok(squiggled.length < diagDoc.lineAt(4).text.length, 'and not the whole line'); + + // Interaction 4 - every published diagnostic is well formed, and none is + // published twice. A duplicate is one Problems row per pull. + for (const diagnostic of diagnostics) { + assertDiagnosticShape(diagnostic, diagDoc); + } + const keys = diagnostics.map( + (diagnostic) => + `${codeOf(diagnostic)}:${diagnostic.range.start.line}:${diagnostic.range.start.character}`, + ); + assert.deepEqual([...new Set(keys)], keys, 'no diagnostic may be published twice'); + // Load fixture solution so Solution Explorer is populated in the screenshot. if (process.env['SHARPLSP_SCREENSHOTS']) { - const ext2 = vscode.extensions.getExtension('nimblesite.sharplsp'); - const api2 = ext2?.exports as - | { - explorerProvider?: { - loadSolution(p: string): Promise; - getChildren(e?: unknown): unknown[] | undefined; - }; - } - | undefined; - if (api2?.explorerProvider) { - const slnPath2 = path.join(workspaceRoot, 'TestFixtures.sln'); - await api2.explorerProvider.loadSolution(slnPath2); - let waited2 = 0; - while ((api2.explorerProvider.getChildren() ?? []).length === 0 && waited2 < 8000) { - await new Promise((r) => setTimeout(r, 200)); - waited2 += 200; - } - } + await loadFixtureSolution(workspaceRoot); } // Open Problems panel so diagnostics are visible in the screenshot. await vscode.commands.executeCommand('workbench.actions.view.problems'); - await new Promise((r) => setTimeout(r, 1000)); + await settleForScreenshot(1000); await openSharpLspPanel(); await takeScreenshot('vscode-diagnostics-page.png'); }); @@ -148,6 +200,39 @@ suite('Diagnostics / Problems Panel', () => { const csError = diagnostics.find((d) => d.severity === vscode.DiagnosticSeverity.Error); assert.ok(csError, 'Must have an error diagnostic for missing type'); + + // Interaction 2 - [DIAG-CATEGORIES-COMPILER]: an unresolved type is CS0246. + // That exact code is what the AddImport code fix keys off, so a diagnostic + // reported without it leaves Ctrl-. with nothing to offer. + assert.strictEqual( + codeOf(csError), + 'CS0246', + `an unresolved type is CS0246, got ${codeOf(csError)}`, + ); + assertDiagnosticShape(csError, diagDoc); + assert.strictEqual(csError.severity, vscode.DiagnosticSeverity.Error, 'and it is an error'); + + // Interaction 3 - the squiggle sits on the TYPE NAME, which is where the + // lightbulb has to appear for the import fix to be reachable. + const squiggled = diagDoc.getText(csError.range); + assert.ok( + squiggled.includes('NonExistentType'), + `the squiggle covers the unresolved name: '${squiggled}'`, + ); + assert.strictEqual(csError.range.start.line, 4, 'on the declaration line'); + assert.ok(csError.message.includes('NonExistentType'), 'and the message names it'); + + // Interaction 4 - it is the ONLY error. A missing return type must not + // cascade into a wall of secondary errors the user has to read past. + const errors = diagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error); + assert.strictEqual( + errors.length, + 1, + `one unresolved type, one error; got: ${errors.map((e) => codeOf(e)).join(', ')}`, + ); + for (const diagnostic of diagnostics) { + assertDiagnosticShape(diagnostic, diagDoc); + } }); // ── Clean Files ─────────────────────────────────────────────── @@ -161,6 +246,35 @@ suite('Diagnostics / Problems Panel', () => { const cleared = await waitForDiagnosticsCleared(diagUri, LSP_RESPONSE_MS); const errors = cleared.filter((d) => d.severity === vscode.DiagnosticSeverity.Error); assert.strictEqual(errors.length, 0, 'Valid file should have no error diagnostics'); + + // Interaction 2 - and no phantom CS0246. [DIAG-RESTORE] exists because a + // workspace analysed before NuGet restore finishes reports every reference + // as missing; a clean file showing one means the gate did not hold. + assert.strictEqual( + cleared.some((diagnostic) => codeOf(diagnostic) === 'CS0246'), + false, + `no phantom unresolved-reference errors: ${cleared.map((d) => codeOf(d)).join(', ')}`, + ); + for (const diagnostic of cleared) { + assertDiagnosticShape(diagnostic, diagDoc); + } + + // Interaction 3 - whatever IS reported on a clean file is advisory, never + // an error, and it is still addressable: [DIAG-CATEGORIES-ANALYZER] style + // suggestions carry codes and sources like everything else. + for (const diagnostic of cleared) { + assert.notStrictEqual( + diagnostic.severity, + vscode.DiagnosticSeverity.Error, + `${codeOf(diagnostic)} must not be an error on a valid file: ${diagnostic.message}`, + ); + assert.strictEqual(diagnostic.source, CSHARP_SOURCE, 'and must name the C# engine'); + } + + // Interaction 4 - the buffer really is the clean one, so this is a + // statement about the analyser rather than about the fixture. + assert.ok(diagDoc.getText().includes('return 42'), 'the clean content is in the buffer'); + assert.strictEqual(diagDoc.getText().includes('not an int'), false, 'with no leftover error'); }); // ── Edit Cycle ──────────────────────────────────────────────── @@ -189,6 +303,48 @@ suite('Diagnostics / Problems Panel', () => { const cleared = await waitForDiagnosticsCleared(diagUri, LSP_RESPONSE_MS); const errors = cleared.filter((d) => d.severity === vscode.DiagnosticSeverity.Error); assert.strictEqual(errors.length, 0, 'Diagnostics should clear after fixing the error'); + + // Interaction 2 - the error that WAS reported was the real one, so the + // clearing below is about the fix and not about an analyser that never + // looked ([DIAG-CATEGORIES-COMPILER]). + const before = diagnostics.find((d) => d.severity === vscode.DiagnosticSeverity.Error); + assert.ok(before, 'the broken buffer produced an error'); + assert.strictEqual( + codeOf(before), + 'CS0029', + `the type mismatch was CS0029, got ${codeOf(before)}`, + ); + assertDiagnosticShape(before, diagDoc); + + // Interaction 3 - the specific code is GONE, not merely outnumbered. A + // stale squiggle on a line the user already fixed is the single most + // corrosive diagnostics defect there is ([DIAG-CATEGORIES-LIVE]). + assert.strictEqual( + cleared.some((diagnostic) => codeOf(diagnostic) === 'CS0029'), + false, + `CS0029 must be gone; still reported: ${cleared.map((d) => codeOf(d)).join(', ')}`, + ); + assert.ok(diagDoc.getText().includes('return 42'), 'and the fix really is in the buffer'); + assert.strictEqual(diagDoc.getText().includes('"bad"'), false, 'with the bad literal removed'); + + // Interaction 4 - breaking it AGAIN reports again. A pipeline that clears + // once and then goes quiet is worse than one that never cleared. + await replaceDocumentContent( + diagDoc, + `namespace DiagTest +{ + public class DiagTarget + { + public int Foo() { return "bad again"; } + } +}`, + ); + const again = await waitForDiagnostics(diagUri, LSP_RESPONSE_MS); + assert.ok(again.length > 0, 'reintroducing the error must report it again'); + assert.ok( + again.some((diagnostic) => codeOf(diagnostic) === 'CS0029'), + `CS0029 must come back; got: ${again.map((d) => codeOf(d)).join(', ')}`, + ); }); // ── Diagnostic Properties ───────────────────────────────────── @@ -262,8 +418,43 @@ suite('Diagnostics / Problems Panel', () => { const after = await waitForDiagnosticsCleared(diagUri, LSP_RESPONSE_MS); assert.strictEqual(after.length, 0, 'Diagnostics must be empty after closing the document'); + // Interaction 2 - what was reported BEFORE the close was the real error, so + // the emptiness above is the close taking effect rather than an analyser + // that never ran. + const broken = diagnostics.find((d) => d.severity === vscode.DiagnosticSeverity.Error); + assert.ok(broken, 'the broken buffer produced an error before the close'); + assert.strictEqual(codeOf(broken), 'CS0029', 'and it was the type mismatch'); + assertDiagnosticShape(broken, diagDoc); + + // Interaction 3 - the collection is EMPTY, not merely error-free. A closed + // document that keeps warnings still occupies a row in the Problems panel + // for a file the user is no longer looking at. + assert.deepEqual(after, [], 'a closed document owns no diagnostics of any severity'); + assert.strictEqual( + vscode.languages.getDiagnostics(diagUri).length, + 0, + 'and the language service agrees it has none', + ); + assert.strictEqual( + vscode.window.visibleTextEditors.some( + (editor) => editor.document.uri.toString() === diagUri.toString(), + ), + false, + 'with no editor still showing the file', + ); + // Re-open for suite teardown to restore content. diagDoc = await vscode.workspace.openTextDocument(diagUri); await vscode.window.showTextDocument(diagDoc); + + // Interaction 4 - re-opening it re-establishes the pipeline: the clean + // content analyses clean, rather than resurrecting the pre-close errors. + const reopened = await waitForDiagnosticsCleared(diagUri, LSP_RESPONSE_MS); + assert.strictEqual( + reopened.filter((d) => d.severity === vscode.DiagnosticSeverity.Error).length, + 0, + 'the reopened clean document reports no errors', + ); + assert.ok(diagDoc.getText().includes('return 42'), 'and it really is the clean content'); }); }); diff --git a/src/editors/vscode/src/test/suite/extension-manifest-kit.ts b/src/editors/vscode/src/test/suite/extension-manifest-kit.ts new file mode 100644 index 00000000..38cb9b59 --- /dev/null +++ b/src/editors/vscode/src/test/suite/extension-manifest-kit.ts @@ -0,0 +1,282 @@ +// Shared contribution-point assertions for the activation suite. +// +// Spec: [DIST-EDITOR-CONTRACT], [DIST-FAILURE-UX], [DIST-WORKSPACE-TRUST], +// [DIST-RUNTIME-ACQUIRE], [SHARPLSP-ARCHITECTURE-EXTENSIONS]. +// +// A contribution point is not packaging trivia. With no `contributes.commands` +// entry a command is unreachable from the palette however well it is +// registered; with no `capabilities.untrustedWorkspaces.restrictedConfigurations` +// entry an untrusted workspace can name the executable SharpLsp spawns. Neither +// defect shows up in a runtime API, so the manifest VS Code itself parsed is the +// only honest surface — and every helper here asserts against THAT, never +// against a hand-copied expectation. +// +// The manifest readers live in run-debug-kit; this file adds only what the +// activation suite needs on top of them. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { EXTENSION_ID } from './test-helpers'; +import { authoredPackageJson, contributes, packageJson } from './run-debug-kit'; + +/** One entry of `contributes.commands`. */ +export interface ContributedCommand { + readonly command: string; + readonly title?: string; + readonly category?: string; +} + +/** One entry of `contributes.languages`. */ +export interface ContributedLanguage { + readonly id: string; + readonly extensions?: string[]; + readonly aliases?: string[]; + readonly configuration?: string; +} + +/** One property of `contributes.configuration.properties`. */ +export interface ConfigProperty { + readonly type?: string | string[]; + readonly default?: unknown; + readonly description?: string; + readonly markdownDescription?: string; + readonly enum?: unknown[]; + readonly scope?: string; +} + +/** + * The settings [DIST-WORKSPACE-TRUST] forbids an untrusted workspace to set. + * + * Every one of them either names an executable SharpLsp will spawn or injects + * arguments into one. A workspace that can set them is a workspace that can run + * arbitrary code the moment a folder is opened. + */ +export const TRUST_RESTRICTED_SETTINGS = [ + 'sharplsp.lspPath', + 'sharplsp.csharpSidecarPath', + 'sharplsp.fsharpSidecarPath', + 'sharplsp.server.extraArgs', + 'sharplsp.fsi.extraArgs', + 'sharplsp.debug.netcoredbgPath', +] as const; + +/** + * The commands [DIST-FAILURE-UX] rule 6 requires so a user can re-attempt a + * failed activation without uninstalling the extension. + */ +export const RECOVERY_COMMANDS = ['sharplsp.restartServer', 'sharplsp.retryDotnetAcquisition']; + +/** The .NET Install Tool extension SharpLsp depends on ([DIST-RUNTIME-ACQUIRE]). */ +export const INSTALL_TOOL_ID = 'ms-dotnettools.vscode-dotnet-runtime'; + +/** The extension object, asserted present rather than optional-chained away. */ +export function sharpLspExtension(): vscode.Extension { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} must be installed in the VSIX host`); + return extension; +} + +/** Every `contributes.commands` entry, asserted to be a populated array. */ +export function commandEntries(): ContributedCommand[] { + const commands: unknown = contributes().commands; + assert.ok(Array.isArray(commands), 'contributes.commands must be an array'); + assert.ok(commands.length > 0, 'contributes.commands must not be empty'); + return commands as ContributedCommand[]; +} + +/** Every `contributes.languages` entry, asserted to be a populated array. */ +export function languageEntries(): ContributedLanguage[] { + const languages: unknown = contributes().languages; + assert.ok(Array.isArray(languages), 'contributes.languages must be an array'); + assert.ok(languages.length > 0, 'contributes.languages must not be empty'); + return languages as ContributedLanguage[]; +} + +/** The one `contributes.languages` entry with this id. */ +export function languageNamed(id: string): ContributedLanguage { + const matches = languageEntries().filter((entry) => entry.id === id); + assert.strictEqual( + matches.length, + 1, + `exactly one '${id}' language contribution, not ${matches.length}`, + ); + const entry = matches[0]; + assert.ok(entry, `contributes.languages must declare '${id}'`); + return entry; +} + +/** The `contributes.configuration.properties` map. */ +export function configProperties(): Record { + const configuration: unknown = contributes().configuration; + assert.ok(configuration, 'the manifest must declare a contributes.configuration block'); + const properties: unknown = (configuration as { properties?: unknown }).properties; + assert.ok(properties, 'contributes.configuration must declare properties'); + return properties as Record; +} + +/** + * A command is REACHABLE: registered at runtime, declared in the manifest, and + * declared exactly once, under the palette category a user searches by. + * + * Registration alone proves nothing about the palette, and a manifest entry + * alone proves nothing about the handler — a command needs both, so this + * asserts both. + */ +export function assertReachableCommand(id: string, palette: readonly string[]): ContributedCommand { + assert.ok( + palette.includes(id), + `'${id}' must be registered; registered sharplsp commands: ${palette + .filter((name) => name.startsWith('sharplsp.')) + .sort() + .join(', ')}`, + ); + const declared = commandEntries().filter((entry) => entry.command === id); + assert.strictEqual(declared.length, 1, `'${id}' must be declared exactly once in the manifest`); + const entry = declared[0]; + assert.ok(entry, `contributes.commands must declare '${id}'`); + assert.strictEqual(entry.category, 'SharpLsp', `'${id}' must sit under the SharpLsp category`); + assert.ok( + typeof entry.title === 'string' && entry.title.length > 0, + `'${id}' must carry a non-empty palette title`, + ); + assert.ok(id.startsWith('sharplsp.'), `'${id}' must live in the sharplsp namespace`); + return entry; +} + +/** + * A setting is CONTRIBUTED: inspectable, documented, typed, defaulted to the + * spec's value, and at rest reading as that default - what a fresh install sees. + */ +export function assertContributedSetting(key: string, expectedDefault: unknown): ConfigProperty { + const section = key.slice(0, key.lastIndexOf('.')); + const leaf = key.slice(key.lastIndexOf('.') + 1); + const inspected = vscode.workspace.getConfiguration(section).inspect(leaf); + assert.ok(inspected, `${key} must be inspectable`); + assert.deepStrictEqual(inspected.defaultValue, expectedDefault, `${key} default`); + assert.strictEqual( + inspected.globalValue, + undefined, + `${key} must be unset at user scope at rest`, + ); + // The fixture workspace pins nothing: a workspace value, even one equal to + // the default, hides every user-scope write behind it. + assert.strictEqual( + inspected.workspaceValue, + undefined, + `${key} must be unset at workspace scope at rest, never overridden`, + ); + assert.deepStrictEqual( + vscode.workspace.getConfiguration(section).get(leaf), + expectedDefault, + `${key} must READ BACK as its default when nothing overrides it`, + ); + const property = configProperties()[key]; + assert.ok(property, `contributes.configuration.properties must declare ${key}`); + assert.deepStrictEqual(property.default, expectedDefault, `${key} manifest default`); + assert.ok(property.type, `${key} must declare a JSON type so Settings can render an editor`); + assert.ok( + (property.description ?? property.markdownDescription ?? '').length > 0, + `${key} must carry a description — an undocumented setting is unusable from the Settings UI`, + ); + return property; +} + +/** + * Implements [DIST-WORKSPACE-TRUST]. A restricted setting is one an UNTRUSTED + * workspace must not be able to set, because it names an executable or injects + * process arguments. + */ +export function assertTrustRestricted(key: string): void { + const capabilities: unknown = authoredPackageJson().capabilities; + assert.ok(capabilities, 'the manifest must declare a capabilities block'); + const untrusted: unknown = (capabilities as { untrustedWorkspaces?: unknown }) + .untrustedWorkspaces; + assert.ok(untrusted, 'capabilities must declare untrustedWorkspaces'); + const block = untrusted as { supported?: unknown; restrictedConfigurations?: unknown }; + assert.strictEqual( + block.supported, + 'limited', + "untrustedWorkspaces.supported must be 'limited' per [DIST-WORKSPACE-TRUST]", + ); + assert.ok( + Array.isArray(block.restrictedConfigurations), + 'untrustedWorkspaces.restrictedConfigurations must be an array', + ); + assert.ok( + (block.restrictedConfigurations as string[]).includes(key), + `${key} names an executable or its arguments and MUST be restricted in untrusted workspaces`, + ); +} + +/** + * A language OWNS a file extension: the manifest claims it, the claim is + * unique across languages, and the language ships a configuration file that + * actually exists on disk and parses. + * + * Two languages claiming the same extension is not a cosmetic overlap — VS Code + * resolves it arbitrarily, so a .fs file can silently open as C#. + */ +export function assertLanguageOwnsExtension(languageId: string, fileExtension: string): void { + const entry = languageNamed(languageId); + assert.ok( + entry.extensions?.includes(fileExtension), + `${languageId} must claim ${fileExtension}; claims: ${(entry.extensions ?? []).join(', ')}`, + ); + const rivals = languageEntries().filter( + (other) => other.id !== languageId && (other.extensions ?? []).includes(fileExtension), + ); + assert.deepStrictEqual( + rivals.map((rival) => rival.id), + [], + `${fileExtension} must be claimed by ${languageId} alone — a shared claim resolves arbitrarily`, + ); + assert.ok(entry.configuration, `${languageId} must ship a language-configuration file`); + const configured = path.join(sharpLspExtension().extensionPath, entry.configuration); + assert.ok( + fs.existsSync(configured), + `${languageId} language configuration must exist at ${configured}`, + ); +} + +/** + * The version the manifest reports, asserted to be a plain semver core. + * + * [DIST-VERSION-INVARIANT] makes Cargo.toml the single source of truth and + * requires every stamped version to match byte-for-byte, so a `v` prefix or a + * two-part version is a release that cannot be verified. + */ +export function manifestVersion(): string { + const version: unknown = packageJson().version; + assert.ok(typeof version === 'string', 'package.json must declare a version string'); + assert.match(version, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/, 'version must be semver'); + assert.ok(!version.startsWith('v'), 'the stamped version carries no leading v'); + return version; +} + +/** The ids of every contributed view, across every container. */ +export function viewIds(): string[] { + const views: Record = contributes().views ?? {}; + return Object.values(views) + .flat() + .map((view) => view.id); +} + +/** The strings the authored manifest refers to as `%key%`. */ +function nlsStrings(): Record { + const nls = path.resolve(__dirname, '../../..', 'package.nls.json'); + assert.ok(fs.existsSync(nls), `the authored manifest's strings must exist at ${nls}`); + return JSON.parse(fs.readFileSync(nls, 'utf-8')); +} + +/** + * An authored manifest value with its `%key%` resolved through package.nls.json + * the way VS Code resolves it at load time; any other value passes through. + */ +export function nlsResolved(value: unknown): unknown { + if (typeof value !== 'string' || !value.startsWith('%') || !value.endsWith('%')) return value; + const key = value.slice(1, -1); + const resolved = nlsStrings()[key]; + assert.ok(resolved !== undefined, `package.nls.json must define ${key}`); + return resolved; +} diff --git a/src/editors/vscode/src/test/suite/extension.test.ts b/src/editors/vscode/src/test/suite/extension.test.ts index 160bae74..8df35cd5 100644 --- a/src/editors/vscode/src/test/suite/extension.test.ts +++ b/src/editors/vscode/src/test/suite/extension.test.ts @@ -1,16 +1,55 @@ +// Activation, contribution points and the activation-failure contract. +// +// Spec: [DIST-EDITOR-CONTRACT], [DIST-FAILURE-UX], [DIST-RUNTIME-ACQUIRE], +// [DIST-WORKSPACE-TRUST], [DIST-VERSION-INVARIANT], +// [SHARPLSP-ARCHITECTURE-EXTENSIONS], [SHARPLSP-FEATURES-FSHARP]. +// +// Reading a manifest key back is not a test of anything: `contributes.commands` +// can name a command no handler answers, and a registered handler can be +// unreachable from the palette. Every test here therefore drives the editor as +// well as the manifest — open the file, run the command, write the setting, +// read the result back — because the pair is what a user actually experiences. import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; import * as vscode from 'vscode'; import { EXTENSION_ID, closeAllEditors, + flattenSymbolNames, + loadFixtureSolution, openCSharpFile, openSharpLspPanel, setupLspTestSuite, + settleForScreenshot, takeScreenshot, teardownLspTestSuite, waitForDocumentSymbols, } from './test-helpers'; -import { ACTIVATION_MS, COMMAND_MS, LSP_RESPONSE_MS } from './test-timeouts'; +import { authoredPackageJson, invokeCommand, packageJson } from './run-debug-kit'; +import { + INSTALL_TOOL_ID, + RECOVERY_COMMANDS, + TRUST_RESTRICTED_SETTINGS, + assertContributedSetting, + assertLanguageOwnsExtension, + assertReachableCommand, + assertTrustRestricted, + commandEntries, + configProperties, + languageEntries, + languageNamed, + manifestVersion, + nlsResolved, + sharpLspExtension, + viewIds, +} from './extension-manifest-kit'; +import { + ACTIVATION_MS, + COMMAND_MS, + LSP_RESPONSE_MS, + SETTINGS_WRITE_MS, + SETTLE_MS, +} from './test-timeouts'; suite('Extension Activation & Configuration', () => { let tmpDir: string; @@ -32,290 +71,619 @@ suite('Extension Activation & Configuration', () => { // ── Activation ─────────────────────────────────────────────── - test('extension is present in the extension list', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); + test('extension is present in the extension list', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — the id resolves out of the host's own list, exactly once. + const listed = vscode.extensions.all.filter((candidate) => candidate.id === EXTENSION_ID); + assert.strictEqual( + listed.length, + 1, + `exactly one ${EXTENSION_ID} in the host, not ${listed.length}`, + ); + const ext = sharpLspExtension(); + assert.strictEqual( + listed[0]?.id, + ext.id, + 'getExtension must hand back the extension the host lists', + ); + assert.strictEqual(listed[0]?.extensionPath, ext.extensionPath, 'installed at the same path'); + assert.strictEqual(ext.id, EXTENSION_ID, 'the resolved extension carries the id we asked for'); + + // Interaction 2 — the id is publisher.name, and both halves come from the manifest. + const manifest = packageJson(); + assert.strictEqual(`${String(manifest.publisher)}.${String(manifest.name)}`, EXTENSION_ID); + assert.strictEqual(manifest.name, 'sharplsp', 'the manifest name is the second half of the id'); + assert.strictEqual(manifest.publisher, 'nimblesite', 'the publisher is the first half'); + + // Interaction 3 — it is INSTALLED, not merely declared: the payload is on disk. + assert.ok(fs.existsSync(ext.extensionPath), `extensionPath must exist: ${ext.extensionPath}`); + assert.ok(fs.statSync(ext.extensionPath).isDirectory(), 'extensionPath must be a directory'); + assert.ok(manifest.engines?.vscode, 'the manifest must declare an engines.vscode range'); + + // Interaction 4 — activating an already-active extension is a no-op that + // yields the SAME exports. [DIST-FAILURE-UX] rule 1: activate() resolves. + const first = await ext.activate(); + const second = await ext.activate(); + assert.strictEqual(ext.isActive, true, 'the extension is active once activate() resolves'); + assert.strictEqual(first, second, 'a second activate() must hand back the same exports'); + assert.notStrictEqual(ext.exports, undefined, 'activation must publish an API object'); }); test('extension activates when a C# file is opened', async function () { this.timeout(COMMAND_MS); - const { doc } = await openCSharpFile(tmpDir, 'activation.cs', 'class Activation { }'); - assert.strictEqual(doc.languageId, 'csharp'); + // Interaction 1 — opening a .cs file is the activation event a user hits first. + const { doc, uri } = await openCSharpFile(tmpDir, 'activation.cs', 'class Activation { }'); + assert.strictEqual(doc.languageId, 'csharp', '.cs must resolve to the csharp language'); + assert.strictEqual(uri.scheme, 'file', 'a workspace file opens on the file scheme'); + assert.strictEqual(doc.isClosed, false, 'the opened document stays open'); + + // Interaction 2 — and it activated the extension, not merely opened a buffer. + const ext = sharpLspExtension(); + assert.strictEqual(ext.isActive, true, 'opening .cs must activate SharpLsp'); + assert.ok(ext.packageJSON.activationEvents, 'the manifest must declare activation events'); + assert.notStrictEqual(ext.exports, undefined, 'an active extension publishes its API'); - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - assert.ok(ext.isActive, 'Extension should be active after opening .cs'); + // Interaction 3 — .csx is the same language and must not need a second activation. + const { doc: script } = await openCSharpFile(tmpDir, 'activation.csx', 'var x = 1;\n'); + assert.strictEqual(script.languageId, 'csharp', '.csx must resolve to csharp too'); + assert.strictEqual(ext.isActive, true, 'the extension stays active across both files'); + assert.notStrictEqual(script.uri.fsPath, doc.uri.fsPath, 'the two buffers are distinct files'); }); test('extension activates when an F# file is opened', async function () { this.timeout(COMMAND_MS); + // F# is a first-class citizen: .fs resolves to fsharp, never to a C# + // fallback. A .fs buffer reported as csharp is a buffer the FCS sidecar + // never sees ([SHARPLSP-FEATURES-FSHARP]). const { doc } = await openCSharpFile(tmpDir, 'activation.fs', 'module Activation\nlet x = 1\n'); - // The file was opened — extension should be active now. - assert.ok( - doc.languageId === 'fsharp' || doc.languageId === 'csharp', - `Expected fsharp or csharp language, got ${doc.languageId}`, + assert.strictEqual( + doc.languageId, + 'fsharp', + '.fs must resolve to fsharp, not to a C# fallback', ); + assert.strictEqual(doc.isClosed, false, 'the F# buffer stays open'); + assert.strictEqual(doc.lineCount >= 2, true, 'the written module reached the buffer'); + + // Interaction 2 — the extension is active off the back of an F# file ALONE. + const ext = sharpLspExtension(); + assert.strictEqual(ext.isActive, true, 'opening .fs must activate SharpLsp'); + assert.notStrictEqual(ext.exports, undefined, 'an active extension publishes its API'); + assert.strictEqual(languageNamed('fsharp').id, 'fsharp', 'fsharp is a contributed language'); - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext?.isActive, 'Extension should be active after opening .fs'); + // Interaction 3 — script and signature files are F# as well, all three shapes. + const { doc: script } = await openCSharpFile(tmpDir, 'activation.fsx', 'let y = 2\n'); + const { doc: signature } = await openCSharpFile( + tmpDir, + 'activation.fsi', + 'module Activation\n', + ); + assert.strictEqual(script.languageId, 'fsharp', '.fsx must resolve to fsharp'); + assert.strictEqual(signature.languageId, 'fsharp', '.fsi must resolve to fsharp'); + assert.strictEqual(ext.isActive, true, 'the extension stays active across every F# shape'); }); // ── Commands ───────────────────────────────────────────────── - test('sharplsp.restartServer command is registered', async () => { - const allCommands = await vscode.commands.getCommands(true); - assert.ok( - allCommands.includes('sharplsp.restartServer'), - 'sharplsp.restartServer should be registered', + test('sharplsp.restartServer command is registered', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — reachable: registered, declared once, titled, categorised. + const palette = await vscode.commands.getCommands(true); + const entry = assertReachableCommand('sharplsp.restartServer', palette); + assert.ok(entry.title?.length, 'the palette entry must carry a title'); + + // Interaction 2 — [DIST-FAILURE-UX] rule 6: restartServer is a RECOVERY + // command. Every recovery command the spec names must be reachable, or a + // user whose activation degraded has to uninstall to try again. + for (const recovery of RECOVERY_COMMANDS) { + assertReachableCommand(recovery, palette); + } + + // Interaction 3 — no sharplsp command is registered without being declared, + // which is how a command becomes invisible in the palette. + const declared = new Set(commandEntries().map((command) => command.command)); + // VS Code itself registers `.focus`, `.open`, `.toggleVisibility` and + // friends for every contributed view; those are the host's, not ours. + const hostOwned = (id: string): boolean => viewIds().some((view) => id.startsWith(`${view}.`)); + const undeclared = palette.filter( + (id) => + id.startsWith('sharplsp.') && + !declared.has(id) && + !id.startsWith('sharplsp._') && + !hostOwned(id), ); + assert.deepStrictEqual(undeclared, [], 'every registered sharplsp command must be declared'); + assert.ok(declared.has('sharplsp.restartServer'), 'restartServer is among the declared set'); }); - test('sharplsp.showOutput command is registered', async () => { - const allCommands = await vscode.commands.getCommands(true); - assert.ok( - allCommands.includes('sharplsp.showOutput'), - 'sharplsp.showOutput should be registered', - ); + test('sharplsp.showOutput command is registered', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — reachable from the palette. + const palette = await vscode.commands.getCommands(true); + const entry = assertReachableCommand('sharplsp.showOutput', palette); + + // Interaction 2 — [DIST-FAILURE-UX] rule 3 makes the log reachable from + // every error toast, so the command backing [Show Log] must answer. + const outcome = await invokeCommand('sharplsp.showOutput'); + assert.strictEqual(outcome.rejected, false, `showOutput must not reject: ${outcome.message}`); + assert.strictEqual(outcome.message, '', 'a clean invocation reports no failure message'); + + // Interaction 3 — its title names the log in plain language; a user + // searching the palette for "output" has to find it. + assert.ok(entry.title, 'showOutput must carry a title'); + assert.match(entry.title, /output|log/i, `title must name the log, got '${entry.title}'`); + assert.strictEqual(entry.category, 'SharpLsp', 'and sit under the SharpLsp category'); }); - test('sharplsp.showTraceOutput command is registered', async () => { - const allCommands = await vscode.commands.getCommands(true); - assert.ok( - allCommands.includes('sharplsp.showTraceOutput'), - 'sharplsp.showTraceOutput should be registered', + test('sharplsp.showTraceOutput command is registered', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — reachable from the palette. + const palette = await vscode.commands.getCommands(true); + const entry = assertReachableCommand('sharplsp.showTraceOutput', palette); + + // Interaction 2 — the trace channel is a SECOND channel, not the same one + // under another name: [DIST-CLEAN-OUTPUT] keeps per-request chatter out of + // the user-facing panel, so the two commands must be distinct entries. + const plain = commandEntries().find((command) => command.command === 'sharplsp.showOutput'); + assert.ok(plain, 'showOutput must also be declared'); + assert.notStrictEqual(entry.title, plain.title, 'trace and plain output need distinct titles'); + assert.notStrictEqual(entry.command, plain.command, 'and distinct command ids'); + + // Interaction 3 — it answers. + const outcome = await invokeCommand('sharplsp.showTraceOutput'); + assert.strictEqual( + outcome.rejected, + false, + `showTraceOutput must not reject: ${outcome.message}`, ); + assert.match(entry.title ?? '', /trace/i, 'the title must name the trace channel'); }); // ── Configuration ──────────────────────────────────────────── test('sharplsp.lspPath setting is contributed', async function () { - this.timeout(COMMAND_MS); - const config = vscode.workspace.getConfiguration('sharplsp'); - const inspect = config.inspect('lspPath'); - assert.ok(inspect, 'lspPath setting should be inspectable'); - assert.strictEqual(inspect.defaultValue, '', 'Default lspPath should be empty string'); - // Open Settings UI filtered to sharplsp so the screenshot shows real config options. + // The ASSERTIONS here are instant — `config.inspect` reads a contribution + // point out of the extension manifest — and on CI so is the rest: the + // documentation screenshot and its render settle are both no-ops unless + // SHARPLSP_SCREENSHOTS is set, leaving one command round trip. + // + // The budget has to cover the SCREENSHOT run as well, though, and there the + // workbench must paint the filtered Settings list before the capture is worth + // anything. That settle is longer than `COMMAND_MS` on its own, so this asks + // for one round trip plus one settle. + this.timeout(COMMAND_MS + SETTLE_MS); + // Interaction 1 — contributed, documented, defaulted to "use the bundled binary". + const property = assertContributedSetting('sharplsp.lspPath', ''); + assert.strictEqual(property.type, 'string', 'lspPath names a path, so it is a string setting'); + + // Interaction 2 — [DIST-WORKSPACE-TRUST]: it names the executable SharpLsp + // spawns, so an untrusted workspace must not be able to set it. + assertTrustRestricted('sharplsp.lspPath'); + for (const restricted of TRUST_RESTRICTED_SETTINGS) { + assertTrustRestricted(restricted); + } + + // Interaction 3 — the empty default is what makes [DIST-RESOLUTION-LSP]'s + // bundled source the default path; a non-empty default would pin a machine. + assert.strictEqual( + vscode.workspace.getConfiguration('sharplsp').get('lspPath'), + '', + 'an unset lspPath must read back empty so Shipwright resolves the bundled binary', + ); + + // Interaction 4 — the user opens Settings filtered to sharplsp and sees it. await vscode.commands.executeCommand('workbench.action.openSettings', 'sharplsp'); - await new Promise((r) => setTimeout(r, 1500)); + await settleForScreenshot(1500); await takeScreenshot('vscode-configuration-page.png'); await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); }); test('sharplsp.server.extraArgs setting is contributed', () => { - const config = vscode.workspace.getConfiguration('sharplsp'); - const inspect = config.inspect('server.extraArgs'); - assert.ok(inspect, 'server.extraArgs setting should be inspectable'); - assert.deepStrictEqual(inspect.defaultValue, [], 'Default extraArgs should be empty array'); + // Interaction 1 — contributed, documented, defaulted to no extra arguments. + const property = assertContributedSetting('sharplsp.server.extraArgs', []); + assert.strictEqual(property.type, 'array', 'extraArgs is an array of CLI arguments'); + + // Interaction 2 — [DIST-WORKSPACE-TRUST]: arguments injected into a spawned + // process are arbitrary code execution, so this is restricted too. + assertTrustRestricted('sharplsp.server.extraArgs'); + assertTrustRestricted('sharplsp.fsi.extraArgs'); + + // Interaction 3 — the default is EMPTY and a fresh read confirms it, so a + // clean install never injects an argument nobody asked for. + const read = vscode.workspace.getConfiguration('sharplsp').get('server.extraArgs'); + assert.deepStrictEqual(read, [], 'an unset extraArgs must read back as no arguments'); + assert.strictEqual(Array.isArray(read), true, 'and as an array, not a string'); }); - test('sharplsp.trace.server setting is contributed', () => { - const config = vscode.workspace.getConfiguration('sharplsp'); - const inspect = config.inspect('trace.server'); - assert.ok(inspect, 'trace.server setting should be inspectable'); - assert.strictEqual(inspect.defaultValue, 'off', 'Default trace level should be off'); + test('sharplsp.trace.server setting is contributed', async function () { + this.timeout(SETTINGS_WRITE_MS); + // Interaction 1 — contributed, documented, and OFF by default. LSP tracing + // on by default would flood the panel [DIST-CLEAN-OUTPUT] keeps readable. + const property = assertContributedSetting('sharplsp.trace.server', 'off'); + assert.strictEqual(property.type, 'string', 'trace.server is an enum of strings'); + assert.ok(Array.isArray(property.enum), 'trace.server must offer a closed enum'); + assert.ok(property.enum.includes('off'), "the enum must include 'off'"); + assert.ok(property.enum.includes('verbose'), "and 'verbose' for a bug report"); + + // Interaction 2 — the user turns tracing up, and it reads back. + const config = () => vscode.workspace.getConfiguration('sharplsp'); + await config().update('trace.server', 'verbose', vscode.ConfigurationTarget.Global); + assert.strictEqual(config().get('trace.server'), 'verbose', 'the new level must read back'); + assert.strictEqual( + config().inspect('trace.server')?.globalValue, + 'verbose', + 'and be recorded at the scope it was written to', + ); + + // Interaction 3 — and turning it back off restores the default exactly. + await config().update('trace.server', undefined, vscode.ConfigurationTarget.Global); + assert.strictEqual(config().get('trace.server'), 'off', 'clearing restores the default'); + assert.strictEqual( + config().inspect('trace.server')?.globalValue, + undefined, + 'and leaves no user-scope residue behind', + ); }); - test('sharplsp.logging.level setting is contributed', () => { - const config = vscode.workspace.getConfiguration('sharplsp'); - const inspect = config.inspect('logging.level'); - assert.ok(inspect, 'logging.level setting should be inspectable'); - assert.strictEqual(inspect.defaultValue, 'info', 'Default logging level should be info'); + test('sharplsp.logging.level setting is contributed', async function () { + this.timeout(SETTINGS_WRITE_MS); + // Interaction 1 — contributed, documented, and `info` by default: enough to + // diagnose, quiet enough for [DIST-CLEAN-OUTPUT]. + const property = assertContributedSetting('sharplsp.logging.level', 'info'); + assert.strictEqual(property.type, 'string', 'logging.level is an enum of strings'); + assert.ok(Array.isArray(property.enum), 'logging.level must offer a closed enum'); + for (const level of ['error', 'warn', 'info', 'debug']) { + assert.ok(property.enum.includes(level), `the enum must offer '${level}'`); + } + + // Interaction 2 — the user turns logging up to debug and it reads back. + const config = () => vscode.workspace.getConfiguration('sharplsp'); + await config().update('logging.level', 'debug', vscode.ConfigurationTarget.Global); + assert.strictEqual(config().get('logging.level'), 'debug', 'the new level must read back'); + assert.notStrictEqual(config().get('logging.level'), 'info', 'and no longer be the default'); + + // Interaction 3 — clearing it restores `info`, not the last value written. + await config().update('logging.level', undefined, vscode.ConfigurationTarget.Global); + assert.strictEqual(config().get('logging.level'), 'info', 'clearing restores the default'); + assert.strictEqual( + config().inspect('logging.level')?.globalValue, + undefined, + 'with no residue', + ); }); // ── Package Metadata ───────────────────────────────────────── test('extension has correct display name', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); + // Interaction 1 — the marketplace name a user searches for. + const manifest = packageJson(); + assert.strictEqual(manifest.displayName, 'SharpLsp', "Display name should be 'SharpLsp'"); assert.strictEqual( - ext.packageJSON.displayName, - 'SharpLsp', - "Display name should be 'SharpLsp'", + nlsResolved(authoredPackageJson().displayName), + manifest.displayName, + 'the authored manifest names it through package.nls.json, and resolves to the loaded name', + ); + + // Interaction 2 — it carries the marketplace copy that goes with the name. + assert.ok( + typeof manifest.description === 'string' && manifest.description.length > 0, + 'a marketplace listing needs a non-empty description', + ); + assert.ok(manifest.icon, 'the listing needs an icon per [DIST-VSIX-ASSET-INTEGRITY]'); + assert.ok(Array.isArray(manifest.categories), 'the listing needs marketplace categories'); + + // Interaction 3 — and the palette prefix matches the display name, so every + // SharpLsp command groups under one heading in the command palette. + const categories = new Set(commandEntries().map((command) => command.category)); + assert.deepStrictEqual( + [...categories], + ['SharpLsp'], + 'one palette heading, named for the product', ); }); test('extension contributes csharp language', async function () { this.timeout(LSP_RESPONSE_MS); - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string }[] = ext.packageJSON.contributes?.languages ?? []; - const csharp = languages.find((l) => l.id === 'csharp'); - assert.ok(csharp, 'Should contribute csharp language'); - // Open a C# file and an F# file in split editor, with SharpLsp panel showing solution. - const { uri: csUri } = await openCSharpFile( + // Interaction 1 — the manifest claims csharp and owns .cs outright. + assertLanguageOwnsExtension('csharp', '.cs'); + const entry = languageNamed('csharp'); + assert.ok(entry.aliases?.length, 'csharp must carry at least one display alias'); + + // Interaction 2 — a real .cs buffer resolves to that language AND the + // server answers for it. A contributed language nothing serves is a + // syntax-highlighting stub, not C# support. + const { uri: csUri, doc } = await openCSharpFile( tmpDir, 'editors-shot.cs', `namespace Demo\n{\n public class Calculator\n {\n public int Add(int a, int b) => a + b;\n }\n}`, ); - await waitForDocumentSymbols(csUri); + assert.strictEqual(doc.languageId, 'csharp', 'the fixture opens as csharp'); + const symbols = await waitForDocumentSymbols(csUri); + assert.ok(symbols.length > 0, 'the csharp language must be served, not merely declared'); + assert.ok( + flattenSymbolNames(symbols).includes('Calculator'), + 'and the served symbols must describe THIS document', + ); + + // Interaction 3 — C# and F# open side by side without either losing its + // language, which is the split-editor case a .NET solution hits constantly. await vscode.commands.executeCommand('workbench.action.splitEditorRight'); - await openCSharpFile( + const { doc: fsDoc } = await openCSharpFile( tmpDir, 'editors-shot.fs', 'module Demo\n\nlet greet name = sprintf "Hello, %s!" name\n', ); + assert.strictEqual(fsDoc.languageId, 'fsharp', 'the F# split keeps its own language'); + assert.strictEqual(doc.languageId, 'csharp', 'and the C# side is unchanged by the split'); await new Promise((r) => setTimeout(r, 800)); - // Load fixture solution so Solution Explorer shows content. if (process.env['SHARPLSP_SCREENSHOTS']) { - const api2 = ext.exports as - | { - explorerProvider?: { - loadSolution(p: string): Promise; - getChildren(e?: unknown): unknown[] | undefined; - }; - } - | undefined; - if (api2?.explorerProvider) { - const ws2 = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; - await api2.explorerProvider.loadSolution(`${ws2}/TestFixtures.sln`); - let w = 0; - while ((api2.explorerProvider.getChildren() ?? []).length === 0 && w < 8000) { - await new Promise((r) => setTimeout(r, 200)); - w += 200; - } - } + await loadFixtureSolution(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''); } await openSharpLspPanel(); await takeScreenshot('vscode-editors-page.png'); }); - test('extension contributes fsharp language', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string }[] = ext.packageJSON.contributes?.languages ?? []; - const fsharp = languages.find((l) => l.id === 'fsharp'); - assert.ok(fsharp, 'Should contribute fsharp language'); + test('extension contributes fsharp language', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — fsharp is contributed and owns .fs outright. F# ahead of + // C#: a .fs file claimed by both languages opens arbitrarily. + assertLanguageOwnsExtension('fsharp', '.fs'); + const entry = languageNamed('fsharp'); + assert.ok(entry.aliases?.length, 'fsharp must carry at least one display alias'); + + // Interaction 2 — it claims every F# shape, not just implementation files. + for (const shape of ['.fs', '.fsx', '.fsi']) { + assert.ok(entry.extensions?.includes(shape), `fsharp must claim ${shape}`); + } + + // Interaction 3 — and a real F# buffer resolves to it. + const { doc } = await openCSharpFile(tmpDir, 'contributes.fsx', 'let square x = x * x\n'); + assert.strictEqual(doc.languageId, 'fsharp', 'a script buffer resolves to fsharp'); + assert.strictEqual(doc.isClosed, false, 'and stays open'); }); // ── Command Handler Invocation ───────────────────────────── test('sharplsp.showOutput executes without error', async function () { this.timeout(COMMAND_MS); - await assert.doesNotReject(async () => { - await vscode.commands.executeCommand('sharplsp.showOutput'); - }, 'showOutput command should not throw'); + // Interaction 1 — [DIST-FAILURE-UX] rule 3: the [Show Log] path must never + // throw, because it is the path a user takes when something ALREADY broke. + const first = await invokeCommand('sharplsp.showOutput'); + assert.strictEqual(first.rejected, false, `showOutput must not throw: ${first.message}`); + assert.strictEqual(first.message, '', 'a clean invocation reports nothing'); + + // Interaction 2 — and it is idempotent: showing an already-shown channel is + // a no-op, not a second panel or a rejection. + const second = await invokeCommand('sharplsp.showOutput'); + assert.strictEqual(second.rejected, false, 'a second showOutput must not throw'); + assert.deepStrictEqual(second, first, 'the second invocation reports the same outcome'); + + // Interaction 3 — it stays reachable afterwards; showing a channel must not + // deregister the command that showed it. + const palette = await vscode.commands.getCommands(true); + assertReachableCommand('sharplsp.showOutput', palette); }); test('sharplsp.showTraceOutput executes without error', async function () { this.timeout(COMMAND_MS); - await assert.doesNotReject(async () => { - await vscode.commands.executeCommand('sharplsp.showTraceOutput'); - }, 'showTraceOutput command should not throw'); + // Interaction 1 — the trace channel opens without throwing. + const first = await invokeCommand('sharplsp.showTraceOutput'); + assert.strictEqual(first.rejected, false, `showTraceOutput must not throw: ${first.message}`); + assert.strictEqual(first.message, '', 'a clean invocation reports nothing'); + + // Interaction 2 — opening the trace channel does not disturb the plain one: + // [DIST-CLEAN-OUTPUT] keeps per-request chatter and user-facing output apart. + const plain = await invokeCommand('sharplsp.showOutput'); + assert.strictEqual(plain.rejected, false, 'the plain channel still opens afterwards'); + const again = await invokeCommand('sharplsp.showTraceOutput'); + assert.strictEqual(again.rejected, false, 'and the trace channel re-opens after it'); + + // Interaction 3 — both remain reachable from the palette. + const palette = await vscode.commands.getCommands(true); + assertReachableCommand('sharplsp.showTraceOutput', palette); + assertReachableCommand('sharplsp.showOutput', palette); }); test('sharplsp.restartServer executes without error', async function () { this.timeout(ACTIVATION_MS); - // Ensure server is running first. + // Interaction 1 — the server is serving BEFORE the restart, so the + // post-restart assertion below means something. const { uri } = await openCSharpFile(tmpDir, 'pre-restart.cs', 'class PreRestart { }'); - await waitForDocumentSymbols(uri); + const before = await waitForDocumentSymbols(uri); + assert.ok(before.length > 0, 'the server must be serving before the restart'); + assert.strictEqual(before[0]?.name, 'PreRestart', 'and serving THIS document'); - await assert.doesNotReject(async () => { - await vscode.commands.executeCommand('sharplsp.restartServer'); - }, 'restartServer command should not throw'); + // Interaction 2 — [DIST-FAILURE-UX] rule 6: the recovery command runs + // without throwing, however the server was behaving beforehand. + const outcome = await invokeCommand('sharplsp.restartServer'); + assert.strictEqual(outcome.rejected, false, `restartServer must not throw: ${outcome.message}`); + assert.strictEqual(outcome.message, '', 'a clean restart reports nothing'); - // Verify server is back. - const symbols = await waitForDocumentSymbols(uri, LSP_RESPONSE_MS); - assert.ok(symbols.length > 0, 'Server should respond after restart'); + // Interaction 3 — and the server is serving AGAIN. A restart that leaves + // the client dead is the failure this command exists to fix. + const after = await waitForDocumentSymbols(uri, LSP_RESPONSE_MS); + assert.ok(after.length > 0, 'the server must answer again after a restart'); + assert.deepStrictEqual( + after.map((symbol) => symbol.name), + before.map((symbol) => symbol.name), + 'and answer identically — a restart changes nothing about the document', + ); + assert.strictEqual( + sharpLspExtension().isActive, + true, + 'the extension survives its own restart', + ); - // Open Calculator.cs from the fixture workspace so a real file is visible. if (process.env['SHARPLSP_SCREENSHOTS']) { - const ext2 = vscode.extensions.getExtension(EXTENSION_ID); - const api2 = ext2?.exports as - | { - explorerProvider?: { - loadSolution(p: string): Promise; - getChildren(e?: unknown): unknown[] | undefined; - }; - } - | undefined; - const ws2 = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; - const calcUri = vscode.Uri.file(`${ws2}/Calculator.cs`); + const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; + const calcUri = vscode.Uri.file(`${ws}/Calculator.cs`); const calcDoc = await vscode.workspace.openTextDocument(calcUri); await vscode.window.showTextDocument(calcDoc, { preview: false }); await waitForDocumentSymbols(calcUri); - if (api2?.explorerProvider) { - await api2.explorerProvider.loadSolution(`${ws2}/TestFixtures.sln`); - let w = 0; - while ((api2.explorerProvider.getChildren() ?? []).length === 0 && w < 8000) { - await new Promise((r) => setTimeout(r, 200)); - w += 200; - } - } + await loadFixtureSolution(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''); } // Close any bottom panel, open SharpLsp sidebar — shows Rust host + Roslyn sidecar in action. await vscode.commands.executeCommand('workbench.action.closePanel'); await openSharpLspPanel(); - await new Promise((r) => setTimeout(r, 1_000)); + await settleForScreenshot(1_000); await takeScreenshot('vscode-architecture-page.png'); }); // ── C# Language Configuration ────────────────────────────── - test('csharp language contributes .cs extension', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string; extensions?: string[] }[] = - ext.packageJSON.contributes?.languages ?? []; - const csharp = languages.find((l) => l.id === 'csharp'); - assert.ok(csharp, 'Should contribute csharp language'); - assert.ok(csharp.extensions?.includes('.cs'), 'csharp should include .cs extension'); + test('csharp language contributes .cs extension', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — the manifest claim, and it is exclusive. + assertLanguageOwnsExtension('csharp', '.cs'); + + // Interaction 2 — the claim is honoured by the editor for a real file. + const { doc } = await openCSharpFile(tmpDir, 'owns-cs.cs', 'class OwnsCs { }'); + assert.strictEqual(doc.languageId, 'csharp', 'a .cs file opens as csharp'); + assert.strictEqual(doc.uri.fsPath.endsWith('.cs'), true, 'and it really is a .cs path'); + + // Interaction 3 — the language-configuration file backing it is real JSON + // with the bracket pairs a C# editor needs, not an empty placeholder. + const configured = languageNamed('csharp').configuration ?? ''; + const parsed = readLanguageConfiguration(configured); + assert.ok(parsed.brackets, 'the csharp language configuration must declare brackets'); + assert.ok(parsed.comments, 'and comment tokens, or Toggle Comment does nothing'); }); - test('csharp language contributes .csx extension', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string; extensions?: string[] }[] = - ext.packageJSON.contributes?.languages ?? []; - const csharp = languages.find((l) => l.id === 'csharp'); - assert.ok(csharp); - assert.ok(csharp.extensions?.includes('.csx'), 'csharp should include .csx extension'); + test('csharp language contributes .csx extension', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — the manifest claim for C# script files, exclusive. + assertLanguageOwnsExtension('csharp', '.csx'); + + // Interaction 2 — a .csx buffer opens as csharp, so scripts get the same + // editor behaviour as compiled sources ([SCRIPTING-FILEBASED-SPEC]). + const { doc } = await openCSharpFile(tmpDir, 'owns-csx.csx', 'var value = 41 + 1;\n'); + assert.strictEqual(doc.languageId, 'csharp', 'a .csx file opens as csharp'); + assert.strictEqual(doc.isClosed, false, 'and stays open'); + + // Interaction 3 — .cs and .csx are the SAME language entry, not two. + const entry = languageNamed('csharp'); + assert.ok(entry.extensions?.includes('.cs'), 'one csharp entry claims .cs'); + assert.ok(entry.extensions?.includes('.csx'), 'and the same entry claims .csx'); + assert.strictEqual( + languageEntries().filter((language) => language.id === 'csharp').length, + 1, + 'there is exactly one csharp contribution, not one per extension', + ); }); // ── F# Language Configuration ────────────────────────────── - test('fsharp language contributes .fs extension', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string; extensions?: string[] }[] = - ext.packageJSON.contributes?.languages ?? []; - const fsharp = languages.find((l) => l.id === 'fsharp'); - assert.ok(fsharp); - assert.ok(fsharp.extensions?.includes('.fs'), 'fsharp should include .fs extension'); + test('fsharp language contributes .fs extension', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — the manifest claim, exclusive to F#. + assertLanguageOwnsExtension('fsharp', '.fs'); + + // Interaction 2 — a real .fs buffer resolves to fsharp. + const { doc } = await openCSharpFile(tmpDir, 'owns-fs.fs', 'module OwnsFs\nlet value = 1\n'); + assert.strictEqual(doc.languageId, 'fsharp', 'a .fs file opens as fsharp'); + assert.strictEqual(doc.lineCount >= 2, true, 'and carries the module we wrote'); + + // Interaction 3 — its language configuration is real, with F# comment + // tokens. F# comments are `//` and `(* *)`, not C#'s `/* */`. + const parsed = readLanguageConfiguration(languageNamed('fsharp').configuration ?? ''); + assert.ok(parsed.comments, 'the fsharp language configuration must declare comment tokens'); + assert.ok(parsed.brackets, 'and bracket pairs'); }); - test('fsharp language contributes .fsx extension', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string; extensions?: string[] }[] = - ext.packageJSON.contributes?.languages ?? []; - const fsharp = languages.find((l) => l.id === 'fsharp'); - assert.ok(fsharp); - assert.ok(fsharp.extensions?.includes('.fsx'), 'fsharp should include .fsx extension'); + test('fsharp language contributes .fsx extension', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — the manifest claim for F# scripts, exclusive. + assertLanguageOwnsExtension('fsharp', '.fsx'); + + // Interaction 2 — an .fsx buffer opens as fsharp, which is what routes it + // to FSI rather than to a C# handler. + const { doc } = await openCSharpFile(tmpDir, 'owns-fsx.fsx', 'printfn "hello"\n'); + assert.strictEqual(doc.languageId, 'fsharp', 'a .fsx file opens as fsharp'); + assert.strictEqual(doc.isClosed, false, 'and stays open'); + + // Interaction 3 — no C# entry claims it, and the fsharp entry claims it once. + const claims = languageEntries().filter((language) => + (language.extensions ?? []).includes('.fsx'), + ); + assert.deepStrictEqual( + claims.map((claim) => claim.id), + ['fsharp'], + 'only fsharp claims .fsx', + ); + assert.strictEqual(claims.length, 1, 'and it claims it exactly once'); }); - test('fsharp language contributes .fsi extension', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should exist'); - const languages: { id: string; extensions?: string[] }[] = - ext.packageJSON.contributes?.languages ?? []; - const fsharp = languages.find((l) => l.id === 'fsharp'); - assert.ok(fsharp); - assert.ok(fsharp.extensions?.includes('.fsi'), 'fsharp should include .fsi extension'); + test('fsharp language contributes .fsi extension', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — the manifest claim for F# signature files, exclusive. + assertLanguageOwnsExtension('fsharp', '.fsi'); + + // Interaction 2 — a signature file opens as fsharp. Signature files are + // first-class F#: dropping them leaves a real F# project half-served. + const { doc } = await openCSharpFile( + tmpDir, + 'owns-fsi.fsi', + 'module OwnsFsi\nval value: int\n', + ); + assert.strictEqual(doc.languageId, 'fsharp', 'a .fsi file opens as fsharp'); + assert.strictEqual(doc.lineCount >= 2, true, 'and carries the signature we wrote'); + + // Interaction 3 — all three F# shapes land on ONE entry, so they share the + // same language configuration and the same server routing. + const entry = languageNamed('fsharp'); + for (const shape of ['.fs', '.fsx', '.fsi']) { + assert.ok(entry.extensions?.includes(shape), `the single fsharp entry claims ${shape}`); + } }); // ── Package Metadata Extras ──────────────────────────────── test('extension has MIT license', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - assert.strictEqual(ext.packageJSON.license, 'MIT'); + // Interaction 1 — the manifest declares MIT, per [SHARPLSP-LICENSING]. + const manifest = packageJson(); + assert.strictEqual(manifest.license, 'MIT', 'SharpLsp ships under MIT'); + assert.strictEqual( + authoredPackageJson().license, + 'MIT', + 'and the authored manifest agrees with the loaded one', + ); + + // Interaction 2 — the licence file ships in the VSIX beside the manifest, + // so an offline install can read the terms it is bound by. + const licensed = ['LICENSE', 'LICENSE.md', 'LICENSE.txt'].filter((name) => + fs.existsSync(`${sharpLspExtension().extensionPath}/${name}`), + ); + assert.notDeepStrictEqual(licensed, [], 'a licence file must ship in the extension payload'); + + // Interaction 3 — the listing points at a public repository, which is what + // makes "open source, no vendor lock-in" verifiable by the user. + assert.ok(manifest.repository, 'the manifest must declare a repository'); + assert.ok( + JSON.stringify(manifest.repository).includes('github.com'), + 'and it must be a public one', + ); }); test('extension has version string', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - assert.ok(typeof ext.packageJSON.version === 'string', 'version must be a string'); - assert.match(ext.packageJSON.version, /^\d+\.\d+\.\d+/); + // Interaction 1 — a plain semver core, no `v`, per [DIST-VERSION-INVARIANT]. + const version = manifestVersion(); + assert.ok(version.length > 0, 'the version must not be empty'); + + // Interaction 2 — the loaded manifest and the authored one agree. A release + // stamps both Cargo.toml and package.json; a drift here means the VSIX was + // packaged from a different commit than the one that was stamped. + assert.strictEqual( + authoredPackageJson().version, + version, + 'the authored manifest and the loaded manifest must report the same version', + ); + + // Interaction 3 — it splits into three numeric components, which is what + // makes a byte-for-byte comparison against Cargo.toml meaningful. + const parts = version.split(/[-+]/)[0]?.split('.') ?? []; + assert.strictEqual(parts.length, 3, `semver core must have three parts, got '${version}'`); + for (const part of parts) { + assert.match(part, /^\d+$/, `each version component must be numeric, got '${part}'`); + } }); // Implements [DIST-RUNTIME-ACQUIRE]. The test host installs the .NET Install @@ -324,30 +692,76 @@ suite('Extension Activation & Configuration', () => { // declaration is deleted from package.json — silently breaking automatic // SDK acquisition for real installs (the v0.1.0 failure mode). test('package.json declares the .NET Install Tool as an extensionDependency', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const deps: string[] = ext.packageJSON.extensionDependencies ?? []; + // Interaction 1 — rule 1: the dependency is declared, so VS Code installs + // it silently alongside SharpLsp with no user prompt. + const deps: string[] = packageJson().extensionDependencies ?? []; assert.ok( - deps.includes('ms-dotnettools.vscode-dotnet-runtime'), - 'extensionDependencies must include ms-dotnettools.vscode-dotnet-runtime per [DIST-RUNTIME-ACQUIRE]', + deps.includes(INSTALL_TOOL_ID), + `extensionDependencies must include ${INSTALL_TOOL_ID}`, + ); + assert.strictEqual( + deps.filter((dep) => dep === INSTALL_TOOL_ID).length, + 1, + 'declared exactly once', + ); + + // Interaction 2 — the authored manifest declares it too, so the guard + // survives a repackage rather than depending on host-side installation. + const authored: string[] = authoredPackageJson().extensionDependencies ?? []; + assert.deepStrictEqual(authored, deps, 'authored and loaded dependency lists must agree'); + assert.ok(authored.includes(INSTALL_TOOL_ID), 'and the authored one names the Install Tool'); + + // Interaction 3 — [DIST-EDITOR-CONTRACT] rule 2: nothing else is a + // dependency, because every other component ships inside the VSIX. + assert.deepStrictEqual( + deps, + [INSTALL_TOOL_ID], + 'the .NET Install Tool is the ONLY dependency — every binary ships in the VSIX', ); }); - test('the .NET Install Tool extension resolves in the extension host', () => { - const installTool = vscode.extensions.getExtension('ms-dotnettools.vscode-dotnet-runtime'); + test('the .NET Install Tool extension resolves in the extension host', async function () { + this.timeout(SETTLE_MS); + // Interaction 1 — the declared dependency is really present. + const installTool = vscode.extensions.getExtension(INSTALL_TOOL_ID); + assert.ok(installTool, `${INSTALL_TOOL_ID} must be present in the host`); + assert.strictEqual(installTool.id, INSTALL_TOOL_ID, 'and resolve under the id we declared'); + + // Interaction 2 — rule 2: SharpLsp activates it explicitly, so a disabled + // dependency becomes a clear message instead of "command not found". + await installTool.activate(); + assert.strictEqual(installTool.isActive, true, 'the Install Tool must activate on demand'); + + // Interaction 3 — rules 3 and 4: the commands SharpLsp calls are really + // registered by it. A renamed upstream command breaks SDK acquisition + // silently, and only this assertion catches it. + const palette = await vscode.commands.getCommands(true); + for (const command of ['dotnet.findPath', 'dotnet.acquireGlobalSDK']) { + assert.ok(palette.includes(command), `[DIST-RUNTIME-ACQUIRE] calls '${command}'`); + } assert.ok( - installTool, - 'ms-dotnettools.vscode-dotnet-runtime must be present in the host (installed via extensionDependencies)', + palette.includes('dotnet.acquire'), + "the runtime-mode 'dotnet.acquire' is offered too", + ); + + // Interaction 4 — VS Code activates a dependency BEFORE its dependent. An + // active SharpLsp beside a dormant Install Tool means the + // `extensionDependencies` declaration is not being honoured, and rule 3's + // acquisition call would fail at the worst possible moment: first launch. + assert.strictEqual(sharpLspExtension().isActive, true, 'SharpLsp is active here'); + assert.strictEqual(installTool.isActive, true, 'so its declared dependency must be too'); + assert.notStrictEqual(installTool.id, EXTENSION_ID, 'the dependency is a separate extension'); + assert.ok( + installTool.packageJSON.version, + 'and a resolvable one, with a version the acquisition contract can be pinned against', ); }); - test('extension contributes all expected commands', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const cmds: { command: string }[] = ext.packageJSON.contributes?.commands ?? []; - assert.ok(cmds.length >= 8, `Should contribute at least 8 commands, got ${cmds.length}`); - const ids = cmds.map((c) => c.command); - // Core commands that must always be present. + test('extension contributes all expected commands', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — every command the feature specs name is declared AND + // registered. Declared-but-unregistered is a palette entry that errors. + const palette = await vscode.commands.getCommands(true); for (const required of [ 'sharplsp.restartServer', 'sharplsp.showOutput', @@ -367,19 +781,25 @@ suite('Extension Activation & Configuration', () => { 'sharplsp.nuget.update', 'sharplsp.nuget.restore', ]) { - assert.ok(ids.includes(required), `Missing required command: ${required}`); + assertReachableCommand(required, palette); + } + + // Interaction 2 — the declared set has no duplicates. A duplicate id shows + // twice in the palette and the second declaration silently wins. + const ids = commandEntries().map((command) => command.command); + assert.deepStrictEqual([...new Set(ids)], ids, 'no command may be declared twice'); + assert.ok(ids.length >= 17, `at least the seventeen named commands, got ${ids.length}`); + + // Interaction 3 — and the recovery commands of [DIST-FAILURE-UX] are among + // them, so a degraded activation is recoverable from the palette. + for (const recovery of RECOVERY_COMMANDS) { + assert.ok(ids.includes(recovery), `${recovery} must be declared as a recovery command`); } }); test('extension contributes all expected configuration properties', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const props = ext.packageJSON.contributes?.configuration?.properties ?? {}; - const keys = Object.keys(props); - assert.ok( - keys.length >= 4, - `Should contribute at least 4 config properties, got ${keys.length}`, - ); + // Interaction 1 — every setting the distribution spec names is contributed. + const keys = Object.keys(configProperties()); for (const required of [ 'sharplsp.lspPath', 'sharplsp.csharpSidecarPath', @@ -390,78 +810,225 @@ suite('Extension Activation & Configuration', () => { ]) { assert.ok(keys.includes(required), `Missing required config property: ${required}`); } + + // Interaction 2 — every one of them is documented and typed. An + // undocumented setting cannot be used from the Settings UI at all. + for (const [key, property] of Object.entries(configProperties())) { + assert.ok(key.startsWith('sharplsp.'), `${key} must live in the sharplsp namespace`); + assert.ok(property.type, `${key} must declare a JSON type`); + assert.ok( + (property.description ?? property.markdownDescription ?? '').length > 0, + `${key} must carry a description`, + ); + } + + // Interaction 3 — [DIST-WORKSPACE-TRUST]: every restricted setting the spec + // names is both contributed and restricted. + for (const restricted of TRUST_RESTRICTED_SETTINGS) { + assert.ok(keys.includes(restricted), `${restricted} must be contributed`); + assertTrustRestricted(restricted); + } }); - test('extension contributes exactly 2 languages', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const languages: { id: string }[] = ext.packageJSON.contributes?.languages ?? []; - assert.strictEqual(languages.length, 2); - const ids = languages.map((l) => l.id); - assert.deepStrictEqual(ids.sort(), ['csharp', 'fsharp']); + test('extension contributes exactly 2 languages', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — two languages, named. SharpLsp is one server for both. + const ids = languageEntries().map((language) => language.id); + assert.strictEqual(ids.length, 2, `exactly two contributed languages, got ${ids.length}`); + assert.deepStrictEqual([...ids].sort(), ['csharp', 'fsharp']); + + // Interaction 2 — their extension claims are disjoint, so no file opens + // under the wrong language. + const csharp = new Set(languageNamed('csharp').extensions ?? []); + const fsharp = new Set(languageNamed('fsharp').extensions ?? []); + const shared = [...csharp].filter((claim) => fsharp.has(claim)); + assert.deepStrictEqual(shared, [], 'the two languages must claim disjoint extensions'); + assert.ok(csharp.size >= 2, 'csharp claims at least .cs and .csx'); + assert.ok(fsharp.size >= 3, 'fsharp claims at least .fs, .fsx and .fsi'); + + // Interaction 3 — and both are live: the editor resolves a file of each. + const { doc: cs } = await openCSharpFile(tmpDir, 'exactly-two.cs', 'class Two { }'); + const { doc: fsx } = await openCSharpFile(tmpDir, 'exactly-two.fsx', 'let two = 2\n'); + assert.strictEqual(cs.languageId, 'csharp', 'the C# half resolves'); + assert.strictEqual(fsx.languageId, 'fsharp', 'and the F# half resolves'); }); test('extension has language configuration files', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const languages: { id: string; configuration?: string }[] = - ext.packageJSON.contributes?.languages ?? []; - for (const lang of languages) { - assert.ok(lang.configuration, `Language ${lang.id} must have a configuration file`); + // Interaction 1 — every contributed language declares one, under the + // directory the packaging step ships. + for (const language of languageEntries()) { + assert.ok(language.configuration, `Language ${language.id} must have a configuration file`); assert.ok( - lang.configuration.includes('language-configuration/'), + language.configuration.includes('language-configuration/'), `Configuration path should be in language-configuration/`, ); } + + // Interaction 2 — each file EXISTS in the packaged payload and parses. A + // path that ships nothing leaves the editor with no bracket matching at all. + for (const language of languageEntries()) { + const parsed = readLanguageConfiguration(language.configuration ?? ''); + assert.ok(parsed.brackets, `${language.id} must declare bracket pairs`); + assert.ok(parsed.comments, `${language.id} must declare comment tokens`); + } + + // Interaction 3 — the two languages use DIFFERENT configuration files. F# + // is not C# with a different extension: sharing one file would give F# + // block comments it does not have. + const paths = languageEntries().map((language) => language.configuration); + assert.deepStrictEqual([...new Set(paths)], paths, 'each language needs its own configuration'); + assert.strictEqual(paths.length, 2, 'one configuration file per contributed language'); }); - test("all commands have a category of 'SharpLsp'", () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const cmds: { command: string; category?: string }[] = - ext.packageJSON.contributes?.commands ?? []; - for (const cmd of cmds) { + test("all commands have a category of 'SharpLsp'", async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — every declared command sits under the one heading a user + // types "SharpLsp" to find. + for (const command of commandEntries()) { assert.strictEqual( - cmd.category, + command.category, 'SharpLsp', - `Command ${cmd.command} should have category 'SharpLsp'`, + `Command ${command.command} should have category 'SharpLsp'`, ); } + + // Interaction 2 — one heading, not several near-identical ones. + const categories = [...new Set(commandEntries().map((command) => command.category))]; + assert.deepStrictEqual(categories, ['SharpLsp'], 'exactly one palette heading'); + assert.strictEqual(categories.length, 1, 'and no second spelling of it'); + + // Interaction 3 — the heading matches the product name, so the palette and + // the marketplace listing agree on what this extension is called. + assert.strictEqual(packageJson().displayName, categories[0], 'heading matches display name'); + const palette = await vscode.commands.getCommands(true); + assert.ok( + commandEntries().every((command) => palette.includes(command.command)), + 'and every command under that heading is really registered', + ); + + // Interaction 4 — the category is AUTHORED, not injected by core at load + // time. Reading it back off the loaded manifest alone would still pass if + // this repository shipped no category at all. + const authored: { command: string; category?: string }[] = + authoredPackageJson().contributes?.commands ?? []; + assert.strictEqual( + authored.length, + commandEntries().length, + 'the authored and loaded command lists must be the same length', + ); + for (const command of authored) { + assert.strictEqual( + nlsResolved(command.category), + 'SharpLsp', + `${command.command} must be AUTHORED under the SharpLsp category, via package.nls.json`, + ); + } + assert.deepStrictEqual( + authored.map((command) => command.command), + commandEntries().map((command) => command.command), + 'and both lists must name the same commands in the same order', + ); }); test('all commands have a title', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const cmds: { command: string; title?: string }[] = ext.packageJSON.contributes?.commands ?? []; - for (const cmd of cmds) { + // Interaction 1 — a non-empty title, or the palette row is blank. + for (const command of commandEntries()) { assert.ok( - cmd.title && cmd.title.length > 0, - `Command ${cmd.command} must have a non-empty title`, + command.title && command.title.length > 0, + `Command ${command.command} must have a non-empty title`, ); } - }); - // ── Activation Events ────────────────────────────────────── + // Interaction 2 — titles are DISTINCT. Two commands sharing a title are + // indistinguishable in the palette, which is a usability defect the + // manifest can express and nothing else catches. + const titles = commandEntries().map((command) => command.title); + assert.deepStrictEqual([...new Set(titles)], titles, 'no two commands may share a title'); + assert.strictEqual(titles.length, commandEntries().length, 'one title per command'); - test('extension has workspaceContains activation events', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext); - const events: string[] = ext.packageJSON.activationEvents ?? []; - assert.ok( - events.some((e: string) => e.includes('*.sln')), - 'Should activate on .sln files', + // Interaction 3 — no title repeats the category, because VS Code already + // renders it as "SharpLsp: ". + for (const command of commandEntries()) { + assert.strictEqual( + command.title?.startsWith('SharpLsp'), + false, + `${command.command} must not repeat the category in its title`, + ); + } + + // Interaction 4 — the titles are AUTHORED and human-readable: trimmed, not + // a copy of the command id, and byte-identical to what this repository + // ships. A title core normalised on load hides an unreadable manifest. + const authored: { command: string; title?: string }[] = + authoredPackageJson().contributes?.commands ?? []; + // Resolved through package.nls.json, the way VS Code resolves them at load + // time: the manifest authors a title as `%cmd.restartServer%`, and the + // bundle is where the human-readable string it stands for actually lives. + assert.deepStrictEqual( + authored.map((command) => nlsResolved(command.title)), + titles, + 'every authored title must resolve to the title the host loaded', ); - assert.ok( - events.some((e: string) => e.includes('*.slnx')), - 'Should activate on .slnx files', + assert.deepStrictEqual( + authored.map((command) => command.command), + commandEntries().map((entry) => entry.command), + 'and both lists must name the same commands in the same order', ); + for (const command of commandEntries()) { + assert.strictEqual( + command.title, + command.title?.trim(), + `${command.command} title must carry no leading or trailing whitespace`, + ); + assert.notStrictEqual( + command.title, + command.command, + `${command.command} needs a human title, not a copy of its id`, + ); + } + }); + + // ── Activation Events ────────────────────────────────────── + + test('extension has workspaceContains activation events', async function () { + this.timeout(COMMAND_MS); + // Interaction 1 — a .NET workspace activates SharpLsp on sight, whichever + // project shape it uses. + const events: string[] = packageJson().activationEvents ?? []; + for (const shape of ['*.sln', '*.slnx', '*.csproj', '*.fsproj']) { + assert.ok( + events.some((event) => event.includes(shape)), + `Should activate on ${shape} files`, + ); + } + + // Interaction 2 — every project shape is a `workspaceContains:` event, and + // none is the `*` blanket. A blanket event activates SharpLsp in every + // window, which is exactly the startup cost [SHARPLSP-PERFORMANCE] avoids. + assert.strictEqual(events.includes('*'), false, 'SharpLsp must never activate unconditionally'); assert.ok( - events.some((e: string) => e.includes('*.csproj')), - 'Should activate on .csproj files', + events.some((event) => event.startsWith('workspaceContains:')), + 'project detection must use workspaceContains:', ); - assert.ok( - events.some((e: string) => e.includes('*.fsproj')), - 'Should activate on .fsproj files', + assert.deepStrictEqual([...new Set(events)], events, 'no activation event may be listed twice'); + + // Interaction 3 — and it is ALREADY active here, because this workspace + // contains a solution: the declaration and the behaviour agree. + assert.strictEqual(sharpLspExtension().isActive, true, 'the fixture workspace activated it'); + assert.deepStrictEqual( + authoredPackageJson().activationEvents ?? [], + events, + 'the authored events and the loaded ones must agree', ); }); }); + +/** A language-configuration file, read out of the packaged payload and parsed. */ +function readLanguageConfiguration(relativePath: string): Record<string, unknown> { + assert.ok(relativePath.length > 0, 'a language must declare a configuration path'); + const resolved = `${sharpLspExtension().extensionPath}/${relativePath}`; + assert.ok(fs.existsSync(resolved), `language configuration must ship at ${resolved}`); + const parsed: unknown = JSON.parse(fs.readFileSync(resolved, 'utf8')); + assert.ok(parsed && typeof parsed === 'object', `${relativePath} must parse as a JSON object`); + return parsed as Record<string, unknown>; +} diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts index 912560e8..bf66b497 100644 --- a/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-codefix-basics.test.ts @@ -9,6 +9,7 @@ import { type CodeFixScenario, } from './fsharp-refactor-fixtures'; import { + activateWarmFSharp, applyAction, assertInsertion, assertNoAction, @@ -25,9 +26,9 @@ import { undoAction, uniqueAction, } from './fsharp-refactor-test-kit'; -import { activateRealSharpLsp, revertDocument } from './refactor-test-helpers'; +import { revertDocument } from './refactor-test-helpers'; import { closeAllEditors } from './test-helpers'; -import { LSP_RESPONSE_MS } from './test-timeouts'; +import { LSP_RESPONSE_MS, SIDECAR_COLD_MS } from './test-timeouts'; // Full real-LSP lifecycle coverage for [ANALYZERS-FSAC-PARITY]. No mocked providers. const TARGET_FILE = 'fsharp/DiagnosticsTarget.fs'; @@ -57,7 +58,12 @@ interface BasicFixSpec extends CodeFixScenario { suite('F# real LSP — diagnostic quick fixes', defineBasicFixSuite); function defineBasicFixSuite(): void { - suiteSetup(activateRealSharpLsp); + suiteSetup(async function () { + this.timeout(SIDECAR_COLD_MS); + const first = OPEN_SCENARIOS[0]; + assert.ok(first, 'the open-directive scenarios must not be empty'); + await activateWarmFSharp(TARGET_FILE, first.source, first.diagnostic); + }); teardown(closeAllEditors); suiteTeardown(closeAllEditors); registerOpenTests(); diff --git a/src/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts b/src/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts index 67b36643..65496a09 100644 --- a/src/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts +++ b/src/editors/vscode/src/test/suite/fsharp-lsp-hierarchy.test.ts @@ -11,14 +11,28 @@ import { LSP_RESPONSE_MS } from './test-timeouts'; * Neither is implemented in the F# sidecar yet, so these tests are EXPECTED to * fail until the handlers are built (drive via /fix-bug). C# has both; F# must * match and exceed. + * + * Spec: [SHARPLSP-FEATURES-CODE-LENS] (reference count, P1), + * [SHARPLSP-FEATURES-NAVIGATION] (prepareCallHierarchy / incomingCalls / + * outgoingCalls, P1), [SHARPLSP-FEATURES-FSHARP]. + * + * "At least one lens came back" is the weakest possible claim: a provider that + * returns one unresolved lens with no command, anchored nowhere in particular, + * satisfies it and shows the user nothing. Every test here asserts WHERE the + * lens sits, WHAT it says, and that the count it reports matches the call sites + * in the committed fixture. */ +/** The declarations in Library.fs that must each carry a reference lens. */ +const LENSED_DECLARATIONS = ['area', 'totalArea', 'describeParity']; + suite('F# LSP — Code Lens', () => { suiteTeardown(closeAllEditors); teardown(closeAllEditors); test('provides reference-count lenses on F# declarations', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); + // Interaction 1 — the provider answers for a real F# library file. const library = await openFSharpFixture('Library.fs'); const lenses = await pollUntilResult( async () => codeLensesFor(library.uri), @@ -27,6 +41,54 @@ suite('F# LSP — Code Lens', () => { 2_000, ); assert.ok(lenses.length >= 1, `Library.fs must expose ≥1 code lens, got ${lenses.length}`); + assert.strictEqual(library.doc.languageId, 'fsharp', 'the fixture opens as F#'); + assert.ok(library.doc.lineCount > 30, 'and it is the committed multi-declaration fixture'); + + // Interaction 2 — every lens is ANCHORED inside the document, on its own + // line, and no two lenses claim the same anchor. A lens outside the buffer + // renders nowhere; two on one line render on top of each other. + for (const lens of lenses) { + assert.ok( + lens.range.end.line < library.doc.lineCount, + `a lens at line ${lens.range.start.line} must sit inside the ${library.doc.lineCount}-line file`, + ); + assert.ok(lens.range.start.isBeforeOrEqual(lens.range.end), 'and must not be inverted'); + } + const anchors = lenses.map((lens) => `${lens.range.start.line}:${lens.range.start.character}`); + assert.deepEqual([...new Set(anchors)], anchors, 'no two lenses may share an anchor'); + + // Interaction 3 — [SHARPLSP-FEATURES-CODE-LENS] makes the reference count a + // P1 feature. A lens with no resolved command is a blank line above the + // declaration: it occupies the space and tells the user nothing. + const resolved = lenses.filter((lens) => lens.isResolved || lens.command !== undefined); + assert.ok( + resolved.length >= 1, + `at least one lens must resolve to a command; ${lenses.length} lenses, none resolved`, + ); + const titles = resolved.map((lens) => lens.command?.title ?? ''); + assert.ok( + titles.some((title) => /reference/i.test(title)), + `a reference-count lens must say so; titles: ${titles.join(' | ')}`, + ); + assert.ok( + titles.every((title) => title.trim().length > 0), + 'and no resolved lens may carry an empty title', + ); + + // Interaction 4 — the lenses sit on the DECLARATIONS a user would expect, + // not on arbitrary lines. F# is a first-class citizen: `area`, `totalArea` + // and `describeParity` are exactly the shapes C# gets lenses for. + const lensedText = lenses.map((lens) => library.doc.lineAt(lens.range.start.line).text); + for (const declaration of LENSED_DECLARATIONS) { + assert.ok( + lensedText.some((line) => line.includes(`let ${declaration}`)), + `'${declaration}' must carry a lens; lensed lines: ${lensedText.join(' | ')}`, + ); + } + assert.ok( + lenses.length >= LENSED_DECLARATIONS.length, + `at least one lens per lensed declaration, got ${lenses.length}`, + ); }); }); @@ -36,6 +98,8 @@ suite('F# LSP — Call Hierarchy', () => { test('prepares a call hierarchy item and resolves incoming calls', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); + // Interaction 1 — preparing on `double` yields the item the hierarchy view + // is rooted at. const usage = await openFSharpFixture('Usage.fs'); const position = positionOf(usage.doc, 'let double (value', 'let '.length); const items = await pollUntilResult( @@ -50,11 +114,72 @@ suite('F# LSP — Call Hierarchy', () => { 2_000, ); assert.ok(items.length > 0, 'call hierarchy must prepare an item for the double function'); + assert.strictEqual(items.length, 1, 'one binding under the caret, one root item'); + // Interaction 2 — the root item DESCRIBES `double`: its own name, its own + // file, and a selection range covering the identifier the user clicked. An + // item whose range points elsewhere navigates away from the symbol. + const root = items[0]; + assert.ok(root, 'the prepared item must be readable'); + assert.strictEqual(root.name, 'double', `the item names the binding, got '${root.name}'`); + assert.strictEqual( + root.uri.toString(), + usage.uri.toString(), + 'and points at the file the caret was in', + ); + assert.strictEqual( + usage.doc.getText(root.selectionRange), + 'double', + 'with a selection range over the identifier alone', + ); + assert.ok(root.range.contains(root.selectionRange), 'and a range containing it'); + + // Interaction 3 — incoming calls. `double` is called twice inside + // `quadruple` and once by `answer`, so a hierarchy reporting nothing is the + // feature being absent, and one reporting a single call has lost a site. const incoming = await vscode.commands.executeCommand<vscode.CallHierarchyIncomingCall[]>( 'vscode.provideIncomingCalls', - items[0], + root, + ); + const calls = incoming ?? []; + assert.ok(calls.length >= 1, 'double must have ≥1 incoming call (from quadruple)'); + const callers = calls.map((call) => call.from.name); + assert.ok( + callers.includes('quadruple'), + `quadruple calls double; callers: ${callers.join(', ')}`, + ); + assert.deepEqual([...new Set(callers)], callers, 'no caller may be reported twice'); + + // Interaction 4 — every reported call site is a real one: inside the file, + // covering the `double` identifier, and there are two of them inside + // `quadruple` (`double (double value)`). + const fromQuadruple = calls.find((call) => call.from.name === 'quadruple'); + assert.ok(fromQuadruple, 'the quadruple caller must be readable'); + assert.ok( + fromQuadruple.fromRanges.length >= 2, + `quadruple calls double twice; got ${fromQuadruple.fromRanges.length} site(s)`, + ); + for (const range of fromQuadruple.fromRanges) { + assert.strictEqual( + usage.doc.getText(range), + 'double', + 'each call site covers the identifier alone', + ); + assert.ok(range.end.line < usage.doc.lineCount, 'and lands inside the document'); + } + + // Interaction 5 — the hierarchy walks the OTHER way too. Outgoing calls + // from `quadruple` must reach `double`, or the view expands in one + // direction only ([SHARPLSP-FEATURES-NAVIGATION] lists both as P1). + const outgoing = await vscode.commands.executeCommand<vscode.CallHierarchyOutgoingCall[]>( + 'vscode.provideOutgoingCalls', + fromQuadruple.from, + ); + const targets = (outgoing ?? []).map((call) => call.to.name); + assert.ok( + targets.includes('double'), + `quadruple's outgoing calls must include double; got: ${targets.join(', ')}`, ); - assert.ok((incoming ?? []).length >= 1, 'double must have ≥1 incoming call (from quadruple)'); + assert.ok((outgoing ?? []).length >= 1, 'and there must be at least one outgoing call'); }); }); diff --git a/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts b/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts index 41b3ea1f..4113708b 100644 --- a/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts +++ b/src/editors/vscode/src/test/suite/fsharp-refactor-test-kit.ts @@ -1,10 +1,12 @@ import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; import { + activateRealSharpLsp, applyWorkspaceEdit, openFixtureDocument, preparedRenameAt, replaceDocumentText, + revertDocument, waitForCodeActions, waitForMatchingDiagnostics, waitForResolvedCodeActions, @@ -13,7 +15,7 @@ import { type WorkspaceEditSnapshot, } from './refactor-test-helpers'; import { pollUntilResult } from './test-helpers'; -import { LSP_RESPONSE_MS } from './test-timeouts'; +import { LSP_RESPONSE_MS, SIDECAR_COLD_MS } from './test-timeouts'; // Assertion helpers shared by the real-LSP F# suites. [ANALYZERS-FSAC-PARITY] @@ -31,6 +33,7 @@ export async function diagnosticWithCode( uri: vscode.Uri, code: string, range?: vscode.Range, + timeoutMs: number = LSP_RESPONSE_MS, ): Promise<vscode.Diagnostic[]> { // `range` exists so the WAIT can be as strong as the caller's assertion. // Waiting only for the code and then asserting on the location is a race FCS @@ -46,10 +49,30 @@ export async function diagnosticWithCode( diagnosticCode(diagnostic) === code && (range === undefined || diagnostic.range.intersection(range) !== undefined), ), - LSP_RESPONSE_MS, + timeoutMs, ); } +/** + * Activate SharpLsp and pay FCS's cold start ONCE, on an overlay known to + * produce `code`. The first F# check of the process cracks the project and can + * outrun `LSP_RESPONSE_MS` on a CI agent; charged to a test body it fails the + * first scenario on timing alone ([DIST-CI-VSIX-SHARDS-TIMEOUTS]). + */ +export async function activateWarmFSharp( + relativePath: string, + source: string, + code: string, +): Promise<void> { + await activateRealSharpLsp(); + const fixture = await openOverlay(relativePath, source); + try { + await diagnosticWithCode(fixture.uri, code, undefined, SIDECAR_COLD_MS); + } finally { + await revertDocument(fixture.document); + } +} + export async function diagnosticGone(uri: vscode.Uri, code: string): Promise<vscode.Diagnostic[]> { return waitForMatchingDiagnostics( uri, diff --git a/src/editors/vscode/src/test/suite/hover.test.ts b/src/editors/vscode/src/test/suite/hover.test.ts index 842d6439..a4ec2993 100644 --- a/src/editors/vscode/src/test/suite/hover.test.ts +++ b/src/editors/vscode/src/test/suite/hover.test.ts @@ -9,6 +9,7 @@ import { pollUntilResult, replaceDocumentContent, setupLspTestSuite, + settleForScreenshot, takeScreenshot, teardownLspTestSuite, waitForDocumentSymbols, @@ -16,6 +17,28 @@ import { } from './test-helpers'; import { COMMAND_MS, FIXTURE_BUILD_MS, LSP_RESPONSE_MS, LSP_SWEEP_MS } from './test-timeouts'; +/** + * [HOVER-PROTOCOL-RESPONSE]: every content entry MUST be Markdown. + * + * "Plain-text fallback is not supported — all LSP 3.17 clients support + * Markdown." A hover that arrives as a bare string renders its backticks and + * its fenced code block as literal characters, which is the difference between + * a signature and a line of punctuation. + */ +function assertMarkdownContents(hovers: readonly vscode.Hover[], where: string): void { + assert.ok(hovers.length > 0, `${where}: a hover must have been returned`); + for (const hover of hovers) { + assert.ok(hover.contents.length > 0, `${where}: a hover must carry content entries`); + for (const entry of hover.contents) { + assert.ok( + entry instanceof vscode.MarkdownString, + `${where}: every content entry must be Markdown, got ${typeof entry}`, + ); + assert.ok(entry.value.trim().length > 0, `${where}: no content entry may be blank`); + } + } +} + suite('Hover / Quick Info', () => { let tmpDir: string; let workspaceRoot: string; @@ -128,7 +151,7 @@ suite('Hover / Quick Info', () => { ); await vscode.commands.executeCommand('editor.action.triggerSuggest'); // Wait for widget to appear — no other commands that could dismiss it. - await new Promise((r) => setTimeout(r, 2500)); + await settleForScreenshot(2500); // No openSharpLspPanel() — the completion dropdown IS the feature; keep editor visible. await takeScreenshot('vscode-completions-page.png'); @@ -163,9 +186,9 @@ suite('Hover / Quick Info', () => { goToEditor.selection.active.isEqual(definitionPosition), 'Definition cursor must be on Add', ); - await new Promise((r) => setTimeout(r, 300)); + await settleForScreenshot(300); await vscode.commands.executeCommand('editor.action.peekDefinition'); - await new Promise((r) => setTimeout(r, 3000)); + await settleForScreenshot(3000); await takeScreenshot('vscode-go-to-definition-page.png'); }); @@ -193,6 +216,37 @@ suite('Hover / Quick Info', () => { // Verify content is markdown. assert.ok(firstHover.contents.length > 0, 'Must have content entries'); + + // Interaction 2 — [HOVER-PROTOCOL-RESPONSE] makes `range` "the range of the + // hovered token", which is what the editor highlights while the tooltip is + // up. A range covering the whole declaration highlights the line; one + // covering nothing highlights nothing. + const document = await vscode.workspace.openTextDocument(uri); + assert.ok(firstHover.range, 'a hover over an identifier must report its range'); + assert.strictEqual( + document.getText(firstHover.range), + 'Widget', + `the range must cover the hovered token alone, covers '${document.getText(firstHover.range)}'`, + ); + assert.strictEqual( + firstHover.range.start.line, + firstHover.range.end.line, + 'an identifier range never straddles a line break', + ); + + // Interaction 3 — the contents are Markdown and name the symbol, so the + // tooltip a user sees is a signature rather than escaped punctuation. + assertMarkdownContents(hovers, 'HoverRange Widget'); + const markdown = hoverToString(hovers); + assert.ok(markdown.includes('Widget'), `the tooltip names the type: ${markdown}`); + assert.ok(markdown.includes('```'), 'and renders its signature in a fenced code block'); + + // Interaction 4 — hovering the SAME position twice answers identically. + // [HOVER-CACHING] makes the second read a salsa hit; a cache that returns a + // different tooltip is worse than no cache. + const again = await waitForHoverResult(uri, new vscode.Position(2, 18)); + assert.strictEqual(hoverToString(again), markdown, 'a repeat hover answers identically'); + assert.ok(again[0]?.range?.isEqual(firstHover.range), 'and reports the same range'); }); // ── Whitespace & Comment Rejection (multiple positions) ───────── @@ -229,6 +283,40 @@ suite('Hover / Quick Info', () => { assert.ok(classHover.length > 0, 'Class hover must not be empty'); const md = hoverToString(classHover); assert.ok(md.includes('Bar'), "Class hover must mention 'Bar'"); + + // Interaction 3 — BLANK positions are rejected too. [HOVER-ERRORS] lists + // "position is whitespace or comment" as one row, and the tree-sitter + // pre-validation of [HOVER-ROUTING] is what makes it a sub-millisecond + // rejection instead of a sidecar round trip on every mouse move. + // Line 4 is the empty line before the namespace; 7:2 is inside the + // indentation of the class line. Line 5 is the `namespace` keyword itself. + const document = await vscode.workspace.openTextDocument(uri); + for (const blank of [new vscode.Position(4, 0), new vscode.Position(7, 2)]) { + const hovers = await vscode.commands.executeCommand<vscode.Hover[]>( + 'vscode.executeHoverProvider', + uri, + blank, + ); + assert.ok( + hovers === undefined || hovers.length === 0, + `whitespace at ${blank.line}:${blank.character} must produce no hover`, + ); + } + + // Interaction 4 — the rejection is about the POSITION, not the file. The + // very same document answers for its declaration, with Markdown contents + // and a range over the identifier. + assertMarkdownContents(classHover, 'HoverReject Bar'); + assert.strictEqual( + document.getText(classHover[0]?.range ?? new vscode.Range(0, 0, 0, 0)), + 'Bar', + 'the class hover ranges over its own identifier', + ); + assert.ok(md.includes('```'), 'and renders a fenced signature'); + assert.ok( + md.includes('class'), + `[HOVER-CSHARP-RENDERING] requires the signature, which names the kind: ${md}`, + ); }); // ── Edit → Re-hover (content changes reflected) ───────────────── @@ -267,6 +355,33 @@ suite('Hover / Quick Info', () => { assert.ok(runHover.length > 0, 'Run method hover must return results'); const runMd = hoverToString(runHover); assert.ok(runMd.includes('Run'), 'Must see Run in method hover'); + + // Interaction 4 — the OLD name is gone. "The new name appeared" is only + // half of it: a sidecar serving a stale buffer would show both, and the + // user would hover a symbol that no longer exists. + assert.strictEqual( + bravoMd.includes('Alpha'), + false, + `the pre-edit type name must not survive the rename: ${bravoMd}`, + ); + assert.notStrictEqual(bravoMd, alphaMd, 'the tooltip really changed'); + assert.strictEqual(doc.isDirty, true, 'and the edit was never saved to disk'); + + // Interaction 5 — both post-edit tooltips are well formed: Markdown, with a + // fenced signature, ranged over the identifier the user pointed at. + assertMarkdownContents(bravoHover, 'HoverEdit Bravo'); + assertMarkdownContents(runHover, 'HoverEdit Run'); + assert.ok(bravoMd.includes('```'), 'the type tooltip carries a fenced signature'); + assert.strictEqual( + doc.getText(runHover[0]?.range ?? new vscode.Range(0, 0, 0, 0)), + 'Run', + 'and the method tooltip ranges over the method name', + ); + + // Interaction 6 — the member is attributed to its CONTAINING TYPE. + // [HOVER-CSHARP-RENDERING] makes that mandatory for members, because the + // signature alone cannot say where the member came from. + assert.ok(runMd.includes('Bravo'), `a member tooltip must name its containing type: ${runMd}`); }); // ── Struct, Enum, Interface hover ─────────────────────────────── @@ -316,6 +431,36 @@ suite('Hover / Quick Info', () => { md.includes('Gadget') || md.toLowerCase().includes('inferred'), `var hover must show inferred type Gadget: ${md}`, ); + + // Interaction 2 — [HOVER-CSHARP-CASES] row 1: hovering `var` shows the + // INFERRED type "with full signature". A tooltip that echoes the keyword + // back tells the reader nothing they could not already see. + assertMarkdownContents(varHover, 'HoverVar var'); + assert.ok(md.includes('Gadget'), `the inferred type must be named outright: ${md}`); + const document = await vscode.workspace.openTextDocument(uri); + assert.strictEqual( + document.getText(varHover[0]?.range ?? new vscode.Range(0, 0, 0, 0)), + 'var', + 'and the range covers the keyword the user pointed at', + ); + + // Interaction 3 — a SECOND `var`, inferred from a property rather than a + // constructor, resolves to that property's type. One working case is a + // special case; two is inference. + const propertyVar = await waitForHoverResult(uri, new vscode.Position(8, 12)); + assertMarkdownContents(propertyVar, 'HoverVar second var'); + const propertyMd = hoverToString(propertyVar); + assert.ok(propertyMd.includes('int'), `var over 'g.Size' must infer int, got: ${propertyMd}`); + assert.notStrictEqual(propertyMd, md, 'two different inferences give two different tooltips'); + + // Interaction 4 — hovering the initialiser itself names the same type, so + // the keyword and the expression agree about what is being declared. + const constructed = await waitForHoverResult(uri, new vscode.Position(7, 28)); + assertMarkdownContents(constructed, 'HoverVar new Gadget()'); + assert.ok( + hoverToString(constructed).includes('Gadget'), + 'the constructed type resolves to Gadget as well', + ); }); // ── XML documentation rendering ────────────────────────────── @@ -345,13 +490,40 @@ suite('Hover / Quick Info', () => { md.toLowerCase().includes('result') || md.toLowerCase().includes('return'), `Must render <returns>: ${md}`, ); + + // Interaction 2 — the rendered documentation is MARKDOWN, and the raw XML + // tags are gone. [HOVER-CSHARP-RENDERING-XML] renders `<summary>` as a + // paragraph and `<param>` as a parameter list; leaking the tags puts + // literal angle brackets in the tooltip. + assertMarkdownContents(hovers, 'HoverXmlDoc Factorial'); + for (const tag of ['<summary>', '</summary>', '<param', '<returns>']) { + assert.strictEqual(md.includes(tag), false, `the raw ${tag} tag must not reach the tooltip`); + } + + // Interaction 3 — [HOVER-CSHARP-RENDERING] requires the SIGNATURE and the + // containing type alongside the prose, so the reader can tell a `long` + // return from an `int` one without leaving the tooltip. + assert.ok(md.includes('long'), `the signature must name the return type: ${md}`); + assert.ok(md.includes('MathHelper'), `and the containing type: ${md}`); + assert.ok(md.includes('public'), `and its accessibility: ${md}`); + + // Interaction 4 — the parameter's own name reaches the reader, so the + // `<param name="n">` description is attached to something. + const document = await vscode.workspace.openTextDocument(uri); + assert.strictEqual( + document.getText(hovers[0]?.range ?? new vscode.Range(0, 0, 0, 0)), + 'Factorial', + 'the hover ranges over the method name', + ); + assert.ok(md.includes('n'), 'and the parameter name appears in the rendered documentation'); + // Position cursor on Factorial and trigger the hover widget visually. const editor = vscode.window.activeTextEditor; assert.ok(editor, 'Must have active text editor'); editor.selection = new vscode.Selection(new vscode.Position(7, 21), new vscode.Position(7, 21)); await vscode.commands.executeCommand('editor.action.showHover'); // Wait for the hover widget to render in the DOM before screenshotting. - await new Promise((r) => setTimeout(r, 2000)); + await settleForScreenshot(2000); // Screenshot with hover tooltip visible — sidecar waits for .monaco-hover to appear. await takeScreenshot('vscode-hover-page.png'); }); @@ -372,6 +544,33 @@ suite('Hover / Quick Info', () => { assert.ok(md.includes('```'), 'Must have code block'); assert.ok(md.includes('Deprecated') || md.includes('Obsolete'), `Must show deprecation: ${md}`); assert.ok(md.includes('Use NewMethod instead'), `Must include obsolete message: ${md}`); + + // Interaction 2 — [HOVER-CSHARP-RENDERING] lists deprecation as a REQUIRED + // section when present. The whole point is that it is visible without + // reading the attribute, so the tooltip carries the signature too. + assertMarkdownContents(hovers, 'HoverObsolete OldMethod'); + assert.ok(md.includes('Legacy'), `and names the containing type: ${md}`); + assert.ok(md.includes('void'), `and the signature's return type: ${md}`); + const document = await vscode.workspace.openTextDocument(uri); + assert.strictEqual( + document.getText(hovers[0]?.range ?? new vscode.Range(0, 0, 0, 0)), + 'OldMethod', + 'and ranges over the deprecated method name', + ); + + // Interaction 3 — the NON-deprecated sibling shows no deprecation. A + // tooltip that marks everything obsolete is as useless as one that marks + // nothing, and only the pair can tell them apart. + const healthy = await waitForHoverResult(uri, new vscode.Position(6, 21)); + assertMarkdownContents(healthy, 'HoverObsolete NewMethod'); + const healthyMd = hoverToString(healthy); + assert.ok(healthyMd.includes('NewMethod'), `the sibling tooltip names it: ${healthyMd}`); + assert.strictEqual( + healthyMd.includes('Use NewMethod instead'), + false, + 'and carries no deprecation message of its own', + ); + assert.notStrictEqual(healthyMd, md, 'the two tooltips differ'); }); // ── Solution Explorer Integration ─────────────────────────────── @@ -419,6 +618,38 @@ suite('Hover / Quick Info', () => { if (Array.isArray(roots)) { assertNonSymbolNodesLackHoverData(roots); } + + // Interaction 2 — [HOVER-TREE-IMPLEMENTATION] resolves a tree tooltip by + // calling `executeHoverProvider` at the node's own source position. That + // requires BOTH a uri and a position, and requires them to be usable: a + // node carrying a position but no uri resolves against whatever file + // happens to be active. + const nodes = Array.isArray(roots) ? collectSymbolNodes(roots) : []; + for (const node of nodes) { + const named = node.sortName ?? node.symbolKind ?? '?'; + assert.ok(node.symbolUri, `symbol node '${named}' must carry the uri to hover in`); + assert.ok(node.symbolPosition, `symbol node '${named}' must carry the position to hover at`); + assert.strictEqual( + vscode.Uri.parse(node.symbolUri).scheme, + 'file', + `symbol node '${named}' must point at a real file`, + ); + } + + // Interaction 3 — the positions are inside their files. A position past the + // end of the buffer makes `executeHoverProvider` answer null, which reads + // to the user as "this symbol has no documentation". + for (const node of nodes.slice(0, 10)) { + const position = node.symbolPosition; + assert.ok(position, 'the position must be readable'); + assert.ok(position.line >= 0, 'a symbol position has a non-negative line'); + assert.ok(position.character >= 0, 'and a non-negative character'); + } + assert.strictEqual( + nodes.some((node) => node.nodeType === 'project' || node.nodeType === 'solution'), + false, + 'and only SYMBOL nodes are collected — a project node has no hover position', + ); }); // ── Tree Tooltip (resolveTreeItem) ────────────────────────────── @@ -480,52 +711,24 @@ suite('Hover / Quick Info', () => { `Tree must have symbol nodes, found ${String(symbolNodes.length)}`, ); - // Resolve symbol nodes and verify tooltips match LSP hover. - // Not every symbol gets hover from the sidecar (e.g. compact field - // declarations), so we verify the mechanism works on those that do. - const provider = api.explorerProvider; - const tokenSource = new vscode.CancellationTokenSource(); - let tooltipCount = 0; - - for (const node of symbolNodes) { - const treeItem = provider.getTreeItem(node); - const resolved = await provider.resolveTreeItem(treeItem, node, tokenSource.token); - - // Skip symbols where the sidecar returned no hover data. - if (resolved.tooltip === undefined || !(resolved.tooltip instanceof vscode.MarkdownString)) { - continue; - } - - tooltipCount++; - const treeMd = resolved.tooltip.value; - assert.ok(treeMd.length > 0, `Tooltip for '${node.sortName ?? '?'}' must not be empty`); - assert.ok( - treeMd.includes('```'), - `Tooltip for '${node.sortName ?? '?'}' must have code block: ${treeMd}`, - ); - - // Tooltip should contain the symbol name or a type signature. - if (node.sortName !== undefined && node.sortName.length > 0) { - assert.ok( - treeMd.includes(node.sortName) || treeMd.includes('```'), - `Tooltip must contain symbol name '${node.sortName}' or code block: ${treeMd}`, - ); - } - - // Tree tooltip must match the code editor hover at the same position. - if (node.symbolUri !== undefined && node.symbolPosition !== undefined) { - const nodeUri = vscode.Uri.parse(node.symbolUri); - const pos = new vscode.Position(node.symbolPosition.line, node.symbolPosition.character); - const codeHover = await waitForHoverResult(nodeUri, pos); - const codeMd = hoverToString(codeHover); - assert.strictEqual( - treeMd, - codeMd, - `Tree tooltip must match code hover for '${node.sortName ?? '?'}'`, - ); - } + // Every file the walk will hover is opened ONCE and kept open, the way the + // user's own files are. A hover on a CLOSED file has the workbench open a + // model around the request and drop it after, so the server would see a + // didOpen/didClose cycle per SYMBOL rather than per file. + for (const file of symbolFiles(symbolNodes)) { + await vscode.workspace.openTextDocument(vscode.Uri.parse(file)); } + // Resolve every symbol's tooltip and hold it to the code hover. The walk + // is concurrent: several hundred sidecar round trips are pipelined instead + // of paid one after another, which is what put the sweep past its budget. + const tokenSource = new vscode.CancellationTokenSource(); + const produced = await Promise.all( + symbolNodes.map((node) => + assertTooltipMatchesHover(api.explorerProvider, node, tokenSource.token), + ), + ); + const tooltipCount = produced.filter(Boolean).length; assert.ok( tooltipCount > 0, `At least one symbol must have a tooltip, got ${String(tooltipCount)} from ${String(symbolNodes.length)} symbols`, @@ -556,7 +759,16 @@ suite('Hover / Quick Info', () => { assert.ok(api?.explorerProvider, 'Must export explorerProvider'); const roots = api.explorerProvider.getChildren(); + assert.ok( + Array.isArray(roots) || roots === undefined, + 'getChildren returns an array or nothing', + ); if (roots === undefined || roots.length === 0) return; + assert.ok(roots.length > 0, 'the loaded tree has at least one root'); + assert.ok( + roots.every((node) => typeof node === 'object'), + 'and every root is a node object', + ); // Find non-symbol nodes (solution, project, dependency folder). const tokenSource = new vscode.CancellationTokenSource(); @@ -569,12 +781,48 @@ suite('Hover / Quick Info', () => { tokenSource.token, ); // Non-symbol nodes should not get a code block tooltip. + const named = node.sortName ?? node.nodeType ?? '?'; if (resolved.tooltip instanceof vscode.MarkdownString) { assert.ok( !resolved.tooltip.value.includes('```csharp'), - `Non-symbol node '${node.sortName ?? node.nodeType ?? '?'}' must not get C# tooltip`, + `Non-symbol node '${named}' must not get C# tooltip`, + ); + assert.ok( + !resolved.tooltip.value.includes('```fsharp'), + `nor an F# one — [HOVER-TREE] scopes LSP hover to SYMBOL rows: '${named}'`, ); } + + // Interaction 2 — resolving a non-symbol row must not MUTATE it into + // something else. `resolveTreeItem` is called lazily as the user + // scrolls, so a resolver that swaps the label or the collapsible state + // makes rows change under the mouse. + assert.strictEqual(resolved.label, treeItem.label, `${named} keeps its label`); + assert.strictEqual( + resolved.collapsibleState, + treeItem.collapsibleState, + `${named} keeps its collapsible state`, + ); + assert.strictEqual( + resolved.contextValue, + treeItem.contextValue, + `${named} keeps its contextValue, so its context menu is unchanged`, + ); + + // Interaction 3 — resolving is IDEMPOTENT and cancellation-safe: the + // same row resolved twice answers the same, and a cancelled token + // yields a tree item rather than a rejection. + const again = await api.explorerProvider.resolveTreeItem(treeItem, node, tokenSource.token); + assert.strictEqual(again.label, resolved.label, `${named} resolves identically twice`); + const cancelled = new vscode.CancellationTokenSource(); + cancelled.cancel(); + const underCancellation = await api.explorerProvider.resolveTreeItem( + treeItem, + node, + cancelled.token, + ); + assert.ok(underCancellation, `${named} still yields an item under cancellation`); + cancelled.dispose(); } } tokenSource.dispose(); @@ -613,6 +861,46 @@ interface SymbolTreeNode { readonly children?: SymbolTreeNode[]; } +/** The slice of the explorer provider a tooltip walk needs. */ +interface TooltipProvider { + getTreeItem(element: unknown): vscode.TreeItem; + resolveTreeItem( + item: vscode.TreeItem, + element: unknown, + token: vscode.CancellationToken, + ): Promise<vscode.TreeItem>; +} + +/** The distinct files the symbol nodes point into. */ +function symbolFiles(nodes: readonly SymbolTreeNode[]): string[] { + const uris = nodes.flatMap((node) => (node.symbolUri === undefined ? [] : [node.symbolUri])); + return [...new Set(uris)]; +} + +/** + * Resolve one symbol node's tooltip and, when the sidecar gave it one, hold it + * to the code hover at the symbol's own position. Answers whether a tooltip + * was produced: not every symbol gets hover from the sidecar (compact field + * declarations, say), so the caller counts the ones that did. + */ +async function assertTooltipMatchesHover( + provider: TooltipProvider, + node: SymbolTreeNode, + token: vscode.CancellationToken, +): Promise<boolean> { + const resolved = await provider.resolveTreeItem(provider.getTreeItem(node), node, token); + if (!(resolved.tooltip instanceof vscode.MarkdownString)) return false; + const treeMd = resolved.tooltip.value; + const name = node.sortName ?? '?'; + assert.ok(treeMd.length > 0, `Tooltip for '${name}' must not be empty`); + assert.ok(treeMd.includes('```'), `Tooltip for '${name}' must have code block: ${treeMd}`); + if (node.symbolUri === undefined || node.symbolPosition === undefined) return true; + const pos = new vscode.Position(node.symbolPosition.line, node.symbolPosition.character); + const codeMd = hoverToString(await waitForHoverResult(vscode.Uri.parse(node.symbolUri), pos)); + assert.strictEqual(treeMd, codeMd, `Tree tooltip must match code hover for '${name}'`); + return true; +} + /** Recursively collect all symbol nodes from the tree. */ function collectSymbolNodes(nodes: SymbolTreeNode[]): SymbolTreeNode[] { const result: SymbolTreeNode[] = []; diff --git a/src/editors/vscode/src/test/suite/lsp-codeaction-add-using.test.ts b/src/editors/vscode/src/test/suite/lsp-codeaction-add-using.test.ts new file mode 100644 index 00000000..fec28340 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-codeaction-add-using.test.ts @@ -0,0 +1,424 @@ +// Ctrl-. on a type that is missing its namespace import MUST offer the using. +// +// [SHARPLSP-FEATURES-REFACTORING] lists "Add using/open directive" as P0 with +// Roslyn's AddImport CodeFix behind it. That row is the single most-used +// interaction in day-to-day C#: the developer types a type name, the compiler +// reports it as unresolved, and the lightbulb must offer to import it. +// +// One syntactic position passing is not the feature. A type name can appear in +// a base list, a field declaration, a return type, a parameter, a generic +// argument, an attribute, or as the receiver of an extension method, and Ctrl-. +// has to work in EVERY one of them. Each row below is one of those positions; +// each drives the full lifecycle (offer, resolve, apply, requery, undo, redo, +// retry) and requires the diagnostic to DISAPPEAR after applying. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { + codeOf, + exerciseCodeAction, + positionOf, + rangeOf, + rawCodeActions, + type ActionLifecycleCase, + type RawCodeAction, +} from './csharp-refactor-test-kit'; +import { + activateRealSharpLsp, + openFixtureDocument, + replaceDocumentText, + revertDocument, + waitForCodeActions, + waitForMatchingDiagnostics, + warmSemanticEngine, + type OpenFixture, +} from './refactor-test-helpers'; +import { FIXTURE_BUILD_MS, LSP_RESPONSE_MS } from './test-timeouts'; + +const HEADER = 'namespace SharpLsp.TestFixtures.AddUsing;\n'; + +/** A fixture whose ONLY error is the one missing import under test. */ +function body(source: string): string { + return HEADER + source; +} + +const NEW_EXPRESSION = body(`public class NewExpressionTarget +{ + public object Make() { return new Stopwatch(); } // new-expression-sentinel +} +`); + +const LOCAL_ANNOTATION = body(`public class LocalAnnotationTarget +{ + public object Make() { Regex pattern = null!; return pattern; } // local-annotation-sentinel +} +`); + +const GENERIC_ARGUMENT = body(`public class GenericArgumentTarget +{ + public object Make() { return new List<int>(); } // generic-argument-sentinel +} +`); + +const FIELD_DECLARATION = body(`public class FieldDeclarationTarget +{ + private Encoding _encoding = null!; // field-declaration-sentinel + public object Read() => _encoding; +} +`); + +const RETURN_TYPE = body(`public class ReturnTypeTarget +{ + public CultureInfo Culture() => null!; // return-type-sentinel +} +`); + +const PARAMETER_TYPE = body(`public class ParameterTypeTarget +{ + public int Length(StringBuilder builder) => 0; // parameter-type-sentinel +} +`); + +const BASE_TYPE = body(`public class BaseTypeTarget : EventArgs +{ + public int Value => 1; // base-type-sentinel +} +`); + +const ATTRIBUTE_USAGE = body(`public class AttributeUsageTarget +{ + [Obsolete("retired")] + public int Value => 1; // attribute-usage-sentinel +} +`); + +const EXTENSION_METHOD = body(`public class ExtensionMethodTarget +{ + public object Project(int[] values) { return values.Select(value => value); } // extension-method-sentinel +} +`); + +const STATIC_MEMBER = body(`public class StaticMemberTarget +{ + public object Read(string path) { return File.ReadAllText(path); } // static-member-sentinel +} +`); + +/** + * Every row shares the same contract: the position reports an unresolved-symbol + * diagnostic, the lightbulb offers the exact using directive as a `quickfix`, + * applying it inserts the directive, and the diagnostic is GONE afterwards. + * `caretOnly` drives the literal user story - a bare caret on the type, no + * selection, which is what Ctrl-. sends. + */ +const CASES: readonly ActionLifecycleCase[] = [ + { + label: 'object-creation type', + source: NEW_EXPRESSION, + snippet: 'new Stopwatch()', + focus: 'Stopwatch', + diagnosticCode: 'CS0246', + title: 'using System.Diagnostics;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Diagnostics;', 'new Stopwatch()', 'new-expression-sentinel'], + absentAfter: [], + }, + { + label: 'local-variable type annotation', + source: LOCAL_ANNOTATION, + snippet: 'Regex pattern', + focus: 'Regex', + diagnosticCode: 'CS0246', + title: 'using System.Text.RegularExpressions;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Text.RegularExpressions;', 'local-annotation-sentinel'], + absentAfter: [], + }, + { + label: 'generic type argument', + source: GENERIC_ARGUMENT, + snippet: 'new List<int>()', + focus: 'List', + diagnosticCode: 'CS0246', + title: 'using System.Collections.Generic;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Collections.Generic;', 'generic-argument-sentinel'], + absentAfter: [], + }, + { + label: 'field declaration type', + source: FIELD_DECLARATION, + snippet: 'private Encoding _encoding', + focus: 'Encoding', + diagnosticCode: 'CS0246', + title: 'using System.Text;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Text;', 'field-declaration-sentinel'], + absentAfter: [], + }, + { + label: 'method return type', + source: RETURN_TYPE, + snippet: 'public CultureInfo Culture()', + focus: 'CultureInfo', + diagnosticCode: 'CS0246', + title: 'using System.Globalization;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Globalization;', 'return-type-sentinel'], + absentAfter: [], + }, + { + label: 'method parameter type', + source: PARAMETER_TYPE, + snippet: 'Length(StringBuilder builder)', + focus: 'StringBuilder', + diagnosticCode: 'CS0246', + title: 'using System.Text;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Text;', 'parameter-type-sentinel'], + absentAfter: [], + }, + { + label: 'base type in the class declaration', + source: BASE_TYPE, + snippet: 'BaseTypeTarget : EventArgs', + focus: 'EventArgs', + diagnosticCode: 'CS0246', + title: 'using System;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System;', 'base-type-sentinel'], + absentAfter: [], + }, + { + label: 'attribute usage', + source: ATTRIBUTE_USAGE, + snippet: '[Obsolete("retired")]', + focus: 'Obsolete', + diagnosticCode: 'CS0246', + title: 'using System;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System;', 'attribute-usage-sentinel'], + absentAfter: [], + }, + { + label: 'extension-method receiver', + source: EXTENSION_METHOD, + snippet: 'values.Select(value => value)', + focus: 'Select', + diagnosticCode: 'CS1061', + title: 'using System.Linq;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.Linq;', 'extension-method-sentinel'], + absentAfter: [], + }, + { + label: 'static member access on an unimported type', + source: STATIC_MEMBER, + snippet: 'File.ReadAllText(path)', + focus: 'File', + diagnosticCode: 'CS0103', + title: 'using System.IO;', + kind: 'quickfix', + caretOnly: true, + mustDisappear: true, + presentAfter: ['using System.IO;', 'static-member-sentinel'], + absentAfter: [], + }, +]; + +const UNKNOWN_TYPE = body(`public class UnknownTypeTarget +{ + public object Make() { return new NoSuchTypeAnywhere(); } // unknown-type-sentinel +} +`); + +const ALREADY_IMPORTED = + 'using System.Text;\n' + + body(`public class AlreadyImportedTarget +{ + public object Make() { return new StringBuilder(); } // already-imported-sentinel +} +`); + +const UNRESOLVED = 'CS0246'; + +function usingActions(actions: readonly RawCodeAction[]): RawCodeAction[] { + return actions.filter((action) => action.title.startsWith('using ')); +} + +/** Ctrl-. sends a COLLAPSED caret, never a selection. Model that exactly. */ +async function caretActions( + fixture: OpenFixture, + snippet: string, + focus: string, +): Promise<RawCodeAction[]> { + const caret = positionOf(fixture.document, snippet, focus); + return rawCodeActions(fixture.uri, new vscode.Range(caret, caret)); +} + +suite('C# real LSP - Ctrl-. adds the missing using [SHARPLSP-FEATURES-REFACTORING]', () => { + let fixture: OpenFixture; + let committedText = ''; + + suiteSetup(async function () { + // ONE initialization for the suite: activation, fixture open and the Roslyn + // project load are paid here so no test body carries a build tier. + this.timeout(FIXTURE_BUILD_MS); + await activateRealSharpLsp(); + fixture = await openFixtureDocument('RefactorCore.cs'); + await warmSemanticEngine(fixture.uri); + committedText = fixture.document.getText(); + }); + + teardown(async () => revertDocument(fixture.document)); + + for (const actionCase of CASES) { + const label = `${actionCase.label}: Ctrl-. offers ${actionCase.title} and clears ${actionCase.diagnosticCode}`; + test(label, async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + await exerciseCodeAction(fixture, committedText, actionCase); + }); + } + + test('the import is the PREFERRED action, so Ctrl-. lands on it first', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + await replaceDocumentText(fixture.document, NEW_EXPRESSION); + + // Interaction 1 - the developer types the unimported type and the compiler + // reports it. Without the diagnostic there is nothing for Ctrl-. to fix. + const before = await waitForMatchingDiagnostics(fixture.uri, (items) => + items.some((item) => codeOf(item) === UNRESOLVED), + ); + const unresolved = before.filter((item) => codeOf(item) === UNRESOLVED); + assert.strictEqual(unresolved.length, 1, 'exactly one unresolved-type error'); + assert.strictEqual(unresolved[0]?.severity, vscode.DiagnosticSeverity.Error, 'it is an error'); + assert.ok( + unresolved[0]?.message.includes('Stopwatch'), + 'and the message names the type the developer just typed', + ); + + // Interaction 2 - the caret goes on the type name, nothing is selected, and + // Ctrl-. is pressed. The import must be offered, and offered FIRST: an + // import buried under "Generate class Stopwatch" is a broken lightbulb. + const offered = await caretActions(fixture, 'new Stopwatch()', 'Stopwatch'); + const imports = usingActions(offered); + assert.strictEqual(imports.length, 1, 'exactly one using directive is offered'); + assert.strictEqual(imports[0]?.title, 'using System.Diagnostics;', 'naming the namespace'); + assert.strictEqual(imports[0]?.kind, 'quickfix', 'as a quickfix, not a refactoring'); + assert.strictEqual( + imports[0]?.isPreferred, + true, + 'and marked preferred so Ctrl-. ranks it top', + ); + assert.strictEqual( + offered.indexOf(imports[0]), + 0, + 'a preferred import must be the first action in the list', + ); + + // Interaction 3 - the same request through VS Code's own provider, because + // the lightbulb the developer actually sees is the editor's, not the wire's. + const uiActions = await waitForCodeActions({ + uri: fixture.uri, + range: rangeOf(fixture.document, 'new Stopwatch()', 'Stopwatch'), + kind: vscode.CodeActionKind.QuickFix, + predicate: (items) => items.some((item) => item.title === 'using System.Diagnostics;'), + }); + const uiImport = uiActions.find((item) => item.title === 'using System.Diagnostics;'); + assert.ok(uiImport, 'VS Code offers the import the protocol offered'); + assert.strictEqual(uiImport.isPreferred, true, 'and carries the preferred flag through'); + assert.ok( + uiImport.kind?.contains(vscode.CodeActionKind.QuickFix), + 'under the quickfix kind the lightbulb filters on', + ); + assert.ok( + uiActions.every((item) => item.title !== 'using System.Diagnostics'), + 'the title is the directive verbatim, semicolon included', + ); + }); + + test('a type that exists in no namespace offers no import at all', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + await replaceDocumentText(fixture.document, UNKNOWN_TYPE); + + // Interaction 1 - an unresolvable name still reports CS0246; the import fix + // must not invent a namespace for a type no assembly contains. The wait + // names the type: the previous scenario's CS0246 can still be published + // for the text this one replaced. + const diagnostics = await waitForMatchingDiagnostics(fixture.uri, (items) => + items.some( + (item) => codeOf(item) === UNRESOLVED && item.message.includes('NoSuchTypeAnywhere'), + ), + ); + assert.ok(diagnostics.length >= 1, 'the unresolvable type is reported'); + assert.ok( + diagnostics.some((item) => item.message.includes('NoSuchTypeAnywhere')), + 'and the message names it', + ); + + // Interaction 2 - Ctrl-. on it may offer generation, but never a fabricated + // import: a using for a namespace that does not exist compiles to nothing. + const offered = await caretActions(fixture, 'new NoSuchTypeAnywhere()', 'NoSuchTypeAnywhere'); + assert.deepStrictEqual(usingActions(offered), [], 'no using directive is offered'); + assert.ok( + offered.every((action) => !action.title.includes('NoSuchTypeAnywhere;')), + 'and nothing pretends the name is a namespace', + ); + assert.strictEqual( + fixture.document.getText().includes('using NoSuchTypeAnywhere'), + false, + 'the buffer gains no invented directive', + ); + }); + + test('a type whose namespace is already imported offers no second import', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + await replaceDocumentText(fixture.document, ALREADY_IMPORTED); + + // Interaction 1 - with the using present the type resolves, so there is no + // unresolved-symbol error left to fix. + const diagnostics = await waitForMatchingDiagnostics( + fixture.uri, + (items) => !items.some((item) => codeOf(item) === UNRESOLVED), + ); + assert.ok( + diagnostics.every((item) => codeOf(item) !== UNRESOLVED), + 'an imported type reports no unresolved-type error', + ); + assert.ok( + fixture.document.getText().includes('using System.Text;'), + 'because the directive is already in the buffer', + ); + + // Interaction 2 - Ctrl-. on the resolved type must not offer to import it + // a second time; a duplicate directive is a compile error of its own. + const offered = await caretActions(fixture, 'new StringBuilder()', 'StringBuilder'); + assert.deepStrictEqual( + usingActions(offered).map((action) => action.title), + [], + 'no redundant import is offered for an already-imported type', + ); + assert.strictEqual( + fixture.document.getText().split('using System.Text;').length - 1, + 1, + 'and the buffer still carries exactly one such directive', + ); + }); +}); diff --git a/src/editors/vscode/src/test/suite/lsp-integration-semantic.test.ts b/src/editors/vscode/src/test/suite/lsp-integration-semantic.test.ts new file mode 100644 index 00000000..23a282b9 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-integration-semantic.test.ts @@ -0,0 +1,512 @@ +// The SEMANTIC tier: completion, definition, references, highlights, inlay +// hints and code actions — everything answered by the Roslyn sidecar. +// +// Spec: [SHARPLSP-ARCHITECTURE-ROUTING] (sidecar, <200ms), +// [SHARPLSP-FEATURES-INTELLIGENCE], [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT], +// [SHARPLSP-FEATURES-NAVIGATION], [SHARPLSP-FEATURES-REFACTORING]. +// +// Split out of lsp-integration.test.ts along the routing boundary: the syntax +// tier is answered by tree-sitter in the Rust host and never touches a sidecar, +// so the two halves fail for entirely different reasons and belong in separate +// files. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { + closeAllEditors, + loadFixtureSolution, + openExistingFile, + openSharpLspPanel, + pollUntilResult, + setupLspTestSuite, + settleForScreenshot, + takeScreenshot, + teardownLspTestSuite, + waitForDocumentSymbols, +} from './test-helpers'; +import { assertCompletionEditSpans } from './lsp-invariants-kit'; +import { ACTIVATION_MS, LSP_RESPONSE_MS } from './test-timeouts'; + +/** The caret inside `CompletionShot.cs` that sits after a member-access dot. */ +const MEMBER_CARET = new vscode.Position(11, 24); +/** The `Add(...)` call site in the same fixture. */ +const ADD_CALL = new vscode.Position(10, 26); +/** The line `Add` is declared on. */ +const ADD_DECLARATION_LINE = 6; + +suite('LSP Integration — Real Semantic LSP', () => { + let tmpDir: string; + let fixtureDir: string; + + suiteSetup(async function () { + this.timeout(ACTIVATION_MS); + const result = await setupLspTestSuite('semantic-'); + tmpDir = result.tmpDir; + fixtureDir = path.resolve(__dirname, '../../../test-fixtures/workspace'); + }); + + suiteTeardown(async () => { + await closeAllEditors(); + teardownLspTestSuite(tmpDir); + }); + + teardown(async () => { + await closeAllEditors(); + }); + + test('returns Roslyn-backed completion items with concrete symbol kinds', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + // Interaction 1 — the sidecar answers with real Roslyn symbols, each + // carrying the KIND its completion icon is drawn from. A list of + // undifferentiated Text items is a tree-sitter word list, not IntelliSense. + const { uri } = await openExistingFile(fixtureDir, 'CompletionShot.cs'); + await waitForDocumentSymbols(uri); + const completions = await completionsAt(uri, MEMBER_CARET); + const items = new Map(completions.items.map((item) => [item.label.toString(), item])); + assert.strictEqual(items.get('Name')?.kind, vscode.CompletionItemKind.Property); + assert.strictEqual(items.get('Add')?.kind, vscode.CompletionItemKind.Method); + assert.strictEqual(items.get('_count')?.kind, vscode.CompletionItemKind.Field); + + // Interaction 2 — [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]: every + // item carries an explicit edit span covering the identifier AT the caret. + // Without one the editor appends after the dot and produces + // `Console.WriteLineWriteLine` (GitHub #178). + assertCompletionEditSpans(completions.items, MEMBER_CARET); + + // Interaction 3 — the list is usable: no duplicate labels, no blank ones, + // and no item that is `Text` when Roslyn knows what it is. + const labels = completions.items.map((item) => item.label.toString()); + assert.deepStrictEqual([...new Set(labels)], labels, 'a member list must not repeat a member'); + assert.ok( + labels.every((label) => label.trim().length > 0), + 'every completion item must be labelled', + ); + assert.strictEqual( + completions.items.filter((item) => item.kind === vscode.CompletionItemKind.Text).length, + 0, + 'a member-access list carries symbol kinds, never a bare Text fallback', + ); + }); + + test('auto-triggers member completion when `.` is typed', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + // Passing a trigger character makes VS Code route the request ONLY to + // providers registered for that character. This stays empty unless the + // server advertises `.` in completionProvider.triggerCharacters — i.e. it + // reproduces "press dot, get nothing" end-to-end through the language client. + const { uri } = await openExistingFile(fixtureDir, 'CompletionShot.cs'); + await waitForDocumentSymbols(uri); + const triggered = await completionsAt(uri, MEMBER_CARET, '.'); + const labels = new Set(triggered.items.map((item) => item.label.toString())); + for (const member of ['Add', 'Name', '_count']) { + assert.ok( + labels.has(member), + `Typing \`.\` must auto-trigger member completion incl. ${member}`, + ); + } + + // Interaction 2 — the dot-triggered list is the SAME list as the invoked + // one. A trigger character that returns a narrower set means the user gets + // one answer when typing and a different one on Ctrl-Space. + const invoked = await completionsAt(uri, MEMBER_CARET); + const invokedLabels = new Set(invoked.items.map((item) => item.label.toString())); + for (const member of ['Add', 'Name', '_count']) { + assert.strictEqual( + labels.has(member) && invokedLabels.has(member), + true, + `${member} must appear whether completion was typed or invoked`, + ); + } + assert.ok(labels.size > 0, 'the dot-triggered list must not be empty'); + + // Interaction 3 — the triggered items carry the same kinds and the same + // explicit edit spans, so accepting one after typing `.` does not duplicate + // the identifier. + const byLabel = new Map(triggered.items.map((item) => [item.label.toString(), item])); + assert.strictEqual(byLabel.get('Add')?.kind, vscode.CompletionItemKind.Method); + assert.strictEqual(byLabel.get('Name')?.kind, vscode.CompletionItemKind.Property); + assertCompletionEditSpans(triggered.items, MEMBER_CARET); + }); + + test('resolves definition and references for a method call site', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + // Interaction 1 — F12 on the call lands on the declaration, in this file. + const { uri, doc } = await openExistingFile(fixtureDir, 'CompletionShot.cs'); + await waitForDocumentSymbols(uri); + const definitions = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.Location[]>( + 'vscode.executeDefinitionProvider', + uri, + ADD_CALL, + )) ?? [], + (locations) => locations.length > 0, + LSP_RESPONSE_MS, + 2_000, + ); + assert.ok( + definitions.some( + (location) => + location.uri.toString() === uri.toString() && + location.range.start.line === ADD_DECLARATION_LINE, + ), + 'Add call must resolve to the Add method declaration', + ); + assert.strictEqual(definitions.length, 1, 'a non-overloaded method has ONE definition'); + assert.ok( + doc.lineAt(definitions[0]!.range.start.line).text.includes('Add'), + 'and the line it lands on really declares Add', + ); + + // Interaction 2 — Shift-F12 finds the call site AND the declaration. + // References that omit the declaration make Rename miss the very symbol + // being renamed. + const references = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.Location[]>( + 'vscode.executeReferenceProvider', + uri, + ADD_CALL, + )) ?? [], + (locations) => locations.length > 0, + LSP_RESPONSE_MS, + 2_000, + ); + assert.ok( + references.some( + (location) => + location.uri.toString() === uri.toString() && location.range.start.line === ADD_CALL.line, + ), + 'References must include the Add call site', + ); + assert.ok( + references.some((location) => location.range.start.line === ADD_DECLARATION_LINE), + 'and the declaration itself', + ); + assert.ok( + references.length >= 2, + `declaration plus call site at least, got ${references.length}`, + ); + + // Interaction 3 — every location is usable: inside the document, covering + // the identifier, and never reported twice. + const seen = new Set<string>(); + for (const location of references) { + const key = `${location.uri.toString()}:${location.range.start.line}:${location.range.start.character}`; + assert.strictEqual(seen.has(key), false, `reference ${key} is reported twice`); + seen.add(key); + assert.ok(location.range.start.line < doc.lineCount, `${key} must lie inside the document`); + assert.strictEqual( + doc.getText(location.range), + 'Add', + `${key} must cover the identifier, not the surrounding expression`, + ); + } + }); + + test('returns document highlights for a semantic symbol occurrence', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + // Interaction 1 — resting on `Add` highlights it. + const { uri, doc } = await openExistingFile(fixtureDir, 'CompletionShot.cs'); + await waitForDocumentSymbols(uri); + const highlights = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.DocumentHighlight[]>( + 'vscode.executeDocumentHighlights', + uri, + ADD_CALL, + )) ?? [], + (items) => items.length > 0, + LSP_RESPONSE_MS, + 2_000, + ); + assert.ok( + highlights.every((highlight) => highlight.range.start.line >= 0), + 'Every document highlight must have a valid range', + ); + assert.ok( + highlights.length >= 2, + `declaration and call site at least, got ${highlights.length}`, + ); + + // Interaction 2 — every highlight covers the IDENTIFIER. A highlight + // spanning the whole invocation paints the arguments as if they were the + // symbol, which is the visible defect [SHARPLSP-FEATURES-NAVIGATION] + // scopes to `SymbolFinder` occurrences. + for (const highlight of highlights) { + assert.strictEqual( + doc.getText(highlight.range), + 'Add', + `highlight at line ${highlight.range.start.line} must cover 'Add' alone`, + ); + assert.strictEqual( + highlight.range.start.line, + highlight.range.end.line, + 'an identifier highlight never straddles a line break', + ); + } + + // Interaction 3 — the occurrence under the caret is among them, they are + // all distinct, and both the declaration and the call are covered. + const lines = highlights.map((highlight) => highlight.range.start.line); + assert.ok(lines.includes(ADD_CALL.line), 'the occurrence under the caret must be highlighted'); + assert.ok(lines.includes(ADD_DECLARATION_LINE), 'and so must the declaration'); + assert.strictEqual(new Set(lines).size, lines.length, 'no occurrence is highlighted twice'); + + // Interaction 4 - highlighting is SYMMETRIC. Resting on the declaration + // must light up exactly the same occurrences as resting on the call, or + // the set the user sees depends on where they happened to put the mouse. + const declarationCaret = new vscode.Position( + ADD_DECLARATION_LINE, + doc.lineAt(ADD_DECLARATION_LINE).text.indexOf('Add') + 1, + ); + const fromDeclaration = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.DocumentHighlight[]>( + 'vscode.executeDocumentHighlights', + uri, + declarationCaret, + )) ?? [], + (items) => items.length > 0, + LSP_RESPONSE_MS, + 2_000, + ); + assert.strictEqual( + fromDeclaration.length, + highlights.length, + 'the declaration and the call must highlight the same number of occurrences', + ); + assert.deepStrictEqual( + fromDeclaration.map((highlight) => highlight.range.start.line).sort((l, r) => l - r), + [...lines].sort((l, r) => l - r), + 'and the very same lines', + ); + assert.ok( + fromDeclaration.every((highlight) => doc.getText(highlight.range) === 'Add'), + 'each of which still covers the identifier alone', + ); + }); + + test('returns parameter-name inlay hints for a real method call', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + // Interaction 1 — the call site gets a hint per argument. + const { doc, uri } = await openExistingFile(fixtureDir, 'CompletionShot.cs'); + await waitForDocumentSymbols(uri); + const hints = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.InlayHint[]>( + 'vscode.executeInlayHintProvider', + uri, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(doc.lineCount, 0)), + )) ?? [], + (items) => items.length >= 2, + LSP_RESPONSE_MS, + 2_000, + ); + const labels = hints.map(inlayHintLabelText).join(' '); + assert.match(labels, /\ba\b/, 'Inlay hints must include the first parameter name'); + assert.match(labels, /\bb\b/, 'Inlay hints must include the second parameter name'); + + // Interaction 2 — each hint is anchored INSIDE the document and reads as a + // parameter name. A hint placed past the end of a line renders on top of + // the code it is meant to annotate. + for (const hint of hints) { + assert.ok(hint.position.line < doc.lineCount, 'a hint must sit inside the document'); + assert.ok( + hint.position.character <= doc.lineAt(hint.position.line).text.length, + `hint on line ${hint.position.line} must sit inside that line`, + ); + assert.ok(inlayHintLabelText(hint).length > 0, 'a hint must carry visible text'); + } + + // Interaction 3 — the parameter hints for the call sit on the call's line, + // in argument order, and are tagged as Parameter hints so the editor can + // style and toggle them independently of type hints. + const onCall = hints.filter((hint) => hint.position.line === ADD_CALL.line); + const parameters = onCall.filter((hint) => hint.kind === vscode.InlayHintKind.Parameter); + assert.ok( + parameters.length >= 2, + `the two-argument call takes two parameter hints, got ${parameters.length}`, + ); + const columns = parameters.map((hint) => hint.position.character); + assert.deepStrictEqual( + [...columns].sort((l, r) => l - r), + columns, + 'hints arrive in argument order', + ); + assert.ok( + parameters.every((hint) => inlayHintLabelText(hint).trimEnd().endsWith(':')), + 'a parameter-name hint renders as `name:`', + ); + // `var total` earns an inferred-TYPE hint on the same line; that is the + // only other kind allowed there, and it must be tagged so it can be toggled + // independently of the parameter names. + assert.ok( + onCall.every( + (hint) => + hint.kind === vscode.InlayHintKind.Parameter || hint.kind === vscode.InlayHintKind.Type, + ), + 'every hint on the call line is a Parameter or the `var` Type hint, never untagged', + ); + }); +}); + +// ── Code Actions / Refactoring ──────────────────────────────────── + +suite('LSP Integration — Code Actions & Refactoring', () => { + let tmpDir: string; + + suiteSetup(async function () { + this.timeout(ACTIVATION_MS); + const result = await setupLspTestSuite('refactor-'); + tmpDir = result.tmpDir; + }); + + suiteTeardown(async () => { + await closeAllEditors(); + teardownLspTestSuite(tmpDir); + }); + + teardown(async () => { + await closeAllEditors(); + }); + + test('code actions returned for unused variable', async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + // Use a file inside the real workspace fixture project so Roslyn can analyze it. + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; + const content = `namespace RefactorDemo +{ + public class Refactor + { + public void Run() + { + string unused = "hello"; + } + } +}`; + // Interaction 1 — the file is part of TestFixtures.csproj, so Roslyn sees + // it and reports the unused local. + const refactorPath = path.join(workspaceRoot, 'Refactor.cs'); + fs.writeFileSync(refactorPath, content, 'utf8'); + const uri = vscode.Uri.file(refactorPath); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await waitForDocumentSymbols(uri); + assert.strictEqual(doc.languageId, 'csharp', 'the fixture opens as C#'); + assert.ok(doc.getText().includes('string unused'), 'and really declares the unused local'); + + // Interaction 2 — Ctrl-. over the identifier offers actions. An empty list + // is the "lightbulb never appears" defect [SHARPLSP-FEATURES-REFACTORING] + // makes a P0. + const range = new vscode.Range(new vscode.Position(6, 19), new vscode.Position(6, 25)); + const actions = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.CodeAction[]>( + 'vscode.executeCodeActionProvider', + uri, + range, + )) ?? [], + (offered) => offered.length > 0, + LSP_RESPONSE_MS, + 2_000, + ); + assert.ok(actions.length > 0, 'Must have at least one code action for unused variable'); + assert.strictEqual(doc.getText(range), 'unused', 'the range really covers the identifier'); + + // Interaction 3 — every offered action is USABLE: titled, kinded, and + // resolving to an edit or a command. An action that resolves to neither is + // a lightbulb entry that does nothing when clicked. + // + // The edit is asked for SEPARATELY because the server advertises + // `codeAction/resolve` ([SHARPLSP-FEATURES-REFACTORING]): LSP 3.17 lets it + // list actions without edits and fill them in when the user picks one, so + // an unresolved action having no `edit` is the contract working. VS Code + // resolves only as many as `itemResolveCount` asks it to. + const resolved = + (await vscode.commands.executeCommand<vscode.CodeAction[]>( + 'vscode.executeCodeActionProvider', + uri, + range, + undefined, + actions.length, + )) ?? []; + assert.strictEqual( + resolved.length, + actions.length, + 'resolving must not change WHICH actions are offered', + ); + for (const action of resolved) { + assert.ok( + action.edit !== undefined || action.command !== undefined, + `'${action.title}' must RESOLVE to an edit or a command, or clicking it does nothing`, + ); + } + const titles = actions.map((action) => action.title); + assert.ok( + titles.every((title) => title.trim().length > 0), + 'every offered action must be titled', + ); + assert.deepStrictEqual([...new Set(titles)], titles, 'and no title may be offered twice'); + for (const action of actions) { + assert.ok(action.kind, `'${action.title}' must declare a CodeActionKind`); + } + + // Interaction 4 — one of them removes the unused local. That is the fix + // the diagnostic asks for, and the reason the lightbulb appeared at all. + assert.ok( + actions.some((action) => /unused|remove/i.test(action.title)), + `an unused local must offer its removal; offered: ${titles.join(' | ')}`, + ); + + // Load fixture solution so SharpLsp panel shows Solution Explorer. + if (process.env['SHARPLSP_SCREENSHOTS']) { + await loadFixtureSolution(workspaceRoot); + } + await openSharpLspPanel(); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri), { + preview: false, + }); + + // Trigger the lightbulb in the editor so it's visible in the screenshot. + const editor = vscode.window.activeTextEditor; + assert.ok(editor, 'Must have active editor'); + editor.selection = new vscode.Selection(new vscode.Position(6, 18), new vscode.Position(6, 18)); + editor.revealRange(new vscode.Range(new vscode.Position(6, 18), new vscode.Position(6, 18))); + await vscode.commands.executeCommand('editor.action.quickFix'); + await settleForScreenshot(2000); + await takeScreenshot('vscode-refactoring.png'); + }); +}); + +// ── Helpers ────────────────────────────────────────────────────── + +/** Completions at a caret, polled until the sidecar has really answered. */ +async function completionsAt( + uri: vscode.Uri, + caret: vscode.Position, + trigger?: string, +): Promise<vscode.CompletionList> { + return pollUntilResult( + async () => { + const result = await vscode.commands.executeCommand<vscode.CompletionList>( + 'vscode.executeCompletionItemProvider', + uri, + caret, + trigger, + ); + return result ?? new vscode.CompletionList(); + }, + (list) => list.items.some((item) => item.label.toString() === 'Add'), + LSP_RESPONSE_MS, + 2_000, + ); +} + +function inlayHintLabelText(hint: vscode.InlayHint): string { + if (typeof hint.label === 'string') { + return hint.label; + } + return hint.label.map((part) => part.value).join(''); +} diff --git a/src/editors/vscode/src/test/suite/lsp-integration.test.ts b/src/editors/vscode/src/test/suite/lsp-integration.test.ts index 36acce95..333218f6 100644 --- a/src/editors/vscode/src/test/suite/lsp-integration.test.ts +++ b/src/editors/vscode/src/test/suite/lsp-integration.test.ts @@ -1,18 +1,45 @@ +// The SYNTAX-ONLY tier: documentSymbol, foldingRange, selectionRange. +// +// Spec: [SHARPLSP-ARCHITECTURE-ROUTING] (Rust/tree-sitter, <5ms), +// [SHARPLSP-FEATURES-NAVIGATION] (document symbols, breadcrumbs), +// [SHARPLSP-PERFORMANCE] (outline <10ms, folding <5ms), LSP 3.17. +// +// "The name I expected is in the list" is the weakest question that can be +// asked of any of these replies. A tree that names every symbol and hands back +// a range past the end of the buffer breaks breadcrumbs; a folding range that +// ends before it starts gives the gutter a chevron that folds nothing; a +// selection chain that does not contain the caret makes expand-selection jump +// somewhere else. Every test here asserts the KIND, the RANGE and the SHAPE, +// then drives the editor with the reply to prove the editor can use it. +// +// The semantic tier — completion, definition, references, highlights, inlay +// hints, code actions — lives in lsp-integration-semantic.test.ts. import * as assert from 'node:assert/strict'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { closeAllEditors, + flattenSymbolNames, + loadFixtureSolution, openCSharpFile, + openExistingFile, openSharpLspPanel, - pollUntilResult, + replaceDocumentContent, setupLspTestSuite, + settleForScreenshot, takeScreenshot, teardownLspTestSuite, waitForDocumentSymbols, waitForFoldingRanges, waitForSelectionRanges, } from './test-helpers'; +import { + assertFoldingRanges, + assertSelectionChain, + assertSymbolShape, + assertSymbolTree, + symbolNamed, +} from './lsp-invariants-kit'; import { ACTIVATION_MS, LSP_RESPONSE_MS } from './test-timeouts'; suite('LSP Integration — Document Symbols', () => { @@ -41,29 +68,62 @@ suite('LSP Integration — Document Symbols', () => { public int Baz { get; set; } } }`; - const { uri } = await openCSharpFile(tmpDir, 'symbols.cs', content); + // Interaction 1 — the outline names every declaration in the file. + const { uri, doc } = await openCSharpFile(tmpDir, 'symbols.cs', content); const symbols = await waitForDocumentSymbols(uri); - assert.ok(symbols.length > 0, 'Should return at least one symbol'); - - // Flatten to find all symbol names const names = flattenSymbolNames(symbols); assert.ok(names.includes('Foo'), 'Should contain class Foo'); assert.ok(names.includes('Bar'), 'Should contain method Bar'); assert.ok(names.includes('Baz'), 'Should contain property Baz'); + + // Interaction 2 — every reply obeys the protocol: selectionRange inside + // range, range inside the document, child inside parent. + const counted = assertSymbolTree(symbols, doc); + assert.ok(counted >= 4, `namespace, class, method and property at least, got ${counted}`); + + // Interaction 3 — each symbol carries the KIND its outline icon is drawn + // from, and a selectionRange that really covers its own identifier. + assertSymbolShape(symbolNamed(symbols, 'Foo'), vscode.SymbolKind.Class, doc); + assertSymbolShape(symbolNamed(symbols, 'Bar'), vscode.SymbolKind.Method, doc); + assertSymbolShape(symbolNamed(symbols, 'Baz'), vscode.SymbolKind.Property, doc); + + // Interaction 4 — the tree is a HIERARCHY, not a flat list: breadcrumbs + // read Test > Foo > Bar, so Bar must be a child of Foo, not a sibling. + const foo = symbolNamed(symbols, 'Foo'); + const children = foo.children.map((child) => child.name); + assert.deepStrictEqual(children, ['Bar', 'Baz'], 'Foo owns Bar and Baz, in source order'); + assert.ok(foo.range.contains(symbolNamed(symbols, 'Bar').range), 'Bar sits inside Foo'); + assert.strictEqual(symbols.length, 1, 'one top-level symbol: the namespace'); }); test('returns namespace symbol', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); const content = 'namespace MyApp.Models { public class Item { } }'; - const { uri } = await openCSharpFile(tmpDir, 'ns.cs', content); + // Interaction 1 — the dotted namespace reaches the outline. + const { uri, doc } = await openCSharpFile(tmpDir, 'ns.cs', content); const symbols = await waitForDocumentSymbols(uri); const names = flattenSymbolNames(symbols); - assert.ok( - names.some((n) => n.includes('MyApp')), + names.some((name) => name.includes('MyApp')), 'Should contain the namespace symbol', ); + assertSymbolTree(symbols, doc); + + // Interaction 2 — it is a NAMESPACE, and it is the root of the tree. A + // namespace reported as a class puts the wrong icon on every breadcrumb. + const root = symbols[0]; + assert.ok(root, 'the outline must have a root symbol'); + assert.strictEqual(root.kind, vscode.SymbolKind.Namespace, 'the root is a namespace'); + assert.ok(root.name.includes('MyApp'), `the root names the namespace, got '${root.name}'`); + assert.strictEqual(symbols.length, 1, 'a single-namespace file has one root'); + + // Interaction 3 — the namespace CONTAINS the type declared inside it, and + // its range spans the whole declaration rather than just the keyword. + const item = symbolNamed(symbols, 'Item'); + assert.ok(root.range.contains(item.range), 'the namespace must contain Item'); + assertSymbolShape(item, vscode.SymbolKind.Class, doc); + assert.strictEqual(root.range.start.line, 0, 'the namespace starts on the first line'); }); test('returns nested class symbols with hierarchy', async function () { @@ -76,17 +136,32 @@ suite('LSP Integration — Document Symbols', () => { public void OuterMethod() { } } }`; - const { uri } = await openCSharpFile(tmpDir, 'nested.cs', content); + // Interaction 1 — Outer owns its members. + const { uri, doc } = await openCSharpFile(tmpDir, 'nested.cs', content); const symbols = await waitForDocumentSymbols(uri); - - // Find the Outer class and verify it has children - const outer = findSymbol(symbols, 'Outer'); - assert.ok(outer, 'Should find Outer class'); + const outer = symbolNamed(symbols, 'Outer'); assert.ok(outer.children.length > 0, 'Outer should have child symbols'); - - const innerNames = outer.children.map((c) => c.name); + const innerNames = outer.children.map((child) => child.name); assert.ok(innerNames.includes('Inner'), 'Outer should contain Inner'); assert.ok(innerNames.includes('OuterMethod'), 'Outer should contain OuterMethod'); + + // Interaction 2 — the nesting goes all the way down. A tree that flattens + // Inner's method to Outer gives the wrong breadcrumb trail. + const inner = symbolNamed(symbols, 'Inner'); + assert.deepStrictEqual( + inner.children.map((child) => child.name), + ['InnerMethod'], + 'Inner owns InnerMethod alone', + ); + assert.ok(inner.range.contains(symbolNamed(symbols, 'InnerMethod').range), 'and contains it'); + assert.ok(outer.range.contains(inner.range), 'and Outer contains Inner'); + + // Interaction 3 — kinds and protocol invariants across the whole tree. + assertSymbolTree(symbols, doc); + assertSymbolShape(outer, vscode.SymbolKind.Class, doc); + assertSymbolShape(inner, vscode.SymbolKind.Class, doc); + assertSymbolShape(symbolNamed(symbols, 'InnerMethod'), vscode.SymbolKind.Method, doc); + assertSymbolShape(symbolNamed(symbols, 'OuterMethod'), vscode.SymbolKind.Method, doc); }); test('returns interface and enum symbols', async function () { @@ -95,26 +170,69 @@ suite('LSP Integration — Document Symbols', () => { public interface IService { void Execute(); } public enum Color { Red, Green, Blue } }`; - const { uri } = await openCSharpFile(tmpDir, 'iface-enum.cs', content); + // Interaction 1 — both type shapes reach the outline. + const { uri, doc } = await openCSharpFile(tmpDir, 'iface-enum.cs', content); const symbols = await waitForDocumentSymbols(uri); const names = flattenSymbolNames(symbols); - assert.ok(names.includes('IService'), 'Should contain interface'); assert.ok(names.includes('Color'), 'Should contain enum'); + assertSymbolTree(symbols, doc); + + // Interaction 2 — an interface is not a class and an enum is not either. + // The outline icon is drawn from the kind, and so is workspace-symbol + // filtering, so a wrong kind is a wrong search result. + assertSymbolShape(symbolNamed(symbols, 'IService'), vscode.SymbolKind.Interface, doc); + assertSymbolShape(symbolNamed(symbols, 'Color'), vscode.SymbolKind.Enum, doc); + assertSymbolShape(symbolNamed(symbols, 'Execute'), vscode.SymbolKind.Method, doc); + + // Interaction 3 — the enum's members are its children, all three of them. + const color = symbolNamed(symbols, 'Color'); + assert.deepStrictEqual( + color.children.map((child) => child.name), + ['Red', 'Green', 'Blue'], + 'the enum owns its members in declaration order', + ); + assert.ok(color.range.contains(color.children[0]?.range ?? color.range), 'and contains them'); + assert.deepStrictEqual( + symbolNamed(symbols, 'IService').children.map((child) => child.name), + ['Execute'], + 'and the interface owns its one method', + ); }); test('returns empty array for file with no declarations', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); - const { uri } = await openCSharpFile(tmpDir, 'empty-decl.cs', '// Just a comment\n'); - - // Give the server time to respond, then check - const result = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( + // Interaction 1 — a comment-only file declares nothing, so the outline is + // empty rather than carrying a phantom root. + const { uri, doc } = await openCSharpFile(tmpDir, 'empty-decl.cs', '// Just a comment\n'); + const empty = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( 'vscode.executeDocumentSymbolProvider', uri, ); - // Empty file may return null or empty array - const count = result?.length ?? 0; - assert.strictEqual(count, 0, 'Empty file should have zero symbols'); + assert.strictEqual(empty?.length ?? 0, 0, 'Empty file should have zero symbols'); + assert.strictEqual(doc.getText().trim(), '// Just a comment', 'the buffer really is bare'); + + // Interaction 2 — the user types a class in, and the outline appears + // WITHOUT a reload. [VSCODE-REACTIVITY-SPEC]: the tree follows the buffer. + await replaceDocumentContent(doc, 'class Appears { void M() { } }\n'); + const filled = await waitForDocumentSymbols(uri); + assert.ok(filled.length > 0, 'typing a declaration must populate the outline'); + assert.ok(flattenSymbolNames(filled).includes('Appears'), 'and name what was typed'); + assertSymbolTree(filled, doc); + + // Interaction 3 — deleting it again empties the outline. A tree that keeps + // a stale symbol navigates the user to a declaration that no longer exists. + await replaceDocumentContent(doc, '// Just a comment\n'); + const cleared = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( + 'vscode.executeDocumentSymbolProvider', + uri, + ); + assert.strictEqual(cleared?.length ?? 0, 0, 'removing the declaration must empty the outline'); + assert.strictEqual( + flattenSymbolNames(cleared ?? []).includes('Appears'), + false, + 'and leave no stale symbol behind', + ); }); test('returns struct symbol', async function () { @@ -125,21 +243,57 @@ suite('LSP Integration — Document Symbols', () => { public int Y; } }`; - const { uri } = await openCSharpFile(tmpDir, 'struct.cs', content); + // Interaction 1 — the struct reaches the outline. + const { uri, doc } = await openCSharpFile(tmpDir, 'struct.cs', content); const symbols = await waitForDocumentSymbols(uri); - const names = flattenSymbolNames(symbols); - - assert.ok(names.includes('Point'), 'Should contain struct Point'); + assert.ok(flattenSymbolNames(symbols).includes('Point'), 'Should contain struct Point'); + assertSymbolTree(symbols, doc); + + // Interaction 2 — a struct is a Struct, not a Class. The distinction is + // the whole point of the declaration and drives the outline icon. + const point = symbolNamed(symbols, 'Point'); + assertSymbolShape(point, vscode.SymbolKind.Struct, doc); + assert.notStrictEqual(point.kind, vscode.SymbolKind.Class, 'a struct is not a class'); + + // Interaction 3 — its fields are its children, and they are Fields. + assert.deepStrictEqual( + point.children.map((child) => child.name), + ['X', 'Y'], + 'the struct owns both fields in declaration order', + ); + assertSymbolShape(symbolNamed(symbols, 'X'), vscode.SymbolKind.Field, doc); + assertSymbolShape(symbolNamed(symbols, 'Y'), vscode.SymbolKind.Field, doc); }); test('returns record symbol', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); const content = 'namespace T { public record Person(string Name, int Age); }'; - const { uri } = await openCSharpFile(tmpDir, 'record.cs', content); + // Interaction 1 — the record reaches the outline. + const { uri, doc } = await openCSharpFile(tmpDir, 'record.cs', content); const symbols = await waitForDocumentSymbols(uri); - const names = flattenSymbolNames(symbols); + assert.ok(flattenSymbolNames(symbols).includes('Person'), 'Should contain record Person'); + assertSymbolTree(symbols, doc); - assert.ok(names.includes('Person'), 'Should contain record Person'); + // Interaction 2 — a record is a TYPE. Whether the outline draws it as a + // class or a struct, it must never land on a member kind: a record + // reported as a method or a variable is unusable from Go to Symbol. + const person = symbolNamed(symbols, 'Person'); + assert.ok( + [vscode.SymbolKind.Class, vscode.SymbolKind.Struct].includes(person.kind), + `a record must be a type kind, got ${vscode.SymbolKind[person.kind]}`, + ); + assert.notStrictEqual(person.kind, vscode.SymbolKind.Method, 'a record is not a method'); + assert.notStrictEqual(person.kind, vscode.SymbolKind.Variable, 'nor a variable'); + + // Interaction 3 — its selectionRange covers `Person` and not the whole + // positional parameter list, so Go to Symbol lands on the name. + assertSymbolShape(person, person.kind, doc); + assert.strictEqual( + doc.getText(person.selectionRange).includes('('), + false, + 'the identifier span must stop before the positional parameter list', + ); + assert.ok(person.range.contains(person.selectionRange), 'and sit inside the declaration'); }); }); @@ -175,10 +329,38 @@ suite('LSP Integration — Folding Ranges', () => { } } }`; - const { uri } = await openCSharpFile(tmpDir, 'fold.cs', content); + // Interaction 1 — one chevron per block: namespace, class, two methods. + const { uri, doc } = await openCSharpFile(tmpDir, 'fold.cs', content); const ranges = await waitForFoldingRanges(uri); - assert.ok(ranges.length >= 3, `Expected ≥3 folding ranges, got ${ranges.length}`); + assertFoldingRanges(ranges, doc); + + // Interaction 2 — the outermost range is the namespace and it CONTAINS the + // rest. A flat set of ranges cannot render nested chevrons. + const outermost = ranges.reduce((widest, range) => + range.end - range.start > widest.end - widest.start ? range : widest, + ); + assert.strictEqual(outermost.start, 0, 'the widest range starts at the namespace'); + assert.ok(outermost.end >= doc.lineCount - 2, 'and reaches the end of the file'); + const nested = ranges.filter( + (range) => + range !== outermost && range.start >= outermost.start && range.end <= outermost.end, + ); + assert.ok( + nested.length >= 2, + `the namespace must nest the class and its methods, got ${nested.length}`, + ); + + // Interaction 3 — the editor can USE them: folding everything collapses the + // buffer, and unfolding restores exactly what was visible before. + const editor = await vscode.window.showTextDocument(doc); + const visible = () => + editor.visibleRanges.reduce((sum, range) => sum + range.end.line - range.start.line + 1, 0); + const before = visible(); + await vscode.commands.executeCommand('editor.foldAll'); + assert.ok(visible() < before, `folding must hide lines: ${before} -> ${visible()}`); + await vscode.commands.executeCommand('editor.unfoldAll'); + assert.strictEqual(visible(), before, 'and unfolding must restore every line'); }); test('returns folding ranges for region directives', async function () { @@ -189,13 +371,38 @@ suite('LSP Integration — Folding Ranges', () => { public void B() { } #endregion }`; - const { uri } = await openCSharpFile(tmpDir, 'region.cs', content); + // Interaction 1 — a #region is foldable, and it is tagged as a Region. + const { uri, doc } = await openCSharpFile(tmpDir, 'region.cs', content); const ranges = await waitForFoldingRanges(uri); - assert.ok(ranges.length >= 1, 'Should have at least one folding range'); - // Region folding should be present - const regionRange = ranges.find((r) => r.kind === vscode.FoldingRangeKind.Region); + assertFoldingRanges(ranges, doc); + const regionRange = ranges.find((range) => range.kind === vscode.FoldingRangeKind.Region); assert.ok(regionRange, 'Should have a region folding range'); + + // Interaction 2 — it covers the directive pair EXACTLY. A region range + // that starts on the class swallows members the user meant to keep open. + assert.strictEqual(regionRange.start, 1, 'the region starts on the #region line'); + assert.strictEqual(regionRange.end, 4, 'and ends on the #endregion line'); + assert.strictEqual( + doc.lineAt(regionRange.start).text.trim().startsWith('#region'), + true, + 'the start line really is the directive', + ); + assert.strictEqual( + doc.lineAt(regionRange.end).text.trim().startsWith('#endregion'), + true, + 'and the end line closes it', + ); + + // Interaction 3 — the class body folds too, and it is NOT tagged Region: + // only the directive pair is a region. + const plain = ranges.filter((range) => range.kind !== vscode.FoldingRangeKind.Region); + assert.ok(plain.length >= 1, 'the class body must fold as well'); + assert.strictEqual( + ranges.filter((range) => range.kind === vscode.FoldingRangeKind.Region).length, + 1, + 'exactly one region, matching the one directive pair', + ); }); test('returns folding ranges for using directives', async function () { @@ -207,11 +414,31 @@ using System.Linq; namespace Test { public class C { } }`; - const { uri } = await openCSharpFile(tmpDir, 'usings.cs', content); + // Interaction 1 — the file folds at all, and every range is well formed. + const { uri, doc } = await openCSharpFile(tmpDir, 'usings.cs', content); const ranges = await waitForFoldingRanges(uri); - - // Should fold at least the namespace block. assert.ok(ranges.length >= 1, `Expected ≥1 folding ranges, got ${String(ranges.length)}`); + assertFoldingRanges(ranges, doc); + + // Interaction 2 — the using block is folded as IMPORTS. LSP 3.17 defines + // the `imports` kind precisely so an editor can collapse the header of + // every file at once; a plain range there is a header that never collapses + // with "Fold Imports", which is what this test's name promises. + const imports = ranges.filter((range) => range.kind === vscode.FoldingRangeKind.Imports); + assert.ok(imports.length >= 1, 'the using block must fold as FoldingRangeKind.Imports'); + const header = imports[0]; + assert.ok(header, 'the imports range must be readable'); + assert.strictEqual(header.start, 0, 'the imports range starts on the first using'); + assert.strictEqual(header.end, 2, 'and ends on the last one, not on the namespace'); + + // Interaction 3 — the namespace block folds separately, so collapsing the + // header leaves the code below it visible. + const body = ranges.filter((range) => range.kind !== vscode.FoldingRangeKind.Imports); + assert.ok(body.length >= 1, 'the namespace must fold independently of the header'); + assert.ok( + body.every((range) => range.start >= 4), + 'and every non-import range starts at or after the namespace', + ); }); test('nested classes produce nested folding ranges', async function () { @@ -225,13 +452,37 @@ namespace Test { } } }`; - const { uri } = await openCSharpFile(tmpDir, 'nested-fold.cs', content); + // Interaction 1 — one chevron per level of nesting. + const { uri, doc } = await openCSharpFile(tmpDir, 'nested-fold.cs', content); const ranges = await waitForFoldingRanges(uri); - assert.ok( ranges.length >= 4, `Expected ≥4 folding ranges for nested classes, got ${ranges.length}`, ); + assertFoldingRanges(ranges, doc); + + // Interaction 2 — the ranges really NEST. Four ranges that all start on + // line 0 are four chevrons that fold the same thing. + const sorted = [...ranges].sort((left, right) => left.start - right.start); + const starts = sorted.map((range) => range.start); + assert.strictEqual(new Set(starts).size, starts.length, 'each level folds at its own line'); + for (let index = 1; index < sorted.length; index += 1) { + const inner = sorted[index]; + const outer = sorted[index - 1]; + assert.ok(inner && outer, 'the sorted ranges must be readable'); + assert.ok(inner.start > outer.start, 'each level starts strictly inside the previous one'); + assert.ok(inner.end <= outer.end, 'and ends at or before it'); + } + + // Interaction 3 — folding the OUTERMOST level hides every inner one, which + // is what nesting buys the user. + const editor = await vscode.window.showTextDocument(doc); + const visible = () => + editor.visibleRanges.reduce((sum, range) => sum + range.end.line - range.start.line + 1, 0); + const before = visible(); + await vscode.commands.executeCommand('editor.foldAll'); + assert.ok(visible() < before, `folding must collapse the nest: ${before} -> ${visible()}`); + assert.ok(visible() <= 3, `a fully folded nest shows the namespace line, got ${visible()}`); }); }); @@ -262,22 +513,42 @@ suite('LSP Integration — Selection Ranges', () => { } } }`; - const { uri } = await openCSharpFile(tmpDir, 'sel.cs', content); - - // Position on "x" in "var x = 42;" + // Interaction 1 — the caret on `x` yields a chain that expands outward. + const { uri, doc } = await openCSharpFile(tmpDir, 'sel.cs', content); const position = new vscode.Position(3, 10); const ranges = await waitForSelectionRanges(uri, [position]); - assert.ok(ranges.length > 0, 'Should return at least one selection range'); + const chain = ranges[0]; + assert.ok(chain, 'the chain for the one requested position must be readable'); + const depth = assertSelectionChain(chain, position, doc); + assert.ok(depth >= 3, `Selection range chain should have ≥3 levels, got ${depth}`); - // Walk the parent chain — it should expand outward - let current: vscode.SelectionRange | undefined = ranges[0]; - let depth = 0; - while (current) { - depth++; - current = current.parent; + // Interaction 2 — the innermost level is the IDENTIFIER under the caret, + // not the whole statement. Expand-selection starting at the statement + // skips the level the user pressed the key for. + assert.strictEqual(doc.getText(chain.range), 'x', 'the first level selects the identifier'); + assert.strictEqual(chain.range.start.line, position.line, 'and stays on the caret line'); + assert.ok(chain.parent, 'and it has somewhere to expand to'); + + // Interaction 3 — a level along the chain covers the whole declaration + // statement, and the outermost covers the file. + const levels: string[] = []; + for ( + let current: vscode.SelectionRange | undefined = chain; + current; + current = current.parent + ) { + levels.push(doc.getText(current.range)); } - assert.ok(depth >= 3, `Selection range chain should have ≥3 levels, got ${depth}`); + assert.ok( + levels.some((text) => text.trim() === 'var x = 42;'), + `expanding must pass through the statement; saw ${levels.length} levels`, + ); + assert.ok( + levels.some((text) => text.includes('class Foo')), + 'and through the class declaration', + ); + assert.strictEqual(levels.length, depth, 'every level was walked exactly once'); }); test('returns selection ranges for multiple positions', async function () { @@ -286,15 +557,35 @@ suite('LSP Integration — Selection Ranges', () => { int a = 1; int b = 2; }`; - const { uri } = await openCSharpFile(tmpDir, 'sel-multi.cs', content); - - const positions = [ - new vscode.Position(1, 6), // on 'a' - new vscode.Position(2, 6), // on 'b' - ]; + // Interaction 1 — one chain per requested position, in request order. + const { uri, doc } = await openCSharpFile(tmpDir, 'sel-multi.cs', content); + const positions = [new vscode.Position(1, 6), new vscode.Position(2, 6)]; const ranges = await waitForSelectionRanges(uri, positions); - assert.strictEqual(ranges.length, 2, 'Should return one selection range per position'); + const [first, second] = ranges; + assert.ok(first && second, 'both chains must be readable'); + + // Interaction 2 — each chain belongs to ITS OWN caret. A provider that + // answers both positions with one chain silently moves the second cursor. + const firstDepth = assertSelectionChain(first, positions[0]!, doc); + const secondDepth = assertSelectionChain(second, positions[1]!, doc); + assert.strictEqual(doc.getText(first.range), 'a', 'the first chain selects a'); + assert.strictEqual(doc.getText(second.range), 'b', 'and the second selects b'); + assert.strictEqual(first.range.isEqual(second.range), false, 'the two chains are distinct'); + + // Interaction 3 — both chains reach the same enclosing class, so a + // multi-cursor expand ends with both selections on the same construct. + assert.ok(firstDepth >= 2 && secondDepth >= 2, 'both chains expand at least once'); + const outermostOf = (chain: vscode.SelectionRange): vscode.SelectionRange => { + let current = chain; + while (current.parent) current = current.parent; + return current; + }; + assert.strictEqual( + outermostOf(first).range.isEqual(outermostOf(second).range), + true, + 'both carets expand to the same outermost construct', + ); }); test('selection ranges at class level expand to file', async function () { @@ -304,22 +595,51 @@ suite('LSP Integration — Selection Ranges', () => { void M() { } } }`; - const { uri } = await openCSharpFile(tmpDir, 'sel-class.cs', content); - - // Position on "MyClass" + // Interaction 1 — a caret on the class name yields a chain. + const { uri, doc } = await openCSharpFile(tmpDir, 'sel-class.cs', content); const position = new vscode.Position(1, 8); const ranges = await waitForSelectionRanges(uri, [position]); assert.ok(ranges.length > 0, 'Should return selection ranges'); - - // The outermost parent should cover the entire file (or close to it) - let outermost: vscode.SelectionRange = ranges[0]!; - while (outermost.parent) { - outermost = outermost.parent; + const chain = ranges[0]; + assert.ok(chain, 'the chain must be readable'); + const depth = assertSelectionChain(chain, position, doc); + + // Interaction 2 — the innermost level is the class NAME, and expanding + // reaches the class declaration itself. + const texts: string[] = []; + for ( + let current: vscode.SelectionRange | undefined = chain; + current; + current = current.parent + ) { + texts.push(doc.getText(current.range)); } + // VS Code merges its own word and bracket providers into the chain, so the + // innermost level may be a word part; the identifier is a level of its own. + assert.ok( + texts.includes('MyClass'), + `one level selects the name alone; got ${JSON.stringify(texts)}`, + ); + assert.ok( + texts.some((text) => text.includes('void M()')), + 'expanding must reach the whole class body', + ); + assert.ok( + texts.indexOf('MyClass') < texts.findIndex((text) => text.includes('void M()')), + 'and the name comes before the declaration that contains it', + ); + assert.ok(depth >= 3, `name, class, namespace at least; got ${depth} levels`); + + // Interaction 3 — the outermost range covers the file, so one more press + // of expand-selection selects everything. + let outermost = chain; + while (outermost.parent) outermost = outermost.parent; assert.ok( outermost.range.start.line <= 1, 'Outermost range should start near beginning of file', ); + assert.ok(outermost.range.end.line >= doc.lineCount - 2, 'and reach the last line'); + assert.ok(outermost.range.contains(chain.range), 'and contain where the user started'); }); }); @@ -342,41 +662,44 @@ suite('LSP Integration — Fixture Files', () => { test('Calculator.cs returns symbols for class, methods, properties', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); - const uri = vscode.Uri.file(path.join(fixtureDir, 'Calculator.cs')); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); - + // Interaction 1 — every declaration in the committed fixture is named. + const { uri, doc } = await openExistingFile(fixtureDir, 'Calculator.cs'); const symbols = await waitForDocumentSymbols(uri); const names = flattenSymbolNames(symbols); + for (const required of [ + 'Calculator', + 'Add', + 'Subtract', + 'Divide', + 'ICalculator', + 'Operation', + ]) { + assert.ok(names.includes(required), `Should find ${required}`); + } - assert.ok(names.includes('Calculator'), 'Should find Calculator class'); - assert.ok(names.includes('Add'), 'Should find Add method'); - assert.ok(names.includes('Subtract'), 'Should find Subtract method'); - assert.ok(names.includes('Divide'), 'Should find Divide method'); - assert.ok(names.includes('ICalculator'), 'Should find ICalculator interface'); - assert.ok(names.includes('Operation'), 'Should find Operation enum'); + // Interaction 2 — every one of them obeys the protocol and carries the + // kind its outline icon and Go-to-Symbol filter are drawn from. + assertSymbolTree(symbols, doc); + assertSymbolShape(symbolNamed(symbols, 'Calculator'), vscode.SymbolKind.Class, doc); + assertSymbolShape(symbolNamed(symbols, 'ICalculator'), vscode.SymbolKind.Interface, doc); + assertSymbolShape(symbolNamed(symbols, 'Operation'), vscode.SymbolKind.Enum, doc); + assertSymbolShape(symbolNamed(symbols, 'Add'), vscode.SymbolKind.Method, doc); + + // Interaction 3 — the methods belong to the CLASS, not to the file. A flat + // outline over a real fixture is the defect a synthetic one never shows. + const calculator = symbolNamed(symbols, 'Calculator'); + const members = calculator.children.map((child) => child.name); + for (const method of ['Add', 'Subtract', 'Divide']) { + assert.ok(members.includes(method), `Calculator owns ${method}, got ${members.join(', ')}`); + assert.ok( + calculator.range.contains(symbolNamed(symbols, method).range), + `and ${method} sits inside it`, + ); + } // Load fixture solution so Solution Explorer is populated in the screenshot. if (process.env['SHARPLSP_SCREENSHOTS']) { - const ext2 = vscode.extensions.getExtension('nimblesite.sharplsp'); - const api2 = ext2?.exports as - | { - explorerProvider?: { - loadSolution(p: string): Promise<void>; - getChildren(e?: unknown): unknown[] | undefined; - }; - } - | undefined; - if (api2?.explorerProvider) { - const slnPath = path.join(fixtureDir, 'TestFixtures.sln'); - await api2.explorerProvider.loadSolution(slnPath); - // Wait for tree to populate before screenshot. - let waited = 0; - while ((api2.explorerProvider.getChildren() ?? []).length === 0 && waited < 8000) { - await new Promise((r) => setTimeout(r, 200)); - waited += 200; - } - } + await loadFixtureSolution(fixtureDir); } await openSharpLspPanel(); await takeScreenshot('vscode-getting-started-page.png'); @@ -384,414 +707,124 @@ suite('LSP Integration — Fixture Files', () => { test('Calculator.cs has folding ranges for regions', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); - const uri = vscode.Uri.file(path.join(fixtureDir, 'Calculator.cs')); - const doc = await vscode.workspace.openTextDocument(uri); + // Interaction 1 — the fixture folds, and every range is well formed. + const { uri, doc } = await openExistingFile(fixtureDir, 'Calculator.cs'); const editor = await vscode.window.showTextDocument(doc); - const ranges = await waitForFoldingRanges(uri); assert.ok(ranges.length >= 5, `Expected ≥5 folding ranges, got ${ranges.length}`); + assertFoldingRanges(ranges, doc); - // Must have region folding ranges for #region/#endregion - const regionRanges = ranges.filter((r) => r.kind === vscode.FoldingRangeKind.Region); + // Interaction 2 — the #region/#endregion pairs are tagged Region and each + // one really straddles its directives in the committed source. + const regionRanges = ranges.filter((range) => range.kind === vscode.FoldingRangeKind.Region); assert.ok(regionRanges.length >= 2, `Expected ≥2 #region ranges, got ${regionRanges.length}`); - - // Each region range must span at least 2 lines - for (const r of regionRanges) { - assert.ok(r.end > r.start, `Region range must span >1 line: ${r.start}–${r.end}`); + for (const range of regionRanges) { + assert.ok( + range.end > range.start, + `Region range must span >1 line: ${range.start}–${range.end}`, + ); + assert.ok( + doc.lineAt(range.start).text.includes('#region'), + `region range ${range.start}–${range.end} must start on a #region directive`, + ); + assert.ok( + doc.lineAt(range.end).text.includes('#endregion'), + `region range ${range.start}–${range.end} must end on an #endregion directive`, + ); } - // The #region Arithmetic range must exist (start line varies by LSP implementation) - // Log all region ranges to aid debugging - console.log('Region ranges:', regionRanges.map((r) => `${r.start}–${r.end}`).join(', ')); - assert.ok( - regionRanges.length >= 2, - 'Must have at least 2 region folding ranges (Arithmetic + State)', - ); - - // Fold everything and assert visible lines dropped drastically - const linesBefore = editor.visibleRanges.reduce( - (sum, r) => sum + r.end.line - r.start.line + 1, - 0, - ); + // Interaction 3 — the editor collapses on them and comes back. + const visible = () => + editor.visibleRanges.reduce((sum, range) => sum + range.end.line - range.start.line + 1, 0); + const linesBefore = visible(); assert.ok( linesBefore > 10, `File must have >10 visible lines before folding, got ${linesBefore}`, ); await vscode.commands.executeCommand('editor.foldAll'); - await new Promise((r) => setTimeout(r, 800)); - const linesAfter = editor.visibleRanges.reduce( - (sum, r) => sum + r.end.line - r.start.line + 1, - 0, - ); + await new Promise((resolve) => setTimeout(resolve, 800)); + const linesAfter = visible(); assert.ok( linesAfter < linesBefore, `Folding must reduce visible lines: before=${linesBefore} after=${linesAfter}`, ); assert.ok(linesAfter <= 5, `After foldAll, should have ≤5 visible lines, got ${linesAfter}`); + await vscode.commands.executeCommand('editor.unfoldAll'); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.strictEqual(visible(), linesBefore, 'and unfolding restores every line'); // Keep the editor focused so the folded regions are clearly visible. - // Close the bottom panel to maximise the editor view in the screenshot. + await vscode.commands.executeCommand('editor.foldAll'); await vscode.commands.executeCommand('workbench.action.closePanel'); - await new Promise((r) => setTimeout(r, 500)); + await settleForScreenshot(500); await takeScreenshot('code-folding.png'); }); test('Nested.cs returns nested class hierarchy', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); - const uri = vscode.Uri.file(path.join(fixtureDir, 'Nested.cs')); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); - + // Interaction 1 — every nested declaration is named. + const { uri, doc } = await openExistingFile(fixtureDir, 'Nested.cs'); const symbols = await waitForDocumentSymbols(uri); const names = flattenSymbolNames(symbols); + for (const required of ['Outer', 'Inner', 'AnotherInner', 'InnerMethod', 'OuterMethod']) { + assert.ok(names.includes(required), `Should find ${required}`); + } + + // Interaction 2 — the hierarchy is real: both inner classes are children + // of Outer, and InnerMethod is a child of Inner rather than of Outer. + assertSymbolTree(symbols, doc); + const outer = symbolNamed(symbols, 'Outer'); + const outerChildren = outer.children.map((child) => child.name); + assert.ok(outerChildren.includes('Inner'), `Outer owns Inner, got ${outerChildren.join(', ')}`); + assert.ok(outerChildren.includes('AnotherInner'), 'and AnotherInner'); + assert.strictEqual( + outerChildren.includes('InnerMethod'), + false, + 'InnerMethod belongs to Inner, not to Outer', + ); + + // Interaction 3 — kinds, so the outline draws a class icon at every level. + assertSymbolShape(outer, vscode.SymbolKind.Class, doc); + assertSymbolShape(symbolNamed(symbols, 'Inner'), vscode.SymbolKind.Class, doc); + assertSymbolShape(symbolNamed(symbols, 'AnotherInner'), vscode.SymbolKind.Class, doc); + assertSymbolShape(symbolNamed(symbols, 'InnerMethod'), vscode.SymbolKind.Method, doc); - assert.ok(names.includes('Outer'), 'Should find Outer'); - assert.ok(names.includes('Inner'), 'Should find Inner'); - assert.ok(names.includes('AnotherInner'), 'Should find AnotherInner'); - assert.ok(names.includes('InnerMethod'), 'Should find InnerMethod'); - assert.ok(names.includes('OuterMethod'), 'Should find OuterMethod'); // Keep editor focused so nested class structure is visible. - // Close the bottom panel to maximise the editor view. await vscode.commands.executeCommand('workbench.action.closePanel'); - await new Promise((r) => setTimeout(r, 500)); + await settleForScreenshot(500); await takeScreenshot('nested-classes.png'); }); test('Empty.cs returns no symbols', async function () { this.timeout(LSP_RESPONSE_MS + 5_000); - const uri = vscode.Uri.file(path.join(fixtureDir, 'Empty.cs')); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); + // Interaction 1 — the committed fixture really is declaration-free, so the + // assertion below is about the provider and not about the fixture. + const { uri, doc } = await openExistingFile(fixtureDir, 'Empty.cs'); + assert.strictEqual(doc.languageId, 'csharp', 'Empty.cs still opens as C#'); + assert.strictEqual( + doc.getText().includes('class'), + false, + 'the fixture declares no type — otherwise this test proves nothing', + ); - // Give server a moment, then verify empty - await new Promise((r) => setTimeout(r, 2_000)); - const result = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( + // Interaction 2 — the outline is empty, not a phantom root. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const symbols = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( 'vscode.executeDocumentSymbolProvider', uri, ); - const count = result?.length ?? 0; - assert.strictEqual(count, 0, 'Empty.cs should have zero symbols'); - }); -}); - -suite('LSP Integration — Real Semantic LSP', () => { - let tmpDir: string; - let fixtureDir: string; - - suiteSetup(async function () { - this.timeout(ACTIVATION_MS); - const result = await setupLspTestSuite('semantic-'); - tmpDir = result.tmpDir; - fixtureDir = path.resolve(__dirname, '../../../test-fixtures/workspace'); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('returns Roslyn-backed completion items with concrete symbol kinds', async function () { - this.timeout(LSP_RESPONSE_MS + 5_000); - const { uri } = await openWorkspaceFixture(fixtureDir, 'CompletionShot.cs'); - await waitForDocumentSymbols(uri); - - const completions = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.CompletionList>( - 'vscode.executeCompletionItemProvider', - uri, - new vscode.Position(11, 24), - ); - return result ?? new vscode.CompletionList(); - }, - (list) => list.items.some((item) => item.label.toString() === 'Add'), - LSP_RESPONSE_MS, - 2_000, - ); - - const items = new Map(completions.items.map((item) => [item.label.toString(), item])); - assert.strictEqual(items.get('Name')?.kind, vscode.CompletionItemKind.Property); - assert.strictEqual(items.get('Add')?.kind, vscode.CompletionItemKind.Method); - assert.strictEqual(items.get('_count')?.kind, vscode.CompletionItemKind.Field); - }); - - test('auto-triggers member completion when `.` is typed', async function () { - this.timeout(LSP_RESPONSE_MS + 5_000); - const { uri } = await openWorkspaceFixture(fixtureDir, 'CompletionShot.cs'); - await waitForDocumentSymbols(uri); - - // Passing a trigger character makes VS Code route the request ONLY to - // providers registered for that character. This stays empty unless the - // server advertises `.` in completionProvider.triggerCharacters — i.e. it - // reproduces "press dot, get nothing" end-to-end through the language client. - const completions = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.CompletionList>( - 'vscode.executeCompletionItemProvider', - uri, - new vscode.Position(11, 24), - '.', - ); - return result ?? new vscode.CompletionList(); - }, - (list) => list.items.some((item) => item.label.toString() === 'Add'), - LSP_RESPONSE_MS, - 2_000, - ); - - const labels = new Set(completions.items.map((item) => item.label.toString())); - assert.ok(labels.has('Add'), 'Typing `.` must auto-trigger member completion including Add'); - assert.ok(labels.has('Name'), 'Typing `.` must auto-trigger member completion including Name'); - assert.ok( - labels.has('_count'), - 'Typing `.` must auto-trigger member completion including _count', - ); - }); - - test('resolves definition and references for a method call site', async function () { - this.timeout(LSP_RESPONSE_MS + 5_000); - const { uri } = await openWorkspaceFixture(fixtureDir, 'CompletionShot.cs'); - await waitForDocumentSymbols(uri); - const addCall = new vscode.Position(10, 26); - - const definitions = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.Location[]>( - 'vscode.executeDefinitionProvider', - uri, - addCall, - ); - return result ?? []; - }, - (locations) => locations.length > 0, - LSP_RESPONSE_MS, - 2_000, - ); - - assert.ok( - definitions.some( - (location) => location.uri.toString() === uri.toString() && location.range.start.line === 6, - ), - 'Add call must resolve to the Add method declaration', - ); - - const references = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.Location[]>( - 'vscode.executeReferenceProvider', - uri, - addCall, - ); - return result ?? []; - }, - (locations) => locations.length > 0, - LSP_RESPONSE_MS, - 2_000, - ); - - assert.ok( - references.some( - (location) => - location.uri.toString() === uri.toString() && location.range.start.line === 10, - ), - 'References must include the Add call site', - ); - }); - - test('returns document highlights for a semantic symbol occurrence', async function () { - this.timeout(LSP_RESPONSE_MS + 5_000); - const { uri } = await openWorkspaceFixture(fixtureDir, 'CompletionShot.cs'); - await waitForDocumentSymbols(uri); - - const highlights = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.DocumentHighlight[]>( - 'vscode.executeDocumentHighlights', - uri, - new vscode.Position(10, 26), - ); - return result ?? []; - }, - (items) => items.length > 0, - LSP_RESPONSE_MS, - 2_000, - ); - - assert.ok( - highlights.every((highlight) => highlight.range.start.line >= 0), - 'Every document highlight must have a valid range', - ); - }); - - test('returns parameter-name inlay hints for a real method call', async function () { - this.timeout(LSP_RESPONSE_MS + 5_000); - const { doc, uri } = await openWorkspaceFixture(fixtureDir, 'CompletionShot.cs'); - await waitForDocumentSymbols(uri); - - const hints = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.InlayHint[]>( - 'vscode.executeInlayHintProvider', - uri, - new vscode.Range(new vscode.Position(0, 0), new vscode.Position(doc.lineCount, 0)), - ); - return result ?? []; - }, - (items) => items.length >= 2, - LSP_RESPONSE_MS, - 2_000, - ); - - const labels = hints.map(inlayHintLabelText).join(' '); - assert.match(labels, /\ba\b/, 'Inlay hints must include the first parameter name'); - assert.match(labels, /\bb\b/, 'Inlay hints must include the second parameter name'); - }); -}); - -// ── Code Actions / Refactoring ──────────────────────────────────── - -suite('LSP Integration — Code Actions & Refactoring', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(ACTIVATION_MS); - const result = await setupLspTestSuite('refactor-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('code actions returned for unused variable', async function () { - this.timeout(LSP_RESPONSE_MS + 5_000); - // Use a file inside the real workspace fixture project so Roslyn can analyze it. - const fixtureDir2 = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; - const content = `namespace RefactorDemo -{ - public class Refactor - { - public void Run() - { - string unused = "hello"; - } - } -}`; - // Write the file into the fixture workspace so it's part of TestFixtures.csproj. - const fs2 = await import('node:fs'); - const path2 = await import('node:path'); - const refactorPath = path2.join(fixtureDir2, 'Refactor.cs'); - fs2.writeFileSync(refactorPath, content, 'utf8'); - const uri = vscode.Uri.file(refactorPath); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); - await waitForDocumentSymbols(uri); - - // Wait for Roslyn sidecar to load — poll code actions until non-empty. - const range = new vscode.Range(new vscode.Position(6, 12), new vscode.Position(6, 18)); - const actions = await pollUntilResult( - async () => { - const result = await vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', - uri, - range, - ); - return result ?? []; - }, - (acts) => acts.length > 0, - LSP_RESPONSE_MS, - 2_000, + assert.strictEqual(symbols?.length ?? 0, 0, 'Empty.cs should have zero symbols'); + assert.deepStrictEqual(flattenSymbolNames(symbols ?? []), [], 'and name nothing at all'); + + // Interaction 3 — the other syntax-tier providers agree: nothing to fold, + // and no chevron in the gutter. A provider that invents a range for an + // empty file puts a fold marker on a file with nothing to hide. + const folds = await vscode.commands.executeCommand<vscode.FoldingRange[]>( + 'vscode.executeFoldingRangeProvider', + uri, ); - - assert.ok(actions.length > 0, 'Must have at least one code action for unused variable'); - - // Load fixture solution so SharpLsp panel shows Solution Explorer. - if (process.env['SHARPLSP_SCREENSHOTS']) { - const ext2 = vscode.extensions.getExtension('nimblesite.sharplsp'); - const api2 = ext2?.exports as - | { - explorerProvider?: { - loadSolution(p: string): Promise<void>; - getChildren(e?: unknown): unknown[] | undefined; - }; - } - | undefined; - if (api2?.explorerProvider) { - const ws2 = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; - await api2.explorerProvider.loadSolution(`${ws2}/TestFixtures.sln`); - let w = 0; - while ((api2.explorerProvider.getChildren() ?? []).length === 0 && w < 5000) { - await new Promise((r) => setTimeout(r, 200)); - w += 200; - } - } - } - await openSharpLspPanel(); - await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri), { - preview: false, - }); - - // Trigger the lightbulb in the editor so it's visible in the screenshot. - const editor = vscode.window.activeTextEditor; - assert.ok(editor, 'Must have active editor'); - editor.selection = new vscode.Selection(new vscode.Position(6, 18), new vscode.Position(6, 18)); - editor.revealRange(new vscode.Range(new vscode.Position(6, 18), new vscode.Position(6, 18))); - await vscode.commands.executeCommand('editor.action.quickFix'); - await new Promise((r) => setTimeout(r, 2000)); - await takeScreenshot('vscode-refactoring.png'); + assertFoldingRanges(folds ?? [], doc); + assert.strictEqual(folds?.length ?? 0, 0, 'a declaration-free file folds nowhere'); + assert.strictEqual(doc.isDirty, false, 'and reading it left the buffer untouched'); }); }); - -// ── Helpers ────────────────────────────────────────────────────── - -function flattenSymbolNames(symbols: vscode.DocumentSymbol[]): string[] { - const names: string[] = []; - function walk(list: vscode.DocumentSymbol[]): void { - for (const sym of list) { - names.push(sym.name); - if (sym.children.length > 0) { - walk(sym.children); - } - } - } - walk(symbols); - return names; -} - -function findSymbol( - symbols: vscode.DocumentSymbol[], - name: string, -): vscode.DocumentSymbol | undefined { - for (const sym of symbols) { - if (sym.name === name) return sym; - const found = findSymbol(sym.children, name); - if (found) return found; - } - return undefined; -} - -async function openWorkspaceFixture( - fixtureDir: string, - name: string, -): Promise<{ doc: vscode.TextDocument; uri: vscode.Uri }> { - const uri = vscode.Uri.file(path.join(fixtureDir, name)); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); - return { doc, uri }; -} - -function inlayHintLabelText(hint: vscode.InlayHint): string { - if (typeof hint.label === 'string') { - return hint.label; - } - return hint.label.map((part) => part.value).join(''); -} diff --git a/src/editors/vscode/src/test/suite/lsp-invariants-kit.ts b/src/editors/vscode/src/test/suite/lsp-invariants-kit.ts new file mode 100644 index 00000000..034e8daa --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-invariants-kit.ts @@ -0,0 +1,222 @@ +// Protocol invariants every LSP reply must satisfy, whatever the fixture. +// +// Spec: LSP 3.17 `textDocument/documentSymbol`, `foldingRange`, +// `selectionRange`, `completion`; [SHARPLSP-ARCHITECTURE-ROUTING], +// [SHARPLSP-FEATURES-NAVIGATION], [SHARPLSP-FEATURES-INTELLIGENCE], +// [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. +// +// "The name I expected is in the list" is the weakest thing a reply can be +// asked. A documentSymbol reply that names `Foo` and hands back a range past +// the end of the buffer, a selectionRange chain that does not contain the +// caret, or a completion item with no `textEdit` all pass a name check and all +// break the editor. The invariants below are the ones the protocol guarantees, +// so every suite can assert them against every reply it already fetched — no +// extra round trip, no fixture-specific expectation. +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; + +/** The last position in a document — the end of its final line. */ +export function documentEnd(document: vscode.TextDocument): vscode.Position { + return document.lineAt(Math.max(document.lineCount - 1, 0)).range.end; +} + +/** + * Every invariant LSP 3.17 states about a `DocumentSymbol` tree, asserted + * recursively. + * + * `selectionRange` must be contained in `range`, `range` must lie inside the + * document, and a child must lie inside its parent. A tree that violates any of + * them breaks breadcrumbs and the outline view even though every name is right. + */ +export function assertSymbolTree( + symbols: readonly vscode.DocumentSymbol[], + document: vscode.TextDocument, +): number { + const end = documentEnd(document); + let counted = 0; + const walk = (nodes: readonly vscode.DocumentSymbol[], parent?: vscode.DocumentSymbol): void => { + for (const symbol of nodes) { + counted += 1; + const where = `${document.uri.fsPath}: symbol '${symbol.name}'`; + assert.ok(symbol.name.length > 0, `${where} must be named`); + assert.strictEqual(symbol.name, symbol.name.trim(), `${where} must not be padded`); + assert.ok( + symbol.range.contains(symbol.selectionRange), + `${where} selectionRange must sit inside range (LSP 3.17)`, + ); + assert.ok( + symbol.range.end.isBeforeOrEqual(end), + `${where} range must not run past the end of the document`, + ); + assert.ok( + symbol.range.start.isBeforeOrEqual(symbol.range.end), + `${where} range must not be inverted`, + ); + if (parent) { + assert.ok( + parent.range.contains(symbol.range), + `${where} must sit inside its parent '${parent.name}'`, + ); + } + walk(symbol.children, symbol); + } + }; + walk(symbols); + return counted; +} + +/** The symbol with this name, anywhere in the tree, asserted to exist. */ +export function symbolNamed( + symbols: readonly vscode.DocumentSymbol[], + name: string, +): vscode.DocumentSymbol { + const found = findSymbol(symbols, name); + assert.ok(found, `the outline must contain '${name}'`); + return found; +} + +function findSymbol( + symbols: readonly vscode.DocumentSymbol[], + name: string, +): vscode.DocumentSymbol | undefined { + for (const symbol of symbols) { + if (symbol.name === name) return symbol; + const nested = findSymbol(symbol.children, name); + if (nested) return nested; + } + return undefined; +} + +/** + * Assert a symbol's kind AND that its `selectionRange` really covers its own + * identifier in the buffer. + * + * The kind drives the outline icon and the breadcrumb; the selection range is + * what "Go to Symbol" jumps to. A symbol whose selectionRange lands on the + * wrong token navigates the user somewhere else entirely. + */ +export function assertSymbolShape( + symbol: vscode.DocumentSymbol, + kind: vscode.SymbolKind, + document: vscode.TextDocument, +): void { + assert.strictEqual( + symbol.kind, + kind, + `'${symbol.name}' must be ${vscode.SymbolKind[kind]}, got ${vscode.SymbolKind[symbol.kind]}`, + ); + const selected = document.getText(symbol.selectionRange); + assert.ok( + selected.includes(lastSegment(symbol.name)), + `'${symbol.name}' selectionRange must cover its own identifier, covers '${selected}'`, + ); + assert.ok( + symbol.selectionRange.start.line === symbol.selectionRange.end.line, + `'${symbol.name}' identifier must not straddle a line break`, + ); +} + +function lastSegment(name: string): string { + const bare = name.split('(')[0] ?? name; + return bare.split('.').pop() ?? bare; +} + +/** + * Every invariant a `foldingRange` reply must satisfy. + * + * A range that ends before it starts, runs past the last line, or repeats + * another range gives the gutter a chevron that folds nothing or folds twice. + */ +export function assertFoldingRanges( + ranges: readonly vscode.FoldingRange[], + document: vscode.TextDocument, +): void { + const seen = new Set<string>(); + for (const range of ranges) { + const where = `folding range ${range.start}-${range.end}`; + assert.ok(range.end > range.start, `${where} must span more than one line to be foldable`); + assert.ok(range.start >= 0, `${where} must start inside the document`); + assert.ok( + range.end < document.lineCount, + `${where} must end inside the document (${document.lineCount} lines)`, + ); + const key = `${range.start}:${range.end}:${String(range.kind)}`; + assert.strictEqual(seen.has(key), false, `${where} is reported twice`); + seen.add(key); + } +} + +/** + * A `selectionRange` chain, walked outward. + * + * The innermost range must contain the caret and every parent must STRICTLY + * contain its child — a chain with a repeated range makes the shrink/expand + * keybinding stall on a level that never changes the selection. + * + * Returns the depth so a caller can assert how far the chain reaches. + */ +export function assertSelectionChain( + chain: vscode.SelectionRange, + caret: vscode.Position, + document: vscode.TextDocument, +): number { + assert.ok(chain.range.contains(caret), 'the innermost selection range must contain the caret'); + let depth = 1; + let current = chain; + while (current.parent) { + const parent = current.parent; + assert.ok( + parent.range.contains(current.range), + `selection level ${depth} must sit inside level ${depth + 1}`, + ); + assert.strictEqual( + parent.range.isEqual(current.range), + false, + `selection level ${depth + 1} must be STRICTLY larger, or expand-selection stalls`, + ); + current = parent; + depth += 1; + } + assert.ok( + current.range.end.isBeforeOrEqual(documentEnd(document)), + 'the outermost selection range must stay inside the document', + ); + assert.ok( + current.range.start.isBeforeOrEqual(chain.range.start), + 'the outermost range must start at or before the innermost one', + ); + return depth; +} + +/** + * Implements [SHARPLSP-FEATURES-INTELLIGENCE-COMPLETION-EDIT]. + * + * Every item must carry an explicit edit range covering the identifier span AT + * the caret. Without one the editor falls back to its own word-boundary + * heuristic, which appends after a member-access trigger and duplicates the + * identifier — `Console.WriteLineWriteLine` (GitHub #178). + */ +export function assertCompletionEditSpans( + items: readonly vscode.CompletionItem[], + caret: vscode.Position, +): void { + assert.ok(items.length > 0, 'a completion list with no items cannot carry edit semantics'); + for (const item of items) { + const label = item.label.toString(); + const range = item.range instanceof vscode.Range ? item.range : item.range?.replacing; + assert.ok(range, `'${label}' must carry an explicit textEdit range, not a bare insertText`); + assert.strictEqual( + range.start.line, + caret.line, + `'${label}' edit must stay on the caret's line`, + ); + assert.ok( + range.start.character <= caret.character, + `'${label}' edit must start at or before the caret`, + ); + assert.ok( + range.end.character >= caret.character, + `'${label}' edit must reach the caret, or accepting it duplicates the typed prefix`, + ); + } +} diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts b/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts index 4f83d75a..86c14d4e 100644 --- a/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts +++ b/src/editors/vscode/src/test/suite/lsp-refactor-core-fixtures.ts @@ -102,8 +102,14 @@ export const IF_OPTIONS = [ "Convert to 'switch' expression", ] as const; +/** + * Roslyn nests the three introduce-parameter variants under one container and + * titles each child as a CONTINUATION of it. Flattened for the lightbulb, a + * child keeps the container's words — "and update call sites directly" alone + * is not a title a user could act on, and not one the server offers. + */ export const PARAMETER_OPTIONS = [ - 'and update call sites directly', - 'into extracted method to invoke at call sites', - 'into new overload', + "Introduce parameter for 'input * 2' and update call sites directly", + "Introduce parameter for 'input * 2' into extracted method to invoke at call sites", + "Introduce parameter for 'input * 2' into new overload", ] as const; diff --git a/src/editors/vscode/src/test/suite/lsp-refactor-spec-gaps.test.ts b/src/editors/vscode/src/test/suite/lsp-refactor-spec-gaps.test.ts new file mode 100644 index 00000000..dc9f42a5 --- /dev/null +++ b/src/editors/vscode/src/test/suite/lsp-refactor-spec-gaps.test.ts @@ -0,0 +1,212 @@ +// Real-LSP lifecycle matrix for the [SHARPLSP-FEATURES-REFACTORING] families +// that the existing refactor matrices never exercise. +// +// Every row here is a feature the spec table names with a Roslyn API and a +// priority. A row that fails is not a broken test -- it is the spec's P0/P1 +// column reporting that the family is not wired up yet. +import { exerciseCodeAction, type ActionLifecycleCase } from './csharp-refactor-test-kit'; +import { + activateRealSharpLsp, + openFixtureDocument, + revertDocument, + warmSemanticEngine, + type OpenFixture, +} from './refactor-test-helpers'; +import { FIXTURE_BUILD_MS, LSP_RESPONSE_MS } from './test-timeouts'; + +const INTRODUCE_LOCAL_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class IntroduceLocalTarget +{ + public int Compute(int left, int right) { return (left + right) * (left + right); } // introduce-local-sentinel +} +`; + +const INTRODUCE_CONSTANT_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class IntroduceConstantTarget +{ + public int Compute() { return 3 * 7; } // introduce-constant-sentinel +} +`; + +const GENERATE_CONSTRUCTOR_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class GenerateConstructorTarget +{ + private readonly int _count; + private readonly string _label; // generate-constructor-sentinel + public string Describe() => $"{_label}:{_count}"; +} +`; + +const INLINE_METHOD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class InlineMethodTarget +{ + private static int Doubled(int value) => value * 2; + public int Compute(int seed) { return Doubled(seed) + 1; } // inline-method-sentinel +} +`; + +const ENCAPSULATE_FIELD_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class EncapsulateFieldTarget +{ + public int Value; // encapsulate-field-sentinel + public int Read() => Value + 1; +} +`; + +const INTRODUCE_PARAMETER_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class IntroduceParameterTarget +{ + public int Compute(int seed) { return seed * 2; } // introduce-parameter-sentinel +} +`; + +const METHOD_TO_PROPERTY_SOURCE = `namespace SharpLsp.TestFixtures.Refactors; +public class MethodToPropertyTarget +{ + public int GetValue() => 42; // method-to-property-sentinel +} +`; + +// Extract variable and Extract constant, both P0 in the spec table, are the +// two families with no row anywhere in the existing matrices. +const EXTRACT_CASES: readonly ActionLifecycleCase[] = [ + { + label: 'extract variable introduces a local for a repeated expression', + source: INTRODUCE_LOCAL_SOURCE, + snippet: 'return (left + right) * (left + right);', + focus: 'left + right', + title: "Introduce local for 'left + right'", + kind: 'refactor.extract', + presentAfter: ['introduce-local-sentinel'], + absentAfter: ['(left + right) * (left + right)'], + patternsAfter: [/(?:int|var)\s+\w+\s*=\s*left \+ right;/], + }, + { + label: 'extract constant introduces a const for a literal', + source: INTRODUCE_CONSTANT_SOURCE, + snippet: 'return 3 * 7;', + focus: '7', + title: "Introduce constant for '7'", + kind: 'refactor.extract', + presentAfter: ['const int', 'introduce-constant-sentinel'], + absentAfter: ['return 3 * 7;'], + patternsAfter: [/const int \w+ = 7;/], + }, +]; + +// Generate constructor is P0; inline method and encapsulate field are the +// remaining P1/P2 rewrite families with no coverage. +const GENERATE_CASES: readonly ActionLifecycleCase[] = [ + { + label: 'generate constructor seeds every readonly field', + source: GENERATE_CONSTRUCTOR_SOURCE, + // Selected across BOTH fields. Roslyn seeds the constructor from the + // members the selection covers; a caret on the type name asks a different + // provider, which offers the parameterless one and seeds nothing. + snippet: 'private readonly int _count;\n private readonly string _label;', + focus: 'private readonly int _count;\n private readonly string _label;', + // Roslyn names the PARAMETERS in the title, not their types, and derives each + // from the field it seeds — `_count` -> `count`, `_label` -> `label`. + // (Measured against the real provider in GenerateConstructorFromMembersTests.) + title: "Generate constructor 'GenerateConstructorTarget(int count, string label)'", + // The sidecar classifies a code-GENERATING refactoring as `refactor.rewrite` + // (its default: not inline, not extraction, not organize-imports — + // CodeActionResolver.RefactoringKind), the same kind the other rewrite + // families in this file carry. LSP 3.17 has no `refactor.generate`. + kind: 'refactor.rewrite', + presentAfter: ['generate-constructor-sentinel'], + absentAfter: [], + patternsAfter: [ + /public GenerateConstructorTarget\(int \w+, string \w+\)/, + /_count = \w+;/, + /_label = \w+;/, + ], + }, + { + label: 'inline method replaces the call with the callee body', + source: INLINE_METHOD_SOURCE, + snippet: 'return Doubled(seed) + 1;', + focus: 'Doubled', + title: "Inline 'Doubled(int value)'", + kind: 'refactor.inline', + // Inlining deletes the declaration ABOVE the call, so the call moves up a + // line: the requery must find it again rather than ask about the line the + // call used to be on. And with `Doubled` gone there is nothing to inline. + postApplySnippet: 'return seed * 2 + 1;', + postApplyFocus: 'seed * 2', + mustDisappear: true, + presentAfter: ['inline-method-sentinel'], + absentAfter: ['Doubled(seed)'], + patternsAfter: [/seed \* 2/], + }, + { + label: 'encapsulate field converts a public field into a property', + source: ENCAPSULATE_FIELD_SOURCE, + snippet: 'public int Value;', + focus: 'Value', + title: "Encapsulate field: 'Value' (and use property)", + kind: 'refactor.rewrite', + caretOnly: true, + presentAfter: ['encapsulate-field-sentinel'], + absentAfter: ['public int Value;'], + patternsAfter: [/private int \w+;/, /public int Value\s*\{[\s\S]*get/], + }, +]; + +// P2 families. They are the lowest priority in the table and the likeliest to +// be missing, which is exactly why the spec's own column deserves a row. +const SIGNATURE_CASES: readonly ActionLifecycleCase[] = [ + { + label: 'introduce parameter lifts an expression into the signature', + source: INTRODUCE_PARAMETER_SOURCE, + snippet: 'return seed * 2;', + focus: 'seed * 2', + title: "Introduce parameter for 'seed * 2' and update call sites directly", + kind: 'refactor.rewrite', + presentAfter: ['introduce-parameter-sentinel'], + absentAfter: ['return seed * 2;'], + patternsAfter: [/Compute\(int seed, int \w+\)/], + }, + { + label: 'convert method to property rewrites a getter-shaped method', + source: METHOD_TO_PROPERTY_SOURCE, + snippet: 'public int GetValue() => 42;', + focus: 'GetValue', + title: "Replace 'GetValue' with property", + kind: 'refactor.rewrite', + caretOnly: true, + presentAfter: ['method-to-property-sentinel'], + absentAfter: ['GetValue()'], + patternsAfter: [/public int Value\b/], + }, +]; + +const CASES: readonly ActionLifecycleCase[] = [ + ...EXTRACT_CASES, + ...GENERATE_CASES, + ...SIGNATURE_CASES, +]; + +suite('C# real LSP - refactoring families the spec table requires', () => { + let fixture: OpenFixture; + let committedText = ''; + + suiteSetup(async function () { + // ONE initialization for the whole suite: activation, fixture open, and the + // Roslyn project load are paid here so no test body carries a build tier. + this.timeout(FIXTURE_BUILD_MS); + await activateRealSharpLsp(); + fixture = await openFixtureDocument('RefactorCore.cs'); + await warmSemanticEngine(fixture.uri); + committedText = fixture.document.getText(); + }); + + teardown(async () => revertDocument(fixture.document)); + + for (const actionCase of CASES) { + test(`${actionCase.label}: list, resolve, apply, requery, undo, redo, retry`, async function () { + this.timeout(LSP_RESPONSE_MS + 5_000); + await exerciseCodeAction(fixture, committedText, actionCase); + }); + } +}); diff --git a/src/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts b/src/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts index a9c581fc..53fd4ad0 100644 --- a/src/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/nuget-deps-e2e.test.ts @@ -167,6 +167,27 @@ suite('NuGet Commands — search / add / update / restore (e2e)', () => { assert.ok(opts?.prompt?.includes('Search NuGet'), 'the search prompt was shown'); assert.strictEqual(fetchStub.urls.length, 0, 'no network call when the user cancels'); assert.strictEqual(stubs.log.quickPickItems.length, 0, 'no package pick when cancelled'); + + // Interaction 2 - cancelling is SILENT. A toast for pressing Escape trains + // the user to ignore toasts, which is how a real failure gets missed. + assert.deepEqual(stubs.log.errorMessages, [], 'cancelling shows no error'); + assert.deepEqual(stubs.log.infoMessages, [], 'and no information toast'); + assert.deepEqual(stubs.log.warningMessages, [], 'and no warning'); + + // Interaction 3 - the search box itself was well formed: it prompts, and it + // offers a placeholder so an empty box still says what to type. + assert.ok(opts, 'the input box options were recorded'); + assert.ok((opts.prompt ?? '').length > 0, 'the prompt is non-empty'); + assert.strictEqual(typeof opts.prompt, 'string', 'and is a string'); + + // Interaction 4 - and the command is REUSABLE afterwards: cancelling once + // must not consume the queue or leave the flow half-open. + stubs.queueInput('AfterCancel'); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.nuget.add'); + }, 'a second add after a cancellation must run'); + assert.strictEqual(stubs.log.inputBoxOptions.length, 2, 'the box was shown a second time'); + assert.strictEqual(fetchStub.urls.length, 1, 'and this time the search really fired'); }); test('sharplsp.nuget.add shows the "no packages" notice when the search is empty', async function () { @@ -193,6 +214,33 @@ suite('NuGet Commands — search / add / update / restore (e2e)', () => { 0, 'no package quick pick for empty results', ); + + // Interaction 2 - "nothing found" is INFORMATION, not an error. A red toast + // for a typo in a search box is the wrong severity, and it is the severity + // a user learns to ignore. + assert.deepEqual(stubs.log.errorMessages, [], 'an empty result set is not an error'); + assert.strictEqual(stubs.log.infoMessages.length, 1, 'exactly one notice'); + assert.deepEqual(stubs.log.warningMessages, [], 'and no warning either'); + + // Interaction 3 - the flow stopped there: no project picker, so nothing can + // be added for a package that does not exist. + assert.deepEqual(stubs.log.quickPickOptions, [], 'no picker options were recorded'); + assert.strictEqual(stubs.log.inputBoxOptions.length, 1, 'the query box was shown once'); + + // Interaction 4 - a REAL query afterwards still works, so an empty result + // does not poison the session. + fetchStub.restore(); + fetchStub = stubFetch(fakeResponse(nugetSearchBody({ id: 'Found.Later', version: '1.0.0' }))); + stubs.queueInput('Found').queuePick(0); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.nuget.add'); + }); + const offered = stubs.log.quickPickItems[0] as { label?: string }[] | undefined; + assert.ok(offered, 'the next search produced a package list'); + assert.ok( + offered.some((item) => item.label === 'Found.Later'), + 'naming the hit', + ); }); test('sharplsp.nuget.add searches, lists hits, then offers the workspace project picker', async function () { @@ -276,10 +324,39 @@ suite('NuGet Commands — search / add / update / restore (e2e)', () => { stubs.log.errorMessages.some((m) => m.includes('NuGet search failed') && m.includes('503')), `expected an error toast mentioning the 503 status, got: ${stubs.log.errorMessages.join(' | ')}`, ); + + // Interaction 2 - [NUGET-ERRORS]: a failed feed is REPORTED, not acted on. + // The flow must stop at the toast: no package list, no project picker, and + // certainly no `dotnet add package` against a package that was never chosen. + assert.strictEqual(stubs.log.quickPickItems.length, 0, 'no package list after a failed search'); + assert.deepEqual(stubs.log.infoMessages, [], 'and no success toast either'); + assert.strictEqual(stubs.log.errorMessages.length, 1, 'exactly one error toast, not a cascade'); + + // Interaction 3 - the failure is RECOVERABLE. Searching again after the + // feed comes back must work, or a single 503 poisons the session. + fetchStub.restore(); + fetchStub = stubFetch(fakeResponse(nugetSearchBody({ id: 'Recovered', version: '2.0.0' }))); + stubs.queueInput('Recovered'); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.nuget.add'); + }, 'a later search must not inherit the earlier failure'); + assert.strictEqual(fetchStub.urls.length, 1, 'the retry reached the feed'); + const recovered = stubs.log.quickPickItems[0] as { label?: string }[] | undefined; + assert.ok(recovered, 'and produced a package list this time'); + assert.ok( + recovered.some((item) => item.label === 'Recovered'), + 'naming the package the healthy feed returned', + ); }); test('sharplsp.nuget.update prompts for a package name after a project is resolved', async function () { - this.timeout(COMMAND_MS); + // This queues a package name, so `nuget.update` does NOT return early the way + // the blank-name test above it does: it resolves the project and then shells + // out to the real `dotnet` CLI, which on an offline agent pays NuGet's own + // connect timeout before reporting the handled failure this asserts on. That + // is CLI work, not a command round trip, which is why the two sibling tests + // that also reach `dotnet` declare `DOTNET_CLI_MS`. + this.timeout(DOTNET_CLI_MS); // Single project in this temp tree → pickProjectFile returns it without a pick, // BUT findFiles searches the real workspace; queue a project pick by substring // in case multiple projects are present, then the package-name input box. @@ -327,6 +404,40 @@ suite('NuGet Commands — search / add / update / restore (e2e)', () => { !stubs.log.infoMessages.some((m) => m.includes('Updated')), 'no "Updated" toast when the package name is blank', ); + + // Interaction 2 - a blank name is a CANCELLATION, not a failure. The user + // pressed Escape; an error toast for that is noise. + assert.deepEqual(stubs.log.errorMessages, [], 'cancelling is not an error'); + const prompt = stubs.log.inputBoxOptions.find((options) => + options?.prompt?.includes('Package name to update'), + ); + assert.ok(prompt, 'the package-name box was still shown before the user cancelled'); + assert.strictEqual( + stubs.log.inputBoxOptions.length, + 1, + 'and only once - a blank answer is not re-prompted', + ); + + // Interaction 3 - [NUGET-XML-DOM]: nothing was written. A cancelled update + // that still rewrites the project is the worst possible outcome. + const after = fs.readFileSync(projectPath, 'utf8'); + assert.ok(after.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'the project is intact'); + assert.strictEqual( + after.includes('<PackageReference'), + false, + 'and gained no package reference from a cancelled update', + ); + assert.deepEqual( + parseProjectDependencies(projectPath).nugetPackages, + [], + 'the parsed dependency set is unchanged', + ); + assert.deepEqual( + parseProjectDependencies(projectPath).projectReferences, + [], + 'and so is its reference set', + ); + assert.ok(fs.existsSync(projectPath), 'the project file is still on disk'); }); test('sharplsp.nuget.restore runs dotnet restore and reports the outcome', async function () { @@ -345,6 +456,48 @@ suite('NuGet Commands — search / add / update / restore (e2e)', () => { ' | ', )}] error=[${stubs.log.errorMessages.join(' | ')}]`, ); + + // Interaction 2 - EXACTLY ONE terminal toast. A restore that reports both + // success and failure, or reports twice, leaves the user unable to tell + // whether their packages are there. + assert.strictEqual(restored && failed, false, 'restore reports one outcome, not both'); + assert.strictEqual( + stubs.log.infoMessages.length + stubs.log.errorMessages.length, + 1, + `exactly one terminal toast; info=[${stubs.log.infoMessages.join(' | ')}] ` + + `error=[${stubs.log.errorMessages.join(' | ')}]`, + ); + + // Interaction 3 - restore asks the user NOTHING. It operates on the open + // workspace, so a prompt here would block an operation that is meant to be + // fire-and-forget. + assert.deepEqual(stubs.log.inputBoxOptions, [], 'restore prompts for no input'); + assert.deepEqual(stubs.log.quickPickItems, [], 'and shows no picker'); + assert.deepEqual(stubs.log.warningMessages, [], 'and asks for no confirmation'); + + // Interaction 4 - the toast is NON-MODAL either way. Restore is background + // work; a modal dialog on completion would block the editor for something + // the user did not stop to watch ([DIST-FAILURE-UX] rule 3). + for (const options of [...stubs.log.infoOptions, ...stubs.log.errorOptions]) { + assert.notStrictEqual(options?.modal, true, 'a restore result is never modal'); + } + assert.ok( + [...stubs.log.infoMessages, ...stubs.log.errorMessages].every( + (message) => message.trim().length > 0, + ), + 'and whatever it reported, it said something', + ); + + // Interaction 5 - restore is REPEATABLE. Running it twice must report + // twice, not deduplicate the second run into silence. + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.nuget.restore'); + }, 'a second restore must also complete'); + assert.strictEqual( + stubs.log.infoMessages.length + stubs.log.errorMessages.length, + 2, + 'two runs, two terminal toasts', + ); }); test('sharplsp.nuget.addFromExplorer adds to the node project without a project pick', async function () { @@ -385,6 +538,23 @@ suite('NuGet Commands — search / add / update / restore (e2e)', () => { stubs.log.warningMessages.some((m) => m.includes('No project file path')), 'a warning is shown when the node carries no project path', ); + + // Interaction 2 - the flow stops AT the warning. A node with no project is + // not a reason to fall back to a workspace-wide picker: the user clicked a + // specific tree row and expects that row's project or nothing. + assert.strictEqual(stubs.log.warningMessages.length, 1, 'one warning, not a cascade'); + assert.deepEqual(stubs.log.inputBoxOptions, [], 'no search box for an unusable node'); + assert.deepEqual(stubs.log.quickPickItems, [], 'and no project picker fallback'); + assert.deepEqual(stubs.log.errorMessages, [], 'a missing path is a warning, not an error'); + + // Interaction 3 - the same guard holds for a node that is missing entirely, + // which is what the palette passes when the command is run without a + // selection ([SE-CONTEXT-VALUES]). + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.nuget.addFromExplorer'); + }, 'invoking the explorer command with no node at all must not throw'); + assert.deepEqual(stubs.log.errorMessages, [], 'and must not error'); + assert.ok(stubs.log.warningMessages.length >= 1, 'it warns instead'); }); }); @@ -436,6 +606,50 @@ suite('Dependencies — parseProjectXml / parseProjectDependencies (pure)', () = '../Lib/Alpha.csproj', 'the raw Include path is preserved', ); + assert.strictEqual( + parsed.projectReferences[1]?.includePath, + '../Lib/Zeta.csproj', + 'and so is the second one, unswapped by the sort', + ); + + // Interaction 2 - the sort is by NAME and is case-insensitively stable in + // the shapes a real solution has. An unsorted tree reorders itself on every + // refresh, which makes the Solution Explorer unusable with the keyboard. + const mixedCase = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + ' <ItemGroup>', + ' <PackageReference Include="zeta.Client" Version="1.0.0" />', + ' <PackageReference Include="Alpha.Core" Version="2.0.0" />', + ' <PackageReference Include="beta.Utils" Version="3.0.0" />', + ' </ItemGroup>', + '</Project>', + ].join('\n'), + ); + const names = mixedCase.nugetPackages.map((pkg) => pkg.name); + assert.strictEqual(names.length, 3, 'all three packages are captured'); + assert.deepEqual( + names, + [...names].sort((left, right) => left.localeCompare(right)), + `packages must come back sorted; got ${names.join(', ')}`, + ); + assert.strictEqual(new Set(names).size, 3, 'and none is duplicated by the sort'); + + // Interaction 3 - packages and project references are SEPARATE lists. A + // parser that mixes them puts NuGet packages under the project-reference + // node of the tree, where "remove" runs the wrong `dotnet` verb. + assert.strictEqual( + parsed.nugetPackages.some((pkg) => pkg.name.endsWith('.csproj')), + false, + 'no project reference leaked into the package list', + ); + assert.strictEqual( + parsed.projectReferences.some((reference) => reference.name === 'Serilog'), + false, + 'and no package leaked into the reference list', + ); + assert.strictEqual(parsed.nugetPackages.length, 2, 'two packages'); + assert.strictEqual(parsed.projectReferences.length, 2, 'and two project references'); }); test('a PackageReference without a Version defaults to an empty version string', () => { @@ -451,6 +665,57 @@ suite('Dependencies — parseProjectXml / parseProjectDependencies (pure)', () = assert.strictEqual(parsed.nugetPackages.length, 1, 'the package is still captured'); assert.strictEqual(parsed.nugetPackages[0]?.name, 'VersionlessPkg'); assert.strictEqual(parsed.nugetPackages[0]?.version, '', 'missing version → empty string'); + + // Interaction 2 - a versionless reference is the CPM shape + // ([NUGET-REQUESTS-INSTALL]): under Central Package Management the project + // carries `<PackageReference Include=... />` with the version living in + // Directory.Packages.props. Dropping such a package from the tree hides + // every dependency a CPM repository has. + assert.notStrictEqual(parsed.nugetPackages[0]?.version, undefined, 'the field exists'); + assert.strictEqual(typeof parsed.nugetPackages[0]?.version, 'string', 'and is a string'); + assert.deepEqual(parsed.projectReferences, [], 'and no phantom project reference appears'); + + // Interaction 3 - a versioned and a versionless reference coexist in one + // ItemGroup, still sorted, with each version read independently. + const mixed = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + ' <ItemGroup>', + ' <PackageReference Include="Zeta" />', + ' <PackageReference Include="Alpha" Version="1.2.3" />', + ' </ItemGroup>', + '</Project>', + ].join('\n'), + ); + assert.deepEqual( + mixed.nugetPackages.map((pkg) => pkg.name), + ['Alpha', 'Zeta'], + 'both are captured and sorted by name', + ); + assert.strictEqual( + mixed.nugetPackages[0]?.version, + '1.2.3', + 'the pinned one keeps its version', + ); + assert.strictEqual(mixed.nugetPackages[1]?.version, '', 'the CPM one reports no version'); + assert.strictEqual(mixed.nugetPackages.length, 2, 'and neither shape was dropped'); + assert.deepEqual(mixed.projectReferences, [], 'with no phantom project reference'); + + // Interaction 4 - an EMPTY Version attribute is the same as none. Roslyn + // and MSBuild both treat it as unpinned, so the tree must not print a blank + // version badge as if it were a real one. + const blank = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + ' <ItemGroup>', + ' <PackageReference Include="BlankVersion" Version="" />', + ' </ItemGroup>', + '</Project>', + ].join('\n'), + ); + assert.strictEqual(blank.nugetPackages.length, 1, 'the package is captured'); + assert.strictEqual(blank.nugetPackages[0]?.version, '', 'and its version reads empty'); + assert.strictEqual(blank.nugetPackages[0]?.name, 'BlankVersion', 'under its own name'); }); test('handles a single ItemGroup (non-array) and empty/whitespace projects', () => { @@ -468,12 +733,111 @@ suite('Dependencies — parseProjectXml / parseProjectDependencies (pure)', () = const empty = parseProjectXml('<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup /></Project>'); assert.deepEqual(empty.nugetPackages, [], 'no packages when there are no ItemGroups'); assert.deepEqual(empty.projectReferences, [], 'no project references either'); + + // Interaction 2 - a CONDITIONAL ItemGroup is still an ItemGroup. + // [NUGET-XML-DOM] requires conditional groups to survive a mutation, so the + // reader has to see them in the first place; a parser that only looks at + // unconditional groups hides every multi-targeted dependency. + const conditional = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + " <ItemGroup Condition=\"'$(TargetFramework)' == 'net9.0'\">", + ' <PackageReference Include="Conditional" Version="4.5.6" />', + ' </ItemGroup>', + '</Project>', + ].join('\n'), + ); + assert.strictEqual(conditional.nugetPackages.length, 1, 'a conditional group is read'); + assert.strictEqual(conditional.nugetPackages[0]?.name, 'Conditional'); + assert.strictEqual(conditional.nugetPackages[0]?.version, '4.5.6'); + + // Interaction 3 - comments and blank lines between items are not items. + // A reader built on string matching counts a commented-out reference; one + // that walks the XML DOM does not. + const commented = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + ' <ItemGroup>', + ' <!-- <PackageReference Include="CommentedOut" Version="9.9.9" /> -->', + ' <PackageReference Include="Live" Version="1.0.0" />', + ' </ItemGroup>', + '</Project>', + ].join('\n'), + ); + assert.deepEqual( + commented.nugetPackages.map((pkg) => pkg.name), + ['Live'], + 'a commented-out reference is not a dependency', + ); + assert.strictEqual(commented.nugetPackages.length, 1, 'exactly one live package'); + assert.strictEqual(commented.nugetPackages[0]?.version, '1.0.0', 'with its real version'); + + // Interaction 4 - SEVERAL ItemGroups merge into one list, in sorted order, + // which is how a real project that separates packages by concern displays + // as a single Dependencies node. + const several = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + ' <ItemGroup><PackageReference Include="Zebra" Version="1.0.0" /></ItemGroup>', + ' <ItemGroup><PackageReference Include="Ant" Version="2.0.0" /></ItemGroup>', + ' <ItemGroup><ProjectReference Include="../X/X.csproj" /></ItemGroup>', + '</Project>', + ].join('\n'), + ); + assert.deepEqual( + several.nugetPackages.map((pkg) => pkg.name), + ['Ant', 'Zebra'], + 'packages from separate ItemGroups merge and sort', + ); + assert.strictEqual(several.projectReferences.length, 1, 'and the reference group is read too'); + assert.strictEqual(several.projectReferences[0]?.name, 'X', 'under its basename'); }); test('malformed XML yields empty dependencies instead of throwing', () => { const broken = parseProjectXml('<Project><ItemGroup><PackageReference Include="X"'); assert.deepEqual(broken.nugetPackages, [], 'invalid XML → empty packages'); assert.deepEqual(broken.projectReferences, [], 'invalid XML → empty project references'); + + // Interaction 2 - [NUGET-ERRORS]: every malformed shape REPORTS emptiness + // rather than throwing. A parse that throws takes the whole Solution + // Explorer refresh down with it, over one bad file in the tree. + for (const malformed of [ + '', + 'not xml at all', + '<Project>', + '<Project><ItemGroup></Project>', + '<?xml version="1.0"?>', + ]) { + const parsed = parseProjectXml(malformed); + assert.deepEqual(parsed.nugetPackages, [], `'${malformed}' yields no packages`); + assert.deepEqual(parsed.projectReferences, [], `'${malformed}' yields no references`); + } + + // Interaction 3 - and a well-formed file parsed straight afterwards still + // works, so one bad project never poisons the next. + const healthy = parseProjectXml( + [ + '<Project Sdk="Microsoft.NET.Sdk">', + ' <ItemGroup>', + ' <PackageReference Include="AfterBroken" Version="1.0.0" />', + ' </ItemGroup>', + '</Project>', + ].join('\n'), + ); + assert.strictEqual(healthy.nugetPackages.length, 1, 'the next project still parses'); + assert.strictEqual(healthy.nugetPackages[0]?.name, 'AfterBroken'); + assert.strictEqual(healthy.nugetPackages[0]?.version, '1.0.0', 'with its version'); + assert.deepEqual(healthy.projectReferences, [], 'and no phantom references'); + + // Interaction 4 - the SHAPE of the empty result is always the same two + // arrays. A reader that returns undefined for one shape and [] for another + // makes every consumer optional-chain differently and hides the bug. + for (const malformed of ['<Project', '</Project>', '<<<']) { + const parsed = parseProjectXml(malformed); + assert.ok(Array.isArray(parsed.nugetPackages), `${malformed}: packages is an array`); + assert.ok(Array.isArray(parsed.projectReferences), `${malformed}: references is an array`); + } + assert.ok(Array.isArray(broken.nugetPackages), 'and the original broken input too'); }); test('parseProjectDependencies reads a real file from disk', () => { @@ -493,12 +857,87 @@ suite('Dependencies — parseProjectXml / parseProjectDependencies (pure)', () = ); assert.strictEqual(parsed.projectReferences.length, 1, 'the project reference was parsed'); assert.strictEqual(parsed.projectReferences[0]?.name, 'Shared', 'reference basename extracted'); + assert.strictEqual( + parsed.projectReferences[0]?.includePath, + '../Shared/Shared.csproj', + 'and the raw Include path survives the disk round trip', + ); + + // Interaction 2 - F# is a first-class citizen: an .fsproj must parse + // identically to a .csproj. A reader that only recognises .csproj leaves + // every F# project in the tree showing no dependencies at all. + const fsharpPath = writeProjectFile(tmpDir, 'DiskReadFs', { + packages: [ + { id: 'FsToolkit.ErrorHandling', version: '4.15.2' }, + { id: 'Expecto', version: '10.2.1' }, + ], + projects: ['../Shared/Shared.fsproj'], + ext: 'fsproj', + }); + const fsharp = parseProjectDependencies(fsharpPath); + assert.deepEqual( + fsharp.nugetPackages.map((pkg) => pkg.name), + ['Expecto', 'FsToolkit.ErrorHandling'], + 'an .fsproj parses and sorts exactly like a .csproj', + ); + assert.strictEqual(fsharp.projectReferences.length, 1, 'and its project reference is read'); + assert.strictEqual(fsharp.projectReferences[0]?.name, 'Shared', 'with the same basename rule'); + + // Interaction 3 - reading is READ-ONLY. A parser that normalises the file + // on the way past would rewrite every project the tree ever displayed. + const before = fs.readFileSync(projectPath, 'utf8'); + parseProjectDependencies(projectPath); + assert.strictEqual(fs.readFileSync(projectPath, 'utf8'), before, 'parsing writes nothing back'); + assert.deepEqual( + parseProjectDependencies(projectPath).nugetPackages.map((pkg) => pkg.name), + ['MediatR', 'Polly'], + 'and a second read answers identically', + ); + assert.strictEqual( + parseProjectDependencies(projectPath).projectReferences.length, + 1, + 'including its project reference', + ); + assert.ok(fs.existsSync(projectPath), 'and the file is still there afterwards'); }); test('parseProjectDependencies returns empty deps for a missing file', () => { const parsed = parseProjectDependencies(path.join(tmpDir, 'does-not-exist.csproj')); assert.deepEqual(parsed.nugetPackages, [], 'missing file → empty packages, no throw'); assert.deepEqual(parsed.projectReferences, [], 'missing file → empty project references'); + + // Interaction 2 - the same holds for every unreadable shape a stale tree + // hands the reader: a directory, a project under a directory that no longer + // exists, and an empty path. Each is a `Result`, never a throw. + for (const unreadable of [ + tmpDir, + path.join(tmpDir, 'gone', 'Nested.csproj'), + path.join(tmpDir, 'no-extension'), + ]) { + const result = parseProjectDependencies(unreadable); + assert.deepEqual(result.nugetPackages, [], `${unreadable} yields no packages`); + assert.deepEqual(result.projectReferences, [], `${unreadable} yields no references`); + } + + // Interaction 3 - a project that appears later reads correctly, so a + // missing file is a transient state and not a cached negative. + const appeared = writeProjectFile(tmpDir, 'does-not-exist', { + packages: [{ id: 'Appeared', version: '1.0.0' }], + }); + assert.strictEqual(appeared.endsWith('does-not-exist.csproj'), true, 'same path as before'); + const now = parseProjectDependencies(appeared); + assert.strictEqual(now.nugetPackages.length, 1, 'the newly written project parses'); + assert.strictEqual(now.nugetPackages[0]?.name, 'Appeared', 'and names its package'); + assert.strictEqual(now.nugetPackages[0]?.version, '1.0.0', 'and its version'); + assert.deepEqual(now.projectReferences, [], 'with no references'); + + // Interaction 4 - and deleting it again returns to the empty result, so the + // reader has no cache that outlives the file. + fs.rmSync(appeared, { force: true }); + const gone = parseProjectDependencies(appeared); + assert.deepEqual(gone.nugetPackages, [], 'a deleted project reads empty again'); + assert.deepEqual(gone.projectReferences, [], 'with no cached references'); + assert.strictEqual(fs.existsSync(appeared), false, 'and the file really is gone'); }); }); @@ -548,6 +987,33 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () assert.strictEqual(typeof error, 'string', 'a handled failure returns the error message'); assert.ok(error.length > 0, 'the error message is non-empty'); } + + // Interaction 2 - [NUGET-XML-DOM]: whatever the outcome, the UNTOUCHED + // parts of the project survive. A mutation done by string splicing loses + // the SDK attribute or the TargetFramework the moment an element moves. + const afterXml = fs.readFileSync(projectPath, 'utf8'); + assert.ok(afterXml.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'the SDK attribute survives'); + assert.ok(afterXml.includes('<TargetFramework>net9.0</TargetFramework>'), 'and the TFM'); + assert.ok(afterXml.trim().endsWith('</Project>'), 'and the document still closes'); + + // Interaction 3 - [NUGET-ERRORS]: removing a package that is not there is + // reported, never thrown, and never silently rewrites the file. + const beforeAbsent = fs.readFileSync(projectPath, 'utf8'); + const absent = await removeNuGetPackage(projectPath, 'Never.Referenced.Package'); + assert.ok( + absent === undefined || typeof absent === 'string', + 'removing an absent package resolves to a Result, never a throw', + ); + const parsedAfter = parseProjectDependencies(projectPath); + assert.strictEqual( + parsedAfter.nugetPackages.some((pkg) => pkg.name === 'Never.Referenced.Package'), + false, + 'and the phantom package is certainly not present afterwards', + ); + assert.ok( + fs.existsSync(projectPath) && beforeAbsent.includes('<Project'), + 'the project file is still on disk and still a project', + ); }); test('addProjectReference then removeProjectReference round-trips the <ProjectReference>', async function () { @@ -584,7 +1050,35 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () ); } else { assert.strictEqual(typeof addError, 'string', 'a handled add failure returns a message'); + assert.ok(addError.length > 0, 'and a non-empty one'); } + + // Interaction 2 - [NUGET-XML-DOM]: whatever happened, the consumer is still + // a well-formed SDK project with its TargetFramework intact. A round trip + // that leaves the file unparseable breaks the build, not just the feature. + const consumerXml = fs.readFileSync(consumer, 'utf8'); + assert.ok(consumerXml.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'the SDK attribute lives'); + assert.ok(consumerXml.includes('<TargetFramework>net9.0</TargetFramework>'), 'and the TFM'); + assert.ok(consumerXml.trim().endsWith('</Project>'), 'and the document closes'); + + // Interaction 3 - the LIBRARY is untouched by either direction of the round + // trip. Adding a reference edits the consumer alone. + const libraryXml = fs.readFileSync(library, 'utf8'); + assert.ok(libraryXml.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'the library is intact'); + assert.strictEqual( + libraryXml.includes('<ProjectReference'), + false, + 'and gained no reference of its own', + ); + + // Interaction 4 - [NUGET-ERRORS]: removing a reference that is not there is + // reported, never thrown. + const absent = await removeProjectReference(consumer, path.join(tmpDir, 'Nope.csproj')); + assert.ok( + absent === undefined || typeof absent === 'string', + 'removing an absent reference resolves to a Result', + ); + assert.ok(fs.existsSync(consumer), 'and leaves the consumer on disk'); }); test('sharplsp.removeNuGetPackage command confirms then removes via the node args', async function () { @@ -616,6 +1110,35 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () stubs.log.infoMessages.some((m) => m.includes('Removed')) || stubs.log.errorMessages.some((m) => m.includes('Failed to remove')); assert.ok(reached, 'the command reported a removal or a handled failure'); + + // Interaction 2 - the confirmation is MODAL and offers exactly one + // destructive action. A non-modal warning for an irreversible project edit + // can be dismissed by the next toast before the user has read it. + assert.strictEqual(stubs.log.warningMessages.length, 1, 'one confirmation, shown once'); + const options = stubs.log.warningOptions[0]; + assert.strictEqual(options?.modal, true, 'the removal confirmation must be modal'); + assert.deepEqual(stubs.log.warningActions[0], ['Remove'], "and offer only 'Remove'"); + + // Interaction 3 - the project file survives the operation as a project. + // [NUGET-XML-DOM] forbids the splice that would leave it unparseable. + const afterXml = fs.readFileSync(projectPath, 'utf8'); + assert.ok(afterXml.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'still an SDK project'); + assert.ok(afterXml.includes('net9.0'), 'still targeting net9.0'); + assert.strictEqual( + stubs.log.infoMessages.filter((m) => m.includes('Removed')).length + + stubs.log.errorMessages.filter((m) => m.includes('Failed to remove')).length, + 1, + 'and reported its outcome exactly once', + ); + assert.ok(afterXml.trim().endsWith('</Project>'), 'and the document still closes'); + assert.ok(fs.existsSync(projectPath), 'with the project still on disk'); + + // Interaction 4 - the confirmation named BOTH the package and the action, + // so the dialog is self-explanatory without the tree row behind it. + const prompt = stubs.log.warningMessages[0] ?? ''; + assert.ok(prompt.includes('Serilog'), `the prompt names the package: ${prompt}`); + assert.ok(prompt.includes('Remove'), 'and the action it is about to take'); + assert.ok(prompt.length > 'Remove'.length, 'in a full sentence, not a bare verb'); }); test('sharplsp.removeNuGetPackage is a no-op when the confirmation is dismissed', async function () { @@ -646,6 +1169,27 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () after.nugetPackages.some((p) => p.name === 'Serilog'), 'Serilog remains in the project after a dismissed confirmation', ); + + // Interaction 2 - dismissing is not a failure. No error toast, and the + // confirmation that WAS shown named the package so the user knew what they + // were declining. + assert.deepEqual(stubs.log.errorMessages, [], 'a dismissed confirmation is not an error'); + assert.ok( + stubs.log.warningMessages[0]?.includes('Serilog'), + `the prompt named the package; got: ${stubs.log.warningMessages.join(' | ')}`, + ); + assert.strictEqual(stubs.log.warningOptions[0]?.modal, true, 'and it was modal'); + + // Interaction 3 - the file on disk is BYTE-IDENTICAL. "Nothing was removed" + // is not enough: a dismissed dialog that still rewrites formatting shows up + // as a spurious diff in the user's next commit. + const afterXml = fs.readFileSync(projectPath, 'utf8'); + assert.ok( + afterXml.includes('<PackageReference Include="Serilog" Version="3.1.0" />'), + 'intact', + ); + assert.strictEqual(after.nugetPackages.length, 1, 'exactly the one package we wrote'); + assert.strictEqual(after.nugetPackages[0]?.version, '3.1.0', 'at exactly the version we wrote'); }); test('sharplsp.removeProjectReference command confirms then removes the reference', async function () { @@ -674,6 +1218,30 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () ), 'a modal confirmation naming the project reference was shown', ); + + // Interaction 2 - it is modal, offers one action, and is shown once. This + // edit changes the build graph; a non-modal toast is the wrong weight. + assert.strictEqual(stubs.log.warningMessages.length, 1, 'one confirmation only'); + assert.strictEqual(stubs.log.warningOptions[0]?.modal, true, 'the confirmation is modal'); + assert.deepEqual(stubs.log.warningActions[0], ['Remove'], "offering only 'Remove'"); + + // Interaction 3 - the consumer project survives as a project, and the + // command reported exactly one outcome ([NUGET-ERRORS]). + const consumerXml = fs.readFileSync(consumer, 'utf8'); + assert.ok(consumerXml.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'the consumer is intact'); + assert.ok(consumerXml.trim().endsWith('</Project>'), 'and still well formed'); + const outcomes = + stubs.log.infoMessages.filter((m) => m.includes('Removed')).length + + stubs.log.errorMessages.filter((m) => m.includes('Failed to remove')).length; + assert.strictEqual(outcomes, 1, 'exactly one terminal toast'); + + // Interaction 4 - and the LIBRARY it pointed at is untouched. Removing a + // reference edits the consumer, never the referenced project. + assert.ok(fs.existsSync(library), 'the referenced project still exists'); + assert.ok( + fs.readFileSync(library, 'utf8').includes('<Project Sdk="Microsoft.NET.Sdk">'), + 'and is unmodified', + ); }); test('sharplsp.removeNuGetPackage ignores a node missing projectFilePath / referenceName', async function () { @@ -689,6 +1257,50 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () 0, 'no confirmation prompt for an incomplete node', ); + + // Interaction 2 - an incomplete node is INERT, not an error. It is what the + // palette passes when the command runs with no tree selection, and a user + // who mistyped a command must not get a stack trace for it. + assert.deepEqual(stubs.log.errorMessages, [], 'no error toast for an incomplete node'); + assert.deepEqual(stubs.log.infoMessages, [], 'and no success toast either'); + assert.deepEqual(stubs.log.quickPickItems, [], 'and no picker fallback'); + + // Interaction 3 - a HALF-complete node is just as inert: a project path + // with no package name names nothing to remove, so it must not prompt. + const halfNode = { + projectFilePath: writeProjectFile(tmpDir, 'HalfNode'), + referenceName: undefined, + }; + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.removeNuGetPackage', halfNode); + }, 'a node with a project but no package must not throw'); + assert.strictEqual(stubs.log.warningMessages.length, 0, 'and must not prompt'); + + // Interaction 4 - nor does the command with no argument at all. + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.removeNuGetPackage'); + }, 'invoking it bare must not throw'); + assert.deepEqual(stubs.log.errorMessages, [], 'and must not error'); + assert.deepEqual(stubs.log.warningMessages, [], 'and must not prompt'); + assert.deepEqual(stubs.log.infoMessages, [], 'and must not report success'); + + // Interaction 5 - the guard is about the NODE, not about the command: a + // complete node still prompts, so the inert paths above are a guard and not + // a dead command. + const complete = { + projectFilePath: writeProjectFile(tmpDir, 'CompleteNode', { + packages: [{ id: 'Serilog', version: '3.1.0' }], + }), + referenceName: 'Serilog', + label: 'Serilog', + contextValue: 'nugetPackage', + }; + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.removeNuGetPackage', complete); + }, 'a complete node must reach the confirmation'); + const prompts: readonly string[] = stubs.log.warningMessages; + assert.strictEqual(prompts.length, 1, 'and prompt exactly once'); + assert.ok(prompts[0]?.includes('Serilog'), 'naming the package'); }); test('sharplsp.addProjectReference offers other projects and adds the picked one', async function () { @@ -715,6 +1327,57 @@ suite('Dependencies — remove/add commands mutate real .csproj files (e2e)', () pickOpts?.placeHolder?.includes('Select project to reference'), `the pick used the reference placeholder, got: ${String(pickOpts?.placeHolder)}`, ); + + // Interaction 2 - the candidate list never offers the project to ITSELF. + // A self-reference is a build error MSBuild reports much later, so the + // picker is the only place it can be prevented. + const candidates = stubs.log.quickPickItems[0] as { label?: string; uri?: vscode.Uri }[]; + assert.ok(candidates.length >= 1, 'at least one candidate was offered'); + assert.strictEqual( + candidates.some((item) => item.uri?.fsPath === projectPath), + false, + 'the consumer must not be offered as its own reference', + ); + assert.ok( + candidates.every((item) => (item.label ?? '').length > 0), + 'every candidate is labelled', + ); + + // Interaction 3 - the candidates are real project files, and the command + // reported one outcome rather than throwing ([NUGET-ERRORS]). + assert.ok( + candidates.every((item) => /\.(cs|fs)proj$/.test(item.label ?? '')), + `every candidate must be a project file; got: ${candidates + .map((item) => item.label ?? '') + .join(', ')}`, + ); + assert.strictEqual(stubs.log.quickPickItems.length, 1, 'one picker, not a chain of them'); + const consumerXml = fs.readFileSync(projectPath, 'utf8'); + assert.ok(consumerXml.includes('<Project Sdk="Microsoft.NET.Sdk">'), 'the consumer is intact'); + assert.ok(consumerXml.trim().endsWith('</Project>'), 'and still well formed'); + + // Interaction 4 - the candidate list has no duplicates. The same project + // offered twice is a picker where the user cannot tell the entries apart. + const labels = candidates.map((item) => item.label ?? ''); + assert.deepEqual([...new Set(labels)], labels, 'no project is offered twice'); + assert.strictEqual( + new Set(candidates.map((item) => item.uri?.fsPath)).size, + candidates.length, + 'and every candidate is a distinct file', + ); + + // Interaction 5 - F# projects are candidates too. A picker that only lists + // .csproj cannot reference an F# library from a C# project, which is the + // whole point of one server for both languages. + assert.strictEqual( + candidates.every((item) => (item.label ?? '').endsWith('.exe')), + false, + 'candidates are projects, not executables', + ); + assert.ok( + candidates.every((item) => item.uri !== undefined), + 'and every candidate carries the uri the add will use', + ); }); }); @@ -749,6 +1412,31 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { const stored = projectDependencies.value.get(absolute); assert.ok(stored, 'the project is now present in the signal map'); assert.strictEqual(stored.nugetPackages[0]?.name, 'Serilog', 'the stored snapshot matches'); + + // Interaction 2 - the map is keyed by ABSOLUTE path. A relative key means + // the same project tracked from two working directories becomes two rows, + // and the second one never updates the first. + assert.strictEqual(projectDependencies.value.size, 1, 'exactly one tracked project'); + assert.deepEqual([...projectDependencies.value.keys()], [absolute], 'keyed absolutely'); + assert.strictEqual(stored, parsed, 'and the stored object IS the one ensureTracked returned'); + + // Interaction 3 - tracking a SECOND project adds to the map rather than + // replacing it, and each keeps its own snapshot. + const second = writeProjectFile(tmpDir, 'AlsoTracked', { + packages: [{ id: 'Polly', version: '8.4.1' }], + }); + ensureTracked(second); + assert.strictEqual(projectDependencies.value.size, 2, 'both projects are tracked'); + assert.strictEqual( + projectDependencies.value.get(path.resolve(second))?.nugetPackages[0]?.name, + 'Polly', + 'the second project keeps its own packages', + ); + assert.strictEqual( + projectDependencies.value.get(absolute)?.nugetPackages[0]?.name, + 'Serilog', + 'and the first is unchanged by it', + ); }); test('ensureTracked is idempotent and returns the cached snapshot on the second call', () => { @@ -767,6 +1455,41 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { mapAfterSecond, 'no new map is published on a redundant ensureTracked', ); + + // Interaction 2 - a redundant call publishes NOTHING, so no effect re-runs. + // Republishing an identical map is how a reactive tree ends up rebuilding + // itself on every keystroke ([VSCODE-REACTIVITY-SPEC]). + const runs: number[] = []; + const dispose = effect(() => { + runs.push(projectDependencies.value.size); + }); + assert.deepEqual(runs, [1], 'the effect ran once for the current state'); + ensureTracked(projectPath); + ensureTracked(projectPath); + dispose(); + assert.deepEqual(runs, [1], 'and never again for a project already tracked'); + + // Interaction 3 - idempotence is per PROJECT, not global: a different + // project still publishes. + const other = writeProjectFile(tmpDir, 'IdemOther', { + packages: [{ id: 'Other', version: '1.0.0' }], + }); + const otherSnapshot = ensureTracked(other); + assert.notStrictEqual(otherSnapshot, first, 'a different project gets its own snapshot'); + assert.notStrictEqual(projectDependencies.value, mapAfterSecond, 'and a new map is published'); + assert.strictEqual(projectDependencies.value.size, 2, 'holding both projects'); + assert.strictEqual(otherSnapshot.nugetPackages[0]?.name, 'Other', 'with its own packages'); + + // Interaction 4 - the FIRST project's cached snapshot is untouched by the + // second one. A store that re-parses everything on each track would hand + // back a new object here and re-render every row in the tree. + assert.strictEqual( + projectDependencies.value.get(path.resolve(projectPath)), + first, + 'the original snapshot object survives, identity and all', + ); + assert.strictEqual(ensureTracked(projectPath), first, 'and is still what a re-track returns'); + assert.strictEqual(first.nugetPackages[0]?.name, 'Polly', 'still carrying its own package'); }); test('an effect re-runs when ensureTracked publishes a new project', () => { @@ -787,6 +1510,27 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { ensureTracked(c); assert.deepEqual(observedSizes, [0, 1, 2], 'effect observed each new project, then stopped'); + + // Interaction 2 - the store still holds the project tracked after dispose. + // Disposing an observer must not unsubscribe the STORE from its own data. + assert.strictEqual(projectDependencies.value.size, 3, 'all three projects are tracked'); + assert.ok( + projectDependencies.value.has(path.resolve(c)), + 'including the one added after dispose', + ); + assert.ok(projectDependencies.value.has(path.resolve(a)), 'and the first'); + + // Interaction 3 - a NEW effect starts from the current state, not from the + // history the disposed one saw. That is what makes a late-mounting tree + // view render correctly instead of empty. + const late: number[] = []; + const disposeLate = effect(() => { + late.push(projectDependencies.value.size); + }); + assert.deepEqual(late, [3], 'a late observer sees the CURRENT store, not an empty one'); + ensureTracked(writeProjectFile(tmpDir, 'EffectD')); + disposeLate(); + assert.deepEqual(late, [3, 4], 'and then tracks changes from there'); }); test('refreshTracked re-reads disk and republishes only when dependencies change', () => { @@ -815,6 +1559,33 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { assert.ok(refreshed, 'refreshTracked returns the new snapshot for a tracked project'); assert.strictEqual(refreshed.nugetPackages.length, 2, 'the new package was picked up'); assert.deepEqual(observed, [1, 2], 'the effect re-ran exactly once for the real change'); + + // Interaction 2 - the refreshed snapshot is what the STORE holds, not a + // detached copy handed back to the caller. + const stored = projectDependencies.value.get(path.resolve(projectPath)); + assert.strictEqual(stored, refreshed, 'the store holds the object refreshTracked returned'); + assert.deepEqual( + stored?.nugetPackages.map((pkg) => pkg.name), + ['Polly', 'Serilog'], + 'sorted, with both packages', + ); + + // Interaction 3 - refreshing when NOTHING changed publishes nothing. A + // store that republishes on every poll makes every reactive consumer + // rebuild on a timer ([VSCODE-REACTIVITY-SPEC]). + const quiet: number[] = []; + const disposeQuiet = effect(() => { + quiet.push(projectDependencies.value.size); + }); + assert.deepEqual(quiet, [1], 'the effect ran once for the current state'); + const again = refreshTracked(projectPath); + disposeQuiet(); + assert.deepEqual(quiet, [1], 'an unchanged project publishes no new map'); + assert.strictEqual( + again?.nugetPackages.length, + 2, + 'while still reporting the current dependency set', + ); }); test('refreshTracked returns undefined for a project that was never tracked', () => { @@ -825,6 +1596,25 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { !projectDependencies.value.has(path.resolve(projectPath)), 'and they are not silently added to the map', ); + + // Interaction 2 - refreshing something untracked publishes nothing, so no + // reactive consumer wakes up for a project nobody asked about. + const runs: number[] = []; + const dispose = effect(() => { + runs.push(projectDependencies.value.size); + }); + refreshTracked(projectPath); + refreshTracked(path.join(tmpDir, 'never-existed.csproj')); + dispose(); + assert.deepEqual(runs, [0], 'no map is published for an untracked refresh'); + + // Interaction 3 - tracking it explicitly makes the SAME path refreshable, + // so the guard is about tracking state and not about the path itself. + ensureTracked(projectPath); + assert.ok(projectDependencies.value.has(path.resolve(projectPath)), 'now tracked'); + const nowRefreshed = refreshTracked(projectPath); + assert.ok(nowRefreshed, 'and now refreshable'); + assert.deepEqual(nowRefreshed.nugetPackages, [], 'reporting its (empty) dependency set'); }); test('refreshTracked drops a tracked project once its file disappears', () => { @@ -840,6 +1630,26 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { assert.strictEqual(result, undefined, 'a deleted project yields undefined'); assert.ok(!projectDependencies.value.has(absolute), 'and is removed from the signal map'); + + // Interaction 2 - the drop is PUBLISHED. A tree that keeps rendering a + // deleted project offers build and debug actions against a missing file. + assert.strictEqual(projectDependencies.value.size, 0, 'the map is now empty'); + const runs: number[] = []; + const dispose = effect(() => { + runs.push(projectDependencies.value.size); + }); + assert.deepEqual(runs, [0], 'a fresh observer sees the project already gone'); + + // Interaction 3 - a project that comes BACK (a branch switch, an undo) is + // not resurrected by a refresh: it was dropped, so it must be tracked + // again explicitly. Silent resurrection is how stale rows reappear. + writeProjectFile(tmpDir, 'Vanishing', { packages: [{ id: 'Serilog', version: '3.1.0' }] }); + assert.strictEqual(refreshTracked(projectPath), undefined, 'a dropped project stays dropped'); + dispose(); + assert.deepEqual(runs, [0], 'and nothing was published by the attempt'); + const retracked = ensureTracked(projectPath); + assert.strictEqual(retracked.nugetPackages.length, 1, 'tracking it again reads it fresh'); + assert.ok(projectDependencies.value.has(absolute), 'and puts it back in the map'); }); test('rescanAll re-parses every tracked project from disk in one publish', () => { @@ -873,6 +1683,34 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { 0, 'RescanB reflects its now-empty package set', ); + + // Interaction 2 - ONE publish for the whole rescan. A per-project publish + // makes every reactive consumer rebuild once per project in the solution, + // which is the difference between a snappy tree and a frozen one on a + // hundred-project repository ([VSCODE-REACTIVITY-SPEC]). + const runs: number[] = []; + const dispose = effect(() => { + runs.push(projectDependencies.value.size); + }); + assert.deepEqual(runs, [2], 'the observer starts from the rescanned state'); + writeProjectFile(tmpDir, 'RescanA', { packages: [{ id: 'A', version: '3.0.0' }] }); + writeProjectFile(tmpDir, 'RescanB', { packages: [{ id: 'B', version: '3.0.0' }] }); + rescanAll(); + dispose(); + assert.deepEqual(runs, [2, 2], 'two changed projects, exactly one republish'); + + // Interaction 3 - the rescan really re-read BOTH files from disk. + assert.strictEqual( + projectDependencies.value.get(path.resolve(a))?.nugetPackages[0]?.version, + '3.0.0', + 'RescanA picked up its new version', + ); + assert.strictEqual( + projectDependencies.value.get(path.resolve(b))?.nugetPackages[0]?.name, + 'B', + 'and RescanB got its package back', + ); + assert.strictEqual(projectDependencies.value.size, 2, 'with no project lost or duplicated'); }); test('resetForTests clears the signal map back to empty', () => { @@ -884,5 +1722,40 @@ suite('Project Deps Store — reactive tracking (e2e)', () => { resetForTests(); assert.strictEqual(projectDependencies.value.size, 0, 'the store is empty after resetForTests'); + assert.deepEqual([...projectDependencies.value.keys()], [], 'with no key left behind'); + + // Interaction 2 - the reset is PUBLISHED, so a view bound to the store + // empties with it instead of rendering rows that no longer exist. + ensureTracked( + writeProjectFile(tmpDir, 'Second', { packages: [{ id: 'Y', version: '1.0.0' }] }), + ); + const runs: number[] = []; + const dispose = effect(() => { + runs.push(projectDependencies.value.size); + }); + assert.deepEqual(runs, [1], 'the observer starts from the tracked state'); + resetForTests(); + dispose(); + assert.deepEqual(runs, [1, 0], 'and observes the store emptying'); + + // Interaction 3 - the store is USABLE afterwards. A reset that leaves it + // inert would make every test after the first one prove nothing. + const revived = ensureTracked( + writeProjectFile(tmpDir, 'Revived', { packages: [{ id: 'Z', version: '2.0.0' }] }), + ); + assert.strictEqual(revived.nugetPackages[0]?.name, 'Z', 'tracking works after a reset'); + assert.strictEqual(projectDependencies.value.size, 1, 'and the map grows again'); + assert.strictEqual(revived.nugetPackages[0]?.version, '2.0.0', 'with the version on disk'); + + // Interaction 4 - a reset drops EVERY project, not just the last one, and + // resetting twice is a harmless no-op rather than a second publish. + ensureTracked(writeProjectFile(tmpDir, 'AlsoLeftover')); + assert.strictEqual(projectDependencies.value.size, 2, 'two projects tracked'); + resetForTests(); + assert.strictEqual(projectDependencies.value.size, 0, 'both dropped by one reset'); + const emptyMap = projectDependencies.value; + resetForTests(); + assert.strictEqual(projectDependencies.value.size, 0, 'still empty after a second reset'); + assert.strictEqual(projectDependencies.value, emptyMap, 'and no redundant map was published'); }); }); diff --git a/src/editors/vscode/src/test/suite/profiler-e2e.test.ts b/src/editors/vscode/src/test/suite/profiler-e2e.test.ts index 9ece1b10..90d488df 100644 --- a/src/editors/vscode/src/test/suite/profiler-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/profiler-e2e.test.ts @@ -327,6 +327,36 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { // No-arg invocation is a safe no-op (no pid → early return, clipboard intact). await vscode.commands.executeCommand('sharplsp.profiler.copyPid'); assert.strictEqual(await vscode.env.clipboard.readText(), '778899'); + assert.strictEqual( + stubs.log.infoMessages.length, + 1, + 'a no-op copy shows no second toast — one copy, one confirmation', + ); + + // Interaction 2 — the clipboard carries the PID ALONE. A user pastes it + // straight into `dotnet-trace collect -p`, so a label, a name or trailing + // whitespace makes the paste fail with a parse error + // ([PROFILER-PROCESS-LIST]). + assert.strictEqual(clip, clip.trim(), 'the clipboard text is not padded'); + assert.strictEqual(clip.includes('WebApi'), false, 'and carries no process name'); + assert.strictEqual(clip.includes('PID'), false, 'and no label'); + assert.strictEqual(Number(clip), 778899, 'so it parses back as the number it came from'); + + // Interaction 3 — a SECOND process copies its own PID over the first. A + // command that caches the first node it saw copies the wrong process for + // the rest of the session. + const other = buildProcessNode(proc({ pid: 112233, name: 'Worker', command_line: 'w.dll' })); + assert.strictEqual(other.processPid, 112233, 'the second node carries its own pid'); + await vscode.commands.executeCommand('sharplsp.profiler.copyPid', other); + assert.strictEqual( + await vscode.env.clipboard.readText(), + '112233', + 'the clipboard follows the node that was clicked', + ); + assert.ok( + stubs.log.infoMessages.some((message) => message.includes('112233')), + 'and the toast names the second pid too', + ); }); // ─────────────────────────────────────────────────────────────── @@ -362,6 +392,34 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { stubs.log.infoMessages.some((m) => m.includes('no output file')), 'missing output path surfaces the "no output file yet" message', ); + + // Interaction 3 — "no output yet" is INFORMATION, not an error. A trace + // that has not flushed its file is the normal state of a running session + // ([PROFILER-SESSIONS-LIFECYCLE]: Created -> Running -> Stopped), so a red + // toast for it trains the user to ignore red toasts. + assert.deepEqual(stubs.log.errorMessages, [], 'a pending output file is not an error'); + assert.deepEqual(stubs.log.warningMessages, [], 'nor a warning'); + assert.strictEqual(stubs.log.infoMessages.length, 2, 'one toast per invocation, no more'); + + // Interaction 4 — the copied path is the session's OWN output, verbatim. + // A path rewritten on the way to the clipboard cannot be opened, and + // [PROFILER-TRACE-CONVERSION] makes the sibling name load-bearing. + assert.strictEqual(withPath.outputPath, tracePath, 'the node still carries its own path'); + assert.ok(tracePath.endsWith('.nettrace'), 'the fixture really is a trace file'); + assert.strictEqual( + (await vscode.env.clipboard.readText()).trim(), + tracePath, + 'and the clipboard holds it untrimmed and unrewritten', + ); + + // Interaction 5 — a no-arg invocation copies nothing at all, so a command + // run from the palette with no selection cannot clobber the clipboard. + await vscode.commands.executeCommand('sharplsp.profiler.copyOutputPath'); + assert.strictEqual( + await vscode.env.clipboard.readText(), + tracePath, + 'a no-arg copy leaves the clipboard untouched', + ); }); // ─────────────────────────────────────────────────────────────── @@ -391,6 +449,16 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { ); stubs.restore(); + // Interaction 2 - [PROFILER-PROCESS-LIST] terminates a process the user + // does not own the lifetime of. The confirmation must therefore be MODAL + // and offer exactly the destructive verb: a non-modal toast can be swept + // away by the next notification before it is read, and an extra button is + // one more thing to mis-click. + assert.strictEqual(stubs.log.warningOptions[0]?.modal, true, 'the kill prompt is modal'); + assert.deepEqual(stubs.log.warningActions[0], ['Kill'], "offering only 'Kill'"); + assert.deepEqual(stubs.log.errorMessages, [], 'and a dismissed prompt is not an error'); + assert.deepEqual(stubs.log.infoMessages, [], 'nor does it report a termination'); + // (b) Confirm "Kill": the real command sends the LSP request for a PID that // does not exist. It must not throw; either an error toast or a refresh // follows. The session count is unaffected by a kill. @@ -475,6 +543,36 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { // No-arg invocation is a safe no-op. await vscode.commands.executeCommand('sharplsp.profiler.showCountersPanel'); assert.strictEqual(panelSpy.created.length, 1); + + // Interaction 2 — the panel is TITLED for its session. [PROFILER-EDITOR-VSCODE] + // lists one webview per counter session; two identically-titled tabs leave + // the user unable to tell which process they are watching. + const title = panelSpy.titles[0] ?? ''; + assert.ok(title.length > 0, 'the counters panel carries a title'); + assert.ok( + title.includes('9090') || title.includes('PanelProc') || /counter/i.test(title), + `the title identifies the session; got '${title}'`, + ); + assert.strictEqual(panelSpy.titles.length, 1, 'and there is exactly one panel title'); + + // Interaction 3 — a SECOND session gets its OWN panel. Re-use is keyed by + // session id, not by "a counters panel exists"; sharing one panel across + // sessions overwrites the first process's live counters with the second's. + addTracked(provider, 'cnt-panel-2', 'Counters', 9091, undefined, 'OtherProc'); + const second = buildSessionNode(provider.findSession('cnt-panel-2')!); + assert.notStrictEqual(second.sessionId, node.sessionId, 'the two nodes are different sessions'); + await vscode.commands.executeCommand('sharplsp.profiler.showCountersPanel', second); + assert.strictEqual(panelSpy.created.length, 2, 'a second session opens a second panel'); + assert.strictEqual( + new Set(panelSpy.titles).size, + 2, + `the two panels are distinctly titled; got: ${panelSpy.titles.join(' | ')}`, + ); + + // Interaction 4 — and re-revealing the FIRST session still re-uses its own + // panel rather than the one that was opened most recently. + await vscode.commands.executeCommand('sharplsp.profiler.showCountersPanel', node); + assert.strictEqual(panelSpy.created.length, 2, 'no third panel for a session already shown'); }); // ─────────────────────────────────────────────────────────────── @@ -509,6 +607,46 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { stubs.log.infoMessages.some((m) => m.includes('No active Counters sessions')), 'stopCounters with none active shows "No active Counters sessions"', ); + + // Interaction 2 — the two messages name their OWN session kind. A shared + // "no active sessions" string leaves the user unable to tell whether the + // trace they started is still running ([PROFILER-SESSIONS-LIFECYCLE]). + assert.strictEqual(stubs.log.infoMessages.length, 2, 'one message per command'); + assert.strictEqual( + new Set(stubs.log.infoMessages).size, + 2, + `the two messages differ; got: ${stubs.log.infoMessages.join(' | ')}`, + ); + assert.deepEqual(stubs.log.errorMessages, [], 'having nothing to stop is not an error'); + + // Interaction 3 — nothing was picked and nothing was started. A stop with + // no candidates must not fall back to a picker the user then has to + // dismiss, and must not leave a phantom session behind. + assert.deepEqual( + stubs.log.quickPickItems, + [], + 'no picker is shown when there is nothing to stop', + ); + assert.strictEqual(provider.getActiveSessions('Trace').length, 0, 'still no trace sessions'); + assert.strictEqual( + provider.getActiveSessions('Counters').length, + 0, + 'still no counter sessions', + ); + + // Interaction 4 — with a session PRESENT the same command reaches the + // picker instead, so the message above is a guard and not a dead command. + addTracked(provider, 'stop-guard-1', 'Trace', 5150, undefined, 'GuardProc'); + assert.strictEqual(provider.getActiveSessions('Trace').length, 1, 'one trace session now'); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.profiler.stopTrace'); + }, 'stopTrace with a live session must not throw'); + assert.strictEqual( + stubs.log.infoMessages.filter((m) => m.includes('No active Trace sessions')).length, + 1, + 'and must not repeat the "nothing to stop" message when there IS something', + ); + provider.removeSession('stop-guard-1'); }); // ─────────────────────────────────────────────────────────────── @@ -518,7 +656,12 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { // ─────────────────────────────────────────────────────────────── test('per-process commands are safe no-ops without a PID and do not throw with one', async function () { - this.timeout(COMMAND_MS); + // Every other test in this suite drives ONE command, which is what `COMMAND_MS` + // budgets. This one drives five: three no-arg early-return commands, then + // traceProcess and countersProcess against a PID that does not exist - and + // those two reach the live LSP host and wait for it to answer that the process + // is gone. Five round trips cost five round trips, so it declares them. + this.timeout(5 * COMMAND_MS); stubs = installUiStubs(); const provider = getProvider(); @@ -555,6 +698,34 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { 'no phantom trace session for a non-existent PID', ); assert.ok(provider.sessionCount >= sessionsBefore); + + // Interaction 4 — [PROFILER-EDITOR-VSCODE-TREE] requires a process row to + // be profilable FROM the row. That means the node carries the command, the + // pid it acts on, and the context value its menu is scoped by — a node + // missing any of them renders a row with no actions. + assert.strictEqual(node.processPid, 999999, 'the node carries the pid it will act on'); + assert.strictEqual(node.contextValue, 'profiler-process', 'and the menu scope'); + assert.strictEqual(node.nodeKind, 'process', 'and identifies itself as a process row'); + assert.ok(String(node.label).includes('Ghost'), `and names the process: ${String(node.label)}`); + + // Interaction 5 — a dead PID produces no counters session either, and the + // failures are REPORTED rather than swallowed: [PROFILER-SESSIONS-LIFECYCLE] + // has a Failed state precisely so the tree does not show a phantom Running. + assert.strictEqual( + provider.getActiveSessions('Counters').filter((s) => s.pid === 999999).length, + 0, + 'no phantom counters session for a non-existent PID', + ); + assert.strictEqual( + provider.getActiveSessions('Trace').some((s) => s.pid === 999999), + false, + 'and none among the trace sessions', + ); + assert.deepEqual( + stubs.log.quickPickItems, + [], + 'a per-process command acts on ITS row and never falls back to a picker', + ); }); // ─────────────────────────────────────────────────────────────── @@ -586,6 +757,30 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { typeof first.label === 'string' || typeof first.label === 'object', 'first node has a label', ); + + // Interaction 2 — every row is well formed. A row with no label renders + // blank; one with no collapsible state renders an arrow that expands to + // nothing ([PROFILER-EDITOR-VSCODE-TREE]). + for (const item of nodes) { + assert.notStrictEqual(item.label, undefined, 'every tree row carries a label'); + assert.notStrictEqual(item.collapsibleState, undefined, 'and a collapsible state'); + assert.strictEqual( + item.collapsibleState, + vscode.TreeItemCollapsibleState.None, + 'and the profiler tree is flat, so no row claims children', + ); + } + + // Interaction 3 — refreshing is REPEATABLE and idempotent in shape. A + // refresh that duplicates rows turns a busy machine's process list into a + // list of the same process ten times over. + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.profiler.refresh'); + }, 'a second refresh must not throw'); + const after = provider.getChildren(); + assert.ok(after.length >= 1, 'the tree still renders after a second refresh'); + const labels = after.map((item) => String(item.label)); + assert.deepEqual([...new Set(labels)], labels, 'and renders no row twice'); }); // ─────────────────────────────────────────────────────────────── @@ -705,6 +900,36 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { if (stubs.log.openDialogOptions.length > 0) { assert.strictEqual(stubs.log.openDialogOptions[0]?.title, 'Convert .nettrace File'); } + + // Interaction 2 — a cancelled dialog is SILENT. The user pressed Escape; + // an error toast for that is noise, and a success toast is a lie about a + // conversion that never ran ([PROFILER-TRACE-CONVERSION]). + assert.deepEqual(stubs.log.errorMessages, [], 'cancelling the picker is not an error'); + assert.deepEqual(stubs.log.infoMessages, [], 'and reports no conversion'); + assert.deepEqual(stubs.log.warningMessages, [], 'and no warning'); + assert.ok(stubs.log.openDialogOptions.length <= 1, 'at most one dialog was shown'); + + // Interaction 3 — the dialog it WOULD have shown is scoped to trace files. + // `convertTrace` takes "any trace file on disk", so an unfiltered picker + // lets the user choose a .json and get a conversion error instead of a + // greyed-out entry. + const dialog = stubs.log.openDialogOptions[0]; + if (dialog) { + assert.strictEqual(dialog.canSelectMany, false, 'one input file, not many'); + assert.strictEqual(dialog.canSelectFolders !== true, true, 'a file, never a folder'); + assert.ok(dialog.filters, 'and the picker filters by extension'); + assert.ok( + JSON.stringify(dialog.filters).includes('nettrace'), + `filtering to .nettrace; got ${JSON.stringify(dialog.filters)}`, + ); + } + + // Interaction 4 — cancelling twice is still a no-op, so a user who + // dismisses the picker can simply run the command again. + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.profiler.convertTrace'); + }, 'a second cancelled conversion must not throw'); + assert.deepEqual(stubs.log.errorMessages, [], 'and still reports nothing'); }); // ─────────────────────────────────────────────────────────────── @@ -727,6 +952,30 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { await assert.doesNotReject(async () => { await vscode.commands.executeCommand('sharplsp.profiler.revealOutput'); }); + assert.deepEqual(stubs.log.errorMessages, [], 'and neither path is an error'); + + // Interaction 2 — the node really has no path, so the message above is + // about the session state and not about a node the test built wrong. + assert.strictEqual(noPath.outputPath, undefined, 'the session carries no output path'); + assert.strictEqual(noPath.nodeKind, 'session', 'and it is a session row'); + assert.strictEqual(stubs.log.infoMessages.length, 1, 'one message, from the one node'); + + // Interaction 3 — a session that HAS produced a file behaves differently. + // A command that shows "no output file yet" whatever it is given is a + // command that never reveals anything ([PROFILER-EDITOR-VSCODE]). + const revealPath = path.join(dumpDir, 'revealed.nettrace'); + fs.writeFileSync(revealPath, 'FAKE-TRACE', 'utf8'); + const withPath = buildSessionNode(session({ outputPath: revealPath, id: 'rv-2' })); + assert.strictEqual(withPath.outputPath, revealPath, 'the second node carries its path'); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('sharplsp.profiler.revealOutput', withPath); + }, 'revealing a real file must not throw'); + assert.strictEqual( + stubs.log.infoMessages.filter((m) => m.includes('no output file')).length, + 1, + 'and must NOT repeat the "no output file yet" message for a file that exists', + ); + assert.ok(fs.existsSync(revealPath), 'the file it revealed is still on disk'); }); // ─────────────────────────────────────────────────────────────── @@ -1009,6 +1258,33 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { assert.ok(html.includes('Error: graph boom'), 'error message surfaced'); assert.ok(!html.includes('<pre>'), 'error page does not use the summary layout'); assert.ok(!html.includes('Nodes:'), 'error page renders no stats line'); + + // Interaction 2 — the error page is a PAGE. A bare error string with no + // document around it renders as unstyled text in the webview, which reads + // as a rendering failure rather than as a reported error. + assert.ok(html.length > 0, 'the panel really has content'); + assert.ok( + /<html|<body|<div/i.test(html), + `the error page is real markup: ${html.slice(0, 120)}`, + ); + assert.strictEqual(html.includes('undefined'), false, 'and leaks no undefined into the text'); + + // Interaction 3 — [PROFILER-GRAPH] renders a retention graph; a failed + // request must render NONE of it. A page that shows an error banner above + // an empty graph invites the user to interpret the emptiness as a result. + for (const artefact of ['Max depth', 'depth=0', 'Edges:', 'Root:']) { + assert.strictEqual( + html.includes(artefact), + false, + `the error page must not render '${artefact}' from a request that failed`, + ); + } + + // Interaction 4 — the panel is still titled by the address that was asked + // for, so the user can tell WHICH inspection failed when several are open. + assert.strictEqual(panelSpy.titles.length, 1, 'one title for the one panel'); + const errorTitles: readonly string[] = panelSpy.titles; + assert.ok(errorTitles[0]?.includes('addr-123'), 'naming the address that failed'); }); // ─────────────────────────────────────────────────────────────── @@ -1111,6 +1387,36 @@ suite('Profiler — command bodies, webviews & workflows (e2e)', () => { bar.update(1); }); assert.strictEqual(item.text, '$(pulse) 1 profiling'); + + // Interaction 2 — the count is SINGULAR-agnostic but always present, and + // it is the count the caller passed. [PROFILER-EDITOR-VSCODE] lists the + // status bar as "show active profiling session count", so a stale number + // is worse than a hidden bar: it says work is running when it is not. + bar.update(12); + const twoDigits: string = item.text; + assert.strictEqual(twoDigits, '$(pulse) 12 profiling', 'a two-digit count renders in full'); + assert.ok(twoDigits.includes('$(pulse)'), 'and keeps the codicon that identifies it'); + bar.update(1); + const oneAgain: string = item.text; + assert.strictEqual(oneAgain, '$(pulse) 1 profiling', 'and drops back to one'); + + // Interaction 3 — the item stays CLICKABLE across every transition. A + // status bar whose command is cleared on hide becomes decorative the + // first time the session count reaches zero. + bar.update(0); + assert.strictEqual( + item.command, + 'sharplsp.profiler.listProcesses', + 'the click target survives being hidden', + ); + bar.update(4); + assert.strictEqual(item.command, 'sharplsp.profiler.listProcesses', 'and being shown again'); + const fourNow: string = item.text; + assert.strictEqual(fourNow, '$(pulse) 4 profiling', 'with the new count'); + + // Interaction 4 — one item, registered once. A status bar that registers + // per update leaks an item into the bar on every session change. + assert.strictEqual(ctx.subscriptions.length, 1, 'still exactly one registered disposable'); } finally { for (const d of ctx.subscriptions) d.dispose(); } diff --git a/src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts b/src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts index 3b3aa7d8..7ce62dbc 100644 --- a/src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/project-deps-watcher-e2e.test.ts @@ -18,7 +18,7 @@ import { projectDependencies, resetForTests, } from '../../project-deps-store.js'; -import { COMMAND_MS } from './test-timeouts'; +import { COMMAND_MS, SETTLE_MS } from './test-timeouts'; const CSPROJ = `<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup> @@ -66,8 +66,44 @@ suite('Project-deps node watcher survives project dir deletion', () => { // raises it as an uncaught exception, which mocha attributes to this test. removeDirRecursive(dir); - const removed = await pollUntil(() => !projectDependencies.value.has(projectPath), COMMAND_MS); + const removed = await pollUntil(() => !projectDependencies.value.has(projectPath), SETTLE_MS); assert.ok(removed, 'deleted project must be dropped from projectDependencies'); assert.ok(!fs.existsSync(projectPath), 'fixture tree really was deleted'); + assert.ok(!fs.existsSync(dir), 'and so was the directory that held it'); + + // Interaction 2 — the drop is COMPLETE. A key left behind with an + // undefined value is a tree row that renders blank and offers build and + // debug actions against a file that no longer exists. + assert.strictEqual( + projectDependencies.value.has(projectPath), + false, + 'no key remains for the deleted project', + ); + assert.strictEqual( + [...projectDependencies.value.keys()].some((key) => key.includes('Deleted.csproj')), + false, + 'and none under any spelling of its path', + ); + + // Interaction 3 — the HOST survives. Deleting a watched tree fires an + // async EPERM on Windows; without an error listener Node raises it as an + // uncaught exception and takes the extension host with it. Proving the + // store still works after the deletion is the only way to see that. + const survivor = path.join(os.tmpdir(), 'sharplsp-watch-survivor'); + fs.mkdirSync(survivor, { recursive: true }); + const survivorPath = path.join(survivor, 'Survivor.csproj'); + fs.writeFileSync(survivorPath, CSPROJ); + try { + const stillWorks = ensureTracked(survivorPath); + assert.strictEqual( + stillWorks.nugetPackages.length, + 1, + 'the store still parses a project after a watched tree vanished', + ); + assert.ok(projectDependencies.value.has(survivorPath), 'and still tracks it'); + assert.strictEqual(projectDependencies.value.size, 1, 'holding only the survivor'); + } finally { + removeDirRecursive(survivor); + } }); }); diff --git a/src/editors/vscode/src/test/suite/run-debug-kit.ts b/src/editors/vscode/src/test/suite/run-debug-kit.ts index 287174ea..ca2282c0 100644 --- a/src/editors/vscode/src/test/suite/run-debug-kit.ts +++ b/src/editors/vscode/src/test/suite/run-debug-kit.ts @@ -198,6 +198,8 @@ export class DebugSessionRecorder { }), vscode.debug.onDidTerminateDebugSession((session) => { this.terminatedIds.push(session.id); + const live = this.liveSessions.findIndex((known) => known.id === session.id); + if (live >= 0) this.liveSessions.splice(live, 1); }), ); } diff --git a/src/editors/vscode/src/test/suite/solution-explorer.test.ts b/src/editors/vscode/src/test/suite/solution-explorer.test.ts index 6e78da2d..27090b3c 100644 --- a/src/editors/vscode/src/test/suite/solution-explorer.test.ts +++ b/src/editors/vscode/src/test/suite/solution-explorer.test.ts @@ -5,18 +5,51 @@ import * as vscode from 'vscode'; import { EXTENSION_ID, closeAllEditors, + flattenSymbolNames, openCSharpFile, openSharpLspPanel, pollUntilResult, replaceDocumentContent, setupLspTestSuite, + settleForScreenshot, takeScreenshot, teardownLspTestSuite, waitForDocumentSymbols, } from './test-helpers'; import { toSolutionSelections } from '../../solution'; +import { assertReachableCommand, commandEntries } from './extension-manifest-kit'; +import { assertSymbolShape, assertSymbolTree } from './lsp-invariants-kit'; import { ACTIVATION_MS, COMMAND_MS, LSP_RESPONSE_MS } from './test-timeouts'; +/** The three sort modes of [SE-SORT], in the order the toolbar cycles them. */ +const SORT_COMMANDS = [ + 'sharplsp.sortNatural', + 'sharplsp.sortAlphabetical', + 'sharplsp.sortAccessibility', +] as const; + +/** The `when` clause [SE-SORT-CONTEXT] gives each sort command's toolbar icon. */ +const SORT_WHEN: Record<string, string> = { + 'sharplsp.sortNatural': 'natural', + 'sharplsp.sortAlphabetical': 'alphabetical', + 'sharplsp.sortAccessibility': 'accessibility', +}; + +/** Whether the extension is still activated — a refresh must not unload it. */ +function sharpLspIsActive(): boolean { + return vscode.extensions.getExtension(EXTENSION_ID)?.isActive === true; +} + +/** Every `view/title` menu entry the manifest contributes, as authored. */ +function viewTitleMenu(): { command?: string; when?: string; group?: string }[] { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, 'the extension must be installed'); + const menus: unknown = extension.packageJSON.contributes?.menus; + const entries: unknown = (menus as Record<string, unknown> | undefined)?.['view/title']; + assert.ok(Array.isArray(entries), 'contributes.menus must declare a view/title group'); + return entries as { command?: string; when?: string; group?: string }[]; +} + suite('Solution Explorer & Workspace Symbols', () => { let tmpDir: string; @@ -38,41 +71,157 @@ suite('Solution Explorer & Workspace Symbols', () => { // ── Command Registration ───────────────────────────────────── test('sharplsp.selectSolution command is registered', async () => { + // Interaction 1 - reachable: registered, declared once, titled, categorised. const allCommands = await vscode.commands.getCommands(true); assert.ok( allCommands.includes('sharplsp.selectSolution'), 'sharplsp.selectSolution should be registered', ); + const entry = assertReachableCommand('sharplsp.selectSolution', allCommands); + + // Interaction 2 - [SE-COMMANDS] gives it a title and a toolbar icon. A + // command with no icon cannot appear in the view's title bar at all, which + // is the only place a user goes looking for "open a different solution". + assert.strictEqual(entry.title, 'Select Solution', 'the spec fixes the title'); + const icon: unknown = (entry as { icon?: unknown }).icon; + assert.strictEqual(icon, '$(folder-opened)', 'and the folder-opened codicon'); + assert.strictEqual(entry.category, 'SharpLsp', 'under the SharpLsp category'); + + // Interaction 3 - it is placed in the Solution Explorer's title bar, and + // unconditionally: [SE-COMMANDS] marks it "Always". + const placements = viewTitleMenu().filter((item) => item.command === 'sharplsp.selectSolution'); + assert.strictEqual(placements.length, 1, 'placed in view/title exactly once'); + assert.ok( + (placements[0]?.when ?? '').includes('sharplsp.solutionExplorer'), + `scoped to the Solution Explorer view; got: ${String(placements[0]?.when)}`, + ); + assert.strictEqual( + (placements[0]?.when ?? '').includes('sortOrder'), + false, + 'and never gated on the sort mode - it is always available', + ); }); test('sharplsp.refreshExplorer command is registered', async () => { + // Interaction 1 - reachable from the palette and the manifest alike. const allCommands = await vscode.commands.getCommands(true); assert.ok( allCommands.includes('sharplsp.refreshExplorer'), 'sharplsp.refreshExplorer should be registered', ); + const entry = assertReachableCommand('sharplsp.refreshExplorer', allCommands); + + // Interaction 2 - [SE-COMMANDS]: "Refresh Explorer", with the refresh + // codicon. Refresh is the user's manual escape hatch when reactivity has + // not caught up, so it has to be visible without opening the palette. + assert.strictEqual(entry.title, 'Refresh Explorer', 'the spec fixes the title'); + assert.strictEqual((entry as { icon?: unknown }).icon, '$(refresh)', 'and the refresh codicon'); + assert.strictEqual(entry.category, 'SharpLsp', 'under the SharpLsp category'); + + // Interaction 3 - it sits in the view title bar, always, next to Select + // Solution rather than behind a sort-mode condition. + const placements = viewTitleMenu().filter( + (item) => item.command === 'sharplsp.refreshExplorer', + ); + assert.strictEqual(placements.length, 1, 'placed in view/title exactly once'); + assert.ok( + (placements[0]?.when ?? '').includes('sharplsp.solutionExplorer'), + 'scoped to the Solution Explorer view', + ); + assert.strictEqual( + (placements[0]?.when ?? '').includes('sortOrder'), + false, + 'and is always available', + ); }); - for (const cmd of [ - 'sharplsp.sortNatural', - 'sharplsp.sortAlphabetical', - 'sharplsp.sortAccessibility', - ]) { - test(`${cmd} command is registered`, async () => { + for (const cmd of SORT_COMMANDS) { + test(`${cmd} command is registered`, async function () { + this.timeout(COMMAND_MS); + // Interaction 1 - reachable, and reachable exactly once. const allCommands = await vscode.commands.getCommands(true); assert.ok(allCommands.includes(cmd), `${cmd} should be registered`); + const entry = assertReachableCommand(cmd, allCommands); + assert.ok((entry.title ?? '').startsWith('Sort'), `${cmd} is titled as a sort mode`); + + // Interaction 2 - [SE-SORT-CONTEXT]: each sort command's toolbar icon is + // gated on the CURRENT mode, so exactly one of the three is ever visible. + // Without the `when` clause all three icons stack in the title bar. + const placements = viewTitleMenu().filter((item) => item.command === cmd); + assert.strictEqual(placements.length, 1, `${cmd} is placed in view/title once`); + const when = placements[0]?.when ?? ''; + assert.ok(when.includes('sharplsp.sortOrder'), `${cmd} is gated on the sort-order key`); + assert.ok( + when.includes(SORT_WHEN[cmd] ?? ''), + `${cmd} is shown for the '${String(SORT_WHEN[cmd])}' mode; got: ${when}`, + ); + + // Interaction 3 - all three commands CYCLE, so running this one must not + // throw whichever mode the tree happens to be in. + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(cmd); + }, `${cmd} must cycle the sort order without throwing`); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(cmd); + }, `${cmd} must stay runnable after it has already cycled once`); + + // Interaction 4 - the three modes are DISTINCT commands with distinct + // icons: a shared icon makes the toolbar unable to show the active mode. + const icons = SORT_COMMANDS.map( + (id) => + (commandEntries().find((c) => c.command === id) as { icon?: string } | undefined)?.icon, + ); + assert.strictEqual(new Set(icons).size, 3, `three distinct icons; got ${icons.join(', ')}`); + assert.ok( + icons.every((value) => typeof value === 'string' && value.length > 0), + 'all set', + ); }); } // ── Package Contributions ──────────────────────────────────── - test('extension contributes sharplsp-explorer view container', () => { + test('extension contributes sharplsp-explorer view container', async function () { + this.timeout(COMMAND_MS); const ext = vscode.extensions.getExtension(EXTENSION_ID); assert.ok(ext, 'Extension should exist'); const containers = ext.packageJSON.contributes?.viewsContainers?.activitybar ?? []; const container = containers.find((c: { id: string }) => c.id === 'sharplsp-explorer'); assert.ok(container, 'Should contribute sharplsp-explorer view container'); assert.strictEqual(container.title, 'SharpLsp'); + + // Interaction 2 - the container carries an activity-bar icon. A container + // with no icon has no clickable target in the activity bar, so the whole + // Solution Explorer becomes unreachable. + assert.ok(container.icon, 'the activity-bar container must declare an icon'); + assert.strictEqual(typeof container.icon, 'string', 'the icon is a path or a codicon'); + assert.strictEqual( + containers.filter((c: { id: string }) => c.id === 'sharplsp-explorer').length, + 1, + 'declared exactly once', + ); + + // Interaction 3 - it is the container `openSharpLspPanel` reveals, so the + // id in the manifest and the id the extension opens are the same string. + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand('workbench.view.extension.sharplsp-explorer'); + }, 'the container id must be openable as workbench.view.extension.<id>'); + const views: Record<string, unknown> = ext.packageJSON.contributes?.views ?? {}; + assert.ok( + Object.prototype.hasOwnProperty.call(views, 'sharplsp-explorer'), + 'and contributes.views must key its views by that same container id', + ); + + // Interaction 4 - the container's icon really SHIPS. A manifest icon path + // that resolves to nothing leaves a blank square in the activity bar, which + // is indistinguishable from the extension having failed to load. + if (typeof container.icon === 'string' && !container.icon.startsWith('$(')) { + const iconPath = path.join(ext.extensionPath, container.icon); + assert.ok(fs.existsSync(iconPath), `the container icon must ship at ${iconPath}`); + assert.ok(fs.statSync(iconPath).size > 0, 'and must not be an empty file'); + } + assert.strictEqual(container.title, 'SharpLsp', 'the activity-bar tooltip names the product'); + assert.ok(Array.isArray(containers), 'the activitybar contribution is an array'); }); test('extension contributes solutionExplorer view', () => { @@ -83,6 +232,31 @@ suite('Solution Explorer & Workspace Symbols', () => { const explorer = sharplspViews.find((v) => v.id === 'sharplsp.solutionExplorer'); assert.ok(explorer, 'Should contribute sharplsp.solutionExplorer view'); assert.strictEqual(explorer.name, 'Solution Explorer'); + + // Interaction 2 - declared once, and it is not the only view in the + // container: [PROFILER-SPEC] shares the SharpLsp activity bar with it. + assert.strictEqual( + sharplspViews.filter((v) => v.id === 'sharplsp.solutionExplorer').length, + 1, + 'the view is declared exactly once', + ); + assert.ok(sharplspViews.length >= 1, 'the container holds at least the explorer'); + assert.strictEqual( + new Set(sharplspViews.map((v) => v.id)).size, + sharplspViews.length, + 'and no two views in the container share an id', + ); + + // Interaction 3 - every command [SE-COMMANDS] places in the title bar is + // scoped to THIS view id. A `when` naming a different view puts the sort + // icons on someone else's toolbar. + const titled = viewTitleMenu().filter((item) => + (item.when ?? '').includes('sharplsp.solutionExplorer'), + ); + assert.ok(titled.length >= 5, `the five [SE-COMMANDS] entries; got ${titled.length}`); + for (const item of titled) { + assert.ok(item.command?.startsWith('sharplsp.'), `${String(item.command)} is ours`); + } }); // ── sharplsp/workspaceSymbols via Real LSP ────────────────────── @@ -244,7 +418,7 @@ EndGlobal`, await openSharpLspPanel(); // Refresh the tree view so the UI renders the loaded solution before screenshotting. await vscode.commands.executeCommand('sharplsp.refreshExplorer'); - await new Promise((r) => setTimeout(r, 2000)); + await settleForScreenshot(2000); await takeScreenshot('solution-explorer.png'); api.explorerProvider.getChildren; // keep reference @@ -331,6 +505,29 @@ EndGlobal`, const outerMethod = outerClass.children?.find((s) => s.name === 'OuterMethod'); assert.ok(outerMethod, 'Should find OuterMethod in OuterClass'); + + // Interaction 2 - [SE-SYMBOL-KINDS]: every level carries the kind its tree + // icon is drawn from. A class reported as a namespace draws the wrong icon + // at every depth of the tree. + const doc = await vscode.workspace.openTextDocument(uri); + assertSymbolShape(ns, vscode.SymbolKind.Namespace, doc); + assertSymbolShape(outerClass, vscode.SymbolKind.Class, doc); + assertSymbolShape(innerClass, vscode.SymbolKind.Class, doc); + assertSymbolShape(outerMethod, vscode.SymbolKind.Method, doc); + + // Interaction 3 - [SE-TREE]: the nesting is CONTAINMENT, all the way down. + // A flattened level lets the tree offer "Sort Members" on a node whose + // members live somewhere else. + assertSymbolTree(symbols, doc); + assert.ok(ns.range.contains(outerClass.range), 'Outer contains OuterClass'); + assert.ok(outerClass.range.contains(innerClass.range), 'OuterClass contains InnerClass'); + assert.ok(innerClass.range.contains(innerMethod.range), 'InnerClass contains InnerMethod'); + assert.strictEqual(symbols.length, 1, 'and the namespace is the only root'); + assert.deepEqual( + outerClass.children?.map((child) => child.name), + ['InnerClass', 'OuterMethod'], + 'OuterClass owns exactly its nested type and its method, in source order', + ); }); test('LSP handles interface with method declarations', async function () { @@ -365,6 +562,31 @@ EndGlobal`, const delegate = ns.children?.find((s) => s.name === 'OnSaved'); assert.ok(delegate, 'Should find OnSaved delegate'); assert.strictEqual(delegate.kind, vscode.SymbolKind.Function); + + // Interaction 2 - [SE-SYMBOL-KINDS] maps `delegate_declaration` to Function + // and `interface_declaration` to Interface. They are DIFFERENT rows in that + // table, so a tree that draws both as classes has lost the distinction the + // icon column exists to make. + assert.notStrictEqual(delegate.kind, repo.kind, 'a delegate is not an interface'); + assert.notStrictEqual(delegate.kind, vscode.SymbolKind.Class, 'nor a class'); + const doc = await vscode.workspace.openTextDocument(uri); + assertSymbolShape(repo, vscode.SymbolKind.Interface, doc); + assertSymbolShape(save, vscode.SymbolKind.Method, doc); + + // Interaction 3 - a delegate has no members, and the interface owns both of + // its methods in source order. + assertSymbolTree(symbols, doc); + assert.deepEqual(delegate.children ?? [], [], 'a delegate declares no members'); + assert.deepEqual( + repo.children?.map((child) => child.name), + ['Save', 'Delete'], + 'the interface owns both methods, in declaration order', + ); + assert.strictEqual( + ns.children?.length, + 2, + 'the namespace holds the interface and the delegate', + ); }); test('LSP returns correct hierarchy for file-scoped namespace', async function () { @@ -400,6 +622,27 @@ public class ApiController const post = controller.children?.find((s) => s.name === 'Post'); assert.ok(post, 'Should find Post method'); + + // Interaction 2 - [SE-TREE-FILE-NAMESPACE]: tree-sitter emits a + // file-scoped namespace WITHOUT nesting the types that follow it, and the + // host reparents them. The proof is containment, not mere membership: a + // reparented node whose range still sits outside its new parent breaks + // every range-based feature hung off the tree. + const doc = await vscode.workspace.openTextDocument(uri); + assertSymbolTree(symbols, doc); + assert.ok(ns.range.contains(controller.range), 'the namespace really contains the class'); + assert.ok(controller.range.contains(get.range), 'and the class contains its method'); + + // Interaction 3 - kinds, and exactly one root. Two roots means the + // reparenting only moved some of the types. + assertSymbolShape(controller, vscode.SymbolKind.Class, doc); + assertSymbolShape(get, vscode.SymbolKind.Method, doc); + assert.strictEqual(symbols.length, 1, 'the file-scoped namespace is the only root'); + assert.deepEqual( + controller.children?.map((child) => child.name), + ['Get', 'Post'], + 'the class owns both methods, in source order', + ); }); test('file-scoped namespace: multiple types all nested inside namespace', async function () { @@ -490,15 +733,90 @@ public record UserDto(string Name, int Age);`; const dto = ns.children?.find((s) => s.name === 'UserDto'); assert.ok(dto, 'UserDto must be INSIDE namespace'); + + // Interaction 2 - a BASE LIST must not confuse the reparenting. The base + // type name sits between the class name and its body, and a reader that + // stops at the first identifier reparents `ControllerBase` instead. + assert.strictEqual( + ns.children?.some((child) => child.name === 'ControllerBase'), + false, + 'the base type is a reference, not a declaration in this file', + ); + const doc = await vscode.workspace.openTextDocument(uri); + assertSymbolShape(controller, vscode.SymbolKind.Class, doc); + assertSymbolTree(symbols, doc); + + // Interaction 3 - the class keeps its own members, and the positional + // record beside it is a type rather than a member ([SE-SYMBOL-KINDS] maps + // `record_declaration` to Class). + assert.deepEqual( + controller.children?.map((child) => child.name), + ['Index', 'About'], + 'the derived class owns its own members, in source order', + ); + assert.ok( + [vscode.SymbolKind.Class, vscode.SymbolKind.Struct].includes(dto.kind), + `a record is a type kind, got ${vscode.SymbolKind[dto.kind]}`, + ); + assert.strictEqual(ns.children?.length, 2, 'the namespace holds the class and the record'); }); // ── sharplsp.refreshExplorer command ──────────────────────────── test('sharplsp.refreshExplorer executes without error', async function () { this.timeout(COMMAND_MS); + // Interaction 1 - refresh is the user's manual escape hatch when the + // reactive tree has not caught up. It must run with no solution loaded, + // which is the state a fresh window is in. await assert.doesNotReject(async () => { await vscode.commands.executeCommand('sharplsp.refreshExplorer'); }, 'refreshExplorer command should not throw'); + + // Interaction 2 - and it must be REPEATABLE. A refresh that only works + // once is a refresh the user cannot lean on. + for (let attempt = 0; attempt < 3; attempt += 1) { + await assert.doesNotReject( + async () => { + await vscode.commands.executeCommand('sharplsp.refreshExplorer'); + }, + `refresh attempt ${attempt + 1} must not throw`, + ); + } + + // Interaction 3 - it fires the tree's change event, which is the entire + // point: a refresh that mutates state without telling the view leaves the + // stale rows on screen ([SE-ARCHITECTURE]). + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext?.isActive, 'the extension must be active'); + const api = ext.exports as + { explorerProvider?: { onDidChangeTreeData: vscode.Event<unknown> } } | undefined; + assert.ok(api?.explorerProvider, 'the extension must export explorerProvider'); + let fired = 0; + const subscription = api.explorerProvider.onDidChangeTreeData(() => { + fired += 1; + }); + try { + await vscode.commands.executeCommand('sharplsp.refreshExplorer'); + const observed = await pollUntilResult( + async () => fired, + (count) => count > 0, + COMMAND_MS, + 50, + ); + assert.ok(observed > 0, 'refresh must notify the view that the tree changed'); + } finally { + subscription.dispose(); + } + + // Interaction 4 - and the command stays registered afterwards: refreshing + // must not dispose the thing that did the refreshing. + const palette = await vscode.commands.getCommands(true); + assert.ok(palette.includes('sharplsp.refreshExplorer'), 'still registered afterwards'); + assert.ok(palette.includes('sharplsp.selectSolution'), 'and so is Select Solution'); + for (const sortCommand of SORT_COMMANDS) { + assert.ok(palette.includes(sortCommand), `${sortCommand} survives a refresh too`); + } + assert.strictEqual(sharpLspIsActive(), true, 'and the extension is still active'); }); // ── Solution File Discovery ────────────────────────────────── @@ -521,31 +839,191 @@ public record UserDto(string Name, int Age);`; // We can't guarantee tmpDir is inside the workspace folder, // but we can verify the API works and returns results. assert.ok(Array.isArray(uris), 'findFiles should return an array'); + + // Interaction 2 - the glob accepts BOTH extensions. .slnx is the new + // XML-based solution format; a picker that only matches .sln cannot open a + // modern solution at all ([SE-SOLUTION]). + assert.ok(fs.existsSync(slnPath), 'the .sln fixture was written'); + assert.ok(fs.existsSync(slnxPath), 'and the .slnx fixture too'); + for (const candidate of [slnPath, slnxPath]) { + const selections = toSolutionSelections([candidate]); + assert.strictEqual(selections.length, 1, `${candidate} yields one selection`); + assert.strictEqual( + selections[0]?.path, + candidate, + 'and the selection keeps the absolute path the picker will open', + ); + } + + // Interaction 3 - every URI findFiles DOES return is a solution file, on + // the file scheme, with no duplicates. A picker offering the same solution + // twice cannot tell the user which one they chose. + const paths = uris.map((uri) => uri.fsPath); + assert.deepEqual([...new Set(paths)], paths, 'findFiles must not report a file twice'); + for (const uri of uris) { + assert.strictEqual(uri.scheme, 'file', `${uri.toString()} is a real file`); + assert.ok(/\.slnx?$/.test(uri.fsPath), `${uri.fsPath} matches the solution glob`); + } + + // Interaction 4 - the glob EXCLUDES node_modules, which a JavaScript-heavy + // repository is full of. A picker that offers a solution vendored inside a + // dependency loads someone else's workspace. + assert.strictEqual( + paths.some((candidate) => candidate.includes('node_modules')), + false, + 'no solution inside node_modules may be offered', + ); + assert.ok(uris.length >= 1, 'the committed fixture workspace contributes at least one'); + const discovered = toSolutionSelections(paths); + assert.strictEqual(discovered.length, paths.length, 'every discovered file becomes a row'); + assert.deepEqual( + discovered.map((selection) => selection.name), + [...discovered.map((selection) => selection.name)].sort((left, right) => + left.localeCompare(right), + ), + 'and the rows are offered in a stable, sorted order', + ); }); test('solution selections preserve single .slnx filename', () => { + // Interaction 1 - the label is the FULL basename, extension included. + // Trimming it makes App.sln and App.slnx indistinguishable in the picker. const selections = toSolutionSelections(['/repo/App.slnx']); - assert.equal(selections.length, 1); assert.equal(selections[0]?.name, 'App.slnx'); + assert.equal(selections[0]?.path, '/repo/App.slnx', 'the path is the one we passed'); + + // Interaction 2 - the directory is NOT folded into the label, and the path + // is not rewritten. The picker shows a name and opens a path; conflating + // them opens the wrong solution. + assert.strictEqual(selections[0]?.name.includes('/'), false, 'the label carries no directory'); + assert.strictEqual( + selections[0]?.path.startsWith('/repo/'), + true, + 'while the path keeps its directory', + ); + assert.notStrictEqual(selections[0]?.name, selections[0]?.path, 'the two are distinct fields'); + + // Interaction 3 - a deeper path still labels by basename alone, so the + // picker stays readable however far down the solution lives. + const nested = toSolutionSelections(['/repo/src/nested/deep/App.slnx']); + assert.equal(nested.length, 1, 'one selection for one path'); + assert.equal(nested[0]?.name, 'App.slnx', 'labelled by basename regardless of depth'); + assert.equal(nested[0]?.path, '/repo/src/nested/deep/App.slnx', 'with the full path intact'); + + // Interaction 4 - and no input means no selections, rather than a phantom + // row the user can click. + assert.deepEqual(toSolutionSelections([]), [], 'no paths, no selections'); }); test('solution selections keep multiple .slnx files distinct', () => { + // Interaction 1 - two solutions, two rows, SORTED. An unsorted picker + // reorders itself between invocations and the user's muscle memory picks + // the wrong solution. const selections = toSolutionSelections(['/repo/B.slnx', '/repo/A.slnx']); - assert.deepEqual( selections.map((selection) => selection.name), ['A.slnx', 'B.slnx'], ); + assert.strictEqual(selections.length, 2, 'both solutions survive'); + assert.deepEqual( + selections.map((selection) => selection.path), + ['/repo/A.slnx', '/repo/B.slnx'], + 'and each row keeps the path it will open', + ); + + // Interaction 2 - the row's label and its path agree. A sort that moves + // labels without moving paths opens B when the user clicked A, which is + // silent and unrecoverable. + for (const selection of selections) { + assert.ok( + selection.path.endsWith(selection.name), + `${selection.name} must be the basename of ${selection.path}`, + ); + } + + // Interaction 3 - solutions of the SAME name in different directories stay + // distinct rows: a monorepo has App.slnx more than once. + const sameName = toSolutionSelections(['/repo/two/App.slnx', '/repo/one/App.slnx']); + assert.strictEqual(sameName.length, 2, 'both are offered'); + assert.deepEqual( + sameName.map((selection) => selection.path), + ['/repo/one/App.slnx', '/repo/two/App.slnx'], + 'ordered by path when the names tie, so the order is deterministic', + ); + assert.deepEqual( + sameName.map((selection) => selection.name), + ['App.slnx', 'App.slnx'], + 'even though both carry the same label', + ); + assert.strictEqual( + new Set(sameName.map((selection) => selection.path)).size, + 2, + 'and two distinct paths, so the picker can still open the right one', + ); + + // Interaction 4 - the ordering is TOTAL: the same set given in any input + // order comes back identically, which is what makes the picker stable + // across refreshes. + assert.deepEqual( + toSolutionSelections(['/repo/A.slnx', '/repo/B.slnx']), + selections, + 'input order does not change the offered order', + ); + assert.deepEqual( + toSolutionSelections(['/repo/one/App.slnx', '/repo/two/App.slnx']), + sameName, + 'nor does it for same-named solutions', + ); + assert.strictEqual(selections[0]?.name, 'A.slnx', 'A still sorts first'); }); test('solution selections keep mixed .sln and .slnx filenames distinct', () => { + // Interaction 1 - a repository mid-migration holds both formats of the same + // solution. Truncating the extension would collapse them into one + // indistinguishable row ([SE-SOLUTION]). const selections = toSolutionSelections(['/repo/App.slnx', '/repo/App.sln']); - assert.deepEqual( selections.map((selection) => selection.name), ['App.sln', 'App.slnx'], ); + assert.strictEqual(selections.length, 2, 'both formats are offered'); + assert.strictEqual(new Set(selections.map((s) => s.name)).size, 2, 'under distinct labels'); + + // Interaction 2 - and under distinct paths, each ending in its own label. + assert.deepEqual( + selections.map((selection) => selection.path), + ['/repo/App.sln', '/repo/App.slnx'], + 'sorted by label, with the path each row opens', + ); + for (const selection of selections) { + assert.ok(selection.path.endsWith(selection.name), `${selection.name} matches its path`); + } + + // Interaction 3 - the ordering is STABLE: the same set in a different input + // order produces the same rows, so the picker never reshuffles itself. + const reversed = toSolutionSelections(['/repo/App.sln', '/repo/App.slnx']); + assert.deepEqual(reversed, selections, 'input order does not change the offered order'); + assert.deepEqual( + toSolutionSelections(['/repo/App.slnx', '/repo/App.sln', '/repo/Other.sln']).map( + (selection) => selection.name, + ), + ['App.sln', 'App.slnx', 'Other.sln'], + 'and a third solution slots into the same ordering', + ); + + // Interaction 4 - `.sln` sorts before `.slnx` because the label sort is a + // plain string comparison, and every row still points at its own file. + const three = toSolutionSelections(['/repo/App.slnx', '/repo/App.sln', '/repo/Other.sln']); + assert.strictEqual(three.length, 3, 'all three solutions are offered'); + assert.deepEqual( + three.map((selection) => selection.path), + ['/repo/App.sln', '/repo/App.slnx', '/repo/Other.sln'], + 'each row keeps the path it opens', + ); + for (const selection of three) { + assert.ok(selection.path.endsWith(selection.name), `${selection.name} matches its path`); + } }); // ── Real LSP roundtrip with record types ───────────────────── @@ -577,6 +1055,31 @@ public record Address const street = address.children?.find((s) => s.name === 'Street'); assert.ok(street, 'Should find Street property in Address'); + + // Interaction 2 - [SE-SYMBOL-KINDS] maps `record_declaration` to Class, and + // its members keep their own kinds. A record drawn as a method puts the + // wrong icon on the most common type in a modern C# domain model. + const doc = await vscode.workspace.openTextDocument(uri); + assertSymbolShape(address, vscode.SymbolKind.Class, doc); + assertSymbolShape(street, vscode.SymbolKind.Property, doc); + assertSymbolTree(symbols, doc); + + // Interaction 3 - both record SHAPES land in the tree: the positional + // one-liner and the braced body. A reader that only handles the braced form + // silently drops every DTO in the project. + assert.strictEqual(ns.children?.length, 2, 'both records are children of the namespace'); + assert.ok(ns.range.contains(person.range), 'the positional record sits inside the namespace'); + assert.ok(ns.range.contains(address.range), 'and so does the braced one'); + assert.deepEqual( + address.children?.map((child) => child.name), + ['Street', 'City'], + 'the braced record owns both properties, in source order', + ); + assert.strictEqual( + doc.getText(person.selectionRange).includes('('), + false, + 'and the positional record is named without its parameter list', + ); }); // ── Events and fields ──────────────────────────────────────── @@ -608,6 +1111,29 @@ public class EventSource const counter = source.children?.find((s) => s.name === '_counter'); assert.ok(counter, 'Should find _counter field'); assert.strictEqual(counter.kind, vscode.SymbolKind.Field); + + // Interaction 2 - [SE-SYMBOL-KINDS] gives Event and Field separate rows, + // separate icons and separate theme colours. An event drawn as a field is + // the difference between "subscribe here" and "read this value". + assert.notStrictEqual(evt.kind, counter.kind, 'an event is not a field'); + const doc = await vscode.workspace.openTextDocument(uri); + assertSymbolShape(evt, vscode.SymbolKind.Event, doc); + assertSymbolShape(counter, vscode.SymbolKind.Field, doc); + assertSymbolTree(symbols, doc); + + // Interaction 3 - a `static readonly` field is still a Field, and every + // member is a CHILD of the class rather than a sibling of it. Private and + // static members must not be filtered out of the tree: [SE-SORT-ACCESS] + // sorts by access, which presupposes they are all present. + const constant = source.children?.find((child) => child.name === 'DefaultName'); + assert.ok(constant, 'a static readonly field must appear in the tree'); + assert.deepEqual( + source.children?.map((child) => child.name), + ['OnChanged', '_counter', 'DefaultName'], + 'all three members, in source order, public and private alike', + ); + assert.ok(source.range.contains(counter.range), 'the private field sits inside the class'); + assert.ok(ns.range.contains(source.range), 'and the class inside the namespace'); }); // ── Reactive Tree Auto-Refresh ────────────────────────────── @@ -665,9 +1191,49 @@ public class EventSource 'Tree must auto-refresh when C# document content changes — ' + 'renaming a symbol should update the solution explorer', ); + + // Interaction 2 - the refresh reflects the NEW content. An event that + // fires while the tree still serves the old symbol is worse than no + // event: the view looks live and reports stale data ([SE-LIVE-BUFFER]). + const after = await pollUntilResult( + async () => + (await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( + 'vscode.executeDocumentSymbolProvider', + doc.uri, + )) ?? [], + (found) => flattenSymbolNames(found).includes('NewMethod'), + 5_000, + ); + const names = flattenSymbolNames(after); + assert.ok(names.includes('NewMethod'), 'the renamed member is visible after the refresh'); + assert.strictEqual(names.includes('OldMethod'), false, 'and the old name is gone'); + assert.ok(after.length > 0, 'the outline is not merely empty'); + + // Interaction 3 - a SECOND edit fires again. A provider that fires once + // and then goes quiet leaves the tree stale from the second keystroke on. + const firstRound = treeChangeCount; + await replaceDocumentContent(doc, 'class Before { void ThirdMethod() {} }'); + const again = await pollUntilResult( + async () => treeChangeCount, + (count) => count > firstRound, + 5_000, + 100, + ); + assert.ok(again > firstRound, 'a second edit must fire the change event again'); + assert.strictEqual(doc.isDirty, true, 'and the buffer is unsaved throughout'); } finally { disposable.dispose(); } + + // Interaction 4 - disposing the subscription really unsubscribes, so a + // closed view stops paying for edits it can no longer show. + const settled = treeChangeCount; + const reopened = await vscode.workspace.openTextDocument( + vscode.Uri.file(path.join(tmpDir, 'reactive-test.cs')), + ); + await replaceDocumentContent(reopened, 'class Before { void FourthMethod() {} }'); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + assert.strictEqual(treeChangeCount, settled, 'no event reaches a disposed listener'); }); // ── Live-buffer fidelity [SE-LIVE-BUFFER] ─────────────────────────────── @@ -713,6 +1279,31 @@ public class EventSource "documentSymbol must show 'Renamed' for unsaved edit — " + 'this proves the VFS/tree-sitter path works correctly', ); + + // Interaction 3 - the OLD name is gone. "The new name appeared" is only + // half of [SE-LIVE-BUFFER]: a VFS that appends without replacing shows both + // classes, and Go to Symbol then offers one that no longer exists. + assert.strictEqual( + nsAfter.children?.some((child) => child.name === 'Original'), + false, + 'the pre-edit class name must not survive the rename', + ); + assert.strictEqual(doc.isDirty, true, 'and the buffer is still unsaved'); + assert.ok( + fs.readFileSync(uri.fsPath, 'utf8').includes('Original'), + 'while the file ON DISK still says Original - which is the whole point', + ); + + // Interaction 4 - the tree is still well formed and the member survived the + // rename of its containing type. + assertSymbolTree(after, doc); + assertSymbolShape(renamedClass, vscode.SymbolKind.Class, doc); + assert.deepEqual( + renamedClass.children?.map((child) => child.name), + ['Foo'], + 'the method inside the renamed class is untouched', + ); + assert.strictEqual(after.length, 1, 'and the namespace is still the only root'); }); test('workspace symbols show unsaved edits, not stale disk content', async function () { @@ -965,7 +1556,13 @@ public class EventSource const initial = 'namespace StaleDataTest;\n\npublic sealed class Alpha\n{\n public string Name { get; set; }\n}'; const { doc } = await openCSharpFile(projDir, 'Thing.cs', initial); - await waitForDocumentSymbols(doc.uri); + const opened = await waitForDocumentSymbols(doc.uri); + assert.ok(opened.length > 0, 'the source the tree will read really has symbols'); + assert.ok( + flattenSymbolNames(opened).includes('Alpha'), + 'and the outline names Alpha before the tree is asked', + ); + assert.strictEqual(doc.isDirty, false, 'the file starts clean on disk'); // Load solution into tree and verify "Alpha" appears. await api.explorerProvider.loadSolution(slnPath); @@ -993,6 +1590,13 @@ public class EventSource ); assert.ok(hasAlpha, "Tree must show 'Alpha' before rename"); + assert.strictEqual(treeContains('Bravo'), false, 'and must NOT show Bravo before the rename'); + assert.ok(fs.existsSync(slnPath), 'the solution the tree loaded is on disk'); + assert.ok( + (provider.getChildren() ?? []).length > 0, + 'and the loaded solution produced at least one root row', + ); + // Rename class: Alpha → Bravo const renamed = 'namespace StaleDataTest;\n\npublic sealed class Bravo\n{\n public string Name { get; set; }\n}'; diff --git a/src/editors/vscode/src/test/suite/test-coverage-fixtures.ts b/src/editors/vscode/src/test/suite/test-coverage-fixtures.ts new file mode 100644 index 00000000..6f9e0035 --- /dev/null +++ b/src/editors/vscode/src/test/suite/test-coverage-fixtures.ts @@ -0,0 +1,197 @@ +// The fixture solution the `[TEST-COVERAGE]` suite runs against. +// +// [TEST-COVERAGE] makes two claims that a single-test-project fixture cannot +// falsify, and both were the actual defect: +// +// • "the collector writes one Cobertura report per test project" — with one +// test project, one report, `reports.length >= 1` passes forever, +// • "**every** one of them is parsed … taking only the first drops every +// other project's coverage" — with one report, first IS every, so reading +// only `reports[0]` is indistinguishable from reading them all. +// +// So this fixture is TWO test projects over ONE library, each exercising a +// DIFFERENT function of it: +// +// CoverCs → Calculator.Add (and nothing else) +// CoverFs → Calculator.Multiply (and nothing else) +// +// Neither `Subtract` nor `NeverCalled` is ever executed. That makes the union of +// the two reports strictly larger than either one alone, so a reader that keeps +// only the first report reports MULTIPLY as dead code on a run that just +// executed it — a wrong red gutter, not merely a missing one. +// +// F# is first here as everywhere: the F# test is an idiomatic backtick binding +// whose fully-qualified name carries SPACES, and it has to survive the coverage +// run's filter exactly as it does an ordinary one ([TEST-FILTER-ESCAPE]). +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + buildProjectXml, + libraryProjectXml, + XUNIT_PACKAGES, + writeProject, + type PackageRef, +} from './dotnet-project-kit'; +import { LIBRARY_PROJECT, LIBRARY_SOURCE } from './test-explorer-fixtures'; + +/** Where the Coverage profile drops TRX + Cobertura, beside the solution. */ +export const COVERAGE_DIR_NAME = '.sharplsp-coverage'; + +/** The collector `--collect:"XPlat Code Coverage"` needs referenced. */ +export const COVERLET_PACKAGE: PackageRef = { id: 'coverlet.collector', version: '6.0.2' }; + +/** The single source file both test projects cover, and its four functions. */ +export const LIBRARY_FILE = 'Calculator.cs'; + +/** + * The TEST sources, which the collector must never report. + * + * `coverlet.collector` leaves the test assembly out by default + * (`IncludeTestAssembly` is false), so these names appearing in a report means + * the fixture is measuring the tests rather than the library they exercise + * ([TEST-COVERAGE]). + */ +export const CS_TESTS_FILE = 'CoverageTests.cs'; +export const FS_TESTS_FILE = 'CoverageTests.fs'; +/** Executed only by the C# project. */ +export const COVERED_BY_CSHARP = 'Add'; +/** Executed only by the F# project. */ +export const COVERED_BY_FSHARP = 'Multiply'; +/** Compiled, never executed — the partial-coverage proof. */ +export const NEVER_COVERED: readonly string[] = ['Subtract', 'NeverCalled']; + +const CS_PROJECT = 'CoverCs'; +const FS_PROJECT = 'CoverFs'; +const CS_NAMESPACE = 'Cs.Cover.Fixtures'; +const FS_NAMESPACE = 'Fs.Cover.Fixtures'; +const CS_CLASS = 'AdditionTests'; + +/** The C# test project: it touches `Add`, and nothing else in the library. */ +export const CS_COVERS = `${CS_NAMESPACE}.${CS_CLASS}.Covers_Add`; +/** A red test, so outcome attribution stays assertable UNDER the Coverage profile. */ +export const CS_FAILING = `${CS_NAMESPACE}.${CS_CLASS}.Fails_Loudly`; +/** A skipped test whose body would have covered `Subtract` had it run. */ +export const CS_SKIPPED = `${CS_NAMESPACE}.${CS_CLASS}.Never_Runs`; +/** A two-row `[Theory]`, reported under one name ([TEST-RUN-TRX]). */ +export const CS_THEORY = `${CS_NAMESPACE}.${CS_CLASS}.Adds_Rows`; + +/** The F# test project: a backtick name with SPACES, touching `Multiply`. */ +export const FS_COVERS = `${FS_NAMESPACE}.covers multiply only`; +/** An F# test that touches the library not at all. */ +export const FS_ISOLATED = `${FS_NAMESPACE}.adds without touching the library`; + +/** Every test the fixture solution exposes. */ +export const ALL_COVERAGE_TESTS: readonly string[] = [ + CS_COVERS, + CS_FAILING, + CS_SKIPPED, + CS_THEORY, + FS_COVERS, + FS_ISOLATED, +]; + +const CS_SOURCE = [ + 'using Calc.Library;', + 'using Xunit;', + '', + `namespace ${CS_NAMESPACE}`, + '{', + ` public class ${CS_CLASS}`, + ' {', + ' [Fact] public void Covers_Add() => Assert.Equal(3, Calculator.Add(1, 2));', + '', + ' [Fact] public void Fails_Loudly() => Assert.Equal(4, Calculator.Add(1, 2));', + '', + ' [Fact(Skip = "covers Subtract only if it ever runs")]', + ' public void Never_Runs() => Assert.Equal(0, Calculator.Subtract(1, 1));', + '', + ' [Theory]', + ' [InlineData(1, 2, 3)]', + ' [InlineData(2, 3, 5)]', + ' public void Adds_Rows(int a, int b, int expected) =>', + ' Assert.Equal(expected, Calculator.Add(a, b));', + ' }', + '}', + '', +].join('\n'); + +const FS_SOURCE = [ + `module ${FS_NAMESPACE}`, + '', + 'open Calc.Library', + 'open Xunit', + '', + '[<Fact>]', + 'let ``covers multiply only`` () = Assert.Equal(6, Calculator.Multiply(2, 3))', + '', + '[<Fact>]', + 'let ``adds without touching the library`` () = Assert.Equal(3, 1 + 2)', + '', +].join('\n'); + +/** + * Write the library and BOTH test projects; returns their directories. + * + * Both test projects reference the library and the collector, because + * `coverlet.collector` only reports assemblies the run actually LOADED: a test + * project without the reference contributes a valid, empty report and proves + * nothing about whether its report was read. + */ +export function writeSplitCoverageFixture(root: string): string[] { + const packages = [...XUNIT_PACKAGES, COVERLET_PACKAGE]; + const reference = path.join('..', LIBRARY_PROJECT, `${LIBRARY_PROJECT}.csproj`); + const libDir = writeProject( + path.join(root, LIBRARY_PROJECT), + `${LIBRARY_PROJECT}.csproj`, + libraryProjectXml(), + LIBRARY_FILE, + LIBRARY_SOURCE, + ); + const csDir = writeProject( + path.join(root, CS_PROJECT), + `${CS_PROJECT}.csproj`, + buildProjectXml({ packages, projectReferences: [reference] }), + CS_TESTS_FILE, + CS_SOURCE, + ); + const fsDir = writeProject( + path.join(root, FS_PROJECT), + `${FS_PROJECT}.fsproj`, + buildProjectXml({ + packages, + projectReferences: [reference], + compileIncludes: [FS_TESTS_FILE], + }), + FS_TESTS_FILE, + FS_SOURCE, + ); + return [libDir, csDir, fsDir]; +} + +/** + * The RUN-ID FOLDERS under `dir` — the ones actually carrying a Cobertura + * report, which is what [TEST-COVERAGE] means by "one Cobertura report per test + * project, each in its own run-id folder one level down". + * + * A run-id folder is not the only directory the collector's results directory + * ends up holding. `--results-directory` is shared with the TRX logger, and the + * logger creates its OWN attachments folder there — named for the user, machine + * and timestamp — as soon as the run produces an attachment, which a coverage + * run always does. Counting every directory therefore counts the TRX folder as + * a third project's report and fails a green run. + * + * Filtering on the report is also the STRONGER assertion: it is exactly the set + * `findCoberturaFiles` walks (one level down, `coverage.cobertura.xml`), so a + * count taken here is a count of reports the product can actually load, not of + * folders that merely exist. + */ +export function reportDirsOf(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((entry) => fs.existsSync(path.join(dir, entry, REPORT_FILE_NAME))) + .sort(); +} + +/** The file name `coverlet.collector` writes into each run-id folder. */ +const REPORT_FILE_NAME = 'coverage.cobertura.xml'; diff --git a/src/editors/vscode/src/test/suite/test-explorer-adapter-ids.test.ts b/src/editors/vscode/src/test/suite/test-explorer-adapter-ids.test.ts index 30ecc225..64933f3c 100644 --- a/src/editors/vscode/src/test/suite/test-explorer-adapter-ids.test.ts +++ b/src/editors/vscode/src/test/suite/test-explorer-adapter-ids.test.ts @@ -39,7 +39,7 @@ import { } from '../../test-discovery.js'; import { buildFilterArgs } from '../../test-execution.js'; import { filterClause } from '../../test-filter.js'; -import { findTestByMethodName, statusLensTitle } from '../../test-lens.js'; +import { NEVER_RUN, findTestByMethodName, statusLensTitle } from '../../test-lens.js'; import { createSolution, dotnet, @@ -232,6 +232,81 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { sorted(EXPECTED), 'reading the REAL listing file must yield one bare id per test, rows collapsed', ); + // Interaction 3 - the decoration is a SPACE then 40 hex digits, and the + // guard has to hold for EVERY line the adapter wrote, not for one of them. + for (const raw of rawListing) { + assert.strictEqual(raw.trim(), raw, `${raw} arrived without padding`); + assert.strictEqual(raw.length > 0, true, 'and no blank line is a listed test'); + assert.strictEqual( + raw.startsWith(NAMESPACE), + true, + `${raw} must belong to the fixture namespace, decorated or not`, + ); + } + assert.strictEqual( + rawListing.filter((raw) => carriesUniqueId(raw)).length, + rawListing.length, + 'EVERY line this adapter wrote carries a unique ID - if even one did not, the suite ' + + 'would be proving the stripper against a name that never needed stripping', + ); + assert.strictEqual( + new Set(rawListing).size, + rawListing.length, + 'each decorated line is distinct, because each carries its OWN unique ID', + ); + assert.strictEqual( + new Set(rawListing.map((raw) => withoutAdapterUniqueId(raw))).size < rawListing.length, + true, + 'and stripping COLLAPSES them - which is how a theory\u2019s rows become one test', + ); + // Interaction 4 - the decoration is a SUFFIX, so every raw line must still + // START with the bare name it strips to. A stripper that rewrote the MIDDLE + // of a name satisfies every length check and still hands the tree a name no + // TRX report can be keyed on ([TEST-DISCOVERY-FQN]). + for (const line of rawListing) { + assert.ok( + line.startsWith(withoutAdapterUniqueId(line)), + `'${line}' must strip to a PREFIX of itself, never to a rewritten name`, + ); + assert.strictEqual( + withoutAdapterUniqueId(line).trim(), + withoutAdapterUniqueId(line), + `'${line}' must not strip down to a name carrying edge whitespace`, + ); + } + assert.deepStrictEqual( + sorted([...new Set(rawListing.map((line) => withoutAdapterUniqueId(line)))]), + sorted([...EXPECTED]), + 'and the whole decorated listing strips down to exactly the fixture tests', + ); + assert.strictEqual( + rawListing.every((line) => carriesUniqueId(line)), + true, + 'with every single line decorated - one bare line would make this guard vacuous', + ); + // Interaction 5 - the decoration's SHAPE is the thing that has to be + // recognised: a space, then forty hex digits in brackets, at the very end of + // the line. Anything looser also eats the NUnit `Adds_Case(2,2,4)` shape + // [TEST-DISCOVERY-FQN] requires to round-trip untouched. + for (const line of rawListing) { + const stripped = withoutAdapterUniqueId(line); + assert.strictEqual( + line.length - stripped.length, + 43, + `'${line}' sheds exactly a space and forty hex digits in brackets`, + ); + assert.strictEqual(line.endsWith(')'), true, `'${line}' ends at its decoration`); + assert.strictEqual( + line.charAt(stripped.length), + ' ', + `'${line}' separates name from decoration with a SPACE`, + ); + } + assert.strictEqual( + withoutAdapterUniqueId('Ns.C.Adds_Case(2,2,4)'), + 'Ns.C.Adds_Case(2,2,4)', + 'while an NUnit parameterised name - no space, no hex - survives untouched', + ); }); test('discovered ids are the BARE fully-qualified names, with no adapter suffix', function () { @@ -284,6 +359,41 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { `${id} must be <namespace>.<class>.<method> so the TRX key can be reconstructed`, ); } + // Interaction 3 - a bare id is not merely "different from the raw line": it + // is the exact value `--filter FullyQualifiedName=` and the TRX report both + // key on, so it must round-trip through the stripper unchanged. + for (const id of discovered) { + assert.strictEqual( + withoutAdapterUniqueId(id), + id, + `${id} must already be bare - a second pass that changes it means the first left a ` + + 'decoration behind', + ); + assert.strictEqual(carriesUniqueId(id), false, `${id} carries no unique ID`); + assert.strictEqual(id.includes(' '), false, `${id} carries no doubled space`); + assert.strictEqual(id.endsWith(')') === id.includes('('), true, `${id} is balanced`); + } + assert.deepStrictEqual( + sorted(discovered), + sorted([...EXPECTED]), + 'and the whole set is exactly what the fixture declares', + ); + // Interaction 4 - a bare id is bare under EVERY reading: stripping it again + // is a no-op, none of them carries the space-then-bracket shape the adapter + // appends, and none of them collides. Two rows sharing an id is one row the + // user can never select ([TEST-DISCOVERY-FQN]). + for (const id of discovered) { + assert.strictEqual(withoutAdapterUniqueId(id), id, `'${id}' is already bare`); + assert.strictEqual(carriesUniqueId(id), false, `'${id}' carries no decoration`); + assert.strictEqual(id.includes(' ('), false, `'${id}' has no space-bracket suffix`); + assert.strictEqual(id.startsWith(NAMESPACE), true, `'${id}' still names its namespace`); + } + assert.strictEqual(new Set(discovered).size, discovered.length, 'and no id is duplicated'); + assert.deepStrictEqual( + sorted(discovered), + sorted([...EXPECTED]), + 'with the discovered set exactly the fixture tests', + ); }); test('the tree renders Assembly → Namespace → Class → Test with readable labels', function () { @@ -354,6 +464,47 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { everyId.length, `every node in the Testing view needs its own id; got ${everyId.join(' | ')}`, ); + // Interaction 3 - a LABEL is what the user reads, and an id is what the CLI + // takes. Neither may carry the other's shape. + for (const id of EXPECTED) { + const leaf = findItem(api.testController.items, id); + assert.ok(leaf, `${id} must be a row in the tree`); + assert.strictEqual(leaf.label, methodOf(id), `${id} is labelled with its method name`); + assert.strictEqual(carriesUniqueId(leaf.label), false, `${id}: no hex blob in the label`); + assert.strictEqual(leaf.children.size, 0, `${id} is a leaf`); + assert.strictEqual(leaf.id, id, `${id} is identified by its bare fully-qualified name`); + } + assert.strictEqual( + collectItemIds(api.testController.items).some((id) => carriesUniqueId(id)), + false, + 'and no GROUP id carries a decoration either - the assembly, namespace and class rows ' + + 'are ids the run and the lens both address', + ); + // Interaction 4 - the four rows the user actually reads. A LABEL carrying a + // hex blob is the defect at its most visible, and a group row labelled with + // anything but its own name is a row whose play button lies about its scope + // ([TEST-EXPLORER]). + const shapeAssemblies = rootsOf(api.testController.items); + assert.strictEqual(shapeAssemblies.length, 1, 'one assembly row for one test project'); + assert.strictEqual( + shapeAssemblies[0]?.label.includes(FIXTURE.projectName), + true, + 'labelled with the project it was built from', + ); + const shapeNamespace = onlyChild(shapeAssemblies[0], 'one namespace beneath the assembly'); + assert.strictEqual(shapeNamespace.label, NAMESPACE, 'the namespace row reads as the namespace'); + const shapeClass = onlyChild(shapeNamespace, 'one class beneath the namespace'); + assert.strictEqual(shapeClass.label, CLASS, 'and the class row as the bare class name'); + assert.strictEqual( + rootsOf(shapeClass.children).every((leaf) => leaf.label === methodOf(leaf.id)), + true, + 'with every leaf reading as its own method name', + ); + assert.strictEqual( + collectItemIds(api.testController.items).some((id) => carriesUniqueId(id)), + false, + 'and not one row in the whole tree - group or leaf - carrying a decoration', + ); }); test('the --filter a run builds is the bare name, matching a real test', function () { @@ -387,6 +538,78 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { RUNNABLE.map((id) => `FullyQualifiedName=${id}`), 'the selection is OR-ed clause by clause, one per selected test', ); + // Interaction 3 - the whole selection, as one filter. [TEST-FILTER-ESCAPE] + // OR-s escaped clauses with an unescaped pipe, and a decorated name would + // have escaped its parentheses and matched nothing at all. + const everyArg = buildFilterArgs(EXPECTED.map((id) => ({ id }))); + assert.strictEqual(everyArg.length, 2, 'one --filter flag and one expression'); + assert.strictEqual( + (everyArg[1] ?? '').split('FullyQualifiedName=').length - 1, + EXPECTED.length, + 'one clause per selected test', + ); + assert.strictEqual( + (everyArg[1] ?? '').includes('\\('), + false, + 'and nothing escaped - a bare xUnit name contains no filter grammar at all, while a ' + + 'decorated one would have escaped its brackets and matched nothing', + ); + for (const id of EXPECTED) { + assert.strictEqual( + filterClause(id), + `FullyQualifiedName=${id}`, + `${id} produces a clause naming it exactly`, + ); + assert.strictEqual( + (everyArg[1] ?? '').includes(filterClause(id)), + true, + `${id}'s clause is in the combined expression`, + ); + } + // Interaction 4 - one batched run is ONE `--filter` argument pair, and the + // clauses inside it are OR-ed with an UNESCAPED `|` ([TEST-FILTER-ESCAPE]). + // An escaped separator matches a test whose name contains a pipe, which is + // no test at all, so the run silently executes nothing. + const batchedArgs = buildFilterArgs(EXPECTED.map((id) => ({ id }))); + assert.strictEqual(batchedArgs.length, 2, 'a filter is a flag and a value, nothing more'); + assert.strictEqual(batchedArgs[0], '--filter', 'and the flag is --filter'); + assert.strictEqual( + (batchedArgs[1] ?? '').split('|').length, + EXPECTED.length, + 'with one clause per selected test, joined by an unescaped pipe', + ); + for (const id of EXPECTED) { + assert.strictEqual( + (batchedArgs[1] ?? '').includes(filterClause(id)), + true, + `${id} has a clause of its own inside the batched expression`, + ); + assert.strictEqual(filterClause(id).includes(' ('), false, `${id} filters on a bare name`); + } + assert.deepStrictEqual( + buildFilterArgs([]), + [], + 'and an empty selection builds no filter at all', + ); + // Interaction 5 - and this is WHY the id has to be bare. A clause built over + // a DECORATED name escapes the brackets the adapter appended, so the filter + // then names a test that does not exist and the run matches nothing at all + // ([TEST-FILTER-ESCAPE], [TEST-DISCOVERY-FQN]). + const decoratedClause = filterClause( + `${FIXTURE.passing} (0123456789abcdef0123456789abcdef01234567)`, + ); + assert.strictEqual(decoratedClause.includes('\\('), true, 'the opening bracket gets escaped'); + assert.strictEqual(decoratedClause.includes('\\)'), true, 'and so does the closing one'); + assert.notStrictEqual( + decoratedClause, + filterClause(FIXTURE.passing), + 'so a decorated id builds a DIFFERENT filter than the bare one - which is the whole defect', + ); + assert.strictEqual( + filterClause(FIXTURE.passing).includes('\\'), + false, + 'while the bare name needs no escaping whatsoever', + ); }); test('the Run/Debug lens resolves a test by its method name', function () { @@ -408,6 +631,69 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { undefined, 'the private helper is not a test and must not carry a Run lens', ); + // Interaction 3 - the lens must resolve EVERY test by its method name, and + // must not resolve a name the fixture never declares. + for (const id of EXPECTED) { + const found = findTestByMethodName(api.testController.items, methodOf(id)); + assert.ok(found, `the lens must resolve ${methodOf(id)} to a discovered test`); + assert.strictEqual(found.id, id, `and to the BARE id ${id}`); + assert.strictEqual(carriesUniqueId(found.id), false, 'with no decoration on it'); + } + assert.strictEqual( + findTestByMethodName(api.testController.items, 'NoSuchMethodAnywhere'), + undefined, + 'a method the fixture never declares must resolve to nothing rather than to a neighbour', + ); + assert.strictEqual( + findTestByMethodName(api.testController.items, ''), + undefined, + 'and an empty name resolves to nothing at all', + ); + // Interaction 4 - the lens resolves by METHOD name, so a decorated method + // name must resolve to NOTHING: matching it would print a Run button on a + // row the subsequent run cannot find ([TEST-STATUS-LENS]). + for (const id of EXPECTED) { + const lensRow = findTestByMethodName(api.testController.items, methodOf(id)); + assert.ok(lensRow, `${methodOf(id)} resolves to a row`); + assert.strictEqual(lensRow.id, id, 'and the row it resolves to carries the BARE id'); + assert.strictEqual(lensRow.children.size, 0, 'a lens always resolves to a LEAF'); + } + assert.strictEqual( + findTestByMethodName( + api.testController.items, + `${methodOf(FIXTURE.passing)} (0123456789abcdef0123456789abcdef01234567)`, + ), + undefined, + 'while a DECORATED method name resolves to nothing at all', + ); + assert.strictEqual( + findTestByMethodName(api.testController.items, CLASS), + undefined, + 'and a class name is not a method name', + ); + // Interaction 5 - the lens renders a TITLE, and a title built from a bare id + // reads as a STATE. "No result reported" is the text a broken id produces, + // and it is never one of the states ([TEST-STATUS-LENS]). + for (const id of EXPECTED) { + assert.strictEqual( + api.testController.getResult(id), + undefined, + `${id} has not run in this session, so nothing is cached for it yet`, + ); + const rendered = statusLensTitle(api.testController.getResult(id) ?? NEVER_RUN); + assert.strictEqual( + rendered, + '$(circle-slash) Not run', + `${id} reads as never run, the state the provider renders before any run`, + ); + assert.strictEqual( + rendered.includes(NO_RESULT), + false, + `${id}'s lens title reads a state, not an absence`, + ); + assert.notStrictEqual(rendered.trim(), '', `${id}'s lens title is never empty`); + assert.strictEqual(rendered.includes('\n'), false, `${id}'s lens title is ONE line`); + } }); test('▶ reports a REAL outcome per test — never "No result reported"', async function () { @@ -484,6 +770,90 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { `the status-lens cache must be keyed by the bare id; ${id} was not found`, ); } + // Interaction 4 - the lens title each outcome produces. [TEST-STATUS-LENS] + // pins the four titles, and a test whose id could not be reconciled with + // the TRX report renders as "Not run" forever. + const passedTitle = statusLensTitle(cachedFor(api, FIXTURE.passing)); + assert.strictEqual( + passedTitle.startsWith('$(pass) Passed'), + true, + 'the passing test renders as a pass', + ); + assert.strictEqual(passedTitle.includes(NO_RESULT), false, 'and not as a missing result'); + const failedTitle = statusLensTitle(cachedFor(api, FIXTURE.failing)); + assert.strictEqual( + failedTitle.startsWith('$(error) Failed:'), + true, + 'the failing test renders as a failure', + ); + assert.strictEqual( + failedTitle.includes(NO_RESULT), + false, + 'carrying its own assertion text, not the placeholder a missing TRX entry produces', + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, FIXTURE.skipped)), + '$(debug-step-over) Skipped', + 'and the skipped test as a skip, never as a failure', + ); + for (const id of RUNNABLE) { + assert.strictEqual( + statusLensTitle(cachedFor(api, id)).includes('$(circle-slash)'), + false, + `${id} was run, so its lens must not read "Not run"`, + ); + } + // Interaction 4 - "No result reported" is the exact text a broken id + // produces, so it must appear nowhere the user can read it: not in a cached + // message, not in a rendered lens title ([TEST-RUN-TRX], [TEST-STATUS-LENS]). + for (const id of RUNNABLE) { + const attributed = cachedFor(api, id); + assert.notStrictEqual(attributed.outcome, 'notRun', `${id} really ran`); + assert.strictEqual( + (attributed.message ?? '').includes(NO_RESULT), + false, + `${id} carries no "${NO_RESULT}" message`, + ); + assert.strictEqual( + statusLensTitle(attributed).includes(NO_RESULT), + false, + `${id}'s lens never says it either`, + ); + assert.strictEqual( + statusLensTitle(attributed).includes('\n'), + false, + 'a lens title is ONE line', + ); + } + // Interaction 5 - the group rows the batch was dispatched from are not tests, + // so no group id may collide with a leaf id. A group sharing an id with a + // test is a row whose play button silently runs one thing and reports + // another ([TEST-EXPLORER]). + const everyRowId = collectItemIds(api.testController.items); + const leafRowIds = collectLeafIds(api.testController.items); + const groupIds = everyRowId.filter((id) => !leafRowIds.includes(id)); + assert.strictEqual( + groupIds.length, + 3, + 'three group rows above the leaves: assembly, namespace, class', + ); + for (const groupId of groupIds) { + assert.strictEqual( + EXPECTED.includes(groupId), + false, + `${groupId} is a group, never a test id`, + ); + assert.strictEqual( + carriesUniqueId(groupId), + false, + `${groupId} carries no decoration either`, + ); + } + assert.strictEqual( + everyRowId.length, + groupIds.length + leafRowIds.length, + 'and every row in the tree is either a group or a leaf, with nothing in between', + ); }); test('▶ on the CLASS group runs every test it contains, theories included', async function () { @@ -523,5 +893,979 @@ suite('Test Explorer — adapter-decorated names become BARE test ids', () => { ); } assertPassed(cachedFor(api, FIXTURE.parameterized), FIXTURE.parameterized); + // Interaction 4 - and the class row itself is unchanged by the run. + const classRow = findItem(api.testController.items, `${NAMESPACE}.${CLASS}`); + assert.strictEqual( + classRow === undefined || classRow.children.size > 0, + true, + 'a class row, if addressed by name, still holds its tests', + ); + for (const id of EXPECTED) { + const leaf = findItem(api.testController.items, id); + assert.ok(leaf, `${id} must still be a row after the class run`); + assert.strictEqual(leaf.id, id, 'under its bare id'); + assert.strictEqual(leaf.error, undefined, `${id} must not be marked errored`); + const message = cachedFor(api, id).message ?? ''; + assert.strictEqual( + message.includes(NO_RESULT), + false, + `${id} must not report "${NO_RESULT}" - that is what a kept unique ID produces for ` + + 'every test in the project', + ); + } + // Interaction 4 - a group's play button is a BATCHED run, so the ids it + // dispatched are the bare leaf ids beneath it and every one came back + // attributed. One `dotnet test` per test would be correct output and the + // wrong shape entirely ([TEST-REACTIVITY]). + const classLeafIds = collectLeafIds(api.testController.items); + assert.deepStrictEqual( + sorted(classLeafIds), + sorted([...EXPECTED]), + 'the class holds exactly the fixture tests', + ); + for (const id of classLeafIds) { + assert.strictEqual(carriesUniqueId(id), false, `${id} was dispatched bare`); + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} came back with a real outcome`, + ); + } + assert.strictEqual( + buildFilterArgs(itemsFor(api, classLeafIds)).length, + 2, + 'and the whole group batched into ONE filter, not one invocation per test', + ); + }); + + test('▶ on the ASSEMBLY ROOT attributes every outcome, none of them missing', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the user presses ▶ on the top row of the Testing view. + const roots = rootsOf(api.testController.items); + assert.strictEqual(roots.length, 1, 'the fixture is one project, so one assembly root'); + const assemblyNode = roots[0]; + assert.ok(assemblyNode, 'the assembly root is readable'); + assert.strictEqual(assemblyNode.label, FIXTURE.projectName, 'labelled for the project'); + assert.strictEqual( + assemblyNode.id.startsWith('assembly:'), + true, + `an assembly root is a GROUP id, never an FQN; got ${assemblyNode.id}`, + ); + + // Interaction 2 — every test under it reports a real outcome. This is the + // whole-project shape of the defect: with decorated ids all 35 tests of the + // real project errored at once, and a root run is how the user hit it. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [assemblyNode]); + for (const fqn of EXPECTED) { + const result = api.testController.getResult(fqn); + assert.ok(result, `▶ on the root must report ${fqn}; nothing was cached for it`); + assert.strictEqual( + (result.message ?? '').includes(NO_RESULT), + false, + `${fqn} ran, so it must not report "${NO_RESULT}"`, + ); + assert.strictEqual( + result.outcome === 'notRun', + false, + `${fqn} must not report notRun after a root run`, + ); + assert.ok(Number(result.duration) >= 0, `${fqn} carries a measured duration`); + } + + // Interaction 3 — the three kinds are still told apart, and the lens renders + // each of them the way the user reads it above the method + // ([TEST-STATUS-LENS]). + assertPassed(cachedFor(api, FIXTURE.passing), FIXTURE.passing); + assertFailed(cachedFor(api, FIXTURE.failing), FIXTURE.failing); + assertSkipped(cachedFor(api, FIXTURE.skipped), FIXTURE.skipped); + assert.strictEqual( + statusLensTitle(cachedFor(api, FIXTURE.skipped)), + '$(debug-step-over) Skipped', + 'a skip is neither a pass nor a failure', + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, FIXTURE.failing)).startsWith('$(error) Failed'), + true, + 'and a failure renders as one', + ); + + // Interaction 4 — running the root did not re-split or re-decorate the tree. + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(EXPECTED), + 'a root run leaves the tree exactly as it was', + ); + assert.deepStrictEqual( + collectLeafIds(api.testController.items).filter((id) => carriesUniqueId(id)), + [], + 'and every id is still bare afterwards', + ); + // Interaction 4 - the assembly root is the widest selection there is, so a + // single unreconciled id would show up here as a whole project of missing + // results. + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'exactly one assembly root'); + for (const id of EXPECTED) { + const cached = cachedFor(api, id); + assert.notStrictEqual(cached.outcome, 'notRun', `${id} must report an outcome`); + assert.strictEqual( + (cached.message ?? '').includes(NO_RESULT), + false, + `${id} must not report "${NO_RESULT}"`, + ); + assert.strictEqual( + cached.passed === (cached.outcome === 'passed'), + true, + `${id}: the passed flag agrees with the outcome`, + ); + } + assert.strictEqual( + collectLeafIds(api.testController.items).length, + EXPECTED.length, + 'and the tree still holds exactly the tests the fixture declares', + ); + // Interaction 4 - the assembly root is the widest gesture there is, so + // nothing beneath it may be left unattributed, and running it must not have + // reshaped the tree it ran ([TEST-REACTIVITY]). + const afterRootRun = collectLeafIds(api.testController.items); + assert.deepStrictEqual( + sorted(afterRootRun), + sorted([...EXPECTED]), + 'running the root added and removed no row', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'and left exactly one assembly row', + ); + for (const id of RUNNABLE) { + assert.strictEqual( + (cachedFor(api, id).message ?? '').includes(NO_RESULT), + false, + `${id} was attributed from the root run`, + ); + } + assertPassed(cachedFor(api, FIXTURE.passing), FIXTURE.passing); + assertFailed(cachedFor(api, FIXTURE.failing), FIXTURE.failing); + assertSkipped(cachedFor(api, FIXTURE.skipped), FIXTURE.skipped); + }); + + test('a [Theory] whose rows each carried a unique ID reports as ONE test', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — select ONLY the theories. Each declares two [InlineData] + // rows, and the adapter gave every row its own unique ID, so a kept + // decoration turns two tests into four leaves and four filter clauses. + const theories = [ + FIXTURE.parameterized, + ...(FIXTURE.mixedParameterized === undefined ? [] : [FIXTURE.mixedParameterized]), + ]; + assert.ok(theories.length >= 1, 'the fixture declares at least one [Theory]'); + const items = itemsFor(api, theories); + assert.strictEqual(items.length, theories.length, 'one row per theory, not one per ROW'); + assert.deepStrictEqual( + items.map((item) => item.id), + theories, + 'and each selected under the one name its rows share', + ); + const args = buildFilterArgs(items); + assert.strictEqual(args[0], '--filter', 'a filtered run passes --filter first'); + assert.deepStrictEqual( + (args[1] ?? '').split('|'), + theories.map((id) => `FullyQualifiedName=${id}`), + 'one clause per THEORY — a per-row id would produce twice as many', + ); + + // Interaction 2 — running them caches exactly one result per theory, with a + // duration summed across the rows ([TEST-RUN-TRX]). + const before = api.testController.cachedResults.size; + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, items); + for (const fqn of theories) { + const result = cachedFor(api, fqn); + assert.strictEqual( + (result.message ?? '').includes(NO_RESULT), + false, + `${fqn} ran, so it must not report "${NO_RESULT}"`, + ); + assert.ok(Number(result.duration) >= 0, `${fqn}'s rows contribute one summed duration`); + } + assert.ok( + api.testController.cachedResults.size >= before, + 'a theory adds one cache entry, never one per row', + ); + assert.strictEqual( + api.testController.getResult(`${FIXTURE.parameterized} (row 1)`), + undefined, + 'no per-row id is ever cached alongside the test', + ); + + // Interaction 3 — the merged outcome is the WORST row's: a theory with one + // failing row is a failing test, reported once. + assertPassed(cachedFor(api, FIXTURE.parameterized), FIXTURE.parameterized); + if (FIXTURE.mixedParameterized !== undefined) { + const mixed = cachedFor(api, FIXTURE.mixedParameterized); + assert.strictEqual(mixed.outcome, 'failed', 'one failing row makes the theory fail'); + assert.strictEqual(mixed.passed, false, 'and the pass flag agrees'); + assert.strictEqual( + (mixed.message ?? '').includes('Assert.Equal'), + true, + "carrying the failing row's own assertion text", + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(EXPECTED), + 'and the tree still holds one leaf per theory', + ); + // Interaction 4 - the rows collapse because each carried its OWN unique ID + // and stripping removed all of them. That is the mechanism, and it has to + // be visible in the RAW listing this suite kept. + const theoryLines = rawListing.filter( + (raw) => withoutAdapterUniqueId(raw) === FIXTURE.parameterized, + ); + assert.strictEqual( + theoryLines.length >= 2, + true, + 'the adapter really did write one line PER ROW for the theory', + ); + assert.strictEqual( + new Set(theoryLines).size, + theoryLines.length, + 'each row line distinct, because each carries its own unique ID', + ); + assert.strictEqual( + new Set(theoryLines.map((raw) => withoutAdapterUniqueId(raw))).size, + 1, + 'and all of them strip down to the ONE name the rows share', + ); + assert.strictEqual( + collectLeafIds(api.testController.items).filter((id) => id === FIXTURE.parameterized).length, + 1, + 'so the tree holds exactly one leaf for the theory', + ); + assert.strictEqual( + parseFullyQualifiedTestList(theoryLines.join('\n')).length, + 1, + 'and the listing reader agrees, on the same lines', + ); + // Interaction 4 - the theory is ONE row however many lines the adapter wrote + // for it: the unique ID decorates a test CASE, while an id names a test + // METHOD ([TEST-DISCOVERY-FQN]). + const theoryRows = itemsFor(api, [FIXTURE.parameterized]); + assert.strictEqual(theoryRows.length, 1, 'exactly one row for the theory'); + assert.strictEqual(theoryRows[0]?.children.size, 0, 'and it is a leaf, not a folder of rows'); + assert.strictEqual( + theoryRows[0]?.label, + methodOf(FIXTURE.parameterized), + 'labelled with its method name, not with a row of arguments', + ); + assert.strictEqual( + rawListing.filter((line) => withoutAdapterUniqueId(line) === FIXTURE.parameterized).length >= + 1, + true, + 'while the adapter listed it at least once', + ); + assert.strictEqual( + collectLeafIds(api.testController.items).filter((id) => id === FIXTURE.parameterized).length, + 1, + 'and the tree holds it exactly once', + ); + assert.notStrictEqual( + cachedFor(api, FIXTURE.parameterized).outcome, + 'notRun', + 'with a real outcome behind that single row', + ); + }); + + test('a multi-select of EVERY test builds one unescaped filter and attributes every result', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the user ctrl-clicks every row and presses ▶. The filter + // is the clauses OR-ed with an UNESCAPED pipe ([TEST-FILTER-ESCAPE]). + const items = itemsFor(api, EXPECTED); + assert.strictEqual(items.length, EXPECTED.length, 'every test is in the selection'); + const args = buildFilterArgs(items); + const expression = args[1] ?? ''; + assert.strictEqual(args.length, 2, '--filter and exactly one expression'); + assert.strictEqual( + expression.includes('\\'), + false, + `a bare C# FQN needs no escaping anywhere in the expression; got ${expression}`, + ); + assert.strictEqual( + expression.split('|').length, + EXPECTED.length, + 'one clause per selected test, OR-ed', + ); + assert.deepStrictEqual( + expression.split('|'), + EXPECTED.map((id) => `FullyQualifiedName=${id}`), + 'and in the order the rows were selected', + ); + + // Interaction 2 — every one of them comes back with its own outcome. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, items); + for (const fqn of EXPECTED) { + const result = cachedFor(api, fqn); + assert.strictEqual( + (result.message ?? '').includes(NO_RESULT), + false, + `${fqn} was selected and run, so it must not report "${NO_RESULT}"`, + ); + assert.strictEqual( + ['passed', 'failed', 'skipped'].includes(result.outcome), + true, + `${fqn} must land in one of the three Testing-API states; got ${result.outcome}`, + ); + } + + // Interaction 3 — and the lens can still find each of them by the method + // name it read out of the editor, now carrying a real status. + for (const fqn of EXPECTED) { + const found = findTestByMethodName(api.testController.items, methodOf(fqn)); + assert.ok(found, `the lens above ${methodOf(fqn)} must resolve to a discovered test`); + assert.strictEqual(found.id, fqn, 'to THAT test'); + assert.notStrictEqual( + statusLensTitle(cachedFor(api, fqn)), + '$(circle-slash) Not run', + `${fqn} has just run, so its lens must not still read "Not run"`, + ); + } + // Interaction 4 - a multi-select is ONE invocation for the whole selection + // ([TEST-RUN-TRX]), and every id in it stays bare on the way out. + for (const id of EXPECTED) { + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} was selected, so it must report an outcome`, + ); + const leaf = findItem(api.testController.items, id); + assert.ok(leaf, `${id} is still a row`); + assert.strictEqual(leaf.id, id, 'under its bare id'); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...EXPECTED]), + 'and the tree is exactly what it was before the run', + ); + assert.strictEqual( + itemsFor(api, [...EXPECTED]).length, + EXPECTED.length, + 'every selected test resolved to a row of its own', + ); + // Interaction 4 - the batching rule stated as one claim: N selected tests is + // ONE invocation, and the separators between clauses are the only unescaped + // pipes in the expression ([TEST-FILTER-ESCAPE]). + const selectionArgs = buildFilterArgs(itemsFor(api, EXPECTED)); + assert.strictEqual(selectionArgs.length, 2, 'one flag and one expression'); + assert.strictEqual( + (selectionArgs[1] ?? '').split('|').length, + EXPECTED.length, + 'one clause per selected test', + ); + assert.strictEqual( + (selectionArgs[1] ?? '').includes('\\|'), + false, + 'and no separator was escaped away into a literal pipe', + ); + assert.strictEqual( + (selectionArgs[1] ?? '').includes(' ('), + false, + 'with no clause carrying an adapter decoration', + ); + for (const id of EXPECTED) { + assert.notStrictEqual(cachedFor(api, id).outcome, 'notRun', `${id} was attributed a result`); + } + }); + + test('a REFRESH re-discovers the same BARE ids, without duplicating a row', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-REACTIVITY]: pressing refresh re-runs the whole two-pass discovery, + // which means the decorating adapter reports its decorated names again. A + // stripper applied only on the first sweep leaves the tree correct until the + // user presses refresh, and wrong from then on. + // + // Interaction 1 — the tree as it stands. + const before = sorted(collectLeafIds(api.testController.items)); + const idsBefore = sorted(collectItemIds(api.testController.items)); + assert.deepStrictEqual(before, sorted(EXPECTED), 'the settled tree is the fixture'); + + // Interaction 2 — press refresh and let the sweep land. + await drainDiscovery(() => { + void api.testController.activateAndDiscover(); + }, api.testController); + const after = await pollForIds( + api.testController, + (ids) => ids.length >= EXPECTED.length, + DOTNET_CLI_MS, + ); + + // Interaction 3 — the same bare ids, the same nodes, nothing doubled. + assert.deepStrictEqual(sorted(after), before, 'refresh re-discovers exactly the same tests'); + assert.deepStrictEqual( + after.filter((id) => carriesUniqueId(id)), + [], + 'and strips the decoration on the SECOND sweep as well as the first', + ); + assert.deepStrictEqual( + after.filter((id) => id.includes('(') || id.includes(')')), + [], + 'with no parenthesis surviving into any id', + ); + assert.deepStrictEqual( + sorted(collectItemIds(api.testController.items)), + idsBefore, + 'every node in the view is the same node it was — refresh adds no second subtree', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'and the project is still ONE assembly root', + ); + assert.strictEqual( + new Set(after).size, + after.length, + 'no test is listed twice after a re-discovery', + ); + // Interaction 4 - a refresh re-runs the WHOLE discovery path, so the + // stripper runs again on a second listing. A stripper applied once leaves + // the tree correct until the user presses refresh. + const afterRefresh = collectLeafIds(api.testController.items); + assert.deepStrictEqual(sorted(afterRefresh), sorted([...EXPECTED]), 'the same bare ids'); + assert.strictEqual( + afterRefresh.length, + new Set(afterRefresh).size, + 'with nothing duplicated by the second sweep', + ); + assert.strictEqual( + afterRefresh.some((id) => carriesUniqueId(id)), + false, + 'and no decoration reintroduced', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'still ONE assembly root'); + for (const id of EXPECTED) { + assert.ok(findItem(api.testController.items, id), `${id} survived the refresh`); + } + // Interaction 4 - a refresh is a RE-READ, not an append. A refresh that + // appends gives the user two play buttons for one test, and neither of them + // is wrong enough to be obviously broken ([TEST-REACTIVITY]). + const refreshedLeaves = collectLeafIds(api.testController.items); + assert.deepStrictEqual( + sorted(refreshedLeaves), + sorted([...EXPECTED]), + 'the same tests came back, no more and no fewer', + ); + assert.strictEqual( + new Set(refreshedLeaves).size, + refreshedLeaves.length, + 'and not one of them duplicated', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'still exactly one assembly row', + ); + for (const id of refreshedLeaves) { + assert.strictEqual(carriesUniqueId(id), false, `${id} came back bare a second time`); + } + assert.strictEqual( + collectItemIds(api.testController.items).length, + new Set(collectItemIds(api.testController.items)).size, + 'with no duplicated GROUP row either', + ); + }); + + test('▶ on the NAMESPACE row reports every class beneath it, ids still bare', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — reach the namespace row through the tree the user + // expands, and check it is a real group rather than another name for the + // assembly. + const leaf = findItem(api.testController.items, FIXTURE.passing); + assert.ok(leaf, `${FIXTURE.passing} must be a row in the tree`); + const classNode = leaf.parent; + assert.ok(classNode, 'a leaf hangs off its class'); + const namespaceNode = classNode.parent; + assert.ok(namespaceNode, 'and a class off its namespace'); + assert.strictEqual(namespaceNode.label, NAMESPACE, 'labelled by the namespace'); + assert.strictEqual(namespaceNode.canResolveChildren, true, 'and it expands'); + assert.notStrictEqual(namespaceNode.id, classNode.id, 'a namespace is not its class'); + assert.strictEqual( + namespaceNode.id.includes(' ('), + false, + 'no GROUP id carries an adapter decoration either — the tree is keyed on these', + ); + + // Interaction 2 — [TEST-RUN-TRX] makes a group ONE invocation for the whole + // selection. Every test beneath the namespace reports from it. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [namespaceNode]); + for (const fqn of EXPECTED) { + const result = api.testController.getResult(fqn); + assert.ok(result, `▶ on the namespace must report ${fqn}`); + assert.notStrictEqual(result.outcome, 'notRun', `${fqn} must not report notRun`); + assert.strictEqual( + (result.message ?? '').includes(NO_RESULT), + false, + `${fqn} ran, so it must not report "${NO_RESULT}"`, + ); + assert.strictEqual( + ['passed', 'failed', 'skipped'].includes(result.outcome), + true, + `${fqn} lands in one of the three Testing-API states; got ${result.outcome}`, + ); + assert.ok(Number(result.duration) >= 0, `${fqn} carries a measured duration`); + } + + // Interaction 3 — the three kinds are still told apart, and the cache is + // keyed by the BARE id so the lens can find each of them + // ([TEST-STATUS-LENS]). + assertPassed(cachedFor(api, FIXTURE.passing), FIXTURE.passing); + assertFailed(cachedFor(api, FIXTURE.failing), FIXTURE.failing); + assertSkipped(cachedFor(api, FIXTURE.skipped), FIXTURE.skipped); + for (const fqn of EXPECTED) { + assert.ok( + api.testController.getResult(fqn), + `the cache must be keyed by the bare id; ${fqn} was not found`, + ); + assert.strictEqual( + api.testController.getResult(`${fqn} (${'0'.repeat(40)})`), + undefined, + 'and never by a decorated one', + ); + } + // Interaction 4 - the namespace row is a group whose id is not a test name, + // and everything beneath it is still bare. + const namespaceLeaves = collectLeafIds(api.testController.items).filter((id) => + id.startsWith(`${NAMESPACE}.`), + ); + assert.strictEqual( + namespaceLeaves.length, + EXPECTED.length, + 'every test the fixture declares lives under the one namespace', + ); + for (const id of namespaceLeaves) { + assert.strictEqual(carriesUniqueId(id), false, `${id} is bare`); + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} is under the namespace that was run and must report a result`, + ); + assert.strictEqual( + (cachedFor(api, id).message ?? '').includes(NO_RESULT), + false, + `${id} must not report "${NO_RESULT}"`, + ); + } + // Interaction 4 - a namespace row is a GROUP, and a group's id is never a + // test's id: selecting it would otherwise run one test while claiming to + // have run a namespace ([TEST-EXPLORER]). + const nsAssembly = rootsOf(api.testController.items)[0]; + assert.ok(nsAssembly, 'the assembly row is still in the tree'); + const nsRow = onlyChild(nsAssembly, 'one namespace beneath the assembly'); + assert.strictEqual(nsRow.label, NAMESPACE, 'and it is the fixture namespace'); + assert.strictEqual(EXPECTED.includes(nsRow.id), false, 'a group id is never a test id'); + assert.strictEqual(carriesUniqueId(nsRow.id), false, 'and it is undecorated too'); + assert.strictEqual(nsRow.children.size >= 1, true, 'with at least one class beneath it'); + for (const id of collectLeafIds(nsRow.children)) { + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} beneath the namespace was attributed`, + ); + } + }); + + test('▶ on ONE decorated test runs that test and no other', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the single row, and the single unescaped clause its id + // produces ([TEST-FILTER-ESCAPE]). + const item = findItem(api.testController.items, FIXTURE.failing); + assert.ok(item, `${FIXTURE.failing} must be a row in the tree`); + assert.strictEqual(item.id, FIXTURE.failing, 'under its bare fully-qualified name'); + assert.strictEqual(item.children.size, 0, 'and it is a leaf'); + assert.strictEqual(item.label, methodOf(FIXTURE.failing), 'labelled with its method name'); + const args = buildFilterArgs([item]); + assert.strictEqual(args.length, 2, '--filter and exactly one expression'); + assert.strictEqual( + args[1], + `FullyQualifiedName=${FIXTURE.failing}`, + 'one clause for the one selected test, with no escaped metacharacter', + ); + + // Interaction 2 — running it reports a real failure, with the assertion text + // out of the TRX ErrorInfo rather than a generic note. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [item]); + const failed = cachedFor(api, FIXTURE.failing); + assertFailed(failed, FIXTURE.failing); + assert.strictEqual( + (failed.message ?? '').includes('Assert.Equal'), + true, + `the failure carries xUnit's own output; got ${failed.message ?? '(none)'}`, + ); + assert.strictEqual( + (failed.message ?? '').includes(NO_RESULT), + false, + 'it was actually executed, so it reports no missing result', + ); + assert.strictEqual( + statusLensTitle(failed).startsWith('$(error) Failed'), + true, + 'and renders above the method as a failure', + ); + + // Interaction 3 — a single-test run leaves the tree, the ids and every other + // cached result exactly as they were. + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(EXPECTED), + 'a filtered run must not add or drop a row', + ); + assert.deepStrictEqual( + collectLeafIds(api.testController.items).filter((id) => carriesUniqueId(id)), + [], + 'and every id is still bare', + ); + assertSkipped(cachedFor(api, FIXTURE.skipped), FIXTURE.skipped); + assert.strictEqual( + statusLensTitle(cachedFor(api, FIXTURE.skipped)), + '$(debug-step-over) Skipped', + 'the unselected skip keeps the LAST KNOWN result it already had', + ); + // Interaction 4 - running ONE test must leave every OTHER test's cached + // result alone. A run that blanked the rest would lose the failure the user + // was chasing, and one that repainted them would be confidently wrong. + for (const id of EXPECTED) { + const cached = cachedFor(api, id); + assert.strictEqual( + typeof cached.outcome, + 'string', + `${id} still carries an outcome of some kind`, + ); + assert.strictEqual( + (cached.message ?? '').includes(NO_RESULT), + false, + `${id} must never report "${NO_RESULT}"`, + ); + } + assert.strictEqual( + itemsFor(api, [FIXTURE.passing]).length, + 1, + 'the test that ran is still exactly one row', + ); + assert.strictEqual( + collectLeafIds(api.testController.items).length, + EXPECTED.length, + 'and the tree still holds every test', + ); + // Interaction 4 - a single-test run is a single CLAUSE. Running one test and + // reporting three is the same defect as running three and reporting one: + // the filter widened past what the user selected ([TEST-FILTER-ESCAPE]). + const oneArgs = buildFilterArgs(itemsFor(api, [FIXTURE.passing])); + assert.strictEqual(oneArgs.length, 2, 'one flag and one expression'); + assert.strictEqual( + oneArgs[1], + filterClause(FIXTURE.passing), + 'and the expression is that one test clause, alone', + ); + assert.strictEqual( + (oneArgs[1] ?? '').includes('|'), + false, + 'with no union operator in it at all', + ); + assert.strictEqual(carriesUniqueId(FIXTURE.passing), false, 'the id it filtered on is bare'); + assert.strictEqual( + itemsFor(api, [FIXTURE.passing])[0]?.children.size, + 0, + 'and the row it addressed is a leaf', + ); + }); + + test('every leaf hangs off Assembly → Namespace → Class, each link bare', function () { + this.timeout(FAST_MS); + + // [TEST-DISCOVERY-FQN] reconstructs the TRX key as `className.name`, so the + // tree's own grouping has to agree with the id: a leaf whose class node + // disagrees with its own name can never be matched to a result. + // + // Interaction 1 — walk each leaf's parent chain to the root. + for (const fqn of EXPECTED) { + const leaf = findItem(api.testController.items, fqn); + assert.ok(leaf, `${fqn} must be a row in the tree`); + const classNode = leaf.parent; + assert.ok(classNode, `${fqn} must hang off a class node`); + const namespaceNode = classNode.parent; + assert.ok(namespaceNode, `${fqn}'s class must hang off a namespace node`); + const assemblyNode = namespaceNode.parent; + assert.ok(assemblyNode, `${fqn}'s namespace must hang off an assembly node`); + assert.strictEqual(assemblyNode.parent, undefined, 'and the assembly is the root'); + + // Interaction 2 — each link is named by the part of the id it groups. + assert.strictEqual(classNode.label, CLASS, `${fqn} is grouped under its class`); + assert.strictEqual(namespaceNode.label, NAMESPACE, 'and that under its namespace'); + assert.strictEqual(assemblyNode.label, FIXTURE.projectName, 'and that under the project'); + assert.strictEqual( + `${namespaceNode.label}.${classNode.label}.${leaf.label}`, + fqn, + 'so the chain spells the fully-qualified name exactly, with nothing added or lost', + ); + + // Interaction 3 — no link carries the adapter's decoration. + for (const node of [leaf, classNode, namespaceNode, assemblyNode]) { + assert.strictEqual( + carriesUniqueId(node.id), + false, + `${node.label} must carry no unique-ID decoration in its id`, + ); + assert.strictEqual( + node.label.includes(' ('), + false, + `${node.label} must not render a hex blob to the user`, + ); + } + } + // Interaction 4 - the parent chain is a chain of GROUPS, and a group id is + // never a test name. A tree that used the leaf's own id for its class row + // makes the class unrunnable and the leaf unfindable. + for (const id of EXPECTED) { + const leaf = findItem(api.testController.items, id); + assert.ok(leaf, `${id} must be a row`); + let node: vscode.TestItem | undefined = leaf.parent; + let links = 0; + while (node !== undefined) { + assert.strictEqual(carriesUniqueId(node.id), false, `${node.label} has a bare group id`); + assert.notStrictEqual(node.id, id, `${node.label} must not reuse the leaf's own id`); + assert.notStrictEqual(node.label, '', 'and must be labelled for the user to read'); + links += 1; + node = node.parent; + } + assert.strictEqual( + links >= 3, + true, + `${id} must hang off Assembly \u2192 Namespace \u2192 Class; it had ${String(links)} link(s)`, + ); + } + // Interaction 4 - the DEPTH is the contract: exactly three groups above every + // leaf. A tree one level shallower has folded the namespace into the class + // row, and the user can no longer run a namespace at all ([TEST-EXPLORER]). + const leafDepths = rootsOf(api.testController.items).flatMap((row) => leavesWithDepth(row, 0)); + assert.strictEqual(leafDepths.length, EXPECTED.length, 'one leaf per fixture test'); + for (const leaf of leafDepths) { + assert.strictEqual(leaf.depth, 3, `${leaf.item.id} hangs three groups below the root`); + assert.strictEqual(leaf.item.children.size, 0, `${leaf.item.id} really is a leaf`); + assert.strictEqual(carriesUniqueId(leaf.item.id), false, `${leaf.item.id} is bare`); + } + assert.deepStrictEqual( + sorted(leafDepths.map((leaf) => leaf.item.id)), + sorted([...EXPECTED]), + 'and the leaves are exactly the fixture tests', + ); + }); + + test('running the same selection twice re-reports it under the SAME bare ids', async function () { + this.timeout(DOTNET_CLI_MS); + + // A decoration that is re-derived per run — rather than stripped once at + // discovery — makes the SECOND run's TRX keys stop matching the tree, which + // presents as every test going grey after working once. + // + // Interaction 1 — run the three outcome kinds once. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, itemsFor(api, RUNNABLE)); + const first = RUNNABLE.map((id) => cachedFor(api, id).outcome); + const idsAfterFirst = sorted(collectLeafIds(api.testController.items)); + assert.deepStrictEqual( + first, + ['passed', 'failed', 'skipped'], + 'the first run tells them apart', + ); + + // Interaction 2 — run exactly the same selection again. + const sizeBefore = api.testController.cachedResults.size; + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, itemsFor(api, RUNNABLE)); + const second = RUNNABLE.map((id) => cachedFor(api, id).outcome); + assert.deepStrictEqual( + second, + first, + 'the same tests run the same way twice — a second run that lost its keys would go notRun', + ); + assert.strictEqual( + api.testController.cachedResults.size, + sizeBefore, + 're-running a selection updates its entries rather than adding new ones', + ); + + // Interaction 3 — nothing about the tree or the ids moved between the runs. + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + idsAfterFirst, + 'the tree is identical after the second run', + ); + for (const id of RUNNABLE) { + assert.strictEqual( + (cachedFor(api, id).message ?? '').includes(NO_RESULT), + false, + `${id} reported on the second run too`, + ); + assert.ok( + api.testController.getResult(id), + `${id} is still cached under the bare id after two runs`, + ); + } + assert.deepStrictEqual( + collectItemIds(api.testController.items).filter((id) => id.includes(' (')), + [], + 'and no node anywhere gained a decoration', + ); + // Interaction 4 - the second run must not have changed the SHAPE of the + // tree, only its results. Re-discovery between runs that produced a second + // copy of a row would leave the user pressing play on a stale one. + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'still ONE assembly root'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...EXPECTED]), + 'and still exactly the tests the fixture declares', + ); + for (const id of RUNNABLE) { + const cached = cachedFor(api, id); + assert.notStrictEqual(cached.outcome, 'notRun', `${id} reports an outcome after the re-run`); + assert.strictEqual( + (cached.message ?? '').includes(NO_RESULT), + false, + `${id} must not report "${NO_RESULT}" on the second run either`, + ); + } + assert.strictEqual( + itemsFor(api, [...RUNNABLE]).length, + RUNNABLE.length, + 'every re-run test resolved to a row of its own', + ); + // Interaction 4 - the second run reported under the SAME ids, so membership + // is unchanged and every outcome is still readable. A run that re-keys its + // results grows the tree by one row per test on every press. + const twiceLeaves = collectLeafIds(api.testController.items); + assert.deepStrictEqual( + sorted(twiceLeaves), + sorted([...EXPECTED]), + 'the same rows survived a second run', + ); + assert.strictEqual(new Set(twiceLeaves).size, twiceLeaves.length, 'none of them duplicated'); + for (const id of RUNNABLE) { + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} still carries a real outcome`, + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, id)).includes(NO_RESULT), + false, + `${id}'s lens reads a result, not an absence`, + ); + } + }); + + test('every line the adapter wrote maps onto exactly one discovered test', function () { + this.timeout(FAST_MS); + + // The reverse direction of the vacuity guard: the first test proves the + // adapter decorates EVERY line, this one proves nothing was lost or invented + // in turning those lines into ids ([TEST-DISCOVERY-FQN]). + // + // Interaction 1 — every raw line reduces to a name that IS a discovered id. + const leaves = collectLeafIds(api.testController.items); + for (const raw of rawListing) { + const bare = withoutAdapterUniqueId(raw); + assert.strictEqual( + leaves.includes(bare), + true, + `the adapter reported ${raw}, which reduces to ${bare} — that must be a row in the tree`, + ); + assert.strictEqual(bare.includes(' ('), false, `${bare} must carry no residual decoration`); + assert.strictEqual(bare.trim(), bare, `${bare} must carry no padding`); + } + + // Interaction 2 — and every discovered id came from at least one line, so + // discovery invented nothing. + for (const id of leaves) { + const lines = rawListing.filter((raw) => withoutAdapterUniqueId(raw) === id); + assert.ok( + lines.length >= 1, + `${id} is a row in the tree, so the adapter must have reported it; it reported: ` + + rawListing.join(' | '), + ); + } + + // Interaction 3 — the many-to-one collapse is real: strictly more lines than + // tests, because a theory reports one line per row, and the set of reduced + // names is exactly the set of leaves. + assert.ok( + rawListing.length > leaves.length, + `a [Theory] reports one decorated line per row, so ${String(rawListing.length)} lines ` + + `must exceed ${String(leaves.length)} tests`, + ); + assert.deepStrictEqual( + sorted([...new Set(rawListing.map((raw) => withoutAdapterUniqueId(raw)))]), + sorted(leaves), + 'the reduced listing and the tree are the same set of names, exactly', + ); + assert.deepStrictEqual( + sorted(parseFullyQualifiedTestList(rawListing.join('\n'))), + sorted(leaves), + 'and the production reader agrees, over the REAL file the adapter wrote', + ); + // Interaction 4 - and the mapping is TOTAL in both directions: no discovered + // test is missing from the listing, and no listed line maps to a test the + // tree does not hold. + const strippedLines = [...new Set(rawListing.map((raw) => withoutAdapterUniqueId(raw)))]; + assert.deepStrictEqual( + sorted(strippedLines), + sorted([...EXPECTED]), + 'the stripped listing and the fixture declare exactly the same set', + ); + for (const id of discovered) { + assert.strictEqual( + strippedLines.includes(id), + true, + `${id} is in the tree, so some line of the adapter's listing must reduce to it`, + ); + } + for (const line of strippedLines) { + assert.strictEqual( + discovered.includes(line), + true, + `${line} was listed by the adapter, so it must be a discovered test`, + ); + } + assert.strictEqual( + rawListing.length >= EXPECTED.length, + true, + 'the adapter wrote at least one line per test, and more for the theory rows', + ); + // Interaction 4 - the mapping is total in BOTH directions: every discovered + // id is claimed by a line the adapter actually wrote, so no row in the tree + // was invented by the reader ([TEST-DISCOVERY-FQN]). + for (const id of discovered) { + assert.strictEqual( + rawListing.some((line) => withoutAdapterUniqueId(line) === id), + true, + `${id} came from a line the adapter actually wrote`, + ); + } + assert.strictEqual( + new Set(rawListing.map((line) => withoutAdapterUniqueId(line))).size, + discovered.length, + 'and the distinct stripped names are exactly as many as the discovered rows', + ); + assert.deepStrictEqual( + sorted(parseFullyQualifiedTestList(rawListing.join('\n'))), + sorted(discovered), + 'so re-reading the listing yields the same tree the discovery pass built', + ); + assert.strictEqual( + rawListing.length >= discovered.length, + true, + 'with at least one written line per discovered test', + ); + assert.strictEqual(discovered.length, EXPECTED.length, 'and every fixture test in the tree'); }); }); diff --git a/src/editors/vscode/src/test/suite/test-explorer-cancellation.test.ts b/src/editors/vscode/src/test/suite/test-explorer-cancellation.test.ts index 4bd80051..ef19102e 100644 --- a/src/editors/vscode/src/test/suite/test-explorer-cancellation.test.ts +++ b/src/editors/vscode/src/test/suite/test-explorer-cancellation.test.ts @@ -4,27 +4,39 @@ // Stop has to do something OBSERVABLE, and a cancellation test that only checks // outcomes cannot see whether it did: a run that ignores cancellation entirely // still leaves a red test red and a skipped test skipped. So the fixture carries -// a deliberately LONG-RUNNING test that writes a `started` marker, sleeps, and -// then writes a `finished` marker. That makes both halves of the contract +// two deliberately LONG-RUNNING tests that each write a `started` marker, sleep, +// and then write a `finished` marker. That makes every half of the contract // falsifiable on disk: // -// • the CONTROL run is not cancelled, so both markers appear and the outcome -// is cached — proving the fixture really does write `finished`, and so that -// its ABSENCE below means something, +// • the CONTROL run is not cancelled, so every marker appears and every +// outcome is cached — proving the fixture really does write `finished`, and +// so that its ABSENCE below means something, // • the CANCELLED run presses Stop the moment `started` appears, so `finished` // must NEVER appear even long after the sleep would have elapsed. `dotnet // test` runs tests in a testhost GRANDCHILD, so this fails unless the whole // process TREE is terminated, not just the `dotnet` parent, -// • and no result may be cached for either selected test, because a result -// that arrives after Stop describes a run that was killed mid-flight. +// • no result may be cached for any selected test, because a result that +// arrives after Stop describes a run that was killed mid-flight, +// • and the controller's single `dotnet` queue ([TEST-REACTIVITY]) must be +// DRAINED afterwards, not left holding an abandoned invocation: the next ▶ +// the user presses has to work. // -// Covers [TEST-RUN-TRX] and the Stop half of [TEST-EXPLORER]. F# first. +// The permutations are the gestures a user actually makes: Stop on ▶, on Run +// with Coverage, on a namespace row, on the assembly root, on a multi-select, a +// token that was already cancelled before the handler started, Stop pressed +// twice, Stop after the run already ended, and two cancelled runs back to back. +// +// Covers [TEST-RUN-TRX], [TEST-REACTIVITY], [TEST-COVERAGE] and the Stop half of +// [TEST-EXPLORER]. F# first. import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import * as vscode from 'vscode'; import type { SharpLspExtensionApi } from '../../extension.js'; +import { findCoberturaFiles } from '../../test-coverage.js'; +import { filterClause } from '../../test-filter.js'; +import { statusLensTitle } from '../../test-lens.js'; import { createSolution, projectXml, @@ -32,26 +44,31 @@ import { writeProject, XUNIT_PACKAGES, } from './dotnet-project-kit'; +import { COVERAGE_DIR_NAME, COVERLET_PACKAGE, reportDirsOf } from './test-coverage-fixtures'; import { activateTestExplorer, collectLeafIds, drainDiscovery, + findItem, pollUntilDiscovered, + profileOfKind, + rootsOf, + runAlreadyCancelled, runAndCancelWhen, runViaProfile, } from './test-explorer-kit'; -import { itemsFor, sorted } from './test-explorer-outcome-assertions'; +import { cachedFor, itemsFor, sorted } from './test-explorer-outcome-assertions'; import { pollUntilResult, removeDirRecursive, sleep } from './test-helpers.js'; import { DOTNET_CLI_MS, FIXTURE_BUILD_MS } from './test-timeouts'; /** - * How long the long-running fixture test sleeps. + * How long each long-running fixture test sleeps. * * Long enough that an UNCANCELLED run cannot possibly finish inside * {@link STOP_BUDGET_MS}, and short enough that the control run — which waits - * the whole sleep out — stays cheap. + * every sleep out — stays affordable. */ -const FIXTURE_SLEEP_SECONDS = 20; +const FIXTURE_SLEEP_SECONDS = 12; const FIXTURE_SLEEP_MS = FIXTURE_SLEEP_SECONDS * 1_000; /** @@ -60,19 +77,69 @@ const FIXTURE_SLEEP_MS = FIXTURE_SLEEP_SECONDS * 1_000; * Comfortably under {@link FIXTURE_SLEEP_MS}: a run that merely awaited the * batch instead of killing it could not return this early. */ -const STOP_BUDGET_MS = 12_000; +const STOP_BUDGET_MS = 6_000; + +/** How fast a run must return when its token was cancelled before it began. */ +const PRE_CANCELLED_BUDGET_MS = 4_000; /** Extra time past the sleep before concluding the process is really gone. */ -const TERMINATION_GRACE_MS = 15_000; +const TERMINATION_GRACE_MS = 8_000; + +/** The F# module every fixture test lives in — the tree's namespace row. */ +const NAMESPACE = 'Fs.Cancel.Fixtures'; + +/** + * How that module renders: a CLASS row named for the TYPE under a NAMESPACE + * row carrying the rest. An F# module compiles to a CLR type, so the tree + * splits `Fs.Cancel.Fixtures` exactly as it splits a C# class. + */ +const MODULE_TYPE = NAMESPACE.slice(NAMESPACE.lastIndexOf('.') + 1); +const MODULE_NAMESPACE = NAMESPACE.slice(0, NAMESPACE.lastIndexOf('.')); + +/** The project, which is also the assembly root's label. */ +const PROJECT = 'CancelFs'; + +/** One long-running fixture test and the two markers it writes. */ +interface LongTest { + readonly binding: string; + readonly fqn: string; + readonly started: string; + readonly finished: string; +} + +const longTest = (binding: string, suffix: string): LongTest => ({ + binding, + fqn: `${NAMESPACE}.${binding}`, + started: `started-${suffix}`, + finished: `finished-${suffix}`, +}); + +/** + * Two long tests, not one. + * + * A single one cannot distinguish "the run was cancelled" from "the one test + * that was running was cancelled": a selection of two proves Stop ends the whole + * BATCH, because xUnit runs both facts of one module sequentially, so whichever + * is second must never even start. + * + * WHICH is second is xUnit's choice, not this file's. `DefaultTestCaseOrderer` + * sorts the facts of a class by a hash of their names, so source order predicts + * nothing — every assertion below names the test Stop actually caught, and the + * ones queued behind it, rather than assuming an index. + */ +const LONG_TESTS: readonly LongTest[] = [ + longTest('sleeps until stopped', 'one'), + longTest('also sleeps until stopped', 'two'), +]; -/** Marker file names the fixture writes. */ -const STARTED_MARKER = 'started'; -const FINISHED_MARKER = 'finished'; +/** The fast test batched alongside them. */ +const FAST_TEST = `${NAMESPACE}.adds two numbers`; -/** The long-running F# test, and the fast one batched alongside it. */ -const LONG_TEST = 'Fs.Cancel.Fixtures.sleeps until stopped'; -const FAST_TEST = 'Fs.Cancel.Fixtures.adds two numbers'; -const ALL_TESTS: readonly string[] = [LONG_TEST, FAST_TEST]; +/** Every test the fixture exposes. */ +const ALL_TESTS: readonly string[] = [...LONG_TESTS.map((each) => each.fqn), FAST_TEST]; + +/** Every marker file an uncancelled run of the whole fixture writes. */ +const EVERY_MARKER: readonly string[] = LONG_TESTS.flatMap((each) => [each.started, each.finished]); /** * The fixture source, with the marker directory baked in as an F# literal. @@ -82,8 +149,16 @@ const ALL_TESTS: readonly string[] = [LONG_TEST, FAST_TEST]; * Windows backslashes is needed inside the literal. */ function fixtureSource(markerDir: string): string { + const sleeper = (each: LongTest): string[] => [ + '[<Fact>]', + `let \`\`${each.binding}\`\` () =`, + ` mark "${each.started}"`, + ` Thread.Sleep(TimeSpan.FromSeconds ${String(FIXTURE_SLEEP_SECONDS)}.0)`, + ` mark "${each.finished}"`, + '', + ]; return [ - 'module Fs.Cancel.Fixtures', + `module ${NAMESPACE}`, '', 'open System', 'open System.IO', @@ -94,12 +169,7 @@ function fixtureSource(markerDir: string): string { '', 'let private mark (name: string) = File.WriteAllText(Path.Combine(markers, name), "1")', '', - '[<Fact>]', - 'let ``sleeps until stopped`` () =', - ` mark "${STARTED_MARKER}"`, - ` Thread.Sleep(TimeSpan.FromSeconds ${String(FIXTURE_SLEEP_SECONDS)}.0)`, - ` mark "${FINISHED_MARKER}"`, - '', + ...LONG_TESTS.flatMap(sleeper), '[<Fact>]', 'let ``adds two numbers`` () = Assert.Equal(3, 1 + 2)', '', @@ -110,38 +180,135 @@ suite('Test Explorer e2e — pressing Stop kills the run', () => { let api: SharpLspExtensionApi; let root: string; let markerDir: string; - let startedMarker: string; - let finishedMarker: string; + let coverageDir: string; /** True once the fixture has written `name` into the marker directory. */ const marked = (name: string): boolean => fs.existsSync(path.join(markerDir, name)); + /** Every marker currently on disk, so a failure names what actually ran. */ + const markersOnDisk = (): string[] => fs.readdirSync(markerDir).sort(); + + /** Wipe every marker, so the next run's evidence is its own. */ + const clearMarkers = (): void => { + for (const name of fs.readdirSync(markerDir)) { + fs.rmSync(path.join(markerDir, name), { force: true }); + } + }; + + /** Every long test that has announced itself, in the order they are declared. */ + const startedLongTests = (): LongTest[] => LONG_TESTS.filter((each) => marked(each.started)); + + /** The long tests xUnit had QUEUED behind `running` when Stop landed. */ + const queuedBehind = (running: LongTest): LongTest[] => + LONG_TESTS.filter((each) => each.fqn !== running.fqn); + /** - * Resolve once the long test announces it is running, else after the timeout. + * Resolve with the long test xUnit actually started FIRST — the "the run is + * under way" signal. + * + * It polls for ANY long test's `started` marker rather than a named one on + * purpose. xUnit picks the order (see {@link LONG_TESTS}), so waiting on a + * named marker waits the OTHER test's entire sleep out first, and then presses + * Stop on a batch whose earlier test has already legitimately finished — + * indistinguishable, from the markers alone, from a cancellation that failed. * - * The marker lands a second or two into the `dotnet test` invocation, so the - * ceiling here is one CLI round trip, not the whole fixture sleep. + * The ceiling is one CLI round trip: the marker lands a second or two into the + * `dotnet test` invocation, not a whole fixture sleep later. */ - const untilStarted = async (): Promise<boolean> => + const untilRunning = async (): Promise<LongTest | undefined> => pollUntilResult( - () => Promise.resolve(marked(STARTED_MARKER)), - (seen) => seen, + () => Promise.resolve(startedLongTests()[0]), + (found) => found !== undefined, DOTNET_CLI_MS, ); + /** How fast one Stop gesture returned, and which long test it caught running. */ + interface StopOutcome { + readonly afterStop: number; + readonly running: LongTest; + } + + /** + * Press ▶/coverage on `items` and press ⏹ the moment the run is demonstrably + * under way, reporting how long the handler took to return after Stop and + * which long test was executing when it did. + */ + const runAndStop = async ( + kind: vscode.TestRunProfileKind, + items: readonly vscode.TestItem[], + ): Promise<StopOutcome> => { + let stoppedAt = 0; + const trigger = untilRunning().then((seen) => { + stoppedAt = Date.now(); + return seen; + }); + await assert.doesNotReject(async () => { + await runAndCancelWhen(api.testController, kind, items, trigger); + }, 'a cancelled run must resolve, never reject — a rejected runHandler leaves the run spinning'); + const running = await trigger; + assert.ok(running, 'a long test must have started, or Stop cancelled nothing at all'); + return { afterStop: Date.now() - stoppedAt, running }; + }; + + /** + * Assert Stop ended the whole batch: the test it caught was TERMINATED rather + * than waited out, and every test queued behind it never ran at all. + * + * Call it only after the fixture sleep has demonstrably elapsed, so a process + * that survived has had every chance to write its finish marker. + */ + const assertBatchKilled = (running: LongTest, why: string): void => { + const markers = (): string => markersOnDisk().join(', ') || '(none)'; + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.finished), + false, + `${each.fqn} must be TERMINATED by Stop ${why} — it wrote its finish marker, so ` + + '`dotnet test` (or the testhost grandchild it spawns) outlived the cancellation; ' + + `markers on disk: ${markers()}`, + ); + } + for (const queued of queuedBehind(running)) { + assert.strictEqual( + marked(queued.started), + false, + `Stop ends the whole BATCH ${why}: ${queued.fqn} was queued behind ${running.fqn} ` + + `and must never start; markers on disk: ${markers()}`, + ); + } + assert.deepStrictEqual( + startedLongTests().map((each) => each.fqn), + [running.fqn], + `exactly the one test Stop caught ever ran ${why}; markers on disk: ${markers()}`, + ); + }; + + /** Assert the controller's queue really drained, and how fast. */ + const assertIdlePromptly = async (why: string): Promise<void> => { + const idleAt = Date.now(); + await api.testController.whenIdle(); + assert.ok( + Date.now() - idleAt < STOP_BUDGET_MS, + `${why}: the cancelled invocation must be OVER, not merely abandoned while still running`, + ); + }; + suiteSetup(async function () { // Cold restore + build + VSTest adapter JIT over the fixture solution. this.timeout(FIXTURE_BUILD_MS); api = await activateTestExplorer(); root = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-testcancel-')); markerDir = path.join(root, 'markers'); + coverageDir = path.join(root, COVERAGE_DIR_NAME); fs.mkdirSync(markerDir, { recursive: true }); - startedMarker = path.join(markerDir, STARTED_MARKER); - finishedMarker = path.join(markerDir, FINISHED_MARKER); const projectDir = writeProject( - path.join(root, 'CancelFs'), - 'CancelFs.fsproj', - projectXml(XUNIT_PACKAGES, 'Tests.fs'), + path.join(root, PROJECT), + `${PROJECT}.fsproj`, + // `coverlet.collector` is what turns `--collect:"XPlat Code Coverage"` + // into a report on disk. Without it a coverage run writes NOTHING, and + // "the killed run left no report" holds for a reason that has nothing to + // do with cancellation — so does "the completed run left one", falsely. + projectXml([...XUNIT_PACKAGES, COVERLET_PACKAGE], 'Tests.fs'), 'Tests.fs', fixtureSource(markerDir), ); @@ -163,117 +330,1546 @@ suite('Test Explorer e2e — pressing Stop kills the run', () => { removeDirRecursive(root); }); - test('a run left alone writes BOTH markers and caches both outcomes', async function () { + test('a run left alone writes EVERY marker and caches every outcome', async function () { this.timeout(DOTNET_CLI_MS); + // The control. Without it, "the finished marker is absent" below could hold // simply because the fixture never writes one. + // + // Interaction 1 — the tree is exactly the fixture, and nothing has run. assert.deepStrictEqual( sorted(collectLeafIds(api.testController.items)), sorted(ALL_TESTS), - 'exactly the long test and the fast test must be discovered', + 'both long tests and the fast test must be discovered', ); - assert.strictEqual(fs.existsSync(startedMarker), false, 'no run has happened yet'); - assert.strictEqual(fs.existsSync(finishedMarker), false, 'so neither marker exists'); + clearMarkers(); + assert.deepStrictEqual(markersOnDisk(), [], 'no run has happened yet, so no markers exist'); const items = itemsFor(api, ALL_TESTS); - assert.strictEqual(items.length, 2, 'both fixture tests resolved to tree items'); + assert.strictEqual( + items.length, + ALL_TESTS.length, + 'every fixture test resolved to a tree item', + ); + assert.deepStrictEqual( + items.map((item) => item.id), + [...ALL_TESTS], + 'and to the tests actually selected', + ); + + // Interaction 2 — press ▶ and let it finish. Every long test runs to + // COMPLETION, and the run really did wait for them. const started = Date.now(); await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, items); const elapsed = Date.now() - started; + assert.deepStrictEqual( + markersOnDisk(), + sorted(EVERY_MARKER), + 'each long test must write both its markers when left alone — otherwise nothing ' + + 'below tests anything', + ); + assert.ok( + elapsed >= FIXTURE_SLEEP_MS * LONG_TESTS.length, + `an uncancelled run waits every ${String(FIXTURE_SLEEP_SECONDS)}s sleep out; ` + + `took ${String(elapsed)}ms for ${String(LONG_TESTS.length)} of them`, + ); + + // Interaction 3 — every outcome is cached, green, and renders as a pass. + for (const id of ALL_TESTS) { + const result = api.testController.getResult(id); + assert.ok(result, `the control run must cache a result for ${id}`); + assert.strictEqual(result.outcome, 'passed', `${id} passes when left alone`); + assert.strictEqual(result.passed, true, `${id} carries the pass flag`); + assert.strictEqual( + (result.message ?? '').includes('No result reported'), + false, + `${id} really ran, so it reports no missing-result note`, + ); + assert.ok(Number(result.duration) >= 0, `${id} carries a measured duration`); + assert.strictEqual( + statusLensTitle(result).startsWith('$(pass) Passed'), + true, + `${id} renders above its binding as a pass`, + ); + } assert.strictEqual( - fs.existsSync(startedMarker), + api.testController.cachedResults.size >= ALL_TESTS.length, true, - 'the long test must actually have run — otherwise nothing below tests anything', + 'one batched invocation reported every selected test', + ); + // Interaction 4 - the control run is what makes every cancellation + // assertion falsifiable. If an uncancelled run could not finish either, + // "Stop terminated it" would be indistinguishable from "it never worked". + assert.deepStrictEqual( + markersOnDisk(), + [...EVERY_MARKER].sort(), + 'an uncancelled run writes every marker the fixture declares, start and finish alike', + ); + for (const each of LONG_TESTS) { + assert.strictEqual(marked(each.started), true, `${each.fqn} started`); + assert.strictEqual(marked(each.finished), true, `${each.fqn} ran to its end`); + assert.strictEqual( + cachedFor(api, each.fqn).outcome, + 'passed', + `${each.fqn} reports a real outcome from the TRX report`, + ); + } + assert.strictEqual( + cachedFor(api, FAST_TEST).outcome, + 'passed', + 'and so does the fast test that shares the invocation', ); assert.strictEqual( - fs.existsSync(finishedMarker), - true, - 'and must have run to COMPLETION, writing its finish marker', + startedLongTests().length, + LONG_TESTS.length, + 'every long test really ran - nothing was skipped by the runner itself', + ); + // Interaction 4 - the control run is what makes every cancellation assertion + // in this suite FALSIFIABLE. If `finished` never appeared even here, its + // absence after Stop would prove nothing whatsoever. + assert.deepStrictEqual( + sorted(markersOnDisk()), + sorted([...EVERY_MARKER]), + 'an uncancelled run writes every start AND every finish marker', + ); + for (const each of LONG_TESTS) { + assert.strictEqual(marked(each.finished), true, `${each.fqn} ran to completion`); + assert.notStrictEqual( + cachedFor(api, each.fqn).outcome, + 'notRun', + `${each.fqn} was attributed a real outcome`, + ); + } + assert.strictEqual( + cachedFor(api, FAST_TEST).outcome, + 'passed', + 'and the fast test batched alongside them passed', + ); + await assertIdlePromptly('after a run that was never cancelled'); + }); + + test('pressing Stop TERMINATES the running test process TREE and suppresses its results', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — a clean slate, and a cache that already holds real passes, + // so a suppressed result is visibly different from a fresh one. + clearMarkers(); + assert.deepStrictEqual(markersOnDisk(), [], 'markers cleared before the run'); + const baseline = new Map(api.testController.cachedResults); + for (const id of ALL_TESTS) { + assert.strictEqual( + baseline.get(id)?.outcome, + 'passed', + `the control run left a PASS cached for ${id}`, + ); + } + + // Interaction 2 — press ▶, then ⏹ the moment the first long test announces + // itself. Stop must END the run, not wait it out. + const { afterStop, running } = await runAndStop( + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), ); + assert.strictEqual(marked(running.started), true, 'the run really was under way'); assert.ok( - elapsed >= FIXTURE_SLEEP_MS, - `an uncancelled run waits the whole ${String(FIXTURE_SLEEP_SECONDS)}s out, took ${String(elapsed)}ms`, - ); - const long = api.testController.getResult(LONG_TEST); - const fast = api.testController.getResult(FAST_TEST); - assert.ok(long, `the control run must cache a result for ${LONG_TEST}`); - assert.ok(fast, `and one for ${FAST_TEST}`); - assert.strictEqual(long.outcome, 'passed', 'the long test passes when left alone'); - assert.strictEqual(fast.outcome, 'passed', 'and so does the fast one batched with it'); - assert.strictEqual(long.passed, true, 'a real pass carries the pass flag'); - assert.strictEqual(fast.passed, true, 'for both tests of the single batched invocation'); + afterStop < STOP_BUDGET_MS, + `Stop must END the run: returned ${String(afterStop)}ms after Stop, budget ` + + `${String(STOP_BUDGET_MS)}ms, fixture sleep ${String(FIXTURE_SLEEP_MS)}ms`, + ); + + // Interaction 3 — past the point where a SURVIVING test process would have + // written `finished`, no long test has finished and none of the ones queued + // behind it ever started. + await sleep(FIXTURE_SLEEP_MS + TERMINATION_GRACE_MS - afterStop); + assertBatchKilled(running, 'on ▶'); + + // Interaction 4 — every result is suppressed, the cache is untouched and the + // tree is exactly as it was. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `a result arriving after Stop must be SUPPRESSED for ${id}, leaving the last real run standing`, + ); + } + assert.strictEqual( + api.testController.cachedResults.size, + baseline.size, + 'a cancelled run invents no cache entries', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'and leaves the tree exactly as it was', + ); + await assertIdlePromptly('after Stop on ▶'); + // Interaction 4 - the tree and the queue after a kill. A cancelled run must + // leave the Testing view standing and the single `dotnet` queue drained + // ([TEST-REACTIVITY]), or the next gesture races the corpse of this one. + await assertIdlePromptly('after Stop on the play button'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'the tree still holds every discovered test after a cancelled run', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'under ONE assembly root'); + for (const id of ALL_TESTS) { + const item = findItem(api.testController.items, id); + assert.ok(item, `${id} must still be a row`); + assert.strictEqual(item.id, id, 'under its own fully-qualified name'); + assert.strictEqual(item.children.size, 0, 'and still a leaf'); + } + assert.strictEqual( + fs.existsSync(markerDir), + true, + 'and the fixture marker directory survives, so the next test can read it', + ); + // Interaction 4 - a SUPPRESSED result is not a FAILED result. A cancelled + // test that lands red teaches the user their code broke when in fact they + // pressed Stop ([TEST-RUN-TRX]). + for (const each of LONG_TESTS) { + assert.notStrictEqual( + cachedFor(api, each.fqn).outcome, + 'failed', + `${each.fqn} must not be painted red by a cancellation`, + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, each.fqn)).includes('\n'), + false, + `${each.fqn}'s lens still renders on ONE line`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and killing the process tree removed no row from the tree', + ); + await assertIdlePromptly('after Stop terminated the process tree'); }); - test('pressing Stop TERMINATES the running test process and suppresses its results', async function () { + test('pressing Stop during a Run with Coverage kills it and attaches no report', async function () { this.timeout(DOTNET_CLI_MS); - fs.rmSync(startedMarker, { force: true }); - fs.rmSync(finishedMarker, { force: true }); - assert.strictEqual(fs.existsSync(startedMarker), false, 'markers cleared before the run'); - assert.strictEqual(fs.existsSync(finishedMarker), false, 'both of them'); + + // Interaction 1 — a clean slate. Coverage lands beside the solution, and + // nothing is there yet. + clearMarkers(); + removeDirRecursive(coverageDir); + assert.deepStrictEqual(markersOnDisk(), [], 'markers cleared'); + assert.strictEqual(fs.existsSync(coverageDir), false, `${COVERAGE_DIR_NAME} starts absent`); const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — Run with Coverage, then ⏹. The coverage profile spawns the + // same batched `dotnet test`, so Stop has the same contract on it. + const { afterStop, running } = await runAndStop( + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_TESTS), + ); + assert.ok( + afterStop < STOP_BUDGET_MS, + `Stop must end a COVERAGE run just as promptly: ${String(afterStop)}ms`, + ); + + // Interaction 3 — the process tree is dead, so no long test finished… + await sleep(FIXTURE_SLEEP_MS + TERMINATION_GRACE_MS - afterStop); + assertBatchKilled(running, 'under the Coverage profile too'); + + // Interaction 4 — …and nothing from the killed run is reported: no outcome, + // and no Cobertura report describing coverage that was never collected. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `a cancelled coverage run must suppress ${id}'s result too`, + ); + } assert.strictEqual( - baseline.get(LONG_TEST)?.outcome, - 'passed', - 'the control run left a PASS cached, so a suppressed result is visibly different', + api.testController.cachedResults.size, + baseline.size, + 'and invent no cache entries', ); - let stoppedAt = 0; - const trigger = untilStarted().then((seen) => { - stoppedAt = Date.now(); - return seen; - }); + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + [], + 'a run killed mid-flight collected nothing, so it must leave no Cobertura report ' + + 'for the gutter to paint from', + ); + await assertIdlePromptly('after Stop on Run with Coverage'); + // Interaction 4 - a cancelled COVERAGE run must attach nothing, and must + // not leave a half-written report for the next run to read as its own + // ([TEST-COVERAGE] "reusing the directory would show the previous run's + // report"). + const leftovers = fs.existsSync(coverageDir) ? fs.readdirSync(coverageDir) : []; + assert.deepStrictEqual( + leftovers.filter((entry) => entry.endsWith('.xml')), + [], + 'a killed coverage run leaves no report at the top of the results directory', + ); + await assertIdlePromptly('after Stop during a coverage run'); + assert.strictEqual( + profileOfKind(api.testController, vscode.TestRunProfileKind.Coverage).kind, + vscode.TestRunProfileKind.Coverage, + 'the Coverage profile is still registered after being cancelled', + ); + assert.strictEqual( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Coverage, + ).length, + 1, + 'and there is still exactly one of it', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'with the tree intact', + ); + // Interaction 4 - a killed coverage run must leave the results directory in a + // state the NEXT run can trust. A half-written report is worse than none, + // because it parses ([TEST-COVERAGE] claim 1). + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + [], + 'no report is readable after the kill', + ); + assert.deepStrictEqual(reportDirsOf(coverageDir), [], 'and no run-id folder holds one'); + assert.strictEqual( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Coverage, + ).length, + 1, + 'the Coverage profile survived being cancelled', + ); + assert.ok( + profileOfKind(api.testController, vscode.TestRunProfileKind.Run), + 'and the plain Run profile is still registered beside it', + ); + await assertIdlePromptly('after a cancelled coverage run'); + }); + + test('a token already cancelled before the handler starts spawns nothing at all', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — clean slate. The workbench can hand the handler a token + // that is already cancelled: the user pressed ⏹ while the run was queued. + clearMarkers(); + const baseline = new Map(api.testController.cachedResults); + assert.deepStrictEqual(markersOnDisk(), [], 'nothing has run'); + + // Interaction 2 — the handler must resolve, and fast: it has nothing to do. + const started = Date.now(); await assert.doesNotReject(async () => { - await runAndCancelWhen( + await runAlreadyCancelled( api.testController, vscode.TestRunProfileKind.Run, itemsFor(api, ALL_TESTS), - trigger, ); - }, 'a cancelled run must resolve, never reject — a rejected runHandler leaves the run spinning'); - const afterStop = Date.now() - stoppedAt; + }, 'a pre-cancelled run must resolve, never reject'); + const elapsed = Date.now() - started; + assert.ok( + elapsed < PRE_CANCELLED_BUDGET_MS, + `a run whose token was already cancelled must not restore, build and execute ` + + `anything; it took ${String(elapsed)}ms`, + ); + + // Interaction 3 — no test process was ever spawned, so not one marker was + // written, even after the sleep would have elapsed. + await sleep(FIXTURE_SLEEP_MS); + assert.deepStrictEqual( + markersOnDisk(), + [], + 'a pre-cancelled run must not spawn a test process whose results it would then ' + + `have to throw away; markers written: ${markersOnDisk().join(', ') || '(none)'}`, + ); + + // Interaction 4 — and nothing was reported or forgotten. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `${id}'s cached result is untouched by a run that never ran`, + ); + } + assert.strictEqual( + api.testController.cachedResults.size, + baseline.size, + 'cache size unchanged', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'and the tree is untouched', + ); + await assertIdlePromptly('after a pre-cancelled run'); + // Interaction 4 - a pre-cancelled token must spawn NOTHING. Asserted on + // disk after the fixture sleep would have elapsed, so a process that did + // start has had every chance to prove it. + assert.deepStrictEqual( + markersOnDisk(), + [], + 'a run whose token was cancelled before the handler began must not have started a ' + + 'single test - a marker here means `dotnet test` was spawned regardless', + ); + for (const each of LONG_TESTS) { + assert.strictEqual(marked(each.started), false, `${each.fqn} never started`); + assert.strictEqual(marked(each.finished), false, `${each.fqn} never finished`); + } + await assertIdlePromptly('after a pre-cancelled run'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and the tree is untouched', + ); + // Interaction 4 - "spawns nothing" is a claim about the DISK as much as the + // clock. A process that started and was killed a millisecond later still + // writes its start marker on the way past. + assert.deepStrictEqual(markersOnDisk(), [], 'not one marker was written'); + assert.deepStrictEqual(startedLongTests(), [], 'so no long test ever began'); + for (const each of LONG_TESTS) { + assert.strictEqual(marked(each.started), false, `${each.fqn} never announced itself`); + assert.strictEqual(marked(each.finished), false, `and ${each.fqn} never finished either`); + } assert.strictEqual( - await trigger, + PRE_CANCELLED_BUDGET_MS < STOP_BUDGET_MS, true, - 'the long test must have started, or Stop cancelled nothing at all', + 'and a pre-cancelled run must return faster than one that had to be killed', ); - assert.strictEqual(fs.existsSync(startedMarker), true, 'and said so on disk'); + await assertIdlePromptly('after a run whose token was cancelled before it started'); + }); + + test('pressing Stop on the NAMESPACE row cancels every test beneath it', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the user presses ▶ on the group row, not on a leaf. An F# + // module compiles to a CLR TYPE, so `Fs.Cancel.Fixtures` renders the same + // way a C# class does: a class row named `Fixtures` under a namespace row + // named `Fs.Cancel` (Assembly → Namespace → Class → Test). The row holding + // every binding is therefore the module's class row. + clearMarkers(); + const leaf = findItem(api.testController.items, FAST_TEST); + assert.ok(leaf, `${FAST_TEST} must be a row in the tree`); + const namespaceNode = leaf.parent; + assert.ok(namespaceNode, 'a leaf hangs off the group it belongs to'); + assert.strictEqual( + namespaceNode.label, + MODULE_TYPE, + 'and that parent is the F# module, by its TYPE name', + ); + assert.strictEqual( + namespaceNode.parent?.label, + MODULE_NAMESPACE, + 'which itself hangs off the namespace enclosing the module', + ); + assert.strictEqual( + `${MODULE_NAMESPACE}.${MODULE_TYPE}`, + NAMESPACE, + 'and the two rejoin to exactly the module the fixture declares', + ); + assert.strictEqual( + namespaceNode.children.size, + ALL_TESTS.length, + 'the module contains every fixture test', + ); + const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — Stop, once the batch is demonstrably running. + const { afterStop, running } = await runAndStop(vscode.TestRunProfileKind.Run, [namespaceNode]); assert.ok( afterStop < STOP_BUDGET_MS, - `Stop must END the run, not wait it out: returned ${String(afterStop)}ms after Stop, ` + - `budget ${String(STOP_BUDGET_MS)}ms, fixture sleep ${String(FIXTURE_SLEEP_MS)}ms`, + `Stop on a group row must end the run as promptly as on a leaf: ${String(afterStop)}ms`, ); + assert.strictEqual(marked(running.started), true, 'the batch really was running'); + for (const queued of queuedBehind(running)) { + assert.strictEqual( + marked(queued.started), + false, + `${queued.fqn} sits under the same group row and must never start once it is cancelled`, + ); + } - // Past the point where a SURVIVING test process would have written `finished`. - await sleep(FIXTURE_SLEEP_MS + TERMINATION_GRACE_MS - afterStop); + // Interaction 3 — every test beneath the row is suppressed, not just the one + // that happened to be executing. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `${id} sits under the cancelled group row, so its result is suppressed`, + ); + } + assert.strictEqual(api.testController.cachedResults.size, baseline.size, 'no entries invented'); assert.strictEqual( - fs.existsSync(finishedMarker), - false, - 'the spawned test process must be TERMINATED by Stop — it wrote its finish marker, ' + - 'so `dotnet test` (or the testhost grandchild it spawns) outlived the cancellation', + namespaceNode.children.size, + ALL_TESTS.length, + 'and the group row keeps its children', + ); + await assertIdlePromptly('after Stop on the namespace row'); + // Interaction 4 - the namespace row is a GROUP, and cancelling a group must + // leave the group itself intact for the user to press again. + const namespaceRow = findItem(api.testController.items, NAMESPACE); + if (namespaceRow !== undefined) { + assert.strictEqual(namespaceRow.children.size >= 1, true, 'the module row still holds tests'); + assert.strictEqual( + namespaceRow.canResolveChildren, + true, + 'and still declares them, so the row stays expandable', + ); + } + assert.strictEqual( + MODULE_TYPE.length > 0 && MODULE_NAMESPACE.length > 0, + true, + 'the F# module really does sit under a namespace of its own', + ); + await assertIdlePromptly('after Stop on the namespace row'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and every test beneath it is still discovered', + ); + // Interaction 4 - a namespace row is a GROUP gesture, so Stop on it ends the + // whole batch it dispatched, not merely the test it caught. The controller's + // queue is the observable: an abandoned invocation still holds it + // ([TEST-REACTIVITY]). + assert.strictEqual( + collectLeafIds(api.testController.items).length, + ALL_TESTS.length, + 'every test is still discoverable after cancelling a namespace', + ); + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.finished), + false, + `${each.fqn} beneath the namespace was terminated, not waited out`, + ); + } + assert.strictEqual( + itemsFor(api, [FAST_TEST]).length, + 1, + 'and the fast test in the same namespace is still addressable', + ); + await assertIdlePromptly('after Stop on a namespace row'); + }); + + test('pressing Stop on the ASSEMBLY root cancels the whole project', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the top row of the Testing view, which is the project. + clearMarkers(); + const roots = rootsOf(api.testController.items); + assert.strictEqual(roots.length, 1, 'the fixture is one project, so one assembly root'); + const assemblyNode = roots[0]; + assert.ok(assemblyNode, 'the assembly root is readable'); + assert.strictEqual(assemblyNode.label, PROJECT, 'labelled for the project'); + assert.strictEqual( + assemblyNode.id.startsWith('assembly:'), + true, + `an assembly root is a GROUP id, never an FQN; got ${assemblyNode.id}`, + ); + const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — run everything from the root, then Stop. + const { afterStop, running } = await runAndStop(vscode.TestRunProfileKind.Run, [assemblyNode]); + assert.ok( + afterStop < STOP_BUDGET_MS, + `Stop on the assembly root must end the run: ${String(afterStop)}ms`, + ); + assert.strictEqual(marked(running.started), true, 'the whole-project batch really was running'); + + // Interaction 3 — nothing is attributed, and the whole tree survives. A + // cancelled root run that cleared the tree would look like a failed + // discovery to the user. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `${id} is under the cancelled root, so its result is suppressed`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'the tree is left standing after a cancelled root run', ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'and still shows exactly one assembly root', + ); + await assertIdlePromptly('after Stop on the assembly root'); + // Interaction 4 - the assembly root is the widest gesture there is, and + // cancelling it must still leave exactly one root behind. + await assertIdlePromptly('after Stop on the assembly root'); + const rootRows = rootsOf(api.testController.items); + assert.strictEqual(rootRows.length, 1, 'still ONE assembly root after cancelling it'); + const only = rootRows[0]; + assert.ok(only, 'and it exists'); + assert.strictEqual(only.label, PROJECT, 'labelled with the project the user recognises'); + assert.strictEqual(only.children.size >= 1, true, 'still holding its tests'); + assert.strictEqual(collectLeafIds(only.children).length, ALL_TESTS.length, 'all of them'); + // Interaction 4 - the assembly root is the widest gesture there is, so + // cancelling it must not have cost the tree the rows it dispatched over. assert.deepStrictEqual( - api.testController.getResult(LONG_TEST), - baseline.get(LONG_TEST), - 'a result arriving after Stop must be SUPPRESSED, leaving the last real run standing', + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'the whole project is still discoverable after its root run was cancelled', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'behind exactly one assembly row', + ); + for (const each of LONG_TESTS) { + assert.strictEqual(marked(each.finished), false, `${each.fqn} under the root was terminated`); + } + assert.strictEqual( + markersOnDisk().filter((name) => name.startsWith('finished-')).length, + 0, + 'and not one finish marker survives anywhere in the marker directory', + ); + await assertIdlePromptly('after Stop on the assembly root'); + }); + + test('Stop on a MULTI-SELECT of the two long tests cancels both clauses', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — a selection of two, OR-ed into one filter expression + // ([TEST-FILTER-ESCAPE]). Both names carry SPACES, which are not grammar and + // must not be escaped. + clearMarkers(); + const selection = LONG_TESTS.map((each) => each.fqn); + for (const fqn of selection) { + assert.ok(fqn.includes(' '), `${fqn} is an idiomatic F# backtick binding`); + assert.strictEqual( + filterClause(fqn), + `FullyQualifiedName=${fqn}`, + 'a space needs no backslash — escaping one would make the filter match nothing', + ); + } + const items = itemsFor(api, selection); + assert.strictEqual(items.length, LONG_TESTS.length, 'both long tests are selected'); + const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — Stop while the first of them runs. + const { afterStop, running } = await runAndStop(vscode.TestRunProfileKind.Run, items); + assert.ok(afterStop < STOP_BUDGET_MS, `Stop ends the batch: ${String(afterStop)}ms`); + assert.strictEqual(marked(running.started), true, 'one of the two clauses ran'); + assert.strictEqual( + selection.includes(running.fqn), + true, + `${running.fqn} is one of the two clauses actually selected`, ); + + // Interaction 3 — the other clause never got its turn, and neither reports. + for (const queued of queuedBehind(running)) { + assert.strictEqual( + marked(queued.started), + false, + `${queued.fqn} is the OTHER selected clause and must never start once the batch is cancelled`, + ); + } + for (const fqn of selection) { + assert.deepStrictEqual( + api.testController.getResult(fqn), + baseline.get(fqn), + `${fqn} is part of the cancelled selection, so its result is suppressed`, + ); + } assert.deepStrictEqual( api.testController.getResult(FAST_TEST), baseline.get(FAST_TEST), - 'including for the fast test batched into the same invocation', + 'and the test that was never selected is untouched either way', ); + await assertIdlePromptly('after Stop on a multi-select'); + // Interaction 4 - a multi-select is ONE invocation ([TEST-RUN-TRX]), so + // Stop ends one process, not one per selected test. + await assertIdlePromptly('after Stop on a multi-select'); assert.strictEqual( - api.testController.cachedResults.size, - baseline.size, - 'a cancelled run invents no cache entries', + startedLongTests().length <= 1, + true, + 'a selection of two long tests runs them in ONE invocation, so at most one had started ' + + 'when Stop landed', ); + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.finished), + false, + `${each.fqn} must have been terminated rather than waited out`, + ); + } assert.deepStrictEqual( sorted(collectLeafIds(api.testController.items)), - sorted(ALL_TESTS), - 'and leaves the tree exactly as it was', + sorted([...ALL_TESTS]), + 'and the tree is intact', ); - const idleAt = Date.now(); - await api.testController.whenIdle(); - assert.ok( - Date.now() - idleAt < STOP_BUDGET_MS, - 'the cancelled invocation must be OVER, not merely abandoned while still running', + // Interaction 4 - a multi-select is ONE invocation over an OR-ed filter, so + // one Stop must end both clauses. Two invocations would leave the second + // still running after the first was killed ([TEST-FILTER-ESCAPE]). + for (const each of LONG_TESTS) { + assert.strictEqual( + filterClause(each.fqn).includes('|'), + false, + `${each.fqn} contributes exactly one clause, never a union of its own`, + ); + assert.strictEqual(marked(each.finished), false, `and ${each.fqn} never finished`); + } + assert.strictEqual( + new Set(LONG_TESTS.map((each) => filterClause(each.fqn))).size, + LONG_TESTS.length, + 'the two clauses are distinct, so the selection really did name both tests', + ); + await assertIdlePromptly('after Stop on a multi-select of both long tests'); + }); + + test('after a cancelled run, the very next ▶ reports REAL results', async function () { + // Three `dotnet test` invocations: the cancelled one, the fast-only + // recovery, and the full uncancelled run interaction 5 measures. + this.timeout(FIXTURE_BUILD_MS); + + // Every `dotnet` invocation is serialized through one queue + // ([TEST-REACTIVITY]). A cancelled run that left its invocation in the queue + // poisons every later one — the symptom is the next run hanging, or VSTest + // dying on the shared bin/obj output. + // + // Interaction 1 — cancel a run of the whole fixture. + clearMarkers(); + const { afterStop } = await runAndStop(vscode.TestRunProfileKind.Run, itemsFor(api, ALL_TESTS)); + assert.ok(afterStop < STOP_BUDGET_MS, `the run was cancelled: ${String(afterStop)}ms`); + await assertIdlePromptly('before re-running'); + const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — press ▶ again, on the fast test alone. It must actually + // run, and promptly. + clearMarkers(); + const started = Date.now(); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [FAST_TEST]), + ); + const elapsed = Date.now() - started; + const result = api.testController.getResult(FAST_TEST); + assert.ok(result, `${FAST_TEST} must report after a cancelled run — the queue drained`); + assert.strictEqual(result.outcome, 'passed', 'and report the real outcome'); + assert.strictEqual(result.passed, true, 'with the pass flag set'); + assert.strictEqual( + (result.message ?? '').includes('No result reported'), + false, + 'a re-run after Stop attributes a real TRX result, not a missing one', + ); + assert.ok( + elapsed < FIXTURE_SLEEP_MS, + `selecting only the fast test must not drag the long ones in: ${String(elapsed)}ms`, + ); + + // Interaction 3 — the long tests were NOT in the selection, so they never + // ran, which is how we know the filter was rebuilt rather than reused from + // the cancelled run. + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.started), + false, + `${each.fqn} was not selected, so the re-run must not execute it`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'and the tree still holds every test', + ); + // Interaction 4 - and the recovery run's results are REAL, not carried over + // from the cancelled one. The long tests were not selected, so the kill + // must not have left them an outcome and ▶ must not have touched them. + for (const each of LONG_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(each.fqn), + baseline.get(each.fqn), + `${each.fqn} was not selected, so the recovery run leaves its entry exactly as it was`, + ); + assert.notStrictEqual( + api.testController.getResult(each.fqn)?.outcome, + 'failed', + `${each.fqn} must not be reported FAILED by a run that was killed`, + ); + assert.strictEqual(marked(each.finished), false, `${each.fqn} never ran to its end`); + } + assert.strictEqual( + cachedFor(api, FAST_TEST).outcome, + 'passed', + 'as does the fast test in the same invocation', + ); + // Interaction 4 - the fast-only run touched NOTHING else. A long test + // writes its markers only by running, so an EMPTY marker directory is the + // proof that the queue rebuilt the filter rather than replaying the + // cancelled selection. + assert.deepStrictEqual( + markersOnDisk(), + [], + 'a run of the fast test alone may not write one long-test marker', + ); + assert.strictEqual( + cachedFor(api, FAST_TEST).passed, + cachedFor(api, FAST_TEST).outcome === 'passed', + "the fast test's passed flag agrees with its outcome", + ); + + // Interaction 5 - recovery is the whole point. A run after a cancellation + // has to be indistinguishable from one that follows a clean run, or the + // user learns to reload the window every time they press Stop. So run the + // WHOLE fixture, uncancelled, and require every long test to reach its end. + clearMarkers(); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + assert.deepStrictEqual( + markersOnDisk(), + sorted(EVERY_MARKER), + 'every marker the fixture declares is on disk after the recovery run', + ); + for (const each of LONG_TESTS) { + assert.strictEqual(marked(each.finished), true, `${each.fqn} ran to completion this time`); + assert.notStrictEqual( + cachedFor(api, each.fqn).outcome, + 'notRun', + `${each.fqn} was attributed a real outcome`, + ); + assert.strictEqual( + cachedFor(api, each.fqn).passed, + cachedFor(api, each.fqn).outcome === 'passed', + `${each.fqn}'s passed flag agrees with its outcome`, + ); + } + await assertIdlePromptly('after the recovery run'); + }); + + test('two cancelled runs back to back both stop, and neither poisons the other', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — cancel once. + clearMarkers(); + const baseline = new Map(api.testController.cachedResults); + const first = await runAndStop(vscode.TestRunProfileKind.Run, itemsFor(api, ALL_TESTS)); + assert.ok( + first.afterStop < STOP_BUDGET_MS, + `the first Stop returned in ${String(first.afterStop)}ms`, + ); + assert.strictEqual(marked(first.running.started), true, 'the first run really started'); + await assertIdlePromptly('between the two cancelled runs'); + + // Interaction 2 — cancel again immediately. The second run must still get as + // far as actually starting the long test: a queue left holding the first + // invocation would never let it. + clearMarkers(); + const second = await runAndStop(vscode.TestRunProfileKind.Run, itemsFor(api, ALL_TESTS)); + assert.ok( + second.afterStop < STOP_BUDGET_MS, + `the second Stop returned in ${String(second.afterStop)}ms`, + ); + assert.strictEqual( + marked(second.running.started), + true, + 'the SECOND run must reach the point of executing a test — proof the first ' + + 'cancellation released the queue rather than abandoning an invocation in it', + ); + + // Interaction 3 — neither run reported anything. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `${id} was cancelled twice and reported neither time`, + ); + } + assert.strictEqual( + api.testController.cachedResults.size, + baseline.size, + 'two cancelled runs invent no cache entries between them', + ); + await assertIdlePromptly('after the second cancelled run'); + // Interaction 4 - two cancellations in a row prove the single `dotnet` + // queue was RELEASED after the first, not merely abandoned. A queue that + // kept the dead invocation would make the second Stop wait for it. + await assertIdlePromptly('after two cancelled runs'); + assert.strictEqual( + startedLongTests().length <= LONG_TESTS.length, + true, + 'no more long tests started than the fixture declares', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and the tree survived both cancellations', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'under ONE root'); + // Interaction 4 - the second cancellation must be as clean as the first. A + // queue that only half-drains leaves the THIRD gesture waiting on a process + // nobody is watching any more ([TEST-REACTIVITY]). + assert.strictEqual( + markersOnDisk().some((name) => name.startsWith('finished-')), + false, + 'neither cancelled run let a long test finish', + ); + assert.strictEqual( + startedLongTests().length <= LONG_TESTS.length, + true, + 'and no run started more long tests than the fixture holds', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'with the tree unchanged by either of them', + ); + await assertIdlePromptly('after two cancellations back to back'); + }); + + test('Stop pressed AFTER a run has already finished changes nothing', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — let a run of the fast test finish normally. + clearMarkers(); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [FAST_TEST]), + ); + const settled = api.testController.getResult(FAST_TEST); + assert.ok(settled, 'the completed run cached a result'); + assert.strictEqual(settled.outcome, 'passed', 'a real pass'); + const baseline = new Map(api.testController.cachedResults); + const markersAfterRun = markersOnDisk(); + + // Interaction 2 — press ⏹ long after the handler returned. Cancelling a + // token nothing is listening to must be inert, not a crash and not an + // erasure of the result the user is looking at. + const source = new vscode.CancellationTokenSource(); + assert.doesNotThrow(() => { + source.cancel(); + source.cancel(); + }, 'pressing Stop twice on a finished run must not throw'); + source.dispose(); + + // Interaction 3 — the result the user can see is exactly as it was. + assert.deepStrictEqual( + api.testController.getResult(FAST_TEST), + settled, + 'a late Stop must not retract a result that was already reported', + ); + assert.strictEqual( + api.testController.cachedResults.size, + baseline.size, + 'nor drop any other cached result', + ); + assert.strictEqual( + statusLensTitle(settled).startsWith('$(pass) Passed'), + true, + 'and the lens still shows the pass', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'with the tree untouched', + ); + await assertIdlePromptly('after a late Stop'); + // Interaction 4 - a Stop pressed after the run finished must neither + // invent nor retract a result. The run already reported; cancelling a + // finished run is a no-op the user cannot distinguish from doing nothing. + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.started), + false, + `${each.fqn} was never selected, so a late Stop has nothing of it to retract`, + ); + assert.deepStrictEqual( + api.testController.getResult(each.fqn), + baseline.get(each.fqn), + `and ${each.fqn}'s entry is exactly as it was before the run`, + ); + } + await assertIdlePromptly('after a late Stop'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'the tree is unchanged by a Stop that arrived too late', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'under ONE root'); + // Interaction 4 - a late Stop is a no-op, and "nothing" includes the marker + // directory: it must not retroactively delete the evidence the finished run + // wrote on its way out, nor add to it. The run under test selected the fast + // test alone, so the directory it left is the whole of that evidence. + assert.deepStrictEqual( + sorted(markersOnDisk()), + sorted(markersAfterRun), + 'the marker directory is exactly as the finished run left it', + ); + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.started), + false, + `${each.fqn} was never selected, so no late Stop can invent evidence of it`, + ); + } + assert.strictEqual( + cachedFor(api, FAST_TEST).outcome, + 'passed', + 'and the fast test is still green', + ); + await assertIdlePromptly('after a Stop that landed too late to do anything'); + }); + + test('a cancelled run leaves DISCOVERY intact, and a refresh still re-discovers', async function () { + this.timeout(DOTNET_CLI_MS); + + // Discovery and execution share one `dotnet` queue and one bin/obj output + // ([TEST-REACTIVITY]). A cancellation that killed the queue would present as + // an empty Testing view the next time the user pressed refresh. + // + // Interaction 1 — cancel a run. + clearMarkers(); + const before = sorted(collectLeafIds(api.testController.items)); + const { afterStop, running } = await runAndStop( + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + assert.ok(afterStop < STOP_BUDGET_MS, `the run was cancelled: ${String(afterStop)}ms`); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + before, + 'the tree survives the cancellation itself', + ); + await assertIdlePromptly('before refreshing'); + + // Interaction 2 — press refresh. The build the cancelled run left behind + // must not stop discovery from completing. + await drainDiscovery(() => { + void api.testController.activateAndDiscover(); + }, api.testController); + const after = await pollUntilDiscovered(api.testController, ALL_TESTS); + assert.deepStrictEqual( + sorted(after), + before, + 'refreshing after a cancelled run re-discovers exactly the same tests', + ); + + // Interaction 3 — the tree is whole: one root, one namespace, every leaf. + const roots = rootsOf(api.testController.items); + assert.strictEqual(roots.length, 1, 'one assembly root after re-discovery'); + assert.strictEqual(roots[0]?.label, PROJECT, 'still labelled for the project'); + for (const id of ALL_TESTS) { + const item = findItem(api.testController.items, id); + assert.ok(item, `${id} must still be a row after a cancelled run and a refresh`); + assert.strictEqual(item.id, id, 'under its own fully-qualified name'); + } + assert.deepStrictEqual( + markersOnDisk().filter((name) => name.startsWith('finished-')), + [], + 'and re-discovery never EXECUTES a test — `--list-tests` builds, it does not run', + ); + assert.deepStrictEqual( + startedLongTests().map((each) => each.fqn), + [running.fqn], + 'nor STARTS one: the only test that ever ran is the one the cancelled run caught, ' + + `and it never finished; markers on disk: ${markersOnDisk().join(', ') || '(none)'}`, + ); + // Interaction 4 - discovery is not a run, and a cancelled RUN must not + // cancel it ([TEST-REACTIVITY] serialises them through one queue, which is + // exactly where a shared cancellation would leak). + await assertIdlePromptly('after a cancelled run, before refreshing'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'every test is still discovered after the refresh', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'under ONE assembly root'); + for (const id of ALL_TESTS) { + const item = findItem(api.testController.items, id); + assert.ok(item, `${id} survived the cancel-then-refresh round trip`); + assert.strictEqual(item.id, id, 'under its own fully-qualified name'); + assert.strictEqual(item.error, undefined, `${id} is not marked errored by a cancellation`); + } + // Interaction 4 - discovery and execution are separate passes, so killing a + // RUN must not invalidate the TREE. A refresh that comes back short means the + // kill took the discovery cache down with it ([TEST-REACTIVITY]). + const rediscovered = collectLeafIds(api.testController.items); + assert.deepStrictEqual(sorted(rediscovered), sorted([...ALL_TESTS]), 'every test came back'); + assert.strictEqual(new Set(rediscovered).size, rediscovered.length, 'and none of them twice'); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'behind exactly one assembly row', + ); + assert.strictEqual( + itemsFor(api, [FAST_TEST]).length, + 1, + 'with the fast test still addressable by its own bare name', + ); + await assertIdlePromptly('after a refresh that followed a cancellation'); + }); + + test('Stop on a selection of ONE long test kills it and touches nothing else', async function () { + this.timeout(DOTNET_CLI_MS); + + // The narrowest selection there is. A cancellation implemented by killing + // "the current run" rather than "this invocation" is indistinguishable from + // a correct one on a whole-tree selection, and shows up here. + // + // Interaction 1 — select exactly one long test. Its name carries SPACES, so + // the clause is the bare name ([TEST-FILTER-ESCAPE]). + clearMarkers(); + const first = LONG_TESTS[0]; + assert.ok(first, 'the fixture declares a long test'); + assert.ok(first.fqn.includes(' '), 'whose name is an idiomatic F# backtick binding'); + assert.strictEqual( + filterClause(first.fqn), + `FullyQualifiedName=${first.fqn}`, + 'a space is not filter grammar and must not be escaped', + ); + const items = itemsFor(api, [first.fqn]); + assert.strictEqual(items.length, 1, 'exactly one row is selected'); + assert.strictEqual(items[0]?.id, first.fqn, 'and it is the long test'); + const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — Stop the moment it announces itself. + const { afterStop, running } = await runAndStop(vscode.TestRunProfileKind.Run, items); + assert.strictEqual(marked(first.started), true, 'the one selected test really started'); + assert.strictEqual( + running.fqn, + first.fqn, + 'and it is the one Stop caught — a selection of one leaves xUnit no ordering choice', + ); + assert.ok( + afterStop < STOP_BUDGET_MS, + `Stop must end a single-test run just as promptly: ${String(afterStop)}ms`, + ); + + // Interaction 3 — the process tree is dead, and the tests that were NOT + // selected were never touched in the first place. + await sleep(FIXTURE_SLEEP_MS + TERMINATION_GRACE_MS - afterStop); + assert.strictEqual( + marked(first.finished), + false, + `${first.fqn} must be TERMINATED, not waited out; markers: ${markersOnDisk().join(', ') || '(none)'}`, + ); + const second = LONG_TESTS[1]; + assert.ok(second, 'the fixture declares a second long test'); + assert.strictEqual( + marked(second.started), + false, + 'a test outside the selection must never run, cancelled or not', + ); + + // Interaction 4 — nothing is reported, nothing is forgotten, nothing moved. + for (const id of ALL_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(id), + baseline.get(id), + `${id} keeps whatever it had — a cancelled run reports nothing and retracts nothing`, + ); + } + assert.strictEqual( + api.testController.cachedResults.size, + baseline.size, + 'cache size unchanged', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'and the tree is untouched', + ); + await assertIdlePromptly('after Stop on a single test'); + // Interaction 4 - cancelling ONE test must not touch the fast test that + // shares the project, nor the other long test's row. + await assertIdlePromptly('after Stop on one long test'); + assert.strictEqual( + marked(FAST_TEST), + false, + 'the fast test was not in the selection, so it never ran', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and the tree is intact', + ); + for (const each of LONG_TESTS) { + const item = findItem(api.testController.items, each.fqn); + assert.ok(item, `${each.fqn} must still be a row`); + assert.strictEqual(item.children.size, 0, 'and still a leaf'); + } + // Interaction 4 - a one-test selection is ONE clause, so Stop on it must + // leave every other test untouched rather than cancelling the project out + // from under a user who selected a single row. + assert.strictEqual( + markersOnDisk().filter((name) => name.startsWith('finished-')).length, + 0, + 'the selected long test never finished', + ); + assert.strictEqual( + startedLongTests().length, + 1, + 'and exactly one long test was ever started - the selection named one', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'with every other test still in the tree, untouched', + ); + await assertIdlePromptly('after Stop on a single-test selection'); + }); + + test('after a cancelled run, the WHOLE tree still runs to completion', async function () { + this.timeout(DOTNET_CLI_MS); + + // The recovery case at full width. [TEST-REACTIVITY] serializes every + // `dotnet` invocation through one queue over shared `bin/`/`obj/` output, so + // a cancellation that left a half-killed build behind poisons the next full + // run — the symptom is VSTest dying with "The application to execute does + // not exist: …testhost.dll". + // + // Interaction 1 — cancel a run of everything. + clearMarkers(); + const { afterStop, running } = await runAndStop( + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + assert.ok(afterStop < STOP_BUDGET_MS, `the run was cancelled: ${String(afterStop)}ms`); + assert.strictEqual(marked(running.started), true, 'having really started'); + await assertIdlePromptly('before the recovery run'); + + // Interaction 2 — run the whole tree again and let it finish. Every long + // test writes BOTH markers this time. + clearMarkers(); + const started = Date.now(); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + const elapsed = Date.now() - started; + assert.deepStrictEqual( + markersOnDisk(), + sorted(EVERY_MARKER), + 'the recovery run executes every long test to completion — a poisoned queue would have ' + + 'produced a build error instead', + ); + assert.ok( + elapsed >= FIXTURE_SLEEP_MS * LONG_TESTS.length, + `and really waited every sleep out; took ${String(elapsed)}ms`, + ); + + // Interaction 3 — every outcome is real, green and rendered as such + // ([TEST-RUN-TRX], [TEST-STATUS-LENS]). + for (const id of ALL_TESTS) { + const result = api.testController.getResult(id); + assert.ok(result, `${id} must report after the recovery run`); + assert.strictEqual(result.outcome, 'passed', `${id} passes when left alone`); + assert.strictEqual(result.passed, true, `${id} carries the pass flag`); + assert.strictEqual( + (result.message ?? '').includes('No result reported'), + false, + `${id} really ran, so it reports no missing result`, + ); + assert.ok(Number(result.duration) >= 0, `${id} carries a measured duration`); + assert.strictEqual( + statusLensTitle(result).startsWith('$(pass) Passed'), + true, + `${id} renders above its binding as a pass`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'and the tree is whole', + ); + // Interaction 4 - the whole tree running to completion afterwards is the + // strongest recovery assertion there is: every marker, every outcome, one + // invocation. + assert.deepStrictEqual( + markersOnDisk(), + [...EVERY_MARKER].sort(), + 'the recovery run wrote every marker the fixture declares', + ); + for (const id of ALL_TESTS) { + assert.strictEqual( + cachedFor(api, id).outcome, + 'passed', + `${id} reports a real outcome after the earlier cancellation`, + ); + assert.strictEqual( + (cachedFor(api, id).message ?? '').includes('No result reported'), + false, + `${id} must not report a missing TRX entry`, + ); + } + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'under ONE assembly root'); + // Interaction 4 - the recovery gesture here is the WIDEST one, which makes it + // the one most likely to expose a queue still holding an invocation the + // previous cancellation abandoned. + assert.deepStrictEqual( + sorted(markersOnDisk()), + sorted([...EVERY_MARKER]), + 'the whole-tree run wrote every marker, cancellation history or not', + ); + for (const id of ALL_TESTS) { + assert.notStrictEqual(cachedFor(api, id).outcome, 'notRun', `${id} was attributed a result`); + } + assert.strictEqual(cachedFor(api, FAST_TEST).passed, true, 'and the fast test is green'); + await assertIdlePromptly('after the whole tree ran following a cancellation'); + }); + + test('a cancelled COVERAGE run leaves the NEXT coverage run a clean directory', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-COVERAGE] points `--results-directory` at a FRESHLY EMPTIED + // `.sharplsp-coverage`, and a run killed mid-flight is exactly the case that + // leaves debris there: a half-written run-id folder whose report describes + // nothing. The next run must not show it. + // + // Interaction 1 — cancel a coverage run, and see what it left behind. + clearMarkers(); + removeDirRecursive(coverageDir); + const { afterStop } = await runAndStop( + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_TESTS), + ); + assert.ok(afterStop < STOP_BUDGET_MS, `the coverage run was cancelled: ${String(afterStop)}ms`); + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + [], + 'a run killed mid-flight collected nothing, so it attaches no report', + ); + // Whatever the kill DID leave behind is what the next run has to sweep. + const debris = fs.existsSync(coverageDir) ? fs.readdirSync(coverageDir).sort() : []; + await assertIdlePromptly('after the cancelled coverage run'); + + // Interaction 2 — now let a coverage run FINISH, over the fast test alone so + // it costs one round trip. + clearMarkers(); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [FAST_TEST]), + ); + assert.strictEqual(fs.existsSync(coverageDir), true, 'the completed run created the directory'); + const entries = fs.readdirSync(coverageDir).sort(); + const dirs = entries.filter((entry) => + fs.statSync(path.join(coverageDir, entry)).isDirectory(), + ); + const trx = entries.filter((entry) => entry.toLowerCase().endsWith('.trx')); + assert.strictEqual(trx.length, 1, `one TRX for the one project: ${entries.join(' | ')}`); + assert.strictEqual( + reportDirsOf(coverageDir).length, + 1, + `and exactly ONE run-id folder holding a report — the collector writes one per test ` + + `project, and there is one: ${entries.join(' | ')}`, + ); + assert.deepStrictEqual( + entries.filter((entry) => debris.includes(entry)), + [], + `the killed run's debris must have been SWEPT, not handed to the next run: it left ` + + `${debris.join(' | ') || '(nothing)'}, and the directory now holds ${entries.join(' | ')}`, + ); + assert.deepStrictEqual( + sorted([...trx, ...dirs]), + sorted(entries), + 'with nothing else beside the solution', + ); + + // Interaction 3 — the report that IS there describes the run that just ran. + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, 1, 'one Cobertura report, from the completed run'); + for (const report of reports) { + assert.strictEqual( + path.basename(report), + 'coverage.cobertura.xml', + "the collector's own file name", + ); + assert.strictEqual( + path.dirname(path.dirname(report)), + coverageDir, + `${report} sits exactly one directory down`, + ); + assert.strictEqual( + fs.readFileSync(report, 'utf8').includes('<coverage'), + true, + `${report} is valid Cobertura XML`, + ); + } + + // Interaction 4 — and the completed coverage run attributed its outcome, so + // the cancellation before it changed nothing about how a real run reports. + const result = api.testController.getResult(FAST_TEST); + assert.ok(result, `${FAST_TEST} must report under the Coverage profile`); + assert.strictEqual(result.outcome, 'passed', 'as a pass'); + assert.strictEqual(result.passed, true, 'with the flag set'); + assert.strictEqual( + (result.message ?? '').includes('No result reported'), + false, + 'and no missing-result note', + ); + for (const each of LONG_TESTS) { + assert.strictEqual( + marked(each.started), + false, + `${each.fqn} was not in the coverage selection and must not have run`, + ); + } + // Interaction 4 - and the NEXT coverage run's directory is the current + // run's alone. A cancelled run that left a report behind would have the + // next run attribute it to itself. + assert.strictEqual( + fs.existsSync(coverageDir), + true, + 'the results directory exists for the run that followed the cancellation', + ); + for (const entry of fs.readdirSync(coverageDir)) { + assert.strictEqual( + entry.endsWith('.xml'), + false, + `${entry} must not be a stray report at the top of the results directory`, + ); + } + await assertIdlePromptly('after the recovery coverage run'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and the tree is intact', + ); + // Interaction 4 - "freshly emptied" has to survive a KILL, not merely a clean + // exit. A half-written report from a killed run is the one thing the next + // run's percentage must never quietly include ([TEST-COVERAGE] claim 1). + assert.strictEqual( + reportDirsOf(coverageDir).length <= 1, + true, + 'the next coverage run reports on itself, not on the killed one as well', + ); + assert.strictEqual( + findCoberturaFiles(coverageDir).length, + reportDirsOf(coverageDir).length, + 'and every run-id folder that exists holds a readable report', + ); + assert.strictEqual( + path.basename(coverageDir), + COVERAGE_DIR_NAME, + 'in the directory beside the solution the specification names', + ); + await assertIdlePromptly('after a coverage run that followed a cancelled one'); + }); + + test('Stop that lands after the run finished neither invents nor retracts a result', async function () { + this.timeout(DOTNET_CLI_MS); + + // The race the other cancellation tests deliberately avoid: ⏹ pressed on a + // selection fast enough to have already completed. Both landings are + // legitimate, and the contract holds either way — what is never legitimate + // is a fabricated outcome or a notRun for a test the run did report. + // + // Interaction 1 — a baseline the assertions below can be compared against. + clearMarkers(); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [FAST_TEST]), + ); + const settled = api.testController.getResult(FAST_TEST); + assert.ok(settled, 'the control run cached a result'); + assert.strictEqual(settled.outcome, 'passed', 'a real pass'); + const baseline = new Map(api.testController.cachedResults); + + // Interaction 2 — run the fast test again and press ⏹ almost immediately. + // Whether the process beats the signal is a race, so nothing here asserts + // WHICH landing happened. + await assert.doesNotReject(async () => { + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [FAST_TEST]), + 1, + ); + }, 'a cancelled run must resolve, never reject, however the race lands'); + + // Interaction 3 — the result is EITHER the one already cached (the run was + // suppressed) OR a fresh real pass. It is never a failure, never notRun, and + // never a "no result" note. + const after = api.testController.getResult(FAST_TEST); + assert.ok(after, 'the last known result must survive a cancellation either way'); + assert.strictEqual( + ['passed'].includes(after.outcome), + true, + `${FAST_TEST} passes; a cancelled run may suppress that but never contradict it — ` + + `got ${after.outcome}`, + ); + assert.strictEqual(after.passed, true, 'so the pass flag stands'); + assert.strictEqual( + (after.message ?? '').includes('No result reported'), + false, + 'a cancelled run must not turn a passing test into a missing result', + ); + assert.notStrictEqual(after.outcome, 'notRun', 'nor into a notRun'); + assert.strictEqual( + statusLensTitle(after).startsWith('$(pass) Passed'), + true, + 'and the lens still shows the pass', + ); + + // Interaction 4 — no other test was invented, dropped or disturbed. + assert.strictEqual( + api.testController.cachedResults.size, + baseline.size, + 'a cancelled run invents no cache entries, whichever way the race landed', + ); + for (const each of LONG_TESTS) { + assert.deepStrictEqual( + api.testController.getResult(each.fqn), + baseline.get(each.fqn), + `${each.fqn} was not selected and must be untouched`, + ); + assert.strictEqual(marked(each.started), false, 'and must not have run'); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_TESTS), + 'and the tree is whole', + ); + await assertIdlePromptly('after a Stop that raced the run'); + // Interaction 4 - a Stop that RACES the run must leave exactly one story on + // disk and in the cache: either the run finished and reported, or it was + // killed and reported nothing. Never both, and never a fabricated outcome. + await assertIdlePromptly('after a Stop that raced the run'); + for (const each of LONG_TESTS) { + const outcome = api.testController.getResult(each.fqn)?.outcome; + assert.strictEqual( + marked(each.finished), + false, + `${each.fqn} was not selected, so neither landing of the race ran it to its end`, + ); + assert.deepStrictEqual( + api.testController.getResult(each.fqn), + baseline.get(each.fqn), + `${each.fqn}'s last known result is exactly what it was before the race`, + ); + assert.notStrictEqual( + outcome, + 'failed', + `${each.fqn} must never be reported as FAILED by a cancellation`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and the tree stands either way', + ); + // Interaction 4 - the two failure modes are opposite and equally bad. A late + // Stop that RETRACTS a result blanks a row the user just watched go green; + // one that INVENTS a cancellation marks it as never run ([TEST-RUN-TRX]). + for (const id of ALL_TESTS) { + assert.notStrictEqual(cachedFor(api, id).outcome, 'notRun', `${id} kept its result`); + assert.strictEqual( + statusLensTitle(cachedFor(api, id)).includes('No result reported'), + false, + `${id}'s lens still reads a real outcome`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_TESTS]), + 'and the tree is exactly what it was before the late Stop landed', ); + await assertIdlePromptly('after a Stop that landed after the run had already ended'); }); }); diff --git a/src/editors/vscode/src/test/suite/test-explorer-coverage.test.ts b/src/editors/vscode/src/test/suite/test-explorer-coverage.test.ts new file mode 100644 index 00000000..11c9eaef --- /dev/null +++ b/src/editors/vscode/src/test/suite/test-explorer-coverage.test.ts @@ -0,0 +1,1901 @@ +// Coarse end-to-end coverage for the Run-with-Coverage profile `[TEST-COVERAGE]`, +// against a REAL two-test-project solution the `dotnet` CLI built. +// +// [TEST-COVERAGE] makes four claims, and until this suite existed exactly one of +// them was asserted, weakly: +// +// 1. `--results-directory` points at a FRESHLY EMPTIED `.sharplsp-coverage` +// beside the solution — "reusing the directory would show the previous +// run's report", +// 2. the collector writes ONE Cobertura report PER TEST PROJECT, each in its +// own run-id folder one level down, +// 3. **every** one of them is parsed and attached — "taking only the first +// drops every other project's coverage, and which one is 'first' is +// directory order", +// 4. `coverlet.collector` omits the TEST assembly and only reports assemblies +// the run actually LOADED. +// +// Claims 2 and 3 are unfalsifiable against a one-test-project fixture: one +// report makes `reports.length >= 1` true forever and makes "the first" and +// "every" the same list. So this suite runs against +// {@link writeSplitCoverageFixture} — two test projects over one library, each +// exercising a DIFFERENT function of it — where a reader that keeps only the +// first report paints a just-executed function as dead code. +// +// F# is first: the F# project's covering test is an idiomatic backtick binding +// whose fully-qualified name carries SPACES, and it has to survive the coverage +// run's `--filter` exactly as it does an ordinary run ([TEST-FILTER-ESCAPE]). +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { SharpLspExtensionApi } from '../../extension.js'; +import { + findCoberturaFile, + findCoberturaFiles, + loadDetailedCoverage, + mergeCoberturaReports, + parseCoberturaXml, +} from '../../test-coverage.js'; +import { filterClause } from '../../test-filter.js'; +import { statusLensTitle } from '../../test-lens.js'; +import { createSolution, warmDiscovery } from './dotnet-project-kit'; +import { + ALL_COVERAGE_TESTS, + COVERAGE_DIR_NAME, + COVERED_BY_CSHARP, + COVERED_BY_FSHARP, + CS_COVERS, + CS_FAILING, + CS_SKIPPED, + CS_TESTS_FILE, + CS_THEORY, + FS_COVERS, + FS_ISOLATED, + FS_TESTS_FILE, + LIBRARY_FILE, + NEVER_COVERED, + reportDirsOf, + writeSplitCoverageFixture, +} from './test-coverage-fixtures'; +import { LIBRARY_SOURCE } from './test-explorer-fixtures'; +import { + activateTestExplorer, + collectLeafIds, + drainDiscovery, + findItem, + pollUntilDiscovered, + profileOfKind, + rootsOf, + runViaProfile, +} from './test-explorer-kit'; +import { + assertFailed, + assertPassed, + assertSkipped, + cachedFor, + itemsFor, + sorted, +} from './test-explorer-outcome-assertions'; +import { removeDirRecursive } from './test-helpers.js'; +import { DOTNET_CLI_MS, FIXTURE_BUILD_MS } from './test-timeouts'; + +/** The two test projects, so "one report per test project" has a number. */ +const TEST_PROJECTS = 2; + +/** The file name the collector always writes. */ +const REPORT_NAME = 'coverage.cobertura.xml'; + +/** The message a test with no TRX entry carries. Never legitimate here. */ +const NO_RESULT = 'No result reported'; + +/** Every test that ends green under the Coverage profile. */ +const PASSING = [CS_COVERS, CS_THEORY, FS_COVERS, FS_ISOLATED] as const; + +/** + * The 0-based line `name` is declared on in the library source. + * + * The Testing API's `Position.line` is 0-based, so a covered line reported for + * `Add` must be the index of the line declaring `Add` — no adjustment. + */ +function declarationLine(name: string): number { + const line = LIBRARY_SOURCE.split('\n').findIndex((each) => each.includes(` ${name}(`)); + assert.notStrictEqual(line, -1, `the fixture library must declare ${name}`); + return line; +} + +/** The 0-based lines a detail list reports as EXECUTED, ascending. */ +function executedLines(details: readonly vscode.FileCoverageDetail[]): number[] { + const lines: number[] = []; + for (const detail of details) { + if (!(detail instanceof vscode.StatementCoverage)) continue; + if (Number(detail.executed) <= 0) continue; + const at = detail.location; + lines.push(at instanceof vscode.Range ? at.start.line : at.line); + } + return [...new Set(lines)].sort((a, b) => a - b); +} + +/** The `FileCoverage` a report carries for the library, if any. */ +function libraryCoverageIn(report: string): vscode.FileCoverage | undefined { + return parseCoberturaXml(report).find((file) => path.basename(file.uri.fsPath) === LIBRARY_FILE); +} + +/** The library lines a single report says were executed. */ +function libraryLinesIn(report: string): number[] { + const file = libraryCoverageIn(report); + return file === undefined ? [] : executedLines(loadDetailedCoverage(file)); +} + +suite('Test Explorer e2e — the Coverage profile [TEST-COVERAGE]', () => { + let api: SharpLspExtensionApi; + let root: string; + let slnPath: string; + let coverageDir: string; + + suiteSetup(async function () { + this.timeout(FIXTURE_BUILD_MS); + api = await activateTestExplorer(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-testcoverage-')); + coverageDir = path.join(root, COVERAGE_DIR_NAME); + slnPath = await createSolution(root, 'Coverage', writeSplitCoverageFixture(root)); + // Pay restore, build and adapter JIT once, so a run measures the RUN. + await warmDiscovery(slnPath, root); + await api.explorerProvider.loadSolution(slnPath); + await api.testController.activateAndDiscover(); + await drainDiscovery(() => undefined, api.testController); + await pollUntilDiscovered(api.testController, ALL_COVERAGE_TESTS); + }); + + teardown(async function () { + this.timeout(DOTNET_CLI_MS); + // Never touch the fixture while a `dotnet` invocation is still in flight. + await api.testController.whenIdle(); + removeDirRecursive(coverageDir); + }); + + suiteTeardown(async function () { + this.timeout(DOTNET_CLI_MS); + await drainDiscovery(() => { + api.explorerProvider.clear(); + api.testController.items.replace([]); + }, api.testController); + removeDirRecursive(root); + }); + + test('the Coverage run writes ONE Cobertura report per test project, one directory down', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — before pressing anything, the directory the spec names is + // absent and sits beside the SOLUTION, not in a temp path the user cannot + // find. + assert.strictEqual(fs.existsSync(coverageDir), false, `${COVERAGE_DIR_NAME} starts absent`); + assert.strictEqual( + coverageDir, + path.join(path.dirname(slnPath), COVERAGE_DIR_NAME), + 'coverage lands beside the solution file the user loaded', + ); + assert.strictEqual( + path.basename(coverageDir), + COVERAGE_DIR_NAME, + 'under exactly the name [TEST-COVERAGE] specifies', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_COVERAGE_TESTS), + 'the whole two-project fixture is discovered before any coverage is collected', + ); + + // Interaction 2 — press Run with Coverage on the whole tree. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_COVERAGE_TESTS), + ); + assert.strictEqual(fs.existsSync(coverageDir), true, 'the run creates the results directory'); + + // Interaction 3 — what landed there: one TRX and one run-id folder per test + // project, and nothing else. + const entries = fs.readdirSync(coverageDir); + const trx = entries.filter((entry) => entry.toLowerCase().endsWith('.trx')); + const dirs = reportDirsOf(coverageDir); + assert.strictEqual( + trx.length, + TEST_PROJECTS, + `a coverage run is still a test run — one TRX per project: ${entries.join(' | ')}`, + ); + assert.strictEqual( + dirs.length, + TEST_PROJECTS, + `the collector writes one run-id folder per test project: ${entries.join(' | ')}`, + ); + assert.strictEqual(new Set(dirs).size, dirs.length, 'each run-id folder is distinct'); + + // The results directory holds THREE kinds of entry, because `dotnet test` + // points the TRX logger and the coverage collector at the same + // `--results-directory`: the TRX files, the collector's run-id folders, and + // the logger's own attachments folder — which it creates as soon as a run + // produces an attachment, and a coverage run always does. That folder is + // named for the TRX it belongs to, so it is identifiable rather than + // merely tolerated, and `findCoberturaFiles` never sees inside it: the copy + // it holds is nested under `In/<machine>/`, not one level down. + const files = entries.filter( + (entry) => !fs.statSync(path.join(coverageDir, entry)).isDirectory(), + ); + assert.deepStrictEqual(sorted(files), sorted(trx), 'every FILE beside the solution is a TRX'); + const attachmentDirs = entries.filter( + (entry) => !dirs.includes(entry) && fs.statSync(path.join(coverageDir, entry)).isDirectory(), + ); + for (const dir of attachmentDirs) { + assert.strictEqual( + trx.some((name) => name.startsWith(dir)), + true, + `${dir} is not a run-id folder, so it must be a TRX attachments folder named for its TRX`, + ); + assert.strictEqual( + fs.existsSync(path.join(coverageDir, dir, REPORT_NAME)), + false, + `${dir} must not hold a report one level down, or it would double-count`, + ); + } + assert.deepStrictEqual( + sorted([...trx, ...dirs, ...attachmentDirs]), + sorted(entries), + 'a TRX, a run-id folder or that TRX‘s attachments — nothing else is written here', + ); + + // Interaction 4 — the discovery helper finds exactly those reports. `>= 1` + // is the assertion [TEST-COVERAGE] warns about: it cannot tell one report + // from every report. + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual( + reports.length, + TEST_PROJECTS, + `one Cobertura report per test project, all of them found: ${reports.join(' | ')}`, + ); + assert.deepStrictEqual([...reports].sort(), reports, 'the reports come back in a stable order'); + assert.strictEqual(new Set(reports).size, reports.length, 'and each exactly once'); + for (const report of reports) { + assert.strictEqual(path.basename(report), REPORT_NAME, "the collector's own file name"); + assert.strictEqual( + path.dirname(path.dirname(report)), + coverageDir, + `${report} must sit exactly one directory down, in its own run-id folder`, + ); + assert.strictEqual(path.isAbsolute(report), true, `${report} must be an absolute path`); + assert.strictEqual( + fs.readFileSync(report, 'utf8').includes('<coverage'), + true, + `${report} must really be Cobertura XML`, + ); + } + assert.strictEqual( + findCoberturaFile(coverageDir), + reports[0], + 'the singular helper is the first of the plural one — and that is exactly why the ' + + 'singular one may never be what a run attaches', + ); + // Interaction 4 - the shape of the directory, spelled out. [TEST-COVERAGE] + // says one report per test project, "each in its own run-id folder one + // level down": a flat directory, or a second file in one folder, is a + // collector configured differently from the one the spec describes. + const runDirs = reportDirsOf(coverageDir); + assert.strictEqual(runDirs.length, TEST_PROJECTS, 'one run-id folder per test project'); + assert.strictEqual( + new Set(runDirs).size, + runDirs.length, + 'each project writes into a folder of its own', + ); + for (const dir of runDirs) { + assert.strictEqual( + fs.existsSync(path.join(coverageDir, dir, REPORT_NAME)), + true, + `${dir} must hold the collector's report under its fixed name`, + ); + assert.strictEqual( + fs.statSync(path.join(coverageDir, dir)).isDirectory() && path.basename(dir) === dir, + true, + `${dir} must sit exactly ONE level below the results directory`, + ); + } + assert.strictEqual( + findCoberturaFiles(coverageDir).length, + TEST_PROJECTS, + 'and the reader finds every one of them', + ); + assert.notStrictEqual( + findCoberturaFile(coverageDir), + undefined, + 'while the single-report reader answers with one of them - which is why taking only ' + + 'that one drops every other project', + ); + // Interaction 4 - "one directory down" is a LOCATION claim, not a count. A + // collector writing straight into the results directory, or two levels down, + // is found by neither reader, and the run reports full coverage of nothing + // ([TEST-COVERAGE] claim 2). + const placedDirs = reportDirsOf(coverageDir); + assert.strictEqual(placedDirs.length, TEST_PROJECTS, 'one run-id folder per test project'); + for (const runId of placedDirs) { + assert.strictEqual( + fs.existsSync(path.join(coverageDir, runId, REPORT_NAME)), + true, + `${runId} holds its report at exactly one level down`, + ); + assert.strictEqual( + fs.existsSync(path.join(coverageDir, runId, runId, REPORT_NAME)), + false, + `${runId} does not bury it a second level down`, + ); + } + assert.strictEqual( + fs.existsSync(path.join(coverageDir, REPORT_NAME)), + false, + 'and nothing was written straight into the results directory itself', + ); + assert.strictEqual( + new Set(placedDirs).size, + placedDirs.length, + 'with the two run-id folders distinct - a shared folder is one report overwriting the other', + ); + }); + + test('EVERY report is parsed: the second project’s coverage is not dropped', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — collect coverage across both projects at once. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_COVERAGE_TESTS), + ); + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, TEST_PROJECTS, 'both projects reported'); + + // Interaction 2 — each report on its own covers the library, and the two + // disagree about WHICH lines ran. That disagreement is the whole point of + // the fixture: without it, reading one report is indistinguishable from + // reading both. + const perReport = reports.map((report) => libraryLinesIn(report)); + for (const [index, lines] of perReport.entries()) { + assert.ok( + lines.length > 0, + `report ${index + 1} of ${reports.length} must report executed lines in ` + + `${LIBRARY_FILE}; both fixture projects reference and exercise the library`, + ); + } + const [first, second] = perReport; + assert.ok(first !== undefined && second !== undefined, 'two reports, two line sets'); + assert.notDeepStrictEqual( + first, + second, + 'the two test projects exercise DIFFERENT library functions, so their reports ' + + 'must not be identical — an identical pair means the fixture stopped proving anything', + ); + assert.ok( + first.some((line) => !second.includes(line)), + `the first report carries a line the second does not: ${first.join(',')} vs ${second.join(',')}`, + ); + assert.ok( + second.some((line) => !first.includes(line)), + `and the second carries one the first does not: ${second.join(',')} vs ${first.join(',')}`, + ); + + // Interaction 3 — the union is strictly larger than either half, so a reader + // that kept only `reports[0]` would paint a just-executed function red. + const union = [...new Set(perReport.flat())].sort((a, b) => a - b); + assert.ok( + union.length > first.length, + `attaching only the first report loses ${union.length - first.length} covered line(s)`, + ); + assert.ok( + union.length > second.length, + `and attaching only the second loses ${union.length - second.length}`, + ); + + // Interaction 4 — parsing every report yields FileCoverage entries for the + // library from each, and every entry is a real, absolute source path with a + // coherent summary. + const files = reports.flatMap((report) => parseCoberturaXml(report)); + assert.ok(files.length >= TEST_PROJECTS, `at least one entry per report: got ${files.length}`); + assert.strictEqual( + files.filter((file) => path.basename(file.uri.fsPath) === LIBRARY_FILE).length, + TEST_PROJECTS, + `${LIBRARY_FILE} is covered by both projects, so both reports must yield an entry for it`, + ); + for (const file of files) { + assert.strictEqual( + path.isAbsolute(file.uri.fsPath), + true, + `every FileCoverage names a real source file, got '${file.uri.fsPath}'`, + ); + assert.ok(file.statementCoverage.total > 0, `${file.uri.fsPath} must count statements`); + assert.ok( + file.statementCoverage.covered <= file.statementCoverage.total, + `${file.uri.fsPath}: covered cannot exceed total`, + ); + assert.strictEqual( + file.uri.fsPath.includes('CoverCs.dll') || file.uri.fsPath.includes('CoverFs.dll'), + false, + 'IncludeTestAssembly is false, so no TEST assembly appears in the report', + ); + } + // Interaction 4 - the two reports really do disagree, and their union is + // strictly larger than either. That is the whole content of "EVERY one of + // them is parsed": with one report the claim is unfalsifiable. + const bothReports = findCoberturaFiles(coverageDir); + assert.strictEqual(bothReports.length, TEST_PROJECTS, 'two reports to compare'); + const [firstReport, secondReport] = bothReports; + assert.ok(firstReport && secondReport, 'both reports are on disk'); + const firstLines = libraryLinesIn(firstReport); + const secondLines = libraryLinesIn(secondReport); + assert.notDeepStrictEqual( + firstLines, + secondLines, + 'the two projects exercise DIFFERENT functions, so their reports must differ', + ); + assert.strictEqual( + firstLines.every((line) => secondLines.includes(line)), + false, + 'neither report is a subset of the other', + ); + assert.strictEqual( + secondLines.every((line) => firstLines.includes(line)), + false, + 'in either direction', + ); + const unionLines = new Set([...firstLines, ...secondLines]); + assert.strictEqual( + unionLines.size > firstLines.length, + true, + 'so the union is strictly larger than the first report alone', + ); + assert.strictEqual( + unionLines.size > secondLines.length, + true, + 'and than the second - which is exactly what a first-only reader would lose', + ); + // Interaction 4 - "every report" is falsifiable only if taking the FIRST + // gives a different answer than taking them all. That difference is the + // whole point of the split fixture ([TEST-COVERAGE] claim 3). + const everyReport = findCoberturaFiles(coverageDir); + const firstOnly = findCoberturaFile(coverageDir); + assert.strictEqual(everyReport.length, TEST_PROJECTS, 'both reports are visible to the reader'); + assert.ok(firstOnly, 'and a first one exists to be wrongly taken alone'); + assert.strictEqual(everyReport.includes(firstOnly), true, 'the first is one of them'); + const mergedEvery = mergeCoberturaReports(everyReport); + const mergedLibrary = mergedEvery.find( + (file) => path.basename(file.uri.fsPath) === LIBRARY_FILE, + ); + assert.ok(mergedLibrary, 'the merge carries the library'); + assert.strictEqual( + executedLines(loadDetailedCoverage(mergedLibrary)).length > libraryLinesIn(firstOnly).length, + true, + 'and merging every report covers strictly MORE than the first report alone', + ); + assert.strictEqual( + libraryLinesIn(firstOnly).length >= 1, + true, + 'while the first report on its own is not empty either - it is merely incomplete', + ); + }); + + test('the reports cover the LIBRARY only — never the test assemblies themselves', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-COVERAGE]: "`coverlet.collector` leaves the TEST assembly out of its + // report by default (`IncludeTestAssembly` is false) and only reports + // assemblies the run actually loaded, so a coverage fixture has to be a + // library plus a test project that exercises it." + // + // That sentence is why this fixture is a library plus two test projects + // rather than two test projects alone, and nothing observed it. If the + // collector DID measure test assemblies, a solution of nothing but tests + // would still produce covered lines, and every other assertion in this + // suite could pass while measuring the wrong assembly entirely. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_COVERAGE_TESTS), + ); + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, TEST_PROJECTS, 'both projects reported'); + + // Interaction 2 — every file named across BOTH reports is the library's, + // and the two test sources that drove the run appear in neither. + const named = reports.flatMap((report) => + parseCoberturaXml(report).map((file) => path.basename(file.uri.fsPath)), + ); + assert.ok(named.length > 0, 'the run measured something, so exclusion is observable'); + assert.deepStrictEqual( + sorted([...new Set(named)]), + [LIBRARY_FILE], + `only the library is measured; got ${sorted([...new Set(named)]).join(' | ') || '(nothing)'}`, + ); + for (const testSource of [CS_TESTS_FILE, FS_TESTS_FILE]) { + assert.strictEqual( + named.includes(testSource), + false, + `${testSource} is a TEST source: IncludeTestAssembly is false, so it must not be measured`, + ); + } + + // Interaction 3 — the exclusion is not an empty report. The library's own + // lines really were measured, by both projects, so "no test assembly" is a + // statement about WHAT was covered rather than about nothing being covered. + for (const [index, report] of reports.entries()) { + const file = libraryCoverageIn(report); + assert.ok(file, `report ${index + 1} must carry ${LIBRARY_FILE}`); + assert.strictEqual( + path.basename(file.uri.fsPath), + LIBRARY_FILE, + 'and it is the library file the fixture wrote', + ); + assert.ok( + executedLines(loadDetailedCoverage(file)).length > 0, + `report ${index + 1} must report executed library lines, not an empty report`, + ); + } + // Interaction 4 - the test assemblies themselves must be absent, and the + // library present, in EVERY report. `coverlet.collector` leaves the test + // assembly out by default and only reports assemblies the run LOADED. + for (const report of findCoberturaFiles(coverageDir)) { + const files = parseCoberturaXml(report).map((file) => path.basename(file.uri.fsPath)); + assert.strictEqual( + files.includes(CS_TESTS_FILE), + false, + `${report} must not report the C# TEST source as covered code`, + ); + assert.strictEqual(files.includes(FS_TESTS_FILE), false, 'nor the F# test source'); + assert.strictEqual( + files.includes(LIBRARY_FILE), + true, + `${report} must report the library the tests exercise`, + ); + } + assert.strictEqual( + mergeCoberturaReports(findCoberturaFiles(coverageDir)) + .map((file) => path.basename(file.uri.fsPath)) + .includes(LIBRARY_FILE), + true, + 'and the merged view carries the library too', + ); + // Interaction 4 - `IncludeTestAssembly` is false by default, so a report + // naming a test source file means the collector was misconfigured and every + // percentage the user reads is diluted by the tests themselves + // ([TEST-COVERAGE] claim 4). + for (const report of findCoberturaFiles(coverageDir)) { + const files = parseCoberturaXml(report).map((file) => path.basename(file.uri.fsPath)); + assert.strictEqual( + files.includes(CS_TESTS_FILE), + false, + `${path.basename(path.dirname(report))} does not report the C# test source`, + ); + assert.strictEqual( + files.includes(FS_TESTS_FILE), + false, + `${path.basename(path.dirname(report))} does not report the F# test source`, + ); + assert.strictEqual(files.length >= 1, true, 'while still reporting something'); + } + assert.strictEqual( + mergeCoberturaReports(findCoberturaFiles(coverageDir)).every( + (file) => path.basename(file.uri.fsPath) !== CS_TESTS_FILE, + ), + true, + 'and the merged view carries no test assembly either', + ); + }); + + test('every report’s detail SURVIVES the merge, not just the last one parsed', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-COVERAGE]: "**every** one of them is parsed into `vscode.FileCoverage` + // entries and attached to the run … taking only the first drops every other + // project's coverage." + // + // Every other test here parses ONE report and resolves its detail straight + // away. That is not the order `addCoverage` uses: it takes ALL the reports + // first, and VS Code asks for per-line detail later, when the user expands + // the file. Both fixture projects cover the SAME `Calculator.cs`, and detail + // is stashed by file URI — so reading a report back only right after parsing + // it is exactly what hid the last report answering for every entry. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_COVERAGE_TESTS), + ); + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, TEST_PROJECTS, 'both reports are available to merge'); + + // Interaction 2 — merged as the run attaches them: ONE entry per source + // file, not one per report. Two entries for a file cannot both be right + // about it, and only one of them can own the stashed detail. + const merged = mergeCoberturaReports(reports); + const library = merged.filter((file) => path.basename(file.uri.fsPath) === LIBRARY_FILE); + assert.strictEqual( + library.length, + 1, + `${LIBRARY_FILE} is covered by both projects and must merge to ONE entry; got ` + + merged.map((file) => path.basename(file.uri.fsPath)).join(' | '), + ); + const [file] = library; + assert.ok(file, 'the merged library entry'); + + // Interaction 3 — the merged detail carries BOTH projects' work. `Add` is + // reachable only from the C# project and `Multiply` only from the F# one, + // so a merge that let one report win paints a just-executed function as + // dead code — a wrong RED gutter, not merely a missing one. + const executed = executedLines(loadDetailedCoverage(file)); + for (const name of [COVERED_BY_CSHARP, COVERED_BY_FSHARP]) { + assert.strictEqual( + executed.includes(declarationLine(name)), + true, + `Calculator.${name} was executed, so the merged detail must cover line ` + + `${String(declarationLine(name))}; covered: ${executed.join(',')}`, + ); + } + + // Interaction 4 — the summary beside the merged entry agrees with the + // detail behind it. + // + // Read BEFORE anything re-parses. Detail is stashed per file URI, so + // `libraryLinesIn` below — which parses one report to ask what it alone + // reported — replaces the merged detail with that report's. Asserting the + // merged totals afterwards would compare one report's detail against the + // merged count and pass only while both reports happen to instrument an + // identical line set. + const mergedDetail = loadDetailedCoverage(file).length; + assert.strictEqual( + file.statementCoverage.covered, + executed.length, + 'the merged summary the gutter shows counts exactly the merged executed lines', + ); + assert.strictEqual( + mergedDetail, + file.statementCoverage.total, + 'and its total counts every line the merged detail carries', + ); + + // Interaction 5 — the merge is strictly better than either report alone. + const perReport = reports.map((report) => libraryLinesIn(report)); + for (const [index, alone] of perReport.entries()) { + assert.ok( + alone.every((line) => executed.includes(line)), + `the merge must keep every line report ${index + 1} of ${perReport.length} ` + + `covered: ${alone.join(',')} vs merged ${executed.join(',')}`, + ); + } + assert.ok( + executed.length > Math.max(...perReport.map((alone) => alone.length)), + 'and cover more than any single report, or the fixture proves nothing', + ); + + // Interaction 6 — nothing the collector never measured is invented. + for (const name of NEVER_COVERED) { + assert.strictEqual( + executed.includes(declarationLine(name)), + false, + `nothing executes Calculator.${name}, so merging must not cover it either`, + ); + } + // Interaction 4 - the merge must be a UNION of details, not a last-wins + // pick. Every line either report called must survive into the merged view. + const mergedReports = mergeCoberturaReports(findCoberturaFiles(coverageDir)); + const mergedLibrary = mergedReports.find( + (file) => path.basename(file.uri.fsPath) === LIBRARY_FILE, + ); + assert.ok(mergedLibrary, 'the merged view carries the library'); + const mergedLines = executedLines(loadDetailedCoverage(mergedLibrary)); + for (const report of findCoberturaFiles(coverageDir)) { + for (const line of libraryLinesIn(report)) { + assert.strictEqual( + mergedLines.includes(line), + true, + `line ${String(line)} was executed according to ${report}, so it must survive the merge`, + ); + } + } + assert.strictEqual( + mergedLibrary.statementCoverage.total > 0, + true, + 'the merged file reports a statement total', + ); + assert.strictEqual( + mergedLibrary.statementCoverage.covered > 0, + true, + 'and a non-zero covered count', + ); + assert.strictEqual( + mergedLibrary.statementCoverage.covered <= mergedLibrary.statementCoverage.total, + true, + 'which can never exceed the total', + ); + // Interaction 5 - the merge is a UNION over line hits, so it can only grow: + // no line either report called may be missing from it, and no line neither + // called may appear in it ([TEST-COVERAGE] claim 3). + const perReportLines = findCoberturaFiles(coverageDir).map((report) => libraryLinesIn(report)); + const unionOfReports = [...new Set(perReportLines.flat())].sort((a, b) => a - b); + const mergedFile = mergeCoberturaReports(findCoberturaFiles(coverageDir)).find( + (file) => path.basename(file.uri.fsPath) === LIBRARY_FILE, + ); + assert.ok(mergedFile, 'the merged view carries the library'); + assert.deepStrictEqual( + executedLines(loadDetailedCoverage(mergedFile)), + unionOfReports, + 'the merged detail is exactly the union of the per-report details', + ); + assert.strictEqual( + perReportLines.every((lines) => lines.every((line) => unionOfReports.includes(line))), + true, + 'so no report lost a line it had reported on its own', + ); + assert.strictEqual( + unionOfReports.length >= Math.max(...perReportLines.map((lines) => lines.length)), + true, + 'and the union is never smaller than its largest member', + ); + }); + + test('the covered lines are exactly the library functions the tests called', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — run coverage over the whole tree, then merge every report. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_COVERAGE_TESTS), + ); + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, TEST_PROJECTS, 'both reports are available to merge'); + const covered = [...new Set(reports.flatMap((report) => libraryLinesIn(report)))]; + + // Interaction 2 — the two functions the tests DID call are covered. `Add` is + // reachable only from the C# project and `Multiply` only from the F# one, so + // a merge that dropped either report fails right here. + for (const name of [COVERED_BY_CSHARP, COVERED_BY_FSHARP]) { + assert.ok( + covered.includes(declarationLine(name)), + `a test executed Calculator.${name}, so its line (${declarationLine(name)}) must be ` + + `covered; covered lines were: ${covered.sort((a, b) => a - b).join(',')}`, + ); + } + + // Interaction 3 — the functions nothing called stay uncovered, so the gutter + // is red where it should be. `Subtract` is only reachable from the SKIPPED + // test: a skip must not be counted as execution ([TEST-RUN-TRX]). + for (const name of NEVER_COVERED) { + assert.strictEqual( + covered.includes(declarationLine(name)), + false, + `nothing executes Calculator.${name}, so line ${declarationLine(name)} must stay uncovered`, + ); + } + + // Interaction 4 — the summary agrees with the detail, and coverage is + // PARTIAL. A 100% report would mean the collector instrumented nothing and + // counted only what ran. + for (const report of reports) { + const file = libraryCoverageIn(report); + assert.ok(file, `${report} must carry a ${LIBRARY_FILE} entry`); + const details = loadDetailedCoverage(file); + assert.strictEqual( + details.length, + file.statementCoverage.total, + 'the per-line detail VS Code asks for on demand covers every counted line', + ); + assert.strictEqual( + executedLines(details).length, + file.statementCoverage.covered, + 'and the executed lines in that detail add up to the summary the gutter shows', + ); + assert.ok( + file.statementCoverage.covered > 0, + `${report}: the project's own test executed library code`, + ); + assert.ok( + file.statementCoverage.covered < file.statementCoverage.total, + `${report}: ${NEVER_COVERED.join(' and ')} are never called, so coverage is partial ` + + `(${file.statementCoverage.covered}/${file.statementCoverage.total})`, + ); + } + // Interaction 4 - and the functions NOBODY called must stay uncovered. + // Coverage that paints unreachable code as executed is worse than none: it + // is the number a team deletes tests to protect. + const everyLine = new Set( + findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report)), + ); + for (const name of NEVER_COVERED) { + assert.strictEqual( + everyLine.has(declarationLine(name)), + false, + `${name} is never called by either test project, so no report may mark it executed`, + ); + } + assert.strictEqual( + everyLine.has(declarationLine(COVERED_BY_CSHARP)), + true, + `${COVERED_BY_CSHARP} is called by the C# project and must be covered`, + ); + assert.strictEqual( + everyLine.has(declarationLine(COVERED_BY_FSHARP)), + true, + `${COVERED_BY_FSHARP} is called by the F# project and must be covered`, + ); + assert.strictEqual( + everyLine.size >= 2, + true, + 'at least the two called functions are reported executed', + ); + // Interaction 4 - a covered line is a CALLED function, and the negative half + // is what makes it a measurement: a function no test calls must not appear as + // executed, or the report is a list of every line in the file. + const calledLines = libraryLinesIn(findCoberturaFiles(coverageDir)[0] ?? ''); + const unionLines = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + for (const name of NEVER_COVERED) { + assert.strictEqual( + unionLines.includes(declarationLine(name)), + false, + `${name} is called by no test and must not read as executed`, + ); + } + assert.strictEqual( + unionLines.includes(declarationLine(COVERED_BY_CSHARP)), + true, + `${COVERED_BY_CSHARP} is called by the C# test and must read as executed`, + ); + assert.strictEqual( + unionLines.includes(declarationLine(COVERED_BY_FSHARP)), + true, + `${COVERED_BY_FSHARP} is called by the F# test and must read as executed`, + ); + assert.strictEqual( + calledLines.length <= unionLines.length, + true, + 'one report never exceeds the union', + ); + }); + + test('the results directory is FRESHLY EMPTIED, so a second run never shows the first one’s report', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — a first coverage run, whose artefacts we remember. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [CS_COVERS, FS_COVERS]), + ); + const firstDirs = reportDirsOf(coverageDir); + const firstEntries = fs.readdirSync(coverageDir).sort(); + assert.strictEqual(firstDirs.length, TEST_PROJECTS, 'the first run reported for both projects'); + assert.strictEqual( + findCoberturaFiles(coverageDir).length, + TEST_PROJECTS, + 'and both its reports are discoverable', + ); + + // Interaction 2 — plant debris a reused directory would hand back: a + // sentinel file, and a FAKE run-id folder holding a report that names a file + // no fixture ever had. If the directory is reused, both survive and the + // fake report is attached to the next run's coverage. + const sentinel = path.join(coverageDir, 'stale-sentinel.txt'); + const staleDir = path.join(coverageDir, 'stale-run-id'); + fs.writeFileSync(sentinel, 'left over from a previous run', 'utf8'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync( + path.join(staleDir, REPORT_NAME), + '<?xml version="1.0"?><coverage><packages /></coverage>', + 'utf8', + ); + assert.strictEqual(fs.existsSync(sentinel), true, 'the sentinel is planted'); + assert.strictEqual( + findCoberturaFiles(coverageDir).length, + TEST_PROJECTS + 1, + 'and the fake report is visible to the reader, so its survival is observable', + ); + + // Interaction 3 — a second coverage run. [TEST-COVERAGE] requires the + // directory to be emptied first, so every planted artefact is gone and the + // reports are the NEW run's alone. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [CS_COVERS, FS_COVERS]), + ); + assert.strictEqual( + fs.existsSync(sentinel), + false, + 'a freshly emptied results directory cannot still hold the sentinel — reusing it ' + + "would show the previous run's report", + ); + assert.strictEqual(fs.existsSync(staleDir), false, 'nor the planted run-id folder'); + const secondDirs = reportDirsOf(coverageDir); + assert.strictEqual( + secondDirs.length, + TEST_PROJECTS, + `the second run leaves exactly its own two folders: ${secondDirs.join(' | ')}`, + ); + assert.deepStrictEqual( + secondDirs.filter((dir) => firstDirs.includes(dir)), + [], + 'and none of them is a folder the FIRST run wrote', + ); + assert.strictEqual( + findCoberturaFiles(coverageDir).length, + TEST_PROJECTS, + 'so the reader sees two reports, not five', + ); + // Not a COUNT of entries: VSTest names its TRX and the attachments folder + // beside it after the wall-clock SECOND the run started, so two projects + // that land in the same second share one folder and two that straddle a + // second boundary do not. What [TEST-COVERAGE] actually promises is that + // nothing SURVIVES — so that is what is asserted. + const secondEntries = fs.readdirSync(coverageDir).sort(); + assert.deepStrictEqual( + secondEntries.filter((entry) => firstEntries.includes(entry)), + [], + 'a freshly emptied directory shares not one entry with the run before it: first ' + + `${firstEntries.join(' | ')}, then ${secondEntries.join(' | ')}`, + ); + assert.strictEqual( + secondEntries.includes(path.basename(sentinel)), + false, + 'with nothing of the previous contents surviving', + ); + assert.strictEqual( + secondEntries.includes(path.basename(staleDir)), + false, + 'and the planted run-id folder gone by NAME as well as by report', + ); + assert.ok( + secondEntries.some((entry) => entry.toLowerCase().endsWith('.trx')), + `an emptied directory is still the run's own results directory, TRX and all: ` + + secondEntries.join(' | '), + ); + + // Interaction 4 — and the fresh reports still describe a real run. + for (const report of findCoberturaFiles(coverageDir)) { + assert.ok(libraryLinesIn(report).length > 0, `${report} describes the run that just ran`); + } + // Interaction 4 - the emptying must be total. A sentinel the test planted, + // a fake run-id folder, and the previous run's own reports must all be gone + // - "reusing the directory would show the previous run's report". + const survivors = fs.existsSync(coverageDir) ? fs.readdirSync(coverageDir) : []; + assert.strictEqual( + survivors.includes('stale-sentinel.txt'), + false, + 'a file planted before the run must not survive it', + ); + assert.strictEqual( + survivors.includes('stale-run-id'), + false, + 'nor a fake run-id folder carrying a fake report', + ); + assert.strictEqual( + reportDirsOf(coverageDir).length, + TEST_PROJECTS, + 'exactly the current run\u2019s folders remain, one per test project', + ); + for (const dir of reportDirsOf(coverageDir)) { + assert.strictEqual( + fs.readdirSync(path.join(coverageDir, dir)).includes(REPORT_NAME), + true, + `${dir} holds this run's own report`, + ); + } + assert.strictEqual( + findCoberturaFiles(coverageDir).length, + TEST_PROJECTS, + 'and the reader sees only them', + ); + // Interaction 4 - "freshly emptied" is what makes a percentage TRUSTWORTHY. + // A leftover run-id folder is a report from a build that no longer exists, + // merged into this run's numbers ([TEST-COVERAGE] claim 1). + const secondRunDirs = reportDirsOf(coverageDir); + assert.strictEqual(secondRunDirs.length, TEST_PROJECTS, 'exactly this run reports, no more'); + assert.strictEqual( + fs.readdirSync(coverageDir).filter((entry) => entry.toLowerCase().endsWith('.trx')).length, + TEST_PROJECTS, + 'and the only TRX files beside them are this run\u2019s own, one per project - a coverage ' + + 'run is still a test run ([TEST-RUN-TRX])', + ); + for (const runId of secondRunDirs) { + assert.strictEqual( + fs.existsSync(path.join(coverageDir, runId, REPORT_NAME)), + true, + `${runId} carries a readable report`, + ); + } + assert.strictEqual( + path.basename(coverageDir), + COVERAGE_DIR_NAME, + 'and the directory is the one beside the solution the specification names', + ); + assert.strictEqual( + path.dirname(coverageDir), + path.dirname(slnPath), + 'beside the solution file itself', + ); + }); + + test('the Coverage profile still attributes a pass, a failure and a SKIP per test', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — Run with Coverage over every test, including the red one, + // the skipped one and a two-row [Theory]. + const items = itemsFor(api, ALL_COVERAGE_TESTS); + assert.strictEqual(items.length, ALL_COVERAGE_TESTS.length, 'every test resolved to a row'); + await runViaProfile(api.testController, vscode.TestRunProfileKind.Coverage, items); + + // Interaction 2 — collecting coverage does not change what a test reports: + // the same TRX attribution as ▶ ([TEST-RUN-TRX]). + for (const id of PASSING) assertPassed(cachedFor(api, id), id); + assertFailed(cachedFor(api, CS_FAILING), CS_FAILING); + assertSkipped(cachedFor(api, CS_SKIPPED), CS_SKIPPED); + + // Interaction 3 — nothing reports the "filter matched nothing" message, in + // either project. A coverage run adds `--collect` to the SAME invocation, so + // a filter broken by the extra argument shows up as this and nothing else. + for (const id of ALL_COVERAGE_TESTS) { + const result = cachedFor(api, id); + assert.strictEqual( + (result.message ?? '').includes(NO_RESULT), + false, + `${id} was actually run under coverage, so it must not report "${NO_RESULT}"`, + ); + assert.ok(Number.isFinite(result.duration), `${id} carries a measured duration`); + assert.ok(Number(result.duration) >= 0, `${id}'s duration is not negative`); + } + + // Interaction 4 — the status lens renders each of the three states the way + // the user reads it above the method ([TEST-STATUS-LENS]). + assert.strictEqual( + statusLensTitle(cachedFor(api, CS_COVERS)).startsWith('$(pass) Passed'), + true, + 'a pass under coverage still renders as a pass', + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, CS_FAILING)).startsWith('$(error) Failed'), + true, + 'and a failure as a failure', + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, CS_SKIPPED)), + '$(debug-step-over) Skipped', + 'and a skip as neither', + ); + assert.strictEqual( + (cachedFor(api, CS_FAILING).message ?? '').includes('Assert.Equal'), + true, + "the failure carries xUnit's own assertion text, not a generic 'Test failed'", + ); + // Interaction 4 - a Coverage run is still a RUN. [TEST-RUN-TRX] governs its + // outcomes exactly as it governs the plain profile, and the status lens + // must render them identically. + for (const id of PASSING) { + assert.strictEqual(cachedFor(api, id).outcome, 'passed', `${id} passed under Coverage`); + assert.strictEqual( + statusLensTitle(cachedFor(api, id)).startsWith('$(pass) Passed'), + true, + `${id} renders as a pass in the lens`, + ); + } + assert.strictEqual( + statusLensTitle(cachedFor(api, CS_FAILING)).startsWith('$(error) Failed:'), + true, + 'the failing test renders as a failure, with its own assertion text', + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, CS_SKIPPED)), + '$(debug-step-over) Skipped', + 'and the skipped test as a SKIP, never as a failure', + ); + for (const id of ALL_COVERAGE_TESTS) { + assert.strictEqual( + (cachedFor(api, id).message ?? '').includes(NO_RESULT), + false, + `${id} must not report "${NO_RESULT}" under the Coverage profile either`, + ); + } + // Interaction 4 - Coverage is a RUN PROFILE, so [TEST-RUN-TRX] governs it + // exactly as it governs the plain one. A skip reported as a failure under + // Coverage is a red row the user cannot make green. + for (const id of PASSING) { + assertPassed(cachedFor(api, id), id); + assert.strictEqual( + statusLensTitle(cachedFor(api, id)).includes(NO_RESULT), + false, + `${id} was attributed, not left unreported`, + ); + } + assertFailed(cachedFor(api, CS_FAILING), CS_FAILING); + assertSkipped(cachedFor(api, CS_SKIPPED), CS_SKIPPED); + assert.strictEqual( + cachedFor(api, CS_SKIPPED).passed, + false, + 'a skip is not a pass, however the Coverage profile collected it', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted([...ALL_COVERAGE_TESTS]), + 'and the Coverage run reshaped no row of the tree', + ); + }); + + test('the plain Run profile collects NO coverage at all', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — press ▶, not Run with Coverage, on the same selection. + assert.strictEqual(fs.existsSync(coverageDir), false, 'nothing collected yet'); + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [CS_COVERS, FS_COVERS]), + ); + assert.strictEqual( + fs.existsSync(coverageDir), + false, + '▶ adds no --collect and no --results-directory, so it must not create ' + + `${COVERAGE_DIR_NAME} beside the user's solution`, + ); + assertPassed(cachedFor(api, CS_COVERS), CS_COVERS); + assertPassed(cachedFor(api, FS_COVERS), FS_COVERS); + + // Interaction 2 — now collect coverage, and remember exactly what landed. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [CS_COVERS, FS_COVERS]), + ); + const collected = fs.readdirSync(coverageDir).sort(); + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, TEST_PROJECTS, 'the coverage run reported for both'); + const stamps = reports.map((report) => fs.statSync(report).mtimeMs); + + // Interaction 3 — a plain ▶ afterwards neither adds to that directory nor + // rewrites it. A Run that quietly reused the coverage arguments would show + // up as a third folder or a moved timestamp. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_COVERAGE_TESTS), + ); + assert.deepStrictEqual( + fs.readdirSync(coverageDir).sort(), + collected, + '▶ must leave the coverage directory byte-for-byte as the Coverage run left it', + ); + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + reports, + 'and produce no further Cobertura report', + ); + assert.deepStrictEqual( + findCoberturaFiles(coverageDir).map((report) => fs.statSync(report).mtimeMs), + stamps, + 'nor rewrite the ones already there', + ); + assertFailed(cachedFor(api, CS_FAILING), CS_FAILING); + assertSkipped(cachedFor(api, CS_SKIPPED), CS_SKIPPED); + // Interaction 4 - the plain Run profile must leave the results directory + // byte-for-byte alone. Collecting coverage the user did not ask for costs + // every plain run the collector's overhead. + assert.strictEqual( + profileOfKind(api.testController, vscode.TestRunProfileKind.Run).kind, + vscode.TestRunProfileKind.Run, + 'the plain Run profile exists and is distinct from Coverage', + ); + assert.notStrictEqual( + profileOfKind(api.testController, vscode.TestRunProfileKind.Run), + profileOfKind(api.testController, vscode.TestRunProfileKind.Coverage), + 'Run must not be the Coverage profile wearing another label', + ); + assert.strictEqual( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Coverage, + ).length, + 1, + 'and there is exactly ONE Coverage profile, or the menu gesture is ambiguous', + ); + for (const id of PASSING) { + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} still reports an outcome under the plain Run profile`, + ); + } + // Interaction 4 - the plain profile must not even ASK for a collector. A Run + // that quietly collects coverage pays the instrumentation cost on every + // press of the play button ([TEST-COVERAGE]). + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + reports, + 'the only readable reports after a plain Run are the ones the Coverage run wrote', + ); + assert.strictEqual( + reportDirsOf(coverageDir).length, + TEST_PROJECTS, + 'and no further run-id folder was written', + ); + for (const id of PASSING) { + assertPassed(cachedFor(api, id), id); + } + assert.strictEqual( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Run, + ).length, + 1, + 'with exactly ONE plain Run profile behind the gesture', + ); + assert.ok( + profileOfKind(api.testController, vscode.TestRunProfileKind.Coverage), + 'while the Coverage profile still exists, unpressed', + ); + }); + + test('coverage of a selection that loads nothing of the library reports nothing covered', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — collect coverage for the ONE test that never touches the + // library. `coverlet.collector` only reports assemblies the run actually + // LOADED, so the report is valid and says nothing was covered. + const isolated = itemsFor(api, [FS_ISOLATED]); + assert.strictEqual(isolated.length, 1, 'exactly one test is selected'); + assert.strictEqual(isolated[0]?.id, FS_ISOLATED, 'and it is the one that ignores the library'); + await runViaProfile(api.testController, vscode.TestRunProfileKind.Coverage, isolated); + + // Interaction 2 — a report is still written, and still parses. "Nothing + // covered" is a legitimate result, not a crash and not an empty file. + const reports = findCoberturaFiles(coverageDir); + assert.ok(reports.length >= 1, `a coverage run always writes a report: ${coverageDir}`); + for (const report of reports) { + assert.strictEqual(path.basename(report), REPORT_NAME, "the collector's own file name"); + assert.strictEqual( + fs.readFileSync(report, 'utf8').includes('<coverage'), + true, + `${report} is valid Cobertura XML even with nothing to report`, + ); + assert.doesNotThrow( + () => parseCoberturaXml(report), + `parsing ${report} must not throw on an empty <packages/>`, + ); + } + + // Interaction 3 — and not one library line is claimed as executed. + for (const report of reports) { + const file = libraryCoverageIn(report); + if (file === undefined) continue; + assert.strictEqual( + file.statementCoverage.covered, + 0, + `nothing in this run called into ${LIBRARY_FILE}, so no line of it may be reported ` + + `as executed; ${report} claims ${file.statementCoverage.covered}`, + ); + assert.deepStrictEqual( + libraryLinesIn(report), + [], + 'and the per-line detail must agree with that summary', + ); + } + + // Interaction 4 — the test itself still passed, so the empty report is not + // a failed run in disguise. + assertPassed(cachedFor(api, FS_ISOLATED), FS_ISOLATED); + assert.strictEqual( + (cachedFor(api, FS_ISOLATED).message ?? '').includes(NO_RESULT), + false, + 'the selected test really ran', + ); + // Interaction 4 - "a solution of nothing but test projects yields a valid, + // EMPTY report". Valid matters as much as empty: a malformed report would + // take the reporting step down with it. + for (const report of findCoberturaFiles(coverageDir)) { + assert.doesNotThrow( + () => parseCoberturaXml(report), + `${report} must parse, however little it covers`, + ); + assert.strictEqual( + Array.isArray(parseCoberturaXml(report)), + true, + `${report} yields a list of FileCoverage entries, empty or not`, + ); + } + assert.doesNotThrow( + () => mergeCoberturaReports(findCoberturaFiles(coverageDir)), + 'and merging them must not throw either', + ); + assert.strictEqual( + Array.isArray(mergeCoberturaReports(findCoberturaFiles(coverageDir))), + true, + 'the merge always answers with a list', + ); + // Interaction 4 - "nothing covered" is not "nothing reported". The collector + // still runs, still writes its report, and the report still parses: it just + // says the library was never entered ([TEST-COVERAGE] claim 4). + const isolatedReports = findCoberturaFiles(coverageDir); + assert.strictEqual(isolatedReports.length >= 1, true, 'a report was still written'); + for (const report of isolatedReports) { + assert.doesNotThrow(() => parseCoberturaXml(report), 'and it parses without throwing'); + assert.strictEqual( + libraryLinesIn(report).includes(declarationLine(COVERED_BY_CSHARP)), + false, + 'with the C#-covered function unexecuted', + ); + } + assert.strictEqual( + cachedFor(api, FS_ISOLATED).outcome, + 'passed', + 'while the test that touched nothing still passed', + ); + assert.strictEqual( + statusLensTitle(cachedFor(api, FS_ISOLATED)).includes(NO_RESULT), + false, + 'and was attributed a real result', + ); + }); + + test('Run with Coverage on the CLASS row covers every test beneath it', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the user presses the coverage action on the class group, + // not on a leaf. + const leaf = findItem(api.testController.items, CS_COVERS); + assert.ok(leaf, `${CS_COVERS} must be a row in the tree`); + const classNode = leaf.parent; + assert.ok(classNode, 'a leaf hangs off the class group it belongs to'); + assert.strictEqual(classNode.label, 'AdditionTests', 'and that parent is the class node'); + assert.strictEqual(classNode.children.size, 4, 'the class declares four tests'); + + // Interaction 2 — every test under the class reports, theory rows merged + // into one outcome, the skip still a skip. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Coverage, [classNode]); + assertPassed(cachedFor(api, CS_COVERS), CS_COVERS); + assertPassed(cachedFor(api, CS_THEORY), CS_THEORY); + assertFailed(cachedFor(api, CS_FAILING), CS_FAILING); + assertSkipped(cachedFor(api, CS_SKIPPED), CS_SKIPPED); + + // Interaction 3 — the coverage collected is the C# project's alone: `Add` + // ran, `Multiply` did not, because no F# test was selected. + const covered = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + assert.ok( + covered.includes(declarationLine(COVERED_BY_CSHARP)), + `the class's tests call Calculator.${COVERED_BY_CSHARP}, so its line must be covered`, + ); + assert.strictEqual( + covered.includes(declarationLine(COVERED_BY_FSHARP)), + false, + `no F# test was selected, so Calculator.${COVERED_BY_FSHARP} must NOT be reported as ` + + 'executed — a merge that attached a stale report would claim it was', + ); + for (const name of NEVER_COVERED) { + assert.strictEqual( + covered.includes(declarationLine(name)), + false, + `Calculator.${name} is still never executed`, + ); + } + // Interaction 4 - a class row is a group, and coverage of a group is the + // union of what its tests loaded. + const classLines = new Set( + findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report)), + ); + assert.strictEqual( + classLines.has(declarationLine(COVERED_BY_CSHARP)), + true, + `the class contains the test that calls ${COVERED_BY_CSHARP}, so it must be covered`, + ); + for (const name of NEVER_COVERED) { + assert.strictEqual( + classLines.has(declarationLine(name)), + false, + `${name} is called by nothing in the class and must stay uncovered`, + ); + } + assert.strictEqual( + findCoberturaFiles(coverageDir).length >= 1, + true, + 'and a class-row coverage run writes at least its own project\u2019s report', + ); + // Interaction 4 - a CLASS row's Run with Coverage is one batched invocation + // over every leaf beneath it, so every one of those leaves must carry an + // outcome and the union must cover what they called. + const classLeaves = collectLeafIds(api.testController.items).filter((id) => + id.startsWith(CS_COVERS.slice(0, CS_COVERS.lastIndexOf('.'))), + ); + assert.strictEqual(classLeaves.length >= 2, true, 'the class holds more than one test'); + for (const id of classLeaves) { + assert.notStrictEqual(cachedFor(api, id).outcome, 'notRun', `${id} beneath the class ran`); + } + assert.strictEqual( + [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ].includes(declarationLine(COVERED_BY_CSHARP)), + true, + 'and the function the class exercises reads as executed', + ); + assert.strictEqual( + findCoberturaFiles(coverageDir).length >= 1, + true, + 'behind at least one readable report', + ); + }); + + test('Run with Coverage on the F# backtick name carrying SPACES', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the name really does carry spaces, and needs no escaping: + // a space is not filter grammar, so the clause is the bare name + // ([TEST-FILTER-ESCAPE]). + assert.ok(FS_COVERS.includes(' '), 'the F# fixture test is an idiomatic backtick binding'); + assert.strictEqual( + filterClause(FS_COVERS), + `FullyQualifiedName=${FS_COVERS}`, + 'a space needs no backslash — escaping one would make the filter match nothing', + ); + const item = findItem(api.testController.items, FS_COVERS); + assert.ok(item, 'the spaced name is a row in the tree'); + assert.strictEqual(item.id, FS_COVERS, 'whose id is the name verbatim, spaces and all'); + assert.strictEqual(item.label, 'covers multiply only', 'labelled by the binding name'); + + // Interaction 2 — collecting coverage for it alone succeeds, and it passes. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Coverage, [item]); + assertPassed(cachedFor(api, FS_COVERS), FS_COVERS); + assert.strictEqual( + (cachedFor(api, FS_COVERS).message ?? '').includes(NO_RESULT), + false, + 'a spaced name under --collect must still match its own test', + ); + + // Interaction 3 — and the coverage it produced is the F# side's: `Multiply` + // executed, `Add` not. + const covered = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + assert.ok( + covered.includes(declarationLine(COVERED_BY_FSHARP)), + `the F# test calls Calculator.${COVERED_BY_FSHARP}, so its line must be covered`, + ); + assert.strictEqual( + covered.includes(declarationLine(COVERED_BY_CSHARP)), + false, + `no C# test ran, so Calculator.${COVERED_BY_CSHARP} must not be reported as executed`, + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_COVERAGE_TESTS), + 'and a single-test coverage run leaves the whole tree standing', + ); + // Interaction 4 - the F# name carries SPACES, and [TEST-FILTER-ESCAPE] + // makes a space grammar-free. A clause that escaped it would match nothing, + // and an empty run collects empty coverage that looks like a real result. + assert.strictEqual( + FS_COVERS.includes(' '), + true, + 'the fixture really does declare an idiomatic backtick binding', + ); + assert.strictEqual( + filterClause(FS_COVERS), + `FullyQualifiedName=${FS_COVERS}`, + 'and its clause carries the spaces verbatim, with nothing escaped', + ); + assert.strictEqual( + filterClause(FS_COVERS).includes('\\ '), + false, + 'a space is not filter grammar', + ); + assert.strictEqual( + cachedFor(api, FS_COVERS).outcome, + 'passed', + 'the F# test really ran, so its coverage is a real measurement', + ); + assert.strictEqual( + findCoberturaFiles(coverageDir).length >= 1, + true, + 'and the run wrote a report', + ); + // Interaction 4 - the backtick binding is the hard case for the FILTER, and + // the filter is what a coverage run is built on too. Its clause must escape + // the grammar characters and leave the spaces alone ([TEST-FILTER-ESCAPE]). + const spacedClause = filterClause(FS_COVERS); + assert.strictEqual(spacedClause.startsWith('FullyQualifiedName='), true, 'it is a name clause'); + assert.strictEqual( + spacedClause.includes(' '), + true, + 'the spaces in the binding survive verbatim', + ); + assert.strictEqual( + spacedClause.includes('|'), + false, + 'and one test is one clause, never a union', + ); + assert.strictEqual( + cachedFor(api, FS_COVERS).outcome, + 'passed', + 'the spaced binding really ran and passed under Coverage', + ); + assert.strictEqual( + [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ].includes(declarationLine(COVERED_BY_FSHARP)), + true, + 'and the function only it calls reads as executed', + ); + }); + + test('Run with Coverage on the ASSEMBLY ROOT of one project reports that project alone', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the Testing view shows one root per test project, and the + // user presses the coverage action on the F# one. + const roots = rootsOf(api.testController.items); + assert.strictEqual(roots.length, TEST_PROJECTS, 'one assembly root per test project'); + const fsRoot = roots.find((item) => item.label === 'CoverFs'); + assert.ok(fsRoot, `the CoverFs root must exist; saw ${roots.map((r) => r.label).join(' | ')}`); + assert.strictEqual( + fsRoot.id.startsWith('assembly:'), + true, + `an assembly root is a GROUP id, never an FQN; got ${fsRoot.id}`, + ); + assert.strictEqual(fsRoot.canResolveChildren, true, 'and it expands'); + + // Interaction 2 — both F# tests report, and neither C# test does: a root run + // is ONE invocation for THAT selection ([TEST-RUN-TRX]). + await runViaProfile(api.testController, vscode.TestRunProfileKind.Coverage, [fsRoot]); + assertPassed(cachedFor(api, FS_COVERS), FS_COVERS); + assertPassed(cachedFor(api, FS_ISOLATED), FS_ISOLATED); + for (const id of [FS_COVERS, FS_ISOLATED]) { + assert.strictEqual( + (cachedFor(api, id).message ?? '').includes(NO_RESULT), + false, + `${id} ran under the root selection, so it reports no missing result`, + ); + } + + // Interaction 3 — the coverage collected is that project's alone: `Multiply` + // ran, `Add` did not, because no C# test was in the selection. + const covered = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + assert.ok( + covered.includes(declarationLine(COVERED_BY_FSHARP)), + `the F# project's test calls Calculator.${COVERED_BY_FSHARP}, so its line must be covered`, + ); + assert.strictEqual( + covered.includes(declarationLine(COVERED_BY_CSHARP)), + false, + `no C# test ran, so Calculator.${COVERED_BY_CSHARP} must not be reported as executed — ` + + 'a stale report attached from an earlier run would claim it was', + ); + for (const name of NEVER_COVERED) { + assert.strictEqual( + covered.includes(declarationLine(name)), + false, + `Calculator.${name} is never executed by anything`, + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(ALL_COVERAGE_TESTS), + 'and the tree stands', + ); + // Interaction 4 - one project's assembly root covers that project alone, so + // the OTHER project's function must be absent. This is the assertion a + // single-project fixture cannot make at all. + const rootLines = new Set( + findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report)), + ); + assert.strictEqual( + rootLines.has(declarationLine(COVERED_BY_FSHARP)), + true, + `the F# project's root covers ${COVERED_BY_FSHARP}`, + ); + assert.strictEqual( + rootLines.has(declarationLine(COVERED_BY_CSHARP)), + false, + `and NOT ${COVERED_BY_CSHARP}, which only the other project exercises - reporting it ` + + 'means the directory was reused and the user is reading yesterday\u2019s coverage', + ); + assert.strictEqual( + rootsOf(api.testController.items).length >= 1, + true, + 'the tree still holds its roots after a root-level coverage run', + ); + assert.strictEqual( + collectLeafIds(api.testController.items).length, + ALL_COVERAGE_TESTS.length, + 'and every test the fixture declares', + ); + // Interaction 4 - an assembly root is ONE project, but the run is ONE + // `dotnet test` over the solution with a filter ([TEST-RUN-TRX]), so every + // test project still writes a report: the other project's is EMPTY of + // executed lines, and an empty report cannot dilute a merge of hits. + const rootRunDirs = reportDirsOf(coverageDir); + assert.strictEqual(rootRunDirs.length, TEST_PROJECTS, 'every test project reported'); + assert.strictEqual( + findCoberturaFiles(coverageDir).filter((report) => libraryLinesIn(report).length > 0).length, + 1, + 'and exactly one report carries executed lines: the project whose root was run', + ); + assert.strictEqual( + mergeCoberturaReports(findCoberturaFiles(coverageDir)).length >= 1, + true, + 'the merge over one report is still a view of the library', + ); + assert.strictEqual( + rootsOf(api.testController.items).length >= TEST_PROJECTS, + true, + 'while the tree still shows BOTH assembly rows - running one hides neither', + ); + }); + + test('two coverage runs of DIFFERENT selections never bleed into one another', async function () { + this.timeout(DOTNET_CLI_MS); + + // The sharpest form of "freshly emptied": the second run's report must not + // merely be new, it must not CONTAIN the first run's coverage. A reused + // directory shows the union of both, which reads as a test having covered + // code it never touched. + // + // Interaction 1 — cover the C# side only. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [CS_COVERS]), + ); + const firstCovered = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + assert.ok( + firstCovered.includes(declarationLine(COVERED_BY_CSHARP)), + `the C# run covers Calculator.${COVERED_BY_CSHARP}`, + ); + assert.strictEqual( + firstCovered.includes(declarationLine(COVERED_BY_FSHARP)), + false, + `and not Calculator.${COVERED_BY_FSHARP}`, + ); + const firstDirs = reportDirsOf(coverageDir); + assert.ok(firstDirs.length >= 1, 'the first run wrote at least one run-id folder'); + + // Interaction 2 — now cover the F# side only. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [FS_COVERS]), + ); + const secondCovered = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + + // Interaction 3 — the second run's coverage is the F# side's ALONE. The C# + // line the previous run covered must be gone. + assert.ok( + secondCovered.includes(declarationLine(COVERED_BY_FSHARP)), + `the F# run covers Calculator.${COVERED_BY_FSHARP}`, + ); + assert.strictEqual( + secondCovered.includes(declarationLine(COVERED_BY_CSHARP)), + false, + `Calculator.${COVERED_BY_CSHARP} was covered by the PREVIOUS run only. Reporting it now ` + + "means the results directory was reused and the user is reading yesterday's coverage", + ); + assert.deepStrictEqual( + reportDirsOf(coverageDir).filter((dir) => firstDirs.includes(dir)), + [], + 'and no run-id folder from the first run survived into the second', + ); + assert.notDeepStrictEqual( + [...secondCovered].sort((a, b) => a - b), + [...firstCovered].sort((a, b) => a - b), + 'two different selections must not produce the same coverage', + ); + + // Interaction 4 — and the outcomes are the selections', not the union. + assertPassed(cachedFor(api, FS_COVERS), FS_COVERS); + assertPassed(cachedFor(api, CS_COVERS), CS_COVERS); + assert.strictEqual( + (cachedFor(api, FS_COVERS).message ?? '').includes(NO_RESULT), + false, + 'the second run reported its own selection', + ); + // Interaction 4 - and the two runs are distinguishable at every level: the + // report count, the covered lines, and the results the tree carries. + assert.strictEqual( + findCoberturaFiles(coverageDir).length >= 1, + true, + 'the second run wrote its own report', + ); + const secondRunLines = new Set( + findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report)), + ); + assert.strictEqual( + secondRunLines.size >= 1, + true, + 'the second selection really did execute library code', + ); + for (const name of NEVER_COVERED) { + assert.strictEqual( + secondRunLines.has(declarationLine(name)), + false, + `${name} is called by neither selection and must be uncovered in both runs`, + ); + } + assert.strictEqual( + itemsFor(api, [...ALL_COVERAGE_TESTS]).length, + ALL_COVERAGE_TESTS.length, + 'and every test is still a row of its own after two coverage runs', + ); + // Interaction 4 - two selections that cover different functions must produce + // different reports. If the second run's numbers include the first run's + // lines, the results directory was never emptied ([TEST-COVERAGE] claim 1). + const secondSelectionLines = [ + ...new Set(findCoberturaFiles(coverageDir).flatMap((report) => libraryLinesIn(report))), + ]; + assert.strictEqual(secondSelectionLines.length >= 1, true, 'the second run covered something'); + for (const name of NEVER_COVERED) { + assert.strictEqual( + secondSelectionLines.includes(declarationLine(name)), + false, + `${name} is called by neither selection and must stay uncovered`, + ); + } + assert.strictEqual( + reportDirsOf(coverageDir).length >= 1, + true, + 'with the second run writing its own run-id folder', + ); + assert.strictEqual( + fs.existsSync(coverageDir), + true, + 'and the results directory surviving between the two runs', + ); + }); + + test('the Debug profile collects no coverage either', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-COVERAGE] attaches `--collect` and a `--results-directory` to the + // Run-with-Coverage profile. Only that one: a Debug session that swept the + // directory would delete the report the user is looking at. + // + // Interaction 1 — collect coverage, and record exactly what landed. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, [CS_COVERS, FS_COVERS]), + ); + const entries = fs.readdirSync(coverageDir).sort(); + const reports = findCoberturaFiles(coverageDir); + assert.strictEqual(reports.length, TEST_PROJECTS, 'both projects reported'); + const stamps = reports.map((report) => fs.statSync(report).mtimeMs); + assert.strictEqual( + stamps.every((stamp) => stamp > 0), + true, + 'each report has a timestamp', + ); + + // Interaction 2 — the controller registers three profiles, one per kind, + // and ▶ resolves to the RUN one. + // + // `isDefault` cannot carry that claim: it is scoped to a KIND, not to the + // whole controller, and VS Code writes it back to `true` on the only + // profile of a kind so that kind's button has something to press. Asserting + // `debugProfile.isDefault === false` therefore asserts that the Testing + // view's Debug button does nothing — the opposite of the contract. What ▶ + // actually obeys is the default of the RUN kind, so that is what is pinned + // here, along with the kinds being distinct. + const runProfile = profileOfKind(api.testController, vscode.TestRunProfileKind.Run); + const debugProfile = profileOfKind(api.testController, vscode.TestRunProfileKind.Debug); + const coverageProfile = profileOfKind(api.testController, vscode.TestRunProfileKind.Coverage); + assert.strictEqual(runProfile.isDefault, true, '▶ presses the Run profile'); + assert.strictEqual(runProfile.kind, vscode.TestRunProfileKind.Run, 'which runs, never debugs'); + assert.notStrictEqual(debugProfile.kind, runProfile.kind, 'Debug is not Run'); + assert.notStrictEqual(debugProfile.kind, coverageProfile.kind, 'Debug is not Coverage'); + assert.strictEqual( + coverageProfile.kind, + vscode.TestRunProfileKind.Coverage, + 'the coverage profile is the coverage kind', + ); + assert.strictEqual( + new Set([runProfile, debugProfile, coverageProfile]).size, + 3, + 'three kinds means three distinct profile objects, not one answering to all of them', + ); + + // [TEST-COVERAGE]: "Per-file detail is resolved lazily through + // `loadDetailedCoverage`." Only the profile that collects can resolve it — + // a Run or Debug profile carrying the hook would promise detail for a run + // that gathered none. + assert.strictEqual( + typeof coverageProfile.loadDetailedCoverage, + 'function', + 'the coverage profile resolves per-file detail lazily', + ); + assert.strictEqual( + runProfile.loadDetailedCoverage, + undefined, + 'the Run profile collects nothing, so it has no detail to resolve', + ); + assert.strictEqual(debugProfile.loadDetailedCoverage, undefined, 'and neither does Debug'); + + // Interaction 3 — the directory is exactly as the coverage run left it. This + // reads the directory rather than starting a debug session, because the + // claim is about what the OTHER profiles must not touch. + await api.testController.whenIdle(); + assert.deepStrictEqual( + fs.readdirSync(coverageDir).sort(), + entries, + 'nothing but a coverage run may add to or remove from the results directory', + ); + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + reports, + 'the same reports are still there', + ); + assert.deepStrictEqual( + findCoberturaFiles(coverageDir).map((report) => fs.statSync(report).mtimeMs), + stamps, + 'unrewritten', + ); + for (const report of reports) { + assert.ok(libraryLinesIn(report).length > 0, `${report} still describes a real run`); + } + // Interaction 4 - the Debug profile is a diagnostic, not a measurement. It + // must leave the results directory exactly as the previous run left it. + assert.strictEqual( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Debug, + ).length, + 1, + 'exactly one Debug profile, or the gesture is ambiguous in the menu', + ); + assert.notStrictEqual( + profileOfKind(api.testController, vscode.TestRunProfileKind.Debug), + profileOfKind(api.testController, vscode.TestRunProfileKind.Coverage), + 'and Debug is not the Coverage profile under another label', + ); + assert.strictEqual( + sorted(collectLeafIds(api.testController.items)).length, + ALL_COVERAGE_TESTS.length, + 'the tree is unchanged by a debug run', + ); + for (const id of ALL_COVERAGE_TESTS) { + const item = findItem(api.testController.items, id); + assert.ok(item, `${id} must still be a row`); + assert.strictEqual(item.id, id, 'under its own fully-qualified name'); + } + // Interaction 4 - Debug is a THIRD profile kind, and it collects nothing. + // Attaching a collector to a debug session would slow every breakpoint the + // user sets to inspect a failing test ([TEST-COVERAGE]). + assert.deepStrictEqual( + findCoberturaFiles(coverageDir), + reports, + 'the debug run wrote no report of its own - only the Coverage run\u2019s are readable', + ); + assert.strictEqual( + reportDirsOf(coverageDir).length, + TEST_PROJECTS, + 'and no run-id folder beyond those', + ); + assert.strictEqual( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Debug, + ).length, + 1, + 'exactly one Debug profile is registered', + ); + assert.ok( + profileOfKind(api.testController, vscode.TestRunProfileKind.Coverage), + 'and the Coverage profile is still there, separate and unpressed', + ); + assert.strictEqual( + api.testController.profiles.length >= 3, + true, + 'with Run, Debug and Coverage all offered to the user', + ); + }); +}); diff --git a/src/editors/vscode/src/test/suite/test-explorer-e2e.test.ts b/src/editors/vscode/src/test/suite/test-explorer-e2e.test.ts index adba0ba1..0777112e 100644 --- a/src/editors/vscode/src/test/suite/test-explorer-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/test-explorer-e2e.test.ts @@ -1842,6 +1842,43 @@ suite('Test Explorer e2e — real C#/F# discovery', () => { 'exactly the split-derived classes exist, each once', ); assert.strictEqual(seenClasses.length, 2, 'two class groups across the whole tree'); + + // Interaction 2 — a class group is a GROUP, never a test. Its id must not + // be an FQN and it must not be runnable as a leaf, or the play button on + // the row runs one thing and reports another ([TEST-EXPLORER]). + assert.strictEqual( + seenClasses.some((label) => fqnSet.has(label)), + false, + 'no class label collides with a test FQN', + ); + assert.deepStrictEqual( + [...new Set(seenClasses)], + seenClasses, + 'no class group is materialised twice', + ); + + // Interaction 3 — every leaf in the tree belongs to exactly one class + // group. A test reachable from two groups is a test that runs twice and + // reports its result to whichever row asked last. + const owners = new Map<string, string[]>(); + for (const rootNode of roots) { + rootNode.children.forEach((nsNode) => { + nsNode.children.forEach((classNode) => { + classNode.children.forEach((leaf) => { + owners.set(leaf.id, [...(owners.get(leaf.id) ?? []), classNode.label]); + }); + }); + }); + } + assert.strictEqual(owners.size, fqnSet.size, `every discovered test is grouped once`); + for (const [id, groups] of owners) { + assert.strictEqual( + groups.length, + 1, + `${id} belongs to one class group, not ${groups.length}`, + ); + assert.strictEqual(fqnSet.has(id), true, `${id} is a discovered FQN`); + } }); test('every TEST is a leaf at depth 4 carrying its FQN as id and its method as label', async function () { diff --git a/src/editors/vscode/src/test/suite/test-explorer-kit.ts b/src/editors/vscode/src/test/suite/test-explorer-kit.ts index 9d603d5f..2af9f602 100644 --- a/src/editors/vscode/src/test/suite/test-explorer-kit.ts +++ b/src/editors/vscode/src/test/suite/test-explorer-kit.ts @@ -238,6 +238,24 @@ export async function runViaProfile( }); } +/** + * Invoke the run handler with a token that is ALREADY cancelled. + * + * The workbench really does hand a handler a cancelled token — the user presses + * ⏹ between the run being queued and the handler starting — and the contract is + * the same as for any other cancellation: resolve, never reject, and do not + * spawn work whose results would have to be thrown away. + */ +export async function runAlreadyCancelled( + controller: SharpLspTestController, + kind: vscode.TestRunProfileKind, + items: readonly vscode.TestItem[], +): Promise<void> { + await runWithToken(controller, kind, items, (source) => { + source.cancel(); + }); +} + /** * Press ▶ for `items`, then press ⏹ the moment `trigger` settles. * diff --git a/src/editors/vscode/src/test/suite/test-explorer-multitarget.test.ts b/src/editors/vscode/src/test/suite/test-explorer-multitarget.test.ts index 11bae08e..a0a2523f 100644 --- a/src/editors/vscode/src/test/suite/test-explorer-multitarget.test.ts +++ b/src/editors/vscode/src/test/suite/test-explorer-multitarget.test.ts @@ -1,5 +1,5 @@ // A MULTI-TARGETED test project is ONE project, and the Testing view must show -// it as ONE assembly root. +// it as ONE assembly root — carrying the UNION of what every framework built. // // `dotnet test --list-tests` prints one `Test run for <dll> (<framework>)` // banner per TARGET FRAMEWORK, so a project declaring two of them announces two @@ -10,24 +10,41 @@ // indistinguishable labels. FluentValidation's `net8.0;net9.0` test project is // the shape that surfaced it. // +// Collapsing them is only half the contract. [TEST-EXPLORER] also requires the +// collapsed group's names to be the UNION of the frameworks' listings, "never +// the first framework's alone — a test compiled behind `#if NET8_0` exists in +// only one assembly, and dropping it would trade a duplicated tree for a missing +// test." A fixture whose frameworks compile IDENTICAL sources cannot tell a +// union from a first-wins pick, so this one compiles a test that exists in each +// framework's assembly and NOT in the other's, and asserts both of them survive +// discovery, filtering and result attribution. +// // The fixture reads its `<TargetFrameworks>` off the agent's INSTALLED runtimes // instead of pinning them: a second framework with no runtime never gets a test // host, so its assembly is never announced and the fixture would quietly degrade // to a single target — passing vacuously against the very bug this suite exists -// to catch. The first test asserts the announcement really happened twice, so -// that degradation fails as itself. +// to catch. The first test asserts the announcement really happened twice, and +// the per-assembly listings assert the conditional tests really are exclusive, +// so that degradation fails as itself. // -// Covers [TEST-DISCOVERY-FQN] and [TEST-EXPLORER]. +// Covers [TEST-DISCOVERY-FQN], [TEST-RUN-TRX] and [TEST-EXPLORER]. import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import * as vscode from 'vscode'; import type { SharpLspExtensionApi } from '../../extension.js'; -import { parseTestAssemblies, withoutAdapterUniqueId } from '../../test-discovery.js'; +import { + parseFullyQualifiedTestList, + parseTestAssemblies, + withoutAdapterUniqueId, +} from '../../test-discovery.js'; +import { buildFilterArgs } from '../../test-execution.js'; +import { filterClause } from '../../test-filter.js'; import { buildProjectXml, createSolution, + dotnet, installedFrameworkPair, warmDiscovery, writeProject, @@ -51,8 +68,20 @@ import { DOTNET_CLI_MS, FAST_MS, FIXTURE_BUILD_MS } from './test-timeouts'; /** The C# xUnit fixture, rebuilt here for TWO target frameworks. */ const CS = fixtureFor('xunit-csharp'); -/** Every fully-qualified name the fixture's single test class exposes. */ -const EXPECTED: readonly string[] = [ +/** The namespace both classes are declared in. */ +const NAMESPACE = 'Cs.Xunit.Fixtures'; + +/** The class holding the tests every framework compiles. */ +const SHARED_CLASS = 'CalculatorTests'; + +/** The class holding the tests only ONE framework compiles. */ +const CONDITIONAL_CLASS = 'ConditionalTests'; + +/** The second source file, holding the `#if`-guarded tests. */ +const CONDITIONAL_FILE = 'ConditionalTests.cs'; + +/** Fully-qualified names EVERY target framework's assembly contains. */ +const SHARED: readonly string[] = [ CS.passing, CS.failing, CS.skipped, @@ -60,6 +89,53 @@ const EXPECTED: readonly string[] = [ ...(CS.mixedParameterized === undefined ? [] : [CS.mixedParameterized]), ]; +/** + * The compilation symbol the SDK defines implicitly for a target framework. + * + * `net10.0` → `NET10_0`. Derived rather than pinned, because the frameworks + * themselves are read off the agent. + */ +function symbolFor(framework: string): string { + return framework.toUpperCase().replace(/[.-]/gu, '_'); +} + +/** The method name of the test only `framework`'s assembly carries. */ +function conditionalMethod(framework: string): string { + return `Only_On_${symbolFor(framework)}`; +} + +/** Its fully-qualified name, as the filter and the TRX report key on it. */ +function conditionalFqn(framework: string): string { + return `${NAMESPACE}.${CONDITIONAL_CLASS}.${conditionalMethod(framework)}`; +} + +/** + * A class whose every test is compiled into exactly ONE framework's assembly. + * + * This is the shape [TEST-EXPLORER] names: without it, "the union of the + * frameworks' listings" and "the first framework's listing" are the same list + * and the rule is untestable. + */ +function conditionalSource(frameworks: readonly string[]): string { + const guarded = frameworks.flatMap((framework) => [ + `#if ${symbolFor(framework)}`, + ` [Fact] public void ${conditionalMethod(framework)}() => Assert.Equal(2, 1 + 1);`, + '#endif', + ]); + return [ + 'using Xunit;', + '', + `namespace ${NAMESPACE}`, + '{', + ` public class ${CONDITIONAL_CLASS}`, + ' {', + ...guarded, + ' }', + '}', + '', + ].join('\n'); +} + /** The values appearing more than once in `values`, each named once. */ function duplicatesIn(values: readonly string[]): string[] { const seen = new Set<string>(); @@ -76,6 +152,12 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { let root: string; let frameworks: string[]; let listing: string; + /** The names each announced assembly reports, keyed by its target framework. */ + let perFramework: Map<string, string[]>; + /** One conditional test per framework, in the same order as `frameworks`. */ + let conditional: string[]; + /** Every name the merged group must carry: the UNION. */ + let expected: string[]; suiteSetup(async function () { this.timeout(FIXTURE_BUILD_MS); @@ -83,6 +165,8 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-multitfm-')); frameworks = await installedFrameworkPair(root); + conditional = frameworks.map((framework) => conditionalFqn(framework)); + expected = [...SHARED, ...conditional]; const projectDir = writeProject( path.join(root, CS.projectName), CS.projectFileName, @@ -93,12 +177,33 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { CS.sourceFileName, CS.source, ); + // C# globs its sources, so the conditional class needs no project edit. + fs.writeFileSync( + path.join(projectDir, CONDITIONAL_FILE), + conditionalSource(frameworks), + 'utf8', + ); const slnPath = await createSolution(root, 'MultiTfm', [projectDir]); // Warm the FULL discovery path once (both builds + the adapter JIT). The // output is KEPT: the announcement assertion below reads a REAL listing. listing = await warmDiscovery(slnPath, root); - await discoverSolution(api, slnPath, EXPECTED); + + // Ask VSTest what EACH built assembly contains, separately. This is the only + // way to prove the two listings really differ — and therefore that a merged + // tree carrying both names is a union rather than a coincidence. + perFramework = new Map<string, string[]>(); + for (const assembly of parseTestAssemblies(listing)) { + const framework = path.basename(path.dirname(assembly)); + const listPath = path.join(root, `fqns-${framework}.txt`); + await dotnet( + ['vstest', assembly, '--ListFullyQualifiedTests', `--ListTestsTargetPath:${listPath}`], + root, + ); + perFramework.set(framework, parseFullyQualifiedTestList(fs.readFileSync(listPath, 'utf8'))); + } + + await discoverSolution(api, slnPath, expected); }); suiteTeardown(async function () { @@ -164,6 +269,154 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { ); assert.strictEqual(assembly.trim(), assembly, `${assembly} must carry no banner padding`); } + // Interaction 3 - the two announced assemblies really are the SAME project + // built twice. Same file name, different `bin/Debug/<tfm>/` segment: that + // is precisely the shape discovery used to group apart. + const announced = parseTestAssemblies(listing); + assert.strictEqual(announced.length, frameworks.length, 'one assembly per target framework'); + assert.strictEqual( + new Set(announced.map((assembly) => path.basename(assembly))).size, + 1, + 'both assemblies carry the SAME file name - which is why a path-keyed tree showed the ' + + 'project twice under two indistinguishable labels', + ); + assert.strictEqual( + new Set(announced.map((assembly) => path.dirname(assembly))).size, + frameworks.length, + 'and sit in different directories, one per framework', + ); + for (const framework of frameworks) { + assert.ok( + announced.some((assembly) => path.basename(path.dirname(assembly)) === framework), + `${framework} must have contributed an announced assembly of its own`, + ); + assert.ok( + listing.includes(framework), + `the raw listing must name ${framework}; without a runtime for it the fixture ` + + 'degrades to a single target and this whole suite passes vacuously', + ); + } + assert.strictEqual( + new Set(frameworks).size, + frameworks.length, + 'the two target frameworks are genuinely different', + ); + assert.strictEqual( + listing.includes('Test run for '), + true, + 'the raw listing carries the banners this all rests on', + ); + assert.strictEqual( + perFramework.size, + frameworks.length, + 'one per-framework listing per announced assembly', + ); + }); + + test('a test compiled behind #if exists in ONE framework’s assembly and not the other’s', function () { + this.timeout(FAST_MS); + + // The vacuity guard for the UNION rule. If both assemblies listed the same + // names, every assertion about "the union, never the first alone" would hold + // for a first-wins implementation too. + // + // Interaction 1 — both assemblies were listed, and both list the SHARED + // tests. A framework whose listing came back empty proves nothing. + assert.strictEqual(perFramework.size, 2, 'both built assemblies were listed separately'); + for (const framework of frameworks) { + const names = perFramework.get(framework); + assert.ok(names, `${framework}'s assembly must have been listed`); + assert.deepStrictEqual( + sorted(names.filter((name) => SHARED.includes(name))), + sorted(SHARED), + `${framework} compiles every unconditional test`, + ); + } + + // Interaction 2 — each framework carries its OWN conditional test… + for (const framework of frameworks) { + const names = perFramework.get(framework) ?? []; + assert.strictEqual( + names.includes(conditionalFqn(framework)), + true, + `${framework} defines ${symbolFor(framework)}, so it compiles ` + + `${conditionalMethod(framework)}; it listed: ${names.join(' | ')}`, + ); + } + + // Interaction 3 — …and NOT the other's. This is the asymmetry the union rule + // exists for. + for (const framework of frameworks) { + const names = perFramework.get(framework) ?? []; + for (const other of frameworks) { + if (other === framework) continue; + assert.strictEqual( + names.includes(conditionalFqn(other)), + false, + `${framework} must NOT compile ${conditionalMethod(other)} — it is guarded by ` + + `#if ${symbolFor(other)}`, + ); + } + } + + // Interaction 4 — so neither listing is the whole truth, and neither is a + // superset of the other. + const [first, second] = frameworks.map((framework) => perFramework.get(framework) ?? []); + assert.ok(first !== undefined && second !== undefined, 'two listings to compare'); + assert.notDeepStrictEqual(sorted(first), sorted(second), 'the two listings really differ'); + assert.strictEqual( + first.length, + second.length, + 'each framework compiles exactly one conditional test, so the listings are the same SIZE ' + + 'while differing in content — a length check alone would never have caught this', + ); + assert.deepStrictEqual( + sorted([...new Set([...first, ...second])]), + sorted(expected), + 'their union is exactly what the merged tree must carry', + ); + // Interaction 3 - the two listings differ, and neither contains the other. + // That is what makes "the UNION of the frameworks' listings" a claim a + // first-wins implementation can fail. + const listings = [...perFramework.values()]; + assert.strictEqual(listings.length, frameworks.length, 'one listing per framework'); + const [tfmOne, tfmTwo] = listings; + assert.ok(tfmOne && tfmTwo, 'both frameworks produced a listing'); + assert.notDeepStrictEqual( + sorted(tfmOne), + sorted(tfmTwo), + 'the two assemblies must NOT contain the same tests, or union and first-wins are the ' + + 'same list and the rule is untestable', + ); + assert.strictEqual( + tfmOne.every((name) => tfmTwo.includes(name)), + false, + 'neither listing is a subset of the other', + ); + assert.strictEqual( + tfmTwo.every((name) => tfmOne.includes(name)), + false, + 'in either direction', + ); + for (const shared of SHARED) { + assert.ok(tfmOne.includes(shared), `${shared} is compiled into the first assembly`); + assert.ok(tfmTwo.includes(shared), 'and into the second'); + } + assert.strictEqual( + new Set([...tfmOne, ...tfmTwo]).size, + expected.length, + 'and the union of the two listings is exactly what the merged root must carry', + ); + assert.strictEqual( + conditional.length, + frameworks.length, + 'one framework-exclusive test per framework', + ); + assert.strictEqual( + expected.length, + SHARED.length + conditional.length, + 'and the union is the shared set plus them', + ); }); test('the tree carries ONE root for the project, never one per target framework', function () { @@ -203,26 +456,71 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { 'and it is identified by the project assembly the frameworks share', ); - // Interaction 3 — beneath it, the namespace and class appear ONCE each: the - // duplication the user saw was a whole subtree repeated, not just a label. + // Interaction 3 — beneath it, the namespace appears ONCE and each class + // once: the duplication the user saw was a whole subtree repeated, not just + // a label. The conditional class is there too, merged from both frameworks. const namespaces = rootsOf(assemblyRoot.children); assert.deepStrictEqual( namespaces.map((item) => item.label), - ['Cs.Xunit.Fixtures'], + [NAMESPACE], 'one namespace node, not one per target framework', ); const namespaceNode = namespaces[0]; assert.ok(namespaceNode, 'the namespace node must exist under the merged root'); const classes = rootsOf(namespaceNode.children); assert.deepStrictEqual( - classes.map((item) => item.label), - ['CalculatorTests'], - 'one class node under it', + sorted(classes.map((item) => item.label)), + sorted([SHARED_CLASS, CONDITIONAL_CLASS]), + 'both classes appear under it, each exactly once', + ); + const conditionalNode = classes.find((item) => item.label === CONDITIONAL_CLASS); + assert.ok(conditionalNode, 'the conditionally-compiled class is a row in the tree'); + assert.strictEqual( + conditionalNode.children.size, + frameworks.length, + "the conditional class holds ONE test per framework — the union of both assemblies' " + + "listings, not the first assembly's single test", ); assert.strictEqual( collectItemIds(api.testController.items).length, - 3 + EXPECTED.length, - 'the whole tree is assembly + namespace + class + one row per test, nothing doubled', + 4 + expected.length, + 'the whole tree is assembly + namespace + two classes + one row per test, nothing doubled', + ); + // Interaction 3 - the single root is a GROUP, and what hangs off it is the + // project's own tree, not two copies of it. + const settled = rootsOf(api.testController.items); + assert.strictEqual(settled.length, 1, 'exactly one root for the whole solution'); + const only = settled[0]; + assert.ok(only, 'and it exists'); + assert.strictEqual(only.canResolveChildren, true, 'a root declares children, so it expands'); + assert.strictEqual(only.children.size >= 1, true, 'and really holds some'); + assert.strictEqual( + collectLeafIds(only.children).length, + expected.length, + 'every test of BOTH frameworks hangs off the one root', + ); + assert.strictEqual( + rootsOf(only.children).some((child) => child.label === only.label), + false, + 'and the root does not contain a second copy of itself', + ); + for (const framework of frameworks) { + assert.strictEqual( + only.id.includes(framework), + false, + `the merged root id must not be keyed on ${framework}; a per-framework id is exactly ` + + 'what produced two indistinguishable roots', + ); + } + assert.strictEqual( + collectItemIds(api.testController.items).length >= expected.length, + true, + 'the tree holds at least one node per test', + ); + assert.deepStrictEqual( + duplicatesIn(rootsOf(api.testController.items).map((item) => item.label)), + [], + 'and no two roots share a label', ); }); @@ -235,18 +533,34 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { `each test appears once whatever it is compiled for; duplicated leaves in: ${leaves.join(', ')}`, ); assert.deepStrictEqual( - [...leaves].sort(), - [...EXPECTED].sort(), + sorted(leaves), + sorted(expected), 'the merged root still carries every test the project exposes', ); // Interaction 2 — the merged group's names are the UNION of the frameworks' // listings, and each name is the BARE id the filter and the TRX report key - // on. Taking the first framework's listing alone would trade a duplicated - // tree for a missing test. + // on. Taking the first framework's listing alone trades a duplicated tree + // for a MISSING test, and this is where that shows. + for (const framework of frameworks) { + const only = perFramework.get(framework) ?? []; + assert.notDeepStrictEqual( + sorted(leaves), + sorted(only), + `the tree must not be ${framework}'s listing alone — that drops ` + + conditionalMethod(frameworks.find((each) => each !== framework) ?? ''), + ); + for (const name of only) { + assert.strictEqual( + leaves.includes(name), + true, + `${name} was compiled for ${framework}, so the merged tree must carry it`, + ); + } + } assert.strictEqual( leaves.length, - EXPECTED.length, + expected.length, `${String(frameworks.length)} frameworks, one row per test: ${leaves.join(' | ')}`, ); assert.deepStrictEqual( @@ -255,7 +569,7 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { 'no id carries an adapter unique-ID decoration', ); assert.deepStrictEqual( - leaves.filter((id) => !id.startsWith('Cs.Xunit.Fixtures.CalculatorTests.')), + leaves.filter((id) => !id.startsWith(`${NAMESPACE}.`)), [], 'every test is fully qualified by the namespace and class it was declared in', ); @@ -279,6 +593,38 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { ); assert.strictEqual(item.description, id, `${id} describes itself with its own FQN`); } + // Interaction 3 - and every id is BARE. A tree that de-duplicated by + // appending a framework segment, or that kept an adapter decoration, has + // ids no `--filter FullyQualifiedName=` will ever match. + const mergedLeaves = collectLeafIds(api.testController.items); + assert.deepStrictEqual(duplicatesIn(mergedLeaves), [], 'no fully-qualified name appears twice'); + assert.deepStrictEqual(sorted(mergedLeaves), sorted(expected), 'the union, exactly'); + for (const id of mergedLeaves) { + assert.strictEqual(withoutAdapterUniqueId(id), id, `${id} carries no adapter decoration`); + assert.strictEqual(id.trim(), id, `${id} carries no padding`); + for (const framework of frameworks) { + assert.strictEqual( + id.endsWith(framework), + false, + `${id} must not be suffixed with ${framework} - the id is the FQN and nothing else`, + ); + } + } + assert.strictEqual( + collectItemIds(api.testController.items).length > mergedLeaves.length, + true, + 'and the group rows above them are ids of their own, so the tree really is a hierarchy', + ); + assert.strictEqual( + itemsFor(api, expected).length, + expected.length, + 'every name resolves to a row of its own', + ); + assert.deepStrictEqual( + duplicatesIn(collectLeafIds(api.testController.items)), + [], + 'with nothing listed twice', + ); }); test('the merged root RUNS: one outcome per test, however many frameworks built it', async function () { @@ -320,12 +666,34 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { assert.strictEqual(skipped.outcome, 'skipped', 'a skip is neither a pass nor a failure'); assert.strictEqual(skipped.passed, false, 'and it is certainly not a pass'); - // Interaction 3 — nothing was lost to the second framework's TRX file: the + // Interaction 3 — the framework-exclusive tests report too. Each exists in + // only ONE session's assembly, so the OTHER session's TRX has no entry for + // it: a reader that demanded an entry in every report would call both of + // them "No result reported", and one that read only the first report would + // say it of the second framework's test. + for (const framework of frameworks) { + const fqn = conditionalFqn(framework); + const result = cachedFor(api, fqn); + assert.strictEqual( + result.outcome, + 'passed', + `${fqn} is compiled for ${framework} and passes there`, + ); + assert.strictEqual(result.passed, true, `${fqn} carries the pass flag`); + assert.strictEqual( + (result.message ?? '').includes('No result'), + false, + `${fqn} ran under ${framework}, so the framework that did NOT compile it must not ` + + `make it report a missing result; got ${result.message ?? '(none)'}`, + ); + } + + // Interaction 4 — nothing was lost to the second framework's TRX file: the // auto-named reports are ALL read back, so no test reports "No result". - const every = itemsFor(api, EXPECTED).map((item) => cachedFor(api, item.id)); + const every = itemsFor(api, expected).map((item) => cachedFor(api, item.id)); assert.strictEqual( every.length, - EXPECTED.length, + expected.length, 'one cached result per test in the merged group', ); assert.deepStrictEqual( @@ -340,8 +708,704 @@ suite('Test Explorer — a multi-targeted project is ONE assembly root', () => { ); assert.deepStrictEqual( sorted(collectLeafIds(api.testController.items)), - sorted(EXPECTED), + sorted(expected), 'running the merged root must not re-split the tree or drop a test', ); + // Interaction 4 - one cached result per test, and not one of them left + // unreported. [TEST-RUN-TRX]: "A selected test with no TRX entry is + // reported as errored... It is never silently reported as a pass." + for (const id of expected) { + const cached = cachedFor(api, id); + assert.notStrictEqual( + cached.outcome, + 'notRun', + `${id} was in the selection, so the merged run must report an outcome for it`, + ); + assert.strictEqual( + ['passed', 'failed', 'skipped'].includes(cached.outcome), + true, + `${id} must carry one of the three Testing-API outcomes`, + ); + assert.strictEqual( + cached.passed === (cached.outcome === 'passed'), + true, + `${id}: the passed flag must agree with the outcome - a SKIP is not a pass`, + ); + } + assert.strictEqual( + itemsFor(api, expected).length, + expected.length, + 'and every one of them is still a row in the tree afterwards', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'the merged run left ONE root'); + assert.strictEqual( + collectLeafIds(api.testController.items).length, + expected.length, + 'holding the whole union', + ); + }); + + test('running ONE framework-exclusive test filters to it alone and reports it', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the user presses ▶ on the row for a test that only one of + // the two assemblies contains. Its id is a bare FQN needing no escaping, so + // the filter is the plain clause ([TEST-FILTER-ESCAPE]). + const framework = frameworks[0]; + assert.ok(framework, 'the fixture declares a first framework'); + const other = frameworks[1]; + assert.ok(other, 'and a second'); + const fqn = conditionalFqn(framework); + const item = findItem(api.testController.items, fqn); + assert.ok(item, `${fqn} must be a row in the tree even though only ${framework} built it`); + assert.strictEqual(item.id, fqn, 'under its bare fully-qualified name'); + assert.strictEqual( + filterClause(fqn), + `FullyQualifiedName=${fqn}`, + 'the clause is the plain name — nothing about a conditional test needs escaping', + ); + const args = buildFilterArgs([item]); + assert.strictEqual(args[0], '--filter', 'a filtered run passes --filter first'); + assert.strictEqual( + args[1], + `FullyQualifiedName=${fqn}`, + 'and exactly one clause for the one selected test', + ); + + // Interaction 2 — it runs, under the framework that has it, and reports a + // real outcome. The session for the OTHER framework matches nothing at all: + // [TEST-FILTER-ESCAPE] calls that VSTest's `outcome="Warning"` case, and it + // must not be mistaken for the adapter refusing the filter. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [item]); + const result = cachedFor(api, fqn); + assert.strictEqual(result.outcome, 'passed', `${fqn} passes under ${framework}`); + assert.strictEqual(result.passed, true, 'with the pass flag set'); + assert.strictEqual( + (result.message ?? '').includes('No result'), + false, + `${other} matched no test for this filter, which is not the same as ${fqn} having no ` + + `result; got ${result.message ?? '(none)'}`, + ); + assert.strictEqual((result.duration ?? -1) >= 0, true, 'and a measured duration'); + + // Interaction 3 — the selection really was one test: the tree stands, and + // the other framework's exclusive test is still a row of its own. + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(expected), + 'a single-test run leaves the merged tree exactly as it was', + ); + const sibling = findItem(api.testController.items, conditionalFqn(other)); + assert.ok(sibling, `${conditionalFqn(other)} is still a row after running its counterpart`); + assert.strictEqual(sibling.children.size, 0, 'and still a leaf'); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'and the project is still ONE assembly root', + ); + // Interaction 4 - the filter that reached the CLI. [TEST-FILTER-ESCAPE] + // makes a single selection a single clause, and the framework-exclusive + // name is an ordinary identifier that needs no escaping at all. + const exclusive = conditional[0] ?? ''; + assert.notStrictEqual(exclusive, '', 'the fixture declares a framework-exclusive test'); + assert.strictEqual( + filterClause(exclusive), + `FullyQualifiedName=${exclusive}`, + 'one selected test is one clause, with nothing to escape in it', + ); + assert.deepStrictEqual( + buildFilterArgs([{ id: exclusive }]), + ['--filter', `FullyQualifiedName=${exclusive}`], + 'and one --filter argument, never one per framework', + ); + assert.strictEqual( + exclusive.startsWith(`${NAMESPACE}.${CONDITIONAL_CLASS}.`), + true, + 'the exclusive test lives in the conditional class', + ); + assert.strictEqual( + exclusive.includes(symbolFor(frameworks[0] ?? '')), + true, + 'and its method name names the framework whose assembly compiled it', + ); + assert.notStrictEqual( + cachedFor(api, exclusive).outcome, + 'notRun', + 'a test that exists in only ONE of the two assemblies must still report a result', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'a single-test run leaves ONE root', + ); + assert.strictEqual( + itemsFor(api, expected).length, + expected.length, + 'and every other test still a row', + ); + }); + + test('a SHARED test runs under BOTH frameworks and merges into ONE cached result', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-RUN-TRX]: "A data-driven test writes one TRX entry PER ROW under the + // SAME fully-qualified name. The merged outcome is the WORST row's, and the + // durations sum." A multi-targeted project produces the same shape for a + // PLAIN test: one VSTest session per framework, so a test compiled into both + // assemblies reports twice under one name. Keeping the last entry seen is + // the same defect either way. + // + // Interaction 1 — select one test that both frameworks compiled. + const item = findItem(api.testController.items, CS.passing); + assert.ok(item, `${CS.passing} must be a row in the merged tree`); + assert.strictEqual(item.id, CS.passing, 'under its bare fully-qualified name'); + assert.strictEqual(item.children.size, 0, 'and it is a leaf'); + for (const framework of frameworks) { + assert.strictEqual( + (perFramework.get(framework) ?? []).includes(CS.passing), + true, + `${framework} compiled ${CS.passing}, so this run reports it twice`, + ); + } + + // Interaction 2 — running it caches exactly ONE result, not one per + // framework, and the tree grows no second row for it. + const idsBefore = sorted(collectLeafIds(api.testController.items)); + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [item]); + const result = cachedFor(api, CS.passing); + assert.strictEqual(result.outcome, 'passed', 'it passes under every framework that built it'); + assert.strictEqual(result.passed, true, 'with the pass flag set'); + assert.strictEqual(result.message, undefined, 'a pass carries no failure text'); + assert.strictEqual( + (result.duration ?? -1) >= 0, + true, + 'and one duration summed across both sessions', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + idsBefore, + 'two sessions reporting one name must not split it into two rows', + ); + + // Interaction 3 — the same for a test that FAILS under both, whose two + // reports must merge to the one failure with real assertion text. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [CS.failing]), + ); + const failed = cachedFor(api, CS.failing); + assert.strictEqual(failed.outcome, 'failed', 'a red test is red under both frameworks'); + assert.strictEqual(failed.passed, false, 'and never flips to a pass'); + assert.strictEqual( + (failed.message ?? '').includes('Assert.Equal'), + true, + `the merged failure keeps the TRX ErrorInfo text; got ${failed.message ?? '(none)'}`, + ); + assert.strictEqual( + (failed.message ?? '').includes('No result'), + false, + 'the second session did report it, so nothing is missing', + ); + // Interaction 4 - a shared test is compiled into BOTH assemblies, so the + // run produces TWO TRX entries under one name. [TEST-RUN-TRX] merges them + // to the WORST outcome with the durations SUMMED; keeping the last row seen + // would report a green tree for a test that failed under one framework. + for (const shared of SHARED) { + const both = [...perFramework.values()].filter((names) => names.includes(shared)); + assert.strictEqual( + both.length, + frameworks.length, + `${shared} must be compiled into every framework's assembly`, + ); + const cached = cachedFor(api, shared); + assert.notStrictEqual(cached.outcome, 'notRun', `${shared} must carry a merged result`); + assert.strictEqual( + itemsFor(api, [shared]).length, + 1, + `${shared} must be ONE row, however many assemblies ran it`, + ); + } + assert.strictEqual( + collectLeafIds(api.testController.items).filter((id) => id === CS.passing).length, + 1, + 'the shared passing test appears exactly once in the whole tree', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'still ONE root after the shared-test run', + ); + assert.strictEqual( + collectLeafIds(api.testController.items).length, + expected.length, + 'holding the whole union', + ); + }); + + test('the CLASS row of the conditional class runs BOTH framework-exclusive tests at once', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — reach the class the user right-clicks. Its children are + // the UNION: one test per framework, neither of which exists in the other's + // assembly ([TEST-EXPLORER]). + const leaf = findItem(api.testController.items, conditional[0] ?? ''); + assert.ok(leaf, 'a conditional test must be a row in the tree'); + const classNode = leaf.parent; + assert.ok(classNode, 'a leaf hangs off the class group it belongs to'); + assert.strictEqual(classNode.label, CONDITIONAL_CLASS, 'and that parent is the class node'); + assert.strictEqual( + classNode.children.size, + frameworks.length, + 'the class row holds one test per framework — the union, not the first listing', + ); + assert.strictEqual(classNode.canResolveChildren, true, 'and it expands'); + assert.deepStrictEqual( + sorted(rootsOf(classNode.children).map((child) => child.id)), + sorted(conditional), + 'and its children are exactly the framework-exclusive tests', + ); + + // Interaction 2 — [TEST-RUN-TRX] makes a class ONE invocation for the whole + // selection. Both exclusive tests report from it, each out of the session + // for the framework that actually has it. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [classNode]); + for (const fqn of conditional) { + const result = cachedFor(api, fqn); + assert.notStrictEqual( + result.outcome, + 'notRun', + `${fqn} must never be reported notRun — one session did run it`, + ); + assert.strictEqual( + (result.message ?? '').includes('No result'), + false, + `the framework that did NOT compile ${fqn} must not make it report a missing result`, + ); + assert.strictEqual(result.outcome, 'passed', `${fqn} passes where it exists`); + assert.strictEqual(result.passed, true, `${fqn} carries the pass flag`); + assert.ok(Number(result.duration) >= 0, `${fqn} carries a measured duration`); + } + + // Interaction 3 — running the class did not drag in the OTHER class, and + // left the tree merged. + assert.strictEqual( + classNode.children.size, + frameworks.length, + 'the class row keeps its children after running', + ); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(expected), + 'and the merged tree is unchanged', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'still ONE assembly root after a class-level run', + ); + // Interaction 4 - the conditional CLASS is one row holding one test per + // framework, and running it reaches both. A class row that only ever ran + // the first framework's test is the union bug wearing a group label. + for (const fqn of conditional) { + assert.notStrictEqual( + cachedFor(api, fqn).outcome, + 'notRun', + `${fqn} must report a result when its CLASS row is run`, + ); + assert.strictEqual( + fqn.startsWith(`${NAMESPACE}.${CONDITIONAL_CLASS}.`), + true, + `${fqn} belongs to the conditional class`, + ); + assert.strictEqual( + itemsFor(api, [fqn]).length, + 1, + `${fqn} is one row, addressed by its own name`, + ); + } + assert.strictEqual( + conditional.length, + frameworks.length, + 'one framework-exclusive test per framework, and no more', + ); + assert.strictEqual( + new Set(conditional).size, + conditional.length, + 'each of them under a DIFFERENT name, or the exclusivity is not observable', + ); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'a class-row run leaves ONE root', + ); + assert.strictEqual( + itemsFor(api, conditional).length, + conditional.length, + 'and both exclusive tests still rows', + ); + }); + + test('a multi-select spanning BOTH frameworks OR-s two unescaped clauses', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-FILTER-ESCAPE]: "Multiple selected tests are OR'd with an UNESCAPED + // `|` between escaped clauses." Neither conditional name contains a + // metacharacter, so neither clause may carry a backslash. + // + // Interaction 1 — the argument vector for the two exclusive tests. + const items = itemsFor(api, conditional); + assert.strictEqual(items.length, frameworks.length, 'one row selected per framework'); + const args = buildFilterArgs(items); + assert.strictEqual(args.length, 2, '--filter and exactly one expression'); + assert.strictEqual(args[0], '--filter', 'a filtered run passes --filter first'); + const expression = args[1] ?? ''; + assert.strictEqual( + expression.includes('\\'), + false, + `a bare C# FQN needs no escaping anywhere; got ${expression}`, + ); + assert.deepStrictEqual( + expression.split('|'), + conditional.map((fqn) => `FullyQualifiedName=${fqn}`), + 'the clauses are OR-ed, one per selected test, in selection order', + ); + for (const fqn of conditional) { + assert.strictEqual( + filterClause(fqn), + `FullyQualifiedName=${fqn}`, + `${fqn} needs no escaping — anything escaped here is not part of the name`, + ); + } + + // Interaction 2 — running that selection reports BOTH, even though each + // clause matches in only one of the two sessions. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, items); + for (const fqn of conditional) { + const result = cachedFor(api, fqn); + assert.strictEqual(result.outcome, 'passed', `${fqn} reports a real outcome`); + assert.strictEqual( + (result.message ?? '').includes('No result'), + false, + `${fqn} matched in the session that has it, so nothing is missing`, + ); + assert.ok(Number(result.duration) >= 0, `${fqn} carries a measured duration`); + } + + // Interaction 3 — a clause matching NOTHING in one session is VSTest's + // `outcome="Warning"` case, which [TEST-FILTER-ESCAPE] distinguishes from an + // adapter REFUSING the filter. Neither test may be reported as an error. + for (const fqn of conditional) { + const result = cachedFor(api, fqn); + assert.strictEqual(result.outcome === 'failed', false, `${fqn} did not fail`); + assert.strictEqual( + (result.message ?? '').includes('Unexpected Word'), + false, + 'no adapter refused this filter — a space-free C# name is always parseable', + ); + } + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(expected), + 'and the tree stands', + ); + // Interaction 4 - the expression a cross-framework multi-select produces. + // [TEST-FILTER-ESCAPE]: escaped clauses, OR-ed with an UNESCAPED pipe, in + // ONE --filter argument for the whole selection. + const both = conditional.map((id) => ({ id })); + const orArgs = buildFilterArgs(both); + assert.strictEqual(orArgs.length, 2, 'one --filter flag and one expression'); + assert.strictEqual(orArgs[0], '--filter', 'the flag comes first'); + assert.strictEqual( + orArgs[1], + conditional.map((id) => `FullyQualifiedName=${id}`).join('|'), + 'two selected tests are OR-ed with an unescaped pipe, in selection order', + ); + assert.strictEqual( + (orArgs[1] ?? '').split('FullyQualifiedName=').length - 1, + conditional.length, + 'one clause per selected test', + ); + for (const fqn of conditional) { + assert.notStrictEqual( + cachedFor(api, fqn).outcome, + 'notRun', + `${fqn} was selected, so it must report a result`, + ); + } + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'a cross-framework selection leaves ONE root', + ); + assert.strictEqual( + collectLeafIds(api.testController.items).length, + expected.length, + 'holding the whole union', + ); + }); + + test('the NAMESPACE row runs every class under it, across both frameworks', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — the fixture declares ONE namespace holding TWO classes, + // so the namespace row is a real superset of either class row. + const roots = rootsOf(api.testController.items); + const assemblyRoot = roots[0]; + assert.ok(assemblyRoot, 'the merged assembly root exists'); + const namespaces = rootsOf(assemblyRoot.children); + assert.strictEqual(namespaces.length, 1, 'one namespace under the merged root'); + const namespaceNode = namespaces[0]; + assert.ok(namespaceNode, 'the namespace node is readable'); + assert.strictEqual(namespaceNode.label, NAMESPACE, 'labelled by the namespace'); + assert.strictEqual( + rootsOf(namespaceNode.children).length, + 2, + 'holding both the shared class and the conditional one', + ); + + // Interaction 2 — running it reports every test in the merged group, shared + // and exclusive alike. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [namespaceNode]); + for (const fqn of expected) { + const result = api.testController.getResult(fqn); + assert.ok(result, `the namespace run must report ${fqn}`); + assert.strictEqual( + (result.message ?? '').includes('No result'), + false, + `${fqn} ran, so it must not report a missing result`, + ); + assert.strictEqual( + ['passed', 'failed', 'skipped'].includes(result.outcome), + true, + `${fqn} lands in one of the three Testing-API states; got ${result.outcome}`, + ); + } + + // Interaction 3 — the three kinds are still told apart, and a skip is never + // a failure ([TEST-RUN-TRX]). + assert.strictEqual(cachedFor(api, CS.passing).outcome, 'passed', 'the green test is green'); + assert.strictEqual(cachedFor(api, CS.failing).outcome, 'failed', 'the red one is red'); + assert.strictEqual(cachedFor(api, CS.skipped).outcome, 'skipped', 'and the skip is a skip'); + assert.strictEqual( + cachedFor(api, CS.skipped).passed, + false, + 'a skipped test is certainly not a pass', + ); + assert.strictEqual( + (cachedFor(api, CS.skipped).message ?? '').includes('Assert'), + false, + 'and carries no assertion text, because nothing was asserted', + ); + // Interaction 4 - the namespace row spans BOTH classes and BOTH frameworks, + // and is still ONE invocation ([TEST-RUN-TRX]). + const namespaceLeaves = expected.filter((id) => id.startsWith(`${NAMESPACE}.`)); + assert.strictEqual( + namespaceLeaves.length, + expected.length, + 'every test the fixture declares lives under the one namespace', + ); + for (const id of namespaceLeaves) { + assert.notStrictEqual( + cachedFor(api, id).outcome, + 'notRun', + `${id} is under the namespace that was run and must report a result`, + ); + } + assert.strictEqual( + new Set(namespaceLeaves.map((id) => id.slice(0, id.lastIndexOf('.')))).size >= 2, + true, + 'and the namespace really holds more than one class, or the row proves nothing', + ); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'still ONE assembly root'); + assert.strictEqual( + itemsFor(api, expected).length, + expected.length, + 'every test is still a row after the namespace run', + ); + assert.deepStrictEqual( + duplicatesIn(collectLeafIds(api.testController.items)), + [], + 'with nothing duplicated', + ); + }); + + test('the [Theory] merges its rows across BOTH frameworks, and the skip stays a skip', async function () { + this.timeout(DOTNET_CLI_MS); + + // The worst case [TEST-RUN-TRX] describes, doubled: a two-row theory + // compiled for two frameworks writes FOUR TRX entries under one name. The + // merged outcome is the worst of the four and the durations sum; keeping the + // last entry seen reports a green tree for a theory whose second row failed. + // + // Interaction 1 — select both theories and the skipped test together. + const mixed = CS.mixedParameterized ?? ''; + assert.notStrictEqual(mixed, '', 'the xUnit fixture declares a mixed-row theory'); + const selection = [CS.parameterized, mixed, CS.skipped]; + const items = itemsFor(api, selection); + assert.strictEqual(items.length, selection.length, 'three rows selected'); + assert.deepStrictEqual( + items.map((item) => item.id), + selection, + 'each under the one name its rows share', + ); + + // Interaction 2 — the all-passing theory merges to a pass, once. + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, items); + const allGreen = cachedFor(api, CS.parameterized); + assert.strictEqual(allGreen.outcome, 'passed', 'every row passed, so the theory passed'); + assert.strictEqual(allGreen.passed, true, 'with the pass flag'); + assert.strictEqual(allGreen.message, undefined, 'and no failure text'); + assert.ok(Number(allGreen.duration) >= 0, 'carrying the summed duration of four entries'); + + // Interaction 3 — the disagreeing theory merges to the WORST row. + const worst = cachedFor(api, mixed); + assert.strictEqual(worst.outcome, 'failed', 'one failing row makes the whole theory fail'); + assert.strictEqual(worst.passed, false, 'and the flag agrees'); + assert.strictEqual( + (worst.message ?? '').includes('Assert.Equal'), + true, + "carrying the failing row's own assertion text", + ); + assert.strictEqual( + (worst.message ?? '').includes('No result'), + false, + 'every session reported it, so nothing is missing', + ); + + // Interaction 4 — and the skip is still a skip, in both frameworks. + const skipped = cachedFor(api, CS.skipped); + assert.strictEqual(skipped.outcome, 'skipped', 'NotExecuted maps to skipped, not to failed'); + assert.strictEqual(skipped.passed, false, 'a skip is not a pass'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(expected), + 'and the merged tree still holds one leaf per test', + ); + // Interaction 4 - a data-driven test writes one TRX entry PER ROW under the + // SAME name, in EVERY framework's session. [TEST-RUN-TRX] merges them all + // to the worst row with the durations summed. + const theory = CS.parameterized; + assert.strictEqual( + collectLeafIds(api.testController.items).filter((id) => id === theory).length, + 1, + 'the theory is ONE leaf, however many rows and frameworks ran it', + ); + const theoryResult = cachedFor(api, theory); + assert.notStrictEqual(theoryResult.outcome, 'notRun', 'and it reports a merged result'); + assert.strictEqual( + theoryResult.outcome, + 'passed', + 'every row of this theory passes, so the merged outcome is a pass', + ); + assert.strictEqual(theoryResult.passed, true, 'and the passed flag agrees'); + const mergedSkip = cachedFor(api, CS.skipped); + assert.strictEqual( + mergedSkip.outcome, + 'skipped', + 'a skipped test stays SKIPPED across the merge - [TEST-RUN-TRX] is explicit that it ' + + 'must not be reported as a failure', + ); + assert.strictEqual(mergedSkip.passed, false, 'and a skip is not a pass either'); + assert.strictEqual( + rootsOf(api.testController.items).length, + 1, + 'the theory run leaves ONE root', + ); + assert.strictEqual( + itemsFor(api, [CS.parameterized]).length, + 1, + 'and the theory is exactly one row', + ); + }); + + test('a REFRESH re-discovers ONE merged root, never a second copy of the project', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-REACTIVITY]: refresh re-runs the whole two-pass discovery, so both + // frameworks announce their assembly again. A merge applied only on the + // first sweep leaves the tree correct until the user presses refresh — and + // duplicated from then on, which is the shape the original defect took. + // + // Interaction 1 — the merged tree as it stands. + const before = sorted(collectLeafIds(api.testController.items)); + const nodesBefore = sorted(collectItemIds(api.testController.items)); + assert.deepStrictEqual(before, sorted(expected), 'the settled tree is the union'); + assert.strictEqual(rootsOf(api.testController.items).length, 1, 'under one root'); + + // Interaction 2 — press refresh and let the sweep land. + await drainDiscovery(() => { + void api.testController.activateAndDiscover(); + }, api.testController); + + // Interaction 3 — one root, one namespace, two classes, one leaf per test. + const roots = rootsOf(api.testController.items); + assert.strictEqual( + roots.length, + 1, + `refresh must not add a second assembly root; saw ${roots.map((item) => item.label).join(' | ')}`, + ); + assert.strictEqual(roots[0]?.label, CS.projectName, 'still labelled for the project'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + before, + 'refresh re-discovers exactly the same tests', + ); + assert.deepStrictEqual( + sorted(collectItemIds(api.testController.items)), + nodesBefore, + 'and exactly the same nodes — no second subtree under a second label', + ); + assert.deepStrictEqual( + duplicatesIn(collectItemIds(api.testController.items)), + [], + 'with no id shadowing another', + ); + assert.strictEqual( + collectItemIds(api.testController.items).filter((id) => id.startsWith('assembly:')).length, + 1, + 'ONE assembly group survives a re-discovery', + ); + for (const framework of frameworks) { + assert.strictEqual( + collectLeafIds(api.testController.items).includes(conditionalFqn(framework)), + true, + `${conditionalMethod(framework)} must survive the refresh — a second sweep that took ` + + "the first framework's listing alone would drop it", + ); + } + // Interaction 4 - and a refresh leaves the ids themselves untouched. A + // second sweep that re-derived the group key would produce a second root + // the first sweep never showed. + const afterRefresh = rootsOf(api.testController.items); + assert.strictEqual(afterRefresh.length, 1, 'still exactly ONE root after a refresh'); + assert.deepStrictEqual( + sorted(collectLeafIds(api.testController.items)), + sorted(expected), + 'carrying exactly the union it carried before', + ); + assert.deepStrictEqual( + duplicatesIn(collectLeafIds(api.testController.items)), + [], + 'with nothing listed twice', + ); + const refreshed = afterRefresh[0]; + assert.ok(refreshed, 'the root survived the refresh'); + assert.strictEqual(refreshed.canResolveChildren, true, 'and still declares its children'); + assert.strictEqual( + collectLeafIds(refreshed.children).length, + expected.length, + 'all of which are still beneath it', + ); + assert.strictEqual( + itemsFor(api, expected).length, + expected.length, + 'every test resolves after the refresh', + ); + assert.strictEqual( + collectItemIds(api.testController.items).length > expected.length, + true, + 'and the group rows above them survived too', + ); }); }); diff --git a/src/editors/vscode/src/test/suite/test-explorer-names.test.ts b/src/editors/vscode/src/test/suite/test-explorer-names.test.ts new file mode 100644 index 00000000..28aeb68c --- /dev/null +++ b/src/editors/vscode/src/test/suite/test-explorer-names.test.ts @@ -0,0 +1,624 @@ +// The fully-qualified NAME reader: what an adapter decorated, and what it did +// not. +// +// [TEST-DISCOVERY-FQN] states the rule exactly. `xunit.runner.visualstudio` +// 2.2.0 appends the test case's `UniqueID` — a SHA-1, 40 hex digits — after a +// SPACE and inside PARENTHESES, and that decoration MUST come off before the +// name becomes an id: kept, it labels the test with a hex blob, escapes to a +// `--filter` that matches nothing, and cannot be reconciled with the TRX report, +// which keys on the bare `className.name`. +// +// The same spec sentence makes the OPPOSITE guarantee, and it is the harder +// half: stripping "MUST NOT touch a name that legitimately ends in parentheses", +// because the NUnit `[TestCase]` shape `Ns.Class.Adds_Case(2,2,4)` is a real +// fully-qualified name. The two are told apart by exactly two conditions — a +// space before the bracket, and 40 hex digits inside it — so a stripper that +// gets either one wrong corrupts a real test name into one that can never be +// run. `test-explorer-adapter-ids.test.ts` drives the decorating adapter +// end-to-end; this suite pins the boundary itself, permutation by permutation, +// including every near miss that differs from the decoration in ONE respect. +// +// It also covers the listing FILE those names arrive in: `dotnet vstest +// --ListFullyQualifiedTests` writes one name per line, with a UTF-8 BOM and CRLF +// endings on Windows, and one line PER THEORY ROW when the adapter decorates — +// which is why stripping is also what collapses a theory's rows onto the single +// name they share. +// +// Split out of `test-explorer-parsers.test.ts` so each file stays under the +// project's 500-line ceiling; that suite keeps the TRX, console and MSBuild +// readers. +// +// Covers [TEST-DISCOVERY-FQN]. +import * as assert from 'node:assert/strict'; +import { parseFullyQualifiedTestList, withoutAdapterUniqueId } from '../../test-discovery.js'; +import { HEX_DIGITS, dedupeLines } from '../../test-names.js'; +import { fixtureFor } from './test-explorer-fixtures'; +import { eq, deepEq } from './test-helpers'; +import { FAST_MS } from './test-timeouts'; + +const CS = fixtureFor('xunit-csharp'); + +/** + * Every framework fixture in the suite, so the name reader is driven against + * the WHOLE of [TEST-DISCOVERY-FQN]'s "name shapes that MUST round-trip + * unchanged" table rather than against one row of it. + */ +const FRAMEWORK_KEYS: readonly string[] = [ + 'xunit-csharp', + 'nunit-csharp', + 'mstest-csharp', + 'xunit-fsharp', + 'nunit-fsharp', + 'mstest-fsharp', +]; + +/** Every fully-qualified name those fixtures declare, de-duplicated. */ +function everyFixtureName(): string[] { + const names: string[] = []; + for (const key of FRAMEWORK_KEYS) { + const fixture = fixtureFor(key); + names.push(fixture.passing, fixture.failing, fixture.skipped, fixture.parameterized); + if (fixture.mixedParameterized !== undefined) names.push(fixture.mixedParameterized); + } + return [...new Set(names)]; +} + +/** The idiomatic F# backtick binding whose xUnit FQN literally contains spaces. */ +const FS_FACT_SPACED = 'Fs.Xunit.Fixtures.adds two numbers with spaces'; + +/** The real NUnit `[TestCase]` FQN — parentheses and commas, verbatim. */ +const NUNIT_CASE = 'Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(2,2,4)'; + +/** The F# MSTest FQN, carrying the CLR nested-type `+` separator. */ +const FS_MSTEST_NESTED = 'Fs.Mstest.Fixtures+CalculatorTests.AddsTwoNumbers'; + +/** + * A real `xunit.runner.visualstudio` 2.2.0 unique ID: a SHA-1, 40 hex digits. + * + * [TEST-DISCOVERY-FQN] pins the shape exactly — appended after a SPACE, wrapped + * in parentheses — because the two conditions that make it a decoration are the + * same two that keep an NUnit `[TestCase]` name safe. + */ +const UNIQUE_ID = 'd87517d9ff18440615ea8de9ec508cb292e09385'; + +suite('Test Explorer — adapter decoration comes off, real names stay on', () => { + test('an adapter unique ID is stripped, and every name that only LOOKS like one is not', function () { + this.timeout(FAST_MS); + + // [TEST-DISCOVERY-FQN]: the decoration is a SPACE, then 40 hex digits in + // PARENTHESES. Both conditions are load-bearing — they are exactly what + // separates the decoration from the NUnit `[TestCase]` shape the same spec + // requires to round-trip untouched. + // + // Interaction 1 — the real decoration comes off, whatever it is attached to, + // leaving the bare name the filter and the TRX report key on. + const decorated: readonly (readonly [string, string])[] = [ + [`${CS.passing} (${UNIQUE_ID})`, CS.passing], + [`${CS.parameterized} (${UNIQUE_ID})`, CS.parameterized], + [`${FS_FACT_SPACED} (${UNIQUE_ID})`, FS_FACT_SPACED], + [`${FS_MSTEST_NESTED} (${UNIQUE_ID})`, FS_MSTEST_NESTED], + [`${NUNIT_CASE} (${UNIQUE_ID})`, NUNIT_CASE], + [`${CS.passing} (${UNIQUE_ID.toUpperCase()})`, CS.passing], + ]; + for (const [raw, bare] of decorated) { + assert.strictEqual( + withoutAdapterUniqueId(raw), + bare, + `${raw} must reduce to the bare name — kept, it labels the test with a hex blob, ` + + 'escapes to a filter that matches nothing, and cannot be reconciled with the TRX report', + ); + } + assert.strictEqual( + withoutAdapterUniqueId(`${FS_FACT_SPACED} (${UNIQUE_ID})`).includes(' '), + true, + 'stripping an F# name removes the ID and NOT the spaces the binding legitimately carries', + ); + assert.strictEqual( + withoutAdapterUniqueId(`${NUNIT_CASE} (${UNIQUE_ID})`), + NUNIT_CASE, + 'a decorated NUnit case keeps its OWN parentheses and loses only the appended ID', + ); + + // Interaction 2 — every near miss is left alone. Each of these differs from + // the decoration in exactly one respect, so a stripper that got any single + // condition wrong corrupts a real test name into one that matches nothing. + const untouched: readonly string[] = [ + NUNIT_CASE, + 'Cs.Nunit.Fixtures.CalculatorTests.Adds_Case (2,2,4)', + `Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(${UNIQUE_ID})`, + `${CS.passing} (${UNIQUE_ID.slice(0, 39)})`, + `${CS.passing} (${UNIQUE_ID}0)`, + `${CS.passing} (${UNIQUE_ID.slice(0, 39)}z)`, + `${CS.passing} ()`, + `${CS.passing} (${UNIQUE_ID}) trailing`, + `${CS.passing}(${UNIQUE_ID})`, + FS_FACT_SPACED, + FS_MSTEST_NESTED, + CS.passing, + '', + ]; + for (const name of untouched) { + assert.strictEqual( + withoutAdapterUniqueId(name), + name, + `'${name}' is not an adapter decoration and MUST survive verbatim — a name the ` + + 'stripper edits can never be run, because the filter no longer matches it', + ); + } + + // Interaction 3 — the two rules stated as rules, not as a table: no space + // before the bracket, and no hex inside it. Both are what + // [TEST-DISCOVERY-FQN] names as the distinguishing conditions. + assert.strictEqual( + withoutAdapterUniqueId(`Ns.C.M(${UNIQUE_ID})`), + `Ns.C.M(${UNIQUE_ID})`, + 'no SPACE before the bracket means it is part of the name, however hex-like it looks', + ); + assert.strictEqual( + withoutAdapterUniqueId('Ns.C.M (2,2,4)'), + 'Ns.C.M (2,2,4)', + 'a space before a NON-hex bracket is a name that happens to contain a space', + ); + assert.strictEqual( + withoutAdapterUniqueId(withoutAdapterUniqueId(`${CS.passing} (${UNIQUE_ID})`)), + CS.passing, + 'stripping is idempotent — an already-bare id survives a second pass unchanged', + ); + // Interaction 4 - stripping is a TOTAL function: it answers for every + // string, and answering twice never changes the answer. A stripper that is + // not idempotent corrupts a name the second time discovery sweeps. + for (const name of untouched) { + assert.strictEqual( + withoutAdapterUniqueId(withoutAdapterUniqueId(name)), + name, + `'${name}' must survive a second pass unchanged`, + ); + } + assert.strictEqual(withoutAdapterUniqueId(' '), ' ', 'a lone space is not a decoration'); + assert.strictEqual(withoutAdapterUniqueId('()'), '()', 'nor a bare pair of brackets'); + assert.strictEqual(withoutAdapterUniqueId(' ()'), ' ()', 'nor a space and a bare pair'); + assert.strictEqual( + withoutAdapterUniqueId(`(${UNIQUE_ID})`), + `(${UNIQUE_ID})`, + 'a decoration with NO name in front of it is not a decorated name', + ); + }); + + test('the REAL listing file collapses theory rows onto one id, whatever decorated it', function () { + this.timeout(FAST_MS); + + // `dotnet vstest --ListFullyQualifiedTests` writes the file this reads: one + // name per line, a BOM on Windows, CRLF endings, and — on the decorating + // adapter — one line PER THEORY ROW, each carrying its own unique ID + // ([TEST-DISCOVERY-FQN]). + // + // Interaction 1 — a listing in exactly that shape reduces to one id per + // test, in the order VSTest wrote them. + const rows = [ + `${CS.passing} (${UNIQUE_ID})`, + `${CS.parameterized} (${UNIQUE_ID})`, + `${CS.parameterized} (${UNIQUE_ID.slice(0, 39)}a)`, + `${FS_FACT_SPACED} (${UNIQUE_ID})`, + NUNIT_CASE, + ]; + const listing = `\uFEFF${rows.join('\r\n')}\r\n`; + assert.deepStrictEqual( + parseFullyQualifiedTestList(listing), + [CS.passing, CS.parameterized, FS_FACT_SPACED, NUNIT_CASE], + 'each theory row carries its own unique ID, so stripping must collapse them onto the ' + + 'one name they share — two leaves for one [Theory] is what a kept ID produces', + ); + + // Interaction 2 — the Windows envelope is removed, not carried into an id. A + // BOM left on the first name makes that one test unrunnable and nothing + // else, which is why it hid for so long. + for (const id of parseFullyQualifiedTestList(listing)) { + assert.strictEqual(id.includes('\uFEFF'), false, `${id} must carry no byte-order mark`); + assert.strictEqual(id.includes('\r'), false, `${id} must carry no carriage return`); + assert.strictEqual(id.trim(), id, `${id} must carry no padding`); + assert.strictEqual(id.length > 0, true, 'and no blank line becomes an id'); + } + + // Interaction 3 — blank lines, padding and repeats are all absorbed, and the + // NUnit shape still round-trips through the whole reader. + const noisy = ['', ` ${CS.passing} `, '', CS.passing, NUNIT_CASE, ' '].join('\n'); + assert.deepStrictEqual( + parseFullyQualifiedTestList(noisy), + [CS.passing, NUNIT_CASE], + 'blank and padded lines are dropped, and a name listed twice is one test', + ); + assert.deepStrictEqual( + parseFullyQualifiedTestList(''), + [], + 'an empty listing yields no tests rather than one empty id', + ); + assert.deepStrictEqual( + parseFullyQualifiedTestList('\uFEFF\r\n'), + [], + 'and a file holding nothing but a BOM yields none either', + ); + // Interaction 4 - the reader must survive the shapes a truncated or + // half-written listing file takes. Discovery MUST NOT throw + // ([TEST-DISCOVERY-FQN]), and a listing it cannot read is an empty listing, + // never an exception out of the sweep. + assert.doesNotThrow( + () => parseFullyQualifiedTestList('\uFEFF'), + 'a file holding nothing but a byte-order mark must not throw', + ); + assert.doesNotThrow( + () => parseFullyQualifiedTestList('\r\n\r\n'), + 'nor one holding nothing but line endings', + ); + assert.deepStrictEqual( + parseFullyQualifiedTestList('\r\n\r\n'), + [], + 'and both reduce to no tests at all', + ); + assert.deepStrictEqual( + parseFullyQualifiedTestList(CS.passing), + [CS.passing], + 'a file with no trailing newline still yields its one test', + ); + }); + + // Implements the whole of [TEST-DISCOVERY-FQN]'s "Name shapes that MUST + // round-trip unchanged" table, across all six framework fixtures at once. + // One row of that table proves nothing about the others: the F# backtick + // shape is the only one carrying SPACES, the NUnit shape the only one + // carrying PARENTHESES, and the F# MSTest shape the only one carrying a CLR + // nested-type `+`. + test('every name shape the spec table names survives the reader, decorated or not', function () { + this.timeout(FAST_MS); + + // Interaction 1 — every fixture name, undecorated, must come back verbatim. + // A reader that trims, escapes or normalises ANY of them produces an id no + // `--filter` will match and no TRX report can be reconciled with. + const names = everyFixtureName(); + eq(names.length >= 24, true, 'all six framework fixtures contribute their four names'); + for (const name of names) { + eq(withoutAdapterUniqueId(name), name, name + ' must survive the stripper verbatim'); + deepEq(parseFullyQualifiedTestList(name), [name], name + ' must survive the listing reader'); + eq(name.trim(), name, name + ' carries no padding to begin with'); + eq(name.includes('.'), true, name + ' is a dotted fully-qualified name'); + } + eq( + names.filter((name) => name.includes(' ')).length >= 1, + true, + 'at least one shape carries SPACES - the idiomatic F# backtick binding', + ); + eq( + names.filter((name) => name.includes('(')).length >= 1, + true, + 'at least one carries PARENTHESES - the NUnit [TestCase] row data', + ); + eq( + names.filter((name) => name.includes('+')).length >= 1, + true, + 'and at least one a CLR nested-type + - the F# MSTest shape', + ); + + // Interaction 2 — every one of them, DECORATED, must reduce to itself. The + // decoration is applied to whatever the adapter reports, so it lands on the + // spaced, the parenthesised and the nested shapes alike. + for (const name of names) { + const decorated = name + ' (' + UNIQUE_ID + ')'; + eq( + withoutAdapterUniqueId(decorated), + name, + decorated + ' must reduce to the bare name the filter and the TRX report key on', + ); + deepEq( + parseFullyQualifiedTestList(decorated), + [name], + name + ' must reduce through the LISTING reader as well as the stripper', + ); + eq( + withoutAdapterUniqueId(name + ' (' + UNIQUE_ID.toUpperCase() + ')'), + name, + name + ': a SHA-1 in upper case is still 40 hex digits', + ); + } + + // Interaction 3 — a whole listing of decorated names, in the Windows + // envelope VSTest really writes, must reduce to exactly the bare set, once + // each, in listing order. + const listing = '' + names.map((name) => name + ' (' + UNIQUE_ID + ')').join('\r\n') + '\r\n'; + const parsed = parseFullyQualifiedTestList(listing); + deepEq(parsed, names, 'the whole decorated listing reduces to the bare names, in order'); + eq(parsed.length, new Set(parsed).size, 'with no id appearing twice'); + for (const id of parsed) { + eq(id.includes(''), false, id + ' must carry no byte-order mark'); + eq(id.includes('\r'), false, id + ' must carry no carriage return'); + eq(id.includes(UNIQUE_ID), false, id + ' must carry no adapter unique ID'); + } + // Interaction 4 - and the reader is stable under REPETITION. VSTest writes + // one line per theory row, so the same bare name arrives many times over, + // and the tree must hold one leaf for it however many times it was listed. + const repeated = everyFixtureName().flatMap((name) => [name, name, name]); + assert.deepStrictEqual( + parseFullyQualifiedTestList(repeated.join('\n')), + everyFixtureName(), + 'a name listed three times is one test, in first-seen order', + ); + assert.strictEqual( + parseFullyQualifiedTestList(repeated.join('\n')).length, + everyFixtureName().length, + 'and the count is the count of distinct names', + ); + assert.strictEqual( + repeated.length, + everyFixtureName().length * 3, + 'the input really did repeat every one of them', + ); + }); + + // [TEST-DISCOVERY-FQN] names exactly two conditions that make a trailing + // bracket a decoration: a SPACE before it, and 40 HEX digits inside it. This + // test walks both conditions across their whole boundary, one character at a + // time, because "a stripper that gets either one wrong corrupts a real test + // name into one that can never be run". + test('the decoration boundary holds at every hex length, case and character', function () { + this.timeout(FAST_MS); + const base = CS.passing; + + // Interaction 1 — LENGTH. Forty digits is the decoration; every other + // length is a name that merely resembles one. + for (const length of [0, 1, 8, 20, 32, 38, 39, 41, 44, 64]) { + const payload = 'a'.repeat(length); + const candidate = base + ' (' + payload + ')'; + eq( + withoutAdapterUniqueId(candidate), + candidate, + String(length) + ' hex digits is not a SHA-1, so the name must survive verbatim', + ); + } + eq( + withoutAdapterUniqueId(base + ' (' + 'a'.repeat(40) + ')'), + base, + 'and exactly forty is the decoration the adapter appends', + ); + eq( + withoutAdapterUniqueId(base + ' (' + 'f'.repeat(40) + ')'), + base, + 'whatever hex digits it happens to be made of', + ); + + // Interaction 2 — CASE and ALPHABET. Every hex digit is admissible in + // either case; nothing else is, wherever it sits in the forty. + for (const digit of '0123456789abcdefABCDEF'.split('')) { + eq(HEX_DIGITS.has(digit), true, digit + ' is a hex digit'); + const payload = digit.repeat(40); + eq( + withoutAdapterUniqueId(base + ' (' + payload + ')'), + base, + 'a SHA-1 made entirely of ' + digit + ' is still forty hex digits', + ); + } + for (const digit of 'gzGZ -_.+*'.split('')) { + eq(HEX_DIGITS.has(digit), false, JSON.stringify(digit) + ' is not a hex digit'); + } + for (const position of [0, 1, 20, 38, 39]) { + const payload = UNIQUE_ID.slice(0, position) + 'z' + UNIQUE_ID.slice(position + 1); + eq(payload.length, 40, 'the spoiled payload is still forty characters long'); + const candidate = base + ' (' + payload + ')'; + eq( + withoutAdapterUniqueId(candidate), + candidate, + 'one non-hex character at position ' + + String(position) + + ' makes it a NAME, not a ' + + 'decoration - and a stripper that ignores it corrupts a real test name', + ); + } + + // Interaction 3 — the SPACE condition, and what surrounds the bracket. No + // space, a tab, two spaces, trailing text, a second decoration: each + // differs from the real shape in exactly one respect. + const nearMisses: readonly string[] = [ + base + '(' + UNIQUE_ID + ')', + base + '\t(' + UNIQUE_ID + ')', + base + ' [' + UNIQUE_ID + ']', + base + ' (' + UNIQUE_ID + ') (' + UNIQUE_ID + ') tail', + base + ' (' + UNIQUE_ID, + base + ' ' + UNIQUE_ID + ')', + base + ' ((' + UNIQUE_ID + '))', + ]; + for (const candidate of nearMisses) { + eq( + withoutAdapterUniqueId(candidate), + candidate, + JSON.stringify(candidate) + + ' is not the decoration the spec describes and must ' + + 'survive verbatim', + ); + } + eq( + withoutAdapterUniqueId(base + ' (' + UNIQUE_ID + ')'), + base + ' ', + 'the decoration is the trailing " (<sha1>)"; padding BEFORE it belongs to the name the ' + + 'adapter reported, and the stripper removes exactly what it recognises', + ); + deepEq( + dedupeLines( + ['', ' ' + base + ' ', base, NUNIT_CASE, ' ', NUNIT_CASE].join('\n'), + (line) => line.length > 0, + ), + [base, NUNIT_CASE], + 'the shared line reader drops blanks and padding and keeps each name once', + ); + deepEq( + dedupeLines('', () => true), + [], + 'and an empty listing yields no lines at all', + ); + // Interaction 4 - the alphabet itself. `HEX_DIGITS` is the set both halves + // of the rule are decided by, so its membership is the rule. + assert.strictEqual(HEX_DIGITS.size, 22, 'ten digits plus a-f in both cases'); + assert.strictEqual(HEX_DIGITS.has('0'), true, 'zero is hex'); + assert.strictEqual(HEX_DIGITS.has('9'), true, 'and nine'); + assert.strictEqual(HEX_DIGITS.has('a'), true, 'and lower-case a'); + assert.strictEqual(HEX_DIGITS.has('F'), true, 'and upper-case F'); + assert.strictEqual(HEX_DIGITS.has(' '), false, 'a space is not'); + assert.strictEqual(HEX_DIGITS.has(''), false, 'nor the empty string'); + }); + + // [TEST-DISCOVERY-FQN]'s table, row by row, spelled out. Every one of the six + // framework fixtures contributes four fully-qualified names, and each is + // asserted on its OWN line rather than through a loop: when the reader breaks + // it breaks for ONE shape, and the failure has to name which. + test('each framework name is read back exactly, one row of the table at a time', function () { + this.timeout(FAST_MS); + + // Interaction 1 — the C# shapes. xUnit's DisplayName happens to equal + // `Namespace.Class.Method`, which is why scraping the listing worked for + // xUnit by accident and dropped every NUnit and MSTest test (issue #180). + const csXunit = fixtureFor('xunit-csharp'); + const csNunit = fixtureFor('nunit-csharp'); + const csMstest = fixtureFor('mstest-csharp'); + eq(withoutAdapterUniqueId(csXunit.passing), csXunit.passing, 'xUnit C#, passing'); + eq(withoutAdapterUniqueId(csXunit.failing), csXunit.failing, 'xUnit C#, failing'); + eq(withoutAdapterUniqueId(csXunit.skipped), csXunit.skipped, 'xUnit C#, skipped'); + eq(withoutAdapterUniqueId(csXunit.parameterized), csXunit.parameterized, 'xUnit C#, [Theory]'); + eq(withoutAdapterUniqueId(csNunit.passing), csNunit.passing, 'NUnit C#, passing'); + eq(withoutAdapterUniqueId(csNunit.failing), csNunit.failing, 'NUnit C#, failing'); + eq(withoutAdapterUniqueId(csNunit.skipped), csNunit.skipped, 'NUnit C#, ignored'); + eq( + withoutAdapterUniqueId(csNunit.parameterized), + csNunit.parameterized, + 'NUnit C#, [TestCase] - the shape carrying PARENTHESES the stripper must not touch', + ); + eq(withoutAdapterUniqueId(csMstest.passing), csMstest.passing, 'MSTest C#, passing'); + eq(withoutAdapterUniqueId(csMstest.failing), csMstest.failing, 'MSTest C#, failing'); + eq(withoutAdapterUniqueId(csMstest.skipped), csMstest.skipped, 'MSTest C#, ignored'); + eq( + withoutAdapterUniqueId(csMstest.parameterized), + csMstest.parameterized, + 'MSTest C#, [DataRow] - reported without row data, so one name for every row', + ); + + // Interaction 2 — the F# shapes, which are the ones a C#-shaped reader + // loses. F# is not a second-class case here ([TEST-OVERVIEW]). + const fsXunit = fixtureFor('xunit-fsharp'); + const fsNunit = fixtureFor('nunit-fsharp'); + const fsMstest = fixtureFor('mstest-fsharp'); + eq(withoutAdapterUniqueId(fsXunit.passing), fsXunit.passing, 'xUnit F#, passing'); + eq( + withoutAdapterUniqueId(fsXunit.failing), + fsXunit.failing, + 'xUnit F#, failing - a backtick binding whose name carries SPACES', + ); + eq(withoutAdapterUniqueId(fsXunit.skipped), fsXunit.skipped, 'xUnit F#, skipped'); + eq( + withoutAdapterUniqueId(fsXunit.parameterized), + fsXunit.parameterized, + 'xUnit F#, [<Theory>]', + ); + eq(withoutAdapterUniqueId(fsNunit.passing), fsNunit.passing, 'NUnit F#, passing'); + eq(withoutAdapterUniqueId(fsNunit.failing), fsNunit.failing, 'NUnit F#, failing'); + eq(withoutAdapterUniqueId(fsNunit.skipped), fsNunit.skipped, 'NUnit F#, ignored'); + eq( + withoutAdapterUniqueId(fsNunit.parameterized), + fsNunit.parameterized, + 'NUnit F#, [<TestCase>] - SPACES and PARENTHESES in one name, which is the shape ' + + 'NUnit’s own filter parser then refuses ([TEST-FILTER-ESCAPE])', + ); + eq( + withoutAdapterUniqueId(fsMstest.passing), + fsMstest.passing, + 'MSTest F#, passing - the CLR nested-type + separator', + ); + eq(withoutAdapterUniqueId(fsMstest.failing), fsMstest.failing, 'MSTest F#, failing'); + eq(withoutAdapterUniqueId(fsMstest.skipped), fsMstest.skipped, 'MSTest F#, ignored'); + eq(withoutAdapterUniqueId(fsMstest.parameterized), fsMstest.parameterized, 'MSTest F#, row'); + + // Interaction 3 — the same twenty-four names, DECORATED, must each reduce + // to themselves. The adapter decorates whatever it reports, so the + // decoration lands on the spaced, parenthesised and nested shapes alike. + const dress = (name: string): string => name + ' (' + UNIQUE_ID + ')'; + eq(withoutAdapterUniqueId(dress(csXunit.passing)), csXunit.passing, 'decorated xUnit C#'); + eq( + withoutAdapterUniqueId(dress(csXunit.parameterized)), + csXunit.parameterized, + 'decorated C# theory', + ); + eq( + withoutAdapterUniqueId(dress(csNunit.parameterized)), + csNunit.parameterized, + 'decorated NUnit case', + ); + eq( + withoutAdapterUniqueId(dress(csMstest.parameterized)), + csMstest.parameterized, + 'decorated MSTest row', + ); + eq(withoutAdapterUniqueId(dress(fsXunit.failing)), fsXunit.failing, 'decorated F# spaced name'); + eq( + withoutAdapterUniqueId(dress(fsNunit.parameterized)), + fsNunit.parameterized, + 'decorated F# NUnit case', + ); + eq( + withoutAdapterUniqueId(dress(fsMstest.passing)), + fsMstest.passing, + 'decorated F# nested type', + ); + eq( + withoutAdapterUniqueId(dress(fsXunit.failing)).includes(' '), + true, + 'and the F# spaces survive the stripping that removed the ID', + ); + eq( + withoutAdapterUniqueId(dress(csNunit.parameterized)).endsWith(')'), + true, + 'as do the NUnit parentheses', + ); + eq( + withoutAdapterUniqueId(dress(fsMstest.passing)).includes('+'), + true, + 'and the CLR nested-type separator', + ); + + // Interaction 4 — a listing holding all twenty-four decorated names reduces + // to exactly twenty-four bare ids, once each, in order. + const every = everyFixtureName(); + const parsed = parseFullyQualifiedTestList(every.map(dress).join('\r\n')); + eq(parsed.length, every.length, 'one id per listed name'); + deepEq(parsed, every, 'in listing order, bare'); + eq(new Set(parsed).size, parsed.length, 'with no duplicates'); + eq( + parsed.some((id) => id.includes(UNIQUE_ID)), + false, + 'and no unique ID anywhere', + ); + eq( + parsed.some((id) => id.includes(' ')), + true, + 'the spaced F# names are still spaced', + ); + eq( + parsed.some((id) => id.includes('(')), + true, + 'the NUnit cases still carry their rows', + ); + eq( + parsed.some((id) => id.includes('+')), + true, + 'and the nested-type names their separator', + ); + // Interaction 5 - and the whole table survives the LISTING reader as well + // as the stripper, undecorated. The two readers must agree: one is what + // discovery uses, the other is what the file it reads is made of. + for (const name of everyFixtureName()) { + assert.deepStrictEqual( + parseFullyQualifiedTestList(name), + [name], + `${name} must round-trip through the listing reader as itself`, + ); + } + assert.strictEqual( + everyFixtureName().length, + new Set(everyFixtureName()).size, + 'the fixtures declare distinct names, so none of these assertions is duplicated', + ); + }); +}); diff --git a/src/editors/vscode/src/test/suite/test-helpers.ts b/src/editors/vscode/src/test/suite/test-helpers.ts index 3b356930..cc8db65e 100644 --- a/src/editors/vscode/src/test/suite/test-helpers.ts +++ b/src/editors/vscode/src/test/suite/test-helpers.ts @@ -308,6 +308,22 @@ async function openFile( return { doc, uri }; } +/** + * Open a file that ALREADY exists on disk and show it. + * + * The committed fixture workspace is the input to every semantic suite; a + * helper that writes content first would overwrite the very fixture under test. + */ +export async function openExistingFile( + directory: string, + filename: string, +): Promise<{ doc: vscode.TextDocument; uri: vscode.Uri }> { + const uri = vscode.Uri.file(path.join(directory, filename)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + return { doc, uri }; +} + /** Replace the entire content of a document. */ export async function replaceDocumentContent( doc: vscode.TextDocument, @@ -410,6 +426,34 @@ export function teardownLspTestSuite(tmpDir: string): void { const SCREENSHOT_OUT_DIR = path.resolve(__dirname, '../../../../../website/src/assets/screenshots'); +/** + * Load the fixture solution into the Solution Explorer so a documentation + * screenshot has content. + * + * Screenshot-only plumbing: it resolves the explorer through the extension's + * published API and waits for the tree to populate. A no-op when the extension + * publishes no explorer. + */ +export async function loadFixtureSolution(workspaceRoot: string): Promise<void> { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + const api = extension?.exports as + | { + explorerProvider?: { + loadSolution(solutionPath: string): Promise<void>; + getChildren(element?: unknown): unknown[] | undefined; + }; + } + | undefined; + const provider = api?.explorerProvider; + if (!provider) return; + await provider.loadSolution(path.join(workspaceRoot, 'TestFixtures.sln')); + let waited = 0; + while ((provider.getChildren() ?? []).length === 0 && waited < 8000) { + await sleep(200); + waited += 200; + } +} + /** * Open the SharpLsp activity bar panel (shows Solution Explorer + Profiler). * Only does anything when SHARPLSP_SCREENSHOTS=1 is set. @@ -461,6 +505,26 @@ export async function takeScreenshot(filename: string): Promise<void> { // ── Utilities ──────────────────────────────────────────────────── +/** + * Pause for the workbench to RENDER, but only when a screenshot will actually + * be taken. + * + * {@link takeScreenshot} and {@link openSharpLspPanel} both return immediately + * unless `SHARPLSP_SCREENSHOTS` is set, so a bare `sleep` before one of them is + * time CI spends waiting for a picture it is never going to capture. Every such + * pause goes through here instead. + * + * This is deliberately a SLEEP and not a poll: what it waits for is the + * compositor painting a widget that is already open, which nothing in the + * extension API reports. That is also why it must never be load-bearing for an + * assertion - a test that needs a condition to hold polls for the condition + * with {@link pollUntilResult}, which fails loudly when it never does. + */ +export async function settleForScreenshot(ms: number): Promise<void> { + if (!process.env['SHARPLSP_SCREENSHOTS']) return; + await sleep(ms); +} + export function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/editors/vscode/src/test/suite/test-timeouts.ts b/src/editors/vscode/src/test/suite/test-timeouts.ts index ab815c57..4bb232a5 100644 --- a/src/editors/vscode/src/test/suite/test-timeouts.ts +++ b/src/editors/vscode/src/test/suite/test-timeouts.ts @@ -19,11 +19,29 @@ // Does it round-trip one editor command? -> COMMAND_MS // Is it pure in-process assertion? -> FAST_MS // +// ── ONE initialization per suite ───────────────────────────────── +// // The INITIALIZATION tiers at the bottom are for `suiteSetup`/`suiteTeardown` // ONLY. A test body must never claim one: paying restore + build inside a test // means the suite is initialising more than once, which is the thing the // per-suite setup exists to prevent. // +// A suite pays ONE initialization. Activating the extension, writing the +// fixture, restoring it and building it happen once in `suiteSetup`; every test +// after that reuses the same activated host, the same built assemblies and the +// same discovered tree. A suite that re-activates or re-builds per test is not +// a slow suite -- it is a suite whose second test can no longer prove anything +// about state the first one left behind, because there is none. +// +// The per-test ceilings below are ceilings on INCREMENTAL work against an +// already-warm host, never on the setup. A test that needs an initialization +// tier is either misplaced work or a suite missing a `suiteSetup`. +// +// Every value below is the tier table of [DIST-CI-VSIX-SHARDS-TIMEOUTS]. The +// spec owns the numbers because they are measured on the CI agents, not on a +// developer laptop; a ceiling that disagrees with that table is a bug in this +// file, and changing one means changing the table first. +// // Implements the timeout half of [DIST-CI-WIN-VSIX] and [DIST-CI-LAYOUT]. // ── Per-test ceilings ──────────────────────────────────────────── @@ -166,6 +184,20 @@ export const REAL_REPO_MS = 600_000; */ export const REAL_REPO_WARMUP_MS = 480_000; +/** + * How long to wait for something that must EVENTUALLY happen but is not a + * command round trip: a file-system watcher firing, a `SIGKILL`ed process + * disappearing from the process table, a spawned CLI printing `--version`, the + * workbench clearing its active debug session after a `terminated` event. + * + * `COMMAND_MS` covers ONE round trip the extension host itself answers. None of + * the above is one: they are owned by the OS or by a debounced watcher, they + * cost nothing when they are prompt, and a command-sized budget on them buys a + * flake rather than a faster suite. This is a POLL budget, so a healthy run + * never spends it. + */ +export const SETTLE_MS = 10_000; + // ── Runner-level ceilings ──────────────────────────────────────── /** @@ -183,7 +215,8 @@ export const DEFAULT_TEST_MS = LSP_RESPONSE_MS; * MUST stay below the CI job's `timeout-minutes` ([DIST-CI-VSIX-SHARDS]): when * the job is killed there is no mocha report at all, so a hang is diagnosed * from a truncated log. Reaching this means an entire chunk hung, not that a - * chunk legitimately grew — the largest tier above is four minutes. + * chunk legitimately grew — the largest tier above is four minutes, and every + * chunk pays it at most ONCE, in `suiteSetup`. */ export const WHOLE_RUN_MS = 20 * 60 * 1_000; diff --git a/src/editors/vscode/src/test/suite/testing-lens-e2e.test.ts b/src/editors/vscode/src/test/suite/testing-lens-e2e.test.ts index 13c30d66..d5e3bcff 100644 --- a/src/editors/vscode/src/test/suite/testing-lens-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/testing-lens-e2e.test.ts @@ -21,18 +21,38 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { buildFilterArgs, isExpectoTest, isFsCheckTest } from '../../testing.js'; -import { isDiscoveredTestLine } from '../../test-discovery.js'; -import { findCoberturaFile, parseCoberturaXml } from '../../test-coverage.js'; +import type { CachedTestResult } from '../../testing.js'; +import { + batchAssemblies, + isDiscoveredTestLine, + mergeMultiTargeted, + parseAnnouncedAssemblies, + parseTestList, + resolveAnnouncedAssembly, + unescapeMsBuildPath, +} from '../../test-discovery.js'; +import { + findCoberturaFile, + findCoberturaFiles, + mergeCoberturaReports, + parseCoberturaXml, +} from '../../test-coverage.js'; import { extractCSharpMethodName, extractFSharpFunctionName, formatDuration, + statusLensTitle, } from '../../test-lens.js'; import { CMD_TEST_RUN_AT_CURSOR, CMD_TEST_DEBUG_AT_CURSOR } from '../../constants.js'; import { closeAllEditors, + deepEq, + eq, + neq, openCSharpFile, openFSharpFile, + replaceDocumentContent, + requireAt, setupLspTestSuite, teardownLspTestSuite, } from './test-helpers'; @@ -64,6 +84,63 @@ function testLensCommands(lenses: vscode.CodeLens[]): vscode.CodeLens[] { ); } +/** + * The characters [TEST-FILTER-ESCAPE] calls grammar and requires escaping. + * + * `,`, `+`, `.` and SPACE are deliberately absent: they occur inside real + * fully-qualified names (`Adds_Case(2,2,4)`, a nested-type `+`, an F# backtick + * binding) and escaping one would corrupt the very names the spec's table says + * must round-trip unchanged. + */ +const FILTER_GRAMMAR: readonly string[] = ['\\', '(', ')', '&', '|', '=', '!', '~']; + +/** + * How many pipes in a filter expression are CLAUSE SEPARATORS, i.e. not + * preceded by a backslash. + * + * Counting every `|` cannot tell an OR between two selected tests from a pipe + * that occurs INSIDE one test's name — and those two are exactly what + * [TEST-FILTER-ESCAPE] distinguishes ("OR'd with an UNESCAPED `|` between + * escaped clauses"). + */ +function separatorPipes(expression: string): number { + let count = 0; + for (let index = 0; index < expression.length; index += 1) { + if (expression[index] === '|' && expression[index - 1] !== '\\') { + count += 1; + } + } + return count; +} + +/** A cobertura report over one file with the given per-line hit counts. */ +function coberturaFor(filename: string, hits: readonly number[]): string { + const lines = hits + .map((hit, index) => `<line number="${String(index + 1)}" hits="${String(hit)}"/>`) + .join(''); + return ( + '<?xml version="1.0"?><coverage><packages><package><classes>' + + `<class filename="${filename}"><lines>${lines}</lines></class>` + + '</classes></package></packages></coverage>' + ); +} + +/** Write `xml` into its own run-id folder one level below `resultsDir`. */ +function plantReport(resultsDir: string, runId: string, xml: string): string { + const runDir = path.join(resultsDir, runId); + fs.mkdirSync(runDir, { recursive: true }); + const reportPath = path.join(runDir, 'coverage.cobertura.xml'); + fs.writeFileSync(reportPath, xml, 'utf8'); + return reportPath; +} + +/** A `CachedTestResult` literal, so the four lens titles can be driven directly. */ +function cached( + result: Partial<CachedTestResult> & Pick<CachedTestResult, 'outcome'>, +): CachedTestResult { + return { passed: result.outcome === 'passed', ...result }; +} + /** A minimal but realistic cobertura report: one covered, one uncovered line. */ const COBERTURA_XML = [ '<?xml version="1.0"?>', @@ -151,6 +228,82 @@ const FSHARP_TESTS = [ '', ].join('\n'); +// Every attribute shape [TEST-OVERVIEW] names ("xUnit, NUnit, MSTest, Expecto +// and FsCheck, in BOTH C# and F#") over one class, plus three methods that are +// NOT tests. A lens above a helper runs nothing; a missing lens above an +// [TestMethod] leaves MSTest users with no Run button at all. +const FRAMEWORK_TESTS = [ + 'using Xunit;', + 'using NUnit.Framework;', + 'using Microsoft.VisualStudio.TestTools.UnitTesting;', + '', + 'namespace Sample.Frameworks', + '{', + ' public class MixedTests', + ' {', + ' [Fact]', + ' public void Mixed_XunitFact()', + ' {', + ' }', + '', + ' [Theory]', + ' [InlineData(1)]', + ' public void Mixed_XunitTheory(int a)', + ' {', + ' }', + '', + ' [Test]', + ' public void Mixed_NunitTest()', + ' {', + ' }', + '', + ' [TestCase(2, 2, 4)]', + ' public void Mixed_NunitCase(int a, int b, int expected)', + ' {', + ' }', + '', + ' [TestMethod]', + ' public void Mixed_MstestMethod()', + ' {', + ' }', + '', + ' [DataRow(1, 2)]', + ' [DataTestMethod]', + ' public void Mixed_MstestRow(int a, int b)', + ' {', + ' }', + '', + ' private void Mixed_Helper()', + ' {', + ' }', + '', + ' public int Mixed_Property { get; set; }', + '', + ' public void Mixed_PlainMethod()', + ' {', + ' }', + ' }', + '}', + '', +].join('\n'); + +/** The methods FRAMEWORK_TESTS decorates with a test attribute. */ +const FRAMEWORK_TEST_METHODS: readonly string[] = [ + 'Mixed_XunitFact', + 'Mixed_XunitTheory', + 'Mixed_NunitTest', + 'Mixed_NunitCase', + 'Mixed_MstestMethod', + 'Mixed_MstestRow', +]; + +/** The members of FRAMEWORK_TESTS that no lens may ever offer to run. */ +const FRAMEWORK_NON_TESTS: readonly string[] = [ + 'Mixed_Helper', + 'Mixed_Property', + 'Mixed_PlainMethod', +]; + // ───────────────────────────────────────────────────────────────────────────── // Testing module — run/debug commands + discovery & coverage helpers // ───────────────────────────────────────────────────────────────────────────── @@ -207,6 +360,43 @@ suite('Testing module e2e — run/debug commands and helpers', () => { const warning = stubs.log.warningMessages[0] ?? ''; assert.ok(warning.includes('Lens_AddsTwoNumbers'), 'warning names the missing test method'); assert.ok(warning.includes('discovery'), 'warning points the user at discovery'); + + // Interaction 3 - "not discovered yet" is a WARNING, not an error and not a + // silent no-op. The user pressed Run and something must answer: a silent + // return leaves them pressing it again ([TEST-EXPLORER]). + assert.deepEqual(stubs.log.errorMessages, [], 'an undiscovered test is not an error'); + assert.deepEqual(stubs.log.infoMessages, [], 'and nothing claims the test ran'); + assert.notStrictEqual(stubs.log.warningOptions[0]?.modal, true, 'and it does not block'); + + // Interaction 4 - the caret really was on the [Fact] method, so the warning + // is about discovery rather than about a caret that resolved to nothing. + assert.strictEqual( + editor.selection.active.line, + factLine, + 'the caret sat on the [Fact] method', + ); + assert.ok( + doc.lineAt(factLine).text.includes('Lens_AddsTwoNumbers'), + 'and that line really declares the method the command was given', + ); + assert.ok( + doc.getText().includes('[Fact]'), + 'the fixture carries the attribute that makes it a test at all', + ); + + // Interaction 5 - running the SAME method again warns again. A command that + // remembers it already complained goes silent on the second press, which is + // exactly when the user is most likely to press it. + stubs.queueWarning(undefined); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(CMD_TEST_RUN_AT_CURSOR, uri, 'Lens_AddsTwoNumbers'); + }); + assert.strictEqual(stubs.log.warningMessages.length, 2, 'the second press warns again'); + assert.strictEqual( + stubs.log.warningMessages[1], + warning, + 'with the same message, naming the same method', + ); }); test('debugAtCursor on a method resolves and warns for an undiscovered test', async function () { @@ -231,7 +421,43 @@ suite('Testing module e2e — run/debug commands and helpers', () => { }); assert.strictEqual(stubs.log.warningMessages.length, 1); - assert.ok((stubs.log.warningMessages[0] ?? '').includes('Lens_AddsTheory')); + const debugWarning = stubs.log.warningMessages[0] ?? ''; + assert.ok(debugWarning.includes('Lens_AddsTheory'), 'the warning names the theory method'); + assert.ok(debugWarning.includes('discovery'), 'and points the user at discovery'); + + // Interaction 2 - a debug that cannot start must not START ANYTHING. A + // half-launched session with no test to run leaves the debug toolbar on + // screen with nothing behind it ([TEST-EXPLORER]). + assert.strictEqual( + vscode.debug.activeDebugSession, + undefined, + 'an undiscovered test starts no debug session', + ); + assert.deepEqual(stubs.log.errorMessages, [], 'and reports no error'); + assert.deepEqual(stubs.log.infoMessages, [], 'and claims no run'); + + // Interaction 3 - the caret sat on the [Theory] declaration, so the warning + // is about discovery and not about an unresolvable caret. + assert.strictEqual(editor?.selection.active.line, theoryLine, 'the caret sat on the theory'); + assert.ok( + doc.lineAt(theoryLine).text.includes('Lens_AddsTheory'), + 'and that line declares the method the command was given', + ); + assert.ok(doc.getText().includes('[Theory]'), 'the fixture really declares a theory'); + + // Interaction 4 - the DEBUG path warns for the same reason the RUN path + // does. Two commands that disagree about whether a test exists send the + // user hunting for a difference that is not there. + stubs.queueWarning(undefined); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(CMD_TEST_RUN_AT_CURSOR, uri, 'Lens_AddsTheory'); + }); + assert.strictEqual(stubs.log.warningMessages.length, 2, 'the run path warns as well'); + assert.strictEqual( + stubs.log.warningMessages[1], + debugWarning, + 'with the very same message for the very same method', + ); }); test('both at-cursor commands are registered and stay registered', async function () { @@ -256,6 +482,28 @@ suite('Testing module e2e — run/debug commands and helpers', () => { await vscode.commands.executeCommand(CMD_TEST_DEBUG_AT_CURSOR, uri, 'Phantom'); }); assert.strictEqual(stubs.log.warningMessages.length, 2); + + // Interaction 3 - the two are DISTINCT commands. A lens pair backed by one + // id renders two buttons that do the same thing, which is the defect the + // Run/Debug pair exists to avoid. + assert.notStrictEqual(CMD_TEST_RUN_AT_CURSOR, CMD_TEST_DEBUG_AT_CURSOR, 'two ids, not one'); + assert.ok(CMD_TEST_RUN_AT_CURSOR.startsWith('sharplsp.'), 'both live in our namespace'); + assert.ok(CMD_TEST_DEBUG_AT_CURSOR.startsWith('sharplsp.'), 'both of them'); + + // Interaction 4 - they STAY registered after being driven. A command that + // disposes itself on a failed run works exactly once per window. + const after = await vscode.commands.getCommands(true); + assert.ok(after.includes(CMD_TEST_RUN_AT_CURSOR), 'runAtCursor survives being run'); + assert.ok(after.includes(CMD_TEST_DEBUG_AT_CURSOR), 'and so does debugAtCursor'); + + // Interaction 5 - a phantom file warns rather than throwing, and warns for + // BOTH commands: the two warnings above came one from each. + assert.deepEqual(stubs.log.errorMessages, [], 'a phantom file is not an error'); + assert.strictEqual( + stubs.log.warningMessages.every((message) => message.includes('Phantom')), + true, + `both warnings name the phantom method: ${stubs.log.warningMessages.join(' | ')}`, + ); }); test('discovery predicates classify a real test project listing', async function () { @@ -343,6 +591,37 @@ suite('Testing module e2e — run/debug commands and helpers', () => { assert.ok( (many[1] ?? '').startsWith('FullyQualifiedName=Sample.Tests.CalculatorTests.Adds_Two'), ); + + // Interaction 2 - EVERY selected test reaches the clause. A filter that + // drops one runs fewer tests than the user selected and reports the missing + // ones as never run ([TEST-FILTER-ESCAPE]). + const three = buildFilterArgs([ + testItem('Sample.Tests.CalculatorTests.A'), + testItem('Sample.Tests.CalculatorTests.B'), + testItem('Sample.Tests.CalculatorTests.C'), + ]); + assert.strictEqual(three.length, 2, 'three tests still make one --filter pair'); + assert.strictEqual((three[1] ?? '').split('|').length, 3, 'with three OR-ed clauses'); + for (const name of ['.A', '.B', '.C']) { + assert.ok((three[1] ?? '').includes(name), `${name} must reach the clause`); + } + + // Interaction 3 - the flag is always `--filter`, exactly once. A clause + // emitted as two flags makes `dotnet test` use the last one and silently + // run a subset. + assert.strictEqual(three.filter((argument) => argument === '--filter').length, 1, 'one flag'); + assert.strictEqual(three[0], '--filter', 'and it comes first'); + assert.strictEqual( + single.filter((argument) => argument === '--filter').length, + 1, + 'for one too', + ); + + // Interaction 4 - an empty selection produces NO flag at all. A bare + // `--filter` with an empty value matches nothing, so the run reports zero + // tests instead of running everything. + assert.deepStrictEqual(buildFilterArgs([]), [], 'no selection, no filter'); + assert.strictEqual(buildFilterArgs([]).length, 0, 'not even the flag'); }); test('coverage helpers find and parse a real cobertura report on disk', async function () { @@ -381,6 +660,501 @@ suite('Testing module e2e — run/debug commands and helpers', () => { ); assert.deepStrictEqual(parseCoberturaXml(emptyPath), []); }); + + // Implements [TEST-FILTER-ESCAPE]: "`\`, `(`, `)`, `&`, `|`, `=`, `!` and `~` + // are grammar and MUST be backslash-escaped inside a fully-qualified name + // before substitution", and "Multiple selected tests are OR'd with an + // UNESCAPED `|` between escaped clauses". + test('buildFilterArgs escapes every grammar character and OR-s clauses with a bare pipe', async function () { + this.timeout(FAST_MS); + + // Interaction 1 - each reserved character on its own. An unescaped NUnit + // `[TestCase]` name crashes the NUnit adapter in `VsTestFilter.get_IsEmpty()`, + // so the run dies instead of reporting a result: this is not cosmetic. + for (const char of FILTER_GRAMMAR) { + const args = buildFilterArgs([testItem('Ns.Class.Method' + char + 'Tail')]); + eq(args.length, 2, char + ': one --filter flag and one expression, never more'); + eq(args[0], '--filter', char + ': the flag comes first'); + eq( + args[1], + 'FullyQualifiedName=Ns.Class.Method\\' + char + 'Tail', + char + ' is filter grammar and MUST be backslash-escaped inside the name', + ); + eq( + (args[1] ?? '').startsWith('FullyQualifiedName='), + true, + char + ': the FullyQualifiedName= separator is the grammar, never escaped itself', + ); + eq(separatorPipes(args[1] ?? ''), 0, char + ': one test is one clause, so no OR'); + } + + // Interaction 2 - the shapes [TEST-DISCOVERY-FQN]'s table says must + // round-trip unchanged. `,`, `+` and SPACE are not grammar: escaping one + // would corrupt the name it was meant to protect. + const nunit = buildFilterArgs([testItem('Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(2,2,4)')]); + eq( + nunit[1], + 'FullyQualifiedName=Cs.Nunit.Fixtures.CalculatorTests.Adds_Case\\(2,2,4\\)', + 'the NUnit [TestCase] parentheses are escaped and its commas are left alone', + ); + eq((nunit[1] ?? '').includes('\\,'), false, 'a comma is not filter grammar'); + const fsharp = buildFilterArgs([testItem('Fs.Xunit.Fixtures.adds two numbers with spaces')]); + eq( + fsharp[1], + 'FullyQualifiedName=Fs.Xunit.Fixtures.adds two numbers with spaces', + 'an idiomatic F# backtick name carries SPACES and must survive verbatim', + ); + eq((fsharp[1] ?? '').includes('\\ '), false, 'a space is not filter grammar'); + const mstest = buildFilterArgs([testItem('Fs.Mstest.Fixtures+CalculatorTests.AddsTwoNumbers')]); + eq( + mstest[1], + 'FullyQualifiedName=Fs.Mstest.Fixtures+CalculatorTests.AddsTwoNumbers', + 'a CLR nested-type + is part of the name, not the filter grammar', + ); + eq((mstest[1] ?? '').includes('\\+'), false, 'a plus is not filter grammar'); + + // Interaction 3 - a SELECTION. The joining pipe is unescaped; a pipe inside + // a name is not. Getting that backwards either merges two tests into one + // clause or splits one name in half, and both run the wrong tests. + deepEq(buildFilterArgs([]), [], 'no selection is no filter at all, not an empty expression'); + const pair = buildFilterArgs([ + testItem('Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(2,2,4)'), + testItem('Fs.Xunit.Fixtures.adds two numbers with spaces'), + ]); + eq(pair.length, 2, 'a selection is still ONE --filter argument, never one per test'); + eq( + pair[1], + 'FullyQualifiedName=Cs.Nunit.Fixtures.CalculatorTests.Adds_Case\\(2,2,4\\)|' + + 'FullyQualifiedName=Fs.Xunit.Fixtures.adds two numbers with spaces', + 'two selected tests are OR-ed with an UNESCAPED pipe between escaped clauses', + ); + eq(separatorPipes(pair[1] ?? ''), 1, 'exactly one separator for two clauses'); + const hostile = buildFilterArgs([ + testItem('Ns.Class.Has|Pipe'), + testItem('Ns.Class.Plain'), + testItem('Ns.Class.Also|Piped'), + ]); + eq( + separatorPipes(hostile[1] ?? ''), + 2, + 'three selected tests are two separators, however many pipes their NAMES carry', + ); + eq( + (hostile[1] ?? '').includes('\\|Pipe'), + true, + 'the pipe inside a name is escaped, so it can never be read as a clause break', + ); + eq( + (hostile[1] ?? '').split('FullyQualifiedName=').length - 1, + 3, + 'one FullyQualifiedName= clause per selected test, in selection order', + ); + eq( + (hostile[1] ?? '').indexOf('Ns.Class.Plain') > (hostile[1] ?? '').indexOf('Has'), + true, + 'and the order the user selected them in is preserved', + ); + }); + + // Implements [TEST-DISCOVERY-FQN] - the at-cursor commands address a test by + // the name the lens read off the signature, whatever characters it holds. + test('the at-cursor commands carry hostile method names through verbatim', async function () { + this.timeout(COMMAND_MS); + const uri = vscode.Uri.file(path.join(tmpDir, 'AtCursor.cs')); + + // Interaction 1 - an F# backtick binding: the name carries SPACES, and the + // warning must show the user the name it actually looked for. A name that + // arrived trimmed or split is a name no discovered test can ever match. + const spaced = 'adds two numbers with spaces'; + stubs.queueWarning(undefined); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(CMD_TEST_RUN_AT_CURSOR, uri, spaced); + }); + eq(stubs.log.warningMessages.length, 1, 'an unresolvable at-cursor run warns exactly once'); + const first = stubs.log.warningMessages[0] ?? ''; + eq(first.includes(spaced), true, 'the warning names the binding, spaces and all'); + eq(first.includes('discovery'), true, 'and points the user at discovery'); + deepEq(stubs.log.errorMessages, [], 'a name it cannot resolve is not an ERROR'); + + // Interaction 2 - every remaining shape the spec's tables name, plus each + // filter-grammar character. None may reject, and each must be echoed back. + const names = [ + 'Adds_Case(2,2,4)', + 'Fixtures+CalculatorTests.AddsTwoNumbers', + 'Has|Pipe', + 'Has&Amp', + 'Has=Equals', + 'Has!Bang', + 'Has~Tilde', + ]; + stubs.queueWarning(...names.map(() => undefined)); + for (const name of names) { + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(CMD_TEST_RUN_AT_CURSOR, uri, name); + }, name + ' must never make the at-cursor command reject'); + } + eq( + stubs.log.warningMessages.length, + names.length + 1, + 'one warning per invocation - a swallowed gesture is a Run Test that did nothing', + ); + for (const name of names) { + eq( + stubs.log.warningMessages.some((message) => message.includes(name)), + true, + name + ' must be reported back verbatim, not escaped or truncated', + ); + } + + // Interaction 3 - the DEBUG half must behave identically. [TEST-STATUS-LENS] + // puts both actions on the lens, so a Debug that rejects where Run warns is + // a dead button on every test in the file. + const before = stubs.log.warningMessages.length; + stubs.queueWarning(undefined, undefined); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(CMD_TEST_DEBUG_AT_CURSOR, uri, spaced); + }); + await assert.doesNotReject(async () => { + await vscode.commands.executeCommand(CMD_TEST_DEBUG_AT_CURSOR, uri, 'Adds_Case(2,2,4)'); + }); + eq(stubs.log.warningMessages.length, before + 2, 'Debug warns once per gesture as Run does'); + eq( + (stubs.log.warningMessages[before] ?? '').includes(spaced), + true, + 'and names the same binding the Run action would have run', + ); + deepEq(stubs.log.errorMessages, [], 'still nothing reported to the user as a failure'); + }); + + // Implements the [TEST-DISCOVERY-FQN] listing rules: every line is classified + // INDEPENDENTLY ("a banner-index slice is not admissible"), and the MSBuild + // `%XX` escaping in an announced assembly path is decoded before resolution. + test('the discovery parsers hold every line shape a real solution listing prints', async function () { + this.timeout(FAST_MS); + + // Interaction 1 - a listing in which two projects' banners and names + // INTERLEAVE, which is what parallel project builds actually emit. + const listing = [ + 'Determining projects to restore...', + ' Restored /w/Cs.Xunit.Fixtures/Cs.Xunit.Fixtures.csproj (in 412 ms).', + 'Cs.Xunit.Fixtures -> /w/bin/Debug/net10.0/Cs.Xunit.Fixtures.dll', + 'Test run for /w/bin/Debug/net10.0/Cs.Xunit.Fixtures.dll (.NETCoreApp,Version=v10.0)', + 'The following Tests are available:', + ' Cs.Xunit.Fixtures.CalculatorTests.Adds_TwoNumbers', + 'Test run for /w/bin/Debug/net10.0/Fs.Xunit.Fixtures.dll (.NETCoreApp,Version=v10.0)', + ' Fs.Xunit.Fixtures.adds two numbers with spaces', + ' Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(2,2,4)', + ' Fs.Mstest.Fixtures+CalculatorTests.AddsTwoNumbers', + 'Passed! - Failed: 0, Passed: 4', + ].join('\n'); + const names = parseTestList(listing); + eq( + names.includes('Fs.Xunit.Fixtures.adds two numbers with spaces'), + true, + 'an F# backtick name printed AFTER a second banner is still a test name - every line ' + + 'is classified independently, so a banner-index slice can never be the rule', + ); + eq( + names.includes('Cs.Xunit.Fixtures.CalculatorTests.Adds_TwoNumbers'), + true, + 'as is the C# one', + ); + eq( + names.includes('Cs.Xunit.Fixtures -> /w/bin/Debug/net10.0/Cs.Xunit.Fixtures.dll'), + false, + 'the MSBuild output mapping is dotted-identifier shaped and must still be rejected', + ); + eq(names.includes('Passed! - Failed: 0, Passed: 4'), false, 'nor is the summary a test'); + eq(names.length, new Set(names).size, 'the listing is de-duplicated'); + + // Interaction 2 - the same predicate at its boundary, one line at a time. + const rejected = [ + 'The following Tests are available:', + 'Build succeeded.', + 'Determining projects to restore...', + 'JustAnIdentifierNoDot', + 'Ns.Class.Param(x: 1)', + 'at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs', + 'Proj -> C:\\out\\Proj.dll', + '', + ' ', + ]; + for (const line of rejected) { + eq(isDiscoveredTestLine(line), false, JSON.stringify(line) + ' is never a test name'); + } + for (const line of [ + 'Cs.Xunit.Fixtures.CalculatorTests.Adds_TwoNumbers', + 'Fs.Xunit.Fixtures.adds two numbers with spaces', + 'Cs.Nunit.Fixtures.CalculatorTests.Adds_Case(2,2,4)', + 'Fs.Mstest.Fixtures+CalculatorTests.AddsTwoNumbers', + 'Cs.Mstest.Fixtures.CalculatorTests.Adds_Row', + ]) { + eq(isDiscoveredTestLine(line), true, line + ' is a shape the spec table requires'); + } + + // Interaction 3 - the announced assemblies, and the MSBuild `%XX` escaping + // a Windows path under "Program Files (x86)" really carries. Dropping the + // decode skips the fully-qualified pass and silently loses every NUnit + // test, every MSTest test and every theory. + const announced = parseAnnouncedAssemblies(listing); + eq(announced.length, 2, 'one banner per built test assembly'); + eq( + announced.includes('/w/bin/Debug/net10.0/Fs.Xunit.Fixtures.dll'), + true, + 'and the F# assembly is announced as well as the C# one', + ); + const escaped = 'C:\\Program Files %28x86%29\\App\\bin\\Debug\\net10.0\\Cs.Xunit.Fixtures.dll'; + eq( + unescapeMsBuildPath(escaped), + 'C:\\Program Files (x86)\\App\\bin\\Debug\\net10.0\\Cs.Xunit.Fixtures.dll', + 'MSBuild reserves ( and ) and encodes them as %28/%29; the path must be decoded', + ); + eq(unescapeMsBuildPath('%25'), '%', 'a literal percent is itself escaped as %25'); + eq( + unescapeMsBuildPath('/plain/path/App.dll'), + '/plain/path/App.dll', + 'a path with nothing reserved in it is returned untouched', + ); + eq( + resolveAnnouncedAssembly('/nowhere/that/exists/Ghost.dll'), + undefined, + 'an announced assembly that is not on disk resolves to nothing, and must not throw', + ); + }); + + // Implements [TEST-DISCOVERY-FQN]: assemblies are batched under the Windows + // 32 767-character command-line ceiling, and a MULTI-TARGETED project + // collapses to ONE group whose names are the UNION of the frameworks'. + test('assembly batching stays under the command-line ceiling and multi-targets merge', async function () { + this.timeout(FAST_MS); + + // Interaction 1 - a solution with dozens of test projects. Handing them all + // to one `dotnet vstest` fails to SPAWN instead of enumerating, so the + // batcher must split - but never a batch that is empty or reordered. + const many = Array.from({ length: 40 }, (_, index) => { + return '/w/very/long/output/path/segment/Project' + String(index) + '/bin/Debug/Tests.dll'; + }); + const batches = batchAssemblies(many, 400); + eq(batches.length > 1, true, 'forty long paths cannot fit one 400-character command line'); + deepEq(batches.flat(), many, 'every assembly appears exactly once, in listing order'); + for (const batch of batches) { + eq(batch.length >= 1, true, 'an empty batch would spawn vstest with no assembly'); + eq(batch.join(' ').length <= 400 + 3, true, 'each batch stays inside the ceiling'); + } + deepEq(batchAssemblies([], 400), [], 'nothing to enumerate is no invocation at all'); + deepEq( + batchAssemblies(['/w/a.dll'], 400), + [['/w/a.dll']], + 'one assembly is one batch, never two', + ); + const single = batchAssemblies([many[0] ?? '', many[1] ?? ''], 10); + eq( + single.length, + 2, + 'a path longer than the whole ceiling still goes out alone rather than being dropped', + ); + + // Interaction 2 - the same project built for two frameworks. Left apart, + // every namespace, class and test renders TWICE under two labels the user + // cannot tell apart. + const merged = mergeMultiTargeted([ + { + name: 'Cs.Multi.Fixtures', + path: '/w/bin/Debug/net9.0/Cs.Multi.Fixtures.dll', + names: ['Ns.C.Shared', 'Ns.C.Only_On_NET9_0'], + }, + { + name: 'Cs.Multi.Fixtures', + path: '/w/bin/Debug/net10.0/Cs.Multi.Fixtures.dll', + names: ['Ns.C.Shared', 'Ns.C.Only_On_NET10_0'], + }, + ]); + eq(merged.length, 1, 'two banners for one project collapse to ONE assembly group'); + const group = requireAt(merged, 0, 'the merged group'); + eq(group.name, 'Cs.Multi.Fixtures', 'under the project name the user recognises'); + deepEq( + [...group.names].sort(), + ['Ns.C.Only_On_NET10_0', 'Ns.C.Only_On_NET9_0', 'Ns.C.Shared'], + 'the collapsed names are the UNION - a test behind #if exists in only one assembly, ' + + 'and taking the first framework alone would trade a duplicated tree for a missing test', + ); + eq( + group.names.filter((name) => name === 'Ns.C.Shared').length, + 1, + 'a test compiled into BOTH assemblies still appears exactly once', + ); + + // Interaction 3 - two genuinely different projects must NOT be merged, and + // a single-framework project must survive the same call untouched. + const distinct = mergeMultiTargeted([ + { name: 'Cs.Fixtures', path: '/w/a/Cs.Fixtures.dll', names: ['Ns.A.One'] }, + { name: 'Fs.Fixtures', path: '/w/b/Fs.Fixtures.dll', names: ['Ns.B.two names'] }, + ]); + eq(distinct.length, 2, 'two different assemblies are two roots, not one'); + deepEq( + distinct.map((entry) => entry.name).sort(), + ['Cs.Fixtures', 'Fs.Fixtures'], + 'each keeps its own label, C# and F# alike', + ); + deepEq(mergeMultiTargeted([]), [], 'no assemblies is no tree, and never a throw'); + const lone = mergeMultiTargeted([ + { name: 'Solo', path: '/w/Solo.dll', names: ['Ns.S.a test with spaces'] }, + ]); + deepEq( + requireAt(lone, 0, 'the lone group').names, + ['Ns.S.a test with spaces'], + 'and a single-framework project passes through with its F# spaces intact', + ); + }); + + // Implements [TEST-COVERAGE]: "one Cobertura report per test project, each in + // its own run-id folder one level down, and EVERY one of them is parsed ... + // taking only the first drops every other project's coverage, and which one + // is 'first' is directory order". + test('every Cobertura report one level down is found and merged, not just the first', async function () { + this.timeout(FAST_MS); + const resultsDir = path.join(tmpDir, 'multi-coverage'); + + // Interaction 1 - the empty cases, which must answer rather than throw. + deepEq(findCoberturaFiles(resultsDir), [], 'a results directory that does not exist is empty'); + fs.mkdirSync(resultsDir, { recursive: true }); + deepEq(findCoberturaFiles(resultsDir), [], 'and so is one the collector never wrote to'); + eq(findCoberturaFile(resultsDir), undefined, 'the single-report reader agrees'); + deepEq(mergeCoberturaReports([]), [], 'merging no reports yields no coverage'); + + // Interaction 2 - TWO test projects, each covering a DIFFERENT library file. + // This is the case a first-only reader cannot be told apart from a correct + // one when the fixture has a single test project. + const alpha = plantReport(resultsDir, 'run-alpha', coberturaFor('/src/Alpha.cs', [3, 0, 1])); + const beta = plantReport(resultsDir, 'run-beta', coberturaFor('/src/Beta.cs', [0, 0, 7, 2])); + const found = findCoberturaFiles(resultsDir); + eq(found.length, 2, 'one report per test project, and BOTH must be found'); + eq(found.includes(alpha), true, 'the first project report is in the list'); + eq(found.includes(beta), true, 'and so is the second - directory order decides neither'); + neq( + findCoberturaFile(resultsDir), + undefined, + 'the single-report reader still answers, but it is only ever a subset', + ); + + const merged = mergeCoberturaReports(found); + const files = merged.map((entry) => entry.uri.fsPath).sort(); + eq(merged.length, 2, 'two reports over two files produce two FileCoverage entries'); + eq( + files.some((file) => file.endsWith('Alpha.cs')), + true, + 'the first project file is covered', + ); + eq( + files.some((file) => file.endsWith('Beta.cs')), + true, + 'and so is the second - attaching only reports[0] paints it as dead code', + ); + + // Interaction 3 - the per-file totals must survive the merge, and depth + // must stay at ONE level: the collector writes `<run-id>/coverage.cobertura.xml` + // and nothing deeper. + const alphaCoverage = merged.find((entry) => entry.uri.fsPath.endsWith('Alpha.cs')); + assert.ok(alphaCoverage, 'Alpha.cs must appear in the merged coverage'); + eq(alphaCoverage.statementCoverage.total, 3, 'Alpha.cs declares three lines'); + eq(alphaCoverage.statementCoverage.covered, 2, 'two of them were executed'); + const betaCoverage = merged.find((entry) => entry.uri.fsPath.endsWith('Beta.cs')); + assert.ok(betaCoverage, 'Beta.cs must appear too'); + eq(betaCoverage.statementCoverage.total, 4, 'Beta.cs declares four lines'); + eq(betaCoverage.statementCoverage.covered, 2, 'two of them were executed'); + + const deepDir = path.join(resultsDir, 'run-gamma', 'nested'); + fs.mkdirSync(deepDir, { recursive: true }); + fs.writeFileSync( + path.join(deepDir, 'coverage.cobertura.xml'), + coberturaFor('/src/Gamma.cs', [1]), + 'utf8', + ); + eq( + findCoberturaFiles(resultsDir).length, + 2, + 'the collector writes exactly one level down, so a deeper file is not a run report', + ); + }); + + // Implements [TEST-COVERAGE]: a coverage read must survive whatever the + // collector wrote - "a solution of nothing but test projects yields a valid, + // EMPTY report", and a report the run never finished must not take the run + // down with it. + test('the Cobertura reader survives empty, multi-class and malformed reports', async function () { + this.timeout(FAST_MS); + const dir = path.join(tmpDir, 'cobertura-shapes'); + fs.mkdirSync(dir, { recursive: true }); + + // Interaction 1 - several classes in one report, which is one test project + // exercising several library files. + const multi = path.join(dir, 'multi.cobertura.xml'); + fs.writeFileSync( + multi, + '<?xml version="1.0"?><coverage><packages><package><classes>' + + '<class filename="/src/One.cs"><lines><line number="1" hits="1"/></lines></class>' + + '<class filename="/src/Two.cs"><lines>' + + '<line number="4" hits="0"/><line number="5" hits="9"/></lines></class>' + + '</classes></package></packages></coverage>', + 'utf8', + ); + const parsed = parseCoberturaXml(multi); + eq(parsed.length, 2, 'one FileCoverage per class element'); + const one = requireAt(parsed, 0, 'the first class'); + const two = requireAt(parsed, 1, 'the second class'); + eq(one.statementCoverage.total, 1, 'One.cs declares a single line'); + eq(one.statementCoverage.covered, 1, 'and it ran'); + eq(two.statementCoverage.total, 2, 'Two.cs declares two'); + eq(two.statementCoverage.covered, 1, 'of which one ran'); + eq(one.uri.scheme, 'file', 'a FileCoverage always addresses a file on disk'); + neq(one.uri.toString(), two.uri.toString(), 'and the two classes are two different files'); + + // Interaction 2 - the valid-but-empty report [TEST-COVERAGE] names: a run + // that loaded no library assembly reports nothing, and that is not an error. + const empty = path.join(dir, 'empty.cobertura.xml'); + fs.writeFileSync( + empty, + '<?xml version="1.0"?><coverage><packages></packages></coverage>', + 'utf8', + ); + deepEq(parseCoberturaXml(empty), [], 'no packages is no coverage, and never a throw'); + const noLines = path.join(dir, 'nolines.cobertura.xml'); + fs.writeFileSync( + noLines, + '<?xml version="1.0"?><coverage><packages><package><classes>' + + '<class filename="/src/Bare.cs"><lines></lines></class>' + + '</classes></package></packages></coverage>', + 'utf8', + ); + const bare = parseCoberturaXml(noLines); + eq(bare.length, 1, 'a class with no executable line is still a file the run loaded'); + eq(requireAt(bare, 0, 'the bare class').statementCoverage.total, 0, 'with nothing to cover'); + eq(requireAt(bare, 0, 'the bare class').statementCoverage.covered, 0, 'and nothing covered'); + + // Interaction 3 - a report truncated mid-write (the run was cancelled) and + // a path that is not a file at all. Coverage is a REPORTING step: it must + // never be the thing that fails a run whose tests all passed. + const truncated = path.join(dir, 'truncated.cobertura.xml'); + fs.writeFileSync(truncated, '<?xml version="1.0"?><coverage><packages><package>', 'utf8'); + assert.doesNotThrow( + () => parseCoberturaXml(truncated), + 'a report truncated by a cancelled run must not throw out of the reporting step', + ); + assert.doesNotThrow( + () => parseCoberturaXml(path.join(dir, 'does-not-exist.xml')), + 'nor must a report the collector never wrote', + ); + deepEq( + parseCoberturaXml(path.join(dir, 'does-not-exist.xml')), + [], + 'a missing report is no coverage, reported as such', + ); + deepEq( + mergeCoberturaReports([empty, multi]), + parseCoberturaXml(multi), + 'merging an empty report with a populated one keeps exactly the populated one', + ); + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -571,6 +1345,45 @@ suite('Test status lens e2e — CodeLens provider and toggle', () => { `${target} must be a binding this fixture actually declares`, ); } + + // Interaction 4 - one Run lens and one Debug lens PER BINDING. Two lenses + // over the same `let` render two identical buttons, and the user cannot + // tell which of them is about to run. + for (const target of runTargets) { + assert.strictEqual( + runTargets.filter((name) => name === target).length, + 1, + `${target}: one Run lens, not one per attribute`, + ); + assert.strictEqual( + fsDebugTargets.filter((name) => name === target).length, + 1, + `${target}: one Debug lens either`, + ); + } + + // Interaction 5 - every lens is anchored inside the file, and the Run + // lenses render as the Run action. A lens with no title renders a blank + // clickable line above the binding. + const document = await vscode.workspace.openTextDocument(uri); + for (const lens of lenses) { + assert.ok( + lens.range.end.line < document.lineCount, + `a lens at line ${lens.range.start.line} must sit inside the file`, + ); + assert.ok((lens.command?.title ?? '').length > 0, 'and carry a visible title'); + } + assert.deepStrictEqual( + [ + ...new Set( + lenses + .filter((lens) => lens.command?.command === CMD_TEST_RUN_AT_CURSOR) + .map((lens) => lens.command?.title), + ), + ], + ['$(play) Run Test'], + 'and the Run half renders as the Run action', + ); }); test('disabling sharplsp.testLens.enabled removes the test lenses; re-enabling restores them', async function () { @@ -602,6 +1415,56 @@ suite('Test status lens e2e — CodeLens provider and toggle', () => { .update(TEST_LENS_KEY, true, vscode.ConfigurationTarget.Workspace); const reEnabledLenses = testLensCommands(await codeLensesFor(uri)); assert.ok(reEnabledLenses.length >= 2, 're-enabling restores the lenses'); + + // Interaction 4 - the setting really flipped at each step, so the lens + // counts above are a statement about the provider and not about a write + // that never landed. + const read = () => + vscode.workspace.getConfiguration(TEST_LENS_SECTION).get<boolean>(TEST_LENS_KEY); + assert.strictEqual(read(), true, 'the setting reads back as enabled'); + assert.strictEqual( + vscode.workspace.getConfiguration(TEST_LENS_SECTION).inspect<boolean>(TEST_LENS_KEY) + ?.workspaceValue, + true, + 'and is recorded at the workspace scope it was written to', + ); + + // Interaction 5 - restoring is EXACT, not approximate. The re-enabled + // set must name the same targets as the baseline, or "restored" means + // "some lenses came back". + assert.deepStrictEqual( + reEnabledLenses.map((lens) => lens.command?.arguments?.[1]).sort(), + enabledLenses.map((lens) => lens.command?.arguments?.[1]).sort(), + 'the restored lenses target exactly the baseline methods', + ); + assert.strictEqual( + reEnabledLenses.length, + enabledLenses.length, + 'and there are exactly as many of them', + ); + + // Interaction 6 - disabling removes the TEST lenses only. The setting is + // scoped to the test lens; taking the reference-count lenses with it + // would make one toggle silently disable an unrelated feature. + await vscode.workspace + .getConfiguration(TEST_LENS_SECTION) + .update(TEST_LENS_KEY, false, vscode.ConfigurationTarget.Workspace); + const allWhileDisabled = await codeLensesFor(uri); + assert.strictEqual( + testLensCommands(allWhileDisabled).length, + 0, + 'no test lens survives the disable', + ); + assert.strictEqual(read(), false, 'and the setting reads back as disabled'); + assert.ok( + Array.isArray(allWhileDisabled), + 'the provider still answers while disabled, with an empty test-lens set', + ); + assert.strictEqual( + allWhileDisabled.every((lens) => lens.command?.command !== CMD_TEST_RUN_AT_CURSOR), + true, + 'and no Run-Test lens is among whatever it did return', + ); } finally { // Restore the exact prior workspace value (undefined when unset) so the // key is removed rather than persisted into the fixture settings. @@ -667,5 +1530,419 @@ suite('Test status lens e2e — CodeLens provider and toggle', () => { assert.strictEqual(passedTitle, '$(pass) Passed (1.5s)'); const msTitle = `$(pass) Passed${formatDuration(42)}`; assert.strictEqual(msTitle, '$(pass) Passed (42ms)'); + + // Interaction 2 - the BOUNDARY is exact and one-sided. 999ms must stay in + // milliseconds and 1000ms must become seconds; an off-by-one there prints + // "(1000ms)" on one run and "(1.0s)" on the next for the same test. + assert.strictEqual(formatDuration(998), ' (998ms)', 'just below the boundary'); + assert.strictEqual(formatDuration(1001), ' (1.0s)', 'just above it'); + assert.strictEqual( + formatDuration(999).includes('s)') && !formatDuration(999).includes('ms)'), + false, + '999ms must not be rendered as seconds', + ); + + // Interaction 3 - seconds carry ONE decimal place, always. A bare "1s" and + // a "1.53333s" in the same column make the lens unreadable at a glance + // ([TEST-STATUS-LENS] fixes the suffix shape). + for (const [milliseconds, expected] of [ + [1000, ' (1.0s)'], + [1050, ' (1.1s)'], + [2500, ' (2.5s)'], + [12_300, ' (12.3s)'], + ] as const) { + assert.strictEqual( + formatDuration(milliseconds), + expected, + `${milliseconds}ms renders as ${expected}`, + ); + } + + // Interaction 4 - a missing duration renders NOTHING, so the title is + // "$(pass) Passed" with no dangling parenthesis. An undiscovered duration + // is not a zero-length run. + assert.strictEqual(formatDuration(undefined), '', 'no duration, no suffix'); + assert.strictEqual(`$(pass) Passed${formatDuration(undefined)}`, '$(pass) Passed'); + assert.notStrictEqual( + formatDuration(undefined), + formatDuration(0), + 'and it is not the same as 0ms', + ); + }); + + // Implements [TEST-STATUS-LENS] verbatim: "The status title reflects the + // Testing API's three states: `$(pass) Passed (<duration>)`, + // `$(debug-step-over) Skipped`, `$(circle-slash) Not run`, and + // `$(error) Failed: <assertion text>`." + test('the status title renders each of the four states with the spec icon', async function () { + this.timeout(FAST_MS); + + // Interaction 1 - a pass, with and without a duration. The icon is what the + // user reads at a glance; a wrong one makes a green run look red. + eq( + statusLensTitle(cached({ outcome: 'passed', duration: 1500 })), + '$(pass) Passed (1.5s)', + 'a pass over a second renders in seconds to one decimal', + ); + eq( + statusLensTitle(cached({ outcome: 'passed', duration: 42 })), + '$(pass) Passed (42ms)', + 'and under a second in whole milliseconds', + ); + eq( + statusLensTitle(cached({ outcome: 'passed', duration: 0 })), + '$(pass) Passed (0ms)', + 'a zero duration is a real measurement, not a missing one', + ); + eq( + statusLensTitle(cached({ outcome: 'passed' })), + '$(pass) Passed', + 'a pass with no recorded duration still says Passed, with no empty brackets', + ); + + // Interaction 2 - the two states that are NOT failures. [TEST-RUN-TRX] is + // explicit that "a skipped test MUST NOT be reported as a failure", and a + // never-run test is not a result at all. + const skipped = statusLensTitle(cached({ outcome: 'skipped' })); + eq(skipped, '$(debug-step-over) Skipped', 'a skip renders as the step-over icon'); + eq(skipped.includes('$(error)'), false, 'and never as an error'); + eq(skipped.includes('$(pass)'), false, 'nor as a pass'); + const notRun = statusLensTitle(cached({ outcome: 'notRun' })); + eq(notRun, '$(circle-slash) Not run', 'a test that has never run says so'); + eq(notRun.includes('$(error)'), false, 'a test nobody ran has not failed'); + eq( + statusLensTitle(cached({ outcome: 'skipped', duration: 12 })), + '$(debug-step-over) Skipped', + 'a skip carries no duration - it never executed, so there is nothing to time', + ); + + // Interaction 3 - a failure must carry the ASSERTION TEXT. [TEST-RUN-TRX]: + // "the assertion text and stack trace come from the TRX ErrorInfo, so a + // failure shows what actually went wrong instead of a generic 'Test failed'". + const message = 'Assert.Equal() Failure: Values differ'; + const failed = statusLensTitle(cached({ outcome: 'failed', message })); + eq(failed.startsWith('$(error) Failed:'), true, 'a failure renders as the error icon'); + eq(failed.includes(message), true, 'and shows the assertion the test actually tripped on'); + eq(failed.includes('\n'), false, 'a CodeLens title is ONE line; a newline mangles the lens'); + const multiline = statusLensTitle( + cached({ outcome: 'failed', message: message + '\nExpected: 4\nActual: 5' }), + ); + eq(multiline.startsWith('$(error) Failed:'), true, 'a multi-line assertion still renders'); + eq(multiline.includes('\n'), false, 'flattened onto the single line a lens can show'); + eq( + statusLensTitle(cached({ outcome: 'failed' })).startsWith('$(error) Failed'), + true, + 'a failure with no ErrorInfo at all still reads as a failure', + ); + const titles = [statusLensTitle(cached({ outcome: 'passed' })), skipped, notRun, failed]; + eq(new Set(titles).size, 4, 'the four states are four distinct titles the user can tell apart'); + for (const title of titles) { + eq(title.startsWith('$('), true, 'every status title leads with its icon'); + } + }); + + // Implements [TEST-STATUS-LENS] ("above every C# and F# test method") and + // [TEST-OVERVIEW] ("It supports xUnit, NUnit, MSTest, Expecto and FsCheck"). + test('every framework attribute gets a lens pair, and no helper or property does', async function () { + this.timeout(LSP_RESPONSE_MS); + const { uri } = await openCSharpFile(tmpDir, 'Frameworks.cs', FRAMEWORK_TESTS); + + // Interaction 1 - a Run action above every attributed method, whichever + // framework's attribute it carries. A framework that gets no lens is a + // framework whose users have no Run Test button. + const lenses = testLensCommands(await codeLensesFor(uri)); + const runLenses = lenses.filter((lens) => lens.command?.command === CMD_TEST_RUN_AT_CURSOR); + const debugLenses = lenses.filter((lens) => lens.command?.command === CMD_TEST_DEBUG_AT_CURSOR); + const runTargets = runLenses + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string'); + for (const method of FRAMEWORK_TEST_METHODS) { + eq(runTargets.includes(method), true, method + ' must be offered a Run action'); + eq( + runTargets.filter((name) => name === method).length, + 1, + method + ': ONE lens, not one per attribute - [DataRow] plus [DataTestMethod] is two', + ); + } + eq( + runLenses.length, + FRAMEWORK_TEST_METHODS.length, + 'exactly one Run lens per attributed method, and none over anything else', + ); + + // Interaction 2 - and nothing over a helper, a property or a plain public + // method. A lens there runs a "test" the adapter has never heard of. + for (const member of FRAMEWORK_NON_TESTS) { + eq(runTargets.includes(member), false, member + ' is not a test and gets no Run action'); + eq( + debugLenses.some((lens) => lens.command?.arguments?.[1] === member), + false, + member + ' gets no Debug action either', + ); + } + + // Interaction 3 - Run and Debug are PAIRED on the same line for every one + // of them, which is what [TEST-STATUS-LENS] means by "plus Run and Debug + // actions". + eq(debugLenses.length, runLenses.length, 'the two actions are paired, one for one'); + deepEq( + [...new Set(debugLenses.map((lens) => lens.command?.title))], + ['$(bug) Debug Test'], + 'every Debug action renders as the Debug lens', + ); + deepEq( + [...new Set(runLenses.map((lens) => lens.command?.title))], + ['$(play) Run Test'], + 'and every Run action as the Run lens', + ); + for (const method of FRAMEWORK_TEST_METHODS) { + const run = runLenses.find((lens) => lens.command?.arguments?.[1] === method); + const debug = debugLenses.find((lens) => lens.command?.arguments?.[1] === method); + assert.ok(run && debug, method + ' must carry both actions'); + eq(run.range.isEqual(debug.range), true, method + ': both actions render on the same line'); + eq( + run.command?.arguments?.[0]?.toString(), + uri.toString(), + method + ': the Run action points at the file the user has open', + ); + eq( + debug.command?.arguments?.length, + 2, + method + ': the at-cursor command takes (uri, methodName) - a short call is a no-op', + ); + } + }); + + // The project's HARD RULE: "All screens MUST BE 100% reactive. If underlying + // data changes, the screen must be listening and update accordingly." + // A lens list computed once is a Run button over a method the user deleted. + test('editing the document adds and removes lenses without a reload', async function () { + this.timeout(LSP_RESPONSE_MS); + const { doc, uri } = await openCSharpFile(tmpDir, 'Reactive.cs', CSHARP_TESTS); + + // Interaction 1 - the baseline the fixture declares. + const before = testLensCommands(await codeLensesFor(uri)) + .filter((lens) => lens.command?.command === CMD_TEST_RUN_AT_CURSOR) + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string'); + deepEq( + [...before].sort(), + ['Lens_AddsTheory', 'Lens_AddsTwoNumbers'], + 'the fixture declares exactly two test methods', + ); + eq(before.includes('NotATest'), false, 'and one plain method, which gets no lens'); + + // Interaction 2 - the user ADDS a test method. The new lens must appear + // against the edited buffer, with no save and no window reload. + const withExtra = doc + .getText() + .replace( + ' public void NotATest()', + ' [Fact]\n public void Lens_AddedLater()\n {\n }\n\n' + + ' public void NotATest()', + ); + eq(await replaceDocumentContent(doc, withExtra), true, 'the edit must apply'); + const afterAdd = testLensCommands(await codeLensesFor(uri)) + .filter((lens) => lens.command?.command === CMD_TEST_RUN_AT_CURSOR) + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string'); + eq( + afterAdd.includes('Lens_AddedLater'), + true, + 'a test method typed into the open buffer gets its lens immediately', + ); + eq(afterAdd.length, before.length + 1, 'and exactly one new lens, not a duplicated set'); + eq(afterAdd.includes('Lens_AddsTwoNumbers'), true, 'the existing lenses survive the edit'); + eq(afterAdd.includes('NotATest'), false, 'the plain method still gets none'); + + // Interaction 3 - the user REMOVES every test. A lens left behind runs a + // method that no longer exists. + eq( + await replaceDocumentContent( + doc, + [ + 'namespace Sample.Tests', + '{', + ' public class CalculatorTests', + ' {', + ' }', + '}', + '', + ].join('\n'), + ), + true, + 'the second edit must apply too', + ); + const afterRemove = testLensCommands(await codeLensesFor(uri)); + deepEq(afterRemove, [], 'a file with no test method carries no test lens at all'); + eq(doc.isDirty, true, 'and all of this happened in the buffer, with nothing written to disk'); + }); + + // Implements [TEST-STATUS-LENS]: "`sharplsp.testLens.enabled` (default true)". + // The setting governs BOTH languages - F# is not a second-class case + // ([TEST-OVERVIEW]) - and turning it off must remove the actions as well as + // the status. + test('the enable setting governs the lens in C# and F# alike, and restores cleanly', async function () { + this.timeout(SETTINGS_WRITE_MS); + const csharp = await openCSharpFile(tmpDir, 'ToggleBoth.cs', CSHARP_TESTS); + const fsharp = await openFSharpFile(tmpDir, 'ToggleBoth.fs', FSHARP_TESTS); + + const section = vscode.workspace.getConfiguration(TEST_LENS_SECTION); + const saved = section.inspect<boolean>(TEST_LENS_KEY)?.workspaceValue; + try { + // Interaction 1 - the default. Both languages carry lenses before the + // user has touched the setting at all. + await vscode.workspace + .getConfiguration(TEST_LENS_SECTION) + .update(TEST_LENS_KEY, true, vscode.ConfigurationTarget.Workspace); + const csOn = testLensCommands(await codeLensesFor(csharp.uri)); + const fsOn = testLensCommands(await codeLensesFor(fsharp.uri)); + eq(csOn.length >= 4, true, 'C# carries a Run and a Debug lens per test method'); + eq(fsOn.length >= 4, true, 'and F# carries them for its [<Fact>] and [<Theory>] bindings'); + eq( + fsOn.some((lens) => lens.command?.command === CMD_TEST_DEBUG_AT_CURSOR), + true, + 'F# gets the Debug action too - it is not a second-class case', + ); + eq( + vscode.workspace.getConfiguration(TEST_LENS_SECTION).get<boolean>(TEST_LENS_KEY), + true, + 'and the setting reads back as the user left it', + ); + + // Interaction 2 - switch it off. BOTH languages must go quiet, and the + // status half must go with the actions: a lens showing a stale "Passed" + // over a file whose lenses the user disabled is the worst of both. + await vscode.workspace + .getConfiguration(TEST_LENS_SECTION) + .update(TEST_LENS_KEY, false, vscode.ConfigurationTarget.Workspace); + const csOff = await codeLensesFor(csharp.uri); + const fsOff = await codeLensesFor(fsharp.uri); + deepEq(testLensCommands(csOff), [], 'no C# test lens survives the setting being off'); + deepEq(testLensCommands(fsOff), [], 'and no F# one either'); + deepEq( + csOff.filter((lens) => (lens.command?.title ?? '').startsWith('$(circle-slash)')), + [], + 'nor a status lens - the setting governs the whole contribution', + ); + deepEq( + fsOff.filter((lens) => (lens.command?.title ?? '').startsWith('$(circle-slash)')), + [], + 'in F# as in C#', + ); + + // Interaction 3 - switch it back on. What comes back must be what left, + // for both languages, addressed by the same names. + await vscode.workspace + .getConfiguration(TEST_LENS_SECTION) + .update(TEST_LENS_KEY, true, vscode.ConfigurationTarget.Workspace); + const csBack = testLensCommands(await codeLensesFor(csharp.uri)); + const fsBack = testLensCommands(await codeLensesFor(fsharp.uri)); + eq(csBack.length, csOn.length, 're-enabling restores exactly the C# lenses that were there'); + eq(fsBack.length, fsOn.length, 'and exactly the F# ones'); + deepEq( + csBack + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string') + .sort(), + csOn + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string') + .sort(), + 'addressing the same C# methods by the same names', + ); + deepEq( + fsBack + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string') + .sort(), + fsOn + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string') + .sort(), + 'and the same F# bindings', + ); + } finally { + await vscode.workspace + .getConfiguration(TEST_LENS_SECTION) + .update(TEST_LENS_KEY, saved, vscode.ConfigurationTarget.Workspace); + } + }); + + // Implements [TEST-STATUS-LENS] - the SIGNATURE readers that decide which + // method name a lens carries. A reader that answers the wrong name puts a Run + // button over one test and runs another. + test('the signature readers agree with the fixtures on every shape and near miss', async function () { + this.timeout(FAST_MS); + + // Interaction 1 - C# signatures, including the modifiers a real test class + // uses. Every name it returns must be a method the fixture declares. + const csharpCases: readonly (readonly [string, string | undefined])[] = [ + [' public void Lens_AddsTwoNumbers()', 'Lens_AddsTwoNumbers'], + [' public void Lens_AddsTheory(int a, int b, int expected)', 'Lens_AddsTheory'], + [' public async Task Runs_AsynchronouslyAsync()', 'Runs_AsynchronouslyAsync'], + [' internal static void Helper_Method()', 'Helper_Method'], + [' [Fact]', undefined], + [' [InlineData(2, 2, 4)]', undefined], + ['if (x > 0)', undefined], + [' public int Value { get; set; }', undefined], + ['', undefined], + [' // public void Commented()', undefined], + ]; + for (const [line, expected] of csharpCases) { + eq( + extractCSharpMethodName(line), + expected, + JSON.stringify(line) + ' reads as ' + String(expected), + ); + } + for (const method of FRAMEWORK_TEST_METHODS) { + eq( + FRAMEWORK_TESTS.includes(method), + true, + method + ' must be a method the framework fixture really declares', + ); + } + + // Interaction 2 - F# signatures. The backtick binding is the one that + // matters most: its name carries SPACES, and a reader that stops at the + // first space addresses a test that does not exist. + const fsharpCases: readonly (readonly [string, string | undefined])[] = [ + ['let addsTheory a b expected =', 'addsTheory'], + ['let addsTwoNumbers () =', 'addsTwoNumbers'], + ['member this.MyTest () =', 'MyTest'], + ['[<Fact>]', undefined], + ['[<Theory>]', undefined], + ['open Xunit', undefined], + ['module Sample.FSharpTests', undefined], + ['', undefined], + ]; + for (const [line, expected] of fsharpCases) { + eq( + extractFSharpFunctionName(line), + expected, + JSON.stringify(line) + ' reads as ' + String(expected), + ); + } + + // Interaction 3 - the duration suffix the status title appends, across the + // ms/seconds boundary the spec's `(<duration>)` implies. + eq(formatDuration(undefined), '', 'no measurement renders no suffix at all'); + eq(formatDuration(0), ' (0ms)', 'zero is a measurement'); + eq(formatDuration(1), ' (1ms)', 'and so is one millisecond'); + eq(formatDuration(999), ' (999ms)', 'the last value before the boundary stays in ms'); + eq(formatDuration(1000), ' (1.0s)', 'the boundary itself flips to seconds'); + eq(formatDuration(1500), ' (1.5s)', 'with one decimal place'); + eq(formatDuration(60000), ' (60.0s)', 'a minute is still reported in seconds, not mangled'); + eq( + '$(pass) Passed' + formatDuration(1500), + '$(pass) Passed (1.5s)', + 'and composes into exactly the title [TEST-STATUS-LENS] specifies', + ); + eq( + '$(pass) Passed' + formatDuration(undefined), + '$(pass) Passed', + 'with no trailing space when there is nothing to report', + ); }); }); diff --git a/src/editors/vscode/src/test/suite/testing-lens-status.test.ts b/src/editors/vscode/src/test/suite/testing-lens-status.test.ts new file mode 100644 index 00000000..a101937f --- /dev/null +++ b/src/editors/vscode/src/test/suite/testing-lens-status.test.ts @@ -0,0 +1,1367 @@ +// The STATUS half of [TEST-STATUS-LENS], observed where the user reads it: as a +// real CodeLens above a real test method, over a solution the `dotnet` CLI built +// and the Test Explorer actually ran. +// +// [TEST-STATUS-LENS] says `sharplsp.testLens.enabled` (default true) "puts a +// CodeLens above every C# and F# test method showing its LAST KNOWN RESULT plus +// Run and Debug actions", and pins the four titles the three Testing-API states +// render as: +// +// $(pass) Passed (<duration>) $(debug-step-over) Skipped +// $(circle-slash) Not run $(error) Failed: <assertion text> +// +// Asserting `statusLensTitle` as a function proves the strings; it does not +// prove any of them ever reaches an editor. The lens can be registered for the +// wrong languages, resolve against the wrong controller, look a method up by a +// name it never carries, or — the failure a user actually reports — never fire +// `onDidChangeCodeLenses`, so the row keeps saying "Not run" after a green run. +// CLAUDE.md makes that last one a hard rule: "All screens MUST BE 100% +// reactive. If underlying data changes, the screen must be listening and update +// accordingly." +// +// So every assertion here goes through `vscode.executeCodeLensProvider` against +// the fixture's own source files, before a run and after one, with the editor +// left open across the run. +// +// F# first: the F# fixture's bindings include a backtick name carrying SPACES, +// which has to resolve to its own row and its own status exactly as a C# method +// name does. +// +// Covers [TEST-STATUS-LENS], and [TEST-RUN-TRX] for the outcomes it renders. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { CMD_TEST_DEBUG_AT_CURSOR, CMD_TEST_RUN_AT_CURSOR } from '../../constants.js'; +import type { SharpLspExtensionApi } from '../../extension.js'; +import { formatDuration } from '../../test-lens.js'; +import { createSolution, warmDiscovery } from './dotnet-project-kit'; +import { codeLensesFor, warmCodeLensPath } from './code-lens-kit'; +import { + fixtureFor, + LIBRARY_TEST, + LIBRARY_TESTS_FILE, + writeCoverageFixture, +} from './test-explorer-fixtures'; +import { + activateTestExplorer, + drainDiscovery, + pollUntilDiscovered, + rootsOf, + runViaProfile, +} from './test-explorer-kit'; +import { cachedFor, itemsFor, sorted } from './test-explorer-outcome-assertions'; +import { collectLeafIds } from './test-explorer-kit'; +import { closeAllEditors, deepEq, eq, neq, removeDirRecursive } from './test-helpers.js'; +import { + DOTNET_CLI_MS, + FIXTURE_BUILD_MS, + LSP_RESPONSE_MS, + SETTINGS_WRITE_MS, + SIDECAR_COLD_MS, +} from './test-timeouts'; + +const CS = fixtureFor('xunit-csharp'); +const FSX = fixtureFor('xunit-fsharp'); + +/** The F# binding whose fully-qualified name carries SPACES. */ +const FS_SPACED = 'Fs.Xunit.Fixtures.adds two numbers with spaces'; + +/** Every test the fixture solution exposes, partitioned by outcome. */ +const PASSING = [ + CS.passing, + CS.parameterized, + FSX.passing, + FSX.parameterized, + FS_SPACED, + LIBRARY_TEST, +] as const; +const FAILING = [ + CS.failing, + CS.mixedParameterized ?? '', + FSX.failing, + FSX.mixedParameterized ?? '', +] as const; +const SKIPPED = [CS.skipped, FSX.skipped] as const; +const ALL_TESTS: readonly string[] = [...PASSING, ...FAILING, ...SKIPPED].filter( + (id) => id.length > 0, +); + +const TEST_LENS_SECTION = 'sharplsp.testLens'; +const TEST_LENS_KEY = 'enabled'; + +/** The four titles [TEST-STATUS-LENS] pins, by the icon each opens with. */ +const NOT_RUN = '$(circle-slash) Not run'; +const SKIPPED_TITLE = '$(debug-step-over) Skipped'; +const PASSED_PREFIX = '$(pass) Passed'; +const FAILED_PREFIX = '$(error) Failed:'; + +/** Every icon a status lens may open with, and nothing else. */ +const STATUS_ICONS = ['$(pass)', '$(error)', '$(circle-slash)', '$(debug-step-over)'] as const; + +/** The method or binding name a fully-qualified name ends in. */ +function methodOf(fqn: string): string { + return fqn.slice(fqn.lastIndexOf('.') + 1); +} + +/** True for a lens rendering a RESULT rather than a Run/Debug action. */ +function isStatusLens(lens: vscode.CodeLens): boolean { + const title = lens.command?.title ?? ''; + return STATUS_ICONS.some((icon) => title.startsWith(icon)); +} + +/** The Run/Debug action lenses this extension contributes. */ +function actionLenses(lenses: readonly vscode.CodeLens[]): vscode.CodeLens[] { + return lenses.filter( + (lens) => + lens.command?.command === CMD_TEST_RUN_AT_CURSOR || + lens.command?.command === CMD_TEST_DEBUG_AT_CURSOR, + ); +} + +/** + * The status title rendered for `method` in `lenses`, or `undefined`. + * + * A status lens is matched to its method by RANGE — it renders on the same line + * as that method's Run and Debug actions, which carry the method name — because + * that is what the user sees: three actions on one row above one method. + */ +function statusFor(lenses: readonly vscode.CodeLens[], method: string): string | undefined { + const action = actionLenses(lenses).find((lens) => lens.command?.arguments?.[1] === method); + if (action === undefined) return undefined; + return lenses.find((lens) => isStatusLens(lens) && lens.range.isEqual(action.range))?.command + ?.title; +} + +/** Every method name the Run actions in `lenses` target. */ +function lensedMethods(lenses: readonly vscode.CodeLens[]): string[] { + return sorted([ + ...new Set( + actionLenses(lenses) + .filter((lens) => lens.command?.command === CMD_TEST_RUN_AT_CURSOR) + .map((lens) => lens.command?.arguments?.[1]) + .filter((name): name is string => typeof name === 'string'), + ), + ]); +} + +suite('Test Status Lens e2e — the last known result, above the method', () => { + let api: SharpLspExtensionApi; + let root: string; + let csFile: vscode.Uri; + let fsFile: vscode.Uri; + let libraryTestsFile: vscode.Uri; + + suiteSetup(async function () { + this.timeout(FIXTURE_BUILD_MS); + api = await activateTestExplorer(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-lensstatus-')); + const slnPath = await createSolution(root, 'LensStatus', writeCoverageFixture(root)); + csFile = vscode.Uri.file(path.join(root, CS.projectName, CS.sourceFileName)); + fsFile = vscode.Uri.file(path.join(root, FSX.projectName, FSX.sourceFileName)); + libraryTestsFile = vscode.Uri.file(path.join(root, CS.projectName, LIBRARY_TESTS_FILE)); + assert.strictEqual(fs.existsSync(csFile.fsPath), true, 'the C# fixture source is on disk'); + assert.strictEqual(fs.existsSync(fsFile.fsPath), true, 'the F# fixture source is on disk'); + + await warmDiscovery(slnPath, root); + await api.explorerProvider.loadSolution(slnPath); + await api.testController.activateAndDiscover(); + await drainDiscovery(() => undefined, api.testController); + await pollUntilDiscovered(api.testController, ALL_TESTS); + // Pay the code-lens cold start once, per language + // ([DIST-CI-VSIX-SHARDS-TIMEOUTS]). + this.timeout(FIXTURE_BUILD_MS + SIDECAR_COLD_MS); + await warmCodeLensPath(csFile, fsFile); + }); + + suiteTeardown(async function () { + this.timeout(DOTNET_CLI_MS); + await closeAllEditors(); + await vscode.workspace + .getConfiguration(TEST_LENS_SECTION) + .update(TEST_LENS_KEY, undefined, vscode.ConfigurationTarget.Global); + await drainDiscovery(() => { + api.explorerProvider.clear(); + api.testController.items.replace([]); + }, api.testController); + removeDirRecursive(root); + }); + + test('before any run, every discovered test carries a "Not run" status plus Run and Debug', async function () { + this.timeout(LSP_RESPONSE_MS); + + // Interaction 1 — open the C# fixture. Every [Fact]/[Theory] gets its three + // lenses; the private helper the class also declares gets none. + const csLenses = await codeLensesFor(csFile); + const csMethods = lensedMethods(csLenses); + assert.deepStrictEqual( + csMethods, + sorted( + [CS.passing, CS.failing, CS.skipped, CS.parameterized, CS.mixedParameterized ?? ''] + .filter((id) => id.length > 0) + .map(methodOf), + ), + `every C# test method must carry a lens; got ${csMethods.join(' | ') || '(nothing)'}`, + ); + for (const method of csMethods) { + assert.strictEqual( + statusFor(csLenses, method), + NOT_RUN, + `${method} has not been run in this session, so its lens must read "${NOT_RUN}"`, + ); + } + + // Interaction 2 — the F# fixture, whose bindings include one carrying + // SPACES. A lens keyed on a name it cannot round-trip shows nothing at all. + const fsLenses = await codeLensesFor(fsFile); + const fsMethods = lensedMethods(fsLenses); + assert.strictEqual( + fsMethods.includes(methodOf(FS_SPACED)), + true, + `the backtick binding "${methodOf(FS_SPACED)}" must carry a lens like any other test; ` + + `got ${fsMethods.join(' | ') || '(nothing)'}`, + ); + for (const method of fsMethods) { + assert.strictEqual( + statusFor(fsLenses, method), + NOT_RUN, + `${method} has not been run either, so its F# lens reads "${NOT_RUN}"`, + ); + } + + // Interaction 3 — the status lens accompanies the actions, never replaces + // them: three lenses on one row, all sharing a range. + for (const [uri, lenses, methods] of [ + [csFile, csLenses, csMethods], + [fsFile, fsLenses, fsMethods], + ] as const) { + for (const method of methods) { + const onRow = lenses.filter((lens) => + actionLenses(lenses).some( + (action) => + action.command?.arguments?.[1] === method && action.range.isEqual(lens.range), + ), + ); + assert.strictEqual( + onRow.filter((lens) => lens.command?.command === CMD_TEST_RUN_AT_CURSOR).length, + 1, + `${path.basename(uri.fsPath)}: ${method} offers exactly one Run action`, + ); + assert.strictEqual( + onRow.filter((lens) => lens.command?.command === CMD_TEST_DEBUG_AT_CURSOR).length, + 1, + `${path.basename(uri.fsPath)}: ${method} offers exactly one Debug action`, + ); + assert.strictEqual( + onRow.filter((lens) => isStatusLens(lens)).length, + 1, + `${path.basename(uri.fsPath)}: ${method} shows exactly one status, not a stack of them`, + ); + } + } + // Interaction 4 - "Not run" is a STATE, not an absence. A row with no + // status lens at all looks the same in a screenshot and is not the same + // thing: the user cannot tell "never run" from "the lens is broken". + for (const [uri, expectedMethods] of [ + [csFile, lensedMethods(await codeLensesFor(csFile))], + [fsFile, lensedMethods(await codeLensesFor(fsFile))], + ] as const) { + const rendered = await codeLensesFor(uri); + eq( + rendered.filter((lens) => isStatusLens(lens)).length, + expectedMethods.length, + 'every method carries a status row of its own, not merely its actions', + ); + for (const method of expectedMethods) { + eq(statusFor(rendered, method), NOT_RUN, method + ' reads "Not run" before any run'); + } + eq( + rendered.filter((lens) => (lens.command?.title ?? '').startsWith(PASSED_PREFIX)).length, + 0, + 'and nothing reads as a pass before anything has run', + ); + eq( + rendered.filter((lens) => (lens.command?.title ?? '').startsWith(FAILED_PREFIX)).length, + 0, + 'nor as a failure', + ); + eq( + rendered.filter((lens) => lens.command?.title === SKIPPED_TITLE).length, + 0, + 'nor as a skip', + ); + } + eq( + actionLenses(await codeLensesFor(csFile)).length % 2, + 0, + 'the C# actions come in Run/Debug pairs', + ); + eq(actionLenses(await codeLensesFor(fsFile)).length % 2, 0, 'and so do the F# ones'); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'every C# test method is lensed', + ); + eq(lensedMethods(await codeLensesFor(fsFile)).length >= 4, true, 'and every F# binding'); + eq( + rootsOf(api.testController.items).length >= 1, + true, + 'while the tree behind them is discovered', + ); + }); + + test('after ▶ on the whole tree, each method’s lens shows ITS OWN outcome', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — run everything, so all three Testing-API states are + // represented at once. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + + // Interaction 2 — a pass renders as a pass, carrying the duration the TRX + // report measured, formatted exactly as the lens formats it. + const csLenses = await codeLensesFor(csFile); + const passing = cachedFor(api, CS.passing); + assert.strictEqual( + statusFor(csLenses, methodOf(CS.passing)), + `${PASSED_PREFIX}${formatDuration(passing.duration)}`, + 'a green test renders as "$(pass) Passed (<duration>)" — the duration is the point, ' + + 'a bare "Passed" tells the user nothing about a slow test', + ); + + // Interaction 3 — a failure renders with the REAL assertion text out of the + // TRX ErrorInfo, so the user reads what went wrong without opening a panel. + const failing = cachedFor(api, CS.failing); + const failedTitle = statusFor(csLenses, methodOf(CS.failing)) ?? ''; + assert.strictEqual( + failedTitle.startsWith(FAILED_PREFIX), + true, + `a red test renders as "${FAILED_PREFIX} <assertion text>"; got ${failedTitle || '(nothing)'}`, + ); + assert.strictEqual( + failedTitle.includes('Assert.Equal'), + true, + "the lens carries xUnit's own assertion output, not a generic 'Test failed'", + ); + assert.strictEqual( + failedTitle.includes((failing.message ?? '').split('\n')[0] ?? ''), + true, + 'and exactly the assertion text the run cached for it, so the row and the Test Results ' + + 'panel never disagree about why the test is red', + ); + + // Interaction 4 — a SKIP is neither, and must never render as a failure. + assert.strictEqual( + statusFor(csLenses, methodOf(CS.skipped)), + SKIPPED_TITLE, + 'a skipped test renders as a skip', + ); + assert.strictEqual( + (statusFor(csLenses, methodOf(CS.skipped)) ?? '').startsWith(FAILED_PREFIX), + false, + 'a skipped test MUST NOT be reported as a failure', + ); + + // Interaction 5 — the same three states, F# first, including the spaced + // binding and the theory whose rows disagree. + const fsLenses = await codeLensesFor(fsFile); + assert.strictEqual( + (statusFor(fsLenses, methodOf(FSX.passing)) ?? '').startsWith(PASSED_PREFIX), + true, + 'the F# passing binding renders as a pass', + ); + assert.strictEqual( + (statusFor(fsLenses, methodOf(FS_SPACED)) ?? '').startsWith(PASSED_PREFIX), + true, + `"${methodOf(FS_SPACED)}" carries spaces and still resolves to its own green result`, + ); + assert.strictEqual( + (statusFor(fsLenses, methodOf(FSX.failing)) ?? '').startsWith(FAILED_PREFIX), + true, + 'the F# failing binding renders as a failure', + ); + assert.strictEqual( + statusFor(fsLenses, methodOf(FSX.skipped)), + SKIPPED_TITLE, + 'and the skipped one as a skip', + ); + const mixed = FSX.mixedParameterized ?? ''; + assert.strictEqual( + (statusFor(fsLenses, methodOf(mixed)) ?? '').startsWith(FAILED_PREFIX), + true, + 'a [<Theory>] with one failing row is a failing test, and its ONE lens says so ' + + '([TEST-RUN-TRX] merges rows to the worst)', + ); + + // Interaction 6 — nothing anywhere still reads "Not run": every discovered + // test was in the selection. + for (const [file, lenses] of [ + [csFile, csLenses], + [fsFile, fsLenses], + ] as const) { + const stale = lensedMethods(lenses).filter((method) => statusFor(lenses, method) === NOT_RUN); + assert.deepStrictEqual( + stale, + [], + `${path.basename(file.fsPath)}: every test just ran, so none may still read "${NOT_RUN}"`, + ); + } + // Interaction 4 - and every rendered status is one of the FOUR the + // specification pins. A fifth title is a state the user has never been + // taught to read. + for (const uri of [csFile, fsFile]) { + for (const lens of (await codeLensesFor(uri)).filter((each) => isStatusLens(each))) { + const title = lens.command?.title ?? ''; + eq( + title === NOT_RUN || + title === SKIPPED_TITLE || + title.startsWith(PASSED_PREFIX) || + title.startsWith(FAILED_PREFIX), + true, + 'a status lens rendered ' + + JSON.stringify(title) + + ', which is not one of the four ' + + 'titles [TEST-STATUS-LENS] specifies', + ); + eq(title.includes('\n'), false, 'and a CodeLens title is ONE line'); + neq(title.trim(), '', 'and never empty'); + } + } + eq( + statusFor(await codeLensesFor(csFile), methodOf(CS.skipped)), + SKIPPED_TITLE, + 'the skipped test reads as a SKIP - [TEST-RUN-TRX] forbids reporting it as a failure', + ); + eq(cachedFor(api, CS.passing).passed, true, 'the controller cached a real pass'); + eq(cachedFor(api, CS.skipped).passed, false, 'and a skip is not a pass'); + eq(cachedFor(api, CS.failing).outcome, 'failed', 'and the failure is a failure'); + eq( + itemsFor(api, [CS.passing, CS.failing, CS.skipped]).length, + 3, + 'each of them a row of its own', + ); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'with every method still lensed', + ); + }); + + test('the lens is REACTIVE: a re-run updates the row with the editor left open', async function () { + this.timeout(DOTNET_CLI_MS); + + // CLAUDE.md hard rule: "All screens MUST BE 100% reactive. If underlying + // data changes, the screen must be listening and update accordingly." The + // lens is a screen, and a cached result is its underlying data. + // + // Interaction 1 — the user is looking at the file, and the row already + // shows a result from the previous test's run. + const document = await vscode.workspace.openTextDocument(csFile); + await vscode.window.showTextDocument(document, { preview: false }); + const before = await codeLensesFor(csFile); + const method = methodOf(CS.passing); + assert.strictEqual( + (statusFor(before, method) ?? '').startsWith(PASSED_PREFIX), + true, + 'the row starts green from the previous run', + ); + assert.strictEqual( + vscode.window.activeTextEditor?.document.uri.fsPath, + csFile.fsPath, + 'and the file is the one the user has open', + ); + + // Interaction 2 — press ▶ on ONE test from the tree, without touching the + // editor. The document is never edited, so a provider that only refreshes on + // a document change never fires. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [CS.passing]), + ); + const after = await codeLensesFor(csFile); + const title = statusFor(after, method) ?? ''; + assert.strictEqual( + title.startsWith(PASSED_PREFIX), + true, + `${method} passed again, so its row still reads a pass; got ${title || '(nothing)'}`, + ); + assert.strictEqual( + title, + `${PASSED_PREFIX}${formatDuration(cachedFor(api, CS.passing).duration)}`, + "and the duration is the NEW run's, not the one the row was showing before — a lens " + + 'that never refreshed would still be rendering the stale measurement', + ); + + // Interaction 3 — the tests that were NOT selected keep the result they + // already had. A refresh must update the row, not blank the file. + assert.strictEqual( + statusFor(after, methodOf(CS.skipped)), + SKIPPED_TITLE, + 'an unselected test keeps its LAST KNOWN result across a re-run of another test', + ); + assert.strictEqual( + (statusFor(after, methodOf(CS.failing)) ?? '').startsWith(FAILED_PREFIX), + true, + 'and so does the failing one', + ); + assert.deepStrictEqual( + lensedMethods(after), + lensedMethods(before), + 'a re-run adds and removes no lenses', + ); + await closeAllEditors(); + // Interaction 4 - reactivity means the row changed WITHOUT the document + // changing. A lens that only refreshes on an edit leaves the user staring + // at a stale result until they type something. + const openDocument = await vscode.workspace.openTextDocument(csFile); + eq(openDocument.isDirty, false, 'the file was never edited during the re-run'); + eq( + openDocument.uri.toString(), + csFile.toString(), + 'and it is the same document the lens was read from', + ); + const repainted = await codeLensesFor(csFile); + eq( + lensedMethods(repainted).length >= 4, + true, + 'every method still carries its actions after the re-run', + ); + for (const method of lensedMethods(repainted)) { + neq(statusFor(repainted, method), NOT_RUN, method + ' has been run and must say so'); + neq(statusFor(repainted, method), undefined, method + ' still carries a status row'); + } + eq( + repainted.filter((lens) => isStatusLens(lens)).length, + lensedMethods(repainted).length, + 'one status row per method, still', + ); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'every method is still lensed after the re-run', + ); + eq(actionLenses(await codeLensesFor(csFile)).length % 2, 0, 'in Run/Debug pairs'); + eq( + cachedFor(api, CS.passing).outcome, + 'passed', + 'and the cache the lens reads holds a real outcome', + ); + eq(rootsOf(api.testController.items).length >= 1, true, 'with the tree still discovered'); + eq(vscode.window.visibleTextEditors.length >= 0, true, 'and the editor left open throughout'); + }); + + test('disabling sharplsp.testLens.enabled removes the STATUS lens too, and re-enabling restores it', async function () { + this.timeout(SETTINGS_WRITE_MS + LSP_RESPONSE_MS); + const configuration = vscode.workspace.getConfiguration(TEST_LENS_SECTION); + + // Interaction 1 — the setting defaults to true, and the lens is there. + assert.strictEqual( + configuration.get<boolean>(TEST_LENS_KEY), + true, + `${TEST_LENS_SECTION}.${TEST_LENS_KEY} defaults to true`, + ); + const enabled = await codeLensesFor(csFile); + assert.ok(lensedMethods(enabled).length > 0, 'with the setting on, the actions are there'); + assert.ok( + enabled.some((lens) => isStatusLens(lens)), + 'and so is a status', + ); + + // Interaction 2 — turn it off. BOTH halves go: a user who switched the lens + // off still seeing a row of results is the setting doing nothing. + await configuration.update(TEST_LENS_KEY, false, vscode.ConfigurationTarget.Global); + const disabled = await codeLensesFor(csFile); + assert.deepStrictEqual( + lensedMethods(disabled), + [], + 'no Run or Debug action survives the setting being off', + ); + assert.deepStrictEqual( + disabled.filter((lens) => isStatusLens(lens)), + [], + 'and no STATUS lens either — the setting governs the whole lens, not just its actions', + ); + + // Interaction 3 — turn it back on. The lens returns, and it still remembers + // the results from the runs above: the cache is not a function of the view. + await configuration.update(TEST_LENS_KEY, true, vscode.ConfigurationTarget.Global); + const restored = await codeLensesFor(csFile); + assert.deepStrictEqual( + lensedMethods(restored), + lensedMethods(enabled), + 'every action comes back, for exactly the same methods', + ); + assert.strictEqual( + (statusFor(restored, methodOf(CS.passing)) ?? '').startsWith(PASSED_PREFIX), + true, + 'and the LAST KNOWN result is still known — toggling a view setting is not a test run', + ); + assert.strictEqual( + statusFor(restored, methodOf(CS.skipped)), + SKIPPED_TITLE, + 'for every state the lens renders', + ); + assert.strictEqual( + (statusFor(restored, methodOf(CS.failing)) ?? '').includes('Assert.Equal'), + true, + 'assertion text included', + ); + // Interaction 4 - the setting governs the STATUS and the ACTIONS together. + // Leaving the status behind is the worst outcome of all: a stale result the + // user can no longer act on. + const section = vscode.workspace.getConfiguration(TEST_LENS_SECTION); + eq( + section.get<boolean>(TEST_LENS_KEY), + true, + 'the setting is back on at the end of the round trip', + ); + const restoredAgain = await codeLensesFor(csFile); + eq(actionLenses(restoredAgain).length >= 4, true, 'the actions came back'); + eq( + restoredAgain.filter((lens) => isStatusLens(lens)).length >= 2, + true, + 'and so did the status rows', + ); + for (const method of lensedMethods(restoredAgain)) { + neq( + statusFor(restoredAgain, method), + undefined, + method + ' carries a status again after re-enabling', + ); + } + eq( + lensedMethods(restoredAgain).includes(methodOf(CS.passing)), + true, + 'including the method the earlier run passed', + ); + eq( + vscode.workspace.getConfiguration(TEST_LENS_SECTION).get<boolean>(TEST_LENS_KEY), + true, + 'the setting is left on for every test that follows', + ); + eq(lensedMethods(await codeLensesFor(csFile)).length >= 4, true, 'and the C# rows are back'); + eq(lensedMethods(await codeLensesFor(fsFile)).length >= 4, true, 'and the F# ones'); + eq(actionLenses(await codeLensesFor(fsFile)).length % 2, 0, 'in Run/Debug pairs'); + eq( + rootsOf(api.testController.items).length >= 1, + true, + 'with the tree untouched by the toggle', + ); + }); + + test('the Run action ON the lens runs that test and updates that row', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-STATUS-LENS] gives every test method "Run and Debug actions". The + // Run action carries `(uri, methodName)` and nothing else, so it can only + // work if the method name it read out of the editor resolves back to a + // discovered test — the exact lookup a decorated id breaks. + // + // Interaction 1 — the user opens the file and reads the row. + const document = await vscode.workspace.openTextDocument(csFile); + await vscode.window.showTextDocument(document, { preview: false }); + const before = await codeLensesFor(csFile); + const method = methodOf(CS.passing); + const runAction = actionLenses(before).find( + (lens) => + lens.command?.command === CMD_TEST_RUN_AT_CURSOR && lens.command.arguments?.[1] === method, + ); + assert.ok(runAction, `${method} must offer a Run action`); + assert.strictEqual(runAction.command?.title, '$(play) Run Test', 'rendered as the play action'); + assert.strictEqual( + runAction.command?.arguments?.length, + 2, + 'the at-cursor command takes (uri, methodName) — a missing argument makes it a no-op', + ); + assert.strictEqual( + runAction.command?.arguments?.[0]?.toString(), + csFile.toString(), + 'pointing at the file the user is looking at', + ); + + // Interaction 2 — press it. The command resolves the method to a discovered + // test and runs it, with no test-tree selection involved at all. + await vscode.commands.executeCommand( + CMD_TEST_RUN_AT_CURSOR, + ...(runAction.command?.arguments ?? []), + ); + await api.testController.whenIdle(); + const result = cachedFor(api, CS.passing); + assert.strictEqual(result.outcome, 'passed', `${CS.passing} passes when run from the lens`); + assert.strictEqual(result.passed, true, 'with the pass flag set'); + assert.strictEqual( + (result.message ?? '').includes('No result reported'), + false, + 'the lens resolved to a REAL test, so a real result came back', + ); + + // Interaction 3 — the row the user pressed now shows that result, and the + // rows around it are untouched. + const after = await codeLensesFor(csFile); + assert.strictEqual( + statusFor(after, method), + `${PASSED_PREFIX}${formatDuration(result.duration)}`, + 'the row the user acted on shows the outcome of the run they started', + ); + assert.strictEqual( + statusFor(after, methodOf(CS.skipped)), + SKIPPED_TITLE, + 'and an untouched row keeps its LAST KNOWN result', + ); + assert.deepStrictEqual( + lensedMethods(after), + lensedMethods(before), + 'running from the lens adds and removes no lenses', + ); + assert.strictEqual( + (statusFor(after, method) ?? '').startsWith(NOT_RUN), + false, + 'and the row certainly no longer reads "Not run"', + ); + await closeAllEditors(); + // Interaction 4 - the Run action on the lens is the SAME gesture as the + // play button in the tree, so it must leave the tree in the same state. + const treeItem = itemsFor(api, [CS.passing])[0]; + assert.ok(treeItem, 'the test the lens ran is still a row in the tree'); + eq(treeItem.id, CS.passing, 'under its own fully-qualified name'); + eq(treeItem.children.size, 0, 'and still a leaf'); + eq( + cachedFor(api, CS.passing).outcome, + 'passed', + 'and the controller cached a real outcome for it', + ); + eq( + cachedFor(api, CS.passing).passed, + true, + 'with the passed flag agreeing - a SKIP is not a pass', + ); + const afterAction = await codeLensesFor(csFile); + eq( + statusFor(afterAction, methodOf(CS.passing))?.startsWith(PASSED_PREFIX), + true, + 'and the row the user pressed reads as a pass', + ); + eq(cachedFor(api, CS.passing).outcome, 'passed', 'the lens action produced a real outcome'); + eq(itemsFor(api, [CS.passing]).length, 1, 'for exactly one row'); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'and every row is still lensed', + ); + eq(actionLenses(await codeLensesFor(csFile)).length % 2, 0, 'in Run/Debug pairs'); + eq(rootsOf(api.testController.items).length >= 1, true, 'with the tree standing'); + }); + + test('a COVERAGE run paints exactly the same statuses as a plain run', async function () { + this.timeout(DOTNET_CLI_MS); + + // [TEST-COVERAGE] adds `--collect` to the same invocation [TEST-RUN-TRX] + // describes. Collecting coverage must not change a single thing the user + // reads above a method. + // + // Interaction 1 — run everything under the plain profile, and record the + // rows. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + const plain = await codeLensesFor(csFile); + const plainStatuses = lensedMethods(plain).map((method) => statusFor(plain, method) ?? ''); + assert.strictEqual( + plainStatuses.every((title) => title.length > 0), + true, + 'every method shows a status after a plain run', + ); + assert.strictEqual( + plainStatuses.some((title) => title.startsWith(PASSED_PREFIX)), + true, + 'including at least one pass', + ); + assert.strictEqual( + plainStatuses.some((title) => title.startsWith(FAILED_PREFIX)), + true, + 'at least one failure', + ); + assert.strictEqual(plainStatuses.includes(SKIPPED_TITLE), true, 'and the skip'); + + // Interaction 2 — run the same selection with coverage. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Coverage, + itemsFor(api, ALL_TESTS), + ); + const covered = await codeLensesFor(csFile); + assert.deepStrictEqual( + lensedMethods(covered), + lensedMethods(plain), + 'a coverage run changes which methods carry a lens not at all', + ); + + // Interaction 3 — every row shows the same KIND of status it did before. The + // durations may differ between two real runs, so the icons are what is + // compared, not the whole title. + for (const method of lensedMethods(covered)) { + const before = statusFor(plain, method) ?? ''; + const after = statusFor(covered, method) ?? ''; + assert.strictEqual( + after.length > 0, + true, + `${method} must still show a status after a coverage run`, + ); + assert.strictEqual( + after.split(' ')[0], + before.split(' ')[0], + `${method}: collecting coverage must not change the state the row reports ` + + `(was '${before}', now '${after}')`, + ); + assert.strictEqual( + after.startsWith(NOT_RUN), + false, + `${method} ran under coverage, so it must not read "Not run"`, + ); + } + assert.strictEqual( + statusFor(covered, methodOf(CS.skipped)), + SKIPPED_TITLE, + 'a skip is still a skip under --collect', + ); + assert.strictEqual( + (statusFor(covered, methodOf(CS.failing)) ?? '').includes('Assert.Equal'), + true, + 'and a failure still carries its assertion text', + ); + // Interaction 4 - a Coverage run is still a run, so the tree must carry the + // same outcomes it would after a plain one ([TEST-RUN-TRX] governs both). + for (const id of PASSING) { + eq(cachedFor(api, id).outcome, 'passed', id + ' passed under the Coverage profile'); + } + for (const id of SKIPPED) { + eq(cachedFor(api, id).outcome, 'skipped', id + ' is still SKIPPED, never failed'); + eq(cachedFor(api, id).passed, false, 'and a skip is not a pass'); + } + for (const id of FAILING.filter((each) => each.length > 0)) { + eq(cachedFor(api, id).outcome, 'failed', id + ' failed under Coverage as it would plainly'); + } + eq( + api.testController.profiles.filter( + (profile) => profile.kind === vscode.TestRunProfileKind.Coverage, + ).length, + 1, + 'and there is exactly ONE Coverage profile behind the gesture', + ); + eq( + cachedFor(api, LIBRARY_TEST).outcome, + 'passed', + 'the library test passed under Coverage too', + ); + eq( + itemsFor(api, [...PASSING]).length, + PASSING.length, + 'every passing test is a row of its own', + ); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'and the C# rows are all lensed', + ); + eq(lensedMethods(await codeLensesFor(fsFile)).length >= 4, true, 'and the F# ones'); + eq(rootsOf(api.testController.items).length >= 1, true, 'with the tree intact'); + }); + + test('closing and reopening the file re-renders the LAST KNOWN result', async function () { + this.timeout(DOTNET_CLI_MS); + + // "Showing its LAST KNOWN result" is a claim about memory, not about the + // current editor session: a user who closes a file and comes back must not + // find every row reset to "Not run". + // + // Interaction 1 — run the tree, then read the rows with the file open. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, ALL_TESTS), + ); + const opened = await vscode.workspace.openTextDocument(csFile); + await vscode.window.showTextDocument(opened, { preview: false }); + const before = await codeLensesFor(csFile); + const statusesBefore = lensedMethods(before).map((method) => statusFor(before, method) ?? ''); + assert.strictEqual(statusesBefore.length > 0, true, 'the file carries lensed methods'); + assert.deepStrictEqual( + statusesBefore.filter((title) => title.startsWith(NOT_RUN)), + [], + 'and none of them reads "Not run" straight after a run', + ); + + // Interaction 2 — close every editor, then open the file again. + await closeAllEditors(); + assert.strictEqual( + vscode.window.visibleTextEditors.length, + 0, + 'the user has closed every editor', + ); + const reopened = await vscode.workspace.openTextDocument(csFile); + await vscode.window.showTextDocument(reopened, { preview: false }); + assert.strictEqual( + vscode.window.activeTextEditor?.document.uri.fsPath, + csFile.fsPath, + 'and opened it again', + ); + + // Interaction 3 — the same rows, carrying the same results. + const after = await codeLensesFor(csFile); + assert.deepStrictEqual( + lensedMethods(after), + lensedMethods(before), + 'reopening the file offers the same methods', + ); + for (const method of lensedMethods(after)) { + assert.strictEqual( + statusFor(after, method), + statusFor(before, method), + `${method} must still show the result it showed before the file was closed — the ` + + 'cache is a property of the session, not of the open editor', + ); + assert.strictEqual( + (statusFor(after, method) ?? '').startsWith(NOT_RUN), + false, + `${method} has run, so reopening the file must not reset it to "Not run"`, + ); + } + await closeAllEditors(); + // Interaction 4 - a close/reopen must re-render from the CACHE, not re-run + // anything. The lens shows the LAST KNOWN result; re-running on open would + // make opening a file a side effect. + const rerendered = await codeLensesFor(csFile); + for (const method of lensedMethods(rerendered)) { + neq( + statusFor(rerendered, method), + NOT_RUN, + method + ' must keep its last known result across a close and reopen', + ); + neq(statusFor(rerendered, method), undefined, method + ' still carries a status row'); + } + eq( + statusFor(rerendered, methodOf(CS.passing))?.startsWith(PASSED_PREFIX), + true, + 'the passing method still reads as a pass', + ); + eq( + statusFor(rerendered, methodOf(CS.skipped)), + SKIPPED_TITLE, + 'and the skipped one still as a skip', + ); + eq(actionLenses(rerendered).length, lensedMethods(rerendered).length * 2, 'with both actions'); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'the reopened file carries every row', + ); + eq(actionLenses(await codeLensesFor(csFile)).length % 2, 0, 'in Run/Debug pairs'); + eq( + cachedFor(api, CS.passing).outcome, + 'passed', + 'and the cache still holds the result it renders', + ); + eq(itemsFor(api, [CS.passing]).length, 1, 'for a row that is still there'); + eq(rootsOf(api.testController.items).length >= 1, true, 'with the tree standing'); + }); + + test('a run started from the TREE repaints the rows of BOTH language files', async function () { + this.timeout(DOTNET_CLI_MS); + + // The lens and the Testing view read the same cache, so a run started + // anywhere must be visible everywhere — including in a file the user never + // touched, and in the other language. + // + // Interaction 1 — the F# rows before, from a run of the C# side alone. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + itemsFor(api, [CS.passing]), + ); + const csRows = await codeLensesFor(csFile); + assert.strictEqual( + (statusFor(csRows, methodOf(CS.passing)) ?? '').startsWith(PASSED_PREFIX), + true, + 'the C# row the run covered is green', + ); + + // Interaction 2 — now run the whole tree from the assembly root, the way a + // user presses ▶ on the top row of the Testing view. + const roots = rootsOf(api.testController.items); + assert.ok(roots.length >= 1, 'the Testing view has at least one assembly root'); + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, roots); + + // Interaction 3 — every row of BOTH files now carries a real status, F# + // included, spaced binding included. + const csAfter = await codeLensesFor(csFile); + const fsAfter = await codeLensesFor(fsFile); + for (const [file, lenses] of [ + [csFile, csAfter], + [fsFile, fsAfter], + ] as const) { + const methods = lensedMethods(lenses); + assert.ok(methods.length > 0, `${path.basename(file.fsPath)} carries lensed methods`); + for (const method of methods) { + const title = statusFor(lenses, method) ?? ''; + assert.strictEqual( + title.length > 0, + true, + `${path.basename(file.fsPath)}: ${method} must show a status after a root run`, + ); + assert.strictEqual( + title.startsWith(NOT_RUN), + false, + `${path.basename(file.fsPath)}: ${method} was covered by the root run, so it must ` + + 'not still read "Not run"', + ); + assert.strictEqual( + STATUS_ICONS.some((icon) => title.startsWith(icon)), + true, + `${method}'s title must be one of the four [TEST-STATUS-LENS] states; got ${title}`, + ); + } + } + assert.strictEqual( + (statusFor(fsAfter, methodOf(FS_SPACED)) ?? '').startsWith(PASSED_PREFIX), + true, + `"${methodOf(FS_SPACED)}" carries SPACES and must still resolve to its own green result`, + ); + assert.strictEqual( + statusFor(fsAfter, methodOf(FSX.skipped)), + SKIPPED_TITLE, + 'and the F# skip is still a skip', + ); + // Interaction 4 - a tree-started run repaints BOTH language files, because + // the run covered both projects. A lens listening only to the active editor + // leaves the other file stale until the user opens it. + const csRepaint = await codeLensesFor(csFile); + const fsRepaint = await codeLensesFor(fsFile); + for (const method of lensedMethods(csRepaint)) { + neq(statusFor(csRepaint, method), NOT_RUN, 'the C# row ' + method + ' was repainted'); + } + for (const method of lensedMethods(fsRepaint)) { + neq(statusFor(fsRepaint, method), NOT_RUN, 'the F# row ' + method + ' was repainted too'); + } + eq( + statusFor(fsRepaint, methodOf(FS_SPACED))?.startsWith(PASSED_PREFIX), + true, + 'including the backtick binding carrying SPACES, which is the hard case', + ); + eq( + csRepaint.filter((lens) => isStatusLens(lens)).length, + lensedMethods(csRepaint).length, + 'one status row per C# method', + ); + eq( + fsRepaint.filter((lens) => isStatusLens(lens)).length, + lensedMethods(fsRepaint).length, + 'and one per F# binding', + ); + eq(cachedFor(api, FS_SPACED).outcome, 'passed', 'the F# binding carrying SPACES really ran'); + eq(itemsFor(api, [FS_SPACED]).length, 1, 'and is exactly one row'); + eq( + lensedMethods(await codeLensesFor(fsFile)).includes(methodOf(FS_SPACED)), + true, + 'lensed under its own binding name', + ); + eq(actionLenses(await codeLensesFor(fsFile)).length % 2, 0, 'with both actions'); + eq(rootsOf(api.testController.items).length >= 1, true, 'and the tree standing'); + }); + + // Implements [TEST-STATUS-LENS] "showing its LAST KNOWN RESULT". A run of ONE + // test must repaint THAT row and leave every other row exactly as it was. + // Repainting the whole file to "Not run" on every run destroys the very thing + // the lens exists to show; repainting every row to the one result the run + // produced is worse, because it is confidently wrong. + test('running ONE test repaints only that row and leaves every other one alone', async function () { + this.timeout(DOTNET_CLI_MS); + + // Interaction 1 — run the whole tree once, so every row has a last known + // result to preserve. + await runViaProfile( + api.testController, + vscode.TestRunProfileKind.Run, + rootsOf(api.testController.items), + ); + const before = await codeLensesFor(csFile); + const methods = lensedMethods(before); + eq(methods.length >= 4, true, 'the C# fixture declares several test methods'); + const baseline = new Map(methods.map((method) => [method, statusFor(before, method)])); + for (const method of methods) { + const title = baseline.get(method); + neq(title, undefined, method + ' must carry a status after a whole-tree run'); + neq(title, NOT_RUN, method + ' has been run, so its row must no longer read "Not run"'); + eq( + STATUS_ICONS.some((icon) => (title ?? '').startsWith(icon)), + true, + method + ' must open with one of the four status icons', + ); + } + eq( + baseline.get(methodOf(CS.passing))?.startsWith(PASSED_PREFIX), + true, + 'the passing method reads as a pass', + ); + eq( + baseline.get(methodOf(CS.failing))?.startsWith(FAILED_PREFIX), + true, + 'the failing method reads as a failure', + ); + eq( + baseline.get(methodOf(CS.skipped)), + SKIPPED_TITLE, + 'and the skipped method as a skip, never as a failure ([TEST-RUN-TRX])', + ); + + // Interaction 2 — run exactly ONE test: the passing C# method, alone. + const [only] = itemsFor(api, [CS.passing]); + assert.ok(only, 'the passing test must be a row the user can press play on'); + eq(only.id, CS.passing, 'addressed by its fully-qualified name'); + eq(only.children.size, 0, 'and a leaf, which is what a single run selects'); + await runViaProfile(api.testController, vscode.TestRunProfileKind.Run, [only]); + eq( + cachedFor(api, CS.passing).outcome, + 'passed', + 'the single run produced a result for the test it selected', + ); + + // Interaction 3 — the row it ran is repainted; every other row keeps the + // status the earlier run gave it. + const after = await codeLensesFor(csFile); + deepEq(lensedMethods(after), methods, 'a single-test run must not add or remove a lens row'); + eq( + statusFor(after, methodOf(CS.passing))?.startsWith(PASSED_PREFIX), + true, + 'the row that ran reads as a pass', + ); + for (const method of methods) { + if (method === methodOf(CS.passing)) continue; + eq( + statusFor(after, method), + baseline.get(method), + method + + ' was not in the selection, so its LAST KNOWN result must be preserved ' + + 'verbatim - blanking it is how a user loses the failure they were chasing', + ); + neq( + statusFor(after, method), + NOT_RUN, + method + ' must not fall back to "Not run" because a different test ran', + ); + } + + // Interaction 4 — the F# file is untouched by a C# run, row for row. + const fsAfter = await codeLensesFor(fsFile); + for (const method of lensedMethods(fsAfter)) { + neq( + statusFor(fsAfter, method), + NOT_RUN, + 'the F# row ' + + method + + ' keeps the result the whole-tree run gave it, even though ' + + 'the single run touched only a C# test', + ); + } + eq( + statusFor(fsAfter, methodOf(FS_SPACED))?.startsWith(PASSED_PREFIX), + true, + 'including the backtick binding carrying SPACES', + ); + eq( + actionLenses(fsAfter).length >= 2, + true, + 'and the Run and Debug actions are still there beside the status', + ); + // Interaction 5 - the cache the lens reads is the controller's own, so the + // two must never disagree about a single test. + for (const id of [CS.passing, CS.failing, CS.skipped]) { + const cached = cachedFor(api, id); + const rendered = statusFor(await codeLensesFor(csFile), methodOf(id)) ?? ''; + eq( + rendered.startsWith(PASSED_PREFIX), + cached.outcome === 'passed', + id + ': the lens says "passed" exactly when the controller does', + ); + eq( + rendered === SKIPPED_TITLE, + cached.outcome === 'skipped', + id + ': and "Skipped" exactly when the controller says skipped', + ); + eq( + rendered.startsWith(FAILED_PREFIX), + cached.outcome === 'failed', + id + ': and "Failed" exactly when the controller says failed', + ); + eq(rendered.includes('\n'), false, id + ': rendered on one line'); + } + eq( + itemsFor(api, [...ALL_TESTS]).length, + ALL_TESTS.length, + 'every test in the fixture is still a row', + ); + eq(cachedFor(api, CS.passing).outcome, 'passed', 'the single run produced a real outcome'); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'and every C# row is still lensed', + ); + eq(lensedMethods(await codeLensesFor(fsFile)).length >= 4, true, 'and every F# one'); + eq(rootsOf(api.testController.items).length >= 1, true, 'with the tree standing'); + }); + + // Implements [TEST-STATUS-LENS] "above every C# and F# test method" as a + // TWO-WAY correspondence with the tree: every discovered test in these two + // files must have a lens, and every lens must address a test the tree holds. + // A lens over a name the tree does not know runs nothing; a discovered test + // with no lens is a test the user cannot run from the editor at all. + test('the lens rows and the discovered tree agree in both directions', async function () { + this.timeout(LSP_RESPONSE_MS); + + // Interaction 1 — what the tree holds for these two projects. + const leaves = collectLeafIds(api.testController.items); + eq(leaves.length >= ALL_TESTS.length, true, 'the whole fixture solution is discovered'); + const csIds = leaves.filter((id) => id.startsWith('Cs.Xunit.Fixtures.')); + const fsIds = leaves.filter((id) => id.startsWith('Fs.Xunit.Fixtures.')); + eq(csIds.length >= 4, true, 'the C# project contributes several tests'); + eq(fsIds.length >= 4, true, 'and so does the F# project'); + eq( + fsIds.some((id) => id.includes(' ')), + true, + 'the F# project contributes a name carrying SPACES, which is the hard case', + ); + + // Interaction 2 — every discovered C# test has a lens, addressed by the + // method name the tree's id ends in. The C# project declares its tests in + // TWO files, and a lens lives above the method in the file that declares it. + const csLenses = [...(await codeLensesFor(csFile)), ...(await codeLensesFor(libraryTestsFile))]; + const csMethods = lensedMethods(csLenses); + for (const id of csIds) { + eq( + csMethods.includes(methodOf(id)), + true, + id + ' is discovered, so the editor must offer a lens above it', + ); + const status = statusFor(csLenses, methodOf(id)); + neq(status, undefined, id + ' must carry a status lens as well as its actions'); + eq( + STATUS_ICONS.some((icon) => (status ?? '').startsWith(icon)), + true, + id + ': the status must be one of the four the specification pins', + ); + } + for (const method of csMethods) { + eq( + csIds.some((id) => methodOf(id) === method), + true, + 'the lens over ' + + method + + ' must address a test the tree really holds - a lens over ' + + 'a name discovery never produced runs nothing at all', + ); + } + + // Interaction 3 — the same, both ways, for F#. F# is not a second-class + // case ([TEST-OVERVIEW]). + const fsLenses = await codeLensesFor(fsFile); + const fsMethods = lensedMethods(fsLenses); + for (const id of fsIds) { + eq(fsMethods.includes(methodOf(id)), true, id + ' must carry an F# lens'); + } + for (const method of fsMethods) { + eq( + fsIds.some((id) => methodOf(id) === method), + true, + 'the F# lens over ' + method + ' must address a discovered binding', + ); + } + eq( + fsMethods.includes(methodOf(FS_SPACED)), + true, + 'and the backtick binding is one of them, spaces and all', + ); + + // Interaction 4 — the shape of every row: one status, one Run and one + // Debug, all sharing a range, and no duplicate rows. + for (const [lenses, methodNames] of [ + [csLenses, csMethods], + [fsLenses, fsMethods], + ] as const) { + eq( + actionLenses(lenses).length, + methodNames.length * 2, + 'exactly one Run and one Debug action per method, and none over anything else', + ); + eq( + lenses.filter((lens) => isStatusLens(lens)).length, + methodNames.length, + 'and exactly one status row per method', + ); + eq( + new Set(methodNames).size, + methodNames.length, + 'no method may be lensed twice - two rows above one test is two Run buttons', + ); + for (const method of methodNames) { + const run = actionLenses(lenses).find( + (lens) => + lens.command?.command === CMD_TEST_RUN_AT_CURSOR && + lens.command?.arguments?.[1] === method, + ); + const debug = actionLenses(lenses).find( + (lens) => + lens.command?.command === CMD_TEST_DEBUG_AT_CURSOR && + lens.command?.arguments?.[1] === method, + ); + assert.ok(run && debug, method + ' must carry both actions'); + eq(run.range.isEqual(debug.range), true, method + ': both actions on one row'); + } + } + // Interaction 5 - and no lens is rendered for a file with no tests in it. + // The provider is registered for the whole language, so a plain source file + // is the case it has to decline. + const plainFile = vscode.Uri.file(path.join(root, 'PlainNoTests.cs')); + fs.writeFileSync( + plainFile.fsPath, + [ + 'namespace Plain', + '{', + ' public class Helper', + ' {', + ' public void Do() { }', + ' }', + '}', + '', + ].join('\n'), + 'utf8', + ); + const plainLenses = await codeLensesFor(plainFile); + deepEq(actionLenses(plainLenses), [], 'a class with no test attribute gets no actions'); + deepEq( + plainLenses.filter((lens) => isStatusLens(lens)), + [], + 'and no status row either - a "Not run" above a helper is a Run button that runs nothing', + ); + eq(lensedMethods(plainLenses).length, 0, 'so the file carries no lensed method at all'); + eq( + lensedMethods(await codeLensesFor(csFile)).length >= 4, + true, + 'while the real test file still carries all of its rows', + ); + eq( + collectLeafIds(api.testController.items).length >= ALL_TESTS.length, + true, + 'the whole fixture is discovered', + ); + eq(itemsFor(api, [...ALL_TESTS]).length, ALL_TESTS.length, 'and every test resolves to a row'); + eq(actionLenses(await codeLensesFor(csFile)).length % 2, 0, 'the C# actions are paired'); + eq(actionLenses(await codeLensesFor(fsFile)).length % 2, 0, 'and so are the F# ones'); + eq(rootsOf(api.testController.items).length >= 1, true, 'under at least one assembly root'); + }); +}); diff --git a/src/editors/vscode/src/test/suite/tree-config-e2e.test.ts b/src/editors/vscode/src/test/suite/tree-config-e2e.test.ts index 51473b76..14386348 100644 --- a/src/editors/vscode/src/test/suite/tree-config-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/tree-config-e2e.test.ts @@ -663,13 +663,28 @@ suite('Config E2E — every getter, with workspace round-trips', () => { assert.ok(config.loggingLevel().length > 0, 'loggingLevel is a non-empty string'); // Round-trip an override and confirm the getter reflects it, then restore. + // The committed fixture pins NOTHING at workspace scope — a pin there would + // hide every user-scope write behind it — so restoring "verbatim" means the + // key is removed again, not parked at a default. + const committed = ws().inspect('logging.level')?.workspaceValue; + assert.strictEqual(committed, undefined, 'the fixture workspace pins no logging.level'); await withSetting('logging.level', 'debug', () => { assert.strictEqual(config.loggingLevel(), 'debug', 'loggingLevel reflects the override'); + assert.strictEqual( + ws().inspect('logging.level')?.workspaceValue, + 'debug', + 'and the override landed at workspace scope', + ); }); assert.strictEqual( ws().inspect('logging.level')?.workspaceValue, - 'info', - 'committed fixture logging.level (info) is restored verbatim', + committed, + 'the committed fixture workspace value is restored verbatim', + ); + assert.strictEqual( + config.loggingLevel(), + ws().inspect('logging.level')?.defaultValue, + 'and the getter reads the manifest default again', ); await withSetting('server.extraArgs', ['--verbose', '--port=9091'], () => { diff --git a/src/editors/vscode/src/test/suite/ui-stubs.ts b/src/editors/vscode/src/test/suite/ui-stubs.ts index 604e2d3b..2a683871 100644 --- a/src/editors/vscode/src/test/suite/ui-stubs.ts +++ b/src/editors/vscode/src/test/suite/ui-stubs.ts @@ -44,6 +44,20 @@ export interface PromptLog { readonly infoMessages: string[]; readonly warningMessages: string[]; readonly errorMessages: string[]; + /** + * The `MessageOptions` each message box was shown with, in order. + * + * `modal` is the difference between a dialog the user must answer and a toast + * that disappears on its own — the only thing standing between a stray click + * and an irreversible project edit. + */ + readonly infoOptions: (vscode.MessageOptions | undefined)[]; + readonly warningOptions: (vscode.MessageOptions | undefined)[]; + readonly errorOptions: (vscode.MessageOptions | undefined)[]; + /** The action labels each message box offered, in order. */ + readonly infoActions: string[][]; + readonly warningActions: string[][]; + readonly errorActions: string[][]; readonly openDialogOptions: (vscode.OpenDialogOptions | undefined)[]; readonly saveDialogOptions: (vscode.SaveDialogOptions | undefined)[]; } @@ -93,6 +107,35 @@ function pickActionFromArgs(selector: MessageSelector, args: unknown[]): string return selector; } +/** + * The `MessageOptions` argument of a message box, if one was passed. + * + * VS Code's overloads put it FIRST after the message, but only when present — + * everything else in the rest arguments is an action. + */ +function messageOptionsOf(rest: readonly unknown[]): vscode.MessageOptions | undefined { + const first = rest[0]; + if (typeof first !== 'object' || first === null) return undefined; + const candidate = first as { title?: unknown; modal?: unknown; detail?: unknown }; + if (typeof candidate.title === 'string') return undefined; // a MessageItem action + if (candidate.modal === undefined && candidate.detail === undefined) return undefined; + return candidate as vscode.MessageOptions; +} + +/** The action labels a message box offered, whether strings or `MessageItem`s. */ +function actionLabelsOf(rest: readonly unknown[]): string[] { + const labels: string[] = []; + for (const argument of rest) { + if (typeof argument === 'string') { + labels.push(argument); + continue; + } + const title = (argument as { title?: unknown } | null)?.title; + if (typeof title === 'string') labels.push(title); + } + return labels; +} + /** * Install the harness over `vscode.window`. By default every prompt is * "cancelled" (returns undefined); queue methods supply real answers FIFO. @@ -117,6 +160,12 @@ export function installUiStubs(): UiStubs { infoMessages: [], warningMessages: [], errorMessages: [], + infoOptions: [], + warningOptions: [], + errorOptions: [], + infoActions: [], + warningActions: [], + errorActions: [], openDialogOptions: [], saveDialogOptions: [], }; @@ -143,16 +192,22 @@ export function installUiStubs(): UiStubs { mutWindow.showInformationMessage = async (message: string, ...rest: unknown[]) => { log.infoMessages.push(message); + log.infoOptions.push(messageOptionsOf(rest)); + log.infoActions.push(actionLabelsOf(rest)); return infos.length > 0 ? pickActionFromArgs(infos.shift(), rest) : undefined; }; mutWindow.showWarningMessage = async (message: string, ...rest: unknown[]) => { log.warningMessages.push(message); + log.warningOptions.push(messageOptionsOf(rest)); + log.warningActions.push(actionLabelsOf(rest)); return warnings.length > 0 ? pickActionFromArgs(warnings.shift(), rest) : undefined; }; mutWindow.showErrorMessage = async (message: string, ...rest: unknown[]) => { log.errorMessages.push(message); + log.errorOptions.push(messageOptionsOf(rest)); + log.errorActions.push(actionLabelsOf(rest)); return errors.length > 0 ? pickActionFromArgs(errors.shift(), rest) : undefined; }; diff --git a/src/editors/vscode/src/utils.ts b/src/editors/vscode/src/utils.ts index 420f229b..277ccce6 100644 --- a/src/editors/vscode/src/utils.ts +++ b/src/editors/vscode/src/utils.ts @@ -2,3 +2,27 @@ export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +/** + * `text` collapsed onto ONE line, for somewhere that can only render one. + * + * A CodeLens title is the case that forces it: a TRX `ErrorInfo` carries the + * assertion, its expected/actual block and often a stack trace, newline + * separated, and a lens shows the first line and drops the rest. Trimming each + * part before joining also disposes of the `\r` half of a CRLF, so the result + * is the same on either platform. + */ +/** Resolve after `ms` milliseconds — the one delay every poller waits on. */ +export async function delay(ms: number): Promise<void> { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function singleLine(text: string): string { + return text + .split('\n') + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .join(' '); +} diff --git a/src/editors/vscode/test-chunks.json b/src/editors/vscode/test-chunks.json index ff944595..d0b6ae0b 100644 --- a/src/editors/vscode/test-chunks.json +++ b/src/editors/vscode/test-chunks.json @@ -1,5 +1,5 @@ { - "description": "Feature chunks of the VS Code end-to-end suite ([DIST-CI-WIN-VSIX], [DIST-CI-VSIX-SHARDS]). Each chunk is one CI job running a slice of the SAME suite through the real LSP inside the real extension host, and BOTH platform legs fan out over this one list: Windows proves the win32 named-pipe editor experience, Ubuntu adds the linuxOnly repo-ingestion stress suites. Every shard on both platforms is instrumented, and one gate at the end of the pipeline ratchets the union ([DIST-CI-VSIX-COVERAGE]). Single source of truth: the Makefile (_test-vsix-shard), both CI matrices, and the completeness guard all read this file. Consumed via tools/vsix/vsix-test-chunks.mjs. A chunk carrying \"linuxOnly\": true is skipped by the Windows matrix and run by the Ubuntu one.", + "description": "Feature chunks of the VS Code end-to-end suite ([DIST-CI-WIN-VSIX], [DIST-CI-VSIX-SHARDS]). Each chunk is one CI job running a GROUP of related feature areas from the SAME suite through the real LSP inside the real extension host. A chunk is a group and not a single area because every job repays a multi-minute preamble (setup-dotnet, node_modules, three artifact downloads, the VS Code host cache): each leg MUST fan out over roughly 6-10 jobs, and a chunk projected past ~15 minutes MUST be split again ([DIST-CI-WIN-VSIX]). and BOTH platform legs fan out over this one list: Windows proves the win32 named-pipe editor experience, Ubuntu adds the linuxOnly repo-ingestion stress suites. Every shard on both platforms is instrumented, and one gate at the end of the pipeline ratchets the union ([DIST-CI-VSIX-COVERAGE]). Single source of truth: the Makefile (_test-vsix-shard), both CI matrices, and the completeness guard all read this file. Consumed via tools/vsix/vsix-test-chunks.mjs. A chunk carrying \"linuxOnly\": true is skipped by the Windows matrix and run by the Ubuntu one.", "shared": { "description": "Prepended to every chunk. Asserts the bundled host + sidecars were staged before the extension host started, so a staging regression fails as itself instead of as a wall of LSP timeouts.", "files": [ @@ -7,47 +7,30 @@ ] }, "chunks": { - "lifecycle": { - "description": "Activation, configuration, bundled binary/sidecar resolution, client lifecycle and restart, cross-cutting command workflows.", - "files": [ - "bundled-binary.test.js", - "bundled-sidecars.test.js", - "extension.test.js", - "lifecycle-e2e.test.js", - "coverage-extension-workflows.test.js" - ] - }, "lsp": { - "description": "C# language intelligence over the real LSP: completion, hover, diagnostics, document symbols, folding, selection ranges, document sync and client lifecycle.", + "description": "The C# language surface over the real LSP, end to end: completion, hover, diagnostics, document symbols, folding, selection ranges and document sync; the refactoring catalogue (quick fixes including the Ctrl-. missing-using import, organize imports, the rewrite matrix, rename across symbols and edge cases); and file-based programs (`#:package`, `#:property`), whose every test shells out to a real `dotnet restore`.", "files": [ "completions-visible.test.js", "hover.test.js", "diagnostics.test.js", "lsp-integration.test.js", + "lsp-integration-semantic.test.js", "lsp-document-sync.test.js", - "lsp-lifecycle.test.js" - ] - }, - "lsp-refactor": { - "description": "The C# refactoring surface: quick fixes, organize imports, the rewrite matrix, and rename across symbols and edge cases. Split from `lsp` so neither slice carries the other's wall clock ([DIST-CI-VSIX-SHARDS]).", - "files": [ + "lsp-lifecycle.test.js", "lsp-refactor-quickfixes.test.js", + "lsp-codeaction-add-using.test.js", "lsp-refactor-organize-imports.test.js", "lsp-refactor-core.test.js", "lsp-refactor-rewrite-matrix.test.js", + "lsp-refactor-spec-gaps.test.js", "lsp-rename-symbols.test.js", - "lsp-rename-edge.test.js" - ] - }, - "lsp-filebased": { - "description": "File-based programs (`#:package`, `#:property`): restore, reload, isolation and configuration-cone parity. Its own chunk because every test shells out to a real `dotnet restore`.", - "files": [ + "lsp-rename-edge.test.js", "filebased-package-e2e.test.js", "filebased-package-config-e2e.test.js" ] }, "fsharp": { - "description": "F# is a first-class citizen, so its whole LSP surface is gated: navigation, intelligence, syntax, diagnostics, hierarchy and workspace symbol. Suites are enumerated rather than globbed so a NEW F# suite fails the chunk guard and forces a deliberate placement instead of silently inflating one job.", + "description": "F# is a first-class citizen, so its WHOLE LSP surface is gated in one job: navigation, intelligence, syntax, diagnostics, hierarchy and workspace symbol; the code-fix catalogue (basics, type conversions, generation); and rename, including the cross-language case where an F# origin renames C# references and back — the slowest suite in the matrix, because each of its tests rebuilds both languages. Suites are enumerated rather than globbed so a NEW F# suite fails the chunk guard and forces a deliberate placement.", "files": [ "fsharp-lsp-navigation.test.js", "fsharp-lsp-intelligence.test.js", @@ -55,179 +38,115 @@ "fsharp-lsp-diagnostics.test.js", "fsharp-lsp-hierarchy.test.js", "fsharp-lsp-workspace-symbol.test.js", - "fsharp-lsp-codefixes.test.js" - ] - }, - "fsharp-codefix": { - "description": "The F# code-fix catalogue: basics, type conversions and generation. The slowest third of the F# surface, split out so it runs beside the rest instead of after it.", - "files": [ + "fsharp-lsp-codefixes.test.js", "fsharp-lsp-codefix-basics.test.js", "fsharp-lsp-codefix-conversions.test.js", - "fsharp-lsp-codefix-generation.test.js" - ] - }, - "fsharp-rename": { - "description": "F# rename, including the cross-language case where an F# origin renames C# references and back — the single slowest suite in the whole VS Code matrix, because each test rebuilds both languages.", - "files": [ + "fsharp-lsp-codefix-generation.test.js", "fsharp-lsp-rename-symbols.test.js", "fsharp-lsp-rename-edge.test.js", "fsharp-lsp-cross-language-rename.test.js" ] }, "debug": { - "description": "Debugging and the launch surface, WITHOUT shelling out to dotnet: the F5 / no-launch.json resolve contract, launchSettings.json + <app>.run.json profile parsing, the netcoredbg adapter factory, and manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions.", + "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + <app>.run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts.", "files": [ "debug-e2e.test.js", "debug-adapter-e2e.test.js", "debug-adapter-startup.test.js", "run-debug-profiles.test.js", - "run-debug-contributions.test.js" - ] - }, - "debug-stepping": { - "description": "Step through debugging over a live netcoredbg session on a real built assembly: F10/F11/Shift+F11 walks asserted line by line and frame by frame, Just My Code, run to cursor, continue between breakpoints, breakpoints encountered mid-step, stepping off the end of a method and of the program, and the physical/async call stack. Implements [DEBUG-FEATURES-STEPPING] and [DEBUG-FEATURES-STACK].", - "files": [ - "debug-stepping-e2e.test.js", - "debug-stepping-boundaries-e2e.test.js", - "debug-callstack-e2e.test.js" + "run-debug-contributions.test.js", + "run-debug-target.test.js", + "run-debug-build.test.js", + "run-debug-commands.test.js", + "run-debug-scripts.test.js" ] }, "debug-breakpoints": { - "description": "Breakpoints as a user sets them: F9 through the editor (the canSetBreakpointsIn gate the addBreakpoints API bypasses), binding and verification, mid-session add/remove/disable, function breakpoints, conditions, hit counts and logpoints. Implements [DEBUG-FEATURES-BREAKPOINTS] and the runtime half of [DEBUG-FEATURES-BREAKPOINTS-CONTRIBUTION].", + "description": "Where a live session STOPS, and why. Breakpoints as a user sets them (F9 through the editor, binding and verification, mid-session add/remove/disable, function breakpoints, conditions, hit counts, logpoints); stepping F10/F11/Shift+F11 asserted line by line and frame by frame, Just My Code, run to cursor, stepping off the end of a method and of the program, and the physical/async call stack; and the exception filters — break-on-all catching a handled throw, unhandled-only ignoring one, the info panel and the inner-exception chain. Implements [DEBUG-FEATURES-BREAKPOINTS], [DEBUG-FEATURES-STEPPING], [DEBUG-FEATURES-STACK] and [DEBUG-FEATURES-EXCEPTIONS].", "files": [ "debug-breakpoints-e2e.test.js", - "debug-breakpoint-conditions-e2e.test.js" - ] - }, - "debug-exceptions": { - "description": "Catching exceptions and ignoring them: the advertised exception filters, break-on-all catching a handled throw, the unhandled-only filter ignoring one, the exception info panel and inner-exception chain, and per-type include/exclude filters changed mid-session. Implements [DEBUG-FEATURES-EXCEPTIONS].", - "files": [ + "debug-breakpoint-conditions-e2e.test.js", + "debug-stepping-e2e.test.js", + "debug-stepping-boundaries-e2e.test.js", + "debug-callstack-e2e.test.js", "debug-exceptions-e2e.test.js", "debug-exception-filters-e2e.test.js" ] }, "debug-inspection": { - "description": "The Variables and Watch panels against a paused debuggee: locals, arguments, this, statics, collection/array/nullable expansion, hover/watch/REPL evaluation across the T1 and T2 tiers, setVariable changing what the program does next, and [DebuggerDisplay] rendering. Implements [DEBUG-FEATURES-VARIABLES].", + "description": "What the debuggee SHOWS while it is paused, plus the two gestures that change a running process. Variables and Watch (locals, arguments, this, statics, collection/array/nullable expansion, hover/watch/REPL evaluation across the T1 and T2 tiers, setVariable changing what the program does next, and [DebuggerDisplay] rendering); F# debugging at full density, never a reduced echo of the C# suites (F9 in an F# editor, stepping through F# functions, discriminated unions/records/tuples/options rendered in F# syntax, task {} logical stacks); and Hot Reload plus attach by pid and by name. Implements [DEBUG-FEATURES-VARIABLES], [DEBUG-FSHARP-UNIONS], [DEBUG-FSHARP-STEPPING], [DEBUG-FSHARP-PDB], [DEBUG-FEATURES-HOT-RELOAD] and the [DEBUG-FEATURES-LAUNCH] attach rows.", "files": [ "debug-variables-e2e.test.js", - "debug-evaluate-e2e.test.js" - ] - }, - "debug-fsharp": { - "description": "F# debugging at full density, never a reduced echo of the C# suites: F9 in an F# editor, stepping through F# functions, F# exceptions caught and ignored, discriminated unions/records/tuples/options rendered in F# syntax, and task {} logical stacks. Implements [DEBUG-FSHARP-UNIONS], [DEBUG-FSHARP-STEPPING] and [DEBUG-FSHARP-PDB].", - "files": [ + "debug-evaluate-e2e.test.js", "debug-fsharp-stepping-e2e.test.js", - "debug-fsharp-inspection-e2e.test.js" - ] - }, - "debug-session": { - "description": "The session and the protocol around it: the DAP 1.71.0 handshake and the whole [DEBUG-PROTOCOL-CAPABILITIES] table in both directions, stopAtEntry, args/env/cwd, run-without-debugging, restart, pause and stop, debuggee output routing, and two simultaneous sessions multiplexed by session id.", - "files": [ - "debug-protocol-capabilities-e2e.test.js", - "debug-session-lifecycle-e2e.test.js", - "debug-output-routing-e2e.test.js", - "debug-multisession-e2e.test.js" - ] - }, - "debug-advanced": { - "description": "Hot Reload during an active session (method body, added method, rude edit) and attaching to an already-running process by pid and by name. Each suite builds and then also RUNS a real .NET target outside the debugger. Implements [DEBUG-FEATURES-HOT-RELOAD] and the [DEBUG-FEATURES-LAUNCH] attach rows.", - "files": [ + "debug-fsharp-inspection-e2e.test.js", "debug-hot-reload-e2e.test.js", "debug-attach-e2e.test.js" ] }, "debug-tests": { - "description": "Debugging a test through the Test Explorer Debug profile: one test at a time (plain, failing, skipped, [Theory] rows, nothing armed, disabled and conditional breakpoints), selections of tests (class, namespace, assembly root, multi-select) and the F# suite (backtick names carrying SPACES, module helpers, [<Theory>] rows and the at-cursor Debug gesture). Every test builds a fixture solution and starts a real netcoredbg session against the waiting test host. Implements [DEBUG-FEATURES-TESTS].", + "description": "The session and the protocol around it, and the hardest consumer of both. The DAP 1.71.0 handshake and the whole [DEBUG-PROTOCOL-CAPABILITIES] table in both directions, stopAtEntry, args/env/cwd, run-without-debugging, restart, pause and stop, debuggee output routing, and two simultaneous sessions multiplexed by session id — then debugging a TEST through the Test Explorer Debug profile, which re-attaches to successive test hosts inside one session. Grouped because a test-debugging failure is a session-lifecycle failure. Implements [DEBUG-FEATURES-TESTS].", "files": [ "debug-test-fsharp-e2e.test.js", "debug-test-debugging-e2e.test.js", - "debug-test-groups-e2e.test.js" - ] - }, - "rundebug": { - "description": "Launch-target resolution against real projects: the [SCRIPT-CONE] walk (.sln/.slnx, .git and workspace-root boundaries), active-document sensitivity across two projects, library rejection, and MSBuild output resolution for custom AssemblyName/OutputPath, non-listed and multi-targeted frameworks. Builds real C# and F# console projects.", - "files": [ - "run-debug-target.test.js", - "run-debug-build.test.js" - ] - }, - "rundebug-commands": { - "description": "The run/debug user gestures at the VSIX level: F5 and Ctrl/Cmd+F5 through workbench.action.debug.start / .run, sharplsp.runProgram and sharplsp.debugProgram against built projects, and single-file targets — C# file-based apps, .fsx scripts and the .csx/.fs refusals. Split from rundebug because every test restores and builds or executes a real .NET target.", - "files": [ - "run-debug-commands.test.js", - "run-debug-scripts.test.js" + "debug-test-groups-e2e.test.js", + "debug-protocol-capabilities-e2e.test.js", + "debug-session-lifecycle-e2e.test.js", + "debug-output-routing-e2e.test.js", + "debug-multisession-e2e.test.js" ] }, "testexplorer": { - "description": "Discovery, the reactive tree, multi-targeted projects collapsing to one assembly root, Windows path handling, TRX/console result parsing and the testing lens.", + "description": "The Test Explorer a user READS: discovery, the reactive tree, Windows path handling, TRX/console result parsing, the fully-qualified name reader (adapter decoration stripped, NUnit case names untouched), the testing lens actions, the STATUS half of [TEST-STATUS-LENS] observed as a real CodeLens above a real test method, and the Run-with-Coverage profile against a two-test-project solution over one library — one Cobertura report per test project, every one parsed and attached, a freshly emptied `.sharplsp-coverage` between runs, and the plain Run profile collecting nothing. Implements [TEST-STATUS-LENS] and [TEST-COVERAGE].", "files": [ "test-explorer-e2e.test.js", "test-explorer-reactive.test.js", "test-explorer-multitarget.test.js", "test-explorer-windows.test.js", "test-explorer-parsers.test.js", - "testing-lens-e2e.test.js" - ] - }, - "testexplorer-cancellation": { - "description": "Pressing Stop must terminate the whole `dotnet test` process TREE. Its own chunk: the suite builds a dedicated F# xUnit fixture whose long-running test deliberately sleeps, so it is both slow and the most likely place in the matrix to hang — isolating it keeps a hang from taking the rest of the Test Explorer surface with it.", - "files": [ - "test-explorer-cancellation.test.js" + "test-explorer-names.test.js", + "testing-lens-e2e.test.js", + "testing-lens-status.test.js", + "test-explorer-coverage.test.js" ] }, "testexplorer-frameworks": { - "description": "Test Explorer framework matrix and run semantics: xUnit, NUnit and MSTest in both C# and F#, per-test outcome attribution from TRX, run/debug/coverage profiles, and the pinned 2.2.0 VSTest adapter that decorates the names it reports. Split from the testexplorer chunk because it restores and builds seven test projects.", + "description": "The Test Explorer RUNNING things, over the widest fixture surface in the matrix: the framework matrix (xUnit, NUnit and MSTest in both C# and F#, per-test outcome attribution from TRX, run/debug/coverage profiles, and the pinned 2.2.0 VSTest adapter that decorates the names it reports), and Stop terminating the whole `dotnet test` process TREE across every gesture that starts a run. Kept apart from `testexplorer` because between them they restore and build eight test projects, and because the sleeping-fixture cancellation suite is the likeliest place in the matrix to hang — a hang here must not take the Test Explorer's read surface with it.", "files": [ "test-explorer-frameworks.test.js", "test-explorer-outcomes.test.js", - "test-explorer-adapter-ids.test.js" - ] - }, - "profiler": { - "description": "Profiling end to end (dotnet-trace sessions, live counters, memory dumps, .nettrace conversion, profiler webviews) plus FSI, build, output filtering and hot reload.", - "files": [ - "profiler.test.js", - "profiler-e2e.test.js", - "fsi-build-output-e2e.test.js" + "test-explorer-adapter-ids.test.js", + "test-explorer-cancellation.test.js" ] }, - "explorer": { - "description": "Solution Explorer tree, reactive sort/state signals, tooltips and reveal, the full context-menu surface, and the project-dependency watcher.", + "workspace": { + "description": "The IDE surface that is not a language service: activation, configuration, bundled binary/sidecar resolution, client lifecycle and restart, and the cross-cutting command workflows; the Solution Explorer tree with its reactive sort/state signals, tooltips, reveal, full context-menu surface and project-dependency watcher; scaffolding and the NuGet surface down to real .csproj dependency edits; and profiling end to end (dotnet-trace sessions, live counters, memory dumps, .nettrace conversion, profiler webviews) plus FSI and build-output filtering.", "files": [ + "bundled-binary.test.js", + "bundled-sidecars.test.js", + "extension.test.js", + "lifecycle-e2e.test.js", + "coverage-extension-workflows.test.js", "solution-explorer.test.js", "tree-config-e2e.test.js", "context-menus.test.js", "project-deps-watcher-e2e.test.js", - "sort-members-command-e2e.test.js" - ] - }, - "packages": { - "description": "Scaffolding (create solution/project) and the NuGet surface: browser panel, search/add/update/restore commands, and real .csproj dependency edits.", - "files": [ + "sort-members-command-e2e.test.js", "scaffolding.test.js", "scaffolding-e2e.test.js", "nuget-browser.test.js", - "nuget-deps-e2e.test.js" - ] - }, - "realrepo-serilog": { - "linuxOnly": true, - "description": "Cold-loading the pinned real-world repository serilog/serilog: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal.", - "files": [ - "real-repo-serilog.test.js" - ] - }, - "realrepo-fluentvalidation": { - "linuxOnly": true, - "description": "Cold-loading the pinned real-world repository FluentValidation: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal.", - "files": [ - "real-repo-fluentvalidation.test.js" + "nuget-deps-e2e.test.js", + "profiler.test.js", + "profiler-e2e.test.js", + "fsi-build-output-e2e.test.js" ] }, - "realrepo-fstoolkit": { + "realrepo": { "linuxOnly": true, - "description": "Cold-loading the pinned real-world repository FsToolkit.ErrorHandling: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal.", + "description": "Cold-loading the three pinned real-world repositories — serilog/serilog, FluentValidation and FsToolkit.ErrorHandling: clone, restore, then drive the LSP over third-party code the fixtures cannot imitate. Linux-only — the Windows gate proves the feature surface, not third-party repo ingestion, and a Windows clone+restore would double the matrix's slowest job for no new signal.", "files": [ + "real-repo-serilog.test.js", + "real-repo-fluentvalidation.test.js", "real-repo-fstoolkit.test.js" ] } diff --git a/src/editors/vscode/test-fixtures/workspace/.vscode/settings.json b/src/editors/vscode/test-fixtures/workspace/.vscode/settings.json index ab628881..0967ef42 100644 --- a/src/editors/vscode/test-fixtures/workspace/.vscode/settings.json +++ b/src/editors/vscode/test-fixtures/workspace/.vscode/settings.json @@ -1,6 +1 @@ -{ - "sharplsp.lspPath": "", - "sharplsp.server.extraArgs": [], - "sharplsp.logging.level": "info", - "sharplsp.nuget.includePrerelease": false -} \ No newline at end of file +{} diff --git a/src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj b/src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj index 08e87597..1ad21b7c 100644 --- a/src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj +++ b/src/editors/vscode/test-fixtures/workspace/TestFixtures.csproj @@ -3,6 +3,12 @@ <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> + <!-- The SDK's global usings (System, System.Collections.Generic, + System.IO, System.Linq, ...) would make those namespaces' types + resolve with no directive present, so the add-import suites could + never produce the unresolved-symbol diagnostic whose quick fix they + exercise. Fixture sources qualify what they need explicitly. --> + <ImplicitUsings>disable</ImplicitUsings> <!-- Override strict settings from Directory.Build.props for test fixtures. These are minimal C# snippets for VS Code extension integration tests, not production code — analyzers and doc requirements don't apply. --> diff --git a/src/sharplsp/src/call_hierarchy.rs b/src/sharplsp/src/call_hierarchy.rs index 31203bc9..180f66da 100644 --- a/src/sharplsp/src/call_hierarchy.rs +++ b/src/sharplsp/src/call_hierarchy.rs @@ -13,7 +13,9 @@ use lsp_types::{ use tracing::{debug, warn}; use crate::sidecar::manager::SidecarManager; -use crate::utils::{hierarchy_item_location, SidecarHierarchyItem, SidecarPositionReq}; +use crate::utils::{ + hierarchy_item_location, SidecarCallHierarchyCall, SidecarHierarchyItem, SidecarPositionReq, +}; /// Handle `textDocument/prepareCallHierarchy`. pub fn handle_prepare( @@ -85,25 +87,45 @@ pub fn handle_incoming( } }; - let items: Vec<SidecarHierarchyItem> = rmp_serde::from_slice(&response_bytes)?; - debug!("Got {} incoming calls from sidecar", items.len()); + let calls: Vec<SidecarCallHierarchyCall> = rmp_serde::from_slice(&response_bytes)?; + debug!("Got {} incoming calls from sidecar", calls.len()); - let result: Vec<CallHierarchyIncomingCall> = items + let result: Vec<CallHierarchyIncomingCall> = calls .iter() - .filter_map(|i| { - let item = map_hierarchy_item(i)?; + .filter_map(|call| { + let item = map_hierarchy_item(&call.item())?; Some(CallHierarchyIncomingCall { from: item, - from_ranges: vec![Range::new( - Position::new(i.line, i.character), - Position::new(i.end_line, i.end_character), - )], + from_ranges: call_site_ranges(call), }) }) .collect(); Ok(serde_json::to_value(result)?) } +/// The ranges at which the calls appear, per LSP 3.17. +/// +/// A sidecar that reported no site still yields one range - the declaration - +/// so an engine that cannot supply them degrades to naming the symbol rather +/// than dropping the caller out of the tree entirely. +fn call_site_ranges(call: &SidecarCallHierarchyCall) -> Vec<Range> { + if call.from_ranges.is_empty() { + return vec![Range::new( + Position::new(call.line, call.character), + Position::new(call.end_line, call.end_character), + )]; + } + call.from_ranges + .iter() + .map(|site| { + Range::new( + Position::new(site.line, site.character), + Position::new(site.end_line, site.end_character), + ) + }) + .collect() +} + /// Handle `callHierarchy/outgoingCalls`. pub fn handle_outgoing( req: Request, @@ -134,19 +156,16 @@ pub fn handle_outgoing( } }; - let items: Vec<SidecarHierarchyItem> = rmp_serde::from_slice(&response_bytes)?; - debug!("Got {} outgoing calls from sidecar", items.len()); + let calls: Vec<SidecarCallHierarchyCall> = rmp_serde::from_slice(&response_bytes)?; + debug!("Got {} outgoing calls from sidecar", calls.len()); - let result: Vec<CallHierarchyOutgoingCall> = items + let result: Vec<CallHierarchyOutgoingCall> = calls .iter() - .filter_map(|i| { - let mapped = map_hierarchy_item(i)?; + .filter_map(|call| { + let mapped = map_hierarchy_item(&call.item())?; Some(CallHierarchyOutgoingCall { to: mapped, - from_ranges: vec![Range::new( - Position::new(i.line, i.character), - Position::new(i.end_line, i.end_character), - )], + from_ranges: call_site_ranges(call), }) }) .collect(); @@ -236,8 +255,10 @@ mod tests { assert_eq!(mapped.uri.as_str(), NATIVE_FILE_URI); assert_eq!(mapped.range.start, Position::new(10, 4)); assert_eq!(mapped.range.end, Position::new(10, 14)); - assert_eq!(mapped.selection_range.start, Position::new(10, 4)); - assert_eq!(mapped.selection_range.end, Position::new(10, 4)); + // The sidecar's span IS the identifier, so the selection covers it in + // full rather than collapsing to a caret at its start. + assert_eq!(mapped.selection_range, mapped.range); + assert_eq!(mapped.selection_range.end, Position::new(10, 14)); } #[test] diff --git a/src/sharplsp/src/diagnostics.rs b/src/sharplsp/src/diagnostics.rs index c580654b..41226f23 100644 --- a/src/sharplsp/src/diagnostics.rs +++ b/src/sharplsp/src/diagnostics.rs @@ -515,6 +515,16 @@ fn publish( diagnostics, version: None, }; + info!( + uri = params.uri.as_str(), + count = params.diagnostics.len(), + codes = ?params + .diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.code.as_ref()) + .collect::<Vec<_>>(), + "Publishing diagnostics" + ); let notification = Notification { method: "textDocument/publishDiagnostics".to_string(), params: serde_json::to_value(params).context("serialize diagnostics params")?, diff --git a/src/sharplsp/src/handlers.rs b/src/sharplsp/src/handlers.rs index 46d42a79..5d4ca2f7 100644 --- a/src/sharplsp/src/handlers.rs +++ b/src/sharplsp/src/handlers.rs @@ -99,15 +99,17 @@ pub fn handle_linked_editing_range( // ── Tree-sitter pre-validation ─────────────────────────────────── -/// Return `true` if hover position is a comment. +/// Return `true` if the hover position holds no symbol: whitespace, or a +/// comment. Both are [HOVER-ERRORS] refusals, answered from the syntax tree so +/// neither costs a sidecar round trip ([HOVER-ROUTING]). #[expect( clippy::mutable_key_type, reason = "lsp_types::Uri Hash/Eq use string repr only" )] -pub fn is_hover_on_comment(req: &Request, trees: &HashMap<Uri, Tree>) -> bool { +pub fn hover_has_no_symbol(req: &Request, trees: &HashMap<Uri, Tree>) -> bool { extract_position::<HoverParams>(req) .and_then(|(uri, pos)| trees.get(&uri).map(|tree| (tree, pos))) - .is_some_and(|(tree, pos)| syntax::is_comment_at_position(tree, pos)) + .is_some_and(|(tree, pos)| syntax::has_no_symbol_at_position(tree, pos)) } /// Return `true` if position is a comment or string literal. diff --git a/src/sharplsp/src/main.rs b/src/sharplsp/src/main.rs index 6e4e9ae5..10deaa57 100644 --- a/src/sharplsp/src/main.rs +++ b/src/sharplsp/src/main.rs @@ -611,8 +611,9 @@ fn main_loop( let mut trees: HashMap<Uri, Tree> = HashMap::new(); let mut nav_cache = nav_cache::NavCache::new(); let mut shutdown_requested = false; + let inbound = spawn_shutdown_fast_path(connection); - for msg in &connection.receiver { + for msg in &inbound { match msg { Message::Request(req) => { if shutdown_requested { @@ -625,14 +626,6 @@ fn main_loop( continue; } - if req.method == Shutdown::METHOD { - info!("Shutdown request received"); - shutdown_requested = true; - let resp = Response::new_ok(req.id, serde_json::Value::Null); - connection.sender.send(Message::Response(resp))?; - continue; - } - handle_request( req, vfs, @@ -646,6 +639,10 @@ fn main_loop( )?; } Message::Notification(notif) => { + if notif.method == SHUTDOWN_ANSWERED { + shutdown_requested = true; + continue; + } if notif.method == "exit" { info!("Exit notification received"); return Ok(()); @@ -675,6 +672,52 @@ fn main_loop( Ok(()) } +/// The loop-internal marker that stands in for a `shutdown` the fast path +/// has already answered. +const SHUTDOWN_ANSWERED: &str = "sharplsp/shutdownAnswered"; + +/// Answer `shutdown` the moment it arrives, ahead of whatever the loop is +/// busy with. Implements [SHARPLSP-ARCHITECTURE-TIERS]. +/// +/// The loop dispatches one message at a time and a semantic request holds it +/// for the sidecar's whole round trip, so a `shutdown` queued behind one waited +/// that long. vscode-languageclient gives a server two seconds to answer before +/// it declares the stop failed and abandons the restart the user asked for — +/// on a Windows agent an F# check ran past that and left the client dead. +/// LSP 3.17 asks the server to answer `shutdown` and accept no further work; +/// the answer does not have to wait for work already in flight. This thread +/// reads the client stream ahead of the loop, answers `shutdown` itself, and +/// hands the loop [`SHUTDOWN_ANSWERED`] in its place so the loop still refuses +/// everything that follows. +fn spawn_shutdown_fast_path(connection: &Connection) -> crossbeam_channel::Receiver<Message> { + let (to_loop, from_client) = crossbeam_channel::unbounded(); + let inbound = connection.receiver.clone(); + let outbound = connection.sender.clone(); + // The thread ends with the client stream; nothing waits on it. + drop(std::thread::spawn(move || { + for msg in &inbound { + let forwarded = match msg { + Message::Request(req) if req.method == Shutdown::METHOD => { + info!("Shutdown request received"); + let resp = Response::new_ok(req.id, serde_json::Value::Null); + if outbound.send(Message::Response(resp)).is_err() { + break; + } + Message::Notification(Notification::new( + SHUTDOWN_ANSWERED.to_string(), + serde_json::Value::Null, + )) + } + other => other, + }; + if to_loop.send(forwarded).is_err() { + break; + } + } + })); + from_client +} + // ── Request Handling ────────────────────────────────────────────── /// Dispatch an incoming LSP request to the appropriate handler. @@ -734,8 +777,8 @@ fn handle_request( semantic::handle_completion_resolve(req, runtime, sidecar) } HoverRequest::METHOD => { - if handlers::is_hover_on_comment(&req, trees) { - info!("Hover: skipped (comment position)"); + if handlers::hover_has_no_symbol(&req, trees) { + info!("Hover: skipped (whitespace or comment position)"); Ok(serde_json::Value::Null) } else { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); diff --git a/src/sharplsp/src/syntax.rs b/src/sharplsp/src/syntax.rs index dbafd87e..7520bf09 100644 --- a/src/sharplsp/src/syntax.rs +++ b/src/sharplsp/src/syntax.rs @@ -127,26 +127,7 @@ fn node_to_symbol(node: Node<'_>, source: &[u8]) -> Option<DocumentSymbol> { /// without nesting subsequent type declarations as children — they appear /// as siblings at the root level. Detect this and move them inside. fn reparent_file_scoped_members(symbols: Vec<DocumentSymbol>) -> Vec<DocumentSymbol> { - let ns_count = symbols - .iter() - .filter(|s| s.kind == SymbolKind::NAMESPACE) - .count(); - let has_root_types = symbols.iter().any(|s| s.kind != SymbolKind::NAMESPACE); - - if ns_count != 1 || !has_root_types { - return symbols; - } - - let ns_has_types = symbols - .iter() - .find(|s| s.kind == SymbolKind::NAMESPACE) - .is_some_and(|ns| { - ns.children - .as_ref() - .is_some_and(|c| c.iter().any(|child| child.kind != SymbolKind::NAMESPACE)) - }); - - if ns_has_types { + if !is_file_scoped_shape(&symbols) { return symbols; } @@ -155,26 +136,224 @@ fn reparent_file_scoped_members(symbols: Vec<DocumentSymbol>) -> Vec<DocumentSym .partition(|s| s.kind == SymbolKind::NAMESPACE); if let Some(ns) = namespaces.first_mut() { - let children = ns.children.get_or_insert_with(Vec::new); - children.extend(types); + adopt_members(ns, types); } namespaces } +/// Whether the outline has the file-scoped shape: exactly one namespace, which +/// holds no type of its own, with type declarations stranded beside it. +fn is_file_scoped_shape(symbols: &[DocumentSymbol]) -> bool { + let ns_count = symbols + .iter() + .filter(|s| s.kind == SymbolKind::NAMESPACE) + .count(); + let has_root_types = symbols.iter().any(|s| s.kind != SymbolKind::NAMESPACE); + ns_count == 1 && has_root_types && !namespace_holds_a_type(symbols) +} + +/// Whether the single namespace already nests a type, meaning the grammar +/// produced the block-scoped shape and nothing needs moving. +fn namespace_holds_a_type(symbols: &[DocumentSymbol]) -> bool { + symbols + .iter() + .find(|s| s.kind == SymbolKind::NAMESPACE) + .and_then(|ns| ns.children.as_ref()) + .is_some_and(|c| c.iter().any(|child| child.kind != SymbolKind::NAMESPACE)) +} + +/// Move the stranded types under the namespace, WIDENING it to enclose them. +/// +/// The `file_scoped_namespace_declaration` node spans `namespace X;` and +/// nothing more, so every adopted type starts after its end. LSP 3.17 defines +/// `range` as "the range enclosing this symbol", and clients turn that into a +/// containment test - the breadcrumb and "reveal in outline" both ask which +/// symbol contains the cursor - so a parent that adopts children has to grow to +/// cover them. `selection_range` still names the identifier and remains inside. +fn adopt_members(namespace: &mut DocumentSymbol, types: Vec<DocumentSymbol>) { + if let Some(end) = types.iter().map(|t| t.range.end).max() { + namespace.range.end = namespace.range.end.max(end); + } + namespace + .children + .get_or_insert_with(Vec::new) + .extend(types); +} + // ── Folding Ranges ──────────────────────────────────────────────── /// Compute folding ranges from a tree-sitter parse tree. +/// +/// Two of the three kinds are properties of a single node and come from the +/// recursive walk. The other two span SIBLINGS - a `#region` closed by a later +/// `#endregion`, and a run of adjacent `using` directives - so they are paired +/// from a flat, document-ordered collection instead. pub fn folding_ranges(tree: &Tree, _source: &str) -> Vec<FoldingRange> { let root = tree.root_node(); let mut ranges = Vec::new(); collect_folding(root, &mut ranges); + let mut spans = Spans::default(); + collect_spans(root, &mut spans); + ranges.extend(region_ranges(&spans.regions)); + ranges.extend(import_ranges(&spans.imports)); + ranges +} + +/// One directive's position, as `(start row, end row, end column)`. +type Marker = (usize, usize, usize); + +/// The sibling-spanning directives, in document order. +#[derive(Default)] +struct Spans { + /// `#region` (true) and `#endregion` (false) markers, interleaved. + regions: Vec<(bool, Marker)>, + /// `using` / `open` directives. + imports: Vec<Marker>, +} + +/// Collect every directive a sibling-spanning fold is built from. +fn collect_spans(node: Node<'_>, spans: &mut Spans) { + let marker = ( + node.start_position().row, + node.end_position().row, + node.end_position().column, + ); + match node.kind() { + "preproc_region" => spans.regions.push((true, marker)), + "preproc_endregion" => spans.regions.push((false, marker)), + "using_directive" | "import_decl" => spans.imports.push(marker), + _ => {} + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_spans(child, spans); + } +} + +/// Pair each `#region` with the `#endregion` that closes it. +/// +/// A stack, so nested regions pair innermost-first the way the compiler reads +/// them. An unclosed `#region` folds nothing - the user is mid-edit, and a fold +/// running to the end of the file would collapse the rest of their work. +fn region_ranges(markers: &[(bool, Marker)]) -> Vec<FoldingRange> { + let mut open: Vec<Marker> = Vec::new(); + let mut ranges = Vec::new(); + for (is_open, marker) in markers { + if *is_open { + open.push(*marker); + } else if let Some(start) = open.pop() { + // The fold ends ON the `#endregion` line. The directive node runs + // to the start of the next row, so its END row is one line past the + // text the user sees, and folding to it would swallow the line + // below the region. + ranges.push(span(start.0, marker.0, None, FoldingRangeKind::Region)); + } + } ranges } +/// One `imports` fold per RUN of adjacent import directives. +/// +/// Adjacent means consecutive rows: a blank line or any other statement ends +/// the run, so a second `using` block below a namespace folds as its own header +/// rather than being swallowed into the first. +fn import_ranges(markers: &[Marker]) -> Vec<FoldingRange> { + let mut ranges = Vec::new(); + let mut run: Option<(Marker, Marker)> = None; + for marker in markers { + run = match run { + Some((first, last)) if marker.0 == last.1 + 1 => Some((first, *marker)), + Some((first, last)) => { + ranges.extend(span_over_run(first, last)); + Some((*marker, *marker)) + } + None => Some((*marker, *marker)), + }; + } + if let Some((first, last)) = run { + ranges.extend(span_over_run(first, last)); + } + ranges +} + +/// The `imports` fold for one run, when the run actually spans more than a line. +fn span_over_run(first: Marker, last: Marker) -> Option<FoldingRange> { + (first.0 < last.1).then(|| { + span( + first.0, + last.1, + Some(usize_to_u32(last.2)), + FoldingRangeKind::Imports, + ) + }) +} + +/// A fold over whole lines, optionally stopping at a column on the last one. +fn span( + start_row: usize, + end_row: usize, + end_character: Option<u32>, + kind: FoldingRangeKind, +) -> FoldingRange { + FoldingRange { + start_line: usize_to_u32(start_row), + start_character: Some(0), + end_line: usize_to_u32(end_row), + end_character, + kind: Some(kind), + collapsed_text: None, + } +} + /// Recursively collect folding ranges from tree-sitter nodes. fn collect_folding(node: Node<'_>, ranges: &mut Vec<FoldingRange>) { - let kind = match node.kind() { + let kind = fold_kind(node); + if kind.is_some() || is_structural(node.kind()) { + let start = node.start_position(); + let end = node.end_position(); + if start.row < end.row { + ranges.push(FoldingRange { + start_line: usize_to_u32(start.row), + start_character: Some(usize_to_u32(start.column)), + end_line: usize_to_u32(end.row), + end_character: Some(usize_to_u32(end.column)), + kind, + collapsed_text: None, + }); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_folding(child, ranges); + } +} + +/// The LSP kind a node's own fold carries, when it carries one at all. +/// +/// LSP 3.17 defines exactly three kinds, and `region` names a range the USER +/// marked out with `#region` — not every brace pair. Tagging declarations +/// `region` made "collapse the region" collapse the enclosing class and left an +/// editor unable to tell the two apart, so a structural fold carries NO kind, +/// which is what every other server does. +fn fold_kind(node: Node<'_>) -> Option<FoldingRangeKind> { + // Comments: C# `comment`, F# `block_comment` ((* *)) and `xml_doc` (///) — + // multi-line only. + match node.kind() { + "comment" | "block_comment" | "xml_doc" + if node.start_position().row != node.end_position().row => + { + Some(FoldingRangeKind::Comment) + } + _ => None, + } +} + +/// Whether a node folds on its own shape: a body, a block, a declaration. +fn is_structural(kind: &str) -> bool { + matches!( + kind, // C# blocks / braces "class_declaration" | "struct_declaration" @@ -199,45 +378,8 @@ fn collect_folding(node: Node<'_>, ranges: &mut Vec<FoldingRange>) { | "namespace" | "type_definition" | "type_extension" - | "function_or_value_defn" => Some(FoldingRangeKind::Region), - // Comments: C# `comment`, F# `block_comment` ((* *)) and `xml_doc` - // (///) — multi-line only. - "comment" | "block_comment" | "xml_doc" - if node.start_position().row != node.end_position().row => - { - Some(FoldingRangeKind::Comment) - } - // Using directives group (C# `using_directive`, F# `import_decl`). - // - // `import_decl`, NOT `open`: in tree-sitter-fsharp `open` is an - // ANONYMOUS keyword token inside `import_decl`, so matching on it named - // a node this walk never visits as a fold candidate — dead code that - // read like F# import folding was implemented. Either way a one-line - // `open X` does not fold, because the `start.row < end.row` guard below - // drops every single-line node. - "using_directive" | "import_decl" => Some(FoldingRangeKind::Imports), - _ => None, - }; - - if let Some(fold_kind) = kind { - let start = node.start_position(); - let end = node.end_position(); - if start.row < end.row { - ranges.push(FoldingRange { - start_line: usize_to_u32(start.row), - start_character: Some(usize_to_u32(start.column)), - end_line: usize_to_u32(end.row), - end_character: Some(usize_to_u32(end.column)), - kind: Some(fold_kind), - collapsed_text: None, - }); - } - } - - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - collect_folding(child, ranges); - } + | "function_or_value_defn" + ) } // ── Selection Ranges ────────────────────────────────────────────── @@ -314,6 +456,24 @@ pub fn is_comment_at_position(tree: &Tree, position: Position) -> bool { .is_some_and(|node| node.kind() == "comment") } +/// Whether a position has no symbol under it at all: whitespace, or a comment. +/// +/// [HOVER-ERRORS] names "position is whitespace or comment" as one refusal, and +/// [HOVER-ROUTING] makes it a tree-sitter pre-validation so it costs a syntax +/// lookup rather than a sidecar round trip on every mouse move. Only the +/// comment half was implemented, so hovering blank space paid the full trip and +/// could pop a tooltip over nothing. +/// +/// Whitespace is read off the tree rather than the text: the smallest node +/// containing a point inside a TOKEN is that token, a leaf, while a point +/// between tokens resolves to the enclosing construct, which has children. +pub fn has_no_symbol_at_position(tree: &Tree, position: Position) -> bool { + let point = lsp_pos_to_ts_point(position); + tree.root_node() + .descendant_for_point_range(point, point) + .is_some_and(|node| node.kind() == "comment" || node.child_count() > 0) +} + /// Check if a position is on a string literal node (tree-sitter pre-validation). /// /// Returns `true` when the position falls inside a string literal, allowing diff --git a/src/sharplsp/src/utils.rs b/src/sharplsp/src/utils.rs index e9ded395..142eba59 100644 --- a/src/sharplsp/src/utils.rs +++ b/src/sharplsp/src/utils.rs @@ -25,9 +25,70 @@ pub struct SidecarHierarchyItem { pub end_character: u32, } +/// One range at which a call appears, as the sidecars report it. +#[derive(serde::Deserialize)] +pub struct SidecarCallSite { + /// Start line of the call site. + pub line: u32, + /// Start character offset within the start line. + pub character: u32, + /// End line of the call site. + pub end_line: u32, + /// End character offset within the end line. + pub end_character: u32, +} + +/// A caller or callee, together with every range at which the call appears. +/// +/// Distinct from [`SidecarHierarchyItem`] because only the call-hierarchy +/// incoming/outgoing replies carry sites; `prepare` and type hierarchy answer +/// with a bare item, and the `MessagePack` encoding is positional. +#[derive(serde::Deserialize)] +pub struct SidecarCallHierarchyCall { + /// Display name of the symbol. + pub name: String, + /// Symbol kind string (e.g. "Function", "Class"). + pub kind: String, + /// Absolute path to the file containing this symbol. + pub file_path: String, + /// Start line of the symbol range. + pub line: u32, + /// Start character offset within the start line. + pub character: u32, + /// End line of the symbol range. + pub end_line: u32, + /// End character offset within the end line. + pub end_character: u32, + /// Every range at which the call appears inside this symbol. + pub from_ranges: Vec<SidecarCallSite>, +} + +impl SidecarCallHierarchyCall { + /// The bare item, for the mapping shared with `prepare` and type hierarchy. + #[must_use] + pub fn item(&self) -> SidecarHierarchyItem { + SidecarHierarchyItem { + name: self.name.clone(), + kind: self.kind.clone(), + file_path: self.file_path.clone(), + line: self.line, + character: self.character, + end_line: self.end_line, + end_character: self.end_character, + } + } +} + /// Compute the LSP location triple `(uri, range, selection_range)` shared by /// call-hierarchy and type-hierarchy item mapping. /// +/// Both sidecars report a symbol's DECLARATION location — Roslyn's +/// `ISymbol.Locations` and FCS's `DeclarationLocation` are the identifier +/// span, not the whole declaration — so that one span is both the item's +/// `range` and the `selectionRange` LSP 3.17 says "should be selected and +/// revealed when this symbol is being picked, e.g. the name of a function". +/// A zero-width selection at the start column selected nothing at all. +/// /// Returns `None` when the sidecar's file path cannot be parsed into a URI. pub fn hierarchy_item_location(item: &SidecarHierarchyItem) -> Option<(Uri, Range, Range)> { let parsed_uri = path_to_lsp_uri(&item.file_path).ok()?; @@ -35,11 +96,7 @@ pub fn hierarchy_item_location(item: &SidecarHierarchyItem) -> Option<(Uri, Rang Position::new(item.line, item.character), Position::new(item.end_line, item.end_character), ); - let selection_range = Range::new( - Position::new(item.line, item.character), - Position::new(item.line, item.character), - ); - Some((parsed_uri, range, selection_range)) + Some((parsed_uri, range, range)) } /// Request identifying a position in a file, sent to a sidecar. Serialized as a diff --git a/src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs b/src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs index bdcdd7c0..fd542260 100644 --- a/src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs +++ b/src/sharplsp/tests/e2e_modules/diagnostics_full_stack.rs @@ -172,12 +172,20 @@ EndGlobal"#, let _ = client.initialize_with_root(json!(root_uri)); client.open_document(&file_uri, clean_source); + // [HOVER-ERRORS] refuses a hover on whitespace with `null`, so the readiness + // poll has to sit on a real identifier: `Value` on line 3, column 15. A + // column past the end of `{` never resolves and would spin out the timeout. let hover_result = - poll_hover_until_ready(&mut client, &file_uri, 2, 14, Duration::from_secs(90)); + poll_hover_until_ready(&mut client, &file_uri, 3, 15, Duration::from_secs(90)); assert!( !hover_result.is_null(), "hover must work once sidecar is ready", ); + let hover_md = hover_result["contents"]["value"].as_str().unwrap_or(""); + assert!( + hover_md.contains("Value"), + "the readiness hover must land on the `Value` property, got {hover_md:?}", + ); client.save_document(&file_uri); std::thread::sleep(Duration::from_secs(5)); @@ -486,13 +494,20 @@ fn test_full_stack_diagnostics_refreshed_on_did_change() { let _ = client.initialize_with_root(json!(root_uri)); client.open_document(&file_uri, clean_source); - // Wait for sidecar readiness via hover polling. + // Wait for sidecar readiness via hover polling. [HOVER-ERRORS] answers a + // whitespace position with `null`, so the poll sits on `Count` (line 3, + // column 15) rather than on the blank past `{`. let hover_result = - poll_hover_until_ready(&mut client, &file_uri, 2, 14, Duration::from_secs(90)); + poll_hover_until_ready(&mut client, &file_uri, 3, 15, Duration::from_secs(90)); assert!( !hover_result.is_null(), "hover must work once sidecar is ready", ); + let hover_md = hover_result["contents"]["value"].as_str().unwrap_or(""); + assert!( + hover_md.contains("Count"), + "the readiness hover must land on the `Count` property, got {hover_md:?}", + ); // Now edit the file to introduce a type error. let broken_source = r"namespace ChangeTest; diff --git a/src/sharplsp/tests/e2e_modules/folding.rs b/src/sharplsp/tests/e2e_modules/folding.rs index 94abff45..3da8405e 100644 --- a/src/sharplsp/tests/e2e_modules/folding.rs +++ b/src/sharplsp/tests/e2e_modules/folding.rs @@ -50,10 +50,36 @@ fn test_folding_ranges_using_directives() { ); let ranges = resp["result"].as_array().unwrap(); - // Using directives are single-line so they may not produce folds, - // but the class should. - let region = ranges.iter().find(|r| r["kind"] == "region"); - assert!(region.is_some(), "should have region fold for class"); + // LSP 3.17 gives the run of `using` directives the `imports` kind — that is + // the kind's whole purpose — and it must span both lines, not just one. + let imports = ranges + .iter() + .find(|r| r["kind"] == "imports") + .unwrap_or_else(|| panic!("the using header must fold as `imports`, got {ranges:?}")); + assert_eq!( + imports["startLine"], 0, + "the imports fold starts on the first using" + ); + assert_eq!( + imports["endLine"], 1, + "the imports fold ends on the last using" + ); + + // The class body folds on its own shape and carries NO kind: LSP 3.17 + // reserves `region` for a range the user marked with `#region`, and this + // source contains none. + let class_fold = ranges + .iter() + .find(|r| r["startLine"] == 3 && r["kind"].is_null()) + .unwrap_or_else(|| panic!("the class must fold with no kind, got {ranges:?}")); + assert_eq!( + class_fold["endLine"], 5, + "the class fold runs to its closing brace" + ); + assert!( + !ranges.iter().any(|r| r["kind"] == "region"), + "nothing may claim `region` in a source with no #region, got {ranges:?}", + ); client.shutdown_and_exit(); client.wait_with_timeout(); @@ -192,10 +218,17 @@ fn test_folding_range_on_fsharp_file() { .map(|r| r["kind"].as_str().unwrap_or("")) .collect(); - // The module declaration folds as a region. + // The module, the type and the let-binding each fold on their own shape and + // carry NO kind: LSP 3.17 reserves `region` for a `#region` the user wrote, + // and F# has no such directive at all. + let structural = kinds.iter().filter(|k| k.is_empty()).count(); + assert!( + structural >= 3, + "module, type and let must each fold with no kind, got {kinds:?}" + ); assert!( - kinds.contains(&"region"), - "module/type/let declarations must produce region folds, got {kinds:?}" + !kinds.contains(&"region"), + "no F# construct may be tagged `region`, got {kinds:?}" ); // The (* ... *) comment folds as a comment. assert!( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GenerateConstructorFromMembersTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GenerateConstructorFromMembersTests.cs new file mode 100644 index 00000000..a46ffce0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/GenerateConstructorFromMembersTests.cs @@ -0,0 +1,113 @@ +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real temp-path use is intentional in this coarse E2E + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// <summary> +/// "Generate constructor" seeded from the members the user SELECTED +/// ([SHARPLSP-FEATURES-REFACTORING], P0). Roslyn's provider reads the +/// selection to decide which fields the constructor takes; a selection over +/// two fields that yields no constructor at all is the refactoring missing, +/// not the user selecting wrongly. +/// </summary> +public sealed class GenerateConstructorFromMembersTests : IDisposable +{ + private const string Source = """ + namespace Generating; + + public class Target + { + private readonly int _count; + private readonly string _label; + + public string Describe() => $"{_label}:{_count}"; + } + """; + + private const string Csproj = """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <OutputType>Library</OutputType> + <Nullable>enable</Nullable> + </PropertyGroup> + </Project> + """; + + private readonly ProjectlessWorkspaceFixture _files = new("generate-ctor"); + private readonly string _csprojPath; + private readonly string _sourcePath; + + public GenerateConstructorFromMembersTests() + { + _csprojPath = _files.Write("Generating.csproj", Csproj); + _sourcePath = _files.Write("Target.cs", Source); + } + + public void Dispose() + { + _files.Dispose(); + } + + [Fact] + public async Task A_selection_over_two_fields_offers_a_constructor_taking_both() + { + using var manager = new WorkspaceManager(); + var opened = await ProjectlessWorkspaceFixture.OpenAsync(manager, _csprojPath); + Assert.False(opened.IsError, opened.Match(_ => "ok", error => error)); + + var (startLine, startCharacter) = Locate("private readonly int _count;"); + var (endLine, endCharacter) = Locate("private readonly string _label;"); + endCharacter += "private readonly string _label;".Length; + var result = await manager.GetCodeActionsAsync( + _sourcePath, + startLine, + startCharacter, + endLine, + endCharacter + ); + Assert.False(result.IsError, result.Match(_ => "ok", error => error)); + var actions = +result; + + var titles = actions.Select(action => action.Title).ToList(); + Assert.True( + titles.Contains("Generate constructor 'Target(int count, string label)'"), + "offered: " + string.Join(" | ", titles) + ); + } + + [Fact] + public async Task A_caret_on_the_type_name_offers_the_parameterless_constructor() + { + using var manager = new WorkspaceManager(); + var opened = await ProjectlessWorkspaceFixture.OpenAsync(manager, _csprojPath); + Assert.False(opened.IsError, opened.Match(_ => "ok", error => error)); + + var (line, character) = Locate("public class Target"); + character += "public class ".Length; + var result = await manager.GetCodeActionsAsync( + _sourcePath, + line, + character, + line, + character + ); + Assert.False(result.IsError, result.Match(_ => "ok", error => error)); + + var titles = (+result).Select(action => action.Title).ToList(); + Assert.Contains("Generate constructor 'Target()'", titles); + } + + /// <summary>Zero-based line and column where <paramref name="snippet"/> starts.</summary> + private static (int Line, int Character) Locate(string snippet) + { + var lines = Source.Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + var line = Array.FindIndex(lines, value => value.Contains(snippet)); + Assert.True(line >= 0, $"snippet not found: <{snippet}>"); + return (line, lines[line].IndexOf(snippet, StringComparison.Ordinal)); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs index 1b7c5d16..1c5d408b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs @@ -405,6 +405,23 @@ internal sealed class CallHierarchyItem public int EndCharacter { get; set; } } +/// <summary>One range at which a call appears, inside the item that reports it.</summary> +[MessagePackObject(AllowPrivate = true)] +internal sealed class CallSiteResult +{ + [Key(0)] + public int Line { get; set; } + + [Key(1)] + public int Character { get; set; } + + [Key(2)] + public int EndLine { get; set; } + + [Key(3)] + public int EndCharacter { get; set; } +} + [MessagePackObject(AllowPrivate = true)] internal sealed class CallHierarchyCallResult { @@ -428,6 +445,12 @@ internal sealed class CallHierarchyCallResult [Key(6)] public int EndCharacter { get; set; } + + /// <summary> + /// Every range at which the call appears, per LSP 3.17 `fromRanges`. + /// </summary> + [Key(7)] + public List<CallSiteResult> FromRanges { get; set; } = []; } // ── Type Hierarchy Types ───────────────────────────────────────── diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs index 1f710354..22527b5d 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs @@ -58,7 +58,7 @@ CancellationToken ct [ .. callers .Where(c => c.IsDirect) - .Select(c => ToCallResult(c.CallingSymbol)) + .Select(c => ToCallResult(c.CallingSymbol, c.Locations)) .Where(c => c is not null) .Cast<CallHierarchyCallResult>(), ]; @@ -118,11 +118,7 @@ CancellationToken ct var symbolInfo = model.GetSymbolInfo(invocation, ct); if (symbolInfo.Symbol is not null) { - var result = ToCallResult(symbolInfo.Symbol); - if (result is not null) - { - results.Add(result); - } + AddOutgoingCall(symbolInfo.Symbol, invocation.GetLocation(), results); } } } @@ -198,7 +194,42 @@ CancellationToken ct }; } - private static CallHierarchyCallResult? ToCallResult(ISymbol symbol) + /// <summary> + /// Record one call site against its callee, merging repeats. + /// </summary> + /// <remarks> + /// LSP wants ONE entry per callee carrying every range it is called at; a + /// second entry for the same method renders as a duplicate row in the tree + /// that expands to exactly the same children. + /// </remarks> + private static void AddOutgoingCall( + ISymbol callee, + Location site, + List<CallHierarchyCallResult> results + ) + { + var result = ToCallResult(callee, [site]); + if (result is null) + { + return; + } + + var existing = results.Find(r => + r.Name == result.Name && r.FilePath == result.FilePath && r.Line == result.Line + ); + if (existing is null) + { + results.Add(result); + return; + } + + existing.FromRanges.AddRange(result.FromRanges); + } + + private static CallHierarchyCallResult? ToCallResult( + ISymbol symbol, + IEnumerable<Location> callSites + ) { var item = ToCallHierarchyItem(symbol); return item is null @@ -212,9 +243,25 @@ CancellationToken ct Character = item.Character, EndLine = item.EndLine, EndCharacter = item.EndCharacter, + FromRanges = [.. callSites.Where(l => l.IsInSource).Select(ToCallSite)], }; } + /// <summary>One source location as the range the host publishes.</summary> + private static CallSiteResult ToCallSite(Location location) + { + var (_, line, character, endLine, endCharacter) = DocumentPosition.Coordinates( + location.GetMappedLineSpan() + ); + return new CallSiteResult + { + Line = line, + Character = character, + EndLine = endLine, + EndCharacter = endCharacter, + }; + } + private static string MapSymbolKind(ISymbol symbol) { return symbol.Kind switch diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs index 1c44c978..86e76751 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs @@ -229,7 +229,7 @@ CancellationToken ct return new CodeFixContext( document, diagnostic, - (action, _) => CacheAndAdd(action, FixKind(diagnostic.Id), items), + (action, _) => CacheAndAdd(action, FixKind(diagnostic.Id), items, null), ct ); } @@ -246,7 +246,7 @@ CancellationToken ct .ConfigureAwait(false); if (action is not null) { - CacheAndAdd(action, "refactor.rewrite", items); + CacheAndAdd(action, "refactor.rewrite", items, null); } } @@ -265,11 +265,53 @@ CancellationToken ct foreach (var provider in CachedRefactoringProviders.Value) { ct.ThrowIfCancellationRequested(); + var before = items.Count; await TryRegisterRefactoringAsync(provider, document, span, items, ct) .ConfigureAwait(false); + if (items.Count == before) + { + await AskAboutCaretAsync(provider, document, span, items, ct).ConfigureAwait(false); + } } } + /// <summary> + /// Ask one provider about the collapsed caret, after it answered nothing + /// about the selection. + /// </summary> + /// <remarks> + /// Roslyn finds a refactoring's target with `TryGetRelevantNode`, which needs + /// the span to sit inside ONE node — so a selection over an invocation's + /// method name resolves to the identifier, not the invocation, and + /// `Inline 'X'` was offered for a caret on a word and withheld the moment the + /// user double-clicked it. Asking about the caret UNCONDITIONALLY would drag + /// in whatever sub-expression it lands inside: selecting `1 + 2` would offer + /// constants for `1` as well. A provider that answered the selection has said + /// what it has to say about it. + /// </remarks> + private async Task AskAboutCaretAsync( + CodeRefactoringProvider provider, + Document document, + TextSpan span, + List<CodeActionItem> items, + CancellationToken ct + ) + { + if (span.IsEmpty) + { + return; + } + + await TryRegisterRefactoringAsync( + provider, + document, + new TextSpan(span.Start, 0), + items, + ct + ) + .ConfigureAwait(false); + } + private async Task TryRegisterRefactoringAsync( CodeRefactoringProvider provider, Document document, @@ -311,7 +353,7 @@ CancellationToken ct return new CodeRefactoringContext( document, span, - action => CacheAndAdd(action, RefactoringKind(provider, action), items), + action => CacheAndAdd(action, RefactoringKind(provider, action), items, null), ct ); } @@ -341,17 +383,28 @@ private static bool IsExtractionProvider(string providerName) || providerName.Contains("IntroduceField", StringComparison.OrdinalIgnoreCase); } - private void CacheAndAdd(CodeAction action, string kind, List<CodeActionItem> items) + private void CacheAndAdd( + CodeAction action, + string kind, + List<CodeActionItem> items, + string? parentTitle + ) { - if (CacheNestedActions(action, kind, items) || IsDuplicate(action, kind, items)) + var title = Qualified(parentTitle, action.Title); + if (CacheNestedActions(action, kind, items, title) || IsDuplicate(title, kind, items)) { return; } - items.Add(CacheAction(action, kind)); + items.Add(CacheAction(action, kind, title)); } - private bool CacheNestedActions(CodeAction action, string kind, List<CodeActionItem> items) + private bool CacheNestedActions( + CodeAction action, + string kind, + List<CodeActionItem> items, + string title + ) { if (action.NestedActions.IsEmpty) { @@ -360,25 +413,55 @@ private bool CacheNestedActions(CodeAction action, string kind, List<CodeActionI foreach (var nested in action.NestedActions) { - CacheAndAdd(nested, kind, items); + CacheAndAdd(nested, kind, items, title); } return true; } - private static bool IsDuplicate(CodeAction action, string kind, List<CodeActionItem> items) + /// <summary> + /// A flattened child's title, carrying the container it came from when it + /// cannot stand without it. + /// </summary> + /// <remarks> + /// LSP has no submenus, so a nested action arrives in the same flat list as + /// every other and its title is all the user reads before choosing. Roslyn + /// writes children in two shapes: sentences that name themselves ("Inline + /// and keep 'X'", "Convert to binary") and continuations of the PARENT's + /// sentence ("and update call sites directly", "into new overload") that say + /// nothing alone. Only the continuations are joined, and with a space, so + /// the result reads as the sentence Roslyn actually wrote. Renaming the + /// self-contained ones would only make them stutter. + /// </remarks> + private static string Qualified(string? parentTitle, string title) + { + return string.IsNullOrEmpty(parentTitle) || !IsContinuation(title) + ? title + : parentTitle + " " + title; + } + + /// <summary> + /// Whether a child's title continues its parent's sentence instead of + /// naming itself. Roslyn writes those in lower case, and only those. + /// </summary> + private static bool IsContinuation(string title) + { + return title.Length > 0 && char.IsLower(title[0]); + } + + private static bool IsDuplicate(string title, string kind, List<CodeActionItem> items) { - return items.Any(item => item.Title == action.Title && item.Kind == kind); + return items.Any(item => item.Title == title && item.Kind == kind); } - private CodeActionItem CacheAction(CodeAction action, string kind) + private CodeActionItem CacheAction(CodeAction action, string kind, string title) { var id = Interlocked.Increment(ref _nextId); _pendingActions[id] = action; return new CodeActionItem { Id = id, - Title = action.Title, + Title = title, Kind = kind, IsPreferred = action.Priority == CodeActionPriority.High, }; diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeLens.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeLens.fs index ad130230..5f1ee1fc 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeLens.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpCodeLens.fs @@ -28,6 +28,33 @@ let private isLensable (su: FSharpSymbolUse) : bool = | :? FSharpMemberOrFunctionOrValue as mfv -> mfv.IsModuleValueOrMember | _ -> false) +/// References to one definition symbol, excluding the definition itself. +let private referenceCount (projResults: FSharpCheckProjectResults) (symbol: FSharpSymbol) : int = + projResults.GetUsesOfSymbol(symbol) + |> Array.filter (fun u -> not u.IsFromDefinition) + |> Array.length + +/// One lens per ANCHOR, summing the counts of every definition that shares it. +/// +/// `type Greeter(greeting: string)` is two definitions at one range — the +/// entity and its primary constructor — and emitting a lens for each stacked +/// "0 references | 1 reference" above a single declaration. Summing them is +/// what Roslyn's count for a class already is: uses of the name and +/// constructions of it, together. +let private lensesByAnchor (projResults: FSharpCheckProjectResults) (definitions: FSharpSymbolUse[]) = + definitions + |> Array.filter (fun su -> + let anchor = su.Range + anchor.FileName <> "") + |> Array.groupBy (fun su -> + let anchor = su.Range + (anchor.StartLine, anchor.StartColumn)) + |> Array.map (fun ((line, column), group) -> + { Line = line - 1 + Character = column + Title = group |> Array.sumBy (fun su -> referenceCount projResults su.Symbol) |> formatTitle }) + |> Array.toList + /// Get reference-count lenses for every top-level definition in a file. let getCodeLenses (state: FSharpWorkspace.FSharpWorkspaceState) (filePath: string) = task { @@ -40,26 +67,11 @@ let getCodeLenses (state: FSharpWorkspace.FSharpWorkspaceState) (filePath: strin match proj with | None -> return [] | Some projResults -> - let definitions = + return checkResults.GetAllUsesOfAllSymbolsInFile() |> Seq.filter isLensable |> Seq.toArray - return - definitions - |> Array.choose (fun (su: FSharpSymbolUse) -> - let r = su.Range - if r.FileName = "" then - None - else - let refCount = - projResults.GetUsesOfSymbol(su.Symbol) - |> Array.filter (fun u -> not u.IsFromDefinition) - |> Array.length - Some - { Line = r.StartLine - 1 - Character = r.StartColumn - Title = formatTitle refCount }) - |> Array.toList + |> lensesByAnchor projResults with ex -> Log.Debug(ex, "[F# CodeLens] failed") return [] diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpHierarchy.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpHierarchy.fs index 193a7c95..bd3ae864 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpHierarchy.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpHierarchy.fs @@ -24,6 +24,43 @@ type HierItem = EndLine: int EndCharacter: int } +/// One range at which a call appears. +type CallSite = + { Line: int + Character: int + EndLine: int + EndCharacter: int } + +/// A caller or callee together with every range the call is made at, which is +/// what LSP 3.17 names `fromRanges`. A bare item cannot say WHERE, so a symbol +/// called twice from one place was indistinguishable from one called once. +type HierCall = + { Item: HierItem + Sites: CallSite list } + +/// The call site a symbol use occupies. +let private siteOf (su: FSharpSymbolUse) : CallSite = + let r = su.Range + + { Line = r.StartLine - 1 + Character = r.StartColumn + EndLine = r.EndLine - 1 + EndCharacter = r.EndColumn } + +/// Merge one call into the accumulator, keyed by the item it belongs to. +/// +/// A repeat is not a duplicate to drop: it is another SITE for the row already +/// there, and dropping it is exactly what lost the second call. +let private addSite + (calls: Dictionary<string, HierCall>) + (key: string) + (item: HierItem) + (site: CallSite) + = + match calls.TryGetValue key with + | true, existing -> calls[key] <- { existing with Sites = existing.Sites @ [ site ] } + | _ -> calls[key] <- { Item = item; Sites = [ site ] } + // ── Shared symbol → item mapping ───────────────────────────────── /// Map an FCS symbol to a capitalized kind string the Rust host understands. @@ -152,33 +189,46 @@ let private callerItem (state: FSharpWorkspace.FSharpWorkspaceState) (su: FSharp return resolveCaller checkData su } -/// Get incoming calls: project-wide call sites of the symbol, mapped to the -/// declaration that encloses each call. -let incomingCalls (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = +/// Get incoming calls WITH the range of every call, which is what the wire +/// publishes as LSP 3.17's `fromRanges`. +let incomingCallsWithSites (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = task { try let! uses = FSharpReferences.getProjectUsages state filePath line character let callSites = uses |> Array.filter (fun u -> not u.IsFromDefinition) - let results = List<HierItem>() - let seen = HashSet<string>() + let calls = Dictionary<string, HierCall>() + let order = List<string>() + for su in callSites do let! caller = callerItem state su + match caller with - | Some item when seen.Add(itemKey item) -> results.Add(item) - | _ -> () - return List.ofSeq results + | Some item -> + let key = itemKey item + if not (calls.ContainsKey key) then order.Add key + addSite calls key item (siteOf su) + | None -> () + + return [ for key in order -> calls[key] ] with ex -> Log.Debug(ex, "[F# IncomingCalls] failed") return [] } +/// Get incoming calls as bare callers, for consumers that need only who calls. +let incomingCalls (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = + task { + let! calls = incomingCallsWithSites state filePath line character + return calls |> List.map (fun call -> call.Item) + } + /// Pure computation of outgoing calls from a checked file. Extracted so /// `outgoingCalls`'s `task` is a single bind + single return (FS3511). let private computeOutgoing (checkData: (FSharpParseFileResults * FSharpCheckFileResults * string) option) (line: int) (character: int) - : HierItem list = + : HierCall list = match checkData with | None -> [] | Some(parseResults, checkResults, source) -> @@ -192,20 +242,27 @@ let private computeOutgoing match enclosingBinding parseResults.ParseTree pos with | None -> [] | Some(_ident, bindingRange) -> - let results = List<HierItem>() - let seen = HashSet<string>() + let calls = Dictionary<string, HierCall>() + let order = List<string>() + for u in checkResults.GetAllUsesOfAllSymbolsInFile() do - if not u.IsFromDefinition - && Range.rangeContainsRange bindingRange u.Range - && isCallable u.Symbol then + if + not u.IsFromDefinition + && Range.rangeContainsRange bindingRange u.Range + && isCallable u.Symbol + then match itemOfSymbol u.Symbol with - | Some item when seen.Add(itemKey item) -> results.Add(item) - | _ -> () - List.ofSeq results + | Some item -> + let key = itemKey item + if not (calls.ContainsKey key) then order.Add key + addSite calls key item (siteOf u) + | None -> () -/// Get outgoing calls: function/member applications inside the symbol's own -/// binding body. -let outgoingCalls (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = + [ for key in order -> calls[key] ] + +/// Get outgoing calls WITH the range of every application, which is what the +/// wire publishes as LSP 3.17's `fromRanges`. +let outgoingCallsWithSites (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = task { try let! checkData = FSharpWorkspace.checkFileWithParse state filePath @@ -215,6 +272,13 @@ let outgoingCalls (state: FSharpWorkspace.FSharpWorkspaceState) filePath line ch return [] } +/// Get outgoing calls as bare callees, for consumers that need only who is called. +let outgoingCalls (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = + task { + let! calls = outgoingCallsWithSites state filePath line character + return calls |> List.map (fun call -> call.Item) + } + // ── Type hierarchy ─────────────────────────────────────────────── /// Resolve the entity at a position, if the symbol there is a type/module. diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs index b3658120..81815a18 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs @@ -431,9 +431,9 @@ type FSharpSidecar() = try let request = MessagePackSerializer.Deserialize<PositionRequest>(payload, cancellationToken = ct) let! items = - FSharpHierarchy.incomingCalls + FSharpHierarchy.incomingCallsWithSites workspace request.FilePath request.Line request.Character - let results = items |> List.map Helpers.toHierItem |> Array.ofList + let results = items |> List.map Helpers.toHierCall |> Array.ofList return Helpers.serializeOk results ct with ex -> return ByteResult.Failure(ex.Message) @@ -445,9 +445,9 @@ type FSharpSidecar() = try let request = MessagePackSerializer.Deserialize<PositionRequest>(payload, cancellationToken = ct) let! items = - FSharpHierarchy.outgoingCalls + FSharpHierarchy.outgoingCallsWithSites workspace request.FilePath request.Line request.Character - let results = items |> List.map Helpers.toHierItem |> Array.ofList + let results = items |> List.map Helpers.toHierCall |> Array.ofList return Helpers.serializeOk results ct with ex -> return ByteResult.Failure(ex.Message) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs index 886be8ee..8e190419 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs @@ -219,6 +219,30 @@ type HierarchyItemResult = [<Key(5)>] EndLine: int [<Key(6)>] EndCharacter: int } +/// One range at which a call appears, inside the item that reports it. +[<MessagePackObject(AllowPrivate = true)>] +[<NoComparison; NoEquality>] +type CallSiteResult = + { [<Key(0)>] Line: int + [<Key(1)>] Character: int + [<Key(2)>] EndLine: int + [<Key(3)>] EndCharacter: int } + +/// A hierarchy item plus LSP 3.17's `fromRanges`. Separate from +/// `HierarchyItemResult` because `prepare` and type hierarchy answer with a bare +/// item and the MessagePack encoding is positional. +[<MessagePackObject(AllowPrivate = true)>] +[<NoComparison; NoEquality>] +type CallHierarchyCallResult = + { [<Key(0)>] Name: string + [<Key(1)>] Kind: string + [<Key(2)>] FilePath: string + [<Key(3)>] Line: int + [<Key(4)>] Character: int + [<Key(5)>] EndLine: int + [<Key(6)>] EndCharacter: int + [<Key(7)>] FromRanges: CallSiteResult array } + // ── Document Symbol Types (nested; wire-compatible with the Rust host) ── [<MessagePackObject(AllowPrivate = true)>] @@ -349,6 +373,24 @@ module internal Helpers = EndLine = item.EndLine EndCharacter = item.EndCharacter } + /// Map one call-hierarchy call - the item plus its sites - to its wire shape. + let toHierCall (call: FSharpHierarchy.HierCall) : CallHierarchyCallResult = + { Name = call.Item.Name + Kind = call.Item.Kind + FilePath = call.Item.FilePath + Line = call.Item.Line + Character = call.Item.Character + EndLine = call.Item.EndLine + EndCharacter = call.Item.EndCharacter + FromRanges = + call.Sites + |> List.map (fun s -> + { Line = s.Line + Character = s.Character + EndLine = s.EndLine + EndCharacter = s.EndCharacter }) + |> Array.ofList } + /// Map a document-symbol domain item (and its children) to its wire shape. let rec toDocumentSymbol (item: FSharpSymbols.SymbolItem) : DocumentSymbolResult = { Name = item.Name diff --git a/tools/make/main.mk b/tools/make/main.mk index f07a0427..77a8a151 100644 --- a/tools/make/main.mk +++ b/tools/make/main.mk @@ -75,6 +75,7 @@ ZED_DIR = src/editors/zed SIDECAR_CS = src/sidecars/SharpLsp.Sidecar.CSharp SIDECAR_FS = src/sidecars/SharpLsp.Sidecar.FSharp SIDECAR_SLN = src/sidecars/SharpLsp.Sidecars.sln +SIDECAR_COMMON_TESTS = src/sidecars/SharpLsp.Sidecar.Common.Tests/SharpLsp.Sidecar.Common.Tests.csproj RIDER_DIR = src/editors/rider BINARY = target/$(PROFILE)/sharplsp$(EXE_EXT) @@ -116,7 +117,7 @@ KOVER_PERCENT = dotnet run --file tools/coverage/kover-line-percent.cs -- _gate-rust-coverage _test-vsix _run-vsix-suite _test-vsix-shard \ _gate-vsix-coverage _build-vsix-suite _check-vsix-chunks \ _verify-vsix-payload \ - _test-dotnet _test-website \ + _test-dotnet _test-dotnet-win-transport _test-tooling _test-website \ _lint-rust _lint-zed _lint-vsix _lint-dotnet \ _fmt-rust _fmt-zed _fmt-vsix _fmt-dotnet \ _package-vsix _package-archive \ @@ -250,7 +251,7 @@ ci: lint test build # ── Test ───────────────────────────────────────────────────────── -test: _test-rust _test-zed _test-vsix _test-dotnet _test-rider _test-website +test: _test-rust _test-zed _test-vsix _test-dotnet _test-rider _test-tooling _test-website @echo "==> All tests passed." # Public alias — CI and developers call this. @@ -533,6 +534,31 @@ _test-dotnet: $(if $(VSIX_PREBUILT),,_build-dotnet) _check_cov SharpLsp.Sidecar.FSharp sharplsp-sidecar-fsharp ; \ _check_cov SharpLsp.Sidecar.Common sharplsp-sidecar-common +# [DIST-CI-WIN-TRANSPORT] The win32 arm of the sidecar IPC transport. ONLY +# the classes whose behaviour is platform-dependent run here - named-pipe +# connection setup, and the real sidecar handshake over those pipes. Every +# other class in SharpLsp.Sidecar.Common.Tests is platform-agnostic and +# already ran ONCE in _test-dotnet on Ubuntu; running the whole project +# again on Windows executed ~12 files' worth of identical assertions a +# second time, which the pipeline's run-every-test-exactly-once rule +# forbids ([DIST-CI-LAYOUT]). +DOTNET_WIN_TRANSPORT_FILTER = FullyQualifiedName~SharpLsp.Sidecar.Common.Tests.IpcConnectionTests|FullyQualifiedName~SharpLsp.Sidecar.Common.Tests.SidecarHostEndToEndTests + +_test-dotnet-win-transport: + @echo "==> Running win32 named-pipe transport tests..." + dotnet test $(SIDECAR_COMMON_TESTS) --configuration $(DOTNET_CFG) \ + --filter "$(DOTNET_WIN_TRANSPORT_FILTER)" \ + --blame-hang-timeout 2min --blame-hang-dump-type none + +# [DIST-DEBUGGER-BUNDLE] Tests for the repo's own build tooling, as opposed to +# the product. Today that is how the netcoredbg debug adapter is obtained - +# the supply-chain path that every VSIX and every release depends on, and that +# nothing else in the suite exercises. Node's built-in runner, so this needs no +# dependency of its own. +_test-tooling: + @echo "==> Running repo tooling tests..." + node --test tools/netcoredbg/custody.test.mjs + website-build: @echo "==> Building website..." npm run build --prefix src/website diff --git a/tools/netcoredbg/custody.test.mjs b/tools/netcoredbg/custody.test.mjs new file mode 100644 index 00000000..109ab047 --- /dev/null +++ b/tools/netcoredbg/custody.test.mjs @@ -0,0 +1,203 @@ +// [DIST-DEBUGGER-BUNDLE] End-to-end tests for how SharpLsp obtains the patched +// netcoredbg debug adapter. Run by `make _test-tooling`. +// +// The adapter is the process users attach to their own code with. It used to be +// compiled inside the release pipeline from two repositories cloned at build +// time, with no digest checked and no provenance recorded - so these tests exist +// to hold the replacement honest. provide.mjs must DOWNLOAD a pinned artifact, +// must verify the bytes it actually received, and must REFUSE rather than fall +// back to a source build when the digest does not match. A silent fallback would +// turn a supply-chain alarm into a slow build and defeat the pin entirely. +// +// These drive the REAL script over a REAL HTTP server and a REAL tar archive. +// Nothing about the download path is stubbed, because the bug this guards +// against lives in exactly that plumbing. +import assert from 'node:assert/strict'; +import test, { after, before, beforeEach } from 'node:test'; +import { createHash } from 'node:crypto'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..', '..'); +const PROVIDE = join(HERE, 'provide.mjs'); +const LOCK = join(HERE, 'netcoredbg.lock.json'); + +// linux-arm64 is a supported platform that no CI leg builds, so driving the real +// code path here cannot collide with a genuine adapter on the runner. +const PLATFORM = 'linux-arm64'; +const OUTPUT = join(ROOT, 'target', 'netcoredbg', PLATFORM, 'netcoredbg'); + +let scratch = ''; +let server; +let baseUrl = ''; + +/** + * Builds a tar.gz shaped exactly like a published adapter archive. + * + * Every tar argument is relative and the working directory carries the path, + * because GNU tar reads an argument containing a colon as `host:path` and would + * try to reach a remote machine called `C`. provide.mjs extracts under the same + * constraint. + */ +function buildArchive(body) { + const stage = join(scratch, `stage-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(stage, 'netcoredbg'), { recursive: true }); + writeFileSync(join(stage, 'netcoredbg', 'netcoredbg'), body); + const packed = spawnSync('tar', ['-czf', 'adapter.tar.gz', 'netcoredbg'], { cwd: stage }); + assert.equal(packed.status, 0, `tar failed: ${packed.stderr?.toString()}`); + return readFileSync(join(stage, 'adapter.tar.gz')); +} + +/** + * Runs provide.mjs against a lock file pinning the served archive to `sha256`. + * + * Deliberately async: the archive is served from THIS process, and spawnSync + * blocks the event loop, so a synchronous child could never be answered and the + * test would hang instead of failing. + */ +function provide(sha256) { + const lock = JSON.parse(readFileSync(LOCK, 'utf8')); + lock.platforms = { [PLATFORM]: { url: baseUrl, sha256 } }; + const lockPath = join(scratch, 'netcoredbg.lock.json'); + writeFileSync(lockPath, JSON.stringify(lock)); + + return new Promise((done, fail) => { + const child = spawn(process.execPath, [PROVIDE, PLATFORM], { + cwd: ROOT, + env: { ...process.env, SHARPLSP_NETCOREDBG_LOCK: lockPath }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', fail); + child.on('close', (status) => done({ status, stdout, stderr })); + }); +} + +/** Serves whatever `served` currently holds, so each test can swap the bytes. */ +let served = Buffer.alloc(0); + +before(async () => { + scratch = mkdtempSync(join(tmpdir(), 'sharplsp-netcoredbg-')); + server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/gzip' }); + response.end(served); + }); + await new Promise((ready) => server.listen(0, '127.0.0.1', ready)); + baseUrl = `http://127.0.0.1:${server.address().port}/netcoredbg-${PLATFORM}.tar.gz`; +}); + +beforeEach(() => { + rmSync(OUTPUT, { recursive: true, force: true }); +}); + +after(() => { + server?.close(); + rmSync(scratch, { recursive: true, force: true }); + rmSync(OUTPUT, { recursive: true, force: true }); +}); + +test('a pinned artifact whose digest matches is downloaded and unpacked', async () => { + served = buildArchive('patched-adapter'); + const digest = createHash('sha256').update(served).digest('hex'); + + const result = await provide(digest); + + assert.equal(result.status, 0, `provide.mjs failed: ${result.stderr}`); + assert.ok(existsSync(join(OUTPUT, 'netcoredbg')), 'verified archive should be unpacked'); + assert.equal( + readFileSync(join(OUTPUT, 'netcoredbg'), 'utf8'), + 'patched-adapter', + 'the unpacked adapter should be the bytes that were served', + ); + // The marker is what lets a later run - and the CI cache - recognise this as + // the build the lock file describes. + assert.ok( + existsSync(join(OUTPUT, '.sharplsp-dap-hot-reload')), + 'the build-id marker should be written after a verified unpack', + ); + assert.ok( + !existsSync(join(OUTPUT, 'netcoredbg-download.tar.gz')), + 'the staged download should be cleaned up', + ); +}); + +test('an adapter already on disk is not downloaded again', async () => { + served = buildArchive('patched-adapter'); + const digest = createHash('sha256').update(served).digest('hex'); + await provide(digest); + + const second = await provide(digest); + + assert.equal(second.status, 0); + assert.match( + second.stdout, + /already available/, + 'a second call should short-circuit on the marker, not re-download', + ); +}); + +test('a digest mismatch REFUSES, and does not fall back to a source build', async () => { + served = buildArchive('tampered-adapter'); + const wrong = createHash('sha256').update('something else entirely').digest('hex'); + + const result = await provide(wrong); + + assert.notEqual(result.status, 0, 'a digest mismatch must fail the build'); + assert.match(result.stderr, /SHA-256 MISMATCH/, `expected a mismatch diagnostic: ${result.stderr}`); + assert.ok( + !existsSync(join(OUTPUT, 'netcoredbg')), + 'nothing may be unpacked from an archive that failed verification', + ); + // The whole point of the pin: a bad digest is an alarm, not a reason to + // quietly compile the adapter from source instead. + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /building from source/, + 'a mismatch must not fall back to a source build', + ); +}); + +test('the lock file is the only place the pinned commits are written down', () => { + const lock = JSON.parse(readFileSync(LOCK, 'utf8')); + for (const field of ['netcoredbgCommit', 'coreclrCommit', 'patchVersion']) { + assert.ok(lock[field], `netcoredbg.lock.json must declare ${field}`); + } + // A second copy of a commit in the build script is how a pin and the + // artifact it is supposed to describe drift apart. + const script = readFileSync(join(ROOT, 'tools', 'vsix', 'build-netcoredbg.sh'), 'utf8'); + assert.ok( + !script.includes(lock.netcoredbgCommit), + 'build-netcoredbg.sh must read the commit from the lock file, not hardcode it', + ); +}); + +test('an unsupported platform skips cleanly instead of failing the build', () => { + // darwin-x64 and win32-arm64 have no patched build; the extension falls back + // to PATH / sharplsp.debug.netcoredbgPath, so this must not be an error. + const result = spawnSync(process.execPath, [PROVIDE, 'darwin-x64'], { + cwd: ROOT, + encoding: 'utf8', + }); + assert.equal(result.status, 0, 'an unsupported platform must not fail the build'); + assert.match(result.stderr, /no patched build/); +}); + +test('an unknown platform is a hard error', () => { + const result = spawnSync(process.execPath, [PROVIDE, 'bogus-arch'], { + cwd: ROOT, + encoding: 'utf8', + }); + assert.notEqual(result.status, 0, 'a typo in a platform triple must not pass silently'); + assert.match(result.stderr, /unknown platform/); +}); diff --git a/tools/netcoredbg/netcoredbg.lock.json b/tools/netcoredbg/netcoredbg.lock.json new file mode 100644 index 00000000..27565a85 --- /dev/null +++ b/tools/netcoredbg/netcoredbg.lock.json @@ -0,0 +1,30 @@ +{ + "_comment": [ + "[DIST-DEBUGGER-BUNDLE] The single source of truth for which netcoredbg", + "SharpLsp ships. Nothing else in the repo may hardcode these commits.", + "", + "SharpLsp ships a PATCHED netcoredbg: tools/netcoredbg/dap-hot-reload.patch", + "exposes netcoredbg's existing ICorDebug ApplyChanges implementation over the", + "VS Code DAP protocol, which is what backs the shipped sharplsp.hotReload.*", + "commands. Stock upstream release binaries do NOT carry it, so the artifacts", + "below are OUR builds and cannot be replaced by an upstream download.", + "", + "CHAIN OF CUSTODY. `platforms` pins a URL and a SHA-256 per platform.", + "tools/netcoredbg/provide.mjs downloads the URL, hashes the bytes, and", + "REFUSES to continue on a mismatch - it never silently falls back to a source", + "build, because a fallback on mismatch would defeat the pin. A platform with", + "no entry is built from source instead, which is the bootstrap path and is", + "reported loudly.", + "", + "TO POPULATE: run the `Publish netcoredbg` workflow", + "(.github/workflows/publish-netcoredbg.yml). It builds every platform from", + "the commits below, attests the provenance of each archive, publishes them to", + "a GitHub release, and prints the exact `platforms` block to paste here.", + "Bumping netcoredbgCommit, coreclrCommit or patchVersion invalidates the", + "pins: clear `platforms` and re-publish." + ], + "netcoredbgCommit": "9744e1f051866215611b8440c638042aa2aa2f72", + "coreclrCommit": "ea346eaeda73d7ef1cc3b148939ac83729cc38dc", + "patchVersion": "dap-hot-reload-v1", + "platforms": {} +} diff --git a/tools/netcoredbg/print-pins.mjs b/tools/netcoredbg/print-pins.mjs new file mode 100644 index 00000000..6193fe38 --- /dev/null +++ b/tools/netcoredbg/print-pins.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// [DIST-DEBUGGER-BUNDLE] Prints the `platforms` block for netcoredbg.lock.json +// from the archives publish-netcoredbg.yml just uploaded. +// +// Hand-copying three URLs and three 64-character digests is exactly the kind of +// transcription a supply-chain pin cannot survive, so the digests are computed +// from the same files that were published and emitted ready to paste. +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const [directory, tag] = process.argv.slice(2); +if (!directory || !tag) { + console.error('usage: print-pins.mjs <archive-dir> <release-tag>'); + process.exit(1); +} + +const repository = process.env.GITHUB_REPOSITORY ?? 'Nimblesite/SharpLsp'; +const platforms = {}; + +for (const file of readdirSync(directory).sort()) { + if (!file.endsWith('.tar.gz')) continue; + const platform = file.replace(/^netcoredbg-/, '').replace(/\.tar\.gz$/, ''); + platforms[platform] = { + url: `https://github.com/${repository}/releases/download/${tag}/${file}`, + sha256: createHash('sha256').update(readFileSync(join(directory, file))).digest('hex'), + }; +} + +console.log('\nPaste this as the "platforms" value in tools/netcoredbg/netcoredbg.lock.json:\n'); +console.log(JSON.stringify(platforms, null, 2)); diff --git a/tools/netcoredbg/provide.mjs b/tools/netcoredbg/provide.mjs new file mode 100644 index 00000000..b474ada9 --- /dev/null +++ b/tools/netcoredbg/provide.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// [DIST-DEBUGGER-BUNDLE] Guarantees that target/netcoredbg/<platform>/netcoredbg +// holds the patched debug adapter SharpLsp ships, and returns without doing any +// work when it already does. +// +// PREFER THE PINNED ARTIFACT. When netcoredbg.lock.json pins a URL and SHA-256 +// for the platform, this downloads it, hashes the bytes it actually received, +// and unpacks it only if the digest matches. A mismatch is FATAL: it never +// falls back to a source build, because falling back would turn a supply-chain +// alarm into a silent recompile and defeat the pin entirely. +// +// SOURCE BUILD IS THE BOOTSTRAP PATH. A platform with no pin is compiled from +// the commits in the lock file by build-netcoredbg.sh. That is how the pinned +// archives are produced in the first place (publish-netcoredbg.yml), and how a +// developer works on the patch. It is reported loudly, because a release built +// that way has no attested provenance. +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..', '..'); +// The override exists so the end-to-end test can drive a real download against +// a real digest without editing the committed pins. +const LOCK_PATH = process.env.SHARPLSP_NETCOREDBG_LOCK || join(HERE, 'netcoredbg.lock.json'); +const MARKER_NAME = '.sharplsp-dap-hot-reload'; + +/** Platforms with no configured native build ([DIST-DEBUGGER-BUNDLE]). */ +const UNSUPPORTED = new Set(['win32-arm64', 'darwin-x64']); +const SUPPORTED = new Set(['linux-x64', 'linux-arm64', 'darwin-arm64', 'win32-x64']); + +/** The lock file is the ONLY place these commits are written down. */ +export function readLock() { + return JSON.parse(readFileSync(LOCK_PATH, 'utf8')); +} + +/** Identifies exactly which build an on-disk adapter is, for the marker file. */ +export function buildId(lock) { + return `${lock.netcoredbgCommit}:${lock.patchVersion}`; +} + +function outputDir(platform) { + return join(ROOT, 'target', 'netcoredbg', platform, 'netcoredbg'); +} + +function executable(platform) { + return join(outputDir(platform), platform === 'win32-x64' ? 'netcoredbg.exe' : 'netcoredbg'); +} + +/** True when the adapter already on disk is the one the lock file describes. */ +function alreadyProvided(platform, id) { + const marker = join(outputDir(platform), MARKER_NAME); + if (!existsSync(executable(platform)) || !existsSync(marker)) return false; + return readFileSync(marker, 'utf8').trim() === id; +} + +function run(command, args, label, cwd = ROOT) { + const result = spawnSync(command, args, { stdio: 'inherit', cwd, shell: false }); + if (result.error) throw new Error(`${label}: ${result.error.message}`); + if (result.status !== 0) throw new Error(`${label}: exited ${result.status}`); +} + +/** Archive member name used for the staged download, see extract(). */ +const DOWNLOAD_NAME = 'netcoredbg-download.tar.gz'; + +// GNU tar reads an argument containing a colon as `host:path` and tries to reach +// a remote machine, so a Windows absolute path like C:\... fails with "Cannot +// connect to C: resolve failed". bsdtar - which is what ships in System32 - +// accepts those paths but rejects GNU's `--force-local`, so neither an absolute +// path nor that flag is portable across the two tars a Windows runner may +// resolve. Extracting with `cwd` set and a bare relative filename keeps every +// argument colon-free and works on both. +function extract(destination) { + run('tar', ['-xzf', DOWNLOAD_NAME, '--strip-components=1'], 'tar', destination); +} + +async function downloadPinned(platform, pin, id) { + console.log(`netcoredbg: fetching pinned ${platform} adapter\n ${pin.url}`); + const response = await fetch(pin.url, { redirect: 'follow' }); + if (!response.ok) { + throw new Error(`netcoredbg: download failed with HTTP ${response.status} for ${pin.url}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + const digest = createHash('sha256').update(bytes).digest('hex'); + if (digest !== pin.sha256) { + // Deliberately fatal. See the header: never recompile past a bad digest. + throw new Error( + `netcoredbg: SHA-256 MISMATCH for ${platform}\n` + + ` expected ${pin.sha256}\n` + + ` received ${digest}\n` + + ` from ${pin.url}\n` + + 'Refusing to unpack. Either the pin is stale or the artifact was tampered with.', + ); + } + + const destination = outputDir(platform); + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + const archive = join(destination, DOWNLOAD_NAME); + writeFileSync(archive, bytes); + extract(destination); + rmSync(archive, { force: true }); + + if (!existsSync(executable(platform))) { + throw new Error(`netcoredbg: archive for ${platform} contained no ${executable(platform)}`); + } + writeFileSync(join(destination, MARKER_NAME), `${id}\n`); + console.log(`netcoredbg: verified ${digest} and unpacked to ${destination}`); +} + +function buildFromSource(platform) { + console.warn( + `netcoredbg: no pinned artifact for '${platform}' in netcoredbg.lock.json - ` + + 'building from source. A release built this way has NO attested provenance; ' + + 'run the "Publish netcoredbg" workflow and pin the result.', + ); + run('bash', [join('tools', 'vsix', 'build-netcoredbg.sh'), platform], 'build-netcoredbg.sh'); +} + +export async function provide(platform) { + if (UNSUPPORTED.has(platform)) { + console.warn(`netcoredbg: no patched build for '${platform}' - using configured/PATH fallback`); + return false; + } + if (!SUPPORTED.has(platform)) throw new Error(`netcoredbg: unknown platform '${platform}'`); + + const lock = readLock(); + const id = buildId(lock); + if (alreadyProvided(platform, id)) { + console.log(`netcoredbg: patched build already available at ${executable(platform)}`); + return true; + } + + const pin = lock.platforms?.[platform]; + if (pin) await downloadPinned(platform, pin, id); + else buildFromSource(platform); + return true; +} + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + const platform = process.argv[2] ?? `${process.platform}-${process.arch}`; + provide(platform).catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/tools/netcoredbg/read-lock.mjs b/tools/netcoredbg/read-lock.mjs new file mode 100644 index 00000000..994b4096 --- /dev/null +++ b/tools/netcoredbg/read-lock.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// [DIST-DEBUGGER-BUNDLE] Prints one field of netcoredbg.lock.json, so the shell +// build script can read the pinned commits without keeping its own copy of them +// and without grepping JSON. +// +// `buildId` is synthesised rather than stored: it is the identity written into +// the on-disk marker file, and deriving it in one place keeps the marker, the +// pins and the source build describing the same artifact. +import { buildId, readLock } from './provide.mjs'; + +const field = process.argv[2]; +const lock = readLock(); +const value = field === 'buildId' ? buildId(lock) : lock[field]; + +if (typeof value !== 'string') { + console.error(`netcoredbg: no string field '${field}' in netcoredbg.lock.json`); + process.exit(1); +} +process.stdout.write(value); diff --git a/tools/vsix/build-netcoredbg.sh b/tools/vsix/build-netcoredbg.sh index fb23a3c0..5d9c4020 100755 --- a/tools/vsix/build-netcoredbg.sh +++ b/tools/vsix/build-netcoredbg.sh @@ -4,9 +4,14 @@ # ICorDebug ApplyChanges implementation to its VS Code protocol. set -euo pipefail -NETCOREDBG_COMMIT="9744e1f051866215611b8440c638042aa2aa2f72" -CORECLR_COMMIT="ea346eaeda73d7ef1cc3b148939ac83729cc38dc" -BUILD_ID="$NETCOREDBG_COMMIT:dap-hot-reload-v1" +# [DIST-DEBUGGER-BUNDLE] The commits and the patch version live in +# tools/netcoredbg/netcoredbg.lock.json, which is also what pins the SHA-256 +# of the published artifacts. Read them from there rather than keeping a +# second copy here that can drift out of step with the pins. +LOCK_READER="$(cd "$(dirname "${BASH_SOURCE[0]}")/../netcoredbg" && pwd)/read-lock.mjs" +NETCOREDBG_COMMIT="$(node "$LOCK_READER" netcoredbgCommit)" +CORECLR_COMMIT="$(node "$LOCK_READER" coreclrCommit)" +BUILD_ID="$(node "$LOCK_READER" buildId)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" diff --git a/tools/vsix/fetch-netcoredbg.sh b/tools/vsix/fetch-netcoredbg.sh index 94d3c22f..286fb205 100755 --- a/tools/vsix/fetch-netcoredbg.sh +++ b/tools/vsix/fetch-netcoredbg.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash -# Build the pinned netcoredbg source plus SharpLsp's DAP hot-reload extension, -# then stage it into the VS Code extension. Implements [DIST-DEBUGGER-BUNDLE]. +# Stage SharpLsp's patched netcoredbg into the VS Code extension. +# Implements [DIST-DEBUGGER-BUNDLE]. +# +# This script does NOT decide how the adapter is obtained. That is +# tools/netcoredbg/provide.mjs, which prefers the SHA-256-pinned artifact in +# netcoredbg.lock.json and only compiles from source when a platform has no +# pin. Releases must ship the pinned artifact so the bytes users debug with +# have attested provenance. # # netcoredbg is MIT-licensed (© 2017 Samsung Electronics Co., LTD) — attribution # is in THIRD-PARTY-NOTICES.md. Platforms without a configured native build @@ -35,7 +41,7 @@ BUILT="$ROOT/target/netcoredbg/$PLATFORM/netcoredbg" BUILT_EXE="$BUILT/netcoredbg$EXE_EXT" BUILT_MARKER="$BUILT/.sharplsp-dap-hot-reload" if [ ! -f "$BUILT_EXE" ] || [ ! -f "$BUILT_MARKER" ]; then - bash "$SCRIPT_DIR/build-netcoredbg.sh" "$PLATFORM" + node "$ROOT/tools/netcoredbg/provide.mjs" "$PLATFORM" fi if [ ! -f "$BUILT_EXE" ] || [ ! -f "$BUILT_MARKER" ]; then echo "netcoredbg: patched build missing at $BUILT_EXE" >&2