diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml new file mode 100644 index 00000000..3867e1d5 --- /dev/null +++ b/.github/workflows/health-check.yml @@ -0,0 +1,111 @@ +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 labelName = 'ci-health'; + 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'); + + // 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: labelName, + 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: [labelName], + }); + core.info(`Opened issue #${created.number}.`); + } diff --git a/scripts/checkVersionSources.js b/scripts/checkVersionSources.js new file mode 100644 index 00000000..733b49c2 --- /dev/null +++ b/scripts/checkVersionSources.js @@ -0,0 +1,209 @@ +// 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). + // 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' }, + // 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, 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; + while ((match = versionRegex.exec(xml)) !== null) { + versions.push(match[1]); + } + for (let i = versions.length - 1; i >= 0; 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, versionLine } = artifact; + const scope = versionLine ? ` (${versionLine}.x line)` : ''; + console.log(`\n== ${groupId}:${artifactId}${scope} ==`); + + 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(); + + 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.'); + } + } + + const latestStable = parseLatestStableVersion(xml, versionLine); + if (!latestStable) { + throw new Error(`No stable version found${scope} in maven-metadata.xml.`); + } + console.log(` ok latest stable version${scope} = ${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 4f88abd8..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.9.3', + 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,22 +182,73 @@ 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 response: any = await getHttpsAsJSON(getQueryLink(groupId, artifactId)); - - if (!response.response?.docs?.[0]?.latestVersion) { - sendError(new Error('Invalid format for the latest version response')); - return undefined; + const xml: string = await getHttpsAsText(getMetadataLink(groupId, artifactId)); + const version: string | undefined = parseLatestStableVersion(xml, versionLine); + if (version === undefined) { + 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 response.response.docs[0].latestVersion; + return version; } catch (e) { - sendError(new Error(`Failed to fetch the latest version for ${groupId}:${artifactId}`)); + const detail: string = (e instanceof Error) ? e.message : String(e); + sendError(new Error(`Failed to fetch the latest version for ${groupId}:${artifactId}: ${detail}`)); } return undefined; } +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; + 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--) { + const candidate: string = versions[i]; + if (!isStableVersion(candidate)) { + continue; + } + if (lineRegex && !lineRegex.test(candidate)) { + continue; + } + return candidate; + } + + 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 + // or -SNAPSHOT is treated as a pre-release. + return /^\d+(\.\d+)*$/.test(version); +} + async function downloadJar( libFolder: string, groupId: string, @@ -274,17 +337,21 @@ async function updateProjectSettings(projectUri: Uri, libFolder: string): Promis window.showInformationMessage(`Test libraries have been downloaded into '${relativePath}/'.`); } -async function getHttpsAsJSON(link: 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, { + 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()); @@ -294,8 +361,11 @@ async function getHttpsAsJSON(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); }); - return JSON.parse(response); } async function getTotalBytes(url: string): Promise { @@ -315,8 +385,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 { @@ -328,4 +398,17 @@ 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 +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..a93c9d4f --- /dev/null +++ b/test/suite/testDependenciesCommands.test.ts @@ -0,0 +1,193 @@ +// 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); + }); + }); + + 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 d1dec9b7..7939fefc 100644 --- a/test/unmanaged-folder-suite/enableTests.test.ts +++ b/test/unmanaged-folder-suite/enableTests.test.ts @@ -26,15 +26,27 @@ suite('Test Enable Tests', () => { test('test enable tests for unmanaged folder', async () => { await enableTests(TestKind.JUnit5); + // 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 downloaded: boolean = files.some((file) => { - return file.includes('junit-platform-console-standalone'); - }); - if (downloaded) { + 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 JUnit 5 (1.x) release. Got: ${anyMatching.join(', ')}. ` + + `Expected a file matching ${STABLE_JUNIT5_JAR_PATTERN}.`, + ); + } } await sleep(1000 /*ms*/); }