test: add unified DOSBox-X milestone runner
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
!.qemu/share/*.c
|
||||
.qemu/share/fec/
|
||||
|
||||
# Reproducible DOSBox-X/Open Watcom development cache and ephemeral runs
|
||||
.dosboxx/
|
||||
|
||||
.qemu/*.qcow2
|
||||
.qemu/*.img
|
||||
.qemu/*.iso
|
||||
|
||||
@@ -18,11 +18,16 @@ VM 자동화 **명령 목록과 플래그는 문서가 아니라 CLI가 규범**
|
||||
```powershell
|
||||
uv run ferro-vm --help
|
||||
uv run ferro-vm <command> --help
|
||||
uv run ferro-test --help
|
||||
```
|
||||
|
||||
## 검증 규칙
|
||||
|
||||
- **실행 검증은 QEMU FreeDOS 내부에서만 한다.** DOSBox는 쓰지 않는다.
|
||||
- 개발 중 빠른 회귀 검사는 `uv run ferro-test run --through <milestone>`로
|
||||
DOSBox-X에서 수행한다.
|
||||
이 경로도 컴파일러 A를 DOS 내부 Open Watcom으로 매번 새로 빌드한다.
|
||||
- **마일스톤의 최종 공식 검증은 QEMU FreeDOS 내부에서만 한다.** DOSBox-X 결과는
|
||||
개발용 smoke test이며 완료 게이트를 대체하지 않는다.
|
||||
- 컴파일러 A와 생성 C 모두 VM 안의 Open Watcom으로 컴파일한다.
|
||||
컴파일러 A와 bits16은 `WCL`, bits32 생성 C는 `WCL386`.
|
||||
- **호스트에서 컴파일하지 않는다.** WSL이나 호스트 C 컴파일러 결과는 정식 검증으로
|
||||
|
||||
@@ -5,11 +5,13 @@ description = "Ferro language compiler and QEMU development automation"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"onnxruntime>=1.28.0",
|
||||
"pytest>=9.0.0",
|
||||
"rapidocr>=3.9.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ferro-vm = "ferrolang_vm.cli:main"
|
||||
ferro-test = "ferrolang_vm.test_cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Reproducible DOSBox-X/Open Watcom development test backend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .daemon import ROOT
|
||||
from .suite import Case
|
||||
|
||||
|
||||
CACHE = ROOT / ".dosboxx"
|
||||
LOCK_PATH = ROOT / "tools" / "toolchains" / "dosboxx.lock.json"
|
||||
RUNS = CACHE / "runs"
|
||||
|
||||
|
||||
class DosboxError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _lock() -> dict[str, object]:
|
||||
return json.loads(LOCK_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _download(name: str, spec: dict[str, object]) -> Path:
|
||||
downloads = CACHE / "downloads"
|
||||
downloads.mkdir(parents=True, exist_ok=True)
|
||||
target = downloads / Path(str(spec["url"])).name
|
||||
expected = str(spec["sha256"]).lower()
|
||||
if target.is_file() and _sha256(target) == expected:
|
||||
return target
|
||||
target.unlink(missing_ok=True)
|
||||
partial = target.with_suffix(target.suffix + ".part")
|
||||
partial.unlink(missing_ok=True)
|
||||
print(f"ferro-test: downloading {name} {spec['version']}...")
|
||||
try:
|
||||
with urllib.request.urlopen(str(spec["url"])) as response, partial.open("wb") as output:
|
||||
shutil.copyfileobj(response, output, length=1024 * 1024)
|
||||
except Exception:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
actual = _sha256(partial)
|
||||
if actual != expected:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise DosboxError(f"{name} SHA-256 mismatch: expected {expected}, got {actual}")
|
||||
partial.replace(target)
|
||||
return target
|
||||
|
||||
|
||||
def _safe_extract(archive: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}-", dir=destination.parent))
|
||||
try:
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
root = temporary.resolve()
|
||||
for member in bundle.infolist():
|
||||
target = (temporary / member.filename).resolve()
|
||||
if target != root and root not in target.parents:
|
||||
raise DosboxError(f"unsafe archive member: {member.filename}")
|
||||
bundle.extractall(temporary)
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
temporary.replace(destination)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _required_paths(lock: dict[str, object]) -> tuple[Path, Path, list[Path]]:
|
||||
dosbox_spec = lock["dosboxx"]
|
||||
watcom_spec = lock["open_watcom"]
|
||||
assert isinstance(dosbox_spec, dict) and isinstance(watcom_spec, dict)
|
||||
dosbox = CACHE / "dosbox-x" / str(dosbox_spec["executable"])
|
||||
watcom = CACHE / "watcom"
|
||||
required = [watcom / str(item) for item in watcom_spec["required"]]
|
||||
return dosbox, watcom, required
|
||||
|
||||
|
||||
def setup(*, accept_watcom_license: bool = False) -> tuple[Path, Path]:
|
||||
if os.name != "nt":
|
||||
raise DosboxError("the DOSBox-X development backend currently supports Windows only")
|
||||
lock = _lock()
|
||||
dosbox, watcom, watcom_required = _required_paths(lock)
|
||||
marker = CACHE / "SETUP.OK"
|
||||
lock_hash = _sha256(LOCK_PATH)
|
||||
if (marker.is_file() and marker.read_text(encoding="ascii").strip() == lock_hash
|
||||
and dosbox.is_file() and all(path.is_file() for path in watcom_required)):
|
||||
return dosbox, watcom
|
||||
if not accept_watcom_license:
|
||||
raise DosboxError(
|
||||
"Open Watcom is distributed under the Sybase Open Watcom Public License. "
|
||||
"Review tools/toolchains/dosboxx.lock.json and rerun "
|
||||
"`uv run ferro-test setup --accept-watcom-license`."
|
||||
)
|
||||
dosbox_spec = lock["dosboxx"]
|
||||
watcom_spec = lock["open_watcom"]
|
||||
assert isinstance(dosbox_spec, dict) and isinstance(watcom_spec, dict)
|
||||
_safe_extract(_download("DOSBox-X", dosbox_spec), CACHE / "dosbox-x")
|
||||
_safe_extract(_download("Open Watcom", watcom_spec), watcom)
|
||||
dosbox, watcom, watcom_required = _required_paths(lock)
|
||||
missing = [str(path.relative_to(CACHE)) for path in [dosbox, *watcom_required]
|
||||
if not path.is_file()]
|
||||
if missing:
|
||||
raise DosboxError("toolchain archive is missing: " + ", ".join(missing))
|
||||
marker.write_text(lock_hash + "\n", encoding="ascii")
|
||||
return dosbox, watcom
|
||||
|
||||
|
||||
def resolve_tools() -> tuple[Path, Path]:
|
||||
lock = _lock()
|
||||
dosbox, watcom, required = _required_paths(lock)
|
||||
if not dosbox.is_file() or not all(path.is_file() for path in required):
|
||||
raise DosboxError("toolchain is not installed; run `uv run ferro-test setup`")
|
||||
return dosbox, watcom
|
||||
|
||||
|
||||
def _batch(cases: list[Case], *, show_dos: bool, trace_dos: bool) -> str:
|
||||
lines = [
|
||||
"@echo off", "if not exist RESULTS md RESULTS", "if not exist OUT md OUT",
|
||||
"set WATCOM=W:", "set INCLUDE=W:\\H",
|
||||
"set LIB=W:\\LIB286\\DOS;W:\\LIB286;W:\\LIB386\\DOS;W:\\LIB386",
|
||||
"call BUILD.BAT", "if not exist BUILD.OK goto BUILDFAIL",
|
||||
"echo PASS>RESULTS\\BUILD.RES",
|
||||
]
|
||||
for index, case in enumerate(cases):
|
||||
key = f"C{index:03d}"
|
||||
command = case.command
|
||||
if not trace_dos:
|
||||
command += f" > RESULTS\\{key}.LOG"
|
||||
lines.append(command)
|
||||
if case.expect_success:
|
||||
lines.extend([
|
||||
f"if errorlevel 1 goto {key}F", f"echo PASS>RESULTS\\{key}.RES",
|
||||
f"goto {key}D", f":{key}F", f"echo FAIL>RESULTS\\{key}.RES", f":{key}D",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
f"if errorlevel 1 goto {key}P", f"echo FAIL>RESULTS\\{key}.RES",
|
||||
f"goto {key}D", f":{key}P", f"echo PASS>RESULTS\\{key}.RES", f":{key}D",
|
||||
])
|
||||
lines.extend([
|
||||
"goto FINISH", ":BUILDFAIL", "echo FAIL>RESULTS\\BUILD.RES", ":FINISH",
|
||||
"echo DONE>RUN.OK", *(["pause"] if show_dos else []), "exit", "",
|
||||
])
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuiteRun:
|
||||
root: Path
|
||||
cases: list[Case]
|
||||
keep: bool = False
|
||||
|
||||
@property
|
||||
def fec(self) -> Path:
|
||||
return self.root / "FEC"
|
||||
|
||||
def _key(self, case: Case) -> str:
|
||||
return f"C{self.cases.index(case):03d}"
|
||||
|
||||
def result(self, case: Case | None = None) -> str:
|
||||
name = "BUILD" if case is None else self._key(case)
|
||||
path = self.fec / "RESULTS" / f"{name}.RES"
|
||||
return path.read_text(encoding="ascii").strip() if path.is_file() else "MISSING"
|
||||
|
||||
def log(self, case: Case | None = None) -> str:
|
||||
name = "BUILD" if case is None else self._key(case)
|
||||
path = self.fec / "RESULTS" / f"{name}.LOG"
|
||||
content = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
|
||||
if content:
|
||||
return content
|
||||
errors = sorted(self.fec.glob("*.ERR"))
|
||||
if errors:
|
||||
return "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in errors)
|
||||
console = self.root / "CONSOLE.LOG"
|
||||
return console.read_text(encoding="utf-8", errors="replace") if console.is_file() else ""
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if not self.keep:
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
|
||||
def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False,
|
||||
trace_dos: bool = False) -> SuiteRun:
|
||||
dosbox, watcom = resolve_tools()
|
||||
RUNS.mkdir(parents=True, exist_ok=True)
|
||||
run_root = Path(tempfile.mkdtemp(prefix="suite-", dir=RUNS))
|
||||
result = SuiteRun(run_root, cases, keep)
|
||||
fec = result.fec
|
||||
try:
|
||||
shutil.copytree(ROOT / "fec" / "src", fec / "SRC")
|
||||
shutil.copytree(ROOT / "fec" / "std", fec / "STD")
|
||||
shutil.copytree(ROOT / "fec" / "tests", fec / "TESTS")
|
||||
shutil.copy2(ROOT / "fec" / "build-dos.bat", fec / "BUILD.BAT")
|
||||
console = run_root / "CONSOLE.LOG"
|
||||
config = run_root / "DOSBOX.CON"
|
||||
config.write_text(
|
||||
f"[log]\nlogfile={console}\n[dosbox]\nlog console=quiet\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
(fec / "RUN.BAT").write_text(
|
||||
_batch(cases, show_dos=show_dos, trace_dos=trace_dos),
|
||||
encoding="ascii", newline="",
|
||||
)
|
||||
command = [str(dosbox)]
|
||||
if not show_dos:
|
||||
command.append("-silent")
|
||||
command.extend([
|
||||
"-fastlaunch", "-conf", str(config),
|
||||
"-c", f'mount C "{run_root}"', "-c", f'mount W "{watcom}" -ro',
|
||||
"-c", "C:", "-c", "cd \\FEC", "-c", "RUN.BAT",
|
||||
])
|
||||
completed = subprocess.run(command, check=False, timeout=300)
|
||||
if completed.returncode != 0:
|
||||
raise DosboxError(f"DOSBox-X exited with status {completed.returncode}")
|
||||
if not (fec / "RUN.OK").is_file():
|
||||
raise DosboxError("DOSBox-X did not complete the test batch")
|
||||
return result
|
||||
except Exception:
|
||||
result.keep = True
|
||||
raise
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Explicit M1--M3 commands and expectations from TEST-DOS.BAT."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .suite import Case
|
||||
|
||||
|
||||
def _c(milestone: int, name: str, command: str, ok: bool = True) -> Case:
|
||||
return Case(f"m{milestone}-{name}", milestone, command, ok)
|
||||
|
||||
|
||||
M1_M3_CASES: list[Case] = []
|
||||
for _name in ("basic", "literals", "keybuilt", "v012form"):
|
||||
M1_M3_CASES.append(_c(1, f"{_name}-parse", f"FEC.EXE --dump-ast TESTS\\PASS\\{_name.upper()}.FE"))
|
||||
for _name in ("core", "fmt", "io", "list", "map", "mem", "str", "sys"):
|
||||
M1_M3_CASES.append(_c(1, f"std-{_name}-parse", f"FEC.EXE --dump-ast STD\\{_name.upper()}.FE"))
|
||||
for _name in ("misssemi", "unclcomm", "logical"):
|
||||
M1_M3_CASES.append(_c(1, f"{_name}-reject", f"FEC.EXE --dump-ast TESTS\\FAIL\\{_name.upper()}.FE", False))
|
||||
|
||||
for _name in ("hello", "scopes"):
|
||||
_upper = _name.upper()
|
||||
M1_M3_CASES.extend([
|
||||
_c(2, f"{_name}-emit", f"FEC.EXE --target=bits32 --emit-c TESTS\\M2\\{_upper}.FE -o TESTS\\M2\\{_upper}.C"),
|
||||
_c(2, f"{_name}-build", f"WCL386 -q -za -bt=dos -fe=TESTS\\M2\\{_upper}.EXE TESTS\\M2\\{_upper}.C"),
|
||||
_c(2, f"{_name}-run", f"TESTS\\M2\\{_upper}.EXE"),
|
||||
])
|
||||
M1_M3_CASES.extend([
|
||||
_c(2, "castwhil-emit", "FEC.EXE --target=bits16 --emit-c TESTS\\M2\\CASTWHIL.FE -o TESTS\\M2\\CAST16.C"),
|
||||
_c(2, "castwhil-build", "WCL -q -za -bt=dos -fe=TESTS\\M2\\CAST16.EXE TESTS\\M2\\CAST16.C"),
|
||||
_c(2, "castwhil-run", "TESTS\\M2\\CAST16.EXE"),
|
||||
])
|
||||
_m2_outputs = {
|
||||
"bad-cond": "BAD-CO", "bad-cast": "BAD-CA", "bad-asgn": "BAD-AS",
|
||||
"bad-unk": "BAD-UN", "bad-ari": "BAD-AR", "bad-type": "BAD-TY",
|
||||
"bad-ret": "BAD-RE", "bad-unit": "BAD-UI", "bad-void": "BAD-VO",
|
||||
}
|
||||
for _name, _output in _m2_outputs.items():
|
||||
M1_M3_CASES.append(_c(2, f"{_name}-reject", "FEC.EXE --target=bits32 --emit-c "
|
||||
f"TESTS\\M2\\{_name.upper()}.FE -o TESTS\\M2\\{_output}.C", False))
|
||||
|
||||
def _m3_runtime(name: str) -> list[Case]:
|
||||
upper = name.upper()
|
||||
return [
|
||||
_c(3, f"{name}-emit", f"FEC.EXE --target=bits32 --emit-c TESTS\\M3\\{upper}.FE -o TESTS\\M3\\{upper}.C"),
|
||||
_c(3, f"{name}-build", f"WCL386 -q -za -bt=dos -fe=TESTS\\M3\\{upper}.EXE TESTS\\M3\\{upper}.C"),
|
||||
_c(3, f"{name}-run", f"TESTS\\M3\\{upper}.EXE"),
|
||||
]
|
||||
|
||||
|
||||
for _name in ("struct", "enum", "array", "mutable"):
|
||||
M1_M3_CASES.extend(_m3_runtime(_name))
|
||||
for _name in ("bad-mlet", "bad-shwr"):
|
||||
M1_M3_CASES.append(_c(3, f"{_name}-reject", "FEC.EXE --target=bits32 --emit-c "
|
||||
f"TESTS\\M3\\{_name.upper()}.FE -o TESTS\\M3\\{_name.upper()}.C", False))
|
||||
for _name in ("str", "for", "nested", "char", "arrayctx"):
|
||||
M1_M3_CASES.extend(_m3_runtime(_name))
|
||||
for _name in ("bounds", "slcbound"):
|
||||
_upper = _name.upper()
|
||||
M1_M3_CASES.extend([
|
||||
_c(3, f"{_name}-emit", f"FEC.EXE --target=bits32 --emit-c TESTS\\M3\\{_upper}.FE -o TESTS\\M3\\{_upper}.C"),
|
||||
_c(3, f"{_name}-build", f"WCL386 -q -za -bt=dos -fe=TESTS\\M3\\{_upper}.EXE TESTS\\M3\\{_upper}.C"),
|
||||
_c(3, f"{_name}-trap", f"TESTS\\M3\\{_upper}.EXE", False),
|
||||
])
|
||||
M1_M3_CASES.extend([
|
||||
_c(3, "bounds-no-checks-emit", "FEC.EXE --target=bits32 --no-checks --emit-c TESTS\\M3\\BOUNDS.FE -o TESTS\\M3\\BOUNDS-N.C"),
|
||||
_c(3, "bounds-no-checks-build", "WCL386 -q -za -bt=dos -fe=TESTS\\M3\\BOUNDS-N.EXE TESTS\\M3\\BOUNDS-N.C"),
|
||||
])
|
||||
for _name in ("badfld", "badmat", "badarr", "badcycle", "badstr", "badchar", "badfield", "badindex"):
|
||||
M1_M3_CASES.append(_c(3, f"{_name}-reject", "FEC.EXE --target=bits32 --emit-c "
|
||||
f"TESTS\\M3\\{_name.upper()}.FE -o TESTS\\M3\\{_name.upper()}.C", False))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Explicit M4--M5 cases from TEST-DOS.BAT and M6 fixture expectations.
|
||||
|
||||
Only commands and their expected status live here; the ``.fe`` fixtures stay
|
||||
under ``fec/tests`` and are copied by the runner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .suite import Case
|
||||
|
||||
|
||||
def _c(milestone: int, name: str, command: str, ok: bool) -> Case:
|
||||
return Case(f"m{milestone}-{name}", milestone, command, ok)
|
||||
|
||||
|
||||
M4_M6_CASES: list[Case] = [
|
||||
_c(4, "format", "FEC.EXE --target=bits32 --emit-c TESTS\\M4\\FORMAT.FE -o TESTS\\M4\\FORMAT.C", True),
|
||||
_c(4, "format-build", "WCL386 -q -za -wx -wcd=202 -bt=dos -fe=TESTS\\M4\\FORMAT.EXE TESTS\\M4\\FORMAT.C", True),
|
||||
_c(4, "format-run", "TESTS\\M4\\FORMAT.EXE", True),
|
||||
_c(4, "try-fpr", "FEC.EXE --target=bits32 --emit-c TESTS\\M4\\TRY-FPR.FE -o TESTS\\M4\\TRY-FPR.C", True),
|
||||
_c(4, "try-fpr-build", "WCL386 -q -za -wx -wcd=202 -bt=dos -fe=TESTS\\M4\\TRY-FPR.EXE TESTS\\M4\\TRY-FPR.C", True),
|
||||
_c(4, "try-fpr-run", "TESTS\\M4\\TRY-FPR.EXE", True),
|
||||
_c(4, "prop", "FEC.EXE --target=bits32 --emit-c TESTS\\M4\\PROP.FE -o TESTS\\M4\\PROP.C", True),
|
||||
_c(4, "prop-build", "WCL386 -q -za -wx -wcd=202 -bt=dos -fe=TESTS\\M4\\PROP.EXE TESTS\\M4\\PROPTEST.C", True),
|
||||
_c(4, "prop-run", "TESTS\\M4\\PROP.EXE", True),
|
||||
]
|
||||
|
||||
_m4_outputs = {"bad-type": "BAD-TYP", "bad-writ": "BAD-WRI"}
|
||||
for _name in ("bad-ari", "bad-verb", "bad-run", "bad-type", "bad-try", "bad-writ", "bad-bufw", "bad-many", "bad-open", "bad-cls"):
|
||||
_output = _m4_outputs.get(_name, _name.upper())
|
||||
M4_M6_CASES.append(_c(4, _name, "FEC.EXE --target=bits32 --emit-c "
|
||||
f"TESTS\\M4\\{_name.upper()}.FE -o TESTS\\M4\\{_output}.C", False))
|
||||
|
||||
for _name in ("defer", "owned"):
|
||||
M4_M6_CASES.append(_c(5, _name, "FEC.EXE --target=bits32 --emit-c "
|
||||
f"TESTS\\M5\\{_name.upper()}.FE -o TESTS\\M5\\{_name.upper()}.C", True))
|
||||
for _name in ("bad-move", "bad-dest", "bad-drop", "bad-dbl", "bad-cond", "bad-proj", "bad-clos", "bad-loop"):
|
||||
_output = "BAD-DES" if _name == "bad-dest" else _name.upper()
|
||||
M4_M6_CASES.append(_c(5, _name, "FEC.EXE --target=bits32 --emit-c "
|
||||
f"TESTS\\M5\\{_name.upper()}.FE -o TESTS\\M5\\{_output}.C", False))
|
||||
M4_M6_CASES += [
|
||||
_c(5, "runtime", "FEC.EXE --target=bits32 --emit-c TESTS\\M5\\RUNTIME.FE -o TESTS\\M5\\RUNT-G.C", True),
|
||||
_c(5, "runtime-build", "WCL386 -q -za -bt=dos -dmalloc=m5_malloc -dfree=m5_free -fe=TESTS\\M5\\RUNTIME.EXE TESTS\\M5\\RUNT-G.C TESTS\\M5\\RUNTIME.C", True),
|
||||
_c(5, "runtime-run", "TESTS\\M5\\RUNTIME.EXE", True),
|
||||
]
|
||||
|
||||
for _name in ("badarg", "badbinit", "badbrmov", "baddefer", "badfld", "badglob", "badgmut", "badinv", "badlocsl", "badloop", "badmove", "badmut", "badmut2", "badptr", "badret", "badrfld", "badridx", "badscop", "badself", "badshwr", "badslfld", "badtwo", "badup", "badweak"):
|
||||
M4_M6_CASES.append(_c(6, _name, f"FEC.EXE --check TESTS\\M6\\{_name.upper()}.FE", False))
|
||||
for _name in ("okbranch", "okdefer", "okglobcp", "oklast", "okr8free", "okr8join", "okr8meth", "okr8stat", "okrebor", "okrtlast", "okshare", "okslreb", "okstatic", "oktemp", "oktrim", "okwcall"):
|
||||
M4_M6_CASES.append(_c(6, _name, f"FEC.EXE --target=bits32 --emit-c -o OUT\\{_name.upper()}.C TESTS\\M6\\{_name.upper()}.FE", True))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Shared types and selection for the explicit milestone registries."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Case:
|
||||
id: str
|
||||
milestone: int
|
||||
command: str
|
||||
expect_success: bool
|
||||
|
||||
|
||||
def all_cases(*, through: int = 6, only: int | None = None) -> list[Case]:
|
||||
from .registry_m1_m3 import M1_M3_CASES
|
||||
from .registry_m4_m6 import M4_M6_CASES
|
||||
|
||||
cases = [*M1_M3_CASES, *M4_M6_CASES]
|
||||
if only is not None:
|
||||
return [case for case in cases if case.milestone == only]
|
||||
return [case for case in cases if case.milestone <= through]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Developer test entry point backed by a disposable DOSBox-X run."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .dosboxx import DosboxError, setup
|
||||
|
||||
|
||||
MILESTONES = tuple(f"m{number}" for number in range(1, 7))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ferro-test",
|
||||
description="Fast Ferro development tests in DOSBox-X/Open Watcom.",
|
||||
)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare = commands.add_parser("setup", help="download and verify pinned development tools")
|
||||
prepare.add_argument("--accept-watcom-license", action="store_true",
|
||||
help="confirm acceptance of the Sybase Open Watcom Public License")
|
||||
run = commands.add_parser("run", help="build once and run milestone pytest cases")
|
||||
selection = run.add_mutually_exclusive_group()
|
||||
selection.add_argument("--through", choices=MILESTONES, default="m6",
|
||||
help="run cumulatively through this milestone (default: m6)")
|
||||
selection.add_argument("--only", choices=MILESTONES,
|
||||
help="run only this milestone's cases")
|
||||
run.add_argument("-v", "--verbose", action="store_true", help="show every pytest case")
|
||||
run.add_argument("--keep-failed", action="store_true",
|
||||
help="keep the disposable DOS filesystem after failures")
|
||||
run.add_argument("--show-dos", action="store_true",
|
||||
help="show DOSBox-X and wait for a key before closing")
|
||||
run.add_argument("--dos-log", action="store_true",
|
||||
help="print the captured DOS console after the run")
|
||||
run.add_argument("--trace-dos", action="store_true",
|
||||
help="do not redirect case command output")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "setup":
|
||||
dosbox, watcom = setup(accept_watcom_license=args.accept_watcom_license)
|
||||
print(f"DOSBox-X: {dosbox}")
|
||||
print(f"Open Watcom: {watcom}")
|
||||
return 0
|
||||
if args.only:
|
||||
os.environ["FERRO_TEST_ONLY"] = args.only
|
||||
else:
|
||||
os.environ["FERRO_TEST_THROUGH"] = args.through
|
||||
for enabled, name in (
|
||||
(args.keep_failed, "FERRO_TEST_KEEP_FAILED"),
|
||||
(args.show_dos, "FERRO_TEST_SHOW_DOS"),
|
||||
(args.trace_dos, "FERRO_TEST_TRACE_DOS"),
|
||||
(args.dos_log, "FERRO_TEST_DOS_LOG"),
|
||||
):
|
||||
if enabled:
|
||||
os.environ[name] = "1"
|
||||
import pytest
|
||||
test_file = os.fspath(
|
||||
Path(__file__).resolve().parents[2] / "tools" / "tests" / "test_milestones_dosboxx.py"
|
||||
)
|
||||
pytest_args = [test_file, "--tb=short", "-v" if args.verbose else "-q"]
|
||||
if args.dos_log:
|
||||
pytest_args.append("-s")
|
||||
return int(pytest.main(pytest_args))
|
||||
except (DosboxError, ValueError) as exc:
|
||||
print(f"ferro-test: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+29
-3
@@ -2,12 +2,37 @@
|
||||
|
||||
## Host support
|
||||
|
||||
Automation currently supports **Windows 10/11 only**. It requires `uv`, QEMU
|
||||
with WHPX support, and `ffmpeg.exe` on `PATH`. The Python implementation uses
|
||||
portable APIs where possible, but other hosts are not supported yet.
|
||||
Automation currently supports **Windows 10/11 only**. The fast development loop
|
||||
requires only `uv`; it downloads pinned DOSBox-X and Open Watcom DOS releases.
|
||||
The final milestone gate additionally requires QEMU with WHPX support and
|
||||
`ffmpeg.exe` on `PATH`. Other hosts are not supported yet.
|
||||
|
||||
## Getting started
|
||||
|
||||
```powershell
|
||||
uv run ferro-test setup --accept-watcom-license
|
||||
uv run ferro-test run --through m6 -v
|
||||
```
|
||||
|
||||
`setup` reads `tools/toolchains/dosboxx.lock.json`, downloads the exact official
|
||||
archives, verifies their SHA-256 hashes, and extracts them under ignored
|
||||
`.dosboxx/`. Review the Open Watcom license referenced by the lock file before
|
||||
accepting it. Archives and installed tools are deliberately not committed.
|
||||
|
||||
Each `run` creates a disposable DOS drive, copies the current compiler, standard
|
||||
library, and fixtures, then builds `FEC.EXE` once inside DOS with Open Watcom.
|
||||
All selected milestone commands execute sequentially in that same DOSBox-X
|
||||
instance, while pytest reports every emit, Watcom build, runtime, and rejection
|
||||
check separately. Thus stale QEMU binaries cannot make the test pass.
|
||||
|
||||
`--through m6` runs cumulatively from M1; `--only m6` selects one milestone.
|
||||
Use `--keep-failed` to preserve a failed drive under `.dosboxx/runs/`,
|
||||
`--dos-log` to print the captured DOS console, `--trace-dos` to disable command
|
||||
output redirection, and `--show-dos` to keep the GUI open until a key is pressed.
|
||||
|
||||
This is the quick development smoke test. Run the QEMU/FreeDOS workflow below
|
||||
for the authoritative milestone completion gate.
|
||||
|
||||
```powershell
|
||||
uv run ferro-vm start
|
||||
uv run ferro-vm status
|
||||
@@ -18,6 +43,7 @@ The command list lives in the CLI itself, not in this file:
|
||||
```powershell
|
||||
uv run ferro-vm --help
|
||||
uv run ferro-vm <command> --help
|
||||
uv run ferro-test --help
|
||||
```
|
||||
|
||||
Working rules, verification gates, and DOS build traps are in `AGENTS.md`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from ferrolang_vm.dosboxx import SuiteRun, run_suite
|
||||
from ferrolang_vm.suite import Case, all_cases
|
||||
|
||||
|
||||
def _number(name: str) -> int:
|
||||
if not name.startswith("m") or not name[1:].isdigit():
|
||||
raise ValueError(f"invalid milestone: {name}")
|
||||
value = int(name[1:])
|
||||
if value not in range(1, 7):
|
||||
raise ValueError(f"unsupported milestone: {name}")
|
||||
return value
|
||||
|
||||
|
||||
ONLY = os.environ.get("FERRO_TEST_ONLY")
|
||||
CASES = all_cases(
|
||||
through=_number(os.environ.get("FERRO_TEST_THROUGH", "m6")),
|
||||
only=_number(ONLY) if ONLY else None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def suite_run() -> SuiteRun:
|
||||
run = run_suite(
|
||||
CASES,
|
||||
keep=os.environ.get("FERRO_TEST_KEEP_FAILED") == "1",
|
||||
show_dos=os.environ.get("FERRO_TEST_SHOW_DOS") == "1",
|
||||
trace_dos=os.environ.get("FERRO_TEST_TRACE_DOS") == "1",
|
||||
)
|
||||
yield run
|
||||
if os.environ.get("FERRO_TEST_DOS_LOG") == "1":
|
||||
console = run.root / "CONSOLE.LOG"
|
||||
if console.is_file():
|
||||
print(console.read_text(encoding="utf-8", errors="replace"))
|
||||
run.cleanup()
|
||||
|
||||
|
||||
def test_compiler_build(suite_run: SuiteRun) -> None:
|
||||
assert suite_run.result() == "PASS", suite_run.log()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id)
|
||||
def test_milestone_case(case: Case, suite_run: SuiteRun) -> None:
|
||||
if suite_run.result() != "PASS":
|
||||
pytest.skip("compiler build failed")
|
||||
assert suite_run.result(case) == "PASS", (
|
||||
f"DOS command: {case.command}\nExpected success: {case.expect_success}\n"
|
||||
f"{suite_run.log(case)}"
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"dosboxx": {
|
||||
"version": "2026.08.02",
|
||||
"url": "https://github.com/joncampbell123/dosbox-x/releases/download/dosbox-x-v2026.08.02/dosbox-x-vsbuild-win64-2026.08.02-portable.zip",
|
||||
"sha256": "ca28208f5fee25a74caf3a02cc0189c7f7943a42ce37c02848f5fc03450a96cf",
|
||||
"executable": "bin/x64/Release/dosbox-x.exe"
|
||||
},
|
||||
"open_watcom": {
|
||||
"version": "2026-08-01-Build",
|
||||
"url": "https://github.com/open-watcom/open-watcom-v2/releases/download/2026-08-01-Build/open-watcom-2_0-c-dos.exe",
|
||||
"sha256": "80db4ab340f382e59bf3d396280576ec837964c2ef00e8ddd3b2b3724ab63edf",
|
||||
"required": [
|
||||
"binw/wcl.exe",
|
||||
"binw/wcl386.exe",
|
||||
"binp/wlink.exe",
|
||||
"h/stdio.h",
|
||||
"lib286/dos/clibl.lib",
|
||||
"lib386/dos/clib3r.lib",
|
||||
"license.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -175,12 +175,14 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "pytest" },
|
||||
{ name = "rapidocr" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "onnxruntime", specifier = ">=1.28.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.0" },
|
||||
{ name = "rapidocr", specifier = ">=3.9.2" },
|
||||
]
|
||||
|
||||
@@ -201,6 +203,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.2"
|
||||
@@ -418,6 +429,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "7.35.1"
|
||||
@@ -463,6 +483,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
||||
Reference in New Issue
Block a user