156 lines
9.4 KiB
Python
156 lines
9.4 KiB
Python
import contextlib
|
|
import hashlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import failure_reconciliation as failures
|
|
import scheduler_progress as progress
|
|
import prepare_module_implementation_packet as packets
|
|
import run_freecad_web_scheduler as scheduler
|
|
from inspect_web_module_artifacts import inspect
|
|
import test_failure_reconciliation as fixtures
|
|
|
|
|
|
class ReconciliationEfficiencyTests(unittest.TestCase):
|
|
setUp = fixtures.ReconciliationTests.setUp
|
|
tearDown = fixtures.ReconciliationTests.tearDown
|
|
save_source = fixtures.ReconciliationTests.save_source
|
|
|
|
def test_inline_inspection_parse_error_is_not_a_cad_runtime_error(self):
|
|
entry = {"command": "node --input-type=module -e 'inspect FreeCADWebDraftRuntimeProbe)'",
|
|
"lastError": "file:///project/[eval1]:1 inspect() ) ^ SyntaxError: Unexpected token ')' at compileSourceTextModule"}
|
|
self.assertEqual(failures.classify(entry)[0], "diagnostic_tool")
|
|
entry["lastError"] = "file:///project/[eval1]:1 new Function(code) ^ SyntaxError: Unexpected token at new Function"
|
|
self.assertEqual(failures.classify(entry)[0], "business_runtime")
|
|
|
|
def test_scope_guard_is_reconciled_without_changing_raw_failures(self):
|
|
self.entry.update(command="/bin/bash -lc 'git status --short'", lastExitCode=64,
|
|
firstError="SCHEDULER_GIT_SCOPE_REJECTED: git status requires a pathspec")
|
|
self.save_source(self.entry)
|
|
before = failures.SOURCE.read_bytes()
|
|
result = failures.synchronize()
|
|
self.assertEqual(result["records"]["one"]["status"], "not_applicable")
|
|
self.assertEqual(failures.SOURCE.read_bytes(), before)
|
|
self.assertTrue(failures.summary_current()["complete"])
|
|
first_proof = result["records"]["one"]["resolution"]["evidencePath"]
|
|
self.entry["count"] = 2
|
|
self.save_source(self.entry)
|
|
repeated = failures.synchronize()
|
|
self.assertNotEqual(repeated["records"]["one"]["resolution"]["evidencePath"], first_proof)
|
|
self.assertTrue((self.root / first_proof).is_file())
|
|
|
|
def test_guard_rule_does_not_archive_mixed_commands_or_other_errors(self):
|
|
entry = {"command": "git status --short && node case.mjs", "lastExitCode": 64,
|
|
"firstError": "SCHEDULER_GIT_SCOPE_REJECTED"}
|
|
self.assertIsNone(failures.diagnostic_disposition(entry))
|
|
entry.update(command="git status --short", lastExitCode=1)
|
|
self.assertIsNone(failures.diagnostic_disposition(entry))
|
|
entry.update(lastExitCode=64, firstError="repository unavailable")
|
|
self.assertIsNone(failures.diagnostic_disposition(entry))
|
|
|
|
def test_readonly_tail_after_rejected_git_is_reconciled_without_replay(self):
|
|
for command in ["git status --short && ls src/WebAssembly && ls src/WebAssembly/FreeCADCoreProbe",
|
|
"git diff --stat && git diff -- web/src/cam.worker.ts | sed -n '1,160p'"]:
|
|
with self.subTest(command=command):
|
|
self.entry.update(command=command, lastExitCode=64, firstError="SCHEDULER_GIT_SCOPE_REJECTED")
|
|
self.save_source(self.entry)
|
|
raw = failures.SOURCE.read_bytes()
|
|
result = failures.synchronize()
|
|
self.assertEqual(result["records"]["one"]["status"], "not_applicable")
|
|
self.assertEqual(failures.SOURCE.read_bytes(), raw)
|
|
|
|
def test_shell_side_effects_and_unknown_commands_are_never_auto_closed(self):
|
|
for command in ["git status --short; cmake --build tree", "git status --short || node case.mjs",
|
|
"git status --short && sed -i 's/a/b/' source.cpp", "git status --short && ls $(touch file)",
|
|
"git status --short && ls `touch file`", "git status --short && ls > output",
|
|
"git status --short | sed -n '1p' source", "git status --short && git diff --output=out",
|
|
"git status --short && python3 case.py", "git status --short && ls\nnode case.mjs"]:
|
|
with self.subTest(command=command):
|
|
self.assertIsNone(failures.diagnostic_disposition({"command": command, "lastExitCode": 64,
|
|
"firstError": "SCHEDULER_GIT_SCOPE_REJECTED"}))
|
|
|
|
def test_attached_shell_operators_cannot_bypass_readonly_check(self):
|
|
self.assertTrue(failures.guarded_inspection_chain("git status --short&&ls x"))
|
|
for command in ["git status --short && ls x||node case.mjs", "git status --short && ls x;node case.mjs",
|
|
"git status --short && ls x&node case.mjs", "bash -lc 'git status --short' ; node case.mjs",
|
|
"git status --short && sed -n 1p -e '1w output' source"]:
|
|
self.assertFalse(failures.guarded_inspection_chain(command))
|
|
|
|
def test_warm_reconciliation_keeps_valid_snapshot_but_rechecks_proof_sources(self):
|
|
self.entry.update(command="sed -n 1p old.json")
|
|
self.save_source(self.entry)
|
|
source = self.root / "checker.py"
|
|
source.write_text("version = 1\n")
|
|
failures.review(["one"], "superseded", "replacement checked", [source])
|
|
before = failures.OUTPUT.read_bytes()
|
|
failures.synchronize()
|
|
self.assertEqual(failures.OUTPUT.read_bytes(), before)
|
|
source.write_text("version = 2\n")
|
|
self.assertEqual(failures.synchronize()["records"]["one"]["status"], "pending")
|
|
|
|
|
|
class ReviewSchedulingTests(unittest.TestCase):
|
|
def test_unresolved_review_yields_without_cooling_business_module(self):
|
|
with tempfile.TemporaryDirectory() as directory, patch.object(progress, "PROGRESS_PATH", Path(directory) / "progress.json"):
|
|
for cycle in range(1, 8):
|
|
batch = {"batchId": f"review-{cycle}", "selected": {"module": "Draft", "tier": "failure-reconciliation"},
|
|
"records": [[f"failure:Draft:key{cycle}"]]}
|
|
result = progress.record_outcome(batch, {}, {}, cycle=cycle, now=1000 + cycle)
|
|
self.assertEqual(result["deferredIds"], [f"failure:Draft:key{cycle}"])
|
|
self.assertEqual(progress.cooling_modules(now=1010), {})
|
|
self.assertNotIn("Draft:new-business", progress.cooling_ids(now=1010, candidates={"Draft:new-business"}))
|
|
self.assertTrue(progress.read(progress.PROGRESS_PATH)["history"])
|
|
|
|
def test_packet_excludes_already_closed_failure_records(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
batch = root / "batch.json"
|
|
output = root / "packet.json"
|
|
batch.write_text(json.dumps({"status": "ready", "batchId": "b", "selected": {"module": "Draft", "tier": "failure-reconciliation"},
|
|
"records": [["failure:Draft:open"], ["failure:Draft:closed"]], "failureKeys": ["open", "closed"]}))
|
|
row = {"key": "open", "category": "diagnostic_tool", "modules": ["Draft"], "failureFingerprint": "one", "status": "pending", "sourceSnapshot": {}}
|
|
ledger = {"records": {"open": row, "closed": {**row, "key": "closed", "status": "resolved"}}}
|
|
with patch.object(packets, "BATCH_PATH", batch), patch.object(packets, "OUTPUT_PATH", output), \
|
|
patch.object(packets, "reconcile_failures", return_value=ledger), contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(packets.main(), 0)
|
|
value = json.loads(output.read_text())
|
|
self.assertEqual(value["recordCount"], 1)
|
|
self.assertEqual(value["failureReconciliation"]["records"][0]["key"], "open")
|
|
|
|
def test_fully_reconciled_batch_needs_no_model_turn(self):
|
|
batch = {"selected": {"tier": "failure-reconciliation"}, "failureKeys": ["one"]}
|
|
with patch.object(failures, "pending_keys", return_value=[]):
|
|
self.assertTrue(scheduler.batch_has_completed_evidence(batch))
|
|
with patch.object(failures, "pending_keys", return_value=["one"]):
|
|
self.assertFalse(scheduler.batch_has_completed_evidence(batch))
|
|
|
|
|
|
class ArtifactInspectionTests(unittest.TestCase):
|
|
def test_published_files_and_hashes_are_checked_without_loading_wasm(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
public = root / "web/public"
|
|
(public / "modules").mkdir(parents=True)
|
|
(public / "kernel").mkdir()
|
|
artifact = {"runtimeUrl": "/kernel/probe.js", "wasmUrl": "/kernel/probe.wasm", "pthreadWorkerUrl": "/kernel/probe.worker.js", "hashes": {}}
|
|
for kind, file in (("runtime", "probe.js"), ("wasm", "probe.wasm"), ("pthreadWorker", "probe.worker.js")):
|
|
content = f"test {kind}".encode()
|
|
(public / "kernel" / file).write_bytes(content)
|
|
artifact["hashes"][kind] = hashlib.sha256(content).hexdigest()
|
|
(public / "modules/manifest.json").write_text(json.dumps({"modules": {"Draft": {"workers": {"draft.worker.ts": artifact}}}}))
|
|
self.assertEqual(inspect("Draft", root)["status"], "passed")
|
|
(public / "kernel/probe.worker.js").unlink()
|
|
self.assertEqual(inspect("Draft", root)["status"], "failed")
|
|
(public / "kernel/probe.worker.js").write_text("wrong content")
|
|
self.assertEqual(inspect("Draft", root)["status"], "failed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|