test: add unified DOSBox-X milestone runner

This commit is contained in:
2026-08-16 21:44:39 +09:00
parent aeec10af48
commit 3a7d7de0d3
12 changed files with 610 additions and 4 deletions
+3
View File
@@ -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
+6 -1
View File
@@ -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 컴파일러 결과는 정식 검증으로
+2
View File
@@ -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"]
+236
View File
@@ -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
+69
View File
@@ -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))
+49
View File
@@ -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))
+22
View File
@@ -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]
+72
View File
@@ -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
View File
@@ -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`.
+54
View File
@@ -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)}"
)
+23
View File
@@ -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"
]
}
}
Generated
+45
View File
@@ -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"