use std::ffi::OsString;
use std::path::PathBuf;
use serde::Serialize;
use crate::actions::doctor::{CheckResult, CheckStatus};
use crate::actions::lint::find_on_path_in;
use crate::client::ReqwestGatewayApi;
use crate::client::webdev::{TestingProbe, testing_probe};
use crate::error::CoreError;
pub const MIN_NODE_MAJOR: u32 = 20;
pub const NODE_INSTALL_HINT: &str = "install Node 20 or newer — https://nodejs.org (LTS installer), \
`brew install node` (macOS/Homebrew), or `nvm install --lts` (nvm)";
const BROWSERS_PATH_VAR: &str = "PLAYWRIGHT_BROWSERS_PATH";
const CHROMIUM_PREFIXES: &[&str] = &["chromium-", "chromium_headless_shell-"];
#[derive(Debug, Clone, Default)]
pub struct E2eEnv {
pub path: Option<OsString>,
pub browsers_cache: Option<PathBuf>,
}
impl E2eEnv {
pub fn from_process() -> Self {
Self {
path: std::env::var_os("PATH"),
browsers_cache: default_browsers_cache(),
}
}
}
fn default_browsers_cache() -> Option<PathBuf> {
if let Some(explicit) = std::env::var_os(BROWSERS_PATH_VAR) {
return Some(PathBuf::from(explicit));
}
let base = directories::BaseDirs::new()?;
let home = base.home_dir();
#[cfg(target_os = "macos")]
{
Some(home.join("Library/Caches/ms-playwright"))
}
#[cfg(target_os = "windows")]
{
let _ = home;
std::env::var_os("LOCALAPPDATA").map(|local| PathBuf::from(local).join("ms-playwright"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
Some(home.join(".cache/ms-playwright"))
}
}
#[derive(Debug, Clone)]
pub struct E2eDoctorOptions {
pub dir: PathBuf,
pub project: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct E2eDoctorResult {
pub dir: String,
pub checks: Vec<CheckResult>,
}
fn parse_node_major(stdout: &str) -> Option<u32> {
let trimmed = stdout.trim();
let version = trimmed.strip_prefix('v').unwrap_or(trimmed);
version.split('.').next()?.parse().ok()
}
fn chromium_present(cache: &std::path::Path) -> bool {
let Ok(entries) = std::fs::read_dir(cache) else {
return false;
};
entries.flatten().any(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
CHROMIUM_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
})
}
fn row(name: &str, status: CheckStatus, detail: String, hint: Option<&str>) -> CheckResult {
CheckResult {
name: name.into(),
status,
detail,
hint: hint.map(str::to_string),
}
}
async fn check_node(env: &E2eEnv) -> CheckResult {
let Some(path_var) = env.path.as_ref() else {
return row(
"node",
CheckStatus::Fail,
"no PATH is set — node cannot be discovered".into(),
Some(NODE_INSTALL_HINT),
);
};
let Some(node) = find_on_path_in(path_var, "node") else {
return row(
"node",
CheckStatus::Fail,
"node is not on PATH".into(),
Some(NODE_INSTALL_HINT),
);
};
let output = tokio::process::Command::new(&node)
.args(["--version"])
.output()
.await;
let stdout = match output {
Ok(output) => String::from_utf8_lossy(&output.stdout).into_owned(),
Err(err) => {
return row(
"node",
CheckStatus::Fail,
format!(
"node was found at {} but could not run: {err}",
node.display()
),
Some(NODE_INSTALL_HINT),
);
}
};
match parse_node_major(&stdout) {
Some(major) if major >= MIN_NODE_MAJOR => row(
"node",
CheckStatus::Ok,
format!("{} ({})", stdout.trim(), node.display()),
None,
),
Some(major) => row(
"node",
CheckStatus::Fail,
format!(
"node {} is too old — major {major} < {MIN_NODE_MAJOR} ({})",
stdout.trim(),
node.display()
),
Some(NODE_INSTALL_HINT),
),
None => row(
"node",
CheckStatus::Warn,
format!(
"node at {} answered {:?} — the version could not be parsed",
node.display(),
stdout.trim()
),
Some(NODE_INSTALL_HINT),
),
}
}
fn check_npm(env: &E2eEnv) -> CheckResult {
match env
.path
.as_ref()
.and_then(|path| find_on_path_in(path, "npm"))
{
Some(npm) => row("npm", CheckStatus::Ok, npm.display().to_string(), None),
None => row(
"npm",
CheckStatus::Fail,
"npm is not on PATH".into(),
Some(NODE_INSTALL_HINT),
),
}
}
fn check_playwright(opts: &E2eDoctorOptions) -> CheckResult {
let marker = opts
.dir
.join("node_modules")
.join("@playwright")
.join("test")
.join("package.json");
if marker.is_file() {
row(
"playwright",
CheckStatus::Ok,
format!("@playwright/test is installed in {}", opts.dir.display()),
None,
)
} else {
row(
"playwright",
CheckStatus::Warn,
format!(
"@playwright/test is not installed in {} (no node_modules/@playwright/test)",
opts.dir.display()
),
Some("run `ign e2e init <DIR> --yes` to scaffold and install"),
)
}
}
fn check_chromium(env: &E2eEnv) -> CheckResult {
let Some(cache) = env.browsers_cache.as_ref() else {
return row(
"chromium",
CheckStatus::Warn,
"the Playwright browsers cache could not be located".into(),
Some("run `ign e2e init <DIR> --browsers --yes`, or set PLAYWRIGHT_BROWSERS_PATH"),
);
};
if chromium_present(cache) {
row(
"chromium",
CheckStatus::Ok,
format!("a chromium build is present in {}", cache.display()),
None,
)
} else {
row(
"chromium",
CheckStatus::Warn,
format!("no chromium build found in {}", cache.display()),
Some("run `ign e2e init <DIR> --browsers --yes` to download it"),
)
}
}
async fn check_bundle(api: Option<&ReqwestGatewayApi>, opts: &E2eDoctorOptions) -> CheckResult {
let Some(project) = opts.project.as_deref() else {
return row(
"testing-bundle",
CheckStatus::Skip,
"no --project given — the gateway's testing bundle was not probed".into(),
None,
);
};
let Some(api) = api else {
return row(
"testing-bundle",
CheckStatus::Skip,
"no gateway profile resolved — the testing bundle was not probed".into(),
None,
);
};
let adopt_hint = "deploy the bundle: `ign webdev deploy --project NAME --with-testing` \
or `ign adopt --project NAME --testing`";
match testing_probe(api, project).await {
Ok(TestingProbe::Present { discovered }) => {
let count = discovered
.get("count")
.and_then(serde_json::Value::as_u64)
.or_else(|| {
discovered
.get("discovered_modules")
.and_then(serde_json::Value::as_array)
.map(|modules| modules.len() as u64)
})
.unwrap_or(0);
row(
"testing-bundle",
CheckStatus::Ok,
format!("deployed in {project} — {count} discovered module(s)"),
None,
)
}
Ok(TestingProbe::Absent) => row(
"testing-bundle",
CheckStatus::Fail,
format!("the testing bundle is not deployed in {project}"),
Some(adopt_hint),
),
Ok(TestingProbe::Unlicensed) => row(
"testing-bundle",
CheckStatus::Fail,
"the WebDev module is installed but unlicensed (HTTP 402)".into(),
Some("license the WebDev module on the gateway"),
),
Ok(TestingProbe::AuthGated { status }) => row(
"testing-bundle",
CheckStatus::Fail,
format!("the testing route rejected this credential (HTTP {status})"),
Some("check the profile's API token — `ign doctor` diagnoses auth"),
),
Ok(TestingProbe::LazyCompile { .. }) => row(
"testing-bundle",
CheckStatus::Warn,
format!(
"the testing route in {project} answered 500 on first touch \
(WebDev lazy-compile) — re-run the doctor"
),
Some("a freshly deployed route compiles on its first request; try again"),
),
Err(err) => row(
"testing-bundle",
CheckStatus::Fail,
format!("the testing bundle could not be probed: {err}"),
Some(adopt_hint),
),
}
}
async fn check_trial(api: Option<&ReqwestGatewayApi>) -> CheckResult {
let Some(api) = api else {
return row(
"trial",
CheckStatus::Skip,
"no gateway profile resolved — the trial state was not read".into(),
None,
);
};
match crate::actions::rig::trial_status(api).await {
Ok(status) if status.expired => row(
"trial",
CheckStatus::Warn,
format!(
"the gateway's trial has EXPIRED ({}) — a browser run against it \
will hit the demo timeout",
status.trial_state
),
Some("reset it: `ign rig trial reset --yes`"),
),
Ok(status) => row(
"trial",
CheckStatus::Ok,
format!(
"{} — {}s remaining",
status.license_mode, status.trial_remaining_s
),
None,
),
Err(err) => row(
"trial",
CheckStatus::Fail,
format!("the trial state could not be read: {err}"),
Some("`ign doctor` diagnoses gateway reachability and auth"),
),
}
}
pub async fn e2e_doctor(
api: Option<&ReqwestGatewayApi>,
env: &E2eEnv,
opts: &E2eDoctorOptions,
) -> Result<E2eDoctorResult, CoreError> {
let checks = vec![
check_node(env).await,
check_npm(env),
check_playwright(opts),
check_chromium(env),
check_bundle(api, opts).await,
check_trial(api).await,
];
Ok(E2eDoctorResult {
dir: opts.dir.display().to_string(),
checks,
})
}
const SCAFFOLD_MARKER: &str = "playwright.config.mjs";
pub const DEFAULT_GATEWAY_URL: &str = "http://localhost:8088";
#[derive(Debug, Clone)]
pub struct E2eInitOptions {
pub dir: PathBuf,
pub project: String,
pub run_project: String,
pub gateway_url: String,
pub browsers: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum E2eFileStatus {
Written,
Skipped,
}
#[derive(Debug, Serialize)]
pub struct E2eFile {
pub path: String,
pub status: E2eFileStatus,
}
#[derive(Debug, Serialize)]
pub struct E2eCommandRun {
pub ran: bool,
pub exit_code: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct E2eInitResult {
pub dir: String,
pub files: Vec<E2eFile>,
pub npm_install: Option<E2eCommandRun>,
pub browsers: Option<E2eCommandRun>,
}
fn npm_install_argv() -> Vec<&'static str> {
vec!["install"]
}
fn browsers_argv() -> Vec<&'static str> {
vec!["playwright", "install", "--with-deps", "chromium"]
}
fn command_lines(dir: &std::path::Path, browsers: bool) -> Vec<String> {
let mut lines = vec![format!(
"(in {}) npm {}",
dir.display(),
npm_install_argv().join(" ")
)];
if browsers {
lines.push(format!(
"(in {}) npx {}",
dir.display(),
browsers_argv().join(" ")
));
}
lines
}
fn refuse_foreign_directory(dir: &std::path::Path) -> Result<(), CoreError> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(()); };
let names: Vec<String> = entries
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
if names.is_empty() {
return Ok(()); }
if names.iter().any(|name| name == SCAFFOLD_MARKER) {
return Ok(()); }
let mut found = names.clone();
found.sort();
Err(CoreError::InvalidInput {
reason: format!(
"target directory {} is not empty and is not an e2e scaffold (no {}) — \
refusing to clobber it; it contains {}",
dir.display(),
SCAFFOLD_MARKER,
found
.iter()
.take(5)
.map(|name| format!("{name:?}"))
.collect::<Vec<_>>()
.join(", ")
),
})
}
fn refuse_without_node(env: &E2eEnv) -> Result<(), CoreError> {
let found = env
.path
.as_ref()
.and_then(|path| find_on_path_in(path, "node"));
if found.is_none() {
return Err(CoreError::NodeToolAbsent {
tool: "node".into(),
});
}
Ok(())
}
pub fn e2e_init_preview(env: &E2eEnv, opts: &E2eInitOptions) -> Result<String, CoreError> {
refuse_foreign_directory(&opts.dir)?;
refuse_without_node(env)?;
let mut lines = vec![
format!(
"scaffold a Playwright E2E suite into {}",
opts.dir.display()
),
String::new(),
format!(
"project: {} run-project: {} gateway: {}",
opts.project, opts.run_project, opts.gateway_url
),
String::new(),
"files:".to_string(),
];
for path in crate::e2e::member_paths() {
let status = if opts.dir.join(path).exists() {
"skip (exists)"
} else {
"write"
};
lines.push(format!(" {status:<14} {path}"));
}
lines.push(String::new());
lines.push("commands:".to_string());
for command in command_lines(&opts.dir, opts.browsers) {
lines.push(format!(" {command}"));
}
lines.push(String::new());
lines.push("this scaffold write".to_string());
Ok(lines.join("\n"))
}
async fn run_installer(
env: &E2eEnv,
dir: &std::path::Path,
tool: &str,
args: &[&str],
) -> E2eCommandRun {
let Some(binary) = env
.path
.as_ref()
.and_then(|path| find_on_path_in(path, tool))
else {
eprintln!(
"[ign e2e init] {tool} is not on PATH — skipping `{tool} {}`",
args.join(" ")
);
return E2eCommandRun {
ran: false,
exit_code: None,
};
};
eprintln!(
"[ign e2e init] running: (in {}) {tool} {}",
dir.display(),
args.join(" ")
);
match tokio::process::Command::new(&binary)
.args(args)
.current_dir(dir)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
{
Ok(status) => E2eCommandRun {
ran: true,
exit_code: status.code(),
},
Err(err) => {
eprintln!("[ign e2e init] {tool} could not run: {err}");
E2eCommandRun {
ran: false,
exit_code: None,
}
}
}
}
pub async fn e2e_init(env: &E2eEnv, opts: &E2eInitOptions) -> Result<E2eInitResult, CoreError> {
refuse_foreign_directory(&opts.dir)?;
refuse_without_node(env)?;
crate::e2e::validate_member_paths()?;
std::fs::create_dir_all(&opts.dir).map_err(|err| {
CoreError::Internal(format!("cannot create {}: {err}", opts.dir.display()))
})?;
let mut files = Vec::with_capacity(crate::e2e::E2E_TEMPLATE_FILES.len());
for (member, body) in crate::e2e::E2E_TEMPLATE_FILES {
let destination = opts.dir.join(member);
if destination.exists() {
files.push(E2eFile {
path: (*member).to_string(),
status: E2eFileStatus::Skipped,
});
continue;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
CoreError::Internal(format!("cannot create {}: {err}", parent.display()))
})?;
}
let rendered =
crate::e2e::render_template(body, &opts.project, &opts.run_project, &opts.gateway_url);
std::fs::write(&destination, rendered).map_err(|err| {
CoreError::Internal(format!("cannot write {}: {err}", destination.display()))
})?;
files.push(E2eFile {
path: (*member).to_string(),
status: E2eFileStatus::Written,
});
}
let npm_install = Some(run_installer(env, &opts.dir, "npm", &npm_install_argv()).await);
let browsers = if opts.browsers {
Some(run_installer(env, &opts.dir, "npx", &browsers_argv()).await)
} else {
None
};
Ok(E2eInitResult {
dir: opts.dir.display().to_string(),
files,
npm_install,
browsers,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_node_major_handles_the_known_shapes() {
assert_eq!(parse_node_major("v22.11.0\n"), Some(22));
assert_eq!(parse_node_major("22.11.0"), Some(22));
assert_eq!(parse_node_major(" v20.0.0 "), Some(20));
assert_eq!(parse_node_major("v18.20.4"), Some(18), "old still parses");
assert_eq!(parse_node_major("not a version"), None);
assert_eq!(parse_node_major(""), None);
assert_eq!(parse_node_major("vX.Y.Z"), None);
}
#[test]
fn chromium_cache_scan_matches_both_entry_prefixes() {
let cache = tempfile::tempdir().expect("a temp dir");
assert!(
!chromium_present(cache.path()),
"an empty cache holds no browser"
);
std::fs::create_dir(cache.path().join("ffmpeg-1011")).unwrap();
assert!(
!chromium_present(cache.path()),
"ffmpeg alone is not chromium"
);
std::fs::create_dir(cache.path().join("chromium-1148")).unwrap();
assert!(chromium_present(cache.path()), "the full build matches");
let shell = tempfile::tempdir().expect("a temp dir");
std::fs::create_dir(shell.path().join("chromium_headless_shell-1148")).unwrap();
assert!(
chromium_present(shell.path()),
"the headless shell matches too"
);
assert!(
!chromium_present(&cache.path().join("does-not-exist")),
"a missing directory is simply absent, never a panic"
);
}
}