1use std::ffi::OsString;
27use std::path::PathBuf;
28
29use serde::Serialize;
30
31use crate::actions::doctor::{CheckResult, CheckStatus};
32use crate::actions::lint::find_on_path_in;
33use crate::client::ReqwestGatewayApi;
34use crate::client::webdev::{TestingProbe, testing_probe};
35use crate::error::CoreError;
36
37pub const MIN_NODE_MAJOR: u32 = 20;
44
45pub const NODE_INSTALL_HINT: &str = "install Node 20 or newer — https://nodejs.org (LTS installer), \
52 `brew install node` (macOS/Homebrew), or `nvm install --lts` (nvm)";
53
54const BROWSERS_PATH_VAR: &str = "PLAYWRIGHT_BROWSERS_PATH";
57
58const CHROMIUM_PREFIXES: &[&str] = &["chromium-", "chromium_headless_shell-"];
60
61#[derive(Debug, Clone, Default)]
64pub struct E2eEnv {
65 pub path: Option<OsString>,
67 pub browsers_cache: Option<PathBuf>,
69}
70
71impl E2eEnv {
72 pub fn from_process() -> Self {
78 Self {
79 path: std::env::var_os("PATH"),
80 browsers_cache: default_browsers_cache(),
81 }
82 }
83}
84
85fn default_browsers_cache() -> Option<PathBuf> {
92 if let Some(explicit) = std::env::var_os(BROWSERS_PATH_VAR) {
93 return Some(PathBuf::from(explicit));
94 }
95 let base = directories::BaseDirs::new()?;
96 let home = base.home_dir();
97 #[cfg(target_os = "macos")]
98 {
99 Some(home.join("Library/Caches/ms-playwright"))
100 }
101 #[cfg(target_os = "windows")]
102 {
103 let _ = home;
104 std::env::var_os("LOCALAPPDATA").map(|local| PathBuf::from(local).join("ms-playwright"))
105 }
106 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
107 {
108 Some(home.join(".cache/ms-playwright"))
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct E2eDoctorOptions {
115 pub dir: PathBuf,
117 pub project: Option<String>,
120}
121
122#[derive(Debug, Serialize)]
124pub struct E2eDoctorResult {
125 pub dir: String,
127 pub checks: Vec<CheckResult>,
132}
133
134fn parse_node_major(stdout: &str) -> Option<u32> {
137 let trimmed = stdout.trim();
138 let version = trimmed.strip_prefix('v').unwrap_or(trimmed);
139 version.split('.').next()?.parse().ok()
140}
141
142fn chromium_present(cache: &std::path::Path) -> bool {
150 let Ok(entries) = std::fs::read_dir(cache) else {
151 return false;
152 };
153 entries.flatten().any(|entry| {
154 let name = entry.file_name();
155 let name = name.to_string_lossy();
156 CHROMIUM_PREFIXES
157 .iter()
158 .any(|prefix| name.starts_with(prefix))
159 })
160}
161
162fn row(name: &str, status: CheckStatus, detail: String, hint: Option<&str>) -> CheckResult {
163 CheckResult {
164 name: name.into(),
165 status,
166 detail,
167 hint: hint.map(str::to_string),
168 }
169}
170
171async fn check_node(env: &E2eEnv) -> CheckResult {
174 let Some(path_var) = env.path.as_ref() else {
175 return row(
176 "node",
177 CheckStatus::Fail,
178 "no PATH is set — node cannot be discovered".into(),
179 Some(NODE_INSTALL_HINT),
180 );
181 };
182 let Some(node) = find_on_path_in(path_var, "node") else {
183 return row(
184 "node",
185 CheckStatus::Fail,
186 "node is not on PATH".into(),
187 Some(NODE_INSTALL_HINT),
188 );
189 };
190 let output = tokio::process::Command::new(&node)
191 .args(["--version"])
192 .output()
193 .await;
194 let stdout = match output {
195 Ok(output) => String::from_utf8_lossy(&output.stdout).into_owned(),
196 Err(err) => {
197 return row(
198 "node",
199 CheckStatus::Fail,
200 format!(
201 "node was found at {} but could not run: {err}",
202 node.display()
203 ),
204 Some(NODE_INSTALL_HINT),
205 );
206 }
207 };
208 match parse_node_major(&stdout) {
209 Some(major) if major >= MIN_NODE_MAJOR => row(
210 "node",
211 CheckStatus::Ok,
212 format!("{} ({})", stdout.trim(), node.display()),
213 None,
214 ),
215 Some(major) => row(
216 "node",
217 CheckStatus::Fail,
218 format!(
219 "node {} is too old — major {major} < {MIN_NODE_MAJOR} ({})",
220 stdout.trim(),
221 node.display()
222 ),
223 Some(NODE_INSTALL_HINT),
224 ),
225 None => row(
226 "node",
227 CheckStatus::Warn,
228 format!(
229 "node at {} answered {:?} — the version could not be parsed",
230 node.display(),
231 stdout.trim()
232 ),
233 Some(NODE_INSTALL_HINT),
234 ),
235 }
236}
237
238fn check_npm(env: &E2eEnv) -> CheckResult {
242 match env
243 .path
244 .as_ref()
245 .and_then(|path| find_on_path_in(path, "npm"))
246 {
247 Some(npm) => row("npm", CheckStatus::Ok, npm.display().to_string(), None),
248 None => row(
249 "npm",
250 CheckStatus::Fail,
251 "npm is not on PATH".into(),
252 Some(NODE_INSTALL_HINT),
253 ),
254 }
255}
256
257fn check_playwright(opts: &E2eDoctorOptions) -> CheckResult {
259 let marker = opts
260 .dir
261 .join("node_modules")
262 .join("@playwright")
263 .join("test")
264 .join("package.json");
265 if marker.is_file() {
266 row(
267 "playwright",
268 CheckStatus::Ok,
269 format!("@playwright/test is installed in {}", opts.dir.display()),
270 None,
271 )
272 } else {
273 row(
274 "playwright",
275 CheckStatus::Warn,
276 format!(
277 "@playwright/test is not installed in {} (no node_modules/@playwright/test)",
278 opts.dir.display()
279 ),
280 Some("run `ign e2e init <DIR> --yes` to scaffold and install"),
281 )
282 }
283}
284
285fn check_chromium(env: &E2eEnv) -> CheckResult {
291 let Some(cache) = env.browsers_cache.as_ref() else {
292 return row(
293 "chromium",
294 CheckStatus::Warn,
295 "the Playwright browsers cache could not be located".into(),
296 Some("run `ign e2e init <DIR> --browsers --yes`, or set PLAYWRIGHT_BROWSERS_PATH"),
297 );
298 };
299 if chromium_present(cache) {
300 row(
301 "chromium",
302 CheckStatus::Ok,
303 format!("a chromium build is present in {}", cache.display()),
304 None,
305 )
306 } else {
307 row(
308 "chromium",
309 CheckStatus::Warn,
310 format!("no chromium build found in {}", cache.display()),
311 Some("run `ign e2e init <DIR> --browsers --yes` to download it"),
312 )
313 }
314}
315
316async fn check_bundle(api: Option<&ReqwestGatewayApi>, opts: &E2eDoctorOptions) -> CheckResult {
320 let Some(project) = opts.project.as_deref() else {
321 return row(
322 "testing-bundle",
323 CheckStatus::Skip,
324 "no --project given — the gateway's testing bundle was not probed".into(),
325 None,
326 );
327 };
328 let Some(api) = api else {
329 return row(
330 "testing-bundle",
331 CheckStatus::Skip,
332 "no gateway profile resolved — the testing bundle was not probed".into(),
333 None,
334 );
335 };
336 let adopt_hint = "deploy the bundle: `ign webdev deploy --project NAME --with-testing` \
337 or `ign adopt --project NAME --testing`";
338 match testing_probe(api, project).await {
339 Ok(TestingProbe::Present { discovered }) => {
340 let count = discovered
341 .get("count")
342 .and_then(serde_json::Value::as_u64)
343 .or_else(|| {
344 discovered
345 .get("discovered_modules")
346 .and_then(serde_json::Value::as_array)
347 .map(|modules| modules.len() as u64)
348 })
349 .unwrap_or(0);
350 row(
351 "testing-bundle",
352 CheckStatus::Ok,
353 format!("deployed in {project} — {count} discovered module(s)"),
354 None,
355 )
356 }
357 Ok(TestingProbe::Absent) => row(
358 "testing-bundle",
359 CheckStatus::Fail,
360 format!("the testing bundle is not deployed in {project}"),
361 Some(adopt_hint),
362 ),
363 Ok(TestingProbe::Unlicensed) => row(
364 "testing-bundle",
365 CheckStatus::Fail,
366 "the WebDev module is installed but unlicensed (HTTP 402)".into(),
367 Some("license the WebDev module on the gateway"),
368 ),
369 Ok(TestingProbe::AuthGated { status }) => row(
370 "testing-bundle",
371 CheckStatus::Fail,
372 format!("the testing route rejected this credential (HTTP {status})"),
373 Some("check the profile's API token — `ign doctor` diagnoses auth"),
374 ),
375 Ok(TestingProbe::LazyCompile { .. }) => row(
376 "testing-bundle",
377 CheckStatus::Warn,
378 format!(
379 "the testing route in {project} answered 500 on first touch \
380 (WebDev lazy-compile) — re-run the doctor"
381 ),
382 Some("a freshly deployed route compiles on its first request; try again"),
383 ),
384 Err(err) => row(
385 "testing-bundle",
386 CheckStatus::Fail,
387 format!("the testing bundle could not be probed: {err}"),
388 Some(adopt_hint),
389 ),
390 }
391}
392
393async fn check_trial(api: Option<&ReqwestGatewayApi>) -> CheckResult {
395 let Some(api) = api else {
396 return row(
397 "trial",
398 CheckStatus::Skip,
399 "no gateway profile resolved — the trial state was not read".into(),
400 None,
401 );
402 };
403 match crate::actions::rig::trial_status(api).await {
404 Ok(status) if status.expired => row(
405 "trial",
406 CheckStatus::Warn,
407 format!(
408 "the gateway's trial has EXPIRED ({}) — a browser run against it \
409 will hit the demo timeout",
410 status.trial_state
411 ),
412 Some("reset it: `ign rig trial reset --yes`"),
413 ),
414 Ok(status) => row(
415 "trial",
416 CheckStatus::Ok,
417 format!(
418 "{} — {}s remaining",
419 status.license_mode, status.trial_remaining_s
420 ),
421 None,
422 ),
423 Err(err) => row(
424 "trial",
425 CheckStatus::Fail,
426 format!("the trial state could not be read: {err}"),
427 Some("`ign doctor` diagnoses gateway reachability and auth"),
428 ),
429 }
430}
431
432pub async fn e2e_doctor(
438 api: Option<&ReqwestGatewayApi>,
439 env: &E2eEnv,
440 opts: &E2eDoctorOptions,
441) -> Result<E2eDoctorResult, CoreError> {
442 let checks = vec![
443 check_node(env).await,
444 check_npm(env),
445 check_playwright(opts),
446 check_chromium(env),
447 check_bundle(api, opts).await,
448 check_trial(api).await,
449 ];
450 Ok(E2eDoctorResult {
451 dir: opts.dir.display().to_string(),
452 checks,
453 })
454}
455
456const SCAFFOLD_MARKER: &str = "playwright.config.mjs";
463
464pub const DEFAULT_GATEWAY_URL: &str = "http://localhost:8088";
472
473#[derive(Debug, Clone)]
480pub struct E2eInitOptions {
481 pub dir: PathBuf,
483 pub project: String,
485 pub run_project: String,
487 pub gateway_url: String,
489 pub browsers: bool,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
495#[serde(rename_all = "lowercase")]
496pub enum E2eFileStatus {
497 Written,
499 Skipped,
501}
502
503#[derive(Debug, Serialize)]
505pub struct E2eFile {
506 pub path: String,
508 pub status: E2eFileStatus,
510}
511
512#[derive(Debug, Serialize)]
516pub struct E2eCommandRun {
517 pub ran: bool,
519 pub exit_code: Option<i32>,
521}
522
523#[derive(Debug, Serialize)]
525pub struct E2eInitResult {
526 pub dir: String,
528 pub files: Vec<E2eFile>,
530 pub npm_install: Option<E2eCommandRun>,
532 pub browsers: Option<E2eCommandRun>,
534}
535
536fn npm_install_argv() -> Vec<&'static str> {
538 vec!["install"]
539}
540
541fn browsers_argv() -> Vec<&'static str> {
543 vec!["playwright", "install", "--with-deps", "chromium"]
544}
545
546fn command_lines(dir: &std::path::Path, browsers: bool) -> Vec<String> {
548 let mut lines = vec![format!(
549 "(in {}) npm {}",
550 dir.display(),
551 npm_install_argv().join(" ")
552 )];
553 if browsers {
554 lines.push(format!(
555 "(in {}) npx {}",
556 dir.display(),
557 browsers_argv().join(" ")
558 ));
559 }
560 lines
561}
562
563fn refuse_foreign_directory(dir: &std::path::Path) -> Result<(), CoreError> {
570 let Ok(entries) = std::fs::read_dir(dir) else {
571 return Ok(()); };
573 let names: Vec<String> = entries
574 .flatten()
575 .map(|entry| entry.file_name().to_string_lossy().into_owned())
576 .collect();
577 if names.is_empty() {
578 return Ok(()); }
580 if names.iter().any(|name| name == SCAFFOLD_MARKER) {
581 return Ok(()); }
583 let mut found = names.clone();
584 found.sort();
585 Err(CoreError::InvalidInput {
586 reason: format!(
587 "target directory {} is not empty and is not an e2e scaffold (no {}) — \
588 refusing to clobber it; it contains {}",
589 dir.display(),
590 SCAFFOLD_MARKER,
591 found
592 .iter()
593 .take(5)
594 .map(|name| format!("{name:?}"))
595 .collect::<Vec<_>>()
596 .join(", ")
597 ),
598 })
599}
600
601fn refuse_without_node(env: &E2eEnv) -> Result<(), CoreError> {
603 let found = env
604 .path
605 .as_ref()
606 .and_then(|path| find_on_path_in(path, "node"));
607 if found.is_none() {
608 return Err(CoreError::NodeToolAbsent {
609 tool: "node".into(),
610 });
611 }
612 Ok(())
613}
614
615pub fn e2e_init_preview(env: &E2eEnv, opts: &E2eInitOptions) -> Result<String, CoreError> {
625 refuse_foreign_directory(&opts.dir)?;
626 refuse_without_node(env)?;
627
628 let mut lines = vec![
629 format!(
630 "scaffold a Playwright E2E suite into {}",
631 opts.dir.display()
632 ),
633 String::new(),
634 format!(
635 "project: {} run-project: {} gateway: {}",
636 opts.project, opts.run_project, opts.gateway_url
637 ),
638 String::new(),
639 "files:".to_string(),
640 ];
641 for path in crate::e2e::member_paths() {
642 let status = if opts.dir.join(path).exists() {
643 "skip (exists)"
644 } else {
645 "write"
646 };
647 lines.push(format!(" {status:<14} {path}"));
648 }
649 lines.push(String::new());
650 lines.push("commands:".to_string());
651 for command in command_lines(&opts.dir, opts.browsers) {
652 lines.push(format!(" {command}"));
653 }
654 lines.push(String::new());
663 lines.push("this scaffold write".to_string());
664 Ok(lines.join("\n"))
665}
666
667async fn run_installer(
674 env: &E2eEnv,
675 dir: &std::path::Path,
676 tool: &str,
677 args: &[&str],
678) -> E2eCommandRun {
679 let Some(binary) = env
680 .path
681 .as_ref()
682 .and_then(|path| find_on_path_in(path, tool))
683 else {
684 eprintln!(
685 "[ign e2e init] {tool} is not on PATH — skipping `{tool} {}`",
686 args.join(" ")
687 );
688 return E2eCommandRun {
689 ran: false,
690 exit_code: None,
691 };
692 };
693 eprintln!(
694 "[ign e2e init] running: (in {}) {tool} {}",
695 dir.display(),
696 args.join(" ")
697 );
698 match tokio::process::Command::new(&binary)
699 .args(args)
700 .current_dir(dir)
701 .stdout(std::process::Stdio::inherit())
702 .stderr(std::process::Stdio::inherit())
703 .status()
704 .await
705 {
706 Ok(status) => E2eCommandRun {
707 ran: true,
708 exit_code: status.code(),
709 },
710 Err(err) => {
711 eprintln!("[ign e2e init] {tool} could not run: {err}");
712 E2eCommandRun {
713 ran: false,
714 exit_code: None,
715 }
716 }
717 }
718}
719
720pub async fn e2e_init(env: &E2eEnv, opts: &E2eInitOptions) -> Result<E2eInitResult, CoreError> {
728 refuse_foreign_directory(&opts.dir)?;
730 refuse_without_node(env)?;
732 crate::e2e::validate_member_paths()?;
734
735 std::fs::create_dir_all(&opts.dir).map_err(|err| {
739 CoreError::Internal(format!("cannot create {}: {err}", opts.dir.display()))
740 })?;
741 let mut files = Vec::with_capacity(crate::e2e::E2E_TEMPLATE_FILES.len());
742 for (member, body) in crate::e2e::E2E_TEMPLATE_FILES {
743 let destination = opts.dir.join(member);
744 if destination.exists() {
745 files.push(E2eFile {
746 path: (*member).to_string(),
747 status: E2eFileStatus::Skipped,
748 });
749 continue;
750 }
751 if let Some(parent) = destination.parent() {
752 std::fs::create_dir_all(parent).map_err(|err| {
753 CoreError::Internal(format!("cannot create {}: {err}", parent.display()))
754 })?;
755 }
756 let rendered =
757 crate::e2e::render_template(body, &opts.project, &opts.run_project, &opts.gateway_url);
758 std::fs::write(&destination, rendered).map_err(|err| {
759 CoreError::Internal(format!("cannot write {}: {err}", destination.display()))
760 })?;
761 files.push(E2eFile {
762 path: (*member).to_string(),
763 status: E2eFileStatus::Written,
764 });
765 }
766
767 let npm_install = Some(run_installer(env, &opts.dir, "npm", &npm_install_argv()).await);
770
771 let browsers = if opts.browsers {
773 Some(run_installer(env, &opts.dir, "npx", &browsers_argv()).await)
774 } else {
775 None
776 };
777
778 Ok(E2eInitResult {
779 dir: opts.dir.display().to_string(),
780 files,
781 npm_install,
782 browsers,
783 })
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn parse_node_major_handles_the_known_shapes() {
792 assert_eq!(parse_node_major("v22.11.0\n"), Some(22));
793 assert_eq!(parse_node_major("22.11.0"), Some(22));
794 assert_eq!(parse_node_major(" v20.0.0 "), Some(20));
795 assert_eq!(parse_node_major("v18.20.4"), Some(18), "old still parses");
796 assert_eq!(parse_node_major("not a version"), None);
797 assert_eq!(parse_node_major(""), None);
798 assert_eq!(parse_node_major("vX.Y.Z"), None);
799 }
800
801 #[test]
802 fn chromium_cache_scan_matches_both_entry_prefixes() {
803 let cache = tempfile::tempdir().expect("a temp dir");
804 assert!(
805 !chromium_present(cache.path()),
806 "an empty cache holds no browser"
807 );
808
809 std::fs::create_dir(cache.path().join("ffmpeg-1011")).unwrap();
810 assert!(
811 !chromium_present(cache.path()),
812 "ffmpeg alone is not chromium"
813 );
814
815 std::fs::create_dir(cache.path().join("chromium-1148")).unwrap();
816 assert!(chromium_present(cache.path()), "the full build matches");
817
818 let shell = tempfile::tempdir().expect("a temp dir");
819 std::fs::create_dir(shell.path().join("chromium_headless_shell-1148")).unwrap();
820 assert!(
821 chromium_present(shell.path()),
822 "the headless shell matches too"
823 );
824
825 assert!(
826 !chromium_present(&cache.path().join("does-not-exist")),
827 "a missing directory is simply absent, never a panic"
828 );
829 }
830}