@verdaccio/e2e-cli 2.10.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/Cargo.lock +1591 -0
  2. package/Cargo.toml +19 -0
  3. package/bin/e2e-cli.js +26 -6
  4. package/build/bin/verdaccio-e2e +0 -0
  5. package/build/cjs/index.cjs +11 -166
  6. package/build/esm/index.js +10 -153
  7. package/build/index.d.ts +15 -6
  8. package/package.json +14 -16
  9. package/scripts/build-js-package.mjs +25 -0
  10. package/src/e2e_tests/audit.rs +51 -0
  11. package/src/e2e_tests/ci.rs +32 -0
  12. package/src/e2e_tests/deprecate.rs +64 -0
  13. package/src/e2e_tests/dist_tags.rs +56 -0
  14. package/src/e2e_tests/info.rs +43 -0
  15. package/src/e2e_tests/install.rs +27 -0
  16. package/src/e2e_tests/login.rs +64 -0
  17. package/src/e2e_tests/mod.rs +104 -0
  18. package/src/e2e_tests/ping.rs +33 -0
  19. package/src/e2e_tests/publish.rs +68 -0
  20. package/src/e2e_tests/search.rs +14 -0
  21. package/src/e2e_tests/unpublish.rs +37 -0
  22. package/src/main.rs +1411 -0
  23. package/src/scenarios/install_multiple_deps.rs +99 -0
  24. package/src/scenarios/minimum_release_age.rs +45 -0
  25. package/src/scenarios/mod.rs +11 -0
  26. package/build/adapters/adapter.d.ts +0 -1
  27. package/build/adapters/bun.d.ts +0 -2
  28. package/build/adapters/deno.d.ts +0 -2
  29. package/build/adapters/index.d.ts +0 -6
  30. package/build/adapters/npm.d.ts +0 -2
  31. package/build/adapters/pnpm.d.ts +0 -2
  32. package/build/adapters/yarn-classic.d.ts +0 -2
  33. package/build/adapters/yarn-modern.d.ts +0 -2
  34. package/build/cjs/_virtual/_rolldown/runtime.cjs +0 -23
  35. package/build/cjs/adapters/bun.cjs +0 -63
  36. package/build/cjs/adapters/bun.cjs.map +0 -1
  37. package/build/cjs/adapters/deno.cjs +0 -58
  38. package/build/cjs/adapters/deno.cjs.map +0 -1
  39. package/build/cjs/adapters/index.cjs +0 -6
  40. package/build/cjs/adapters/npm.cjs +0 -75
  41. package/build/cjs/adapters/npm.cjs.map +0 -1
  42. package/build/cjs/adapters/pnpm.cjs +0 -104
  43. package/build/cjs/adapters/pnpm.cjs.map +0 -1
  44. package/build/cjs/adapters/yarn-classic.cjs +0 -98
  45. package/build/cjs/adapters/yarn-classic.cjs.map +0 -1
  46. package/build/cjs/adapters/yarn-modern.cjs +0 -206
  47. package/build/cjs/adapters/yarn-modern.cjs.map +0 -1
  48. package/build/cjs/index.cjs.map +0 -1
  49. package/build/cjs/reporter.cjs +0 -113
  50. package/build/cjs/reporter.cjs.map +0 -1
  51. package/build/cjs/runner.cjs +0 -141
  52. package/build/cjs/runner.cjs.map +0 -1
  53. package/build/cjs/scenarios/index.cjs +0 -8
  54. package/build/cjs/scenarios/index.cjs.map +0 -1
  55. package/build/cjs/scenarios/install-multiple-deps.cjs +0 -117
  56. package/build/cjs/scenarios/install-multiple-deps.cjs.map +0 -1
  57. package/build/cjs/scenarios/minimum-release-age.cjs +0 -112
  58. package/build/cjs/scenarios/minimum-release-age.cjs.map +0 -1
  59. package/build/cjs/tests/audit.cjs +0 -47
  60. package/build/cjs/tests/audit.cjs.map +0 -1
  61. package/build/cjs/tests/ci.cjs +0 -83
  62. package/build/cjs/tests/ci.cjs.map +0 -1
  63. package/build/cjs/tests/deprecate.cjs +0 -107
  64. package/build/cjs/tests/deprecate.cjs.map +0 -1
  65. package/build/cjs/tests/dist-tags.cjs +0 -43
  66. package/build/cjs/tests/dist-tags.cjs.map +0 -1
  67. package/build/cjs/tests/index.cjs +0 -33
  68. package/build/cjs/tests/index.cjs.map +0 -1
  69. package/build/cjs/tests/info.cjs +0 -42
  70. package/build/cjs/tests/info.cjs.map +0 -1
  71. package/build/cjs/tests/install.cjs +0 -27
  72. package/build/cjs/tests/install.cjs.map +0 -1
  73. package/build/cjs/tests/login.cjs +0 -117
  74. package/build/cjs/tests/login.cjs.map +0 -1
  75. package/build/cjs/tests/ping.cjs +0 -30
  76. package/build/cjs/tests/ping.cjs.map +0 -1
  77. package/build/cjs/tests/publish.cjs +0 -37
  78. package/build/cjs/tests/publish.cjs.map +0 -1
  79. package/build/cjs/tests/search.cjs +0 -19
  80. package/build/cjs/tests/search.cjs.map +0 -1
  81. package/build/cjs/tests/unpublish.cjs +0 -40
  82. package/build/cjs/tests/unpublish.cjs.map +0 -1
  83. package/build/cjs/utils/info.cjs +0 -25
  84. package/build/cjs/utils/info.cjs.map +0 -1
  85. package/build/cjs/utils/process.cjs +0 -85
  86. package/build/cjs/utils/process.cjs.map +0 -1
  87. package/build/cjs/utils/project.cjs +0 -55
  88. package/build/cjs/utils/project.cjs.map +0 -1
  89. package/build/cjs/utils/registry-client.cjs +0 -56
  90. package/build/cjs/utils/registry-client.cjs.map +0 -1
  91. package/build/esm/adapters/bun.js +0 -61
  92. package/build/esm/adapters/bun.js.map +0 -1
  93. package/build/esm/adapters/deno.js +0 -56
  94. package/build/esm/adapters/deno.js.map +0 -1
  95. package/build/esm/adapters/index.js +0 -7
  96. package/build/esm/adapters/npm.js +0 -73
  97. package/build/esm/adapters/npm.js.map +0 -1
  98. package/build/esm/adapters/pnpm.js +0 -102
  99. package/build/esm/adapters/pnpm.js.map +0 -1
  100. package/build/esm/adapters/yarn-classic.js +0 -96
  101. package/build/esm/adapters/yarn-classic.js.map +0 -1
  102. package/build/esm/adapters/yarn-modern.js +0 -203
  103. package/build/esm/adapters/yarn-modern.js.map +0 -1
  104. package/build/esm/index.js.map +0 -1
  105. package/build/esm/reporter.js +0 -107
  106. package/build/esm/reporter.js.map +0 -1
  107. package/build/esm/runner.js +0 -138
  108. package/build/esm/runner.js.map +0 -1
  109. package/build/esm/scenarios/index.js +0 -8
  110. package/build/esm/scenarios/index.js.map +0 -1
  111. package/build/esm/scenarios/install-multiple-deps.js +0 -114
  112. package/build/esm/scenarios/install-multiple-deps.js.map +0 -1
  113. package/build/esm/scenarios/minimum-release-age.js +0 -109
  114. package/build/esm/scenarios/minimum-release-age.js.map +0 -1
  115. package/build/esm/tests/audit.js +0 -44
  116. package/build/esm/tests/audit.js.map +0 -1
  117. package/build/esm/tests/ci.js +0 -80
  118. package/build/esm/tests/ci.js.map +0 -1
  119. package/build/esm/tests/deprecate.js +0 -104
  120. package/build/esm/tests/deprecate.js.map +0 -1
  121. package/build/esm/tests/dist-tags.js +0 -41
  122. package/build/esm/tests/dist-tags.js.map +0 -1
  123. package/build/esm/tests/index.js +0 -33
  124. package/build/esm/tests/index.js.map +0 -1
  125. package/build/esm/tests/info.js +0 -40
  126. package/build/esm/tests/info.js.map +0 -1
  127. package/build/esm/tests/install.js +0 -25
  128. package/build/esm/tests/install.js.map +0 -1
  129. package/build/esm/tests/login.js +0 -113
  130. package/build/esm/tests/login.js.map +0 -1
  131. package/build/esm/tests/ping.js +0 -28
  132. package/build/esm/tests/ping.js.map +0 -1
  133. package/build/esm/tests/publish.js +0 -35
  134. package/build/esm/tests/publish.js.map +0 -1
  135. package/build/esm/tests/search.js +0 -17
  136. package/build/esm/tests/search.js.map +0 -1
  137. package/build/esm/tests/unpublish.js +0 -38
  138. package/build/esm/tests/unpublish.js.map +0 -1
  139. package/build/esm/utils/info.js +0 -24
  140. package/build/esm/utils/info.js.map +0 -1
  141. package/build/esm/utils/process.js +0 -82
  142. package/build/esm/utils/process.js.map +0 -1
  143. package/build/esm/utils/project.js +0 -50
  144. package/build/esm/utils/project.js.map +0 -1
  145. package/build/esm/utils/registry-client.js +0 -53
  146. package/build/esm/utils/registry-client.js.map +0 -1
  147. package/build/reporter.d.ts +0 -7
  148. package/build/runner.d.ts +0 -13
  149. package/build/scenarios/index.d.ts +0 -5
  150. package/build/scenarios/install-multiple-deps.d.ts +0 -2
  151. package/build/scenarios/minimum-release-age.d.ts +0 -11
  152. package/build/tests/audit.d.ts +0 -2
  153. package/build/tests/ci.d.ts +0 -2
  154. package/build/tests/deprecate.d.ts +0 -2
  155. package/build/tests/dist-tags.d.ts +0 -2
  156. package/build/tests/index.d.ts +0 -15
  157. package/build/tests/info.d.ts +0 -2
  158. package/build/tests/install.d.ts +0 -2
  159. package/build/tests/login.d.ts +0 -2
  160. package/build/tests/ping.d.ts +0 -2
  161. package/build/tests/publish.d.ts +0 -2
  162. package/build/tests/search.d.ts +0 -2
  163. package/build/tests/unpublish.d.ts +0 -2
  164. package/build/types.d.ts +0 -81
  165. package/build/utils/index.d.ts +0 -4
  166. package/build/utils/info.d.ts +0 -14
  167. package/build/utils/process.d.ts +0 -4
  168. package/build/utils/project.d.ts +0 -6
  169. package/build/utils/registry-client.d.ts +0 -15
