feat: complete production workflow migration
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.modules.supply_chain import run as supply_run
|
||||
from gyxx_flow.modules.supply_chain.orchestrator import config as supply_config
|
||||
from gyxx_flow.modules.supply_chain.orchestrator import (
|
||||
mcp_workflow,
|
||||
pg_writer,
|
||||
runner,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SUPPLY_WRAPPERS = (
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ "supply_chain"
|
||||
/ "orchestrator"
|
||||
/ "scripts"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acceptance_environment(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Path:
|
||||
evidence = tmp_path / "evidence.jsonl"
|
||||
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
|
||||
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
|
||||
monkeypatch.setenv("GYXX_DATA_ROOT", str(tmp_path / "data"))
|
||||
return evidence
|
||||
|
||||
|
||||
def _forbidden(message: str):
|
||||
def fail(*_args, **_kwargs):
|
||||
pytest.fail(message)
|
||||
|
||||
return fail
|
||||
|
||||
|
||||
def test_frontend_supply_run_uses_stable_business_day_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
|
||||
|
||||
assert mcp_workflow._resolve_workflow_id("purchase-confirmation", None) == (
|
||||
"purchase-confirmation_20260803"
|
||||
)
|
||||
assert mcp_workflow._resolve_workflow_id(
|
||||
"purchase-confirmation", "explicit-run"
|
||||
) == "explicit-run"
|
||||
|
||||
|
||||
def test_invalid_business_date_keeps_unique_run_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026/08/03")
|
||||
|
||||
first = mcp_workflow._resolve_workflow_id("purchase-confirmation", None)
|
||||
second = mcp_workflow._resolve_workflow_id("purchase-confirmation", None)
|
||||
|
||||
assert first.startswith("purchase-confirmation_")
|
||||
assert first != second
|
||||
|
||||
|
||||
def test_local_hermes_profile_api_key_is_used_without_copying_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = tmp_path / "profiles" / "data-analyzer"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / ".env").write_text(
|
||||
"API_SERVER_PORT=8642\nAPI_SERVER_KEY=local-only-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("GYXX_SUPPLY_HERMES_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False)
|
||||
monkeypatch.delenv("HERMES_API_KEY", raising=False)
|
||||
|
||||
assert supply_config._hermes_profile_api_key("data-analyzer") == (
|
||||
"local-only-key"
|
||||
)
|
||||
|
||||
|
||||
def test_stable_run_manifest_accepts_only_current_attempt_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
run_dir = tmp_path / "purchase-confirmation" / "run_id=stable-run"
|
||||
run_dir.mkdir(parents=True)
|
||||
old_file = run_dir / "old.csv"
|
||||
current_file = run_dir / "current.csv"
|
||||
old_file.write_text("old", encoding="utf-8")
|
||||
current_file.write_text("current", encoding="utf-8")
|
||||
(run_dir / "_run_manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": "stable-run",
|
||||
"files": [old_file.name, current_file.name],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(mcp_workflow, "SHARED_DIR", tmp_path)
|
||||
|
||||
assert mcp_workflow._validate_run_manifest(
|
||||
"purchase-confirmation", "stable-run", [str(current_file)]
|
||||
) == (True, "")
|
||||
assert set(
|
||||
mcp_workflow._reusable_collected_files(
|
||||
"purchase-confirmation", "stable-run"
|
||||
)
|
||||
) == {str(old_file), str(current_file)}
|
||||
|
||||
|
||||
def test_replenishment_batch_uses_stable_business_day_ids(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from gyxx_flow.modules.supply_chain.orchestrator.scripts import batch_process
|
||||
|
||||
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
|
||||
|
||||
assert batch_process._stable_run_id("replenishment") == (
|
||||
"replenishment_20260803"
|
||||
)
|
||||
assert batch_process._stable_run_id("replenishment-alert") == (
|
||||
"replenishment-alert_20260803"
|
||||
)
|
||||
|
||||
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment-alert")
|
||||
assert batch_process._is_alert_only_target()
|
||||
|
||||
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment")
|
||||
assert not batch_process._is_alert_only_target()
|
||||
|
||||
|
||||
def test_alert_batch_stops_before_replenishment_side_effects(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from gyxx_flow.modules.supply_chain.orchestrator.scripts import batch_process
|
||||
|
||||
threshold_file = tmp_path / "threshold_alert_data.json"
|
||||
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
|
||||
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment-alert")
|
||||
monkeypatch.setattr(batch_process, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(batch_process, "THRESHOLD_ALERT_FILE", threshold_file)
|
||||
monkeypatch.setattr(batch_process, "_PG_IMPORT_OK", False)
|
||||
monkeypatch.setattr(batch_process, "_run_product_replenishment", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
batch_process,
|
||||
"_ensure_threshold_alert_file",
|
||||
lambda: {"alert_count": 1, "alerts": [{"sku": "sku-1"}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
batch_process,
|
||||
"_pick_latest_xlsx",
|
||||
_forbidden("alert workflow must not enter replenishment persistence"),
|
||||
)
|
||||
|
||||
assert batch_process.main() == 0
|
||||
payload = json.loads(threshold_file.read_text(encoding="utf-8"))
|
||||
assert payload["run_id"] == "replenishment-alert_20260803"
|
||||
assert payload["pg_ok"] is False
|
||||
|
||||
|
||||
def _forbid_gateway_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_acquire_workflow_lock",
|
||||
_forbidden("cookie preflight must run before workflow locking"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_send_collector_heartbeat",
|
||||
_forbidden("cookie preflight must run before Feishu heartbeats"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"call_hermes",
|
||||
_forbidden("cookie preflight must run before Hermes"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_run_local_collection_script",
|
||||
_forbidden("cookie preflight must not start a browser collector"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow.requests,
|
||||
"Session",
|
||||
_forbidden("cookie preflight must not create an HTTP session"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow.requests,
|
||||
"post",
|
||||
_forbidden("cookie preflight must not send an HTTP request"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("workflow_name", "expected_script_id"),
|
||||
[
|
||||
(
|
||||
"purchase-confirmation",
|
||||
"supply_chain:orchestrator/scripts/PurchaseConfirmation.py",
|
||||
),
|
||||
(
|
||||
"replenishment-alert",
|
||||
"supply_chain:orchestrator/scripts/ProductReplenishment.py",
|
||||
),
|
||||
(
|
||||
"replenishment",
|
||||
"supply_chain:orchestrator/scripts/ProductReplenishment.py",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_gateway_skips_missing_cookie_before_all_external_effects(
|
||||
workflow_name: str,
|
||||
expected_script_id: str,
|
||||
acceptance_environment: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
||||
_forbid_gateway_side_effects(monkeypatch)
|
||||
|
||||
result = mcp_workflow.run_mcp_workflow(workflow_name, "acceptance-run")
|
||||
|
||||
assert result["status"] == "skipped_cookie"
|
||||
assert result["workflow_id"] == "acceptance-run"
|
||||
assert result["cookie_preflight"]["script_id"] == expected_script_id
|
||||
assert result["cookie_preflight"]["status"] == "SKIPPED_COOKIE"
|
||||
assert result["reason"] == "browser state is missing"
|
||||
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in acceptance_environment.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
gateway_event = next(
|
||||
item for item in events if item["event"] == "supply_gateway_cookie_skipped"
|
||||
)
|
||||
assert gateway_event["operation"] == f"supply.{workflow_name}.cookie_preflight"
|
||||
assert gateway_event["details"]["script_id"] == expected_script_id
|
||||
assert gateway_event["details"]["workflow_id"] == "acceptance-run"
|
||||
|
||||
|
||||
def test_profile_without_required_platform_cookie_is_still_skipped(
|
||||
acceptance_environment: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
||||
binding = mcp_workflow._acceptance_binding_for_workflow("purchase-confirmation")
|
||||
cookie_db = binding.profile_dir / "Default" / "Network" / "Cookies"
|
||||
cookie_db.parent.mkdir(parents=True)
|
||||
with sqlite3.connect(cookie_db) as connection:
|
||||
connection.execute("CREATE TABLE cookies (expires_utc INTEGER)")
|
||||
connection.execute(
|
||||
"INSERT INTO cookies (expires_utc) VALUES (?)",
|
||||
(999_999_999_999_999_999,),
|
||||
)
|
||||
(binding.profile_dir / "Local State").write_text("{}", encoding="utf-8")
|
||||
_forbid_gateway_side_effects(monkeypatch)
|
||||
|
||||
result = mcp_workflow.run_mcp_workflow(
|
||||
"purchase-confirmation",
|
||||
"nonempty-profile-run",
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped_cookie"
|
||||
assert result["cookie_preflight"]["script_id"].endswith(
|
||||
"/PurchaseConfirmation.py"
|
||||
)
|
||||
assert result["reason"] == "browser state is missing"
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in acceptance_environment.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
assert events[-1]["event"] == "supply_gateway_cookie_skipped"
|
||||
assert events[-1]["details"]["reason"] == "browser state is missing"
|
||||
|
||||
|
||||
def test_supply_credentials_allow_preflight_without_cookie_or_live_cdp(
|
||||
acceptance_environment: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
del acceptance_environment
|
||||
monkeypatch.setenv("GYXX_SUPPLY_ERP_PASSWORD", "configured-for-test")
|
||||
lock_calls: list[tuple[str, str]] = []
|
||||
|
||||
def reject_lock(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
|
||||
lock_calls.append((workflow_name, workflow_id))
|
||||
return False, "stop-after-preflight"
|
||||
|
||||
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", reject_lock)
|
||||
|
||||
result = mcp_workflow.run_mcp_workflow(
|
||||
"purchase-confirmation",
|
||||
"credential-fallback-run",
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "stop-after-preflight"
|
||||
assert lock_calls == [("purchase-confirmation", "credential-fallback-run")]
|
||||
|
||||
|
||||
def test_analyzer_facts_include_authoritative_absolute_collected_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
collected_file = tmp_path / "purchase_confirmation.csv"
|
||||
|
||||
facts = mcp_workflow._build_analyze_facts(
|
||||
"purchase-confirmation",
|
||||
[str(collected_file)],
|
||||
)
|
||||
|
||||
assert facts == [
|
||||
"AUTHORITATIVE COLLECTED FILES (absolute paths): "
|
||||
f"{collected_file.resolve()}"
|
||||
]
|
||||
|
||||
|
||||
def test_close_workflow_browser_terminates_only_bound_cdp_tree(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeProcess:
|
||||
def __init__(
|
||||
self,
|
||||
pid: int,
|
||||
cmdline: list[str],
|
||||
children: list[FakeProcess] | None = None,
|
||||
) -> None:
|
||||
self.pid = pid
|
||||
self.info = {"pid": pid, "cmdline": cmdline}
|
||||
self._children = children or []
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
|
||||
def children(self, *, recursive: bool) -> list[FakeProcess]:
|
||||
assert recursive is True
|
||||
return self._children
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
child = FakeProcess(102, ["chrome.exe", "--type=renderer"])
|
||||
bound = FakeProcess(
|
||||
101,
|
||||
["chrome.exe", "--remote-debugging-port=22116"],
|
||||
[child],
|
||||
)
|
||||
unrelated = FakeProcess(
|
||||
201,
|
||||
["chrome.exe", "--remote-debugging-port=22115"],
|
||||
)
|
||||
waited: list[tuple[int, ...]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_acceptance_binding_for_workflow",
|
||||
lambda _workflow_name: SimpleNamespace(cdp_port=22116),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow.psutil,
|
||||
"process_iter",
|
||||
lambda _attrs: [bound, unrelated],
|
||||
)
|
||||
|
||||
def fake_wait_procs(processes, *, timeout: int):
|
||||
assert timeout == 5
|
||||
waited.append(tuple(process.pid for process in processes))
|
||||
return list(processes), []
|
||||
|
||||
monkeypatch.setattr(mcp_workflow.psutil, "wait_procs", fake_wait_procs)
|
||||
|
||||
closed = mcp_workflow._close_workflow_browser("purchase-confirmation")
|
||||
|
||||
assert closed == [101, 102]
|
||||
assert bound.terminated is True
|
||||
assert child.terminated is True
|
||||
assert unrelated.terminated is False
|
||||
assert unrelated.killed is False
|
||||
assert waited == [(102, 101)]
|
||||
|
||||
|
||||
def test_close_workflow_browser_ignores_other_workflows(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_acceptance_binding_for_workflow",
|
||||
_forbidden("unmanaged workflow must not resolve a browser binding"),
|
||||
)
|
||||
|
||||
assert mcp_workflow._close_workflow_browser("purchase-order-update") == []
|
||||
|
||||
|
||||
def test_purchase_order_update_block_precedes_cookie_preflight(
|
||||
acceptance_environment: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_acceptance_cookie_preflight",
|
||||
_forbidden("ERP mutation block must take precedence over cookie inspection"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_acquire_workflow_lock",
|
||||
_forbidden("blocked ERP workflow must not acquire a lock"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_send_collector_heartbeat",
|
||||
_forbidden("blocked ERP workflow must not send a heartbeat"),
|
||||
)
|
||||
|
||||
result = mcp_workflow.run_mcp_workflow(
|
||||
"purchase-order-update",
|
||||
"blocked-erp-run",
|
||||
)
|
||||
|
||||
assert result["status"] == "blocked_business_mutation"
|
||||
assert "business_mutation_blocked" in acceptance_environment.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_analyzer_prompt_does_not_rely_on_two_hop_acceptance_environment(
|
||||
acceptance_environment: Path,
|
||||
) -> None:
|
||||
prompt = mcp_workflow._build_analyze_prompt("replenishment")
|
||||
|
||||
assert "Hermes 网关不会继承编排进程的验收环境变量" in prompt
|
||||
assert "不得调用 insert_replenishment_bitable.py" in prompt
|
||||
assert prompt.rfind("【验收最终覆盖规则】") > prompt.rfind(
|
||||
"多维表写入统一走 Python 脚本"
|
||||
)
|
||||
assert mcp_workflow.ANALYZER_OWNER_OPEN_ID in prompt
|
||||
assert "ou_8ee224968aa26a74c7d30ba27fed5eeb" not in prompt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_text",
|
||||
[
|
||||
"没有向业务用户发送任何过程或进度消息,仅投递最终业务通知。",
|
||||
"未向目标用户推送额外进度更新,只发送最终结果。",
|
||||
(
|
||||
"No additional process/progress messages were sent to users; "
|
||||
"final business notification delivered."
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_notification_policy_allows_explicitly_negated_process_messages(
|
||||
response_text: str,
|
||||
) -> None:
|
||||
assert not mcp_workflow._contains_process_notification_violation(response_text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_text",
|
||||
[
|
||||
"已向业务用户发送进度通知。",
|
||||
"Progress notification sent to the target user.",
|
||||
],
|
||||
)
|
||||
def test_notification_policy_rejects_process_messages_sent_to_users(
|
||||
response_text: str,
|
||||
) -> None:
|
||||
assert mcp_workflow._contains_process_notification_violation(response_text)
|
||||
|
||||
|
||||
def test_notification_receipt_rejects_empty_message_ids() -> None:
|
||||
with pytest.raises(ValueError, match="non-empty message_id"):
|
||||
pg_writer.mark_notification_sent("replenishment", "run-1", [])
|
||||
|
||||
|
||||
def test_notification_receipt_requires_persisted_message_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: False)
|
||||
assert not mcp_workflow._notification_receipt_exists("replenishment", "run-1")
|
||||
|
||||
monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: True)
|
||||
assert mcp_workflow._notification_receipt_exists("replenishment", "run-1")
|
||||
|
||||
|
||||
def test_notification_receipt_falls_back_to_zero_row_workflow_batch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeCursor:
|
||||
def __init__(self) -> None:
|
||||
self.fetches = iter((None, None, (1,)))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def execute(self, _sql: str, _params: object) -> None:
|
||||
return None
|
||||
|
||||
def fetchone(self):
|
||||
return next(self.fetches)
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self) -> None:
|
||||
self.cursor_instance = FakeCursor()
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self.cursor_instance
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
|
||||
|
||||
assert pg_writer.notification_already_sent(
|
||||
"purchase-confirmation", "zero-row-run"
|
||||
)
|
||||
|
||||
|
||||
def test_production_mode_preserves_the_existing_lock_first_flow(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
mcp_workflow,
|
||||
"_acceptance_cookie_preflight",
|
||||
_forbidden("production mode must not run the acceptance preflight"),
|
||||
)
|
||||
lock_calls: list[tuple[str, str]] = []
|
||||
|
||||
def reject_lock(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
|
||||
lock_calls.append((workflow_name, workflow_id))
|
||||
return False, "already running"
|
||||
|
||||
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", reject_lock)
|
||||
|
||||
result = mcp_workflow.run_mcp_workflow("replenishment", "production-run")
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "already running"
|
||||
assert lock_calls == [("replenishment", "production-run")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected"),
|
||||
[
|
||||
("completed", 0),
|
||||
("skipped_cookie", COOKIE_SKIP_EXIT_CODE),
|
||||
("blocked_business_mutation", runner.BLOCKED_EXIT_CODE),
|
||||
("failed", 1),
|
||||
],
|
||||
)
|
||||
def test_both_supply_clis_propagate_structured_result_exit_codes(
|
||||
status: str,
|
||||
expected: int,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
result = {"workflow": "replenishment", "status": status}
|
||||
monkeypatch.setattr(runner, "run_mcp", lambda _workflow: result)
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["runner.py", "mcp", "replenishment"],
|
||||
)
|
||||
assert runner.main() == expected
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["run.py", "mcp-run", "replenishment"],
|
||||
)
|
||||
assert supply_run.main() == expected
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
def test_batch_exit_code_uses_failure_then_block_then_cookie_precedence() -> None:
|
||||
assert runner.result_exit_code(
|
||||
{
|
||||
"results": [
|
||||
{"status": "completed"},
|
||||
{"status": "skipped_cookie"},
|
||||
]
|
||||
}
|
||||
) == COOKIE_SKIP_EXIT_CODE
|
||||
assert runner.result_exit_code(
|
||||
{
|
||||
"results": [
|
||||
{"status": "skipped_cookie"},
|
||||
{"status": "blocked_business_mutation"},
|
||||
]
|
||||
}
|
||||
) == runner.BLOCKED_EXIT_CODE
|
||||
assert runner.result_exit_code(
|
||||
{
|
||||
"results": [
|
||||
{"status": "blocked_business_mutation"},
|
||||
{"status": "failed"},
|
||||
]
|
||||
}
|
||||
) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script_name",
|
||||
["collect_confirmation.ps1", "collect_replenishment.ps1"],
|
||||
)
|
||||
def test_direct_collection_wrappers_gate_before_browser_start(
|
||||
script_name: str,
|
||||
) -> None:
|
||||
source = (SUPPLY_WRAPPERS / script_name).read_text(encoding="utf-8-sig")
|
||||
|
||||
acceptance_position = source.index('if ($env:GYXX_WORKFLOW_ACCEPTANCE -eq "1")')
|
||||
gate_position = source.index("[SKIPPED_COOKIE]")
|
||||
browser_start_position = source.index("Start-Process -FilePath $chromePath")
|
||||
assert gate_position < browser_start_position
|
||||
assert "exit 75" in source[gate_position:browser_start_position]
|
||||
assert '$forceFreshBrowser = $false' in source[gate_position - 200:browser_start_position]
|
||||
assert "$env:GYXX_SUPPLY_ERP_PASSWORD" in source[
|
||||
acceptance_position:browser_start_position
|
||||
]
|
||||
assert "starting the target-owned profile for credential fallback" in source
|
||||
|
||||
|
||||
def test_direct_purchase_order_wrapper_blocks_before_task_and_browser_access() -> None:
|
||||
source = (SUPPLY_WRAPPERS / "collect_purchase_order_update.ps1").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
|
||||
gate_position = source.index("[BLOCKED_BUSINESS_MUTATION]")
|
||||
assert gate_position < source.index("$taskFile =")
|
||||
assert gate_position < source.index("Start-Process -FilePath $chromePath")
|
||||
assert "exit 77" in source[gate_position:source.index("$scriptDir =")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("script_name", "expected_code", "expected_marker"),
|
||||
[
|
||||
("collect_confirmation.ps1", COOKIE_SKIP_EXIT_CODE, "SKIPPED_COOKIE"),
|
||||
("collect_replenishment.ps1", COOKIE_SKIP_EXIT_CODE, "SKIPPED_COOKIE"),
|
||||
(
|
||||
"collect_purchase_order_update.ps1",
|
||||
runner.BLOCKED_EXIT_CODE,
|
||||
"BLOCKED_BUSINESS_MUTATION",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_direct_wrapper_process_never_calls_start_process_when_gated(
|
||||
script_name: str,
|
||||
expected_code: int,
|
||||
expected_marker: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
pytest.skip("Windows PowerShell is unavailable")
|
||||
|
||||
target = SUPPLY_WRAPPERS / script_name
|
||||
escaped_target = str(target).replace("'", "''")
|
||||
command = "\n".join(
|
||||
[
|
||||
"function global:Get-NetTCPConnection {",
|
||||
" param([int]$LocalPort, [string]$State)",
|
||||
" return $null",
|
||||
"}",
|
||||
"function global:Start-Process { throw 'START_PROCESS_CALLED' }",
|
||||
f"& '{escaped_target}'",
|
||||
"$code = $LASTEXITCODE",
|
||||
"exit $code",
|
||||
]
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"GYXX_WORKFLOW_ACCEPTANCE": "1",
|
||||
"GYXX_PROJECT_ROOT": str(PROJECT_ROOT),
|
||||
"GYXX_DATA_ROOT": str(tmp_path / "data"),
|
||||
}
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
command,
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
output = completed.stdout + completed.stderr
|
||||
assert completed.returncode == expected_code, output
|
||||
assert expected_marker in output
|
||||
assert "START_PROCESS_CALLED" not in output
|
||||
Reference in New Issue
Block a user