|
| 1 | +// Copyright (c) Microsoft Corporation. All rights reserved. |
| 2 | +// Licensed under the MIT license. |
| 3 | + |
| 4 | +/** |
| 5 | + * Health check for the Maven Central data sources that |
| 6 | + * `src/commands/testDependenciesCommands.ts` relies on for the |
| 7 | + * "Enable Java Tests" download flow. |
| 8 | + * |
| 9 | + * For every artifact that vscode-java-test fetches at runtime, this script: |
| 10 | + * |
| 11 | + * 1. Downloads the artifact's `maven-metadata.xml` from repo1.maven.org. |
| 12 | + * 2. Extracts the `<release>` element and asserts it is a stable version |
| 13 | + * (dot-separated digits only — no `-M3`, `-RC1`, `-beta-1`, etc.). |
| 14 | + * 3. Issues an HTTP HEAD against the resolved `.jar` download URL and |
| 15 | + * asserts a 2xx response. |
| 16 | + * |
| 17 | + * This guards against silent upstream drift, e.g. |
| 18 | + * - the data source moving / being deprecated (microsoft/vscode-java-test#1866, |
| 19 | + * where the legacy search.maven.org Solr index was frozen for ~a year |
| 20 | + * and kept returning a milestone build as the "latest"), |
| 21 | + * - a maintainer accidentally publishing a pre-release as `<release>`, |
| 22 | + * - the jar layout under repo1.maven.org changing. |
| 23 | + * |
| 24 | + * Runs on PRs (so a code change that breaks the lookup never lands) and |
| 25 | + * on a weekly cron (so a purely upstream change is caught within days |
| 26 | + * instead of months). |
| 27 | + * |
| 28 | + * Pure Node, no dependencies — works before `npm install` runs. |
| 29 | + */ |
| 30 | + |
| 31 | +'use strict'; |
| 32 | + |
| 33 | +const ARTIFACTS = [ |
| 34 | + // JUnit 5 / Jupiter (the path that originally surfaced #1866). |
| 35 | + // The console-standalone GAV publishes two coexisting release lines |
| 36 | + // (1.x for legacy Jupiter 5, 6.x for Jupiter 6). The extension routes |
| 37 | + // TestKind.JUnit5 to the 1.x line and TestKind.JUnit6 to the 6.x line, |
| 38 | + // so the health check covers both. |
| 39 | + { |
| 40 | + groupId: 'org.junit.platform', |
| 41 | + artifactId: 'junit-platform-console-standalone', |
| 42 | + versionLine: '1', |
| 43 | + }, |
| 44 | + { |
| 45 | + groupId: 'org.junit.platform', |
| 46 | + artifactId: 'junit-platform-console-standalone', |
| 47 | + versionLine: '6', |
| 48 | + }, |
| 49 | + |
| 50 | + // JUnit 4 + its hamcrest-core dependency. |
| 51 | + { groupId: 'junit', artifactId: 'junit' }, |
| 52 | + // hamcrest-core is pinned to 1.3 in the extension; verify both that 1.3 |
| 53 | + // still resolves and that the artifact metadata is reachable. |
| 54 | + { groupId: 'org.hamcrest', artifactId: 'hamcrest-core', pinnedVersion: '1.3' }, |
| 55 | + |
| 56 | + // TestNG + its transitive deps that we ship. |
| 57 | + { groupId: 'org.testng', artifactId: 'testng' }, |
| 58 | + { groupId: 'com.beust', artifactId: 'jcommander' }, |
| 59 | + { groupId: 'org.slf4j', artifactId: 'slf4j-api' }, |
| 60 | +]; |
| 61 | + |
| 62 | +const MAX_ATTEMPTS = 3; |
| 63 | +const RETRY_BASE_DELAY_MS = 2000; |
| 64 | +const REQUEST_TIMEOUT_MS = 15000; |
| 65 | + |
| 66 | +const STABLE_VERSION_REGEX = /^\d+(\.\d+)*$/; |
| 67 | + |
| 68 | +function groupPath(groupId) { |
| 69 | + return groupId.split('.').join('/'); |
| 70 | +} |
| 71 | + |
| 72 | +function metadataUrl(groupId, artifactId) { |
| 73 | + return `https://repo1.maven.org/maven2/${groupPath(groupId)}/${artifactId}/maven-metadata.xml`; |
| 74 | +} |
| 75 | + |
| 76 | +function jarUrl(groupId, artifactId, version) { |
| 77 | + return `https://repo1.maven.org/maven2/${groupPath(groupId)}/${artifactId}/${version}/${artifactId}-${version}.jar`; |
| 78 | +} |
| 79 | + |
| 80 | +function sleep(ms) { |
| 81 | + return new Promise((resolve) => setTimeout(resolve, ms)); |
| 82 | +} |
| 83 | + |
| 84 | +async function fetchWithRetry(url, init) { |
| 85 | + let lastError; |
| 86 | + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { |
| 87 | + const controller = new AbortController(); |
| 88 | + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); |
| 89 | + try { |
| 90 | + const response = await fetch(url, { ...init, signal: controller.signal }); |
| 91 | + clearTimeout(timer); |
| 92 | + return response; |
| 93 | + } catch (err) { |
| 94 | + clearTimeout(timer); |
| 95 | + lastError = err; |
| 96 | + if (attempt < MAX_ATTEMPTS) { |
| 97 | + const delay = RETRY_BASE_DELAY_MS * attempt; |
| 98 | + console.warn(` ! attempt ${attempt}/${MAX_ATTEMPTS} for ${url} failed: ${err.message}. Retrying in ${delay}ms...`); |
| 99 | + await sleep(delay); |
| 100 | + } |
| 101 | + } |
| 102 | + } |
| 103 | + throw lastError; |
| 104 | +} |
| 105 | + |
| 106 | +function parseLatestStableVersion(xml, versionLine) { |
| 107 | + if (versionLine === undefined) { |
| 108 | + const releaseMatch = xml.match(/<release>([^<]+)<\/release>/); |
| 109 | + if (releaseMatch && STABLE_VERSION_REGEX.test(releaseMatch[1])) { |
| 110 | + return releaseMatch[1]; |
| 111 | + } |
| 112 | + } |
| 113 | + const lineRegex = versionLine !== undefined |
| 114 | + ? new RegExp(`^${versionLine.replace(/\./g, '\\.')}(\\.|$)`) |
| 115 | + : undefined; |
| 116 | + const versions = []; |
| 117 | + const versionRegex = /<version>([^<]+)<\/version>/g; |
| 118 | + let match; |
| 119 | + while ((match = versionRegex.exec(xml)) !== null) { |
| 120 | + versions.push(match[1]); |
| 121 | + } |
| 122 | + for (let i = versions.length - 1; i >= 0; i--) { |
| 123 | + const candidate = versions[i]; |
| 124 | + if (!STABLE_VERSION_REGEX.test(candidate)) { |
| 125 | + continue; |
| 126 | + } |
| 127 | + if (lineRegex && !lineRegex.test(candidate)) { |
| 128 | + continue; |
| 129 | + } |
| 130 | + return candidate; |
| 131 | + } |
| 132 | + return undefined; |
| 133 | +} |
| 134 | + |
| 135 | +async function checkArtifact(artifact) { |
| 136 | + const { groupId, artifactId, pinnedVersion, versionLine } = artifact; |
| 137 | + const scope = versionLine ? ` (${versionLine}.x line)` : ''; |
| 138 | + console.log(`\n== ${groupId}:${artifactId}${scope} ==`); |
| 139 | + |
| 140 | + const metaUrl = metadataUrl(groupId, artifactId); |
| 141 | + console.log(` GET ${metaUrl}`); |
| 142 | + const metaResponse = await fetchWithRetry(metaUrl); |
| 143 | + if (!metaResponse.ok) { |
| 144 | + throw new Error(`maven-metadata.xml returned HTTP ${metaResponse.status} ${metaResponse.statusText}`); |
| 145 | + } |
| 146 | + const xml = await metaResponse.text(); |
| 147 | + |
| 148 | + if (versionLine === undefined) { |
| 149 | + const releaseMatch = xml.match(/<release>([^<]+)<\/release>/); |
| 150 | + if (releaseMatch) { |
| 151 | + const rawRelease = releaseMatch[1]; |
| 152 | + if (!STABLE_VERSION_REGEX.test(rawRelease)) { |
| 153 | + console.warn(` ! <release> is a pre-release: "${rawRelease}". Falling back to <versions> scan.`); |
| 154 | + } |
| 155 | + } else { |
| 156 | + console.warn(' ! <release> tag missing — relying entirely on <versions> fallback.'); |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + const latestStable = parseLatestStableVersion(xml, versionLine); |
| 161 | + if (!latestStable) { |
| 162 | + throw new Error(`No stable version found${scope} in maven-metadata.xml.`); |
| 163 | + } |
| 164 | + console.log(` ok latest stable version${scope} = ${latestStable}`); |
| 165 | + |
| 166 | + const versionsToProbe = pinnedVersion && pinnedVersion !== latestStable |
| 167 | + ? [latestStable, pinnedVersion] |
| 168 | + : [latestStable]; |
| 169 | + |
| 170 | + for (const version of versionsToProbe) { |
| 171 | + const downloadUrl = jarUrl(groupId, artifactId, version); |
| 172 | + console.log(` HEAD ${downloadUrl}`); |
| 173 | + const headResponse = await fetchWithRetry(downloadUrl, { method: 'HEAD' }); |
| 174 | + if (!headResponse.ok) { |
| 175 | + throw new Error(`jar HEAD returned HTTP ${headResponse.status} ${headResponse.statusText} for ${downloadUrl}`); |
| 176 | + } |
| 177 | + console.log(` ok jar reachable (HTTP ${headResponse.status})`); |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +async function main() { |
| 182 | + console.log('Checking Maven Central data sources for vscode-java-test...'); |
| 183 | + const failures = []; |
| 184 | + for (const artifact of ARTIFACTS) { |
| 185 | + try { |
| 186 | + await checkArtifact(artifact); |
| 187 | + } catch (err) { |
| 188 | + failures.push({ artifact, error: err }); |
| 189 | + console.error(` FAIL ${artifact.groupId}:${artifact.artifactId} — ${err.message}`); |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + console.log('\n----'); |
| 194 | + if (failures.length === 0) { |
| 195 | + console.log(`All ${ARTIFACTS.length} artifacts healthy.`); |
| 196 | + return; |
| 197 | + } |
| 198 | + |
| 199 | + console.error(`${failures.length} of ${ARTIFACTS.length} artifact checks FAILED:`); |
| 200 | + for (const { artifact, error } of failures) { |
| 201 | + console.error(` - ${artifact.groupId}:${artifact.artifactId}: ${error.message}`); |
| 202 | + } |
| 203 | + process.exit(1); |
| 204 | +} |
| 205 | + |
| 206 | +main().catch((err) => { |
| 207 | + console.error('Unexpected error:', err); |
| 208 | + process.exit(1); |
| 209 | +}); |
0 commit comments