diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11aab5e5..cd917cbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,9 @@ env: jobs: contract: - # Every PR gets this cheap contract lane, including owner-authored PRs that - # have not opted into the heavier `run-ci` jobs below. Keep it limited to - # deterministic, in-process/static checks so feedback stays fast. - if: github.event_name == 'pull_request' + # Mandatory first lane for every workflow invocation: pull requests and + # pushes to main. Owner-authored PRs do not need `run-ci` for this cheap gate. + # Keep it limited to deterministic, in-process/static checks so feedback stays fast. runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -49,6 +48,16 @@ jobs: bash scripts/workspace_boundary_check.sh - name: Check formatting run: cargo fmt --all -- --check + - name: Ensure test inventory dependency + run: | + if ! command -v rg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install --no-install-recommends -y ripgrep + fi + - name: Check test inventory contract + run: | + bash scripts/test_inventory.sh --self-test + bash scripts/test_inventory.sh - name: Check schema and metadata contracts run: | cargo test --locked -p webcodex --lib -- \ @@ -60,43 +69,57 @@ jobs: explicit_resume_mcp_schema_and_metadata_are_exposed \ http_project_connector_lists_and_dispatches_only_canonical_capabilities - test: - # External PRs run automatically (subject to GitHub's normal fork approval - # protections). PRs authored by the repository owner are intentionally - # opt-in: adding the `run-ci` label triggers this workflow again and enables - # the heavy jobs. Removing the label also cancels an in-progress PR run via - # the concurrency group above. Pushes to main always run. + test-linux-rust: + needs: contract if: >- github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner || contains(github.event.pull_request.labels.*.name, 'run-ci') runs-on: ubuntu-latest timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - shard: server + packages: "-p webcodex" + - shard: runner + packages: "-p webcodex-runner" + - shard: workspace-crates + packages: >- + -p webcodex-admin + -p webcodex-agent-config + -p webcodex-core + -p webcodex-cli + -p webcodex-persistent-shell + -p webcodex-sandbox + -p webcodex-workspace + -p webcodex-process steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - uses: Swatinem/rust-cache@v2 with: shared-key: linux-ci + - name: Run ${{ matrix.shard }} Rust tests + run: cargo test --locked ${{ matrix.packages }} + + test-linux-tooling: + needs: contract + if: >- + github.event_name == 'push' || + github.event.pull_request.user.login != github.repository_owner || + contains(github.event.pull_request.labels.*.name, 'run-ci') + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + # The retained npm release-smoke tooling below invokes node/npm directly. + # Frontend dependency installation and frontend-specific npm caching remain + # contract-lane responsibilities and are intentionally not repeated here. - uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - cache-dependency-path: frontend/package-lock.json - - name: Install frontend dependencies - run: npm ci --prefix frontend - - name: Type-check frontend - run: npm --prefix frontend run typecheck - - name: Test frontend - run: npm --prefix frontend test - - name: Verify committed frontend build - run: npm --prefix frontend run check:dist - - name: Verify workspace boundaries - run: | - bash scripts/workspace_boundary_check.sh --self-test - bash scripts/workspace_boundary_check.sh - name: Test release verification tooling run: | python3 -m unittest scripts.tests.test_verify_public_release scripts.tests.test_collect_release_bundle scripts.tests.test_release_readiness scripts.tests.test_release_publication scripts.tests.test_check_markdown_links @@ -114,12 +137,38 @@ jobs: bash -n scripts/npm_package_smoke.sh scripts/tests/test_npm_package_smoke_existing_binaries.sh bash scripts/npm_package_smoke.sh --help >/dev/null bash scripts/tests/test_npm_package_smoke_existing_binaries.sh - - name: Check formatting - run: cargo fmt --all -- --check - - name: Run tests - run: cargo test --locked --workspace + + test: + needs: [contract, test-linux-rust, test-linux-tooling] + # Preserve the historical aggregate status check. Eligible heavy runs always + # evaluate upstream results so a failed or unexpectedly skipped Linux lane + # cannot turn this required status into a successful or skipped check. + if: >- + always() && + ( + github.event_name == 'push' || + github.event.pull_request.user.login != github.repository_owner || + contains(github.event.pull_request.labels.*.name, 'run-ci') + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require successful Linux lanes + env: + CONTRACT_RESULT: ${{ needs.contract.result }} + RUST_RESULT: ${{ needs.test-linux-rust.result }} + TOOLING_RESULT: ${{ needs.test-linux-tooling.result }} + run: | + echo "contract=$CONTRACT_RESULT rust=$RUST_RESULT tooling=$TOOLING_RESULT" + if [ "$CONTRACT_RESULT" != success ] || \ + [ "$RUST_RESULT" != success ] || \ + [ "$TOOLING_RESULT" != success ]; then + echo "required Linux CI lane did not succeed" >&2 + exit 1 + fi test-macos: + needs: contract if: >- github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner || @@ -131,19 +180,16 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - uses: Swatinem/rust-cache@v2 with: shared-key: macos-ci - - name: Check formatting - run: cargo fmt --all -- --check - name: Check macOS release production surfaces run: cargo check --locked -p webcodex -p webcodex-cli -p webcodex-runner - name: Run native macOS Runner tests run: cargo test --locked -p webcodex-runner test-windows: + needs: contract if: >- github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner || @@ -158,14 +204,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 with: node-version: 22 - - name: Check formatting - run: cargo fmt --all -- --check - name: Run Windows library and CLI tests run: cargo test --locked -p webcodex-agent-config -p webcodex-process -p webcodex-persistent-shell -p webcodex-cli - name: Run Windows Runner tests diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index 71440a76..b53e3fbe 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -8,6 +8,12 @@ use crate::webcodex_runner::{ }; pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { + TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// RAII restore for environment variables mutated by tests: restores the /// previous value (or absence) on drop, even when the test panics, so a /// failure cannot leak env state into later tests. @@ -22,9 +28,9 @@ impl EnvGuard { } } - pub(crate) fn set(mut self, name: &'static str, value: &str) -> Self { + pub(crate) fn set(mut self, name: &'static str, value: impl AsRef) -> Self { self.capture(name); - std::env::set_var(name, value); + std::env::set_var(name, value.as_ref()); self } @@ -1016,7 +1022,7 @@ fn shell_job_native_exe_nonzero_exit_code_is_preserved() { #[cfg(windows)] #[test] fn shell_job_unicode_stdout_stderr_env_and_cwd() { - let _guard = TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); let tmp = tempfile::tempdir().unwrap(); let cfg = test_config(tmp.path().join("config/projects.d")); let unicode_cwd = tmp.path().join("unicode cwd 测试"); @@ -1040,8 +1046,7 @@ fn shell_job_unicode_stdout_stderr_env_and_cwd() { assert_eq!(result.stderr.as_deref(), Some("err 測試")); // Unicode environment value inherited from the parent process. - let saved = std::env::var_os("WEBCODEX_UNICODE_ENV"); - std::env::set_var("WEBCODEX_UNICODE_ENV", "值 测试"); + let _env = EnvGuard::new().set("WEBCODEX_UNICODE_ENV", "值 测试"); let result = run_shell( &cfg.policy, &ShellConfig::default(), @@ -1051,10 +1056,6 @@ fn shell_job_unicode_stdout_stderr_env_and_cwd() { 10, None, ); - match saved { - Some(value) => std::env::set_var("WEBCODEX_UNICODE_ENV", value), - None => std::env::remove_var("WEBCODEX_UNICODE_ENV"), - } assert_eq!(result.exit_code, Some(0), "{result:?}"); assert_eq!(result.stdout.as_deref(), Some("值 测试")); diff --git a/crates/webcodex-runner/src/main_tests/agent_config.rs b/crates/webcodex-runner/src/main_tests/agent_config.rs index 4f36a872..0121c319 100644 --- a/crates/webcodex-runner/src/main_tests/agent_config.rs +++ b/crates/webcodex-runner/src/main_tests/agent_config.rs @@ -34,6 +34,7 @@ fn agent_config_rejects_zero_websocket_connect_timeout() { server_url = "http://127.0.0.1:8000" token = "t" client_id = "oe" +projects_dir = "projects.d" websocket_connect_timeout_secs = 0 "#, ) @@ -56,6 +57,7 @@ fn agent_config_rejects_relative_temporary_projects_root() { server_url = "http://127.0.0.1:8000" token = "t" client_id = "oe" +projects_dir = "projects.d" temporary_projects_root = "temporary" "#, ) @@ -196,6 +198,7 @@ fn quic_client_bind_addr_matches_remote_address_family() { #[test] fn agent_cli_help_and_version_exit_before_runtime() { + let _guard = test_env_lock(); match parse_agent_args(["--help"]).unwrap() { AgentCliAction::Exit { code, @@ -233,12 +236,14 @@ fn agent_cli_help_and_version_exit_before_runtime() { #[test] fn agent_cli_has_no_init_alias() { + let _guard = test_env_lock(); let error = parse_agent_args(["init"]).unwrap_err(); assert!(error.contains("unknown argument: init")); } #[test] fn agent_version_output_includes_build_metadata() { + let _guard = test_env_lock(); match parse_agent_args(["-V"]).unwrap() { AgentCliAction::Exit { code, @@ -256,6 +261,7 @@ fn agent_version_output_includes_build_metadata() { #[test] fn agent_cli_legacy_runtime_args_are_preserved() { + let _guard = test_env_lock(); let action = parse_agent_args(["--config", "/tmp/agent.toml", "--once"]).unwrap(); assert_eq!( action, @@ -268,6 +274,7 @@ fn agent_cli_legacy_runtime_args_are_preserved() { #[test] fn agent_cli_profile_derives_default_config_path() { + let _guard = test_env_lock(); let action = parse_agent_args(["--profile", "special"]).unwrap(); assert_eq!( action, @@ -280,6 +287,7 @@ fn agent_cli_profile_derives_default_config_path() { #[test] fn agent_cli_explicit_config_overrides_profile() { + let _guard = test_env_lock(); let action = parse_agent_args(["--profile", "special", "--config", "/tmp/agent.toml"]).unwrap(); assert_eq!( action, @@ -292,6 +300,7 @@ fn agent_cli_explicit_config_overrides_profile() { #[test] fn agent_cli_rejects_unsafe_profile() { + let _guard = test_env_lock(); let err = parse_agent_args(["--profile", "../x"]).unwrap_err(); assert_eq!(err, CLIENT_PROFILE_ERROR); } @@ -304,7 +313,7 @@ fn empty_tokens_config_parser_accepts_empty_and_whitespace_token() { std::fs::write( &path, format!( - "server_url = \"http://127.0.0.1:8000\"\ntoken = \"{}\"\nclient_id = \"open-agent\"\n[policy]\nallow_cwd_anywhere = true\n", + "server_url = \"http://127.0.0.1:8000\"\ntoken = \"{}\"\nclient_id = \"open-agent\"\nprojects_dir = \"projects.d\"\n[policy]\nallow_cwd_anywhere = true\nallowed_roots = [\".\"]\n", token ), ) @@ -335,6 +344,7 @@ service = "Use the ordinary host-local service mechanism." [policy] allow_cwd_anywhere = true +allowed_roots = ["."] "#, ) .unwrap(); @@ -376,9 +386,11 @@ fn agent_config_without_shell_section_parses() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] "#, ) .unwrap(); @@ -426,6 +438,10 @@ fn agent_config_loads_named_ssh_resources_without_authentication_material() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" + +[policy] +allowed_roots = ["."] [ssh.resources.tmp] host = "tmp" @@ -460,9 +476,11 @@ fn agent_config_shell_profiles_parse() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] [shell] default_profile = "rust" @@ -519,9 +537,11 @@ fn agent_config_shell_default_profile_must_exist() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] [shell] default_profile = "missing" @@ -547,9 +567,11 @@ fn agent_config_shell_profile_name_must_be_safe() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] [shell.profiles."bad/name"] program = "sh" @@ -572,6 +594,7 @@ fn agent_config_shell_profile_type_errors_are_reported() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true @@ -597,6 +620,7 @@ fn agent_config_shell_profile_env_type_errors_are_reported() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true @@ -624,9 +648,11 @@ fn agent_config_shell_errors_do_not_include_init_script_body() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] [shell] default_profile = "missing" @@ -781,6 +807,10 @@ fn agent_config_accepts_static_literal_mcp_gateway_provider() { server_url = "http://127.0.0.1:8000" token = "t" client_id = "oe" +projects_dir = "projects.d" + +[policy] +allowed_roots = ["."] [mcp] request_timeout_secs = 7 @@ -831,6 +861,10 @@ fn agent_config_mcp_gateway_provider_timeout_defaults_to_gateway_timeout() { server_url = "http://127.0.0.1:8000" token = "t" client_id = "oe" +projects_dir = "projects.d" + +[policy] +allowed_roots = ["."] [mcp] request_timeout_secs = 11 @@ -901,6 +935,10 @@ executable = {executable} server_url = "http://127.0.0.1:8000" token = "t" client_id = "oe" +projects_dir = "projects.d" + +[policy] +allowed_roots = ["."] [mcp] request_timeout_secs = 30 diff --git a/crates/webcodex-runner/src/main_tests/project_policy.rs b/crates/webcodex-runner/src/main_tests/project_policy.rs index 0204ad86..1bd668a2 100644 --- a/crates/webcodex-runner/src/main_tests/project_policy.rs +++ b/crates/webcodex-runner/src/main_tests/project_policy.rs @@ -59,14 +59,14 @@ fn register_project_rejects_dangerous_subpaths_without_explicit_root() { #[test] fn load_config_defaults_empty_allowed_roots_to_home() { - let _guard = agent_init::TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); let home = std::env::var_os("HOME").map(PathBuf::from); if let Some(home) = home { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("agent.toml"); std::fs::write( &path, - "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\n", + "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\nprojects_dir = \"projects.d\"\n", ) .unwrap(); let cfg = load_config(&path).unwrap(); @@ -79,15 +79,11 @@ fn load_config_defaults_empty_allowed_roots_to_home() { } #[test] -fn load_config_defaults_allow_cwd_anywhere_to_false() { - let _guard = agent_init::TEST_ENV_LOCK.lock().unwrap(); - let tmp = tempfile::tempdir().unwrap(); - let base = "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\n"; +fn agent_config_defaults_allow_cwd_anywhere_to_false() { + let base = "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\nprojects_dir = \"projects.d\"\n"; - // A config that omits `[policy]` entirely falls back to - // `AgentPolicy::default()`; one that has `[policy]` without the field - // falls back to the per-field serde default. Both must fail closed — - // otherwise the agent runs with no filesystem boundary at all. + // This is a serde/default-policy invariant, not a per-user path test. Parse + // the fixture directly so it cannot observe ambient HOME/USERPROFILE. for (label, body) in [ ("no [policy] section", base.to_string()), ( @@ -95,9 +91,7 @@ fn load_config_defaults_allow_cwd_anywhere_to_false() { format!("{base}\n[policy]\nallow_raw_shell = true\n"), ), ] { - let path = tmp.path().join("agent.toml"); - std::fs::write(&path, body).unwrap(); - let cfg = load_config(&path).unwrap(); + let cfg: AgentConfig = toml::from_str(&body).unwrap(); assert!( !cfg.policy.allow_cwd_anywhere, "{label}: allow_cwd_anywhere must default to false" @@ -132,12 +126,12 @@ fn default_policy_denies_paths_outside_allowed_roots() { #[test] fn load_config_explicit_allowed_roots_override_home_default() { - let _guard = agent_init::TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("agent.toml"); std::fs::write( &path, - "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\n[policy]\nallowed_roots = [\"/root/git\"]\n", + "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\nprojects_dir = \"projects.d\"\n[policy]\nallowed_roots = [\"/root/git\"]\n", ) .unwrap(); let cfg = load_config(&path).unwrap(); @@ -150,7 +144,7 @@ fn load_config_explicit_allowed_roots_override_home_default() { #[test] fn load_config_empty_roots_without_home_and_no_cwd_anywhere_errors() { - let _guard = agent_init::TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); // Windows derives the allowed-root default from USERPROFILE, so both // home sources must be absent to exercise the fail-closed branch. let _env = EnvGuard::new() @@ -161,7 +155,7 @@ fn load_config_empty_roots_without_home_and_no_cwd_anywhere_errors() { let path = tmp.path().join("agent.toml"); std::fs::write( &path, - "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\n\ + "server_url = \"http://x\"\ntoken = \"t\"\nclient_id = \"c\"\nprojects_dir = \"projects.d\"\n\ [policy]\nallow_cwd_anywhere = false\n", ) .unwrap(); diff --git a/crates/webcodex-runner/src/main_tests/shell_config.rs b/crates/webcodex-runner/src/main_tests/shell_config.rs index c8ad94cc..b553e712 100644 --- a/crates/webcodex-runner/src/main_tests/shell_config.rs +++ b/crates/webcodex-runner/src/main_tests/shell_config.rs @@ -173,9 +173,11 @@ fn shell_config_dialect_field_parses_and_validates() { server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] [shell] dialect = "powershell" @@ -201,9 +203,11 @@ args = ["-c"] server_url = "http://127.0.0.1:8000" token = "test-token" client_id = "agent-1" +projects_dir = "projects.d" [policy] allow_cwd_anywhere = true +allowed_roots = ["."] [shell] dialect = "cmd" @@ -217,7 +221,7 @@ dialect = "cmd" #[test] fn shell_config_default_environment_is_inherited() { - let _guard = TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); let tmp = tempfile::tempdir().unwrap(); let cfg = test_config(tmp.path().join("config/projects.d")); let cwd = tmp.path().to_string_lossy().to_string(); diff --git a/crates/webcodex-runner/src/main_tests/shell_job_execution.rs b/crates/webcodex-runner/src/main_tests/shell_job_execution.rs index 3138907b..e0dd5991 100644 --- a/crates/webcodex-runner/src/main_tests/shell_job_execution.rs +++ b/crates/webcodex-runner/src/main_tests/shell_job_execution.rs @@ -3,7 +3,7 @@ use super::*; #[cfg(windows)] #[test] fn shell_job_filters_sensitive_env_case_insensitive() { - let _guard = TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); let tmp = tempfile::tempdir().unwrap(); let cfg = test_config(tmp.path().join("config/projects.d")); let cwd = tmp.path().to_string_lossy().to_string(); diff --git a/crates/webcodex-runner/src/main_tests/shell_profiles.rs b/crates/webcodex-runner/src/main_tests/shell_profiles.rs index 56a6fbe4..aaa486a1 100644 --- a/crates/webcodex-runner/src/main_tests/shell_profiles.rs +++ b/crates/webcodex-runner/src/main_tests/shell_profiles.rs @@ -478,7 +478,7 @@ fn prepared_profile_errors_do_not_leak_init_script_body() { #[test] fn prepared_profile_filters_webcodex_token_env() { - let _guard = TEST_ENV_LOCK.lock().unwrap(); + let _guard = test_env_lock(); let tmp = tempfile::tempdir().unwrap(); let shell = shell_with_profiles(Some("test"), vec![("test", ShellProfileConfig::default())]); // Windows environment names are case-insensitive, so mixed-case spellings @@ -489,8 +489,7 @@ fn prepared_profile_filters_webcodex_token_env() { #[cfg(not(windows))] let spellings = ["WEBCODEX_TOKEN"]; for spelling in spellings { - let saved = std::env::var_os(spelling); - std::env::set_var(spelling, "secret-token"); + let _env = EnvGuard::new().set(spelling, "secret-token"); let result = run_profile_shell( &unrestricted_test_policy(), &shell, @@ -499,10 +498,6 @@ fn prepared_profile_filters_webcodex_token_env() { tmp.path(), &shell_if_else_env_present(spelling), ); - match saved { - Some(value) => std::env::set_var(spelling, value), - None => std::env::remove_var(spelling), - } assert_eq!(result.exit_code, Some(0), "{result:?}"); assert_eq!(result.stdout.as_deref(), Some("absent"), "{result:?}"); } diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs index 924bd796..40509d94 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs @@ -7,7 +7,7 @@ use crate::webcodex_runner::detached_job::{ use serde_json::json; use std::ffi::OsString; use std::io::BufReader; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; use tempfile::TempDir; @@ -151,6 +151,7 @@ fn job_reconciliation_local_snapshot_advances_before_best_effort_send() { ); let (tx, mut rx) = tokio::sync::mpsc::channel(4); + tx.try_send(AgentEnvelope::Ping { ts: 17 }).unwrap(); manager.install_sink(AgentSink::WebSocket { tx, client_id: "test-agent".to_string(), @@ -575,11 +576,11 @@ fn detached_recovery_stop_uses_durable_control_without_managed_child() { )], ); let _ = handoff_detached_job(&store, request.clone()).unwrap(); - assert!(wait_until(Duration::from_secs(5), || pid_marker.exists())); - let payload_pid: u32 = std::fs::read_to_string(&pid_marker) - .unwrap() - .parse() - .unwrap(); + let payload_pid = wait_for_pid_marker( + &pid_marker, + Instant::now() + Duration::from_secs(5), + "detached recovery payload", + ); assert!(process_running(payload_pid)); let manager = JobManager::new(1); @@ -618,11 +619,11 @@ fn detached_recovery_observer_start_failure_retains_durable_control_and_stop_rou )], ); let _ = handoff_detached_job(&store, request.clone()).unwrap(); - assert!(wait_until(Duration::from_secs(5), || pid_marker.exists())); - let payload_pid: u32 = std::fs::read_to_string(&pid_marker) - .unwrap() - .parse() - .unwrap(); + let payload_pid = wait_for_pid_marker( + &pid_marker, + Instant::now() + Duration::from_secs(5), + "detached observer start-failure payload", + ); assert!(process_running(payload_pid)); let manager = JobManager::new(1); @@ -675,11 +676,11 @@ fn detached_recovery_observer_marks_later_supervisor_loss() { )], ); let _ = handoff_detached_job(&store, request.clone()).unwrap(); - assert!(wait_until(Duration::from_secs(5), || pid_marker.exists())); - let payload_pid: u32 = std::fs::read_to_string(&pid_marker) - .unwrap() - .parse() - .unwrap(); + let payload_pid = wait_for_pid_marker( + &pid_marker, + Instant::now() + Duration::from_secs(5), + "detached supervisor-loss payload", + ); let supervisor_pid = store.read(&request.job_id).unwrap().supervisor.unwrap().pid; let manager = JobManager::new(1); @@ -739,17 +740,11 @@ fn job_manager_stop_terminates_the_process_group() { let child = Arc::new(Mutex::new(ManagedChild::spawn(&mut command).unwrap())); let leader_pid = child.lock().unwrap().id(); let pid_file = temp.path().join("descendant.pid"); - let descendant_pid = (0..200) - .find_map(|_| { - let pid = std::fs::read_to_string(&pid_file) - .ok() - .and_then(|text| text.trim().parse::().ok()); - if pid.is_none() { - std::thread::sleep(Duration::from_millis(10)); - } - pid - }) - .expect("descendant pid marker was not ready"); + let descendant_pid = wait_for_pid_marker( + &pid_file, + Instant::now() + Duration::from_secs(5), + "process-group descendant", + ); assert!(process_running(leader_pid)); assert!(process_running(descendant_pid)); @@ -769,13 +764,13 @@ fn job_manager_stop_terminates_the_process_group() { manager.stop("process-group-job").unwrap(); assert!(stop_requested.load(Ordering::SeqCst)); - for _ in 0..200 { - let leader_exited = child.lock().unwrap().try_wait().unwrap().is_some(); - if leader_exited && !process_running(descendant_pid) { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } + assert!( + wait_until(Duration::from_secs(5), || { + child.lock().unwrap().try_wait().unwrap().is_some() + && !process_running(descendant_pid) + }), + "process-group cancellation did not terminate leader {leader_pid} and descendant {descendant_pid} within the deadline" + ); assert!(child.lock().unwrap().try_wait().unwrap().is_some()); assert!( !process_running(descendant_pid), @@ -799,7 +794,7 @@ fn job_shutdown_reaps_a_sigterm_responsive_child() { .stderr(Stdio::null()); let child = Arc::new(Mutex::new(ManagedChild::spawn(&mut command).unwrap())); let leader_pid = child.lock().unwrap().id(); - assert!(wait_until(Duration::from_secs(1), || ready.exists())); + assert!(wait_until(Duration::from_secs(5), || ready.exists())); let manager = JobManager::new(1); let stop_requested = Arc::new(AtomicBool::new(false)); lock_unpoison(&manager.jobs).insert( @@ -840,12 +835,11 @@ fn job_shutdown_escalates_ignored_sigterm_for_parent_and_descendant() { let child = Arc::new(Mutex::new(ManagedChild::spawn(&mut command).unwrap())); let leader_pid = child.lock().unwrap().id(); let pid_file = temp.path().join("descendant.pid"); - assert!(wait_until(Duration::from_secs(2), || pid_file.exists())); - let descendant_pid = std::fs::read_to_string(&pid_file) - .unwrap() - .trim() - .parse::() - .unwrap(); + let descendant_pid = wait_for_pid_marker( + &pid_file, + Instant::now() + Duration::from_secs(5), + "SIGTERM-ignoring descendant", + ); assert!(process_running(leader_pid)); assert!(process_running(descendant_pid)); @@ -908,27 +902,37 @@ struct FailFastAttempt { test_step_ran: bool, } -/// Drain job updates until the job reports `finished`, or the deadline passes. -/// -/// The deadline is wall-clock rather than a sleep count: under a loaded machine -/// a 10ms sleep is not 10ms, so a counting loop silently shortens its own -/// patience exactly when the job needs more of it. +fn recv_envelope_until( + runtime: &tokio::runtime::Runtime, + rx: &mut tokio::sync::mpsc::Receiver, + deadline: Instant, +) -> Result, tokio::time::error::Elapsed> { + runtime.block_on(async { + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), rx.recv()).await + }) +} + +/// Drain ordered JobUpdates until one reports `finished`, the channel closes, +/// or one absolute wall-clock deadline expires. Unrelated envelopes are ignored. fn collect_job_updates( rx: &mut tokio::sync::mpsc::Receiver, - deadline: Duration, + timeout: Duration, ) -> Vec { - let started = Instant::now(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .expect("JobUpdate collection runtime"); + let deadline = Instant::now() + timeout; let mut updates: Vec = Vec::new(); - while started.elapsed() < deadline { - while let Ok(envelope) = rx.try_recv() { - if let AgentEnvelope::JobUpdate { payload } = envelope { - updates.push(payload); - } - } + loop { if updates.last().is_some_and(|update| update.finished) { break; } - std::thread::sleep(Duration::from_millis(10)); + match recv_envelope_until(&runtime, rx, deadline) { + Ok(Some(AgentEnvelope::JobUpdate { payload })) => updates.push(payload), + Ok(Some(_)) => {} + Ok(None) | Err(_) => break, + } } updates } @@ -938,15 +942,18 @@ fn recv_job_update( timeout: Duration, label: &str, ) -> ShellAgentJobUpdateRequest { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .expect("JobUpdate receive runtime"); let deadline = Instant::now() + timeout; loop { - while let Ok(envelope) = rx.try_recv() { - if let AgentEnvelope::JobUpdate { payload } = envelope { - return payload; - } + match recv_envelope_until(&runtime, rx, deadline) { + Ok(Some(AgentEnvelope::JobUpdate { payload })) => return payload, + Ok(Some(_)) => {} + Ok(None) => panic!("channel closed while waiting for {label}"), + Err(_) => panic!("timed out waiting for {label}"), } - assert!(Instant::now() < deadline, "timed out waiting for {label}"); - std::thread::yield_now(); } } @@ -2605,10 +2612,10 @@ fn structured_process_job_handoff_observation_does_not_reset_the_original_total_ // still prove the original timeout is not reset at handoff. assert!(wait_until(Duration::from_secs(30), || marker.exists())); - // Model the Server's short sync grace ending by observing the retained - // active Job after the one child has already started. This observation is - // deliberately read-only: it cannot replace the process or its deadline. - std::thread::sleep(Duration::from_millis(300)); + // Once the marker proves the child started, the JobManager has no further + // sync-grace state transition to wait for: ToolRuntime handoff only exposes + // this same execution. Inventory observation is deliberately read-only and + // cannot replace the process or reset its original deadline. let handoff = manager .inventory() .jobs @@ -2618,7 +2625,8 @@ fn structured_process_job_handoff_observation_does_not_reset_the_original_total_ assert_eq!(handoff.status, "running"); assert_eq!(handoff.command_execution_state, None); - let updates = collect_job_updates(&mut rx, Duration::from_secs(10)); + let observation_timeout = Duration::from_secs(25).saturating_sub(original_start.elapsed()); + let updates = collect_job_updates(&mut rx, observation_timeout); let final_update = updates.last().expect("original timeout terminal update"); assert_eq!(final_update.status, "timeout", "{final_update:?}"); assert_eq!( @@ -3630,17 +3638,9 @@ fn validation_spawn_failure_is_infrastructure_without_failed_assertion() { .unwrap(), }, ); - let update = (0..100) - .find_map(|_| { - let update = rx.try_recv().ok().and_then(|envelope| match envelope { - AgentEnvelope::JobUpdate { payload } if payload.finished => Some(payload), - _ => None, - }); - if update.is_none() { - std::thread::sleep(Duration::from_millis(10)); - } - update - }) + let update = collect_job_updates(&mut rx, Duration::from_secs(5)) + .into_iter() + .find(|update| update.finished) .expect("validation spawn failure update"); assert!(update.finished); assert_eq!(update.status, "failed"); @@ -3793,6 +3793,35 @@ fn wait_until(timeout: Duration, condition: impl Fn() -> bool) -> bool { condition() } +fn wait_for_pid_marker(path: &Path, deadline: Instant, tag: &str) -> u32 { + loop { + let observed = std::fs::read_to_string(path) + .map_err(|error| format!("read failed: {error}")) + .and_then(|text| { + text.trim() + .parse::() + .map_err(|error| format!("invalid PID contents {text:?}: {error}")) + }); + match observed { + Ok(pid) => return pid, + Err(error) => { + let now = Instant::now(); + if now >= deadline { + panic!( + "timed out waiting for {tag} PID marker {}: {error}", + path.display() + ); + } + std::thread::sleep( + deadline + .saturating_duration_since(now) + .min(Duration::from_millis(10)), + ); + } + } + } +} + /// Poll `process_running(pid)` until the process is gone or `timeout` elapses. fn wait_for_process_exit(pid: u32, timeout: Duration, tag: &str) -> bool { let deadline = Instant::now() + timeout; @@ -4062,21 +4091,29 @@ fn job_cleanup_after_parent_exit_terminates_descendant_and_reaches_eof() { // The direct child exits on its own right after spawning the grandchild; // collect its pid line from the production reader's chunk stream. let mut accumulated = String::new(); - let mut grandchild_pid = None; let read_deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < read_deadline { - while let Ok(chunk) = rx.try_recv() { - if let OutputChunk::Stdout(text) = chunk { + let grandchild_pid = loop { + let remaining = read_deadline.saturating_duration_since(Instant::now()); + assert!( + !remaining.is_zero(), + "timed out waiting for GRANDCHILD_PID in job stdout" + ); + match rx.recv_timeout(remaining) { + Ok(OutputChunk::Stdout(text)) => { accumulated.push_str(&text); + if let Some(pid) = extract_grandchild_pid(&accumulated) { + break pid; + } + } + Ok(_) => {} + Err(mpsc::RecvTimeoutError::Timeout) => { + panic!("timed out waiting for GRANDCHILD_PID in job stdout"); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("job stdout disconnected before GRANDCHILD_PID was observed"); } } - if let Some(pid) = extract_grandchild_pid(&accumulated) { - grandchild_pid = Some(pid); - break; - } - std::thread::sleep(Duration::from_millis(10)); - } - let grandchild_pid = grandchild_pid.expect("GRANDCHILD_PID in job stdout"); + }; assert!( wait_until(Duration::from_secs(30), || lock_unpoison(&child) .try_wait() diff --git a/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs b/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs index 29c2ded9..bef6a9f8 100644 --- a/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs @@ -791,6 +791,7 @@ fn lsp_initialize_pre_exit_with_stderr_surfaces_component_missing_diagnostic() { #[cfg(unix)] #[test] fn lsp_rustup_proxy_without_component_is_not_available() { + let _env_lock = crate::tests::test_env_lock(); let _serial = super::super::serialize_fake_lsp_test(); let temp = tempfile::tempdir().unwrap(); let bin = temp.path().join("bin"); @@ -816,10 +817,9 @@ fn lsp_rustup_proxy_without_component_is_not_available() { let command = LspCommand::new(bin.join("rust-analyzer")); // Point detection at the fixture rustup home without spawning anything. - let previous_home = env::var_os("RUSTUP_HOME"); - let previous_toolchain = env::var_os("RUSTUP_TOOLCHAIN"); - env::set_var("RUSTUP_HOME", &rustup_home); - env::remove_var("RUSTUP_TOOLCHAIN"); + let _env = crate::tests::EnvGuard::new() + .set("RUSTUP_HOME", &rustup_home) + .remove("RUSTUP_TOOLCHAIN"); let available_missing = command.is_available(LspServerKind::RustAnalyzer); // Installing the component binary under the active toolchain restores @@ -839,16 +839,6 @@ fn lsp_rustup_proxy_without_component_is_not_available() { } let available_installed = command.is_available(LspServerKind::RustAnalyzer); - // Always restore process env before assertions so a failure cannot leak. - match previous_home { - Some(value) => env::set_var("RUSTUP_HOME", value), - None => env::remove_var("RUSTUP_HOME"), - } - match previous_toolchain { - Some(value) => env::set_var("RUSTUP_TOOLCHAIN", value), - None => env::remove_var("RUSTUP_TOOLCHAIN"), - } - assert!( !available_missing, "rustup shim without component must not report available" @@ -862,30 +852,15 @@ fn lsp_rustup_proxy_without_component_is_not_available() { #[cfg(windows)] #[test] fn rustup_home_falls_back_to_userprofile_on_windows() { + let _env_lock = crate::tests::test_env_lock(); let _serial = super::super::serialize_fake_lsp_test(); let temp = tempfile::tempdir().unwrap(); - let previous_rustup_home = env::var_os("RUSTUP_HOME"); - let previous_home = env::var_os("HOME"); - let previous_userprofile = env::var_os("USERPROFILE"); - - env::remove_var("RUSTUP_HOME"); - env::remove_var("HOME"); - env::set_var("USERPROFILE", temp.path()); + let _env = crate::tests::EnvGuard::new() + .remove("RUSTUP_HOME") + .remove("HOME") + .set("USERPROFILE", temp.path()); let detected = rustup_home_dir(); - match previous_rustup_home { - Some(value) => env::set_var("RUSTUP_HOME", value), - None => env::remove_var("RUSTUP_HOME"), - } - match previous_home { - Some(value) => env::set_var("HOME", value), - None => env::remove_var("HOME"), - } - match previous_userprofile { - Some(value) => env::set_var("USERPROFILE", value), - None => env::remove_var("USERPROFILE"), - } - assert_eq!(detected, Some(temp.path().join(".rustup"))); } diff --git a/crates/webcodex-runner/src/webcodex_runner/mcp_gateway_tests.rs b/crates/webcodex-runner/src/webcodex_runner/mcp_gateway_tests.rs index ce21d463..43d9af4c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/mcp_gateway_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/mcp_gateway_tests.rs @@ -222,9 +222,7 @@ fn tools_call_sends_only_gateway_owned_name_and_arguments() { #[test] fn provider_execution_context_is_explicit_cleared_and_private() { - let _guard = crate::tests::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = crate::tests::test_env_lock(); let _env = crate::tests::EnvGuard::new() .set("GITHUB_TOKEN", "github-provider-secret-value") .set("WEBCODEX_MCP_MAPPED_SOURCE", "mapped-provider-secret-value") @@ -267,9 +265,7 @@ fn provider_execution_context_is_explicit_cleared_and_private() { #[test] fn missing_mapped_source_fails_before_provider_spawn() { - let _guard = crate::tests::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = crate::tests::test_env_lock(); let _env = crate::tests::EnvGuard::new().remove("WEBCODEX_MCP_TEST_MISSING_SOURCE"); let fixture = Fixture::with_execution_context( "normal", diff --git a/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs b/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs index 4acff7df..c7751b3a 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs @@ -118,6 +118,17 @@ fn test_runtime(cfg: &AgentConfig) -> AgentRuntimeState { AgentRuntimeState::new(cfg, PathBuf::new()) } +fn wait_for_path(path: &Path, deadline: Instant, context: &str) { + while !path.exists() { + assert!( + Instant::now() < deadline, + "timed out waiting for {context}: {}", + path.display() + ); + thread::sleep(Duration::from_millis(5)); + } +} + #[test] fn runtime_shutdown_is_fast_ordered_and_runs_once_without_resources() { let cfg = test_agent_config("http://127.0.0.1:1".to_string()); @@ -452,11 +463,18 @@ fn run_polling_agent_against_server( let runtime = test_runtime(&cfg); let shutdown = Arc::new(AtomicBool::new(false)); let failsafe = Arc::clone(&shutdown); - thread::spawn(move || { - thread::sleep(Duration::from_secs(2)); - failsafe.store(true, Ordering::SeqCst); + let (failsafe_cancel_tx, failsafe_cancel_rx) = std::sync::mpsc::channel(); + let failsafe_thread = thread::spawn(move || { + if failsafe_cancel_rx + .recv_timeout(Duration::from_secs(2)) + .is_err() + { + failsafe.store(true, Ordering::SeqCst); + } }); let result = run_polling_agent_with_shutdown(cfg, once, "inst-poll-test", shutdown, &runtime); + let _ = failsafe_cancel_tx.send(()); + failsafe_thread.join().unwrap(); server.join().unwrap(); (result, poll_count.load(Ordering::SeqCst)) } @@ -963,7 +981,7 @@ fn run_polling_agent_against_scripted_server( let failsafe_shutdown = Arc::clone(&server.shutdown); let server_url = server.server_url.clone(); let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); - thread::spawn(move || { + let runner = thread::spawn(move || { let tmp = tempfile::tempdir().unwrap(); let cfg = polling_agent_config(server_url, tmp.path().join("projects.d")); let runtime = test_runtime(&cfg); @@ -972,11 +990,22 @@ fn run_polling_agent_against_scripted_server( let _ = result_tx.send(result); }); match result_rx.recv_timeout(Duration::from_secs(20)) { - Ok(result) => result, - Err(error) => { + Ok(result) => { + runner + .join() + .expect("scripted polling runner thread panicked after reporting its result"); + result + } + Err(error @ std::sync::mpsc::RecvTimeoutError::Timeout) => { failsafe_shutdown.store(true, Ordering::SeqCst); panic!("scripted polling runner exceeded hard timeout: {error}"); } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + runner + .join() + .expect("scripted polling runner thread panicked before reporting its result"); + panic!("scripted polling runner exited without reporting a result"); + } } } @@ -1085,11 +1114,11 @@ fn polling_long_ordinary_dispatch_does_not_pin_and_results_stay_correlated_exact "poll-b", "the next poll must reach the Server before A is released" ); - let deadline = Instant::now() + Duration::from_secs(5); - while !started_a.exists() { - assert!(Instant::now() < deadline, "slow request A did not start"); - thread::sleep(Duration::from_millis(5)); - } + wait_for_path( + &started_a, + Instant::now() + Duration::from_secs(5), + "slow request A to start", + ); assert_eq!( event_rx.recv_timeout(Duration::from_secs(5)).unwrap(), "result-req-fast-b", @@ -1197,9 +1226,8 @@ fn polling_dispatch_bound_backpressures_without_a_local_pending_queue() { }); let deadline = Instant::now() + Duration::from_secs(5); - while !(started[0].exists() && started[1].exists()) { - assert!(Instant::now() < deadline, "first two workers did not start"); - thread::sleep(Duration::from_millis(5)); + for path in &started[..2] { + wait_for_path(path, deadline, "first two polling workers to start"); } assert_eq!(runtime.dispatches.active(), POLLING_DISPATCH_MAX_IN_FLIGHT); assert!( @@ -1213,11 +1241,11 @@ fn polling_dispatch_bound_backpressures_without_a_local_pending_queue() { third_poll_rx .recv_timeout(Duration::from_secs(5)) .expect("releasing one slot must allow the third poll"); - let deadline = Instant::now() + Duration::from_secs(5); - while !started[2].exists() { - assert!(Instant::now() < deadline, "third worker did not start"); - thread::sleep(Duration::from_millis(5)); - } + wait_for_path( + &started[2], + Instant::now() + Duration::from_secs(5), + "third polling worker to start", + ); assert_eq!( runtime.dispatches.active(), POLLING_DISPATCH_MAX_IN_FLIGHT, @@ -1387,11 +1415,11 @@ fn polling_once_waits_for_its_tracked_ordinary_dispatch() { let _ = runner_tx.send(result); }); - let deadline = Instant::now() + Duration::from_secs(5); - while !started.exists() { - assert!(Instant::now() < deadline, "--once request did not start"); - thread::sleep(Duration::from_millis(5)); - } + wait_for_path( + &started, + Instant::now() + Duration::from_secs(5), + "--once request to start", + ); assert!( runner_rx.try_recv().is_err(), "--once returned while its ordinary dispatch was still active" @@ -1469,11 +1497,11 @@ fn polling_once_preserves_job_manager_drain_before_exit() { let _ = runner_tx.send(result); }); - let deadline = Instant::now() + Duration::from_secs(5); - while !started.exists() { - assert!(Instant::now() < deadline, "--once Job did not start"); - thread::sleep(Duration::from_millis(5)); - } + wait_for_path( + &started, + Instant::now() + Duration::from_secs(5), + "--once Job to start", + ); assert!( runner_rx.try_recv().is_err(), "--once returned before JobManager drained its active Job" @@ -1548,14 +1576,11 @@ fn polling_shutdown_with_active_background_dispatch_is_bounded_and_non_replaying let _ = runner_tx.send(result); }); - let deadline = Instant::now() + Duration::from_secs(5); - while !started.exists() { - assert!( - Instant::now() < deadline, - "shutdown fixture dispatch did not start" - ); - thread::sleep(Duration::from_millis(5)); - } + wait_for_path( + &started, + Instant::now() + Duration::from_secs(5), + "shutdown fixture dispatch to start", + ); let shutdown_started = Instant::now(); shutdown.store(true, Ordering::SeqCst); runner_rx @@ -1568,13 +1593,12 @@ fn polling_shutdown_with_active_background_dispatch_is_bounded_and_non_replaying "shutdown exceeded its bounded cleanup budget" ); let polls_after_completion = poll_count.load(Ordering::SeqCst); - thread::sleep(Duration::from_millis(50)); + server.finish(); assert_eq!( poll_count.load(Ordering::SeqCst), polls_after_completion, "polling continued after shutdown completed" ); - server.finish(); assert_eq!(std::fs::read_to_string(marker).unwrap().lines().count(), 1); assert_eq!(runtime.dispatches.active(), 0); @@ -1794,14 +1818,11 @@ fn polling_persistent_shell_exec_remains_responsive_to_close() { let _ = runner_tx.send(result); }); - let deadline = Instant::now() + Duration::from_secs(5); - while !started.exists() { - assert!( - Instant::now() < deadline, - "persistent-shell exec did not start" - ); - thread::sleep(Duration::from_millis(5)); - } + wait_for_path( + &started, + Instant::now() + Duration::from_secs(5), + "persistent-shell exec to start", + ); allow_close.store(true, Ordering::SeqCst); runner_rx .recv_timeout(Duration::from_secs(10)) diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/execute.rs b/crates/webcodex-runner/src/webcodex_runner/validation/execute.rs index 9199067e..d0490e62 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/execute.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/execute.rs @@ -404,21 +404,21 @@ mod tests { const ENV: &str = "WEBCODEX_TEST_VALIDATION_EXECUTABLE"; const MISSING_NAME: &str = "webcodex-validation-executable-that-does-not-exist"; + let _env_lock = crate::tests::test_env_lock(); let temp = tempfile::tempdir().unwrap(); - std::env::set_var(ENV, temp.path()); + let env = crate::tests::EnvGuard::new().set(ENV, temp.path()); assert!(resolve_executable(ENV, MISSING_NAME).is_none()); let file = temp.path().join("tool"); std::fs::write(&file, "#!/bin/sh\nexit 0\n").unwrap(); - std::env::set_var(ENV, &file); + let _env = env.set(ENV, &file); assert!(resolve_executable(ENV, MISSING_NAME).is_none()); let mut permissions = std::fs::metadata(&file).unwrap().permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&file, permissions).unwrap(); assert_eq!(resolve_executable(ENV, MISSING_NAME), Some(file)); - std::env::remove_var(ENV); } // ----------------------------------------------------------------------- diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/tests.rs b/crates/webcodex-runner/src/webcodex_runner/validation/tests.rs index 2cf62b96..2a43f578 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/tests.rs @@ -7,22 +7,6 @@ use crate::validation_bridge::{ }; use std::fs; use std::path::PathBuf; -use std::sync::Mutex; - -static VALIDATION_ENV_LOCK: Mutex<()> = Mutex::new(()); - -struct ValidationEnvRestore { - pyright: Option, -} - -impl Drop for ValidationEnvRestore { - fn drop(&mut self) { - match self.pyright.take() { - Some(value) => std::env::set_var("WEBCODEX_PYRIGHT", value), - None => std::env::remove_var("WEBCODEX_PYRIGHT"), - } - } -} fn typecheck_request(project_id: &str) -> ValidationBridgeRequest { ValidationBridgeRequest { @@ -134,12 +118,7 @@ fn with_path(bin_dir: &std::path::Path, f: impl FnOnce() -> T) -> T { /// the validation fixture. `available = false` points at a guaranteed-missing /// path to exercise the tool-unavailable branch without exposing real tools. fn with_path_mode(bin_dir: &std::path::Path, available: bool, f: impl FnOnce() -> T) -> T { - let _lock = VALIDATION_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let _restore = ValidationEnvRestore { - pyright: std::env::var_os("WEBCODEX_PYRIGHT"), - }; + let _env_lock = crate::tests::test_env_lock(); let program = if available { #[cfg(unix)] { @@ -157,7 +136,7 @@ fn with_path_mode(bin_dir: &std::path::Path, available: bool, f: impl FnOnce( } else { bin_dir.join("webcodex-missing-pyright") }; - std::env::set_var("WEBCODEX_PYRIGHT", program); + let _env = crate::tests::EnvGuard::new().set("WEBCODEX_PYRIGHT", &program); f() } @@ -323,7 +302,7 @@ fn end_to_end_exit_zero_no_diagnostics_is_success() { fn fake_pyright_missing_reports_tool_unavailable() { let project = tempfile::tempdir().unwrap(); let empty_bin = tempfile::tempdir().unwrap(); - // PATH with empty dir only — no pyright (do not prepend system PATH). + // Point directly at a guaranteed-missing pyright; process PATH stays untouched. let response = with_path_mode(empty_bin.path(), false, || { execute_validation_at_root(project.path(), &typecheck_request("demo"), 120).unwrap() }); diff --git a/docs/TESTING.md b/docs/TESTING.md index d70f5031..1ed356c3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,25 +29,42 @@ tests with different cost profiles sharing the same default lane. The lanes above define test semantics; workflows decide when to run them. -- `.github/workflows/ci.yml` is the ordinary repository gate. Every configured - pull-request workflow run gets the lightweight `contract` job without requiring - the `run-ci` label. It covers frontend type/test/dist checks, workspace-boundary - checks, formatting, and focused registry/OpenAPI/MCP schema and metadata parity. -- The heavier Linux `test`, native macOS `test-macos`, and native Windows - `test-windows` jobs run on every push to `main`. External pull requests run - them automatically subject to GitHub fork protections; owner-authored pull - requests opt in with the `run-ci` label. -- Heavy Linux CI runs frontend checks, workspace-boundary and release-tooling - checks, Markdown-link validation, formatting, and the locked workspace test - suite. macOS CI compiles the release production surfaces and runs the native +- `.github/workflows/ci.yml` is the ordinary repository gate. Its lightweight + `contract` job is the mandatory first lane for every configured pull request + and every push to `main`; it never requires the owner-only `run-ci` label. The + lane owns frontend install/type/test/dist validation, workspace-boundary + self-test/checks, formatting, the heuristic test-inventory self-test/report + (without count thresholds), and focused registry/OpenAPI/MCP schema and metadata parity. +- The heavy Linux Rust matrix `test-linux-rust`, Linux tooling lane + `test-linux-tooling`, native macOS `test-macos`, and native Windows + `test-windows` jobs all depend on a successful `contract` job and retain the + existing main/external-PR/owner-`run-ci` policy. The historical `test` job id + remains the aggregate Linux status check: on an eligible heavy run it waits for + `contract` plus both Linux lanes and fails unless every required result is + `success`. Owner-authored pull requests without `run-ci` still intentionally + skip the heavy Linux lanes and aggregate. This is CI orchestration, not a claim + that the repository now has perfectly pure fast/integration/platform suites. +- Linux Rust execution is package-sharded without test-name filters: the server + package `webcodex`, the integration-rich Runner package `webcodex-runner`, and + the remaining workspace crates run as three complete package groups in + parallel. Each workspace package appears in exactly one group, so sharding is + an execution optimization rather than a semantic coverage reduction. Package + boundaries do not imply that every test inside a shard has the same cost or + integration characteristics. +- Linux tooling runs in parallel with the Rust shards and retains + release-verification tooling, Markdown-link validation, and npm package-smoke + tooling; within the Linux heavy split, only this tooling lane installs Node + because those smoke scripts invoke Node/npm directly. macOS still owns release-surface compilation and the native Runner suite, including detached ownership/restart recovery. The local-`sshd` SSH integration fixture remains Linux-only because it depends on Linux daemon - account/auth configuration; macOS still compiles and tests the SSH client and - pure command-shaping surface. Windows CI runs formatting, native Windows - package tests, npm checks, and the Windows artifact-to-install smoke. -- Exact-source release acceptance is separate from ordinary pull-request CI. - Follow [`RELEASE_CHECKLIST.md`](RELEASE_CHECKLIST.md) and - `.github/workflows/release-readiness.yml`. + account/auth configuration; Windows still owns its native library, CLI and + serialized Runner suites, npm tests, and artifact-to-install smoke. +- Exact-source release acceptance is a separate trust boundary from ordinary CI + and intentionally keeps its independent full locked workspace acceptance suite + together with frontend/E2E/native release evidence. Follow + [`RELEASE_CHECKLIST.md`](RELEASE_CHECKLIST.md) and + `.github/workflows/release-readiness.yml`; ordinary-CI package sharding does not + reduce that workflow or `scripts/release_check.sh`. - Slow/manual and real-process lanes remain explicit targeted evidence unless a workflow names them. Do not infer that one lane ran merely because another CI job passed. @@ -94,10 +111,16 @@ Run the current heuristic inventory with: bash scripts/test_inventory.sh ``` -The script is intentionally heuristic. It scans only `src`, `docs`, and `tests` -when those directories exist, does not access the network, does not modify the -workspace, and reports counts plus sanitized risk clues. Use -`bash scripts/test_inventory.sh --details` for a full sanitized file/line list. +The script is intentionally heuristic. It scans all Git-tracked Rust files across +the workspace, so crate-local tests (including Runner tests) are included. Using +the Git index as the source set excludes ordinary untracked `target/` output and +scratch files without maintaining a second ignore list. It does not access the +network or modify the workspace. The output includes a +stable tab-separated area summary for the root `webcodex` package and each +`crates/*` member, plus sanitized risk clues. Use +`bash scripts/test_inventory.sh --details` for a full sanitized file/line list, +and `bash scripts/test_inventory.sh --self-test` to exercise the inventory +contract against a temporary Git fixture. ## Current Test Layout Notes diff --git a/scripts/test_inventory.sh b/scripts/test_inventory.sh index f2b5c4ed..96fb7f4a 100755 --- a/scripts/test_inventory.sh +++ b/scripts/test_inventory.sh @@ -4,7 +4,8 @@ set -euo pipefail # Heuristic, read-only test inventory for WebCodex. # # Scope: -# - scans only src, docs, and tests when those directories exist +# - scans Git-tracked Rust files across the whole workspace +# - groups the root package and crates/* sources for later lane work # - does not access the network # - does not modify the repository # - avoids printing matched source lines so token-looking fixture values are @@ -12,43 +13,94 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$PROJECT_DIR" + +usage() { + printf 'usage: bash scripts/test_inventory.sh [--details|--self-test]\n' +} + +if [ "${1:-}" = "--self-test" ]; then + if [ "$#" -ne 1 ]; then + usage >&2 + exit 2 + fi + if ! command -v python3 >/dev/null 2>&1; then + printf '[inventory][FAIL] python3 is required for --self-test\n' >&2 + exit 2 + fi + cd "$PROJECT_DIR" + PYTHONDONTWRITEBYTECODE=1 \ + exec python3 -m unittest scripts.tests.test_test_inventory_script +fi DETAILS=0 if [ "$#" -gt 0 ]; then case "$1" in --details) + if [ "$#" -ne 1 ]; then + usage >&2 + exit 2 + fi DETAILS=1 ;; -h|--help) - printf 'usage: bash scripts/test_inventory.sh [--details]\n' + if [ "$#" -ne 1 ]; then + usage >&2 + exit 2 + fi + usage exit 0 ;; *) printf '[inventory] unknown argument: %s\n' "$1" >&2 - printf 'usage: bash scripts/test_inventory.sh [--details]\n' >&2 + usage >&2 exit 2 ;; esac fi -ROOTS=() -for dir in src docs tests; do - if [ -d "$dir" ]; then - ROOTS+=("$dir") +for required in git rg; do + if ! command -v "$required" >/dev/null 2>&1; then + printf '[inventory][FAIL] %s is required\n' "$required" >&2 + exit 2 fi done -if [ "${#ROOTS[@]}" -eq 0 ]; then - printf '[inventory] no scan roots found\n' >&2 +cd "$PROJECT_DIR" +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '[inventory][FAIL] project root is not a Git worktree\n' >&2 + exit 2 +fi + +rust_files=() +while IFS= read -r -d '' file; do + rust_files+=("$file") +done < <(git ls-files -z -- '*.rs') + +if [ "${#rust_files[@]}" -eq 0 ]; then + printf '[inventory] no Git-tracked Rust files found\n' >&2 exit 1 fi -rg_count() { +TEST_PATTERN='^[[:space:]]*#\[test' +TOKIO_TEST_PATTERN='^[[:space:]]*#\[tokio::test' +IGNORE_PATTERN='^[[:space:]]*#\[ignore' +SLEEP_PATTERN='sleep[[:space:]]*\(' +TIMEOUT_PATTERN='timeout[[:space:]]*\(' +LOOPBACK_PATTERN='localhost|127\.0\.0\.1|TcpListener' +ENV_MUTATION_PATTERN='(std::)?env::(set_var|remove_var)' +TEST_ENV_LOCK_PATTERN='TEST_ENV_LOCK' + +rg_count_files() { local pattern="$1" + shift + if [ "$#" -eq 0 ]; then + printf '0\n' + return 0 + fi + local output status set +e - output="$(rg --count-matches "$pattern" "${ROOTS[@]}" 2>/dev/null)" + output="$(rg --count-matches "$pattern" -- "$@" 2>/dev/null)" status=$? set -e if [ "$status" -eq 1 ]; then @@ -62,12 +114,17 @@ rg_count() { printf '%s\n' "$output" | awk -F: '{ sum += $NF } END { print sum + 0 }' } +rg_count() { + local pattern="$1" + rg_count_files "$pattern" "${rust_files[@]}" +} + rg_locations() { local label="$1" local pattern="$2" local status set +e - rg --line-number --no-heading "$pattern" "${ROOTS[@]}" 2>/dev/null \ + rg --line-number --no-heading "$pattern" -- "${rust_files[@]}" 2>/dev/null \ | awk -F: -v label="$label" '{ print $1 ":" $2 ":" label }' status=${PIPESTATUS[0]} set -e @@ -85,9 +142,9 @@ rg_file_counts() { local pattern="$2" local status set +e - rg --line-number --no-heading "$pattern" "${ROOTS[@]}" 2>/dev/null \ + rg --line-number --no-heading "$pattern" -- "${rust_files[@]}" 2>/dev/null \ | awk -F: -v label="$label" '{ count[$1]++ } END { for (file in count) print count[file] "\t" file "\t" label }' \ - | sort -nr \ + | sort -t $'\t' -k1,1nr -k2,2 \ | head -n 10 \ | awk -F'\t' '{ print " " $3 " " $2 ": " $1 }' status=${PIPESTATUS[0]} @@ -101,15 +158,34 @@ rg_file_counts() { fi } -rust_files=() -while IFS= read -r file; do - rust_files+=("$file") -done < <(find "${ROOTS[@]}" -type f -name '*.rs' 2>/dev/null | sort) +area_for_file() { + local file="$1" + local rest crate + case "$file" in + crates/*/*) + rest="${file#crates/}" + crate="${rest%%/*}" + printf 'crates/%s\n' "$crate" + ;; + src/*|tests/*) + printf 'webcodex\n' + ;; + *) + printf 'other\n' + ;; + esac +} + +areas=() +while IFS= read -r area; do + areas+=("$area") +done < <( + for file in "${rust_files[@]}"; do + area_for_file "$file" + done | sort -u +) print_ignored_tests() { - if [ "${#rust_files[@]}" -eq 0 ]; then - return 0 - fi awk ' /^[[:space:]]*#\[ignore/ { pending = 1 @@ -135,23 +211,46 @@ print_ignored_tests() { ' "${rust_files[@]}" } -printf '[inventory] roots:' -printf ' %s' "${ROOTS[@]}" -printf '\n\n' +printf '[inventory] source\n' +printf ' scope: Git-tracked Rust files across the workspace\n' +printf ' rust files: %s\n' "${#rust_files[@]}" +printf '\n' printf '[inventory] test attributes\n' -printf ' rust files: %s\n' "${#rust_files[@]}" -printf ' #[test]: %s\n' "$(rg_count '^[[:space:]]*#\[test')" -printf ' #[tokio::test]: %s\n' "$(rg_count '^[[:space:]]*#\[tokio::test')" -printf ' #[ignore]: %s\n' "$(rg_count '^[[:space:]]*#\[ignore')" +printf ' #[test]: %s\n' "$(rg_count "$TEST_PATTERN")" +printf ' #[tokio::test]: %s\n' "$(rg_count "$TOKIO_TEST_PATTERN")" +printf ' #[ignore]: %s\n' "$(rg_count "$IGNORE_PATTERN")" printf '\n' printf '[inventory] risk clue counts\n' -printf ' sleep calls: %s\n' "$(rg_count 'sleep[[:space:]]*\(')" -printf ' timeout calls: %s\n' "$(rg_count 'timeout[[:space:]]*\(')" -printf ' loopback strings or TcpListener: %s\n' "$(rg_count 'localhost|127\.0\.0\.1|TcpListener')" -printf ' env set/remove calls: %s\n' "$(rg_count '(std::)?env::(set_var|remove_var)')" -printf ' TEST_ENV_LOCK mentions: %s\n' "$(rg_count 'TEST_ENV_LOCK')" +printf ' sleep calls: %s\n' "$(rg_count "$SLEEP_PATTERN")" +printf ' timeout calls: %s\n' "$(rg_count "$TIMEOUT_PATTERN")" +printf ' loopback strings or TcpListener: %s\n' "$(rg_count "$LOOPBACK_PATTERN")" +printf ' env set/remove calls: %s\n' "$(rg_count "$ENV_MUTATION_PATTERN")" +printf ' TEST_ENV_LOCK mentions: %s\n' "$(rg_count "$TEST_ENV_LOCK_PATTERN")" +printf '\n' + +printf '[inventory] area summary (tab-separated)\n' +printf 'area\trust_files\ttest\ttokio_test\tignore\tsleep\ttimeout\tloopback_or_listener\tenv_mutation\ttest_env_lock\n' +for area in "${areas[@]}"; do + area_files=() + for file in "${rust_files[@]}"; do + if [ "$(area_for_file "$file")" = "$area" ]; then + area_files+=("$file") + fi + done + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$area" \ + "${#area_files[@]}" \ + "$(rg_count_files "$TEST_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$TOKIO_TEST_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$IGNORE_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$SLEEP_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$TIMEOUT_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$LOOPBACK_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$ENV_MUTATION_PATTERN" "${area_files[@]}")" \ + "$(rg_count_files "$TEST_ENV_LOCK_PATTERN" "${area_files[@]}")" +done printf '\n' printf '[inventory] ignored tests\n' @@ -166,20 +265,20 @@ printf '\n' if [ "$DETAILS" -eq 1 ]; then printf '[inventory] sanitized risk locations\n' { - rg_locations sleep 'sleep[[:space:]]*\(' - rg_locations timeout 'timeout[[:space:]]*\(' - rg_locations loopback_or_listener 'localhost|127\.0\.0\.1|TcpListener' - rg_locations env_mutation '(std::)?env::(set_var|remove_var)' - rg_locations test_env_lock 'TEST_ENV_LOCK' + rg_locations sleep "$SLEEP_PATTERN" + rg_locations timeout "$TIMEOUT_PATTERN" + rg_locations loopback_or_listener "$LOOPBACK_PATTERN" + rg_locations env_mutation "$ENV_MUTATION_PATTERN" + rg_locations test_env_lock "$TEST_ENV_LOCK_PATTERN" } | sort | sed 's/^/ /' else printf '[inventory] top risk files by clue type\n' { - rg_file_counts sleep 'sleep[[:space:]]*\(' - rg_file_counts timeout 'timeout[[:space:]]*\(' - rg_file_counts loopback_or_listener 'localhost|127\.0\.0\.1|TcpListener' - rg_file_counts env_mutation '(std::)?env::(set_var|remove_var)' - rg_file_counts test_env_lock 'TEST_ENV_LOCK' + rg_file_counts sleep "$SLEEP_PATTERN" + rg_file_counts timeout "$TIMEOUT_PATTERN" + rg_file_counts loopback_or_listener "$LOOPBACK_PATTERN" + rg_file_counts env_mutation "$ENV_MUTATION_PATTERN" + rg_file_counts test_env_lock "$TEST_ENV_LOCK_PATTERN" } printf '\n[inventory] rerun with --details for sanitized file:line locations\n' fi diff --git a/scripts/tests/test_test_inventory_script.py b/scripts/tests/test_test_inventory_script.py new file mode 100644 index 00000000..0657bc01 --- /dev/null +++ b/scripts/tests/test_test_inventory_script.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +INVENTORY_SCRIPT = REPO_ROOT / "scripts" / "test_inventory.sh" + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +class TestInventoryScriptTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + (self.root / "scripts").mkdir() + shutil.copyfile(INVENTORY_SCRIPT, self.root / "scripts" / "test_inventory.sh") + + write_text( + self.root / "src" / "lib.rs", + """#[test]\nfn root_test() {\n std::thread::sleep(std::time::Duration::from_millis(1));\n}\n""", + ) + write_text( + self.root / "crates" / "webcodex-runner" / "src" / "lib.rs", + """#[tokio::test]\n#[ignore = \"process fixture\"]\nasync fn runner_test() {\n let _endpoint = \"127.0.0.1:0\";\n std::env::set_var(\"WEBCODEX_FIXTURE_TOKEN\", \"SECRET_SENTINEL\");\n}\n""", + ) + write_text( + self.root / "crates" / "webcodex-cli" / "tests" / "help.rs", + """#[test]\nfn cli_help() {}\n""", + ) + + # These files deliberately remain outside the Git index. A workspace-wide + # inventory should not count build output or arbitrary local scratch files. + write_text( + self.root / "target" / "generated.rs", + """#[test]\nfn generated_test() {}\n""", + ) + write_text( + self.root / "crates" / "webcodex-runner" / "tests" / "untracked.rs", + """#[test]\nfn untracked_test() {\n std::env::set_var(\"UNTRACKED_SECRET\", \"UNTRACKED_SENTINEL\");\n}\n""", + ) + + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + subprocess.run( + [ + "git", + "add", + "scripts/test_inventory.sh", + "src/lib.rs", + "crates/webcodex-runner/src/lib.rs", + "crates/webcodex-cli/tests/help.rs", + ], + cwd=self.root, + check=True, + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def run_inventory(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "scripts/test_inventory.sh", *args], + cwd=self.root, + check=False, + text=True, + capture_output=True, + ) + + def area_rows(self, output: str) -> tuple[list[str], dict[str, list[str]]]: + lines = output.splitlines() + marker = "[inventory] area summary (tab-separated)" + marker_index = lines.index(marker) + header = lines[marker_index + 1].split("\t") + rows: dict[str, list[str]] = {} + for line in lines[marker_index + 2 :]: + if not line: + break + fields = line.split("\t") + rows[fields[0]] = fields[1:] + return header, rows + + def test_scans_all_tracked_workspace_rust_and_groups_areas(self) -> None: + result = self.run_inventory() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("scope: Git-tracked Rust files across the workspace", result.stdout) + self.assertIn("rust files: 3", result.stdout) + self.assertIn("#[test]: 2", result.stdout) + self.assertIn("#[tokio::test]: 1", result.stdout) + + header, rows = self.area_rows(result.stdout) + self.assertEqual( + header, + [ + "area", + "rust_files", + "test", + "tokio_test", + "ignore", + "sleep", + "timeout", + "loopback_or_listener", + "env_mutation", + "test_env_lock", + ], + ) + self.assertEqual( + list(rows), + ["crates/webcodex-cli", "crates/webcodex-runner", "webcodex"], + ) + self.assertEqual(rows["webcodex"], ["1", "1", "0", "0", "1", "0", "0", "0", "0"]) + self.assertEqual( + rows["crates/webcodex-runner"], + ["1", "0", "1", "1", "0", "0", "1", "1", "0"], + ) + self.assertEqual( + rows["crates/webcodex-cli"], + ["1", "1", "0", "0", "0", "0", "0", "0", "0"], + ) + self.assertNotIn("generated.rs", result.stdout) + self.assertNotIn("untracked.rs", result.stdout) + + def test_details_report_locations_without_source_values(self) -> None: + result = self.run_inventory("--details") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertRegex( + result.stdout, + r"crates/webcodex-runner/src/lib\.rs:\d+:env_mutation", + ) + self.assertRegex( + result.stdout, + r"crates/webcodex-runner/src/lib\.rs:\d+:loopback_or_listener", + ) + self.assertNotIn("SECRET_SENTINEL", result.stdout) + self.assertNotIn("WEBCODEX_FIXTURE_TOKEN", result.stdout) + self.assertNotIn("UNTRACKED_SENTINEL", result.stdout) + self.assertNotIn("SECRET_SENTINEL", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/tool_runtime/registry/input_schemas/git.rs b/src/tool_runtime/registry/input_schemas/git.rs index 4d349266..3620872e 100644 --- a/src/tool_runtime/registry/input_schemas/git.rs +++ b/src/tool_runtime/registry/input_schemas/git.rs @@ -129,7 +129,7 @@ pub(crate) fn git_diff_hunks_input_schema() -> Value { ( "continuation", "string", - "Optional opaque continuation returned by a previous git_diff_hunks page.", + "Opaque continuation returned by a previous git_diff_hunks page. When continuing, repeat the exact original diff scope and paging inputs unchanged (base_commit/head_commit for committed mode, cached/worktree mode, paths, max_hunks, and max_hunk_lines); the token is scope-bound and does not reconstruct omitted request fields.", false, ), ])); diff --git a/src/tool_runtime/registry/tool_specs/git.rs b/src/tool_runtime/registry/tool_specs/git.rs index 9ea22f6f..f12f9890 100644 --- a/src/tool_runtime/registry/tool_specs/git.rs +++ b/src/tool_runtime/registry/tool_specs/git.rs @@ -38,7 +38,7 @@ pub(super) fn tool_specs() -> Vec { ), tool_spec( "git_diff_hunks", - "Return producer-bounded structured git diff hunks for worktree/cached state or an exact committed base/head range, with fenced opaque continuation. Read-only.", + "Return producer-bounded structured git diff hunks for worktree/cached state or an exact committed base/head range, with scope-bound opaque continuation. Continuation calls must replay the original scope and paging inputs unchanged. Read-only.", git_diff_hunks_input_schema(), ), tool_spec( diff --git a/src/tool_runtime/tests/schema/descriptions.rs b/src/tool_runtime/tests/schema/descriptions.rs index 87015c44..6c31ad04 100644 --- a/src/tool_runtime/tests/schema/descriptions.rs +++ b/src/tool_runtime/tests/schema/descriptions.rs @@ -47,6 +47,35 @@ fn tool_specs_describe_default_coding_loop_preferences() { ); } + let git_diff_hunks = spec_named(&specs, "git_diff_hunks"); + let git_diff_hunks_desc = git_diff_hunks.description.to_lowercase(); + for phrase in ["scope-bound", "replay", "scope", "paging inputs"] { + assert!( + git_diff_hunks_desc.contains(phrase), + "git_diff_hunks description should mention {phrase}: {git_diff_hunks_desc}" + ); + } + let continuation_desc = git_diff_hunks.input_schema["properties"]["continuation"] + ["description"] + .as_str() + .expect("git_diff_hunks continuation description") + .to_lowercase(); + for phrase in [ + "repeat the exact original", + "base_commit/head_commit", + "cached/worktree mode", + "paths", + "max_hunks", + "max_hunk_lines", + "scope-bound", + "does not reconstruct", + ] { + assert!( + continuation_desc.contains(phrase), + "git_diff_hunks continuation description should mention {phrase}: {continuation_desc}" + ); + } + // Canonical transactional edit path. let apply_text_edits_desc = desc("apply_text_edits"); for phrase in [