215 lines
5.9 KiB
Python
215 lines
5.9 KiB
Python
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"}]
|