From b057cbd0090c63b2669e838cdc8eebd181f897e3 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 2 Jun 2026 17:13:35 +0800 Subject: [PATCH 1/4] fix: use maven-metadata.xml to look up the latest test framework version The previous implementation queried search.maven.org's Solr endpoint and returned the `latestVersion` field. That index has been frozen since around May 2025 (a side effect of the Sonatype migration to central.sonatype.com), so the endpoint now serves stale data. For example, the latest version it reports for `junit-platform-console-standalone` is `1.13.0-M3` (a JUnit 5 milestone), while the actual current stable on Maven Central is `6.1.0`. As a result, `Enable Java Tests` ends up downloading either that milestone or, after falling back, the hard-coded `1.9.3`. Switch the lookup to the artifact's own `maven-metadata.xml` on repo1.maven.org, which is the authoritative metadata maintained alongside the artifact: * Prefer the `` tag (Maven's pointer to the latest non-snapshot version). * Fall back to scanning `` entries from newest to oldest for the first stable build. * Pre-release qualifiers (`-M*`, `-RC*`, `-beta`, `-SNAPSHOT`, etc.) are rejected via `isStableVersion`. Also bump the JUnit Jupiter `defaultVersion` from `1.9.3` to `6.1.0` so the offline fallback matches a currently published stable. Related to #1866 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/commands/testDependenciesCommands.ts | 44 ++++++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/commands/testDependenciesCommands.ts b/src/commands/testDependenciesCommands.ts index 4f88abd8..ef6e7fe8 100644 --- a/src/commands/testDependenciesCommands.ts +++ b/src/commands/testDependenciesCommands.ts @@ -138,7 +138,7 @@ function getJarIds(testKind: TestKind): IArtifactMetadata[] { return [{ groupId: 'org.junit.platform', artifactId: 'junit-platform-console-standalone', - defaultVersion: '1.9.3', + defaultVersion: '6.1.0', }]; case TestKind.JUnit: return [{ @@ -172,13 +172,29 @@ function getJarIds(testKind: TestKind): IArtifactMetadata[] { async function getLatestVersion(groupId: string, artifactId: string): Promise { try { - const response: any = await getHttpsAsJSON(getQueryLink(groupId, artifactId)); + const xml: string = await getHttpsAsText(getMetadataLink(groupId, artifactId)); - if (!response.response?.docs?.[0]?.latestVersion) { - sendError(new Error('Invalid format for the latest version response')); - return undefined; + // Prefer the tag (Maven's authoritative pointer to the latest non-snapshot version). + const releaseMatch: RegExpMatchArray | null = xml.match(/([^<]+)<\/release>/); + if (releaseMatch && isStableVersion(releaseMatch[1])) { + return releaseMatch[1]; } - return response.response.docs[0].latestVersion; + + // Fallback: scan entries (chronologically ordered) for the newest stable version, + // in case is missing or points to a milestone / RC. + const versionRegex: RegExp = /([^<]+)<\/version>/g; + const versions: string[] = []; + let match: RegExpExecArray | null; + while ((match = versionRegex.exec(xml)) !== null) { + versions.push(match[1]); + } + for (let i: number = versions.length - 1; i >= 0; i--) { + if (isStableVersion(versions[i])) { + return versions[i]; + } + } + + sendError(new Error(`No stable version found in maven-metadata.xml for ${groupId}:${artifactId}`)); } catch (e) { sendError(new Error(`Failed to fetch the latest version for ${groupId}:${artifactId}`)); } @@ -186,6 +202,13 @@ async function getLatestVersion(groupId: string, artifactId: string): Promise { +async function getHttpsAsText(link: string): Promise { // eslint-disable-next-line @typescript-eslint/typedef - const response: string = await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let result: string = ''; https.get(link, { headers: { @@ -295,7 +318,6 @@ async function getHttpsAsJSON(link: string): Promise { res.on('error', reject); }); }); - return JSON.parse(response); } async function getTotalBytes(url: string): Promise { @@ -315,8 +337,8 @@ async function getTotalBytes(url: string): Promise { }); } -function getQueryLink(groupId: string, artifactId: string): string { - return `https://search.maven.org/solrsearch/select?q=id:%22${groupId}:${artifactId}%22&rows=1&wt=json`; +function getMetadataLink(groupId: string, artifactId: string): string { + return `https://repo1.maven.org/maven2/${groupId.split('.').join('/')}/${artifactId}/maven-metadata.xml`; } function getDownloadLink(groupId: string, artifactId: string, version: string): string { From 7c859409039945b429ee28357671d68e5b49de87 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Wed, 3 Jun 2026 10:13:16 +0800 Subject: [PATCH 2/4] test: guard against stale Maven Central data sources Add three layers of regression coverage so that a silent upstream change to the Maven Central data sources used by the "Enable Java Tests" flow cannot reappear unnoticed for a year (cf. #1866, where the legacy search.maven.org Solr index was frozen and kept returning 1.13.0-M3 as the "latest" junit-platform-console-standalone). 1. Tighten the existing integration test in test/unmanaged-folder-suite/enableTests.test.ts so it only accepts a stable release jar (junit-platform-console-standalone-X.Y.Z.jar) instead of any filename containing "junit-platform-console-standalone". 2. Add a focused unit test for the XML parser (test/suite/testDependenciesCommands.test.ts) covering: - being a stable version, - being a milestone (fallback to ), - missing entirely, - newest being a pre-release, - all entries being pre-releases (returns undefined), - malformed input. Exposed parseLatestStableVersion / isStableVersion via the existing exportedForTesting convention. 3. Add a CI health check (scripts/checkVersionSources.js + .github/workflows/health-check.yml) that, for every artifact the extension fetches from Maven Central, downloads maven-metadata.xml, parses what production would pick, and HEADs the resolved .jar URL. Runs on every PR/push and weekly via cron; the scheduled run opens (or comments on) a tracking issue labelled ci-health on failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/health-check.yml | 92 +++++++++ scripts/checkVersionSources.js | 186 ++++++++++++++++++ src/commands/testDependenciesCommands.ts | 55 ++++-- test/suite/testDependenciesCommands.test.ts | 123 ++++++++++++ .../enableTests.test.ts | 19 +- 5 files changed, 450 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/health-check.yml create mode 100644 scripts/checkVersionSources.js create mode 100644 test/suite/testDependenciesCommands.test.ts diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml new file mode 100644 index 00000000..7ade0ec2 --- /dev/null +++ b/.github/workflows/health-check.yml @@ -0,0 +1,92 @@ +name: Health Check - Maven Central Data Sources + +# Verifies that the upstream Maven Central data sources used by the +# "Enable Java Tests" download flow (see scripts/checkVersionSources.js) +# are still healthy. This catches situations like microsoft/vscode-java-test#1866, +# where the legacy search.maven.org Solr index was silently frozen and +# kept returning a pre-release as the "latest" stable version. +# +# - Runs on every PR and push to main so a code change cannot break the lookup. +# - Runs weekly so purely upstream changes are caught within days. +# - Can be triggered manually from the Actions tab. + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + # Monday 09:00 UTC. + - cron: '0 9 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-version-sources: + name: Check Maven Central version sources + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Run version source health check + run: node scripts/checkVersionSources.js + + - name: Open issue on scheduled failure + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const title = '[health-check] Maven Central data source check failed'; + const body = [ + 'The scheduled health check for the Maven Central data sources used by', + '`scripts/checkVersionSources.js` failed.', + '', + `Failed run: ${runUrl}`, + '', + 'This typically means one of the following:', + '- An upstream `maven-metadata.xml` is no longer reachable (HTTP 4xx/5xx).', + '- An upstream `` tag has drifted to a pre-release version', + ' (e.g. `-M3`, `-RC1`, `-beta-1`).', + '- A jar download URL is no longer reachable.', + '', + 'See microsoft/vscode-java-test#1866 for the original failure mode.', + '', + 'Please investigate before users start hitting the broken download.', + ].join('\n'); + + const { data: existing } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'ci-health', + per_page: 100, + }); + const alreadyOpen = existing.find((issue) => issue.title === title); + if (alreadyOpen) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: alreadyOpen.number, + body: `Health check failed again. Run: ${runUrl}`, + }); + core.info(`Commented on existing issue #${alreadyOpen.number}.`); + } else { + const { data: created } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['ci-health'], + }); + core.info(`Opened issue #${created.number}.`); + } diff --git a/scripts/checkVersionSources.js b/scripts/checkVersionSources.js new file mode 100644 index 00000000..658ca05a --- /dev/null +++ b/scripts/checkVersionSources.js @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Health check for the Maven Central data sources that + * `src/commands/testDependenciesCommands.ts` relies on for the + * "Enable Java Tests" download flow. + * + * For every artifact that vscode-java-test fetches at runtime, this script: + * + * 1. Downloads the artifact's `maven-metadata.xml` from repo1.maven.org. + * 2. Extracts the `` element and asserts it is a stable version + * (dot-separated digits only — no `-M3`, `-RC1`, `-beta-1`, etc.). + * 3. Issues an HTTP HEAD against the resolved `.jar` download URL and + * asserts a 2xx response. + * + * This guards against silent upstream drift, e.g. + * - the data source moving / being deprecated (microsoft/vscode-java-test#1866, + * where the legacy search.maven.org Solr index was frozen for ~a year + * and kept returning a milestone build as the "latest"), + * - a maintainer accidentally publishing a pre-release as ``, + * - the jar layout under repo1.maven.org changing. + * + * Runs on PRs (so a code change that breaks the lookup never lands) and + * on a weekly cron (so a purely upstream change is caught within days + * instead of months). + * + * Pure Node, no dependencies — works before `npm install` runs. + */ + +'use strict'; + +const ARTIFACTS = [ + // JUnit 5 / Jupiter (the path that originally surfaced #1866). + { groupId: 'org.junit.platform', artifactId: 'junit-platform-console-standalone' }, + + // JUnit 4 + its hamcrest-core dependency. + { groupId: 'junit', artifactId: 'junit' }, + // hamcrest-core is pinned to 1.3 in the extension; verify both that 1.3 + // still resolves and that the artifact metadata is reachable. + { groupId: 'org.hamcrest', artifactId: 'hamcrest-core', pinnedVersion: '1.3' }, + + // TestNG + its transitive deps that we ship. + { groupId: 'org.testng', artifactId: 'testng' }, + { groupId: 'com.beust', artifactId: 'jcommander' }, + { groupId: 'org.slf4j', artifactId: 'slf4j-api' }, +]; + +const MAX_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 2000; +const REQUEST_TIMEOUT_MS = 15000; + +const STABLE_VERSION_REGEX = /^\d+(\.\d+)*$/; + +function groupPath(groupId) { + return groupId.split('.').join('/'); +} + +function metadataUrl(groupId, artifactId) { + return `https://repo1.maven.org/maven2/${groupPath(groupId)}/${artifactId}/maven-metadata.xml`; +} + +function jarUrl(groupId, artifactId, version) { + return `https://repo1.maven.org/maven2/${groupPath(groupId)}/${artifactId}/${version}/${artifactId}-${version}.jar`; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWithRetry(url, init) { + let lastError; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + clearTimeout(timer); + return response; + } catch (err) { + clearTimeout(timer); + lastError = err; + if (attempt < MAX_ATTEMPTS) { + const delay = RETRY_BASE_DELAY_MS * attempt; + console.warn(` ! attempt ${attempt}/${MAX_ATTEMPTS} for ${url} failed: ${err.message}. Retrying in ${delay}ms...`); + await sleep(delay); + } + } + } + throw lastError; +} + +function parseLatestStableVersion(xml) { + const releaseMatch = xml.match(/([^<]+)<\/release>/); + if (releaseMatch && STABLE_VERSION_REGEX.test(releaseMatch[1])) { + return releaseMatch[1]; + } + const versions = []; + const versionRegex = /([^<]+)<\/version>/g; + let match; + while ((match = versionRegex.exec(xml)) !== null) { + versions.push(match[1]); + } + for (let i = versions.length - 1; i >= 0; i--) { + if (STABLE_VERSION_REGEX.test(versions[i])) { + return versions[i]; + } + } + return undefined; +} + +async function checkArtifact(artifact) { + const { groupId, artifactId, pinnedVersion } = artifact; + console.log(`\n== ${groupId}:${artifactId} ==`); + + const metaUrl = metadataUrl(groupId, artifactId); + console.log(` GET ${metaUrl}`); + const metaResponse = await fetchWithRetry(metaUrl); + if (!metaResponse.ok) { + throw new Error(`maven-metadata.xml returned HTTP ${metaResponse.status} ${metaResponse.statusText}`); + } + const xml = await metaResponse.text(); + + const releaseMatch = xml.match(/([^<]+)<\/release>/); + if (releaseMatch) { + const rawRelease = releaseMatch[1]; + if (!STABLE_VERSION_REGEX.test(rawRelease)) { + // Production falls back to the list when + // points to a pre-release, so this is non-fatal — but still + // noisy enough to be worth surfacing in the log. + console.warn(` ! is a pre-release: "${rawRelease}". Falling back to scan.`); + } + } else { + console.warn(' ! tag missing — relying entirely on fallback.'); + } + + const latestStable = parseLatestStableVersion(xml); + if (!latestStable) { + throw new Error('No stable version found in maven-metadata.xml.'); + } + console.log(` ok latest stable version = ${latestStable}`); + + const versionsToProbe = pinnedVersion && pinnedVersion !== latestStable + ? [latestStable, pinnedVersion] + : [latestStable]; + + for (const version of versionsToProbe) { + const downloadUrl = jarUrl(groupId, artifactId, version); + console.log(` HEAD ${downloadUrl}`); + const headResponse = await fetchWithRetry(downloadUrl, { method: 'HEAD' }); + if (!headResponse.ok) { + throw new Error(`jar HEAD returned HTTP ${headResponse.status} ${headResponse.statusText} for ${downloadUrl}`); + } + console.log(` ok jar reachable (HTTP ${headResponse.status})`); + } +} + +async function main() { + console.log('Checking Maven Central data sources for vscode-java-test...'); + const failures = []; + for (const artifact of ARTIFACTS) { + try { + await checkArtifact(artifact); + } catch (err) { + failures.push({ artifact, error: err }); + console.error(` FAIL ${artifact.groupId}:${artifact.artifactId} — ${err.message}`); + } + } + + console.log('\n----'); + if (failures.length === 0) { + console.log(`All ${ARTIFACTS.length} artifacts healthy.`); + return; + } + + console.error(`${failures.length} of ${ARTIFACTS.length} artifact checks FAILED:`); + for (const { artifact, error } of failures) { + console.error(` - ${artifact.groupId}:${artifact.artifactId}: ${error.message}`); + } + process.exit(1); +} + +main().catch((err) => { + console.error('Unexpected error:', err); + process.exit(1); +}); diff --git a/src/commands/testDependenciesCommands.ts b/src/commands/testDependenciesCommands.ts index ef6e7fe8..7740076f 100644 --- a/src/commands/testDependenciesCommands.ts +++ b/src/commands/testDependenciesCommands.ts @@ -173,28 +173,11 @@ function getJarIds(testKind: TestKind): IArtifactMetadata[] { async function getLatestVersion(groupId: string, artifactId: string): Promise { try { const xml: string = await getHttpsAsText(getMetadataLink(groupId, artifactId)); - - // Prefer the tag (Maven's authoritative pointer to the latest non-snapshot version). - const releaseMatch: RegExpMatchArray | null = xml.match(/([^<]+)<\/release>/); - if (releaseMatch && isStableVersion(releaseMatch[1])) { - return releaseMatch[1]; - } - - // Fallback: scan entries (chronologically ordered) for the newest stable version, - // in case is missing or points to a milestone / RC. - const versionRegex: RegExp = /([^<]+)<\/version>/g; - const versions: string[] = []; - let match: RegExpExecArray | null; - while ((match = versionRegex.exec(xml)) !== null) { - versions.push(match[1]); - } - for (let i: number = versions.length - 1; i >= 0; i--) { - if (isStableVersion(versions[i])) { - return versions[i]; - } + const version: string | undefined = parseLatestStableVersion(xml); + if (version === undefined) { + sendError(new Error(`No stable version found in maven-metadata.xml for ${groupId}:${artifactId}`)); } - - sendError(new Error(`No stable version found in maven-metadata.xml for ${groupId}:${artifactId}`)); + return version; } catch (e) { sendError(new Error(`Failed to fetch the latest version for ${groupId}:${artifactId}`)); } @@ -202,6 +185,30 @@ async function getLatestVersion(groupId: string, artifactId: string): Promise tag (Maven's authoritative pointer to the latest non-snapshot version). + const releaseMatch: RegExpMatchArray | null = xml.match(/([^<]+)<\/release>/); + if (releaseMatch && isStableVersion(releaseMatch[1])) { + return releaseMatch[1]; + } + + // Fallback: scan entries (chronologically ordered) for the newest stable version, + // in case is missing or points to a milestone / RC. + const versionRegex: RegExp = /([^<]+)<\/version>/g; + const versions: string[] = []; + let match: RegExpExecArray | null; + while ((match = versionRegex.exec(xml)) !== null) { + versions.push(match[1]); + } + for (let i: number = versions.length - 1; i >= 0; i--) { + if (isStableVersion(versions[i])) { + return versions[i]; + } + } + + return undefined; +} + function isStableVersion(version: string): boolean { // A stable Maven version is composed solely of dot-separated numeric segments // (e.g. 6.1.0, 4.13.2). Anything with a qualifier such as -M3, -RC1, -beta-1 @@ -351,3 +358,9 @@ interface IArtifactMetadata { version?: string; defaultVersion: string; } + +// eslint-disable-next-line @typescript-eslint/typedef +export const exportedForTesting = { + parseLatestStableVersion, + isStableVersion, +} diff --git a/test/suite/testDependenciesCommands.test.ts b/test/suite/testDependenciesCommands.test.ts new file mode 100644 index 00000000..7db2af6b --- /dev/null +++ b/test/suite/testDependenciesCommands.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +'use strict'; + +import * as assert from 'assert'; +import { exportedForTesting } from '../../src/commands/testDependenciesCommands'; + +// tslint:disable: only-arrow-functions +// tslint:disable: no-object-literal-type-assertion + +const { parseLatestStableVersion, isStableVersion } = exportedForTesting; + +suite('testDependenciesCommands - version source parsing', () => { + + suite('isStableVersion', () => { + test('accepts pure dot-separated numeric versions', () => { + assert.strictEqual(isStableVersion('6.1.0'), true); + assert.strictEqual(isStableVersion('4.13.2'), true); + assert.strictEqual(isStableVersion('1.14.4'), true); + assert.strictEqual(isStableVersion('7'), true); + assert.strictEqual(isStableVersion('1.0.0.0'), true); + }); + + test('rejects milestone, RC, beta, snapshot and other qualified versions', () => { + assert.strictEqual(isStableVersion('1.13.0-M3'), false); + assert.strictEqual(isStableVersion('1.0.0-RC1'), false); + assert.strictEqual(isStableVersion('4.13-beta-1'), false); + assert.strictEqual(isStableVersion('4.13-rc-2'), false); + assert.strictEqual(isStableVersion('1.0-SNAPSHOT'), false); + assert.strictEqual(isStableVersion('5.0.0-alpha'), false); + assert.strictEqual(isStableVersion('6.0.0.preview'), false); + assert.strictEqual(isStableVersion(''), false); + }); + }); + + suite('parseLatestStableVersion', () => { + test('returns when it is a stable version', () => { + const xml: string = ` + + + 6.1.0 + + 6.0.0 + 6.1.0 + + + `; + assert.strictEqual(parseLatestStableVersion(xml), '6.1.0'); + }); + + test('falls back to the newest stable when is a milestone', () => { + // This is the exact shape that would have appeared had the upstream + // pointer drifted to a milestone — the same failure mode the stale + // search.maven.org Solr index exhibited for years. + const xml: string = ` + + + 1.13.0-M3 + + 1.13.0-M1 + 1.13.0-M2 + 1.13.0-M3 + 1.14.0 + 1.14.4 + + + `; + assert.strictEqual(parseLatestStableVersion(xml), '1.14.4'); + }); + + test('returns the newest stable when is missing', () => { + const xml: string = ` + + + + 4.13-beta-1 + 4.13 + 4.13.1 + 4.13.2 + + + `; + assert.strictEqual(parseLatestStableVersion(xml), '4.13.2'); + }); + + test('skips trailing pre-releases and picks the most recent stable below them', () => { + // ordering follows publication time, so the newest entry can be a + // pre-release. The parser must walk backwards past it to the latest stable. + const xml: string = ` + + + + 6.0.0 + 6.1.0 + 6.2.0-M1 + 6.2.0-RC1 + + + `; + assert.strictEqual(parseLatestStableVersion(xml), '6.1.0'); + }); + + test('returns undefined when no stable version exists anywhere', () => { + const xml: string = ` + + + 1.0.0-RC1 + + 1.0.0-M1 + 1.0.0-RC1 + + + `; + assert.strictEqual(parseLatestStableVersion(xml), undefined); + }); + + test('returns undefined for malformed input', () => { + assert.strictEqual(parseLatestStableVersion(''), undefined); + assert.strictEqual(parseLatestStableVersion('404 Not Found'), undefined); + }); + }); +}); diff --git a/test/unmanaged-folder-suite/enableTests.test.ts b/test/unmanaged-folder-suite/enableTests.test.ts index d1dec9b7..082ffd53 100644 --- a/test/unmanaged-folder-suite/enableTests.test.ts +++ b/test/unmanaged-folder-suite/enableTests.test.ts @@ -26,15 +26,26 @@ suite('Test Enable Tests', () => { test('test enable tests for unmanaged folder', async () => { await enableTests(TestKind.JUnit5); + // A correctly downloaded artifact must be a stable release of + // junit-platform-console-standalone (no pre-release qualifier such as + // -M3, -RC1, -beta-1, ...). This guards against the stale Maven + // search.maven.org Solr index that used to return milestone versions + // as the "latest" — see microsoft/vscode-java-test#1866. + const STABLE_JAR_PATTERN: RegExp = /^junit-platform-console-standalone-\d+(\.\d+)*\.jar$/; for (let i = 0; i < 5; i++) { if (await fse.pathExists(LIB_PATH)) { const files: string[] = await fse.readdir(LIB_PATH); - const downloaded: boolean = files.some((file) => { - return file.includes('junit-platform-console-standalone'); - }); - if (downloaded) { + const stableJar: string | undefined = files.find((file) => STABLE_JAR_PATTERN.test(file)); + if (stableJar) { return; } + const anyMatching: string[] = files.filter((file) => file.includes('junit-platform-console-standalone')); + if (anyMatching.length > 0) { + assert.fail( + `Downloaded jar is not a stable release. Got: ${anyMatching.join(', ')}. ` + + `Expected a file matching ${STABLE_JAR_PATTERN}.`, + ); + } } await sleep(1000 /*ms*/); } From 68b33a64673d85b494f1c1629155d70197b0f6ba Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Wed, 3 Jun 2026 10:26:31 +0800 Subject: [PATCH 3/4] fix(review): address Copilot review feedback - getHttpsAsText: handle ClientRequest 'error' events (DNS / TLS / socket failures before a response) so the promise no longer hangs forever, and drain the response body on non-2xx instead of leaving the socket open. Accept any 2xx status, not just 200. - getLatestVersion: include the underlying error message in the Error reported to telemetry so transient failures are diagnosable. - health-check workflow: idempotently create the 'ci-health' label before opening / commenting on the tracking issue so that the scheduled-failure branch does not 422 when the label is missing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/health-check.yml | 23 +++++++++++++++++++++-- src/commands/testDependenciesCommands.ts | 17 +++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml index 7ade0ec2..3867e1d5 100644 --- a/.github/workflows/health-check.yml +++ b/.github/workflows/health-check.yml @@ -47,6 +47,7 @@ jobs: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const title = '[health-check] Maven Central data source check failed'; + const labelName = 'ci-health'; const body = [ 'The scheduled health check for the Maven Central data sources used by', '`scripts/checkVersionSources.js` failed.', @@ -64,11 +65,29 @@ jobs: 'Please investigate before users start hitting the broken download.', ].join('\n'); + // Ensure the label exists so issues.create({ labels: [...] }) does not 422. + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + color: 'd93f0b', + description: 'Automated CI health-check failure', + }); + core.info(`Created label "${labelName}".`); + } catch (err) { + if (err.status === 422) { + core.info(`Label "${labelName}" already exists.`); + } else { + throw err; + } + } + const { data: existing } = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', - labels: 'ci-health', + labels: labelName, per_page: 100, }); const alreadyOpen = existing.find((issue) => issue.title === title); @@ -86,7 +105,7 @@ jobs: repo: context.repo.repo, title, body, - labels: ['ci-health'], + labels: [labelName], }); core.info(`Opened issue #${created.number}.`); } diff --git a/src/commands/testDependenciesCommands.ts b/src/commands/testDependenciesCommands.ts index 7740076f..71b8e768 100644 --- a/src/commands/testDependenciesCommands.ts +++ b/src/commands/testDependenciesCommands.ts @@ -179,7 +179,8 @@ async function getLatestVersion(groupId: string, artifactId: string): Promise { // eslint-disable-next-line @typescript-eslint/typedef return new Promise((resolve, reject) => { let result: string = ''; - https.get(link, { + const req: ClientRequest = https.get(link, { headers: { 'User-Agent': 'vscode-JavaTestRunner/0.1', }, }, (res: http.IncomingMessage) => { - if (res.statusCode !== 200) { - return reject(new Error(`Request failed with status code: ${res.statusCode}`)); + const statusCode: number = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + // Drain the socket so it can be returned to the agent pool + // instead of hanging until the server times us out. + res.resume(); + return reject(new Error(`Request to ${link} failed with status code: ${statusCode}`)); } res.on('data', (chunk: any) => { result = result.concat(chunk.toString()); @@ -324,6 +329,10 @@ async function getHttpsAsText(link: string): Promise { }); res.on('error', reject); }); + // https.get returns a ClientRequest that emits 'error' for failures that + // happen before any response is received (DNS, TCP reset, TLS handshake, + // etc.). Without this listener the promise would hang forever. + req.on('error', reject); }); } From 5763d0ed1cdf40a44e0e959355d1a72f401b7da4 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Wed, 3 Jun 2026 12:26:04 +0800 Subject: [PATCH 4/4] feat: route JUnit Jupiter 5 to the Platform 1.x line, JUnit 6 to 6.x JUnit Platform now publishes two coexisting release lines under the same GAV (1.x for Jupiter 5, 6.x for Jupiter 6), and tracks the globally newest stable, which is currently 6.1.0. Without a per-line constraint, picking 'JUnit Jupiter 5' would still resolve to a 6.x jar. This change splits the two menu entries to download what they advertise: - Add an optional 'versionLine' field to IArtifactMetadata that asks getLatestVersion() to find the newest stable version whose leading segment matches a given string (with leading-segment match, so '1' does not accidentally match '10.x'). - parseLatestStableVersion() now skips the shortcut when a versionLine is provided, since may point outside the requested line. - TestKind.JUnit5 -> versionLine '1' (defaultVersion '1.14.4'). - TestKind.JUnit6 -> versionLine '6' (defaultVersion '6.1.0'). - Unit tests cover: matching line, ignoring out-of-line , skipping pre-releases inside the line, no-stable-in-line returns undefined, leading-segment vs prefix match. - Tighten the unmanaged-folder integration test to require a 1.x jar for TestKind.JUnit5. - Health check probes both the 1.x and 6.x lines independently. Pairing with the upstream JDT-LS dispatch fix (eclipse.jdt.ui#2910 / 17f8fa5d9f), this restores the architecturally correct behaviour: each menu entry installs the line it advertises. Users on a vscode-java build that ships the buggy JDT-LS dispatch should pick 'JUnit Jupiter 6' until that fix is released; #4396 tracks the upstream rollout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/checkVersionSources.js | 67 ++++++++++++------ src/commands/testDependenciesCommands.ts | 61 +++++++++++++--- test/suite/testDependenciesCommands.test.ts | 70 +++++++++++++++++++ .../enableTests.test.ts | 19 ++--- 4 files changed, 175 insertions(+), 42 deletions(-) diff --git a/scripts/checkVersionSources.js b/scripts/checkVersionSources.js index 658ca05a..733b49c2 100644 --- a/scripts/checkVersionSources.js +++ b/scripts/checkVersionSources.js @@ -32,7 +32,20 @@ const ARTIFACTS = [ // JUnit 5 / Jupiter (the path that originally surfaced #1866). - { groupId: 'org.junit.platform', artifactId: 'junit-platform-console-standalone' }, + // The console-standalone GAV publishes two coexisting release lines + // (1.x for legacy Jupiter 5, 6.x for Jupiter 6). The extension routes + // TestKind.JUnit5 to the 1.x line and TestKind.JUnit6 to the 6.x line, + // so the health check covers both. + { + groupId: 'org.junit.platform', + artifactId: 'junit-platform-console-standalone', + versionLine: '1', + }, + { + groupId: 'org.junit.platform', + artifactId: 'junit-platform-console-standalone', + versionLine: '6', + }, // JUnit 4 + its hamcrest-core dependency. { groupId: 'junit', artifactId: 'junit' }, @@ -90,11 +103,16 @@ async function fetchWithRetry(url, init) { throw lastError; } -function parseLatestStableVersion(xml) { - const releaseMatch = xml.match(/([^<]+)<\/release>/); - if (releaseMatch && STABLE_VERSION_REGEX.test(releaseMatch[1])) { - return releaseMatch[1]; +function parseLatestStableVersion(xml, versionLine) { + if (versionLine === undefined) { + const releaseMatch = xml.match(/([^<]+)<\/release>/); + if (releaseMatch && STABLE_VERSION_REGEX.test(releaseMatch[1])) { + return releaseMatch[1]; + } } + const lineRegex = versionLine !== undefined + ? new RegExp(`^${versionLine.replace(/\./g, '\\.')}(\\.|$)`) + : undefined; const versions = []; const versionRegex = /([^<]+)<\/version>/g; let match; @@ -102,16 +120,22 @@ function parseLatestStableVersion(xml) { versions.push(match[1]); } for (let i = versions.length - 1; i >= 0; i--) { - if (STABLE_VERSION_REGEX.test(versions[i])) { - return versions[i]; + const candidate = versions[i]; + if (!STABLE_VERSION_REGEX.test(candidate)) { + continue; + } + if (lineRegex && !lineRegex.test(candidate)) { + continue; } + return candidate; } return undefined; } async function checkArtifact(artifact) { - const { groupId, artifactId, pinnedVersion } = artifact; - console.log(`\n== ${groupId}:${artifactId} ==`); + const { groupId, artifactId, pinnedVersion, versionLine } = artifact; + const scope = versionLine ? ` (${versionLine}.x line)` : ''; + console.log(`\n== ${groupId}:${artifactId}${scope} ==`); const metaUrl = metadataUrl(groupId, artifactId); console.log(` GET ${metaUrl}`); @@ -121,24 +145,23 @@ async function checkArtifact(artifact) { } const xml = await metaResponse.text(); - const releaseMatch = xml.match(/([^<]+)<\/release>/); - if (releaseMatch) { - const rawRelease = releaseMatch[1]; - if (!STABLE_VERSION_REGEX.test(rawRelease)) { - // Production falls back to the list when - // points to a pre-release, so this is non-fatal — but still - // noisy enough to be worth surfacing in the log. - console.warn(` ! is a pre-release: "${rawRelease}". Falling back to scan.`); + if (versionLine === undefined) { + const releaseMatch = xml.match(/([^<]+)<\/release>/); + if (releaseMatch) { + const rawRelease = releaseMatch[1]; + if (!STABLE_VERSION_REGEX.test(rawRelease)) { + console.warn(` ! is a pre-release: "${rawRelease}". Falling back to scan.`); + } + } else { + console.warn(' ! tag missing — relying entirely on fallback.'); } - } else { - console.warn(' ! tag missing — relying entirely on fallback.'); } - const latestStable = parseLatestStableVersion(xml); + const latestStable = parseLatestStableVersion(xml, versionLine); if (!latestStable) { - throw new Error('No stable version found in maven-metadata.xml.'); + throw new Error(`No stable version found${scope} in maven-metadata.xml.`); } - console.log(` ok latest stable version = ${latestStable}`); + console.log(` ok latest stable version${scope} = ${latestStable}`); const versionsToProbe = pinnedVersion && pinnedVersion !== latestStable ? [latestStable, pinnedVersion] diff --git a/src/commands/testDependenciesCommands.ts b/src/commands/testDependenciesCommands.ts index 71b8e768..11689bb8 100644 --- a/src/commands/testDependenciesCommands.ts +++ b/src/commands/testDependenciesCommands.ts @@ -78,7 +78,7 @@ async function setupUnmanagedFolder(projectUri: Uri, testKind?: TestKind): Promi message: `Downloading ${jar.artifactId}.jar...`, }); if (!jar.version) { - jar.version = await getLatestVersion(jar.groupId, jar.artifactId) || jar.defaultVersion; + jar.version = await getLatestVersion(jar.groupId, jar.artifactId, jar.versionLine) || jar.defaultVersion; } await downloadJar(libFolder, jar.groupId, jar.artifactId, jar.version, metadata.length, progress, token); } @@ -135,10 +135,22 @@ async function getLibFolder(projectUri: Uri): Promise { function getJarIds(testKind: TestKind): IArtifactMetadata[] { switch (testKind) { case TestKind.JUnit5: + // Pin to the JUnit Platform 1.x line. JUnit Platform 6.x ships + // alongside 1.x under the same GAV, but it requires a compatible + // JDT-LS runner; users who explicitly choose "JUnit Jupiter 5" + // are opting into the 1.x line and should get its newest stable. + return [{ + groupId: 'org.junit.platform', + artifactId: 'junit-platform-console-standalone', + defaultVersion: '1.14.4', + versionLine: '1', + }]; + case TestKind.JUnit6: return [{ groupId: 'org.junit.platform', artifactId: 'junit-platform-console-standalone', defaultVersion: '6.1.0', + versionLine: '6', }]; case TestKind.JUnit: return [{ @@ -170,12 +182,17 @@ function getJarIds(testKind: TestKind): IArtifactMetadata[] { } } -async function getLatestVersion(groupId: string, artifactId: string): Promise { +async function getLatestVersion( + groupId: string, + artifactId: string, + versionLine?: string, +): Promise { try { const xml: string = await getHttpsAsText(getMetadataLink(groupId, artifactId)); - const version: string | undefined = parseLatestStableVersion(xml); + const version: string | undefined = parseLatestStableVersion(xml, versionLine); if (version === undefined) { - sendError(new Error(`No stable version found in maven-metadata.xml for ${groupId}:${artifactId}`)); + const scope: string = versionLine ? ` in the ${versionLine}.x line` : ''; + sendError(new Error(`No stable version found${scope} in maven-metadata.xml for ${groupId}:${artifactId}`)); } return version; } catch (e) { @@ -186,13 +203,23 @@ async function getLatestVersion(groupId: string, artifactId: string): Promise tag (Maven's authoritative pointer to the latest non-snapshot version). - const releaseMatch: RegExpMatchArray | null = xml.match(/([^<]+)<\/release>/); - if (releaseMatch && isStableVersion(releaseMatch[1])) { - return releaseMatch[1]; +function parseLatestStableVersion(xml: string, versionLine?: string): string | undefined { + // When the caller pins a version line (e.g. '1' for the JUnit Platform 1.x line), + // skip the shortcut — it points to the globally latest stable, which may + // be in a different line. Scan entries directly for the newest stable + // version whose major segment matches. + if (versionLine === undefined) { + // Prefer the tag (Maven's authoritative pointer to the latest non-snapshot version). + const releaseMatch: RegExpMatchArray | null = xml.match(/([^<]+)<\/release>/); + if (releaseMatch && isStableVersion(releaseMatch[1])) { + return releaseMatch[1]; + } } + const lineRegex: RegExp | undefined = versionLine !== undefined + ? new RegExp(`^${versionLine.replace(/\./g, '\\.')}(\\.|$)`) + : undefined; + // Fallback: scan entries (chronologically ordered) for the newest stable version, // in case is missing or points to a milestone / RC. const versionRegex: RegExp = /([^<]+)<\/version>/g; @@ -202,9 +229,14 @@ function parseLatestStableVersion(xml: string): string | undefined { versions.push(match[1]); } for (let i: number = versions.length - 1; i >= 0; i--) { - if (isStableVersion(versions[i])) { - return versions[i]; + const candidate: string = versions[i]; + if (!isStableVersion(candidate)) { + continue; + } + if (lineRegex && !lineRegex.test(candidate)) { + continue; } + return candidate; } return undefined; @@ -366,6 +398,13 @@ interface IArtifactMetadata { artifactId: string; version?: string; defaultVersion: string; + /** + * If set, constrains getLatestVersion() to the newest stable version whose + * leading segment matches this string. Used when an artifact maintains + * multiple coexisting release lines under the same GAV (e.g. + * junit-platform-console-standalone publishes both the 1.x and 6.x lines). + */ + versionLine?: string; } // eslint-disable-next-line @typescript-eslint/typedef diff --git a/test/suite/testDependenciesCommands.test.ts b/test/suite/testDependenciesCommands.test.ts index 7db2af6b..a93c9d4f 100644 --- a/test/suite/testDependenciesCommands.test.ts +++ b/test/suite/testDependenciesCommands.test.ts @@ -120,4 +120,74 @@ suite('testDependenciesCommands - version source parsing', () => { assert.strictEqual(parseLatestStableVersion('404 Not Found'), undefined); }); }); + + suite('parseLatestStableVersion with versionLine', () => { + // Real-world shape: junit-platform-console-standalone publishes both the + // 1.x and 6.x lines under the same GAV, with tracking the + // globally newest stable (6.x). + const COEXISTING_LINES_XML: string = ` + + + 6.1.0 + + 1.13.0 + 1.14.0-RC1 + 1.14.0 + 1.14.4 + 6.0.0-RC1 + 6.0.0 + 6.1.0-M1 + 6.1.0 + + + `; + + test('selects the newest stable version in the requested line', () => { + assert.strictEqual(parseLatestStableVersion(COEXISTING_LINES_XML, '1'), '1.14.4'); + assert.strictEqual(parseLatestStableVersion(COEXISTING_LINES_XML, '6'), '6.1.0'); + }); + + test('ignores when it points outside the requested line', () => { + // = 6.1.0 but caller asked for the 1.x line — we must not + // short-circuit to ; we have to walk . + assert.strictEqual(parseLatestStableVersion(COEXISTING_LINES_XML, '1'), '1.14.4'); + }); + + test('skips pre-releases inside the requested line', () => { + const xml: string = ` + + + + 1.14.3 + 1.14.4 + 1.15.0-M1 + 1.15.0-RC1 + + + `; + assert.strictEqual(parseLatestStableVersion(xml, '1'), '1.14.4'); + }); + + test('returns undefined when the requested line has no stable version', () => { + assert.strictEqual(parseLatestStableVersion(COEXISTING_LINES_XML, '2'), undefined); + assert.strictEqual(parseLatestStableVersion(COEXISTING_LINES_XML, '5'), undefined); + }); + + test('matches the line only on the leading segment, not on prefix', () => { + // versionLine '1' must not accidentally match '10.x' or '11.x'. + const xml: string = ` + + + + 1.14.4 + 10.0.0 + 11.2.3 + + + `; + assert.strictEqual(parseLatestStableVersion(xml, '1'), '1.14.4'); + assert.strictEqual(parseLatestStableVersion(xml, '10'), '10.0.0'); + assert.strictEqual(parseLatestStableVersion(xml, '11'), '11.2.3'); + }); + }); }); diff --git a/test/unmanaged-folder-suite/enableTests.test.ts b/test/unmanaged-folder-suite/enableTests.test.ts index 082ffd53..7939fefc 100644 --- a/test/unmanaged-folder-suite/enableTests.test.ts +++ b/test/unmanaged-folder-suite/enableTests.test.ts @@ -26,24 +26,25 @@ suite('Test Enable Tests', () => { test('test enable tests for unmanaged folder', async () => { await enableTests(TestKind.JUnit5); - // A correctly downloaded artifact must be a stable release of - // junit-platform-console-standalone (no pre-release qualifier such as - // -M3, -RC1, -beta-1, ...). This guards against the stale Maven - // search.maven.org Solr index that used to return milestone versions - // as the "latest" — see microsoft/vscode-java-test#1866. - const STABLE_JAR_PATTERN: RegExp = /^junit-platform-console-standalone-\d+(\.\d+)*\.jar$/; + // TestKind.JUnit5 is now pinned to the JUnit Platform 1.x line. + // A correctly downloaded artifact must be a stable 1.x release of + // junit-platform-console-standalone (no pre-release qualifier such + // as -M3, -RC1, -beta-1, ...). This guards against both the stale + // search.maven.org Solr index (#1866) and an accidental drift back + // to the 6.x line. + const STABLE_JUNIT5_JAR_PATTERN: RegExp = /^junit-platform-console-standalone-1\.\d+(\.\d+)*\.jar$/; for (let i = 0; i < 5; i++) { if (await fse.pathExists(LIB_PATH)) { const files: string[] = await fse.readdir(LIB_PATH); - const stableJar: string | undefined = files.find((file) => STABLE_JAR_PATTERN.test(file)); + const stableJar: string | undefined = files.find((file) => STABLE_JUNIT5_JAR_PATTERN.test(file)); if (stableJar) { return; } const anyMatching: string[] = files.filter((file) => file.includes('junit-platform-console-standalone')); if (anyMatching.length > 0) { assert.fail( - `Downloaded jar is not a stable release. Got: ${anyMatching.join(', ')}. ` + - `Expected a file matching ${STABLE_JAR_PATTERN}.`, + `Downloaded jar is not a stable JUnit 5 (1.x) release. Got: ${anyMatching.join(', ')}. ` + + `Expected a file matching ${STABLE_JUNIT5_JAR_PATTERN}.`, ); } }