From 7a94ba2c3ea2ff0a90723bbbb9d39cd8ae751455 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 20 Sep 2026 19:15:50 +0900 Subject: [PATCH] test(issue-28): make the host-dependent tests portable; POSIX ps in the process scanners The 33 failures on the Linux / Python 3.14 host (#28) were environment dependencies, not regressions; each test is worth keeping, so fix the dependency instead of deleting the test: - test_workspace_scope: `source` under `shell=True` ran in dash; run the command string under bash. - conftest autouse: MAM_ENV_FILE points at an absent file for every test, so a workspace .mam.env with a reachable broker no longer breaks the "broker unavailable" tests (T1-4). - help-capture guard: compare normalized help text (whitespace collapsed, "optional arguments"/"options" unified) so argparse re-wrapping across Python versions is not drift. - deploy freshness D-11..D-19 and D-22..D-30: skip with a reason when the nats-docker submodule is not initialized. - lib_py.agents.base.conversation_in_use and heartbeat._process_table: `ps -eo pid=,ppid=,args=` (POSIX) instead of the BSD `-axo ... command=` form, the most likely cause of the 12 purge failures on Linux (the holder scan returned nothing); to be confirmed on that host. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .agents/skills/lib_py/agents/base.py | 2 +- .agents/skills/lib_py/delegation/heartbeat.py | 2 +- tests/conftest.py | 5 ++++ tests/test_deploy_freshness.py | 27 +++++++++++++++++++ tests/test_issue5_phase1_delegation.py | 12 ++++++++- tests/test_workspace_scope.py | 4 ++- 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index 41faa3a..bd2221a 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -47,7 +47,7 @@ def conversation_in_use(uuid: str, paths=()) -> bool: if os.environ.get(var, "").strip() == uuid: return True try: - out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], + out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True, timeout=10).stdout except Exception: out = "" diff --git a/.agents/skills/lib_py/delegation/heartbeat.py b/.agents/skills/lib_py/delegation/heartbeat.py index 11a8371..997ea1f 100644 --- a/.agents/skills/lib_py/delegation/heartbeat.py +++ b/.agents/skills/lib_py/delegation/heartbeat.py @@ -83,7 +83,7 @@ def _is_agent_cli(command: str) -> bool: def _process_table(): """{pid: (ppid, command)} from ps; empty when ps is unavailable.""" try: - out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True).stdout + out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True).stdout except OSError: return {} table = {} diff --git a/tests/conftest.py b/tests/conftest.py index 511f2a5..14ee7ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -170,6 +170,11 @@ def _isolate_mam_state_dirs(_ambient_control_plane, tmp_path, monkeypatch): state = tmp_path / ".mam-test-state" monkeypatch.setenv("MAM_JOBS_DIR", str(state / "jobs")) monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(state / "delegate_job_logs")) + # Issue #28: a workspace whose real .mam.env names a reachable broker made + # "broker unavailable" tests (T1-4) read it through the wrapper's env loader. + # Point every test at an absent env file; a test that wants a real broker + # config sets MAM_ENV_FILE itself. + monkeypatch.setenv("MAM_ENV_FILE", str(state / ".mam.env.absent")) @pytest.fixture diff --git a/tests/test_deploy_freshness.py b/tests/test_deploy_freshness.py index 52a1218..faa6365 100644 --- a/tests/test_deploy_freshness.py +++ b/tests/test_deploy_freshness.py @@ -279,12 +279,19 @@ def _resolve_private_server_doc() -> str: PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc() +# Issue #28: these guards read the nats-docker submodule. A checkout without +# `git submodule update --init` has nothing to check; skip with the reason +# instead of failing on every host that cloned without submodules. +needs_private_server_doc = pytest.mark.skipif( + not os.path.exists(PRIVATE_SERVER_DOC_PATH), + reason="nats-docker submodule not initialized (git submodule update --init)") # -------------------------------------------------------------------------- # D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment # variables that broker_config_from_env() actually parses. # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d11_private_server_env_names_valid(): sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts")) import mqtt_common @@ -315,6 +322,7 @@ def test_d11_private_server_env_names_valid(): # D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in # active configuration code blocks. # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d12_private_server_no_mam_mqtt_in_code_fences(): doc_path = PRIVATE_SERVER_DOC_PATH with open(doc_path, "r", encoding="utf-8") as f: @@ -331,6 +339,7 @@ def test_d12_private_server_no_mam_mqtt_in_code_fences(): # D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must # use valid config blocks (mqtt {) and not HTTP port flag (-m 1883). # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d13_private_server_nats_config_valid(): doc_path = PRIVATE_SERVER_DOC_PATH with open(doc_path, "r", encoding="utf-8") as f: @@ -347,6 +356,7 @@ def test_d13_private_server_nats_config_valid(): # D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid # CLI flags matching the actual scripts' argparse parsers. # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d14_private_server_cli_args_valid(): doc_path = PRIVATE_SERVER_DOC_PATH with open(doc_path, "r", encoding="utf-8") as f: @@ -367,6 +377,7 @@ def test_d14_private_server_cli_args_valid(): # D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME) # and heredocs writing it must be unquoted (< str: DOCKER_DIR = _resolve_docker_dir() COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml") +needs_docker_assets = pytest.mark.skipif( + not os.path.exists(COMPOSE_PATH), + reason="nats-docker submodule not initialized (git submodule update --init)") NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf") ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example") DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md") @@ -523,6 +541,7 @@ def _active_conf_lines(): # -------------------------------------------------------------------------- # D-22 — docker/ assets exist and are populated # -------------------------------------------------------------------------- +@needs_docker_assets def test_d22_docker_assets_exist_and_are_populated(): for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]: assert os.path.exists(p), f"Required asset {p} does not exist" @@ -533,6 +552,7 @@ def test_d22_docker_assets_exist_and_are_populated(): # -------------------------------------------------------------------------- # D-23 — compose image matches doc and is alpine # -------------------------------------------------------------------------- +@needs_docker_assets def test_d23_compose_image_matches_doc_and_is_alpine(): _, svc = _load_compose() compose_img = svc.get("image", "") @@ -552,6 +572,7 @@ def test_d23_compose_image_matches_doc_and_is_alpine(): # -------------------------------------------------------------------------- # D-24 — compose port exposure contract # -------------------------------------------------------------------------- +@needs_docker_assets def test_d24_compose_port_exposure_contract(): _, svc = _load_compose() ports = svc.get("ports", []) @@ -573,6 +594,7 @@ def test_d24_compose_port_exposure_contract(): # -------------------------------------------------------------------------- # D-25 — secrets are fail closed # -------------------------------------------------------------------------- +@needs_docker_assets def test_d25_secrets_are_fail_closed(): _, svc = _load_compose() env = svc.get("environment", {}) @@ -615,6 +637,7 @@ def test_d25_secrets_are_fail_closed(): # -------------------------------------------------------------------------- # D-26 — nats_conf jetstream and mqtt contract # -------------------------------------------------------------------------- +@needs_docker_assets def test_d26_nats_conf_jetstream_and_mqtt_contract(): _, svc = _load_compose() volumes = svc.get("volumes", []) @@ -645,6 +668,7 @@ def test_d26_nats_conf_jetstream_and_mqtt_contract(): # -------------------------------------------------------------------------- # D-27 — observer permissions match topic root # -------------------------------------------------------------------------- +@needs_docker_assets def test_d27_observer_permissions_match_topic_root(): sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts")) import mqtt_common @@ -666,6 +690,7 @@ def test_d27_observer_permissions_match_topic_root(): # -------------------------------------------------------------------------- # D-28 — healthcheck contract and image coupling # -------------------------------------------------------------------------- +@needs_docker_assets def test_d28_healthcheck_contract_and_image_coupling(): _, svc = _load_compose() hc = svc.get("healthcheck") @@ -684,6 +709,7 @@ def test_d28_healthcheck_contract_and_image_coupling(): # -------------------------------------------------------------------------- # D-29 — env secrets never tracked # -------------------------------------------------------------------------- +@needs_docker_assets def test_d29_env_secrets_never_tracked(): is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT @@ -703,6 +729,7 @@ def test_d29_env_secrets_never_tracked(): # -------------------------------------------------------------------------- # D-30 — websocket origin policy is startable # -------------------------------------------------------------------------- +@needs_docker_assets def test_d30_websocket_origin_policy_is_startable(): active_lines = _active_conf_lines() active_text = "\n".join(active_lines) diff --git a/tests/test_issue5_phase1_delegation.py b/tests/test_issue5_phase1_delegation.py index 6eee8d5..89de227 100644 --- a/tests/test_issue5_phase1_delegation.py +++ b/tests/test_issue5_phase1_delegation.py @@ -497,6 +497,11 @@ def test_importing_delegation_does_not_mutate_environment(mam_sandbox): assert set(os.environ) == keys +def _normalize_help(text: str) -> str: + text = text.replace("optional arguments:", "options:") + return re.sub(r"\s+", " ", text).strip() + + def test_shim_help_output_matches_pre_move_capture(): """The shim must be indistinguishable from the implementation at the CLI. @@ -512,7 +517,12 @@ def test_shim_help_output_matches_pre_move_capture(): capture_output=True, text=True, timeout=60, ) assert res.returncode == 0, res.stderr - assert res.stdout + res.stderr == golden, "--help drifted for {}".format(mod) + # argparse re-wraps usage/help text and renamed the "optional arguments" + # group across Python versions (Issue #28: the captures were taken on + # 3.9, the Linux host runs 3.14). Compare the content, not the layout: + # whitespace runs collapsed, the group label unified. + assert _normalize_help(res.stdout + res.stderr) == _normalize_help(golden), \ + "--help drifted for {}".format(mod) # --- PV-4: the context matrix, each scenario in a fresh interpreter ------------ diff --git a/tests/test_workspace_scope.py b/tests/test_workspace_scope.py index 251bd42..6688ccf 100644 --- a/tests/test_workspace_scope.py +++ b/tests/test_workspace_scope.py @@ -25,7 +25,9 @@ class TestWorkspaceScope(unittest.TestCase): lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh") cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'" - res = subprocess.run(cmd, shell=True, capture_output=True, text=True) + # `source` is a bash builtin; on hosts where /bin/sh is dash `shell=True` + # fails with "source: not found" (Issue #28), so run the string under bash. + res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True) self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}") parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-') work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-')