From 3a7d7de0d332092a93d6c38a1474e19bf307d594 Mon Sep 17 00:00:00 2001 From: Sebastian Jeong Date: Sun, 16 Aug 2026 21:43:49 +0900 Subject: [PATCH] test: add unified DOSBox-X milestone runner --- .gitignore | 3 + AGENTS.md | 7 +- pyproject.toml | 2 + src/ferrolang_vm/dosboxx.py | 236 +++++++++++++++++++++++++ src/ferrolang_vm/registry_m1_m3.py | 69 ++++++++ src/ferrolang_vm/registry_m4_m6.py | 49 +++++ src/ferrolang_vm/suite.py | 22 +++ src/ferrolang_vm/test_cli.py | 72 ++++++++ tools/README.md | 32 +++- tools/tests/test_milestones_dosboxx.py | 54 ++++++ tools/toolchains/dosboxx.lock.json | 23 +++ uv.lock | 45 +++++ 12 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 src/ferrolang_vm/dosboxx.py create mode 100644 src/ferrolang_vm/registry_m1_m3.py create mode 100644 src/ferrolang_vm/registry_m4_m6.py create mode 100644 src/ferrolang_vm/suite.py create mode 100644 src/ferrolang_vm/test_cli.py create mode 100644 tools/tests/test_milestones_dosboxx.py create mode 100644 tools/toolchains/dosboxx.lock.json diff --git a/.gitignore b/.gitignore index ac1e67a..e699b19 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 629b919..c9590c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,16 @@ VM 자동화 **명령 목록과 플래그는 문서가 아니라 CLI가 규범** ```powershell uv run ferro-vm --help uv run ferro-vm --help +uv run ferro-test --help ``` ## 검증 규칙 -- **실행 검증은 QEMU FreeDOS 내부에서만 한다.** DOSBox는 쓰지 않는다. +- 개발 중 빠른 회귀 검사는 `uv run ferro-test run --through `로 + 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 컴파일러 결과는 정식 검증으로 diff --git a/pyproject.toml b/pyproject.toml index 2660113..9b0a084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/ferrolang_vm/dosboxx.py b/src/ferrolang_vm/dosboxx.py new file mode 100644 index 0000000..1deb9c6 --- /dev/null +++ b/src/ferrolang_vm/dosboxx.py @@ -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 diff --git a/src/ferrolang_vm/registry_m1_m3.py b/src/ferrolang_vm/registry_m1_m3.py new file mode 100644 index 0000000..a9623ab --- /dev/null +++ b/src/ferrolang_vm/registry_m1_m3.py @@ -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)) diff --git a/src/ferrolang_vm/registry_m4_m6.py b/src/ferrolang_vm/registry_m4_m6.py new file mode 100644 index 0000000..48b23e0 --- /dev/null +++ b/src/ferrolang_vm/registry_m4_m6.py @@ -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)) diff --git a/src/ferrolang_vm/suite.py b/src/ferrolang_vm/suite.py new file mode 100644 index 0000000..7072f51 --- /dev/null +++ b/src/ferrolang_vm/suite.py @@ -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] diff --git a/src/ferrolang_vm/test_cli.py b/src/ferrolang_vm/test_cli.py new file mode 100644 index 0000000..8a9d1e3 --- /dev/null +++ b/src/ferrolang_vm/test_cli.py @@ -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()) diff --git a/tools/README.md b/tools/README.md index 3d909a5..2e3429e 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 --help +uv run ferro-test --help ``` Working rules, verification gates, and DOS build traps are in `AGENTS.md`. diff --git a/tools/tests/test_milestones_dosboxx.py b/tools/tests/test_milestones_dosboxx.py new file mode 100644 index 0000000..c6bc2fd --- /dev/null +++ b/tools/tests/test_milestones_dosboxx.py @@ -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)}" + ) diff --git a/tools/toolchains/dosboxx.lock.json b/tools/toolchains/dosboxx.lock.json new file mode 100644 index 0000000..08b1123 --- /dev/null +++ b/tools/toolchains/dosboxx.lock.json @@ -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" + ] + } +} diff --git a/uv.lock b/uv.lock index c8f60c6..c7b989e 100644 --- a/uv.lock +++ b/uv.lock @@ -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"