import os import subprocess import sys from pathlib import Path import pytest import scripts.execute as execute_module from scripts.execute import ( CodexEnvironmentError, StepExecutor, build_codex_command, build_codex_environment, configure_standard_streams, normalize_codex_add_dirs, run_utf8_process, ) def test_normalize_codex_add_dirs_accepts_existing_absolute_directories(tmp_path): first = tmp_path / "tool" second = tmp_path / "runtime" first.mkdir() second.mkdir() result = normalize_codex_add_dirs([str(first), str(second)]) assert result == (first.resolve(), second.resolve()) @pytest.mark.parametrize("raw", ["relative/tool", "missing"]) def test_normalize_codex_add_dirs_rejects_unsafe_paths(tmp_path, raw): candidate = raw if raw.startswith("relative") else str(tmp_path / raw) with pytest.raises(CodexEnvironmentError): normalize_codex_add_dirs([candidate]) def test_build_codex_command_grants_only_explicit_directories(tmp_path): root = tmp_path / "repo" tool = tmp_path / "tool" runtime = tmp_path / "runtime" root.mkdir() tool.mkdir() runtime.mkdir() codex = tmp_path / "codex.exe" command = build_codex_command( codex, root, (tool.resolve(), runtime.resolve()), ) assert command == [ str(codex), "exec", "--json", "--sandbox", "workspace-write", "--dangerously-bypass-hook-trust", "--add-dir", str(tool.resolve()), "--add-dir", str(runtime.resolve()), "--cd", str(root.resolve()), "-", ] def test_build_codex_environment_appends_tool_directories(tmp_path): tool = (tmp_path / "tool").resolve() runtime = (tmp_path / "runtime").resolve() base = {"PATH": os.pathsep.join(("existing-one", "existing-two")), "KEEP": "value"} result = build_codex_environment(base, (tool, runtime)) assert result["PATH"].split(os.pathsep) == [ "existing-one", "existing-two", str(tool), str(runtime), ] assert result["KEEP"] == "value" assert result is not base def test_run_utf8_process_sends_prompt_as_utf8(tmp_path): verifier = tmp_path / "verify_utf8.py" verifier.write_text( "import sys\n" "payload = sys.stdin.buffer.read()\n" "text = payload.decode('utf-8')\n" "sys.stdout.buffer.write(text.encode('utf-8'))\n", encoding="utf-8", ) prompt = "FESA 한글 prompt" result = run_utf8_process( [sys.executable, str(verifier)], cwd=tmp_path, prompt=prompt, env=os.environ.copy(), timeout=10, ) assert result.returncode == 0 assert result.stdout == prompt def test_invoke_codex_records_timeout_with_partial_output(tmp_path, monkeypatch): phase_dir = tmp_path / "phases" / "test-phase" phase_dir.mkdir(parents=True) (phase_dir / "step0.md").write_text("# Timed step\n", encoding="utf-8") codex = tmp_path / "codex.exe" codex.touch() executor = StepExecutor.__new__(StepExecutor) executor._root = str(tmp_path) executor._phase_dir = phase_dir executor._codex_add_dirs = () monkeypatch.setattr( execute_module.shutil, "which", lambda executable: str(codex) ) def raise_timeout(*args, **kwargs): raise subprocess.TimeoutExpired( cmd=["codex", "exec"], timeout=1800, output=b"partial stdout", stderr=b"partial stderr", ) monkeypatch.setattr(execute_module, "run_utf8_process", raise_timeout) output = executor._invoke_codex( {"step": 0, "name": "timed-step"}, "preamble\n" ) assert output["exitCode"] == 124 assert output["stdout"] == "partial stdout" assert "partial stderr" in output["stderr"] assert "timed out after 1800 seconds" in output["stderr"] assert ( execute_module.json.loads( (phase_dir / "step0-output.json").read_text(encoding="utf-8") ) == output ) def test_execute_single_step_retries_timeout_three_times(tmp_path, monkeypatch): index_file = tmp_path / "index.json" index_file.write_text( execute_module.json.dumps( { "steps": [ {"step": 0, "name": "timed-step", "status": "pending"} ] } ), encoding="utf-8", ) executor = StepExecutor.__new__(StepExecutor) executor._index_file = index_file executor._total = 1 executor._project = "FESA" executor._phase_dir_name = "test-phase" attempts = 0 def timeout_output(step, preamble): nonlocal attempts attempts += 1 return { "step": 0, "name": "timed-step", "exitCode": 124, "stdout": "", "stderr": "Codex timed out after 1800 seconds.", } monkeypatch.setattr(executor, "_invoke_codex", timeout_output) monkeypatch.setattr(executor, "_commit_step", lambda *args: None) monkeypatch.setattr(executor, "_update_top_index", lambda *args: None) with pytest.raises(SystemExit) as exit_info: executor._execute_single_step( {"step": 0, "name": "timed-step", "status": "pending"}, "" ) assert exit_info.value.code == 1 assert attempts == 3 index = execute_module.json.loads(index_file.read_text(encoding="utf-8")) assert index["steps"][0]["status"] == "error" assert "timed out after 1800 seconds" in index["steps"][0]["error_message"] def test_configure_standard_streams_replaces_unencodable_status_characters(): class RecordingStream: def __init__(self): self.calls = [] def reconfigure(self, **kwargs): self.calls.append(kwargs) stdout = RecordingStream() stderr = RecordingStream() configure_standard_streams(stdout, stderr) assert stdout.calls == [{"errors": "replace"}] assert stderr.calls == [{"errors": "replace"}]