Skip to main content

ignition_core/actions/
e2e.rs

1//! `ign e2e` (QUICK-tg4) — the browser-E2E scaffold pair.
2//!
3//! Two verbs, deliberately not three: [`e2e_doctor`] diagnoses the host
4//! and gateway a Playwright suite needs, and `e2e_init` writes the
5//! scaffold. There is NO `ign e2e run` — the scaffold's own `npm test`
6//! is the runner, and wrapping it would put this CLI in the business of
7//! proxying Playwright's reporter, flags, and exit codes.
8//!
9//! ## The doctor posture
10//!
11//! [`e2e_doctor`] returns `Ok` whenever the diagnosis COMPLETES. A
12//! missing Node, an unreachable gateway, an undeployed testing bundle —
13//! all of them are `checks[]` rows, never a `CoreError`. This is the
14//! `actions::doctor` contract verbatim (exit 0 is "I looked"), and it
15//! is what lets an agent read one envelope instead of branching on an
16//! exit code before it can see the diagnosis.
17//!
18//! ## Environment injection
19//!
20//! Both actions take an [`E2eEnv`] rather than reading the process
21//! environment themselves. `std::env::set_var` is `unsafe` in edition
22//! 2024 and races under the workspace's parallel test runs, so the
23//! contract tests build a temp-dir stub PATH and hand it in — the
24//! `ComposeRunner` injection precedent.
25
26use 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
37/// The minimum Node major this scaffold supports.
38///
39/// Playwright 1.5x requires Node 18+; 20 is the floor here because the
40/// scaffold's `global-setup.mjs` and `lib/gateway.mjs` use built-in
41/// `fetch` without a flag, which is only unflagged from 18 and only
42/// non-experimental from 20.
43pub const MIN_NODE_MAJOR: u32 = 20;
44
45/// THE install hint for a missing or too-old Node.
46///
47/// A `pub const` rather than an inline string so `e2e init`'s exit-6
48/// refusal (Task 3) carries the doctor's hint BYTE-IDENTICALLY — a
49/// user who hits the refusal and then runs the doctor must not be
50/// given two differently-worded versions of the same instruction.
51pub 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
54/// The Playwright browsers-cache override the scaffold and the doctor
55/// both honor.
56const BROWSERS_PATH_VAR: &str = "PLAYWRIGHT_BROWSERS_PATH";
57
58/// Directory-entry prefixes a usable Chromium install produces.
59const CHROMIUM_PREFIXES: &[&str] = &["chromium-", "chromium_headless_shell-"];
60
61/// The injected host environment (D10) — everything these actions read
62/// from outside their arguments.
63#[derive(Debug, Clone, Default)]
64pub struct E2eEnv {
65    /// The `PATH` value tool discovery splits.
66    pub path: Option<OsString>,
67    /// Where Playwright keeps downloaded browsers.
68    pub browsers_cache: Option<PathBuf>,
69}
70
71impl E2eEnv {
72    /// Read the real process environment.
73    ///
74    /// The browsers cache follows Playwright's own resolution order
75    /// (D1): `PLAYWRIGHT_BROWSERS_PATH` when set, else the per-OS
76    /// default under the user's cache/local-data directory.
77    pub fn from_process() -> Self {
78        Self {
79            path: std::env::var_os("PATH"),
80            browsers_cache: default_browsers_cache(),
81        }
82    }
83}
84
85/// Playwright's per-OS default browsers cache.
86///
87/// `directories::BaseDirs` (already in the tree) rather than a
88/// hand-rolled home-dir walk — zero new dependencies, and it gets
89/// macOS's `~/Library/Caches` right without a `cfg` ladder for the
90/// home directory itself.
91fn 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/// `ign e2e doctor` options.
113#[derive(Debug, Clone)]
114pub struct E2eDoctorOptions {
115    /// The scaffold directory whose `node_modules` is inspected.
116    pub dir: PathBuf,
117    /// The gateway project whose testing bundle is probed. NO default
118    /// (D8) — without it the two gateway rows honestly `skip`.
119    pub project: Option<String>,
120}
121
122/// `ign e2e doctor` output — the doctor-shaped row list.
123#[derive(Debug, Serialize)]
124pub struct E2eDoctorResult {
125    /// The inspected scaffold directory.
126    pub dir: String,
127    /// The six rows, in a fixed order. Reuses
128    /// [`crate::actions::doctor::CheckResult`] — there is deliberately
129    /// no parallel row type, so every doctor in this CLI renders and
130    /// parses identically.
131    pub checks: Vec<CheckResult>,
132}
133
134/// `"v22.11.0\n"` → `Some(22)`; a bare `"22.11.0"` parses too.
135/// Anything else is `None` (the `parse_compose_version` shape).
136fn 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
142/// Does this cache directory hold a usable Chromium? (D1)
143///
144/// A pure filesystem read — deliberately NOT `npx playwright install
145/// --dry-run`, which reaches for the npm registry on a host without the
146/// package cached. That would break both the doctor's offline posture
147/// and its "exit 0 whenever the diagnosis completes" contract, and the
148/// `--dry-run` output is not a stable contract to parse either.
149fn 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
171/// Row 1 — node: discovery, then a version probe through an ARG VECTOR
172/// (never a shell string).
173async 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
238/// Row 2 — npm: discovery only. The version is not probed: npm ships
239/// WITH node, so a present node and an absent npm is a broken install,
240/// not a version question.
241fn 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
257/// Row 3 — playwright: is the runner installed in the scaffold?
258fn 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
285/// Row 4 — chromium: the browsers-cache scan (D1).
286///
287/// `warn`, never `fail`: a missing browser is one command away, and
288/// grading it a failure would make a perfectly scaffoldable host read
289/// as broken.
290fn 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
316/// Row 5 — testing-bundle. A transport error becomes a `fail` ROW, not
317/// a propagated `CoreError`: letting it escape would break the exit-0
318/// contract and hide the five rows already gathered.
319async 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
393/// Row 6 — trial. Same posture as row 5: a transport error is a row.
394async 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
432/// THE diagnosis: six rows, fixed order, `Ok` whenever it completes.
433///
434/// `api` is `None` when no profile resolves — NOT an error for this
435/// verb (`ign e2e doctor` must be useful on a host that has not been
436/// configured yet, which is exactly when a developer runs it).
437pub 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
456// ---------------------------------------------------------------------------
457// `ign e2e init` — the scaffold write.
458// ---------------------------------------------------------------------------
459
460/// The marker file that identifies a directory as an e2e scaffold (and
461/// therefore safe to re-init over).
462const SCAFFOLD_MARKER: &str = "playwright.config.mjs";
463
464/// The gateway URL baked into a scaffold generated with NO profile.
465///
466/// `ign e2e init` must work before a profile is configured — scaffolding
467/// is something you do on a fresh machine — so the URL falls back to
468/// Ignition's stock HTTP port. The scaffold reads `IGNITION_URL` first
469/// in every place it uses this, so a wrong guess here costs one env var,
470/// never a re-scaffold.
471pub const DEFAULT_GATEWAY_URL: &str = "http://localhost:8088";
472
473/// `ign e2e init` options.
474///
475/// There is deliberately NO `yes` field: `--yes`/`-y` is a GLOBAL clap
476/// arg, and the confirmation guard lives at the dispatch site so the
477/// `GUARDED_OPS` registry and the MCP catalog's synthetic `confirm`
478/// property both see it (D3).
479#[derive(Debug, Clone)]
480pub struct E2eInitOptions {
481    /// Where the scaffold lands.
482    pub dir: PathBuf,
483    /// The project whose testing routes the helpers call.
484    pub project: String,
485    /// The Perspective project the browser test navigates to.
486    pub run_project: String,
487    /// Baked in as the `IGNITION_URL` fallback.
488    pub gateway_url: String,
489    /// Also download the Chromium build.
490    pub browsers: bool,
491}
492
493/// What happened to one scaffold member.
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
495#[serde(rename_all = "lowercase")]
496pub enum E2eFileStatus {
497    /// The member was written.
498    Written,
499    /// The destination already existed and was left untouched.
500    Skipped,
501}
502
503/// One scaffold member's outcome.
504#[derive(Debug, Serialize)]
505pub struct E2eFile {
506    /// The member path, relative to the target directory.
507    pub path: String,
508    /// Written or skipped.
509    pub status: E2eFileStatus,
510}
511
512/// A spawned installer's outcome. A non-zero exit is DATA, not an
513/// error: a network-less host must still be left with a usable
514/// scaffold on disk (the lint doctor posture).
515#[derive(Debug, Serialize)]
516pub struct E2eCommandRun {
517    /// Whether the command was spawned at all.
518    pub ran: bool,
519    /// The child's exit code (`null` when a signal killed it).
520    pub exit_code: Option<i32>,
521}
522
523/// `ign e2e init` output — ALL keys always present.
524#[derive(Debug, Serialize)]
525pub struct E2eInitResult {
526    /// The target directory.
527    pub dir: String,
528    /// Every member with its status, in write order.
529    pub files: Vec<E2eFile>,
530    /// The `npm install` leg.
531    pub npm_install: Option<E2eCommandRun>,
532    /// The browser-download leg (`null` without `--browsers`).
533    pub browsers: Option<E2eCommandRun>,
534}
535
536/// The `npm install` argv.
537fn npm_install_argv() -> Vec<&'static str> {
538    vec!["install"]
539}
540
541/// The Playwright browser-download argv.
542fn browsers_argv() -> Vec<&'static str> {
543    vec!["playwright", "install", "--with-deps", "chromium"]
544}
545
546/// Human-readable renderings of the two commands, for the preview.
547fn 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
563/// GATE 1 — the foreign-directory refusal.
564///
565/// A non-empty target that is not already a scaffold is refused BEFORE
566/// any write, naming a file it found. The `ensure_checkout_target`
567/// message shape: a refusal that does not say what it saw leaves the
568/// user guessing which directory they actually typed.
569fn refuse_foreign_directory(dir: &std::path::Path) -> Result<(), CoreError> {
570    let Ok(entries) = std::fs::read_dir(dir) else {
571        return Ok(()); // absent (or unreadable) — the write step owns it
572    };
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(()); // empty dir — init owns it from here
579    }
580    if names.iter().any(|name| name == SCAFFOLD_MARKER) {
581        return Ok(()); // an existing scaffold: re-init is idempotent
582    }
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
601/// GATE 2 — the node refusal.
602fn 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
615/// The confirmation preview (D3) — the refusal IS the dry run.
616///
617/// Runs gates 1 and 2 first so the foreign-directory and node refusals
618/// WIN over the confirmation refusal (being told to pass `--yes` for a
619/// write that would have been refused anyway is a wasted round trip),
620/// and so the previewed statuses are accurate.
621///
622/// Pure apart from filesystem reads: it writes nothing and spawns
623/// nothing.
624pub 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    // `require_confirmation` renders "<operation> is destructive; rerun
655    // with --yes to confirm", concatenating its suffix directly onto
656    // this string. Every other guarded site passes a short noun phrase,
657    // so the suffix reads fine; a multi-line preview ending in a
658    // command line does NOT — it produces
659    // "npm install is destructive; rerun with --yes to confirm",
660    // which reads as a claim about npm. The closing noun phrase gives
661    // the suffix something sensible to attach to.
662    lines.push(String::new());
663    lines.push("this scaffold write".to_string());
664    Ok(lines.join("\n"))
665}
666
667/// Spawn an installer in the target directory, streaming its output.
668///
669/// An ARG VECTOR, never a shell string (the compose seam precedent):
670/// the target directory is operator-named and must never be able to
671/// become shell syntax. stdout/stderr are inherited so the child
672/// streams through live — the `rig logs` passthrough posture.
673async 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
720/// THE scaffold write. Gates run in a fixed order and NONE may be
721/// reordered.
722///
723/// Gates 1 and 2 are RE-RUN here even though `e2e_init_preview` already
724/// ran them at the CLI seam: the action's own re-check is what keeps
725/// in-process TUI and MCP callers honest (the `api call` convention),
726/// because those callers do not pass through the dispatch arm.
727pub async fn e2e_init(env: &E2eEnv, opts: &E2eInitOptions) -> Result<E2eInitResult, CoreError> {
728    // 1. Foreign directory — before any write.
729    refuse_foreign_directory(&opts.dir)?;
730    // 2. Node — before any write.
731    refuse_without_node(env)?;
732    // A bad template edit must not write outside the target.
733    crate::e2e::validate_member_paths()?;
734
735    // 3. Write. NEVER overwrite: an existing member is recorded
736    //    `skipped` and left byte-for-byte alone, which is what makes a
737    //    second init safe to run over a suite someone has edited.
738    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    // 4. Install. A non-zero child is DATA — a host with no network
768    //    still ends up with a scaffold it can install later.
769    let npm_install = Some(run_installer(env, &opts.dir, "npm", &npm_install_argv()).await);
770
771    // 5. Browsers, only on request.
772    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}