Skip to content

Commit 8f58ac9

Browse files
authored
fix(test): stabilize flaky network-dependent tests (#1923)
## Summary - retry the complete HTTP body and JSON decode operation for npm metadata and the Node.js version index - keep version classification and package-manager priority unit tests deterministic instead of reaching live registries - allow the first Vite config evaluation more time on slow Windows CI workers ## Validation - `cargo fmt --all -- --check` - `cargo clippy -p vite_install -p vite_js_runtime -p vite_global_cli --all-targets -- -D warnings` - `env -u NODE_OPTIONS RUST_MIN_STACK=8388608 cargo test -p vite_install -p vite_js_runtime --locked` - `cargo test -p vite_global_cli test_classify_version --locked` - `git diff --check` The focused TypeScript test requires generated Vite/Core/CLI build artifacts that are not present in a fresh worktree; CI will run it after the normal build setup.
1 parent a9c7034 commit 8f58ac9

6 files changed

Lines changed: 133 additions & 101 deletions

File tree

crates/vite_global_cli/src/commands/env/exec.rs

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -155,30 +155,43 @@ async fn execute_with_version(
155155
///
156156
/// Handles aliases (lts, latest) and version ranges.
157157
async fn resolve_version(version: &str, provider: &NodeProvider) -> Result<String, Error> {
158-
match version.to_lowercase().as_str() {
159-
"lts" => {
158+
match classify_version(version) {
159+
VersionSelector::LatestLts => {
160160
let resolved = provider.resolve_latest_version().await?;
161161
Ok(resolved.to_string())
162162
}
163-
"latest" => {
163+
VersionSelector::AbsoluteLatest => {
164164
let resolved = provider.resolve_absolute_latest_version().await?;
165165
Ok(resolved.to_string())
166166
}
167-
_ => {
168-
// For exact versions, use directly
169-
if NodeProvider::is_exact_version(version) {
170-
// Strip v prefix if present
171-
let normalized = version.strip_prefix('v').unwrap_or(version);
172-
Ok(normalized.to_string())
173-
} else {
174-
// For ranges/partial versions, resolve to exact
175-
let resolved = provider.resolve_version(version).await?;
176-
Ok(resolved.to_string())
177-
}
167+
VersionSelector::Exact(version) => Ok(version.to_string()),
168+
VersionSelector::Range(version) => {
169+
let resolved = provider.resolve_version(version).await?;
170+
Ok(resolved.to_string())
178171
}
179172
}
180173
}
181174

175+
#[derive(Debug, PartialEq, Eq)]
176+
enum VersionSelector<'a> {
177+
LatestLts,
178+
AbsoluteLatest,
179+
Exact(&'a str),
180+
Range(&'a str),
181+
}
182+
183+
fn classify_version(version: &str) -> VersionSelector<'_> {
184+
if version.eq_ignore_ascii_case("lts") {
185+
VersionSelector::LatestLts
186+
} else if version.eq_ignore_ascii_case("latest") {
187+
VersionSelector::AbsoluteLatest
188+
} else if NodeProvider::is_exact_version(version) {
189+
VersionSelector::Exact(version.strip_prefix('v').unwrap_or(version))
190+
} else {
191+
VersionSelector::Range(version)
192+
}
193+
}
194+
182195
/// Create an exit status with the given code.
183196
fn exit_status(code: i32) -> ExitStatus {
184197
#[cfg(unix)]
@@ -232,32 +245,21 @@ mod tests {
232245
assert_eq!(version, "20.18.0");
233246
}
234247

235-
#[tokio::test]
236-
async fn test_resolve_version_partial() {
237-
let provider = NodeProvider::new();
238-
let version = resolve_version("20", &provider).await.unwrap();
239-
// Should resolve to a 20.x.x version - check starts with "20."
240-
assert!(version.starts_with("20."), "Expected version starting with '20.', got: {version}");
248+
#[test]
249+
fn test_classify_version_partial() {
250+
assert_eq!(classify_version("20"), VersionSelector::Range("20"));
241251
}
242252

243-
#[tokio::test]
244-
async fn test_resolve_version_range() {
245-
let provider = NodeProvider::new();
246-
let version = resolve_version("^20.0.0", &provider).await.unwrap();
247-
// Should resolve to a 20.x.x version - check starts with "20."
248-
assert!(version.starts_with("20."), "Expected version starting with '20.', got: {version}");
253+
#[test]
254+
fn test_classify_version_range() {
255+
assert_eq!(classify_version("^20.0.0"), VersionSelector::Range("^20.0.0"));
249256
}
250257

251-
#[tokio::test]
252-
async fn test_resolve_version_lts() {
253-
let provider = NodeProvider::new();
254-
let version = resolve_version("lts", &provider).await.unwrap();
255-
// Should resolve to a valid version (format: x.y.z)
256-
let parts: Vec<&str> = version.split('.').collect();
257-
assert_eq!(parts.len(), 3, "Expected version format x.y.z, got: {version}");
258-
// Major version should be >= 20 (current LTS line)
259-
let major: u32 = parts[0].parse().expect("Major version should be a number");
260-
assert!(major >= 20, "Expected major version >= 20, got: {major}");
258+
#[test]
259+
fn test_classify_version_aliases() {
260+
assert_eq!(classify_version("lts"), VersionSelector::LatestLts);
261+
assert_eq!(classify_version("LTS"), VersionSelector::LatestLts);
262+
assert_eq!(classify_version("latest"), VersionSelector::AbsoluteLatest);
261263
}
262264

263265
#[tokio::test]

crates/vite_install/src/package_manager.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3247,8 +3247,8 @@ mod tests {
32473247
assert_eq!(package_json["name"].as_str().unwrap(), "test-package");
32483248
}
32493249

3250-
#[tokio::test]
3251-
async fn test_detect_package_manager_priority_order_lock_over_config() {
3250+
#[test]
3251+
fn test_detect_package_manager_priority_order_lock_over_config() {
32523252
let temp_dir = create_temp_dir();
32533253
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
32543254
let package_content = r#"{"name": "test-package"}"#;
@@ -3272,14 +3272,19 @@ mod tests {
32723272
fs::write(temp_dir_path.join("package-lock.json"), r#"{"lockfileVersion": 3}"#)
32733273
.expect("Failed to write package-lock.json");
32743274

3275-
let result = PackageManager::builder(temp_dir_path)
3276-
.build()
3277-
.await
3278-
.expect("Should detect npm from package-lock.json");
3275+
let (workspace_root, _) =
3276+
find_workspace_root(&temp_dir_path).expect("Should find workspace root");
3277+
let (package_manager_type, version, hash, source) =
3278+
get_package_manager_type_and_version(&workspace_root, None)
3279+
.expect("Should detect npm from package-lock.json");
32793280
assert_eq!(
3280-
result.bin_name, "npm",
3281+
package_manager_type,
3282+
PackageManagerType::Npm,
32813283
"package-lock.json should take precedence over pnpmfile.cjs and yarn.config.cjs"
32823284
);
3285+
assert_eq!(version, "latest");
3286+
assert_eq!(hash, None);
3287+
assert_eq!(source, PackageManagerSource::LockfileOrConfig);
32833288
}
32843289

32853290
#[tokio::test]

crates/vite_install/src/request.rs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,7 @@ impl HttpClient {
9494
/// * `Ok(T)` - Deserialized JSON data
9595
/// * `Err(e)` - If the request fails or JSON deserialization fails
9696
pub async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, Error> {
97-
tracing::debug!("Fetching JSON from: {}", url);
98-
99-
let response = self.get(url).await?;
100-
let data = response.json::<T>().await?;
101-
Ok(data)
97+
self.get_json_with_optional_accept(url, None).await
10298
}
10399

104100
/// Get JSON data from a URL with a custom Accept header
@@ -119,11 +115,32 @@ impl HttpClient {
119115
url: &str,
120116
accept: &str,
121117
) -> Result<T, Error> {
122-
tracing::debug!("Fetching JSON from: {} (accept: {})", url, accept);
118+
self.get_json_with_optional_accept(url, Some(accept)).await
119+
}
120+
121+
async fn get_json_with_optional_accept<T: DeserializeOwned>(
122+
&self,
123+
url: &str,
124+
accept: Option<&str>,
125+
) -> Result<T, Error> {
126+
tracing::debug!("Fetching JSON from: {} (accept: {:?})", url, accept);
123127

124-
let response = self.get_with_accept(url, Some(accept)).await?;
125-
let data = response.json::<T>().await?;
126-
Ok(data)
128+
let client = vite_shared::shared_http_client();
129+
(|| async {
130+
let mut request = client.get(url);
131+
if let Some(accept) = accept {
132+
request = request.header(reqwest::header::ACCEPT, accept);
133+
}
134+
let response = request.send().await?.error_for_status()?;
135+
Ok::<T, Error>(response.json::<T>().await?)
136+
})
137+
.retry(
138+
ExponentialBuilder::default()
139+
.with_jitter()
140+
.with_min_delay(Duration::from_millis(self.min_delay))
141+
.with_max_times(self.max_times),
142+
)
143+
.await
127144
}
128145

129146
/// Download a file to a specified path
@@ -814,15 +831,16 @@ mod tests {
814831
let server = MockServer::start();
815832

816833
// Mock response with invalid JSON
817-
server.mock(|when, then| {
834+
let mock = server.mock(|when, then| {
818835
when.method(GET).path("/invalid.json");
819836
then.status(200).header("content-type", "application/json").body("not valid json");
820837
});
821838

822-
let client = HttpClient::new();
839+
let client = HttpClient::with_config(2, 1);
823840
let url = format!("{}/invalid.json", server.base_url());
824841

825842
let result: Result<TestData, _> = client.get_json(&url).await;
826843
assert!(result.is_err(), "Expected JSON parsing to fail");
844+
mock.assert_hits(3);
827845
}
828846
}

crates/vite_js_runtime/src/download.rs

Lines changed: 45 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::{fs::File, io::IsTerminal, time::Duration};
88
use backon::{ExponentialBuilder, Retryable};
99
use futures_util::StreamExt;
1010
use indicatif::{ProgressBar, ProgressStyle};
11+
use serde::de::DeserializeOwned;
1112
use sha2::{Digest, Sha256};
1213
use tokio::{fs, io::AsyncWriteExt};
1314
use vite_path::{AbsolutePath, AbsolutePathBuf};
@@ -16,10 +17,9 @@ use vite_str::Str;
1617
use crate::{Error, provider::ArchiveFormat};
1718

1819
/// Response from a cached fetch operation
19-
pub struct CachedFetchResponse {
20-
/// Response body (None if 304 Not Modified)
21-
#[expect(clippy::disallowed_types, reason = "HTTP response body is a String")]
22-
pub body: Option<String>,
20+
pub struct CachedFetchResponse<T> {
21+
/// Deserialized response body (None if 304 Not Modified)
22+
pub body: Option<T>,
2323
/// `ETag` header value
2424
pub etag: Option<Str>,
2525
/// Cache max-age in seconds (from Cache-Control header)
@@ -164,26 +164,60 @@ pub async fn download_text(url: &str) -> Result<String, Error> {
164164
Ok(content)
165165
}
166166

167-
/// Fetch text with conditional request support
167+
/// Fetch JSON with conditional request support.
168168
///
169169
/// If `if_none_match` is provided, sends `If-None-Match` header for conditional request.
170-
/// Returns response with cache headers and `not_modified` flag.
171-
pub async fn fetch_with_cache_headers(
170+
/// The request, response body, and JSON decoding are retried as one operation so a
171+
/// truncated body cannot escape the retry boundary as a deserialization error.
172+
pub async fn fetch_json_with_cache_headers<T: DeserializeOwned>(
172173
url: &str,
173174
if_none_match: Option<&str>,
174-
) -> Result<CachedFetchResponse, Error> {
175+
) -> Result<CachedFetchResponse<T>, Error> {
175176
let client = vite_shared::shared_http_client();
176177

177178
tracing::debug!("Fetching with cache headers from {url}");
178179

179-
let response = (|| async {
180+
(|| async {
180181
let mut request = client.get(url);
181182

182183
if let Some(etag) = if_none_match {
183184
request = request.header("If-None-Match", etag);
184185
}
185186

186-
request.send().await
187+
let response = request.send().await?.error_for_status()?;
188+
189+
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
190+
tracing::debug!("Received 304 Not Modified for {url}");
191+
return Ok(CachedFetchResponse {
192+
body: None,
193+
etag: None,
194+
max_age: None,
195+
not_modified: true,
196+
});
197+
}
198+
199+
// Extract headers before consuming the response.
200+
let etag = response
201+
.headers()
202+
.get("etag")
203+
.and_then(|v| v.to_str().ok())
204+
.map(std::convert::Into::into);
205+
206+
let max_age = response
207+
.headers()
208+
.get("cache-control")
209+
.and_then(|v| v.to_str().ok())
210+
.and_then(parse_max_age);
211+
212+
let bytes = response.bytes().await?;
213+
let body = serde_json::from_slice(&bytes)?;
214+
215+
Ok::<CachedFetchResponse<T>, Error>(CachedFetchResponse {
216+
body: Some(body),
217+
etag,
218+
max_age,
219+
not_modified: false,
220+
})
187221
})
188222
.retry(
189223
ExponentialBuilder::default()
@@ -195,35 +229,7 @@ pub async fn fetch_with_cache_headers(
195229
.map_err(|e| Error::DownloadFailed {
196230
url: url.into(),
197231
reason: vite_shared::format_error_chain(&e).into(),
198-
})?;
199-
200-
// Check for 304 Not Modified
201-
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
202-
tracing::debug!("Received 304 Not Modified for {url}");
203-
return Ok(CachedFetchResponse {
204-
body: None,
205-
etag: None,
206-
max_age: None,
207-
not_modified: true,
208-
});
209-
}
210-
211-
// Extract headers before consuming response
212-
let etag =
213-
response.headers().get("etag").and_then(|v| v.to_str().ok()).map(std::convert::Into::into);
214-
215-
let max_age = response
216-
.headers()
217-
.get("cache-control")
218-
.and_then(|v| v.to_str().ok())
219-
.and_then(parse_max_age);
220-
221-
let body = response.text().await.map_err(|e| Error::DownloadFailed {
222-
url: url.into(),
223-
reason: vite_shared::format_error_chain(&e).into(),
224-
})?;
225-
226-
Ok(CachedFetchResponse { body: Some(body), etag, max_age, not_modified: false })
232+
})
227233
}
228234

229235
/// Parse max-age from Cache-Control header value

crates/vite_js_runtime/src/providers/node.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use vite_str::Str;
1313
use crate::provider::ShasumsSignature;
1414
use crate::{
1515
Error, Platform,
16-
download::fetch_with_cache_headers,
16+
download::fetch_json_with_cache_headers,
1717
platform::Os,
1818
provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider},
1919
};
@@ -237,7 +237,8 @@ impl NodeProvider {
237237
let base_url = get_dist_url();
238238
let index_url = vite_str::format!("{base_url}/index.json");
239239

240-
let response = fetch_with_cache_headers(&index_url, Some(etag)).await?;
240+
let response =
241+
fetch_json_with_cache_headers::<Vec<NodeVersionEntry>>(&index_url, Some(etag)).await?;
241242

242243
if response.not_modified {
243244
// Server confirmed data hasn't changed, refresh TTL
@@ -252,10 +253,9 @@ impl NodeProvider {
252253
}
253254

254255
// Got new data
255-
let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed {
256+
let versions = response.body.ok_or_else(|| Error::VersionIndexParseFailed {
256257
reason: "Empty response body".into(),
257258
})?;
258-
let versions: Vec<NodeVersionEntry> = serde_json::from_str(&body)?;
259259

260260
let new_cache = VersionIndexCache {
261261
expires_at: calculate_expires_at(response.max_age),
@@ -276,12 +276,12 @@ impl NodeProvider {
276276
let index_url = vite_str::format!("{base_url}/index.json");
277277

278278
tracing::debug!("Fetching version index from {index_url}");
279-
let response = fetch_with_cache_headers(&index_url, None).await?;
279+
let response =
280+
fetch_json_with_cache_headers::<Vec<NodeVersionEntry>>(&index_url, None).await?;
280281

281-
let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed {
282+
let versions = response.body.ok_or_else(|| Error::VersionIndexParseFailed {
282283
reason: "Empty response body".into(),
283284
})?;
284-
let versions: Vec<NodeVersionEntry> = serde_json::from_str(&body)?;
285285

286286
let cache = VersionIndexCache {
287287
expires_at: calculate_expires_at(response.max_age),

packages/cli/src/create/__tests__/register-template.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ describe('registerLocalTemplate', () => {
6060
return config.create ?? {};
6161
}
6262

63+
// The first Vite config evaluation can cold-start slowly on Windows CI.
6364
it('creates a vite.config.ts with create.templates when none exists', async () => {
6465
expect(fs.existsSync(path.join(workspaceRoot, 'vite.config.ts'))).toBe(false);
6566

@@ -69,7 +70,7 @@ describe('registerLocalTemplate', () => {
6970
const create = await readCreate();
7071
expect(create.defaultTemplate).toBeUndefined();
7172
expect(create.templates).toEqual([ENTRY_A]);
72-
});
73+
}, 15_000);
7374

7475
it('targets an existing vite.config.mts instead of creating a stray vite.config.ts', async () => {
7576
// A monorepo whose only config is a .mts (or .cts/.cjs) file must be the

0 commit comments

Comments
 (0)