package/src/main.rs ADDED
@@ -0,0 +1,1411 @@
1
+ use anyhow::{anyhow, bail, Context, Result};
2
+ use clap::Parser;
3
+ use reqwest::blocking::Client;
4
+ use serde_json::{json, Value};
5
+ use std::collections::{BTreeMap, BTreeSet};
6
+ use std::env;
7
+ use std::fs;
8
+ use std::io::{Read, Write};
9
+ use std::path::{Path, PathBuf};
10
+ use std::process::{Command, Stdio};
11
+ use std::sync::atomic::{AtomicU64, Ordering};
12
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
+ use tempfile::{Builder, TempDir};
14
+ use url::Url;
15
+
16
+ mod e2e_tests;
17
+ mod scenarios;
18
+
19
+ static RUN_COUNTER: AtomicU64 = AtomicU64::new(0);
20
+
21
+ const RESET: &str = "\x1b[0m";
22
+ const RED: &str = "\x1b[31m";
23
+ const GREEN: &str = "\x1b[32m";
24
+ const YELLOW: &str = "\x1b[33m";
25
+ const CYAN: &str = "\x1b[36m";
26
+ const DIM: &str = "\x1b[2m";
27
+ const BOLD: &str = "\x1b[1m";
28
+
29
+ #[derive(Parser, Debug)]
30
+ #[command(
31
+ name = "verdaccio-e2e",
32
+ disable_version_flag = true,
33
+ after_help = "Examples:\n verdaccio-e2e -r http://localhost:4873 --pm npm --pm pnpm\n verdaccio-e2e -r http://localhost:4873 --test publish --test install\n verdaccio-e2e -r http://localhost:4873 --pm yarn-modern@4 -v"
34
+ )]
35
+ struct Cli {
36
+ #[arg(short = 'r', long = "registry")]
37
+ registry: String,
38
+ #[arg(long = "pm")]
39
+ pm: Vec<String>,
40
+ #[arg(short = 't', long = "test")]
41
+ test: Vec<String>,
42
+ #[arg(short = 'c', long = "concurrency", default_value_t = 1)]
43
+ concurrency: usize,
44
+ #[arg(long = "timeout", default_value_t = 50_000)]
45
+ timeout: u64,
46
+ #[arg(long = "token")]
47
+ token: Option<String>,
48
+ #[arg(short = 'v', long = "verbose")]
49
+ verbose: bool,
50
+ }
51
+
52
+ #[derive(Clone, Debug, PartialEq, Eq)]
53
+ enum AdapterType {
54
+ Npm,
55
+ Pnpm,
56
+ YarnClassic,
57
+ YarnModern,
58
+ Bun,
59
+ Deno,
60
+ }
61
+
62
+ #[derive(Clone, Debug)]
63
+ struct Adapter {
64
+ name: String,
65
+ kind: AdapterType,
66
+ bin: String,
67
+ supports: BTreeSet<&'static str>,
68
+ version: String,
69
+ yarn_major: u64,
70
+ }
71
+
72
+ #[derive(Debug)]
73
+ struct ExecOutput {
74
+ stdout: String,
75
+ stderr: String,
76
+ }
77
+
78
+ #[derive(Debug)]
79
+ struct TestResult {
80
+ name: &'static str,
81
+ passed: bool,
82
+ duration: Duration,
83
+ error: Option<String>,
84
+ sub_tests: Vec<SubTestResult>,
85
+ }
86
+
87
+ #[derive(Debug)]
88
+ struct SubTestResult {
89
+ label: &'static str,
90
+ passed: bool,
91
+ duration: Duration,
92
+ error: Option<String>,
93
+ }
94
+
95
+ #[derive(Debug)]
96
+ struct SuiteResult {
97
+ adapter: String,
98
+ tests: Vec<TestResult>,
99
+ passed: usize,
100
+ failed: usize,
101
+ skipped: usize,
102
+ duration: Duration,
103
+ }
104
+
105
+ struct RunState {
106
+ temp_dirs: Vec<TempDir>,
107
+ verbose: bool,
108
+ }
109
+
110
+ struct TestContext<'a> {
111
+ registry_url: String,
112
+ token: String,
113
+ adapter: Adapter,
114
+ run_id: String,
115
+ timeout: Duration,
116
+ deadline: Instant,
117
+ state: &'a mut RunState,
118
+ sub_results: Vec<SubTestResult>,
119
+ }
120
+
121
+ struct TestDefinition {
122
+ name: &'static str,
123
+ requires: &'static [&'static str],
124
+ applies_to: fn(&Adapter) -> bool,
125
+ run: fn(&mut TestContext<'_>) -> Result<()>,
126
+ }
127
+
128
+ fn main() {
129
+ let code = match run() {
130
+ Ok(code) => code,
131
+ Err(err) => {
132
+ eprintln!("{RED}Error:{RESET} {err:#}");
133
+ 1
134
+ }
135
+ };
136
+ std::process::exit(code);
137
+ }
138
+
139
+ fn run() -> Result<i32> {
140
+ let cli = Cli::parse();
141
+ let mut state = RunState {
142
+ temp_dirs: Vec::new(),
143
+ verbose: cli.verbose,
144
+ };
145
+
146
+ let run_dir = create_temp_dir(&mut state, "e2e-run")?;
147
+ env::set_current_dir(run_dir.path()).context("failed to enter isolated run directory")?;
148
+
149
+ println!("Checking registry at {}...", cli.registry);
150
+ if !ping_registry(&cli.registry) {
151
+ bail!("Registry at {} is not reachable", cli.registry);
152
+ }
153
+ println!("Registry is alive.");
154
+
155
+ let token = match cli.token {
156
+ Some(token) => token,
157
+ None => {
158
+ println!("Creating test user...");
159
+ let user = format!("e2e-user-{}", now_millis());
160
+ let token = create_user(&cli.registry, &user, "e2e-test-password")?;
161
+ println!("User \"{user}\" created.");
162
+ token
163
+ }
164
+ };
165
+
166
+ let adapters = parse_adapters(&cli.pm, &mut state)?;
167
+ println!(
168
+ "Adapters: {}",
169
+ adapters
170
+ .iter()
171
+ .map(|adapter| adapter.name.as_str())
172
+ .collect::<Vec<_>>()
173
+ .join(", ")
174
+ );
175
+
176
+ let tests = e2e_tests::all_tests();
177
+ let selected = if cli.test.is_empty() {
178
+ tests.iter().map(|test| test.name).collect::<Vec<_>>()
179
+ } else {
180
+ cli.test.iter().map(String::as_str).collect::<Vec<_>>()
181
+ };
182
+ println!("Tests: {}", selected.join(", "));
183
+
184
+ let results = run_all(
185
+ &adapters,
186
+ &tests,
187
+ &cli.registry,
188
+ &token,
189
+ &cli.test,
190
+ cli.timeout,
191
+ &mut state,
192
+ );
193
+ report_summary(&results)?;
194
+ Ok(if results.iter().any(|suite| suite.failed > 0) {
195
+ 1
196
+ } else {
197
+ 0
198
+ })
199
+ }
200
+
201
+ fn run_all(
202
+ adapters: &[Adapter],
203
+ tests: &[TestDefinition],
204
+ registry_url: &str,
205
+ token: &str,
206
+ test_filter: &[String],
207
+ timeout_ms: u64,
208
+ state: &mut RunState,
209
+ ) -> Vec<SuiteResult> {
210
+ let mut results = Vec::new();
211
+ for adapter in adapters {
212
+ results.push(run_suite(
213
+ adapter,
214
+ tests,
215
+ registry_url,
216
+ token,
217
+ test_filter,
218
+ timeout_ms,
219
+ state,
220
+ ));
221
+ }
222
+ results
223
+ }
224
+
225
+ fn run_suite(
226
+ adapter: &Adapter,
227
+ tests: &[TestDefinition],
228
+ registry_url: &str,
229
+ token: &str,
230
+ test_filter: &[String],
231
+ timeout_ms: u64,
232
+ state: &mut RunState,
233
+ ) -> SuiteResult {
234
+ let suite_start = Instant::now();
235
+ let mut results = Vec::new();
236
+ let mut skipped = 0usize;
237
+ println!("\n{BOLD}{CYAN}{}{RESET}", adapter.name);
238
+
239
+ for test in tests {
240
+ if !test_filter.is_empty() && !test_filter.iter().any(|name| name == test.name) {
241
+ continue;
242
+ }
243
+ let unsupported = test
244
+ .requires
245
+ .iter()
246
+ .any(|command| !adapter.supports.contains(command));
247
+ if unsupported || !(test.applies_to)(adapter) {
248
+ println!(" {YELLOW}SKIP{RESET} {}", test.name);
249
+ skipped += 1;
250
+ continue;
251
+ }
252
+
253
+ println!(" {DIM}running{RESET} {} > {}...", adapter.name, test.name);
254
+ let started = Instant::now();
255
+ let timeout = Duration::from_millis(timeout_ms);
256
+ let deadline = started + timeout;
257
+ let mut ctx = TestContext {
258
+ registry_url: registry_url.to_string(),
259
+ token: token.to_string(),
260
+ adapter: adapter.clone(),
261
+ run_id: run_id(),
262
+ timeout,
263
+ deadline,
264
+ state,
265
+ sub_results: Vec::new(),
266
+ };
267
+
268
+ let result = match (test.run)(&mut ctx) {
269
+ Ok(()) if started.elapsed() > timeout => TestResult {
270
+ name: test.name,
271
+ passed: false,
272
+ duration: started.elapsed(),
273
+ error: Some(format!("Test timed out after {timeout_ms}ms")),
274
+ sub_tests: ctx.sub_results,
275
+ },
276
+ Ok(()) => TestResult {
277
+ name: test.name,
278
+ passed: true,
279
+ duration: started.elapsed(),
280
+ error: None,
281
+ sub_tests: ctx.sub_results,
282
+ },
283
+ Err(err) => TestResult {
284
+ name: test.name,
285
+ passed: false,
286
+ duration: started.elapsed(),
287
+ error: Some(format!("{err:#}")),
288
+ sub_tests: ctx.sub_results,
289
+ },
290
+ };
291
+ report_test_result(&result);
292
+ results.push(result);
293
+ }
294
+
295
+ let passed = results.iter().filter(|result| result.passed).count();
296
+ let failed = results.iter().filter(|result| !result.passed).count();
297
+ SuiteResult {
298
+ adapter: adapter.name.clone(),
299
+ tests: results,
300
+ passed,
301
+ failed,
302
+ skipped,
303
+ duration: suite_start.elapsed(),
304
+ }
305
+ }
306
+
307
+ fn parse_adapters(filters: &[String], state: &mut RunState) -> Result<Vec<Adapter>> {
308
+ if filters.is_empty() {
309
+ return Ok(vec![create_adapter("npm", None, None, state)?]);
310
+ }
311
+ filters
312
+ .iter()
313
+ .map(|filter| {
314
+ let (name, version, bin_path) = parse_pm_spec(filter);
315
+ create_adapter(&name, version.as_deref(), bin_path.as_deref(), state)
316
+ })
317
+ .collect()
318
+ }
319
+
320
+ fn parse_pm_spec(filter: &str) -> (String, Option<String>, Option<String>) {
321
+ if let Some((name, bin)) = filter.split_once('=') {
322
+ return (name.to_ascii_lowercase(), None, Some(bin.to_string()));
323
+ }
324
+ if let Some((name, version)) = filter.split_once('@') {
325
+ return (name.to_ascii_lowercase(), Some(version.to_string()), None);
326
+ }
327
+ (filter.to_ascii_lowercase(), None, None)
328
+ }
329
+
330
+ fn create_adapter(
331
+ name: &str,
332
+ version: Option<&str>,
333
+ bin_path: Option<&str>,
334
+ state: &mut RunState,
335
+ ) -> Result<Adapter> {
336
+ match name {
337
+ "npm" => {
338
+ let bin = resolve_installed_bin(bin_path, version, "npm", "npm", "npm", state)?;
339
+ let resolved = detect_version(&bin, &[])?;
340
+ Ok(Adapter {
341
+ name: format!("npm@{resolved}"),
342
+ kind: AdapterType::Npm,
343
+ bin,
344
+ supports: set(&["publish", "unpublish", "install", "info", "audit", "deprecate", "dist-tag", "ping", "search"]),
345
+ version: resolved,
346
+ yarn_major: 0,
347
+ })
348
+ }
349
+ "pnpm" => {
350
+ let bin = resolve_installed_bin(bin_path, version, "pnpm", "pnpm", "pnpm", state)?;
351
+ let resolved = detect_version(&bin, &[])?;
352
+ let major = parse_major(&resolved);
353
+ let supports = if major >= 11 {
354
+ set(&["publish", "unpublish", "install", "info", "audit", "deprecate"])
355
+ } else {
356
+ set(&["publish", "unpublish", "install", "info", "audit", "deprecate", "dist-tag", "ping", "search"])
357
+ };
358
+ Ok(Adapter {
359
+ name: format!("pnpm@{resolved}"),
360
+ kind: AdapterType::Pnpm,
361
+ bin,
362
+ supports,
363
+ version: resolved,
364
+ yarn_major: 0,
365
+ })
366
+ }
367
+ "yarn-classic" | "yarn1" => {
368
+ let bin = match (bin_path, version) {
369
+ (Some(bin), _) => bin.to_string(),
370
+ (_, Some(version)) => {
371
+ let package = if version.starts_with('1') {
372
+ format!("yarn@{version}")
373
+ } else {
374
+ "yarn@1".to_string()
375
+ };
376
+ install_package_bin(&package, "node_modules/.bin/yarn", state)?
377
+ }
378
+ _ => resolve_yarn_classic_bin(state)?,
379
+ };
380
+ let resolved = detect_version_with_env(&bin, &[], yarn_base_env())?;
381
+ Ok(Adapter {
382
+ name: format!("yarn-classic@{resolved}"),
383
+ kind: AdapterType::YarnClassic,
384
+ bin,
385
+ supports: set(&["publish", "install", "info", "audit"]),
386
+ version: resolved,
387
+ yarn_major: 1,
388
+ })
389
+ }
390
+ "yarn-modern" | "yarn" => {
391
+ let bin = match (bin_path, version) {
392
+ (Some(bin), _) => bin.to_string(),
393
+ (_, Some(version)) => install_package_bin(
394
+ &format!("@yarnpkg/cli-dist@{version}"),
395
+ "node_modules/@yarnpkg/cli-dist/bin/yarn.js",
396
+ state,
397
+ )?,
398
+ _ => resolve_yarn_modern_bin(state)?,
399
+ };
400
+ let resolved = detect_version_with_env(&bin, &[], yarn_base_env())?;
401
+ let major = parse_major(&resolved);
402
+ Ok(Adapter {
403
+ name: format!("yarn-modern@{resolved}"),
404
+ kind: AdapterType::YarnModern,
405
+ bin,
406
+ supports: set(&["publish", "install", "info", "ping", "deprecate", "login"]),
407
+ version: resolved,
408
+ yarn_major: major,
409
+ })
410
+ }
411
+ "bun" => {
412
+ let bin = bin_path.unwrap_or("bun").to_string();
413
+ let resolved = detect_version(&bin, &[])?;
414
+ Ok(Adapter {
415
+ name: format!("bun@{resolved}"),
416
+ kind: AdapterType::Bun,
417
+ bin,
418
+ supports: set(&["publish", "install", "info", "audit"]),
419
+ version: resolved,
420
+ yarn_major: 0,
421
+ })
422
+ }
423
+ "deno" => {
424
+ let bin = bin_path.unwrap_or("deno").to_string();
425
+ let output = command_output(&bin, &["--version"], None, &[], false, None)?;
426
+ let resolved = output
427
+ .stdout
428
+ .lines()
429
+ .find_map(|line| line.strip_prefix("deno ").map(str::to_string))
430
+ .unwrap_or_else(|| "unknown".to_string());
431
+ Ok(Adapter {
432
+ name: format!("deno@{resolved}"),
433
+ kind: AdapterType::Deno,
434
+ bin,
435
+ supports: set(&["install", "info"]),
436
+ version: resolved,
437
+ yarn_major: 0,
438
+ })
439
+ }
440
+ other => bail!("Unknown package manager: \"{other}\". Supported: npm, pnpm, yarn-classic, yarn-modern, bun, deno"),
441
+ }
442
+ }
443
+
444
+ fn set(items: &[&'static str]) -> BTreeSet<&'static str> {
445
+ items.iter().copied().collect()
446
+ }
447
+
448
+ fn resolve_installed_bin(
449
+ bin_path: Option<&str>,
450
+ version: Option<&str>,
451
+ package_name: &str,
452
+ default_bin: &str,
453
+ bin_rel: &str,
454
+ state: &mut RunState,
455
+ ) -> Result<String> {
456
+ if let Some(bin) = bin_path {
457
+ return Ok(bin.to_string());
458
+ }
459
+ if let Some(version) = version {
460
+ return install_package_bin(
461
+ &format!("{package_name}@{version}"),
462
+ &format!("node_modules/.bin/{bin_rel}"),
463
+ state,
464
+ );
465
+ }
466
+ Ok(default_bin.to_string())
467
+ }
468
+
469
+ fn resolve_yarn_classic_bin(state: &mut RunState) -> Result<String> {
470
+ if let Ok(output) = command_output("which", &["yarn"], None, &[], false, None) {
471
+ let bin = output.stdout.trim();
472
+ if !bin.is_empty() {
473
+ let version = detect_version_with_env(bin, &[], yarn_base_env())
474
+ .unwrap_or_else(|_| "unknown".to_string());
475
+ if parse_major(&version) == 1 {
476
+ return Ok(bin.to_string());
477
+ }
478
+ }
479
+ }
480
+ install_package_bin("yarn@1", "node_modules/.bin/yarn", state)
481
+ }
482
+
483
+ fn resolve_yarn_modern_bin(state: &mut RunState) -> Result<String> {
484
+ if let Ok(output) = command_output("which", &["yarn"], None, &[], false, None) {
485
+ let bin = output.stdout.trim();
486
+ if !bin.is_empty() {
487
+ let version = detect_version_with_env(bin, &[], yarn_base_env())
488
+ .unwrap_or_else(|_| "unknown".to_string());
489
+ if parse_major(&version) >= 2 {
490
+ return Ok(bin.to_string());
491
+ }
492
+ }
493
+ }
494
+ install_package_bin(
495
+ "@yarnpkg/cli-dist@4",
496
+ "node_modules/@yarnpkg/cli-dist/bin/yarn.js",
497
+ state,
498
+ )
499
+ }
500
+
501
+ fn install_package_bin(package: &str, bin_rel: &str, state: &mut RunState) -> Result<String> {
502
+ let dir = tempfile::tempdir().context("failed to create package-manager install dir")?;
503
+ let path = dir.path().to_path_buf();
504
+ let prefix = path.to_string_lossy().to_string();
505
+ command_output(
506
+ "npm",
507
+ &[
508
+ "install",
509
+ "--prefix",
510
+ &prefix,
511
+ package,
512
+ "--loglevel=error",
513
+ "--min-release-age=0",
514
+ ],
515
+ None,
516
+ &[],
517
+ false,
518
+ None,
519
+ )
520
+ .with_context(|| format!("failed to install {package}"))?;
521
+ let bin = path.join(bin_rel).to_string_lossy().to_string();
522
+ let installed = detect_version(&bin, &[]).unwrap_or_else(|_| "unknown".to_string());
523
+ println!(" Auto-installed {} {}", package_label(package), installed);
524
+ state.temp_dirs.push(dir);
525
+ Ok(bin)
526
+ }
527
+
528
+ fn package_label(package: &str) -> &str {
529
+ package.rsplit_once('@').map_or(
530
+ package,
531
+ |(name, _)| {
532
+ if name.is_empty() {
533
+ package
534
+ } else {
535
+ name
536
+ }
537
+ },
538
+ )
539
+ }
540
+
541
+ fn detect_version(bin: &str, args: &[&str]) -> Result<String> {
542
+ detect_version_with_env(bin, args, Vec::new())
543
+ }
544
+
545
+ fn detect_version_with_env(
546
+ bin: &str,
547
+ args: &[&str],
548
+ envs: Vec<(&'static str, &'static str)>,
549
+ ) -> Result<String> {
550
+ let mut full_args = Vec::from(args);
551
+ full_args.push("--version");
552
+ Ok(command_output(bin, &full_args, None, &envs, false, None)?
553
+ .stdout
554
+ .trim()
555
+ .to_string())
556
+ }
557
+
558
+ fn parse_major(version: &str) -> u64 {
559
+ version
560
+ .split('.')
561
+ .next()
562
+ .and_then(|part| part.parse().ok())
563
+ .unwrap_or(0)
564
+ }
565
+
566
+ fn yarn_base_env() -> Vec<(&'static str, &'static str)> {
567
+ vec![("COREPACK_ENABLE_STRICT", "0"), ("YARN_IGNORE_PATH", "1")]
568
+ }
569
+
570
+ fn yarn_env(adapter: &Adapter) -> Vec<(&'static str, &'static str)> {
571
+ let mut envs = yarn_base_env();
572
+ if adapter.yarn_major >= 4 {
573
+ envs.push(("YARN_ENABLE_HARDENED_MODE", "0"));
574
+ envs.push(("YARN_NPM_MINIMAL_AGE_GATE", "0"));
575
+ }
576
+ envs
577
+ }
578
+
579
+ fn command_output(
580
+ bin: &str,
581
+ args: &[&str],
582
+ cwd: Option<&Path>,
583
+ envs: &[(&str, &str)],
584
+ verbose: bool,
585
+ timeout: Option<Duration>,
586
+ ) -> Result<ExecOutput> {
587
+ if verbose {
588
+ let cwd_label = cwd
589
+ .and_then(|path| path.file_name())
590
+ .and_then(|name| name.to_str())
591
+ .map(|name| format!(" {DIM}(cwd: {name}){RESET}"))
592
+ .unwrap_or_default();
593
+ println!(
594
+ " {CYAN}${RESET} {} {}{}",
595
+ short_bin(bin),
596
+ args.join(" "),
597
+ cwd_label
598
+ );
599
+ }
600
+
601
+ let started = Instant::now();
602
+ let mut command = Command::new(bin);
603
+ command.args(args);
604
+ command.stdout(Stdio::piped());
605
+ command.stderr(Stdio::piped());
606
+ if let Some(cwd) = cwd {
607
+ command.current_dir(cwd);
608
+ }
609
+ for (key, value) in envs {
610
+ command.env(key, value);
611
+ }
612
+ let mut child = command
613
+ .spawn()
614
+ .with_context(|| format!("failed to start command: {bin} {}", args.join(" ")))?;
615
+ let status = loop {
616
+ if let Some(status) = child
617
+ .try_wait()
618
+ .with_context(|| format!("failed waiting for command: {bin} {}", args.join(" ")))?
619
+ {
620
+ break status;
621
+ }
622
+ if let Some(limit) = timeout {
623
+ if started.elapsed() >= limit {
624
+ let _ = child.kill();
625
+ let _ = child.wait();
626
+ bail!(
627
+ "Running \"{} {}\" timed out after {}",
628
+ bin,
629
+ args.join(" "),
630
+ format_duration(limit)
631
+ );
632
+ }
633
+ }
634
+ std::thread::sleep(Duration::from_millis(25));
635
+ };
636
+ let mut stdout = String::new();
637
+ let mut stderr = String::new();
638
+ if let Some(mut pipe) = child.stdout.take() {
639
+ pipe.read_to_string(&mut stdout)
640
+ .context("failed to read command stdout")?;
641
+ }
642
+ if let Some(mut pipe) = child.stderr.take() {
643
+ pipe.read_to_string(&mut stderr)
644
+ .context("failed to read command stderr")?;
645
+ }
646
+
647
+ if verbose {
648
+ let status_label = if status.success() {
649
+ format!("{GREEN}ok{RESET}")
650
+ } else {
651
+ format!("{RED}exit {}{RESET}", status.code().unwrap_or(-1))
652
+ };
653
+ println!(
654
+ " {DIM} -> {status_label} {DIM}({}){RESET}",
655
+ format_duration(started.elapsed())
656
+ );
657
+ }
658
+
659
+ if status.success() {
660
+ Ok(ExecOutput { stdout, stderr })
661
+ } else {
662
+ bail!(
663
+ "Running \"{} {}\" returned error code {}...\n\nSTDOUT:\n{}\n\nSTDERR:\n{}\n",
664
+ bin,
665
+ args.join(" "),
666
+ status.code().unwrap_or(-1),
667
+ stdout,
668
+ stderr
669
+ )
670
+ }
671
+ }
672
+
673
+ fn short_bin(bin: &str) -> &str {
674
+ Path::new(bin)
675
+ .file_name()
676
+ .and_then(|name| name.to_str())
677
+ .unwrap_or(bin)
678
+ }
679
+
680
+ fn exec_adapter(
681
+ ctx: &TestContext<'_>,
682
+ cwd: Option<&Path>,
683
+ args: Vec<String>,
684
+ ) -> Result<ExecOutput> {
685
+ let args = normalize_adapter_args(&ctx.adapter, args);
686
+ let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
687
+ let envs = match ctx.adapter.kind {
688
+ AdapterType::YarnClassic | AdapterType::YarnModern => yarn_env(&ctx.adapter),
689
+ _ => Vec::new(),
690
+ };
691
+ command_output(
692
+ &ctx.adapter.bin,
693
+ &refs,
694
+ cwd,
695
+ &envs,
696
+ ctx.state.verbose,
697
+ Some(remaining_test_time(ctx)?),
698
+ )
699
+ }
700
+
701
+ fn remaining_test_time(ctx: &TestContext<'_>) -> Result<Duration> {
702
+ ctx.deadline
703
+ .checked_duration_since(Instant::now())
704
+ .ok_or_else(|| anyhow!("Test timed out after {}", format_duration(ctx.timeout)))
705
+ }
706
+
707
+ fn normalize_adapter_args(adapter: &Adapter, args: Vec<String>) -> Vec<String> {
708
+ match adapter.kind {
709
+ AdapterType::Pnpm if parse_major(&adapter.version) >= 11 => {
710
+ let cmd = args.first().map(String::as_str).unwrap_or_default();
711
+ let mut filtered = if matches!(cmd, "deprecate" | "unpublish" | "publish" | "dist-tag")
712
+ {
713
+ args.into_iter()
714
+ .filter(|arg| arg != "--json" && !arg.starts_with("--loglevel"))
715
+ .collect::<Vec<_>>()
716
+ } else if cmd == "version" {
717
+ args.into_iter()
718
+ .filter(|arg| {
719
+ !arg.starts_with("--registry")
720
+ && !arg.starts_with("--loglevel")
721
+ && !arg.starts_with("--no-git-tag-version")
722
+ })
723
+ .collect::<Vec<_>>()
724
+ } else {
725
+ args
726
+ };
727
+ if filtered.first().map(String::as_str) == Some("deprecate")
728
+ && filtered.get(2).map(String::as_str) == Some("")
729
+ {
730
+ let mut updated = vec!["undeprecate".to_string(), filtered[1].clone()];
731
+ updated.extend(filtered.drain(3..));
732
+ filtered = updated;
733
+ }
734
+ filtered
735
+ }
736
+ AdapterType::YarnModern => {
737
+ let cmd = args.first().cloned().unwrap_or_default();
738
+ let mut rest = args
739
+ .into_iter()
740
+ .skip(1)
741
+ .filter(|arg| !arg.starts_with("--registry"))
742
+ .collect::<Vec<_>>();
743
+ match cmd.as_str() {
744
+ "publish" => {
745
+ rest.retain(|arg| arg != "--json");
746
+ let mut out = vec!["npm".to_string(), "publish".to_string()];
747
+ out.extend(rest);
748
+ out
749
+ }
750
+ "info" => prepend(&["npm", "info"], rest),
751
+ "ping" => prepend(&["npm", "ping"], rest),
752
+ "deprecate" => prepend(&["npm", "deprecate"], rest),
753
+ "login" => prepend(&["npm", "login"], rest),
754
+ "whoami" => prepend(&["npm", "whoami"], rest),
755
+ _ => {
756
+ let mut out = vec![cmd.to_string()];
757
+ out.extend(rest);
758
+ out
759
+ }
760
+ }
761
+ }
762
+ AdapterType::Bun if args.first().map(String::as_str) == Some("info") => {
763
+ let pkg = args.get(1).cloned().unwrap_or_default();
764
+ let cleaned = args
765
+ .into_iter()
766
+ .skip(2)
767
+ .filter(|arg| arg != "--registry" && !arg.starts_with("http"))
768
+ .collect::<Vec<_>>();
769
+ let mut out = vec!["info".to_string(), pkg];
770
+ out.extend(cleaned);
771
+ out
772
+ }
773
+ AdapterType::Deno if args.first().map(String::as_str) == Some("info") => {
774
+ let pkg = args.get(1).cloned().unwrap_or_default();
775
+ let mut out = vec![
776
+ "info".to_string(),
777
+ format!("npm:{pkg}"),
778
+ "--node-modules-dir=auto".to_string(),
779
+ ];
780
+ out.extend(args.into_iter().skip(2).filter(|arg| arg != "--json"));
781
+ out
782
+ }
783
+ AdapterType::Deno if args.first().map(String::as_str) == Some("install") => {
784
+ vec!["install".to_string()]
785
+ }
786
+ _ => args,
787
+ }
788
+ }
789
+
790
+ fn prepend(prefix: &[&str], rest: Vec<String>) -> Vec<String> {
791
+ let mut out = prefix
792
+ .iter()
793
+ .map(|item| item.to_string())
794
+ .collect::<Vec<_>>();
795
+ out.extend(rest);
796
+ out
797
+ }
798
+
799
+ fn registry_arg(adapter: &Adapter, url: &str) -> Vec<String> {
800
+ match adapter.kind {
801
+ AdapterType::Deno | AdapterType::YarnModern => Vec::new(),
802
+ _ => vec!["--registry".to_string(), url.to_string()],
803
+ }
804
+ }
805
+
806
+ fn prepare_project(
807
+ ctx: &mut TestContext<'_>,
808
+ package_name: &str,
809
+ version: &str,
810
+ dependencies: BTreeMap<String, String>,
811
+ dev_dependencies: BTreeMap<String, String>,
812
+ ) -> Result<PathBuf> {
813
+ let dir = create_temp_dir(ctx.state, package_name)?;
814
+ let path = dir.path().to_path_buf();
815
+ fs::write(
816
+ path.join("package.json"),
817
+ package_json(package_name, version, dependencies, dev_dependencies)?,
818
+ )?;
819
+ fs::write(path.join("README.md"), readme(package_name))?;
820
+
821
+ if ctx.adapter.kind == AdapterType::YarnModern {
822
+ let yaml = yarn_config(&ctx.registry_url, Some(&ctx.token), ctx.adapter.yarn_major)?;
823
+ fs::write(path.join(".yarnrc.yml"), yaml)?;
824
+ } else {
825
+ let npmrc = format!(
826
+ "{}:_authToken={}\nregistry={}\nmin-release-age=0\nminimum-release-age=0",
827
+ npm_auth_scope(&ctx.registry_url)?,
828
+ ctx.token,
829
+ ctx.registry_url
830
+ );
831
+ fs::write(path.join(".npmrc"), npmrc)?;
832
+ if ctx.adapter.kind == AdapterType::Pnpm {
833
+ fs::write(
834
+ path.join("pnpm-workspace.yaml"),
835
+ "packages:\n - .\nminimumReleaseAge: 0\n",
836
+ )?;
837
+ }
838
+ }
839
+ Ok(path)
840
+ }
841
+
842
+ fn package_json(
843
+ package_name: &str,
844
+ version: &str,
845
+ dependencies: BTreeMap<String, String>,
846
+ dev_dependencies: BTreeMap<String, String>,
847
+ ) -> Result<String> {
848
+ Ok(serde_json::to_string(&json!({
849
+ "name": package_name,
850
+ "version": version,
851
+ "description": "some cool project",
852
+ "main": "index.js",
853
+ "scripts": { "test": "echo exit 1" },
854
+ "dependencies": dependencies,
855
+ "devDependencies": dev_dependencies,
856
+ "keywords": ["foo", "bar"],
857
+ "author": "Verdaccio E2E <verdaccio@example.org>",
858
+ "license": "MIT"
859
+ }))?)
860
+ }
861
+
862
+ fn readme(package_name: &str) -> String {
863
+ format!("\n # My README {package_name}\n\n some text\n\n ## subtitle\n\n more text\n ")
864
+ }
865
+
866
+ fn yarn_config(registry: &str, token: Option<&str>, major: u64) -> Result<String> {
867
+ let mut value = serde_yaml::Mapping::new();
868
+ value.insert("npmRegistryServer".into(), registry.into());
869
+ value.insert("enableImmutableInstalls".into(), false.into());
870
+ value.insert("unsafeHttpWhitelist".into(), vec!["localhost"].into());
871
+ if major >= 4 {
872
+ value.insert("enableHardenedMode".into(), false.into());
873
+ value.insert("npmMinimalAgeGate".into(), 0.into());
874
+ }
875
+ if let Some(token) = token {
876
+ let mut registry_config = serde_yaml::Mapping::new();
877
+ registry_config.insert("npmAlwaysAuth".into(), true.into());
878
+ registry_config.insert("npmAuthToken".into(), token.into());
879
+ let mut registries = serde_yaml::Mapping::new();
880
+ registries.insert(registry.into(), registry_config.into());
881
+ value.insert("npmRegistries".into(), registries.into());
882
+ }
883
+ Ok(serde_yaml::to_string(&value)?)
884
+ }
885
+
886
+ fn create_temp_dir<'a>(state: &'a mut RunState, prefix: &str) -> Result<&'a TempDir> {
887
+ let sanitized = prefix
888
+ .chars()
889
+ .map(|c| {
890
+ if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
891
+ c
892
+ } else {
893
+ '-'
894
+ }
895
+ })
896
+ .collect::<String>();
897
+ let dir = Builder::new()
898
+ .prefix(&format!("verdaccio-e2e-{sanitized}-"))
899
+ .tempdir()
900
+ .context("failed to create temp dir")?;
901
+ state.temp_dirs.push(dir);
902
+ Ok(state.temp_dirs.last().expect("temp dir was just pushed"))
903
+ }
904
+
905
+ fn ping_registry(registry_url: &str) -> bool {
906
+ Client::new()
907
+ .get(format!("{}/-/ping", registry_url.trim_end_matches('/')))
908
+ .timeout(Duration::from_secs(10))
909
+ .send()
910
+ .map(|resp| resp.status().is_success())
911
+ .unwrap_or(false)
912
+ }
913
+
914
+ fn create_user(registry_url: &str, user: &str, password: &str) -> Result<String> {
915
+ let url = format!(
916
+ "{}/-/user/org.couchdb.user:{}",
917
+ registry_url.trim_end_matches('/'),
918
+ user
919
+ );
920
+ let body = json!({
921
+ "name": user,
922
+ "password": password,
923
+ "_id": format!("org.couchdb.user:{user}"),
924
+ "type": "user",
925
+ "roles": [],
926
+ "date": unix_millis_string(),
927
+ });
928
+ let response = Client::new()
929
+ .put(url)
930
+ .header("Content-Type", "application/json")
931
+ .json(&body)
932
+ .send()
933
+ .context("failed to create registry user")?;
934
+ let status = response.status();
935
+ let body: Value = response.json().unwrap_or(Value::Null);
936
+ if matches!(status.as_u16(), 200 | 201 | 409) {
937
+ if let Some(token) = body.get("token").and_then(Value::as_str) {
938
+ return Ok(token.to_string());
939
+ }
940
+ }
941
+ bail!("Failed to create/login user \"{user}\" on {registry_url}: {status} {body}")
942
+ }
943
+
944
+ fn unix_millis_string() -> String {
945
+ format!("{}", now_millis())
946
+ }
947
+
948
+ fn now_millis() -> u128 {
949
+ SystemTime::now()
950
+ .duration_since(UNIX_EPOCH)
951
+ .unwrap_or_default()
952
+ .as_millis()
953
+ }
954
+
955
+ fn run_id() -> String {
956
+ let counter = RUN_COUNTER.fetch_add(1, Ordering::Relaxed);
957
+ format!("{:x}-{counter:x}", now_millis())
958
+ }
959
+
960
+ fn npm_auth_scope(registry_url: &str) -> Result<String> {
961
+ let url = Url::parse(registry_url)
962
+ .with_context(|| format!("failed to parse registry URL: {registry_url}"))?;
963
+ let host = url
964
+ .host_str()
965
+ .ok_or_else(|| anyhow!("registry URL has no host: {registry_url}"))?;
966
+ let port = url.port().map_or(String::new(), |port| format!(":{port}"));
967
+ let path = url.path().trim_end_matches('/');
968
+ Ok(format!("//{host}{port}{path}/"))
969
+ }
970
+
971
+ fn sub_test(
972
+ ctx: &mut TestContext<'_>,
973
+ label: &'static str,
974
+ f: impl FnOnce(&mut TestContext<'_>) -> Result<()>,
975
+ ) -> Result<()> {
976
+ let started = Instant::now();
977
+ match f(ctx) {
978
+ Ok(()) => {
979
+ let duration = started.elapsed();
980
+ ctx.sub_results.push(SubTestResult {
981
+ label,
982
+ passed: true,
983
+ duration,
984
+ error: None,
985
+ });
986
+ report_sub_test(label, true, duration);
987
+ Ok(())
988
+ }
989
+ Err(err) => {
990
+ let duration = started.elapsed();
991
+ let error = format!("{err:#}");
992
+ ctx.sub_results.push(SubTestResult {
993
+ label,
994
+ passed: false,
995
+ duration,
996
+ error: Some(error.clone()),
997
+ });
998
+ report_sub_test(label, false, duration);
999
+ Err(anyhow!(error))
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ fn prepare_deprecate_project(
1005
+ ctx: &mut TestContext<'_>,
1006
+ pkg: &str,
1007
+ version: &str,
1008
+ ) -> Result<PathBuf> {
1009
+ let temp = prepare_project(ctx, pkg, version, BTreeMap::new(), BTreeMap::new())?;
1010
+ if ctx.adapter.kind == AdapterType::YarnModern {
1011
+ import_plugin(ctx, &temp, "npm-deprecate")?;
1012
+ }
1013
+ Ok(temp)
1014
+ }
1015
+
1016
+ fn publish_pkg(ctx: &TestContext<'_>, temp: &Path) -> Result<()> {
1017
+ publish_pkg_extra(ctx, temp, Vec::new())
1018
+ }
1019
+
1020
+ fn publish_pkg_extra(ctx: &TestContext<'_>, temp: &Path, extra: Vec<String>) -> Result<()> {
1021
+ if ctx.adapter.kind == AdapterType::YarnModern {
1022
+ exec_adapter(ctx, Some(temp), vec!["install".into()])?;
1023
+ }
1024
+ let mut args = vec!["publish".into()];
1025
+ args.extend(extra);
1026
+ args.extend(registry_arg(&ctx.adapter, &ctx.registry_url));
1027
+ exec_adapter(ctx, Some(temp), args)?;
1028
+ Ok(())
1029
+ }
1030
+
1031
+ fn publish_seed(
1032
+ ctx: &mut TestContext<'_>,
1033
+ pkg: &str,
1034
+ version: &str,
1035
+ deps: BTreeMap<String, String>,
1036
+ ) -> Result<()> {
1037
+ let temp = prepare_project(ctx, pkg, version, deps, BTreeMap::new())?;
1038
+ publish_pkg(ctx, &temp)
1039
+ }
1040
+
1041
+ fn deprecate_pkg(
1042
+ ctx: &TestContext<'_>,
1043
+ temp: &Path,
1044
+ package_version: &str,
1045
+ message: &str,
1046
+ ) -> Result<()> {
1047
+ let mut args = vec!["deprecate".into(), package_version.into(), message.into()];
1048
+ if ctx.adapter.kind != AdapterType::YarnModern {
1049
+ args.push("--json".into());
1050
+ }
1051
+ args.extend(registry_arg(&ctx.adapter, &ctx.registry_url));
1052
+ exec_adapter(ctx, Some(temp), args)?;
1053
+ Ok(())
1054
+ }
1055
+
1056
+ fn info_pkg(ctx: &TestContext<'_>, temp: &Path, pkg: &str) -> Result<Value> {
1057
+ let mut args = vec!["info".into(), pkg.into(), "--json".into()];
1058
+ args.extend(registry_arg(&ctx.adapter, &ctx.registry_url));
1059
+ let resp = exec_adapter(ctx, Some(temp), args)?;
1060
+ if ctx.adapter.kind == AdapterType::YarnClassic {
1061
+ for line in resp.stdout.lines() {
1062
+ if let Ok(value) = serde_json::from_str::<Value>(line) {
1063
+ if value.get("type").and_then(Value::as_str) == Some("inspect") {
1064
+ return Ok(normalize_info(
1065
+ value.get("data").cloned().unwrap_or(Value::Null),
1066
+ ));
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+ parse_info_json(&resp.stdout)
1072
+ }
1073
+
1074
+ fn parse_info_json(stdout: &str) -> Result<Value> {
1075
+ Ok(normalize_info(serde_json::from_str(stdout)?))
1076
+ }
1077
+
1078
+ fn normalize_info(value: Value) -> Value {
1079
+ if let Some(array) = value.as_array() {
1080
+ array.last().cloned().unwrap_or(Value::Null)
1081
+ } else {
1082
+ value
1083
+ }
1084
+ }
1085
+
1086
+ fn bump_version(ctx: &TestContext<'_>, temp: &Path, bump: &str) -> Result<()> {
1087
+ if ctx.adapter.kind == AdapterType::YarnModern {
1088
+ let path = temp.join("package.json");
1089
+ let mut pkg: Value = serde_json::from_str(&fs::read_to_string(&path)?)?;
1090
+ let current = pkg
1091
+ .get("version")
1092
+ .and_then(Value::as_str)
1093
+ .unwrap_or("1.0.0");
1094
+ let mut parts = current
1095
+ .split('.')
1096
+ .map(|p| p.parse::<u64>().unwrap_or(0))
1097
+ .collect::<Vec<_>>();
1098
+ while parts.len() < 3 {
1099
+ parts.push(0);
1100
+ }
1101
+ match bump {
1102
+ "major" => {
1103
+ parts[0] += 1;
1104
+ parts[1] = 0;
1105
+ parts[2] = 0;
1106
+ }
1107
+ _ => {
1108
+ parts[1] += 1;
1109
+ parts[2] = 0;
1110
+ }
1111
+ }
1112
+ pkg["version"] = format!("{}.{}.{}", parts[0], parts[1], parts[2]).into();
1113
+ fs::write(path, serde_json::to_string_pretty(&pkg)?)?;
1114
+ } else {
1115
+ let mut args = vec![
1116
+ "version".into(),
1117
+ bump.into(),
1118
+ "--no-git-tag-version".into(),
1119
+ "--loglevel=info".into(),
1120
+ ];
1121
+ args.extend(registry_arg(&ctx.adapter, &ctx.registry_url));
1122
+ exec_adapter(ctx, Some(temp), args)?;
1123
+ }
1124
+ Ok(())
1125
+ }
1126
+
1127
+ fn prepare_login_project(ctx: &mut TestContext<'_>, name: &str) -> Result<PathBuf> {
1128
+ let temp = create_temp_dir(ctx.state, name)?.path().to_path_buf();
1129
+ fs::write(
1130
+ temp.join(".yarnrc.yml"),
1131
+ yarn_config(&ctx.registry_url, None, ctx.adapter.yarn_major)?,
1132
+ )?;
1133
+ fs::write(
1134
+ temp.join("package.json"),
1135
+ serde_json::to_string(&json!({ "name": name, "version": "1.0.0" }))?,
1136
+ )?;
1137
+ import_plugin(ctx, &temp, "npm-login")?;
1138
+ Ok(temp)
1139
+ }
1140
+
1141
+ fn yarn_login(
1142
+ ctx: &TestContext<'_>,
1143
+ cwd: &Path,
1144
+ user: &str,
1145
+ password: &str,
1146
+ email: &str,
1147
+ ) -> Result<ExecOutput> {
1148
+ exec_adapter(
1149
+ ctx,
1150
+ Some(cwd),
1151
+ vec![
1152
+ "login".into(),
1153
+ "--auth-type=legacy".into(),
1154
+ format!("--user={user}"),
1155
+ format!("--password={password}"),
1156
+ format!("--email={email}"),
1157
+ ],
1158
+ )
1159
+ }
1160
+
1161
+ fn yarn_whoami(ctx: &TestContext<'_>, cwd: &Path) -> Result<String> {
1162
+ Ok(exec_adapter(ctx, Some(cwd), vec!["whoami".into()])?.stdout)
1163
+ }
1164
+
1165
+ fn import_plugin(ctx: &TestContext<'_>, cwd: &Path, plugin_name: &str) -> Result<()> {
1166
+ if ctx.adapter.kind != AdapterType::YarnModern {
1167
+ return Ok(());
1168
+ }
1169
+ let tmp = tempfile::tempdir()?;
1170
+ let tmp_path = tmp.path().to_string_lossy().to_string();
1171
+ let pkg = format!("@verdaccio/yarn-plugin-{plugin_name}");
1172
+ command_output(
1173
+ "npm",
1174
+ &["pack", &pkg, "--pack-destination", &tmp_path],
1175
+ None,
1176
+ &[],
1177
+ false,
1178
+ None,
1179
+ )?;
1180
+ let tgz = fs::read_dir(tmp.path())?
1181
+ .filter_map(Result::ok)
1182
+ .map(|entry| entry.path())
1183
+ .find(|path| path.extension().and_then(|ext| ext.to_str()) == Some("tgz"))
1184
+ .ok_or_else(|| anyhow!("failed to find packed yarn plugin"))?;
1185
+ let tgz_str = tgz.to_string_lossy().to_string();
1186
+ command_output(
1187
+ "tar",
1188
+ &["xzf", &tgz_str, "-C", &tmp_path],
1189
+ None,
1190
+ &[],
1191
+ false,
1192
+ None,
1193
+ )?;
1194
+ let bundle = tmp
1195
+ .path()
1196
+ .join(format!("package/bundles/@yarnpkg/plugin-{plugin_name}.js"));
1197
+ let bundle = bundle.to_string_lossy().to_string();
1198
+ exec_adapter(
1199
+ ctx,
1200
+ Some(cwd),
1201
+ vec!["plugin".into(), "import".into(), bundle],
1202
+ )?;
1203
+ Ok(())
1204
+ }
1205
+
1206
+ fn prepare_cooldown_consumer(
1207
+ ctx: &mut TestContext<'_>,
1208
+ name: &str,
1209
+ deps: BTreeMap<String, String>,
1210
+ ) -> Result<PathBuf> {
1211
+ let temp = prepare_project(ctx, name, "1.0.0", deps, BTreeMap::new())?;
1212
+ fs::write(
1213
+ temp.join(".npmrc"),
1214
+ format!(
1215
+ "{}:_authToken={}\nregistry={}",
1216
+ npm_auth_scope(&ctx.registry_url)?,
1217
+ ctx.token,
1218
+ ctx.registry_url
1219
+ ),
1220
+ )?;
1221
+ fs::write(
1222
+ temp.join("pnpm-workspace.yaml"),
1223
+ "packages:\n - .\nminimumReleaseAge: 10080\nminimumReleaseAgeExclude:\n - '@verdaccio/*'\n - 'verdaccio-*'\n",
1224
+ )?;
1225
+ Ok(temp)
1226
+ }
1227
+
1228
+ fn is_pnpm_minimum_release_age_capable(adapter: &Adapter) -> bool {
1229
+ if adapter.kind != AdapterType::Pnpm {
1230
+ return false;
1231
+ }
1232
+ let mut parts = adapter
1233
+ .version
1234
+ .split('.')
1235
+ .filter_map(|part| part.parse::<u64>().ok());
1236
+ let major = parts.next().unwrap_or(0);
1237
+ let minor = parts.next().unwrap_or(0);
1238
+ major == 11 && minor >= 1
1239
+ }
1240
+
1241
+ fn assert_true(condition: bool, message: &str) -> Result<()> {
1242
+ if condition {
1243
+ Ok(())
1244
+ } else {
1245
+ bail!("{message}")
1246
+ }
1247
+ }
1248
+
1249
+ fn assert_contains(haystack: &str, needle: &str) -> Result<()> {
1250
+ assert_true(
1251
+ haystack.contains(needle),
1252
+ &format!("Expected output to contain \"{needle}\" but got \"{haystack}\""),
1253
+ )
1254
+ }
1255
+
1256
+ fn assert_eq_str(actual: &str, expected: &str) -> Result<()> {
1257
+ if actual == expected {
1258
+ Ok(())
1259
+ } else {
1260
+ bail!("Expected \"{expected}\" but got \"{actual}\"")
1261
+ }
1262
+ }
1263
+
1264
+ fn assert_eq_json<T: Into<Value>>(
1265
+ actual: Option<&Value>,
1266
+ expected: T,
1267
+ message: &str,
1268
+ ) -> Result<()> {
1269
+ let expected = expected.into();
1270
+ if actual == Some(&expected) {
1271
+ Ok(())
1272
+ } else {
1273
+ bail!(
1274
+ "{message}: expected {expected}, got {}",
1275
+ actual.cloned().unwrap_or(Value::Null)
1276
+ )
1277
+ }
1278
+ }
1279
+
1280
+ fn report_test_result(result: &TestResult) {
1281
+ if result.passed {
1282
+ if result.sub_tests.is_empty() {
1283
+ println!(
1284
+ " {GREEN}✓{RESET} {} {DIM}{}{RESET}",
1285
+ result.name,
1286
+ format_duration(result.duration)
1287
+ );
1288
+ } else {
1289
+ println!(
1290
+ " {GREEN}PASS{RESET} {} {DIM}{}{RESET}",
1291
+ result.name,
1292
+ format_duration(result.duration)
1293
+ );
1294
+ }
1295
+ } else {
1296
+ println!(
1297
+ " {RED}FAIL{RESET} {} {DIM}{}{RESET}",
1298
+ result.name,
1299
+ format_duration(result.duration)
1300
+ );
1301
+ if let Some(error) = &result.error {
1302
+ for line in error.lines() {
1303
+ println!(" {RED}{line}{RESET}");
1304
+ }
1305
+ if env::var_os("GITHUB_ACTIONS").is_some() {
1306
+ let first = error.lines().next().unwrap_or(error);
1307
+ println!("::error title=E2E {}::{first}", result.name);
1308
+ }
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ fn report_sub_test(label: &str, passed: bool, duration: Duration) {
1314
+ let icon = if passed {
1315
+ format!("{BOLD}{GREEN}✓{RESET}")
1316
+ } else {
1317
+ format!("{BOLD}{RED}✗{RESET}")
1318
+ };
1319
+ println!(
1320
+ "\n {icon} {BOLD}{label}{RESET} {DIM}{}{RESET}\n",
1321
+ format_duration(duration)
1322
+ );
1323
+ }
1324
+
1325
+ fn report_summary(results: &[SuiteResult]) -> Result<()> {
1326
+ println!("\n{BOLD}{}{RESET}", "=".repeat(50));
1327
+ println!("{BOLD}Summary{RESET}\n");
1328
+ let mut total_passed = 0usize;
1329
+ let mut total_failed = 0usize;
1330
+ let mut total_skipped = 0usize;
1331
+ let mut total_duration = Duration::ZERO;
1332
+ for suite in results {
1333
+ let color = if suite.failed > 0 { RED } else { GREEN };
1334
+ let icon = if suite.failed > 0 { "x" } else { "v" };
1335
+ println!(
1336
+ " {color}{icon}{RESET} {}: {GREEN}{} passed{RESET}, {RED}{} failed{RESET}, {YELLOW}{} skipped{RESET} {DIM}({}){RESET}",
1337
+ suite.adapter,
1338
+ suite.passed,
1339
+ suite.failed,
1340
+ suite.skipped,
1341
+ format_duration(suite.duration)
1342
+ );
1343
+ total_passed += suite.passed;
1344
+ total_failed += suite.failed;
1345
+ total_skipped += suite.skipped;
1346
+ total_duration += suite.duration;
1347
+ }
1348
+ println!(
1349
+ "\n Total: {GREEN}{total_passed} passed{RESET}, {RED}{total_failed} failed{RESET}, {YELLOW}{total_skipped} skipped{RESET} {DIM}({}){RESET}",
1350
+ format_duration(total_duration)
1351
+ );
1352
+ println!("{BOLD}{}{RESET}\n", "=".repeat(50));
1353
+ if let Some(summary) = env::var_os("GITHUB_STEP_SUMMARY") {
1354
+ write_github_summary(Path::new(&summary), results)?;
1355
+ }
1356
+ Ok(())
1357
+ }
1358
+
1359
+ fn write_github_summary(path: &Path, results: &[SuiteResult]) -> Result<()> {
1360
+ let mut file = fs::OpenOptions::new()
1361
+ .create(true)
1362
+ .append(true)
1363
+ .open(path)?;
1364
+ writeln!(file, "## E2E Test Results\n")?;
1365
+ for suite in results {
1366
+ let status = if suite.failed > 0 { "❌" } else { "✅" };
1367
+ writeln!(file, "### {status} {}\n", suite.adapter)?;
1368
+ writeln!(file, "| Test | Status | Duration |")?;
1369
+ writeln!(file, "|------|--------|----------|")?;
1370
+ for test in &suite.tests {
1371
+ let icon = if test.passed { "✅" } else { "❌" };
1372
+ writeln!(
1373
+ file,
1374
+ "| {} | {} | {} |",
1375
+ test.name,
1376
+ icon,
1377
+ format_duration(test.duration)
1378
+ )?;
1379
+ for sub in &test.sub_tests {
1380
+ let icon = if sub.passed { "✅" } else { "❌" };
1381
+ let label = if sub.passed {
1382
+ sub.label.to_string()
1383
+ } else {
1384
+ format!("{}: {}", sub.label, sub.error.as_deref().unwrap_or(""))
1385
+ };
1386
+ writeln!(
1387
+ file,
1388
+ "| &nbsp;&nbsp;&nbsp;&nbsp;{} | {} | {} |",
1389
+ label,
1390
+ icon,
1391
+ format_duration(sub.duration)
1392
+ )?;
1393
+ }
1394
+ }
1395
+ writeln!(file)?;
1396
+ }
1397
+ let total_passed: usize = results.iter().map(|suite| suite.passed).sum();
1398
+ let total_failed: usize = results.iter().map(|suite| suite.failed).sum();
1399
+ let total_skipped: usize = results.iter().map(|suite| suite.skipped).sum();
1400
+ let emoji = if total_failed > 0 { "❌" } else { "✅" };
1401
+ writeln!(file, "{emoji} **{total_passed} passed**, **{total_failed} failed**, **{total_skipped} skipped**\n")?;
1402
+ Ok(())
1403
+ }
1404
+
1405
+ fn format_duration(duration: Duration) -> String {
1406
+ if duration < Duration::from_secs(1) {
1407
+ format!("{}ms", duration.as_millis())
1408
+ } else {
1409
+ format!("{:.1}s", duration.as_secs_f64())
1410
+ }
1411
+ }