From 2515c4c1af029e35a554171e262e2188cb3da48f Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:38:55 +0300 Subject: [PATCH 1/6] feat: Fix frontend validation readiness in worktrees --- .../ralphx-project-analyzer/shared/prompt.md | 2 + .../merge_validation/frontend_readiness.rs | 226 ++++++++++++++++++ .../merge_validation/install.rs | 58 ++++- .../merge_validation/mod.rs | 19 +- .../merge_validation/validate.rs | 148 +++++++++++- .../tests/merge_validation_tests.rs | 136 +++++++++++ 6 files changed, 557 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/domain/state_machine/transition_handler/merge_validation/frontend_readiness.rs diff --git a/agents/ralphx-project-analyzer/shared/prompt.md b/agents/ralphx-project-analyzer/shared/prompt.md index 25828d58c6..fb87148722 100644 --- a/agents/ralphx-project-analyzer/shared/prompt.md +++ b/agents/ralphx-project-analyzer/shared/prompt.md @@ -40,6 +40,7 @@ You are the RalphX Project Analyzer Agent. Your job is to scan a project's worki 3. For `package.json`: inspect it to check available scripts (typecheck, lint, build, test) 4. For `Cargo.toml`: check if it's a workspace root (`[workspace]`) vs member 5. Determine the relative `path` from project root (use `.` for root-level) +6. If the only Node package is under `frontend/package.json`, emit `path: "frontend"` so validation runs from `frontend/`, not from the repository root ## Repo-Specific Validation Overrides @@ -104,6 +105,7 @@ Use these placeholders in commands — they are resolved at runtime: - Only detect what actually exists — don't guess or assume - If a monorepo has multiple workspaces, produce entries for each build context - For `package.json`, only include scripts that actually exist (check the `scripts` object) +- Never pass `vitest.config.ts` as a test target. For Vitest, use the package test script with no config-file argument, or pass real `*.test.*` / `*.spec.*` files only. - Focus on commands useful for validation during task execution and review - When repo-local docs define validation policy, prefer those commands over generic defaults diff --git a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/frontend_readiness.rs b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/frontend_readiness.rs new file mode 100644 index 0000000000..3c05c4264d --- /dev/null +++ b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/frontend_readiness.rs @@ -0,0 +1,226 @@ +use std::path::{Path, PathBuf}; + +use tokio::io::AsyncReadExt; +use tokio_util::sync::CancellationToken; + +use crate::infrastructure::tool_paths::resolve_node_cli_path; +use crate::utils::path_safety::{checked_read_to_string, validate_absolute_non_root_path}; + +#[derive(Debug, Clone)] +pub(super) struct FrontendReadinessFailure { + issues: Vec, +} + +impl FrontendReadinessFailure { + pub(super) fn message(&self) -> String { + self.issues.join("; ") + } +} + +pub(super) fn command_cwd( + base_cwd: &Path, + resolved_path: &str, + command: &str, +) -> (PathBuf, String) { + let default_cwd = if resolved_path == "." { + base_cwd.to_path_buf() + } else { + base_cwd.join(resolved_path) + }; + + if resolved_path == "." && is_node_package_command(command) { + let nested_frontend = default_cwd.join("frontend"); + if nested_frontend.join("package.json").exists() + && !default_cwd.join("package.json").exists() + { + return (nested_frontend, "frontend".to_string()); + } + } + + (default_cwd, resolved_path.to_string()) +} + +pub(super) fn sanitize_frontend_validate_command(command: &str) -> String { + let mut parts: Vec<&str> = command + .split_whitespace() + .filter(|part| { + let trimmed = part.trim_matches(|ch| ch == '\'' || ch == '"'); + trimmed != "vitest.config.ts" && !trimmed.ends_with("/vitest.config.ts") + }) + .collect(); + + while parts.last().copied() == Some("--") { + parts.pop(); + } + + let mut sanitized = if parts.is_empty() { + command.to_string() + } else { + parts.join(" ") + }; + + if let Some(rest) = sanitized.strip_prefix("vitest ") { + sanitized = format!("./node_modules/.bin/vitest {rest}"); + } else if sanitized == "vitest" { + sanitized = "./node_modules/.bin/vitest".to_string(); + } + + sanitized +} + +pub(super) fn requires_frontend_readiness(command: &str, cwd: &Path) -> bool { + if !is_frontend_validation_command(command) { + return false; + } + + is_frontend_package_context(cwd) +} + +pub(super) fn is_frontend_package_context(cwd: &Path) -> bool { + cwd.join("package.json").exists() + && (cwd.ends_with("frontend") || package_json_mentions_frontend_stack(cwd)) +} + +pub(super) async fn check_frontend_dependency_readiness( + cwd: &Path, + cancel: &CancellationToken, +) -> Result<(), FrontendReadinessFailure> { + let mut issues = Vec::new(); + let vitest_bin = cwd.join("node_modules").join(".bin").join("vitest"); + if !is_executable_file(&vitest_bin) { + issues.push(format!( + "{} is missing or not executable", + vitest_bin.display() + )); + } + + for specifier in ["vitest/config", "react", "zod", "@tauri-apps/api"] { + if let Err(error) = run_node_import_probe(cwd, specifier, cancel).await { + issues.push(error); + } + } + + if issues.is_empty() { + Ok(()) + } else { + Err(FrontendReadinessFailure { issues }) + } +} + +fn is_node_package_command(command: &str) -> bool { + let trimmed = command.trim_start(); + trimmed.starts_with("npm ") + || trimmed == "npm" + || trimmed.starts_with("npx ") + || trimmed.starts_with("vitest") + || trimmed.starts_with("./node_modules/.bin/vitest") +} + +fn is_frontend_validation_command(command: &str) -> bool { + let command = command.to_ascii_lowercase(); + command.contains("npm run lint") + || command.contains("npm run typecheck") + || command.contains("npm run test") + || command == "npm test" + || command.contains(" vitest") + || command.starts_with("vitest") + || command.starts_with("./node_modules/.bin/vitest") +} + +fn package_json_mentions_frontend_stack(cwd: &Path) -> bool { + let Ok(contents) = checked_read_to_string(&cwd.join("package.json"), "frontend package.json") + else { + return false; + }; + + contents.contains("\"react\"") + || contents.contains("\"vitest\"") + || contents.contains("\"@tauri-apps/api\"") + || contents.contains("\"zod\"") +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(safe_path) = validate_absolute_non_root_path(path, "frontend executable") else { + return false; + }; + let Ok(metadata) = safe_path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } +} + +async fn run_node_import_probe( + cwd: &Path, + specifier: &str, + cancel: &CancellationToken, +) -> Result<(), String> { + let safe_cwd = validate_absolute_non_root_path(cwd, "frontend dependency probe cwd") + .map_err(|error| format!("invalid frontend dependency probe cwd: {error}"))?; + let mut child = tokio::process::Command::new(resolve_node_cli_path()) + .arg("-e") + .arg(format!("import({specifier:?})")) + .current_dir(&safe_cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|error| format!("node import {specifier:?} could not start: {error}"))?; + + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + let stdout_fut = async { + let mut bytes = Vec::new(); + if let Some(mut stdout) = stdout_handle { + let _ = stdout.read_to_end(&mut bytes).await; + } + bytes + }; + let stderr_fut = async { + let mut bytes = Vec::new(); + if let Some(mut stderr) = stderr_handle { + let _ = stderr.read_to_end(&mut bytes).await; + } + bytes + }; + + tokio::select! { + _ = cancel.cancelled() => { + let _ = child.kill().await; + let _ = child.wait().await; + Err(format!("node import {specifier:?} cancelled")) + } + (status, stdout, stderr) = async { tokio::join!(child.wait(), stdout_fut, stderr_fut) } => { + match status { + Ok(status) if status.success() => Ok(()), + Ok(status) => { + let stderr = String::from_utf8_lossy(&stderr); + let stdout = String::from_utf8_lossy(&stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + Err(format!( + "node import {specifier:?} failed with exit {:?}: {}", + status.code(), + detail + )) + } + Err(error) => Err(format!("node import {specifier:?} wait failed: {error}")), + } + } + } +} diff --git a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs index eab6b74133..7961b1ccce 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs @@ -6,9 +6,14 @@ use tokio_util::sync::CancellationToken; use crate::domain::entities::{Project, Task}; use super::{ - setup::run_setup_phase, spawn_cancellable_command, truncate_output, CancellableCommandResult, - MergeAnalysisEntry, PreExecAnalysisEntry, PreExecSetupResult, ValidationLogEntry, - INSTALL_RETRY_DELAY_MS, STATUS_FAILED, + frontend_readiness::{ + check_frontend_dependency_readiness, command_cwd, is_frontend_package_context, + requires_frontend_readiness, + }, + setup::run_setup_phase, + spawn_cancellable_command, truncate_output, CancellableCommandResult, MergeAnalysisEntry, + PreExecAnalysisEntry, PreExecSetupResult, ValidationLogEntry, INSTALL_RETRY_DELAY_MS, + STATUS_FAILED, }; /// Run install commands for pre-execution setup. @@ -31,21 +36,28 @@ pub(crate) async fn run_install_phase( }; let resolved_cmd = resolve(cmd_str); - let resolved_path = resolve(&entry.path); - let cmd_cwd = if resolved_path == "." { - exec_cwd.to_path_buf() - } else { - exec_cwd.join(&resolved_path) - }; + let entry_path = resolve(&entry.path); + let (cmd_cwd, resolved_path) = command_cwd(exec_cwd, &entry_path, &resolved_cmd); + let frontend_readiness_required = is_frontend_package_context(&cmd_cwd) + || requires_frontend_readiness(&resolved_cmd, &cmd_cwd); - // Skip install if node_modules already exists (symlink from setup phase or prior install) + // Skip install only when frontend dependencies are actually ready. A partial + // node_modules directory is not enough: local Vitest and import probes must pass. let nm_path = cmd_cwd.join("node_modules"); - if nm_path.exists() || nm_path.is_symlink() { + let dependency_tree_ready = if frontend_readiness_required { + check_frontend_dependency_readiness(&cmd_cwd, cancel) + .await + .is_ok() + } else { + nm_path.exists() || nm_path.is_symlink() + }; + if dependency_tree_ready { tracing::info!( command = %resolved_cmd, cwd = %cmd_cwd.display(), is_symlink = nm_path.is_symlink(), - "Skipping install: node_modules already exists" + frontend_readiness_required, + "Skipping install: dependency tree is ready" ); log.push(ValidationLogEntry { phase: "install".to_string(), @@ -55,7 +67,11 @@ pub(crate) async fn run_install_phase( status: "skipped".to_string(), exit_code: None, stdout: String::new(), - stderr: "node_modules already exists — install skipped".to_string(), + stderr: if frontend_readiness_required { + "frontend dependencies are ready — install skipped".to_string() + } else { + "node_modules already exists — install skipped".to_string() + }, duration_ms: 0, ..Default::default() }); @@ -241,6 +257,21 @@ pub(crate) async fn run_install_phase( install_had_failures = true; } + if frontend_readiness_required && log_entry.status != STATUS_FAILED { + if let Err(readiness) = check_frontend_dependency_readiness(&cmd_cwd, cancel).await { + install_had_failures = true; + log_entry.status = STATUS_FAILED.to_string(); + log_entry.exit_code = None; + log_entry.stderr = truncate_output( + &format!( + "Frontend dependency setup failed after install: {}", + readiness.message() + ), + 2000, + ); + } + } + if let Some(handle) = app_handle { let _ = handle.emit( "merge:validation_step", @@ -337,6 +368,7 @@ pub async fn run_pre_execution_setup( .map(|e| MergeAnalysisEntry { path: e.path.clone(), label: e.label.clone(), + install: e.install.clone(), validate: Vec::new(), worktree_setup: e.worktree_setup.clone(), }) diff --git a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/mod.rs b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/mod.rs index 9c4677cac3..e275ad9055 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/mod.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/mod.rs @@ -3,8 +3,9 @@ // Extracted from side_effects.rs — runs project analysis commands to verify merge correctness. // Decomposed into setup phase, validate phase, and orchestrator. -mod logging; +mod frontend_readiness; mod install; +mod logging; mod metadata; mod setup; mod validate; @@ -40,19 +41,19 @@ use crate::domain::entities::{ use crate::infrastructure::tool_paths::resolve_shell_cli_path; use crate::utils::truncate_str; +#[cfg(test)] +pub(crate) use install::run_install_phase; +pub use install::run_pre_execution_setup; +#[cfg(test)] +pub(crate) use logging::validation_log_dir; +pub(crate) use logging::{cleanup_validation_logs, emit_merge_progress}; pub(crate) use metadata::format_validation_error_metadata; pub(crate) use metadata::{ extract_cached_validation, format_validation_warn_metadata, take_skip_validation_flag, }; -pub(crate) use logging::{cleanup_validation_logs, emit_merge_progress}; -#[cfg(test)] -pub(crate) use logging::validation_log_dir; -#[cfg(test)] -pub(crate) use install::run_install_phase; -pub use install::run_pre_execution_setup; +use setup::run_setup_phase; #[cfg(test)] pub(crate) use setup::{parse_symlink_command, try_handle_symlink_idempotent}; -use setup::run_setup_phase; use validate::run_validate_phase; /// Outcome of a cancellable shell command execution. @@ -227,6 +228,8 @@ pub(super) struct MergeAnalysisEntry { #[allow(dead_code)] pub(super) label: String, #[serde(default)] + pub(super) install: Option, + #[serde(default)] pub(super) validate: Vec, #[serde(default)] pub(super) worktree_setup: Vec, diff --git a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs index 2be2c4cd44..785a8901ba 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs @@ -6,8 +6,14 @@ use tokio_util::sync::CancellationToken; use crate::domain::entities::merge_progress_event::{map_command_to_phase, MergePhaseStatus}; use super::{ - emit_merge_progress, spawn_cancellable_command, truncate_output, CancellableCommandResult, - MergeAnalysisEntry, ValidationFailure, ValidationLogEntry, STATUS_FAILED, + emit_merge_progress, + frontend_readiness::{ + check_frontend_dependency_readiness, command_cwd, requires_frontend_readiness, + sanitize_frontend_validate_command, + }, + install::run_install_phase, + spawn_cancellable_command, truncate_output, CancellableCommandResult, MergeAnalysisEntry, + PreExecAnalysisEntry, ValidationFailure, ValidationLogEntry, STATUS_FAILED, VALIDATE_RETRY_DELAY_MS, }; @@ -28,9 +34,10 @@ fn emit_skipped_for_remaining( ) { let mut past_failure = false; for entry in entries { - let resolved_path = resolve(&entry.path); + let entry_path = resolve(&entry.path); for cmd_str in &entry.validate { - let resolved_cmd = resolve(cmd_str); + let resolved_cmd = sanitize_frontend_validate_command(&resolve(cmd_str)); + let (_cmd_cwd, resolved_path) = command_cwd(_merge_cwd, &entry_path, &resolved_cmd); // Skip commands we already ran (they're already in the log) if !past_failure { @@ -120,15 +127,11 @@ pub(super) async fn run_validate_phase( continue; } - let resolved_path = resolve(&entry.path); - let cmd_cwd = if resolved_path == "." { - merge_cwd.to_path_buf() - } else { - merge_cwd.join(&resolved_path) - }; + let entry_path = resolve(&entry.path); for cmd_str in &entry.validate { - let resolved_cmd = resolve(cmd_str); + let resolved_cmd = sanitize_frontend_validate_command(&resolve(cmd_str)); + let (cmd_cwd, resolved_path) = command_cwd(merge_cwd, &entry_path, &resolved_cmd); ran_any = true; // Check cache: skip previously-passed validate commands when SHA matches @@ -186,6 +189,129 @@ pub(super) async fn run_validate_phase( } } + if requires_frontend_readiness(&resolved_cmd, &cmd_cwd) { + let readiness = check_frontend_dependency_readiness(&cmd_cwd, cancel).await; + if let Err(before_install) = readiness { + let mut setup_failed = entry.install.is_none(); + if let Some(install_cmd) = entry.install.clone() { + tracing::info!( + command = %resolved_cmd, + cwd = %cmd_cwd.display(), + "Frontend validation dependencies are not ready; running install before validation" + ); + let install_entry = PreExecAnalysisEntry { + path: resolved_path.clone(), + label: entry.label.clone(), + install: Some(install_cmd), + worktree_setup: Vec::new(), + }; + let (install_log, install_had_failures) = run_install_phase( + &[install_entry], + merge_cwd, + task_id_str, + app_handle, + resolve, + "validation_readiness", + cancel, + ) + .await; + setup_failed = install_had_failures; + log.extend(install_log); + } + + let readiness_after_install = + check_frontend_dependency_readiness(&cmd_cwd, cancel).await; + if setup_failed || readiness_after_install.is_err() { + let message = readiness_after_install + .err() + .map(|failure| failure.message()) + .unwrap_or_else(|| before_install.message()); + let stderr = format!( + "Frontend dependency setup failed before validation; not running '{}': {}", + resolved_cmd, message + ); + tracing::warn!( + command = %resolved_cmd, + cwd = %cmd_cwd.display(), + error = %stderr, + "Frontend validation blocked by dependency readiness failure" + ); + emit_merge_progress( + app_handle, + task_id_str, + map_command_to_phase(&resolved_cmd), + MergePhaseStatus::Failed, + stderr.clone(), + ); + let log_entry = ValidationLogEntry { + phase: "validate".to_string(), + command: resolved_cmd.clone(), + path: resolved_path.clone(), + label: entry.label.clone(), + status: STATUS_FAILED.to_string(), + exit_code: None, + stdout: String::new(), + stderr: truncate_output(&stderr, 2000), + duration_ms: 0, + ..Default::default() + }; + if let Some(handle) = app_handle { + let _ = handle.emit( + "merge:validation_step", + serde_json::json!({ + "task_id": task_id_str, + "phase": log_entry.phase, + "command": log_entry.command, + "path": log_entry.path, + "label": log_entry.label, + "status": log_entry.status, + "exit_code": log_entry.exit_code, + "stdout": log_entry.stdout, + "stderr": log_entry.stderr, + "duration_ms": log_entry.duration_ms, + }), + ); + } + log.push(log_entry); + failures.push(ValidationFailure { + command: resolved_cmd.clone(), + path: resolved_path.clone(), + exit_code: None, + stderr, + }); + + use crate::domain::entities::MergeValidationMode; + if matches!( + validation_mode, + MergeValidationMode::Block | MergeValidationMode::AutoFix + ) { + emit_skipped_for_remaining( + entries, + merge_cwd, + task_id_str, + app_handle, + resolve, + &mut log, + &resolved_path, + &resolved_cmd, + ); + let validate_duration_ms = + validate_phase_start.elapsed().as_millis() as u64; + tracing::info!( + task_id = task_id_str, + duration_ms = validate_duration_ms, + command_count = validate_count, + failure_count = failures.len(), + "run_validation_commands: completed validate phase (frontend readiness failure)" + ); + return (log, failures, ran_any); + } + + continue; + } + } + } + // Emit high-level merge progress event let phase = map_command_to_phase(&resolved_cmd); emit_merge_progress( diff --git a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs index 2ada7bfd75..a2b4948766 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs @@ -15,6 +15,39 @@ use super::super::merge_validation::{ use super::helpers::*; use crate::domain::entities::MergeValidationMode; +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +fn write_executable_script(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).unwrap(); + } +} + // ================== // run_validation_commands tests // ================== @@ -228,6 +261,109 @@ async fn run_validation_resolves_template_vars() { assert!(result.unwrap().all_passed); } +#[tokio::test] +async fn frontend_validation_bootstraps_partial_node_modules_and_uses_local_vitest() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + std::fs::create_dir_all(frontend_dir.join("node_modules")).unwrap(); + std::fs::write( + frontend_dir.join("package.json"), + r#"{ + "scripts": { "test:run": "vitest run" }, + "dependencies": { "react": "1.0.0", "zod": "1.0.0", "@tauri-apps/api": "1.0.0" }, + "devDependencies": { "vitest": "1.0.0" } + }"#, + ) + .unwrap(); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_executable_script( + &fake_node, + r#"#!/bin/sh +if [ -x "node_modules/.bin/vitest" ]; then + exit 0 +fi +echo "missing frontend dependency readiness" >&2 +exit 1 +"#, + ); + + let global_marker = worktree_dir.path().join("global-vitest-used"); + let global_vitest = fake_bin.path().join("vitest"); + write_executable_script( + &global_vitest, + &format!( + "#!/bin/sh\nprintf 'global-vitest %s\\n' \"$*\" > '{}'\nexit 0\n", + global_marker.display() + ), + ); + + let local_marker = worktree_dir.path().join("local-vitest-used"); + let install_cmd = format!( + "mkdir -p node_modules/.bin && printf '#!/bin/sh\\nprintf \"local-vitest %s\\\\n\" \"$*\" > \"{}\"\\nexit 0\\n' > node_modules/.bin/vitest && chmod +x node_modules/.bin/vitest", + local_marker.display() + ); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let path_value = format!("{}:/bin:/usr/bin", fake_bin.path().display()); + let _path = EnvVarGuard::set("PATH", std::ffi::OsString::from(path_value)); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some(format!( + r#"[{{ + "path": ".", + "label": "Frontend", + "install": "{}", + "validate": ["vitest run vitest.config.ts"], + "worktree_setup": [] + }}]"#, + install_cmd.replace('\\', "\\\\").replace('"', "\\\"") + )); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-readiness-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend validation should produce a result"); + + assert!( + result.all_passed, + "validation should pass after bootstrap; log={:?}; failures={:?}", + result.log, result.failures + ); + assert!( + local_marker.exists(), + "local node_modules/.bin/vitest should run after bootstrap" + ); + assert!( + !global_marker.exists(), + "global/non-local vitest must not be invoked" + ); + assert!(result.log.iter().any(|entry| { + entry.phase == "install" && entry.path == "frontend" && entry.status == "success" + })); + assert!(result.log.iter().any(|entry| { + entry.phase == "validate" + && entry.path == "frontend" + && entry.command == "./node_modules/.bin/vitest run" + && entry.status == "success" + })); +} + #[tokio::test] async fn run_validation_returns_none_for_invalid_json() { let mut project = make_project(Some("main")); From 309d4e6ff7f4a5912d6e16c36f7cf9d7cf4171e3 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:02:32 +0300 Subject: [PATCH 2/6] Add frontend readiness coverage regressions --- .../tests/merge_validation_tests.rs | 231 ++++++++++++++++-- 1 file changed, 211 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs index a2b4948766..3a98afcc2f 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs @@ -48,6 +48,32 @@ fn write_executable_script(path: &Path, contents: &str) { } } +fn write_frontend_package(frontend_dir: &Path) { + std::fs::create_dir_all(frontend_dir.join("node_modules")).unwrap(); + std::fs::write( + frontend_dir.join("package.json"), + r#"{ + "scripts": { "test:run": "vitest run" }, + "dependencies": { "react": "1.0.0", "zod": "1.0.0", "@tauri-apps/api": "1.0.0" }, + "devDependencies": { "vitest": "1.0.0" } + }"#, + ) + .unwrap(); +} + +fn write_readiness_probe_node(path: &Path) { + write_executable_script( + path, + r#"#!/bin/sh +if [ -x "node_modules/.bin/vitest" ]; then + exit 0 +fi +echo "missing frontend dependency readiness" >&2 +exit 1 +"#, + ); +} + // ================== // run_validation_commands tests // ================== @@ -269,29 +295,11 @@ async fn frontend_validation_bootstraps_partial_node_modules_and_uses_local_vite let project_dir = tempfile::tempdir().unwrap(); let worktree_dir = tempfile::tempdir().unwrap(); let frontend_dir = worktree_dir.path().join("frontend"); - std::fs::create_dir_all(frontend_dir.join("node_modules")).unwrap(); - std::fs::write( - frontend_dir.join("package.json"), - r#"{ - "scripts": { "test:run": "vitest run" }, - "dependencies": { "react": "1.0.0", "zod": "1.0.0", "@tauri-apps/api": "1.0.0" }, - "devDependencies": { "vitest": "1.0.0" } - }"#, - ) - .unwrap(); + write_frontend_package(&frontend_dir); let fake_bin = tempfile::tempdir().unwrap(); let fake_node = fake_bin.path().join("node"); - write_executable_script( - &fake_node, - r#"#!/bin/sh -if [ -x "node_modules/.bin/vitest" ]; then - exit 0 -fi -echo "missing frontend dependency readiness" >&2 -exit 1 -"#, - ); + write_readiness_probe_node(&fake_node); let global_marker = worktree_dir.path().join("global-vitest-used"); let global_vitest = fake_bin.path().join("vitest"); @@ -364,6 +372,189 @@ exit 1 })); } +#[tokio::test] +async fn frontend_validation_blocks_without_install_and_does_not_run_global_vitest() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + + let global_marker = worktree_dir.path().join("global-vitest-used"); + let global_vitest = fake_bin.path().join("vitest"); + write_executable_script( + &global_vitest, + &format!( + "#!/bin/sh\nprintf 'global-vitest %s\\n' \"$*\" > '{}'\nexit 0\n", + global_marker.display() + ), + ); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let path_value = format!("{}:/bin:/usr/bin", fake_bin.path().display()); + let _path = EnvVarGuard::set("PATH", std::ffi::OsString::from(path_value)); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest run"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-readiness-blocked-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend validation should produce a setup failure"); + + assert!(!result.all_passed); + assert_eq!(result.failures.len(), 1); + assert!( + result.failures[0] + .stderr + .contains("Frontend dependency setup failed before validation") + ); + assert!( + !global_marker.exists(), + "global/non-local vitest must not run when readiness fails" + ); + assert!(result.log.iter().any(|entry| { + entry.phase == "validate" + && entry.path == "frontend" + && entry.command == "./node_modules/.bin/vitest run" + && entry.status == "failed" + })); +} + +#[tokio::test] +async fn frontend_pre_execution_install_fails_when_readiness_still_missing() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": "frontend", + "label": "Frontend", + "install": "true", + "validate": [], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_pre_execution_setup( + &project, + &task, + worktree_dir.path(), + "frontend-readiness-install-failure-task", + None, + "pre_execution", + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend install should produce a setup result"); + + assert!(!result.success); + assert_eq!(result.log.len(), 1); + assert_eq!(result.log[0].phase, "install"); + assert_eq!(result.log[0].path, "frontend"); + assert_eq!(result.log[0].status, "failed"); + assert!(result.log[0] + .stderr + .contains("Frontend dependency setup failed after install")); +} + +#[tokio::test] +async fn frontend_pre_execution_install_skips_when_readiness_is_complete() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + std::fs::create_dir_all(frontend_dir.join("node_modules/.bin")).unwrap(); + write_executable_script( + &frontend_dir.join("node_modules/.bin/vitest"), + "#!/bin/sh\nexit 0\n", + ); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "install": "npm install", + "validate": [], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_pre_execution_setup( + &project, + &task, + worktree_dir.path(), + "frontend-readiness-install-skip-task", + None, + "pre_execution", + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend install should produce a setup result"); + + assert!(result.success); + assert_eq!(result.log.len(), 1); + assert_eq!(result.log[0].phase, "install"); + assert_eq!(result.log[0].path, "frontend"); + assert_eq!(result.log[0].status, "skipped"); + assert_eq!( + result.log[0].stderr, + "frontend dependencies are ready — install skipped" + ); +} + #[tokio::test] async fn run_validation_returns_none_for_invalid_json() { let mut project = make_project(Some("main")); From c4dc939939d0a68895cc989010023507e51941c6 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:23:17 +0300 Subject: [PATCH 3/6] Broaden frontend readiness patch coverage --- .../tests/merge_validation_tests.rs | 203 +++++++++++++++++- 1 file changed, 198 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs index 3a98afcc2f..2cbef11173 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs @@ -74,6 +74,14 @@ exit 1 ); } +fn write_ready_vitest(frontend_dir: &Path) { + std::fs::create_dir_all(frontend_dir.join("node_modules/.bin")).unwrap(); + write_executable_script( + &frontend_dir.join("node_modules/.bin/vitest"), + "#!/bin/sh\nexit 0\n", + ); +} + // ================== // run_validation_commands tests // ================== @@ -445,6 +453,195 @@ async fn frontend_validation_blocks_without_install_and_does_not_run_global_vite })); } +#[tokio::test] +async fn frontend_validation_warn_mode_continues_after_readiness_failure() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let path_value = format!("{}:/bin:/usr/bin", fake_bin.path().display()); + let _path = EnvVarGuard::set("PATH", std::ffi::OsString::from(path_value)); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest run"], + "worktree_setup": [] + },{ + "path": ".", + "label": "Shell", + "validate": ["true"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-readiness-warn-task", + None, + None, + &MergeValidationMode::Warn, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("warn-mode frontend validation should produce a result"); + + assert!(!result.all_passed); + assert_eq!(result.failures.len(), 1); + assert!(result.log.iter().any(|entry| { + entry.phase == "validate" + && entry.path == "frontend" + && entry.command == "./node_modules/.bin/vitest run" + && entry.status == "failed" + })); + assert!(result.log.iter().any(|entry| { + entry.phase == "validate" + && entry.path == "." + && entry.command == "true" + && entry.status == "success" + })); +} + +#[tokio::test] +async fn frontend_validation_uses_cached_sanitized_frontend_command() { + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest run vitest.config.ts"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + let cached = vec![ValidationLogEntry { + phase: "validate".to_string(), + command: "./node_modules/.bin/vitest run".to_string(), + path: "frontend".to_string(), + label: "Frontend".to_string(), + status: "success".to_string(), + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + duration_ms: 10, + ..Default::default() + }]; + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-readiness-cached-task", + None, + Some(&cached), + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("cached frontend validation should produce a result"); + + assert!(result.all_passed); + assert_eq!(result.log.len(), 1); + assert_eq!(result.log[0].command, "./node_modules/.bin/vitest run"); + assert_eq!(result.log[0].path, "frontend"); + assert_eq!(result.log[0].status, "cached"); +} + +#[tokio::test] +async fn frontend_validation_detects_frontend_stack_from_package_json() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let package_dir = worktree_dir.path().join("web"); + std::fs::create_dir_all(package_dir.join("node_modules")).unwrap(); + std::fs::write( + package_dir.join("package.json"), + r#"{ + "scripts": { "test": "vitest run" }, + "dependencies": { "react": "1.0.0" }, + "devDependencies": { "vitest": "1.0.0" } + }"#, + ) + .unwrap(); + write_ready_vitest(&package_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + let npm_marker = worktree_dir.path().join("npm-test-used"); + write_executable_script( + &fake_bin.path().join("npm"), + &format!( + "#!/bin/sh\nprintf 'npm %s\\n' \"$*\" > '{}'\nexit 0\n", + npm_marker.display() + ), + ); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let path_value = format!("{}:/bin:/usr/bin", fake_bin.path().display()); + let _path = EnvVarGuard::set("PATH", std::ffi::OsString::from(path_value)); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": "web", + "label": "Web", + "validate": ["npm test"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-stack-package-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("package-json frontend stack validation should produce a result"); + + assert!(result.all_passed); + assert!(npm_marker.exists()); + assert!(result.log.iter().any(|entry| { + entry.phase == "validate" + && entry.path == "web" + && entry.command == "npm test" + && entry.status == "success" + })); +} + #[tokio::test] async fn frontend_pre_execution_install_fails_when_readiness_still_missing() { let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX @@ -506,11 +703,7 @@ async fn frontend_pre_execution_install_skips_when_readiness_is_complete() { let worktree_dir = tempfile::tempdir().unwrap(); let frontend_dir = worktree_dir.path().join("frontend"); write_frontend_package(&frontend_dir); - std::fs::create_dir_all(frontend_dir.join("node_modules/.bin")).unwrap(); - write_executable_script( - &frontend_dir.join("node_modules/.bin/vitest"), - "#!/bin/sh\nexit 0\n", - ); + write_ready_vitest(&frontend_dir); let fake_bin = tempfile::tempdir().unwrap(); let fake_node = fake_bin.path().join("node"); From 41ca7c91f6252194552f0f066eb16e6b9e503128 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:42:52 +0300 Subject: [PATCH 4/6] Cover remaining frontend readiness branches --- .../tests/merge_validation_tests.rs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs index 2cbef11173..9266f5407b 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs @@ -642,6 +642,162 @@ async fn frontend_validation_detects_frontend_stack_from_package_json() { })); } +#[tokio::test] +async fn frontend_validation_runs_ready_local_frontend_command_shapes() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + + let vitest_marker = worktree_dir.path().join("local-vitest-used"); + std::fs::create_dir_all(frontend_dir.join("node_modules/.bin")).unwrap(); + write_executable_script( + &frontend_dir.join("node_modules/.bin/vitest"), + &format!( + "#!/bin/sh\nprintf 'vitest %s\\n' \"$*\" >> '{}'\nexit 0\n", + vitest_marker.display() + ), + ); + let npx_marker = worktree_dir.path().join("npx-used"); + write_executable_script( + &fake_bin.path().join("npx"), + &format!( + "#!/bin/sh\nprintf 'npx %s\\n' \"$*\" > '{}'\nexit 0\n", + npx_marker.display() + ), + ); + let npm_marker = worktree_dir.path().join("npm-used"); + write_executable_script( + &fake_bin.path().join("npm"), + &format!( + "#!/bin/sh\nprintf 'npm %s\\n' \"$*\" > '{}'\nexit 0\n", + npm_marker.display() + ), + ); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let path_value = format!("{}:/bin:/usr/bin", fake_bin.path().display()); + let _path = EnvVarGuard::set("PATH", std::ffi::OsString::from(path_value)); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest", "npx vitest run", "npm run typecheck"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-command-shapes-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("ready frontend validation should produce a result"); + + assert!(result.all_passed, "log={:?}", result.log); + assert!(vitest_marker.exists()); + assert!(npx_marker.exists()); + assert!(npm_marker.exists()); + assert_eq!( + result + .log + .iter() + .filter(|entry| entry.path == "frontend" && entry.status == "success") + .count(), + 3 + ); +} + +#[tokio::test] +async fn frontend_validation_reports_install_failure_before_running_vitest() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_readiness_probe_node(&fake_node); + let global_marker = worktree_dir.path().join("global-vitest-used"); + write_executable_script( + &fake_bin.path().join("vitest"), + &format!( + "#!/bin/sh\nprintf 'global-vitest %s\\n' \"$*\" > '{}'\nexit 0\n", + global_marker.display() + ), + ); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let path_value = format!("{}:/bin:/usr/bin", fake_bin.path().display()); + let _path = EnvVarGuard::set("PATH", std::ffi::OsString::from(path_value)); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "install": "false", + "validate": ["vitest run"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-install-failure-before-validation-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend install failure should produce a validation result"); + + assert!(!result.all_passed); + assert!( + !global_marker.exists(), + "validation command should not run after install/readiness failure" + ); + assert!(result.log.iter().any(|entry| { + entry.phase == "install" && entry.path == "frontend" && entry.status == "failed" + })); + assert!(result.log.iter().any(|entry| { + entry.phase == "validate" + && entry.path == "frontend" + && entry.command == "./node_modules/.bin/vitest run" + && entry.status == "failed" + && entry + .stderr + .contains("Frontend dependency setup failed before validation") + })); +} + #[tokio::test] async fn frontend_pre_execution_install_fails_when_readiness_still_missing() { let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX From 4e8713cac34bc4f04d4db05e0e4acc3f97550160 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:01:09 +0300 Subject: [PATCH 5/6] Cover frontend readiness probe failures --- .../tests/merge_validation_tests.rs | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs index 9266f5407b..1787f364cb 100644 --- a/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs +++ b/src-tauri/src/domain/state_machine/transition_handler/tests/merge_validation_tests.rs @@ -798,6 +798,149 @@ async fn frontend_validation_reports_install_failure_before_running_vitest() { })); } +#[tokio::test] +async fn frontend_validation_reports_stdout_only_readiness_probe_failure() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_executable_script( + &fake_node, + "#!/bin/sh\necho stdout-only-readiness-failure\nexit 1\n", + ); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest run"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-stdout-probe-failure-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend stdout-only readiness failure should produce a result"); + + assert!(!result.all_passed); + assert!(result.failures[0] + .stderr + .contains("stdout-only-readiness-failure")); +} + +#[tokio::test] +async fn frontend_validation_reports_cancelled_readiness_probe() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let fake_bin = tempfile::tempdir().unwrap(); + let fake_node = fake_bin.path().join("node"); + write_executable_script(&fake_node, "#!/bin/sh\nsleep 5\nexit 0\n"); + + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", fake_node.as_os_str()); + let cancel = tokio_util::sync::CancellationToken::new(); + cancel.cancel(); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest run"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-cancelled-probe-task", + None, + None, + &MergeValidationMode::Block, + &cancel, + ) + .await + .expect("frontend cancelled readiness failure should produce a result"); + + assert!(!result.all_passed); + assert!(result.failures[0].stderr.contains("cancelled")); +} + +#[tokio::test] +async fn frontend_validation_reports_node_probe_spawn_failure() { + let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX + .lock() + .expect("env mutex"); + let project_dir = tempfile::tempdir().unwrap(); + let worktree_dir = tempfile::tempdir().unwrap(); + let frontend_dir = worktree_dir.path().join("frontend"); + write_frontend_package(&frontend_dir); + + let missing_node = worktree_dir.path().join("missing-node"); + let _node_path = EnvVarGuard::set("RALPHX_NODE_PATH", missing_node.as_os_str()); + + let mut project = make_project(Some("main")); + project.working_directory = project_dir.path().to_string_lossy().to_string(); + project.detected_analysis = Some( + r#"[{ + "path": ".", + "label": "Frontend", + "validate": ["vitest run"], + "worktree_setup": [] + }]"# + .to_string(), + ); + let task = make_task(None, None); + + let result = run_validation_commands( + &project, + &task, + worktree_dir.path(), + "frontend-node-spawn-failure-task", + None, + None, + &MergeValidationMode::Block, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .expect("frontend node spawn readiness failure should produce a result"); + + assert!(!result.all_passed); + assert!(result.failures[0] + .stderr + .contains("could not start")); +} + #[tokio::test] async fn frontend_pre_execution_install_fails_when_readiness_still_missing() { let _env_lock = crate::infrastructure::tool_paths::TEST_ENV_MUTEX From cc5016b00a99fabf2f50d635edbce4b1e7e77735 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:55:46 +0300 Subject: [PATCH 6/6] Stabilize metrics IPC delivery trend contract --- src-tauri/tests/metrics_commands.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/metrics_commands.rs b/src-tauri/tests/metrics_commands.rs index a8c8985f55..6413bb24d6 100644 --- a/src-tauri/tests/metrics_commands.rs +++ b/src-tauri/tests/metrics_commands.rs @@ -199,6 +199,23 @@ async fn seed_command_metrics_rows(state: &AppState) -> (String, String) { )", rusqlite::params![p2], )?; + conn.execute( + "INSERT INTO agent_conversation_workspace_publication_events ( + id, conversation_id, step, status, summary, classification, created_at + ) + VALUES + ( + 'metrics-command-publication-1', 'metrics-command-workspace-1', + 'published', 'succeeded', 'Published PR #301', NULL, + '2026-06-18T10:30:00+00:00' + ), + ( + 'metrics-command-publication-2', 'metrics-command-workspace-2', + 'published', 'succeeded', 'Published PR #302', NULL, + '2026-06-18T12:15:00+00:00' + )", + [], + )?; Ok(()) }) .await @@ -250,7 +267,9 @@ async fn ipc_contract_insights_metric_commands_default_all_projects_and_filter_b assert_eq!( all_trends .weekly_delivery_throughput - .last() + .iter() + .rev() + .find(|point| point.unified_deliveries > 0) .map(|point| point.unified_deliveries), Some(3) ); @@ -265,7 +284,9 @@ async fn ipc_contract_insights_metric_commands_default_all_projects_and_filter_b assert_eq!( filtered_trends .weekly_delivery_throughput - .last() + .iter() + .rev() + .find(|point| point.unified_deliveries > 0) .map(|point| point.unified_deliveries), Some(2) );