from __future__ import annotations import sys from datetime import datetime, timezone from pathlib import Path import pytest from gyxx_flow.adapters.native import ( DeferredModuleCommandStep, ModuleCommandAdapter, ModuleSourceError, ModuleSourceRoots, ) from gyxx_flow.catalog import WorkflowEntry from gyxx_flow.core.context import RunContext def _entry(**overrides: object) -> WorkflowEntry: values: dict[str, object] = { "workflow_id": "product.backfill", "module": "product_commerce", "trigger": "manual", "entry": "jobs/backfill.py", "args": ("--limit", "25"), "source_project": "product", } values.update(overrides) return WorkflowEntry(**values) # type: ignore[arg-type] def _context() -> RunContext: return RunContext.create( "product.backfill", "2026-07-27", now=datetime(2026, 7, 27, tzinfo=timezone.utc), random_suffix="native1", ) def test_module_adapter_resolves_only_inside_new_module_root(tmp_path: Path) -> None: root = tmp_path / "product_commerce" / "runtime" entry = root / "jobs" / "backfill.py" entry.parent.mkdir(parents=True) entry.write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), python_executable=sys.executable, base_env={"PATH": "portable"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(), context=_context()) assert command.argv == (sys.executable, str(entry.resolve()), "--limit", "25") assert command.cwd == root.resolve() assert command.env["GYXX_PROJECT_ROOT"] == str(tmp_path.resolve()) assert command.env["GYXX_DATA_ROOT"] == str((tmp_path / "var").resolve()) assert command.env["GYXX_MODULE_ROOT"] == str(root.resolve()) assert not any(name.startswith("GYXX_LEGACY_") for name in command.env) @pytest.mark.parametrize("entry", ("../outside.py", "/outside.py", "C:/outside.py")) def test_module_adapter_rejects_paths_outside_module(tmp_path: Path, entry: str) -> None: root = tmp_path / "runtime" root.mkdir() adapter = ModuleCommandAdapter(ModuleSourceRoots({"product_commerce": root})) with pytest.raises(ModuleSourceError, match="relative|escape"): adapter.build(_entry(entry=entry), context=_context()) def test_deferred_module_step_dry_run_never_resolves_or_executes() -> None: def fail(*args: object, **kwargs: object) -> object: raise AssertionError("dry-run must not resolve or execute module source") execution = DeferredModuleCommandStep(_entry(), command_factory=fail).execute( context=_context(), timeout_seconds=60, dry_run=True, ) assert execution.exit_code == 0 assert execution.skipped is True assert execution.reason == "dry-run" def test_module_adapter_uses_explicit_windows_launchers_for_batch_and_powershell( tmp_path: Path, ) -> None: root = tmp_path / "runtime" root.mkdir() (root / "daily.bat").write_text("@echo off", encoding="utf-8") (root / "maint.ps1").write_text("Write-Output ok", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={}, project_root=tmp_path, data_root=tmp_path / "var", ) batch = adapter.build(_entry(entry="daily.bat", args=()), context=_context()) powershell = adapter.build( _entry(entry="maint.ps1", args=()), context=_context() ) assert batch.argv[:4] == ("cmd.exe", "/d", "/s", "/c") assert str((root / "daily.bat").resolve()) in batch.argv[4] assert powershell.argv[:5] == ( "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ) def test_product_config_uses_only_the_new_environment_name(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={"GYXX_PRODUCT_CONFIG": "D:/portable/product.json"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_PRODUCT_CONFIG"] == "D:/portable/product.json" assert "AUTO_FLOW_CONFIG" not in command.env