diff --git a/Makefile b/Makefile index f42719c7..5fc44965 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ else ifeq ($(UNAME_S)-$(UNAME_M),Linux-aarch64) FORKPRESS_TARGET ?= aarch64-unknown-linux-musl endif -.PHONY: all clean test test-compat test-branchfs test-cow test-cow-branch-birth test-cow-branch-ui test-cow-changed test-cow-e2e-git-existing-update-crash test-cow-e2e-remote-cache test-cow-e2e-semantic test-cow-explicit-ids test-cow-fast test-cow-filesystem test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-mysql-import test-cow-plugin-validator test-cow-schema-review test-cow-semantic-fast test-cow-stale-audit test-cow-wp-semantic-validator test-branch-cli-fast test-release init-db test-all forkpress forkpress-dev dist dist-dev +.PHONY: all clean test test-compat test-branchfs test-cow test-cow-branch-birth test-cow-branch-ui test-cow-branch-boot-smoke test-cow-changed test-cow-e2e-git-existing-update-crash test-cow-e2e-remote-cache test-cow-e2e-semantic test-cow-explicit-ids test-cow-fast test-cow-filesystem test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-mysql-import test-cow-plugin-validator test-cow-schema-review test-cow-semantic-fast test-cow-stale-audit test-cow-wp-semantic-validator test-branch-cli-fast test-release init-db test-all forkpress forkpress-dev dist dist-dev all: $(BRANCHFS_EXT_SO) @@ -132,6 +132,9 @@ test-cow-branch-ui: php $(COW_TEST_DIR)/router_branch_birth_guard.php php $(COW_TEST_DIR)/router_lock.php +test-cow-branch-boot-smoke: + php $(COW_TEST_DIR)/wp_boot_smoke.php + test-cow-e2e-remote-cache: FORKPRESS_E2E_ONLY=remote-cache $(COW_TEST_DIR)/e2e.sh $(FORKPRESS_E2E_BIN) @@ -188,6 +191,7 @@ test-cow-fast: test-cow-git-server test-cow-merge-smoke test-cow-mysql-import php $(COW_TEST_DIR)/router_branch_birth_guard.php php $(COW_TEST_DIR)/router_paths.php php $(COW_TEST_DIR)/router_lock.php + php $(COW_TEST_DIR)/wp_boot_smoke.php test-cow: test-cow-fast php $(COW_TEST_DIR)/merge.php diff --git a/crates/forkpress-cli/build.rs b/crates/forkpress-cli/build.rs index 13afd0e1..07a3d400 100644 --- a/crates/forkpress-cli/build.rs +++ b/crates/forkpress-cli/build.rs @@ -73,6 +73,7 @@ fn main() -> Result<()> { "scripts/cow/merge.php", "scripts/cow/mysql_export.php", "scripts/cow/mysql_import_sqlite.php", + "scripts/cow/wp_boot_smoke.php", "scripts/git/autoload.php", "scripts/shared/sqlite_backup.php", "scripts/shared/sqlite_retry.php", @@ -470,6 +471,7 @@ fn build_bundle( add_file(&mut tar, repo_root, "scripts/cow/merge.php")?; add_file(&mut tar, repo_root, "scripts/cow/mysql_export.php")?; add_file(&mut tar, repo_root, "scripts/cow/mysql_import_sqlite.php")?; + add_file(&mut tar, repo_root, "scripts/cow/wp_boot_smoke.php")?; add_file(&mut tar, repo_root, "scripts/git/autoload.php")?; add_file(&mut tar, repo_root, "scripts/shared/sqlite_backup.php")?; add_file(&mut tar, repo_root, "scripts/shared/sqlite_retry.php")?; diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index 3f3f5bb5..6717e2bb 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -75,6 +75,7 @@ const RUNTIME_BUNDLE: &[u8] = include_bytes!(env!("FORKPRESS_RUNTIME_BUNDLE")); const RUNTIME_BUNDLE_ID: &str = env!("FORKPRESS_RUNTIME_BUNDLE_ID"); const FORKPRESS_COW_PARENT_LIFECYCLE_LOCK: &str = "FORKPRESS_COW_PARENT_LIFECYCLE_LOCK"; const REMOTE_CLONE_SSH_CONNECT_TIMEOUT_SECONDS: u16 = 10; +const REMOTE_CLONE_BOOT_DEPENDENCY_RETRIES: usize = 5; #[derive(Parser, Debug)] #[command( @@ -2926,27 +2927,69 @@ fn remote_clone_command( if cache.has_wp_load { "yes" } else { "no" } ); - if let Some(branch) = args.branch { + if let Some(branch) = args.branch.clone() { prepare_runtime(layout)?; let runtime = PortableRuntime::from_layout(layout); let _lock = lock_cow_operations(layout)?; ensure_cow_file_view_ready(layout)?; - let report = branch_remote_site( - layout, - &runtime, - shared, - RemoteBranchOptions { - remote: manifest.name.clone(), - branch, - replace_existing: args.force, - url_hint: branchctl_url_hint(layout).ok(), - }, - )?; - if report.replaced_existing { + let branch_existed_before_clone = cow_branch_exists(layout, &branch)?; + let url_hint = branchctl_url_hint(layout).ok(); + let mut replace_existing = args.force; + let mut fetched_boot_dependencies = Vec::new(); + let report = loop { + match branch_remote_site( + layout, + &runtime, + shared, + RemoteBranchOptions { + remote: manifest.name.clone(), + branch: branch.clone(), + replace_existing, + url_hint: url_hint.clone(), + }, + ) { + Ok(report) => break report, + Err(error) => { + let missing = remote_clone_missing_boot_dependency_from_error( + &error, layout, &branch, &args, + ); + let Some(missing) = missing else { + return Err(error); + }; + if fetched_boot_dependencies.contains(&missing) + || fetched_boot_dependencies.len() >= REMOTE_CLONE_BOOT_DEPENDENCY_RETRIES + { + return Err(error).with_context(|| { + format!( + "remote clone fetched boot dependency {} but the branch still did not boot", + missing.display() + ) + }); + } + println!(" boot dep: fetching {}", missing.display()); + remote_clone_fetch_boot_dependency(&args, &manifest.cache_root, &missing) + .with_context(|| { + format!( + "remote clone branch preview needed {}, but ForkPress could not fetch it from the remote", + missing.display() + ) + })?; + fetched_boot_dependencies.push(missing); + replace_existing = true; + } + } + }; + if report.replaced_existing && branch_existed_before_clone { println!( "forkpress: replaced existing branch '{}' from remote cache '{}'", report.branch, report.remote.name ); + } else if !fetched_boot_dependencies.is_empty() { + println!( + "forkpress: recreated branch '{}' after fetching {} boot dependency file(s)", + report.branch, + fetched_boot_dependencies.len() + ); } println!( "forkpress: remote cache '{}' branched to '{}'", @@ -3091,6 +3134,59 @@ fn remote_clone_rsync_args(args: &RemoteCloneArgs, cache_root: &Path) -> Vec Result<()> { + let rsync_args = remote_clone_boot_dependency_rsync_args(args, cache_root, relative_path); + let output = Command::new("rsync") + .args(&rsync_args) + .output() + .context("failed to start rsync while fetching a branch boot dependency")?; + if !output.status.success() { + bail!( + "rsync could not fetch {} from {} ({})\n\nrsync stderr:\n{}", + relative_path.display(), + remote_clone_rsync_source(&args.ssh, &args.remote_path), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + write_filtered_output(&output.stdout, &output.stderr)?; + Ok(()) +} + +fn remote_clone_boot_dependency_rsync_args( + args: &RemoteCloneArgs, + cache_root: &Path, + relative_path: &Path, +) -> Vec { + let mut out = vec![OsString::from("-azR")]; + if let Some(ssh_command) = remote_clone_rsync_ssh_command(args) { + out.push(OsString::from("-e")); + out.push(OsString::from(ssh_command)); + } + out.push(OsString::from(remote_clone_rsync_relative_source( + &args.ssh, + &args.remote_path, + relative_path, + ))); + out.push(cache_root.as_os_str().to_os_string()); + out +} + +fn remote_clone_rsync_relative_source( + ssh: &str, + remote_path: &str, + relative_path: &Path, +) -> String { + let mut path = remote_path.trim_end_matches('/').to_string(); + path.push_str("/./"); + path.push_str(&relative_path.to_string_lossy().replace('\\', "/")); + format!("{ssh}:{path}") +} + fn remote_clone_rsync_ssh_command(args: &RemoteCloneArgs) -> Option { if args.ssh_key.is_none() && args.ssh_port.is_none() { return None; @@ -3210,6 +3306,105 @@ fn remote_clone_default_excludes(include_uploads: bool) -> Vec<&'static str> { excludes } +fn remote_clone_missing_boot_dependency_from_error( + error: &anyhow::Error, + layout: &Layout, + branch: &str, + args: &RemoteCloneArgs, +) -> Option { + let mut message = String::new(); + for cause in error.chain() { + message.push_str(&cause.to_string()); + message.push('\n'); + } + remote_clone_missing_boot_dependency( + &message, + &cow_branch_root(layout, branch), + args.include_uploads, + args.full_sync, + ) +} + +fn remote_clone_missing_boot_dependency( + message: &str, + branch_root: &Path, + include_uploads: bool, + full_sync: bool, +) -> Option { + if full_sync { + return None; + } + + let mut quoted = false; + let mut current = String::new(); + for ch in message.chars() { + if ch == '\'' { + if quoted { + if let Some(relative) = + remote_clone_recoverable_boot_dependency(¤t, branch_root, include_uploads) + { + return Some(relative); + } + current.clear(); + } + quoted = !quoted; + } else if quoted { + current.push(ch); + } + } + None +} + +fn remote_clone_recoverable_boot_dependency( + candidate: &str, + branch_root: &Path, + include_uploads: bool, +) -> Option { + let path = Path::new(candidate); + let relative = path.strip_prefix(branch_root).ok()?; + if !remote_clone_relative_path_is_safe(relative) { + return None; + } + let relative_slash = relative.to_string_lossy().replace('\\', "/"); + if remote_clone_boot_dependency_prefixes(include_uploads) + .iter() + .any(|prefix| relative_slash.starts_with(prefix)) + { + Some(relative.to_path_buf()) + } else { + None + } +} + +fn remote_clone_relative_path_is_safe(path: &Path) -> bool { + let mut has_component = false; + for component in path.components() { + match component { + std::path::Component::Normal(_) => { + has_component = true; + } + _ => return false, + } + } + has_component +} + +fn remote_clone_boot_dependency_prefixes(include_uploads: bool) -> Vec<&'static str> { + let mut prefixes = vec![ + "wp-content/cache/", + "wp-content/upgrade/", + "wp-content/backups/", + "wp-content/backup-db/", + "wp-content/ai1wm-backups/", + "wp-content/updraft/", + "wp-content/wflogs/", + ]; + if !include_uploads { + prefixes.insert(0, "wp-content/uploads/"); + } + prefixes +} + fn remote_add_command(layout: &Layout, args: RemoteAddArgs) -> Result { let wp_cow_clone_root = match args.wp_cow_clone { Some(raw) => Some(resolve_wp_cow_clone_root( @@ -8490,6 +8685,86 @@ mod git_helper_tests { assert!(rsync.contains(&"deploy@example.com:/srv/www/example/".to_string())); } + #[test] + fn remote_clone_detects_missing_excluded_upload_boot_dependency() { + let branch_root = Path::new("/tmp/forkpress/site/feature"); + let message = "branch preview PHP exception while booting http://feature.wp.localhost:18080/: Failed opening required '/tmp/forkpress/site/feature/wp-content/uploads/forkpress-required/bootstrap.php' (include_path='.:') in /tmp/forkpress/site/feature/wp-content/plugins/forkpress-upload-loader/forkpress-upload-loader.php:5"; + + assert_eq!( + remote_clone_missing_boot_dependency(message, branch_root, false, false).as_deref(), + Some(Path::new( + "wp-content/uploads/forkpress-required/bootstrap.php" + )) + ); + assert_eq!( + remote_clone_missing_boot_dependency(message, branch_root, true, false), + None + ); + assert_eq!( + remote_clone_missing_boot_dependency(message, branch_root, false, true), + None + ); + } + + #[test] + fn remote_clone_missing_boot_dependency_ignores_unmanaged_paths() { + let branch_root = Path::new("/tmp/forkpress/site/feature"); + let plugin_message = "Failed opening required '/tmp/forkpress/site/feature/wp-content/plugins/plugin/vendor/autoload.php'"; + let outside_message = + "Failed opening required '/tmp/forkpress/site/other/wp-content/uploads/file.php'"; + + assert_eq!( + remote_clone_missing_boot_dependency(plugin_message, branch_root, false, false), + None + ); + assert_eq!( + remote_clone_missing_boot_dependency(outside_message, branch_root, false, false), + None + ); + } + + #[test] + fn remote_clone_boot_dependency_rsync_fetches_single_relative_path() { + let clone = RemoteCloneArgs { + name: "production".to_string(), + ssh: "deploy@example.com".to_string(), + ssh_key: Some(PathBuf::from("/Users/alex/.ssh/forkpress id")), + ssh_port: Some(2222), + remote_path: "/srv/www/example/".to_string(), + branch: Some("production-main".to_string()), + remote_url: Some("https://example.com".to_string()), + local_url: None, + include_uploads: false, + full_sync: false, + excludes: Vec::new(), + no_delete: false, + force: false, + }; + + let rsync = remote_clone_boot_dependency_rsync_args( + &clone, + Path::new("/tmp/cache"), + Path::new("wp-content/uploads/forkpress-required/bootstrap.php"), + ); + let rsync: Vec = rsync + .into_iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + + assert_eq!(rsync[0], "-azR"); + assert_eq!( + rsync.iter().position(|arg| arg == "-e").map(|index| { + rsync + .get(index + 1) + .expect("missing rsync ssh command after -e") + .as_str() + }), + Some("ssh -i '/Users/alex/.ssh/forkpress id' -p 2222 -o ConnectTimeout=10") + ); + assert!(rsync.contains(&"deploy@example.com:/srv/www/example/./wp-content/uploads/forkpress-required/bootstrap.php".to_string())); + assert!(rsync.contains(&"/tmp/cache".to_string())); + } + #[test] fn remote_clone_full_sync_disables_boot_excludes() { let cli = Cli::try_parse_from([ diff --git a/crates/forkpress-runtime/src/lib.rs b/crates/forkpress-runtime/src/lib.rs index 65f9905f..bc827fb1 100644 --- a/crates/forkpress-runtime/src/lib.rs +++ b/crates/forkpress-runtime/src/lib.rs @@ -160,7 +160,18 @@ where write_filtered_output(&output.stdout, &output.stderr)?; if !output.status.success() { - bail!("{script_rel} exited with status {}", output.status); + let stdout = output_excerpt(&String::from_utf8_lossy(&output.stdout)); + let stderr = output_excerpt(&filtered_stderr_text(&output.stderr)); + let mut message = format!("{script_rel} exited with status {}", output.status); + if !stdout.is_empty() { + message.push_str("\nstdout:\n"); + message.push_str(&stdout); + } + if !stderr.is_empty() { + message.push_str("\nstderr:\n"); + message.push_str(&stderr); + } + bail!("{message}"); } Ok(()) @@ -177,11 +188,30 @@ pub fn write_filtered_output(stdout: &[u8], stderr: &[u8]) -> Result<()> { let mut out = std::io::stdout().lock(); out.write_all(stdout)?; - let stderr_text = String::from_utf8_lossy(stderr); - for line in stderr_text.lines() { - if !line.contains(STARTUP_WARNING_FILTER) { - writeln!(std::io::stderr().lock(), "{line}")?; - } + let stderr_text = filtered_stderr_text(stderr); + if !stderr_text.is_empty() { + writeln!(std::io::stderr().lock(), "{stderr_text}")?; } Ok(()) } + +fn filtered_stderr_text(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .filter(|line| !line.contains(STARTUP_WARNING_FILTER)) + .collect::>() + .join("\n") +} + +fn output_excerpt(text: &str) -> String { + const LIMIT: usize = 4000; + let trimmed = text.trim(); + if trimmed.len() <= LIMIT { + return trimmed.to_string(); + } + let mut end = LIMIT; + while !trimmed.is_char_boundary(end) { + end -= 1; + } + format!("{}...\n[truncated]", &trimmed[..end]) +} diff --git a/crates/forkpress-storage/src/remote.rs b/crates/forkpress-storage/src/remote.rs index be880327..e30874ea 100644 --- a/crates/forkpress-storage/src/remote.rs +++ b/crates/forkpress-storage/src/remote.rs @@ -1,15 +1,16 @@ use anyhow::{Context, Result, anyhow, bail}; use forkpress_core::{Layout, SharedPaths, validate_branch_name}; -use forkpress_runtime::PortableRuntime; +use forkpress_runtime::{PortableRuntime, run_php_script}; use serde::{Deserialize, Serialize}; +use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ - cleanup_cow_branch_recreate_metadata, cow_branch_exists, create_cow_branch_from_external_tree, - delete_cow_branch, + cleanup_cow_branch_recreate_metadata, cow_branch_exists, cow_branch_root, + create_cow_branch_from_external_tree, delete_cow_branch, }; const REMOTE_SITE_MANIFEST_VERSION: u32 = 1; @@ -243,6 +244,8 @@ pub fn branch_remote_site( validate_branch_name(&options.branch)?; let manifest = read_remote_site_manifest(layout, &options.remote)?; let cache = remote_site_cache_stats(&manifest)?; + let branch = options.branch.clone(); + let url_hint = options.url_hint.clone(); if !cache.has_wp_load { bail!( "remote cache for '{}' is not a materialized WordPress root: {}", @@ -250,42 +253,93 @@ pub fn branch_remote_site( cache.cache_root.display() ); } - let replaced_existing = cow_branch_exists(layout, &options.branch)?; + let replaced_existing = cow_branch_exists(layout, &branch)?; if replaced_existing { if !options.replace_existing { bail!( "branch already exists: {}. Open the existing branch preview or pass --force to replace it from remote cache '{}'.", - options.branch, + branch, manifest.name ); } - delete_cow_branch(layout, &options.branch)?; - cleanup_cow_branch_recreate_metadata(layout, runtime, shared, &options.branch) - .with_context(|| { + delete_cow_branch(layout, &branch)?; + cleanup_cow_branch_recreate_metadata(layout, runtime, shared, &branch).with_context( + || { format!( "failed to clean merge metadata before recreating branch '{}'", - options.branch + branch ) - })?; + }, + )?; } create_cow_branch_from_external_tree( layout, runtime, shared, - &options.branch, + &branch, &manifest.cache_root, &format!("remote site '{}'", manifest.name), None, - options.url_hint, + url_hint.clone(), )?; + smoke_test_remote_branch_preview(layout, runtime, shared, &branch, url_hint.as_ref()) + .with_context(|| { + format!( + "remote cache '{}' was branched to '{}', but the branch homepage did not boot. The branch was left in place for inspection; retry with --force after fixing the reported error.", + manifest.name, branch + ) + })?; Ok(RemoteBranchReport { remote: manifest, - branch: options.branch, + branch, cache, replaced_existing, }) } +fn smoke_test_remote_branch_preview( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + branch: &str, + url_hint: Option<&(String, String)>, +) -> Result<()> { + let branch_root = cow_branch_root(layout, branch); + let host = remote_branch_preview_host(branch, url_hint); + let args = vec![ + branch_root.as_os_str().to_os_string(), + OsString::from(host), + layout.debug_log.as_os_str().to_os_string(), + OsString::from(branch), + ]; + run_php_script( + layout, + runtime, + shared, + "scripts/cow/wp_boot_smoke.php", + args, + ) +} + +fn remote_branch_preview_host(branch: &str, url_hint: Option<&(String, String)>) -> String { + if let Some((root_host, port)) = url_hint { + let host = if branch == "main" { + root_host.clone() + } else { + format!("{branch}.{root_host}") + }; + if port.is_empty() { + host + } else { + format!("{host}:{port}") + } + } else if branch == "main" { + "wp.localhost:18080".to_string() + } else { + format!("{branch}.wp.localhost:18080") + } +} + fn remote_sites_dir(layout: &Layout) -> PathBuf { layout.cow_dir.join("remote-sites") } @@ -385,6 +439,24 @@ mod tests { assert_eq!(sanitize_remote_site_name("...Cow!!!"), "cow"); } + #[test] + fn remote_branch_preview_host_uses_branch_subdomain() { + assert_eq!( + remote_branch_preview_host( + "production-main", + Some(&("wp.localhost".to_string(), "18080".to_string())) + ), + "production-main.wp.localhost:18080" + ); + assert_eq!( + remote_branch_preview_host( + "main", + Some(&("wp.localhost".to_string(), "18080".to_string())) + ), + "wp.localhost:18080" + ); + } + #[test] fn registering_wp_cow_clone_reuses_mirror_cache() { let root = std::env::temp_dir().join(format!( diff --git a/scripts/cow/wp_boot_smoke.php b/scripts/cow/wp_boot_smoke.php new file mode 100644 index 00000000..772272a3 --- /dev/null +++ b/scripts/cow/wp_boot_smoke.php @@ -0,0 +1,136 @@ + [branch] + */ + +$branch_root = rtrim((string)($argv[1] ?? ''), "/\\"); +$host = (string)($argv[2] ?? 'wp.localhost:18080'); +$debug_log = (string)($argv[3] ?? ''); +$branch = (string)($argv[4] ?? ''); + +function forkpress_boot_smoke_write_log(string $debug_log, string $message): void { + if ($debug_log === '') { + return; + } + @file_put_contents($debug_log, $message . "\n", FILE_APPEND); +} + +function forkpress_boot_smoke_fail(string $message, string $debug_log = '', int $status = 1): void { + $line = 'forkpress: ' . $message; + fwrite(STDERR, $line . "\n"); + forkpress_boot_smoke_write_log($debug_log, '[' . date('c') . '] ' . $line); + exit($status); +} + +if ($branch_root === '' || !is_dir($branch_root)) { + forkpress_boot_smoke_fail("branch root missing or not a directory: $branch_root", $debug_log); +} +if (!is_file($branch_root . '/index.php') || !is_file($branch_root . '/wp-load.php')) { + forkpress_boot_smoke_fail("branch root is not a WordPress tree: $branch_root", $debug_log); +} +if ($host === '') { + forkpress_boot_smoke_fail('preview host must not be empty', $debug_log); +} + +if ($debug_log !== '') { + @ini_set('log_errors', '1'); + @ini_set('error_log', $debug_log); +} +@ini_set('display_errors', 'stderr'); +@set_time_limit(20); +error_reporting(E_ALL); + +$host_without_port = $host; +$port = '80'; +if (preg_match('/^(.+):([0-9]+)$/', $host, $matches)) { + $host_without_port = $matches[1]; + $port = $matches[2]; +} + +if ($branch !== '') { + $_SERVER['FORKPRESS_BRANCH'] = $branch; + putenv('FORKPRESS_BRANCH=' . $branch); +} + +$_SERVER = array_merge($_SERVER ?? [], [ + 'HTTP_HOST' => $host, + 'REQUEST_URI' => '/', + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => $host_without_port, + 'SERVER_PORT' => $port, + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'DOCUMENT_ROOT' => $branch_root, + 'SCRIPT_FILENAME' => $branch_root . '/index.php', + 'SCRIPT_NAME' => '/index.php', + 'PHP_SELF' => '/index.php', + 'REMOTE_ADDR' => '127.0.0.1', +]); + +register_shutdown_function(static function () use ($debug_log, $host): void { + $error = error_get_last(); + if (!is_array($error)) { + return; + } + $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; + if (!in_array((int)($error['type'] ?? 0), $fatal_types, true)) { + return; + } + $message = sprintf( + "forkpress: branch preview PHP fatal while booting http://%s/: %s in %s:%s", + $host, + (string)($error['message'] ?? 'unknown fatal error'), + (string)($error['file'] ?? 'unknown file'), + (string)($error['line'] ?? 'unknown line') + ); + fwrite(STDERR, $message . "\n"); + forkpress_boot_smoke_write_log($debug_log, '[' . date('c') . '] ' . $message); +}); + +$previous_cwd = getcwd(); +if ($previous_cwd === false || !chdir($branch_root)) { + forkpress_boot_smoke_fail("failed to enter branch root: $branch_root", $debug_log); +} + +ob_start(); +try { + require $branch_root . '/index.php'; + $body = (string)ob_get_clean(); +} catch (Throwable $throwable) { + if (ob_get_level() > 0) { + ob_end_clean(); + } + forkpress_boot_smoke_fail( + sprintf( + 'branch preview PHP exception while booting http://%s/: %s in %s:%s', + $host, + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine() + ), + $debug_log + ); +} finally { + if ($previous_cwd !== false) { + @chdir($previous_cwd); + } +} + +if (stripos($body, 'There has been a critical error on this website') !== false) { + $excerpt = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?? ''); + $message = "branch preview returned WordPress' critical-error page for http://$host/."; + if ($debug_log !== '') { + $message .= " Check $debug_log for the PHP fatal."; + } + if ($excerpt !== '') { + $message .= ' Page excerpt: ' . substr($excerpt, 0, 300); + } + forkpress_boot_smoke_fail($message, $debug_log); +} + +echo " preview: booted http://$host/\n"; diff --git a/tests/cow/wp_boot_smoke.php b/tests/cow/wp_boot_smoke.php new file mode 100644 index 00000000..c3fca15a --- /dev/null +++ b/tests/cow/wp_boot_smoke.php @@ -0,0 +1,107 @@ +isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($path); +} + +function make_root(string $base, string $name, string $index_php): string { + $root = $base . '/' . $name; + if (!mkdir($root, 0755, true) && !is_dir($root)) { + throw new RuntimeException("failed to create $root"); + } + file_put_contents($root . '/wp-load.php', " ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($proc)) { + throw new RuntimeException('failed to start smoke process'); + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($proc); + return [$status, (string)$stdout, (string)$stderr]; +} + +function assert_contains(string $haystack, string $needle, string $label): void { + if (!str_contains($haystack, $needle)) { + throw new RuntimeException("$label missing '$needle' in:\n$haystack"); + } +} + +try { + $debug_log = $tmp . '/debug.log'; + + $ok_root = make_root($tmp, 'ok', "ok'; if (getenv('FORKPRESS_BRANCH') !== 'feature') { throw new RuntimeException('missing branch env'); }\n"); + [$status, $stdout, $stderr] = run_smoke($ok_root, $debug_log); + if ($status !== 0) { + throw new RuntimeException("ok smoke failed with $status\nSTDOUT:\n$stdout\nSTDERR:\n$stderr"); + } + assert_contains($stdout, 'preview: booted http://feature.wp.localhost:18080/', 'ok stdout'); + + $critical_root = make_root($tmp, 'critical', "There has been a critical error on this website.';\n"); + [$status, $stdout, $stderr] = run_smoke($critical_root, $debug_log); + if ($status === 0) { + throw new RuntimeException("critical page smoke unexpectedly passed\nSTDOUT:\n$stdout\nSTDERR:\n$stderr"); + } + assert_contains($stderr, "WordPress' critical-error page", 'critical stderr'); + + $exception_root = make_root($tmp, 'exception', "getMessage() . "\n"); + exit(1); +} + +cleanup($tmp); +echo "wp_boot_smoke.php passed\n";