724 lines
23 KiB
Python
724 lines
23 KiB
Python
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
|