Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .config/coverage/thresholds.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{
"_agent_pmo": "2efd847",
"default_threshold": 90,
"sharplsp": {
"line_percent": 95.0
},
"sharplsp-zed": {
"line_percent": 85.04
},
"sharplsp-rider": {
"line_percent": 4.09
},
"vscode-extension": {
"line_percent": 94.0
},
Expand Down
84 changes: 84 additions & 0 deletions .github/workflows/ci-editors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# agent-pmo:0b21609
---
# Reusable editor-integration leg of the PR pipeline (called by ci.yml).
#
# 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
'on':
workflow_call: {}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
permissions:
contents: read
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.
runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
components: llvm-tools-preview
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: src/editors/zed
- uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2
with:
tool: cargo-llvm-cov
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
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
# into a hard failure. Without it a toolchain regression would present as a
# passing job that ran nothing.
runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
RIDER_REQUIRED: '1'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '21'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
# kover-line-percent.cs is a `dotnet run --file` app, matching how
# merge-cobertura.cs reads the sidecar reports.
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.300
# The IntelliJ Platform SDK is a multi-GB download resolved through
# Gradle. Uncached, it dominates this job's wall time.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.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/**') }}"
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.
run: make _build-rider
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# 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-editors.yml Zed + Rider tests and coverage gates ([DIST-CI-EDITORS])
#
# Every leg is gated on `detect-changes`, and none of them `needs:` another:
# lint and tests are independent required gates, and serializing tests behind
Expand Down Expand Up @@ -139,6 +140,12 @@ jobs:
- detect-changes
uses: ./.github/workflows/ci-vsix-windows.yml
if: ${{ needs.detect-changes.outputs.code_changed == 'true' }}
editors:
name: Editors
needs:
- detect-changes
uses: ./.github/workflows/ci-editors.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,
Expand Down
25 changes: 24 additions & 1 deletion src/editors/rider/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ plugins {
// 2.14 is the current stable intellij-platform Gradle plugin release;
// 2.2 was rejected by the platform with an "outdated" warning.
id("org.jetbrains.intellij.platform") version "2.14.0"
// Line coverage for the Rider plugin, gated by the repo-wide ratchet in
// .config/coverage/thresholds.json. [DIST-CI-RIDER]
id("org.jetbrains.kotlinx.kover") version "0.9.2"
}

group = providers.gradleProperty("pluginGroup").get()
Expand Down Expand Up @@ -52,6 +55,13 @@ dependencies {

testImplementation("org.junit.jupiter:junit-jupiter:5.11.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// The platform test framework registers `com.intellij.tests.
// JUnit5TestSessionListener`, whose constructor loads `junit.framework.
// TestCase`. Without JUnit 4 on the runtime classpath the listener fails to
// instantiate and the whole test task dies before a single test runs --
// NoClassDefFoundError, not a test failure. Required even though every test
// here is JUnit 5.
testRuntimeOnly("junit:junit:4.13.2")
}

intellijPlatform {
Expand Down Expand Up @@ -89,7 +99,20 @@ intellijPlatform {

tasks {
test {
useJUnitPlatform()
useJUnitPlatform {
// The platform test framework also puts junit-vintage on the
// classpath, and that build is compiled against a newer
// junit-platform-commons than junit-jupiter 5.11.3 ships
// (`support.scanning.ClassFilter`). Vintage then dies during
// DISCOVERY, which aborts the whole task before any test runs.
// Every test here is JUnit 5, so only Jupiter is asked to discover.
includeEngines("junit-jupiter")
}

// A `test` task that discovers nothing reports BUILD SUCCESSFUL. This
// project had a fully configured test harness and not one test file for
// its entire history, so the green was meaningless. Fail instead.
failOnNoDiscoveredTests = true
}

// Let Gradle wire the wrapper task so `./gradlew wrapper` regenerates.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package com.forgelsp.rider.toolwindow.nuget

import com.forgelsp.rider.lsp.InstalledPackage
import com.forgelsp.rider.lsp.PackageInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

/**
* `NuGetState` decides what the browser renders. It is the only part of the
* Rider plugin with branching logic that does not need a running IDE, and the
* merge it performs is not obvious: a search response does not know what is
* installed, so the state has to enrich it — case-insensitively, because NuGet
* ids are not case-sensitive but the two responses do not agree on casing.
*/
class NuGetStateTest {
private fun info(id: String, version: String) =
PackageInfo(id = id, version = version, description = "desc for $id")

private fun installed(id: String, resolved: String) =
InstalledPackage(id = id, requestedVersion = resolved, resolvedVersion = resolved)

/**
* The Browse response carries `isInstalled = false` for everything. If the
* state did not merge, an already-installed package would offer "Install"
* again.
*/
@Test
fun `browse marks a package installed from the installed list`() {
val state = NuGetState()
state.setBrowse(listOf(info("Serilog", "4.2.0"), info("Newtonsoft.Json", "13.0.3")))
state.setInstalled(listOf(installed("Serilog", "4.1.0")))

val visible = state.visible()

assertEquals(2, visible.size)
val serilog = visible.first { it.info.id == "Serilog" }
assertTrue(serilog.info.isInstalled, "Serilog is installed and must render as such")
assertEquals("4.1.0", serilog.info.installedVersion)

val newtonsoft = visible.first { it.info.id == "Newtonsoft.Json" }
assertFalse(newtonsoft.info.isInstalled)
assertNull(newtonsoft.info.installedVersion)
}

/** NuGet ids are case-insensitive; the two responses need not agree. */
@Test
fun `browse matches the installed list ignoring case`() {
val state = NuGetState()
state.setBrowse(listOf(info("Serilog", "4.2.0")))
state.setInstalled(listOf(installed("serilog", "4.1.0")))

val serilog = state.visible().single()

assertTrue(serilog.info.isInstalled, "casing must not decide installed-ness")
assertEquals("4.1.0", serilog.info.installedVersion)
}

/** The Installed tab is a list the user scans, so ordering is part of it. */
@Test
fun `installed tab sorts by id ignoring case`() {
val state = NuGetState()
state.tab = Tab.INSTALLED
state.setInstalled(
listOf(
installed("zzTop", "1.0.0"),
installed("Alpha", "2.0.0"),
installed("beta", "3.0.0"),
),
)

val ids = state.visible().map { it.info.id }

assertEquals(listOf("Alpha", "beta", "zzTop"), ids)
}

/**
* When the Browse tab has already fetched metadata, the Installed tab
* reuses it rather than showing the placeholder description.
*/
@Test
fun `installed tab reuses cached browse metadata`() {
val state = NuGetState()
state.tab = Tab.INSTALLED
state.setBrowse(listOf(info("Serilog", "4.2.0")))
state.setInstalled(listOf(installed("Serilog", "4.1.0")))

val serilog = state.visible().single()

assertEquals("desc for Serilog", serilog.info.description)
assertTrue(serilog.info.isInstalled)
assertEquals("4.1.0", serilog.info.installedVersion, "the resolved version wins")
}

/** With no cached metadata there is still a row, built from what is known. */
@Test
fun `installed tab synthesises a row without cached metadata`() {
val state = NuGetState()
state.tab = Tab.INSTALLED
state.setInstalled(listOf(installed("Serilog", "4.1.0")))

val serilog = state.visible().single()

assertEquals("Serilog", serilog.info.id)
assertEquals("4.1.0", serilog.info.version)
assertTrue(serilog.info.isInstalled)
assertEquals("Installed package", serilog.info.description)
}

/** A row with an in-flight operation must render its spinner in both tabs. */
@Test
fun `a pending package is flagged in both tabs`() {
val state = NuGetState()
state.setBrowse(listOf(info("Serilog", "4.2.0")))
state.setInstalled(listOf(installed("Serilog", "4.1.0")))
state.pending += "Serilog"

assertTrue(state.visible().single().pending, "browse tab")

state.tab = Tab.INSTALLED
assertTrue(state.visible().single().pending, "installed tab")
}

@Test
fun `installed lookups ignore case and miss cleanly`() {
val state = NuGetState()
state.setInstalled(listOf(installed("Serilog", "4.1.0")))

assertTrue(state.isInstalled("SERILOG"))
assertEquals("4.1.0", state.installedVersion("serilog"))

assertFalse(state.isInstalled("Newtonsoft.Json"))
assertNull(state.installedVersion("Newtonsoft.Json"))
}

/** A fresh browser has nothing to show and must not throw doing so. */
@Test
fun `an empty state renders nothing in either tab`() {
val state = NuGetState()

assertTrue(state.visible().isEmpty(), "browse tab")

state.tab = Tab.INSTALLED
assertTrue(state.visible().isEmpty(), "installed tab")
}
}
Loading
Loading