"""Runner + HTTP API end to end on localhost with a fake catalog and a fake bench tool. No hardware, no forgectrl.""" import json import os import shutil import sys import tempfile import threading import time import unittest import urllib.error import urllib.request import helpers from forgetest import bench as bench_mod from forgetest import catalog, server from forgetest.log import Log from forgetest.runner import Failed, Runner def t_pass(ctx): ctx.log("hello") ctx.evidence["k"] = 1 def t_prompt(ctx): ans = ctx.prompt("Did the light blink?", ("Yes", "No")) if ans != "Yes": raise Failed("operator said no") def t_fail(ctx): ctx.check(False, "deliberate") def t_slow(ctx): ctx.sleep(30) def t_error(ctx): raise RuntimeError("boom") class ServerTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.tmp = tempfile.mkdtemp(prefix="forgetest-") os.environ["FORGETEST_DATA"] = cls.tmp os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker") cls.man = helpers.make_manifest() cls.reg = helpers.registry( helpers.make_test("fake.pass", [("forgectrl", "src/ui.c")], always=True, fn=t_pass), helpers.make_test("fake.prompt", [("forgectrl", "src/auth.c")], fn=t_prompt, kind="operator"), helpers.make_test("fake.fail", [("forgectrl", "src/cool.c")], fn=t_fail), helpers.make_test("fake.slow", [("forgectrl", "src/main.c")], fn=t_slow), helpers.make_test("fake.error", [("forgectrl", "src/main.c")], fn=t_error), helpers.make_test("fake.live", [("grblhal-glowforge", "src/**")], fn=t_pass, kind="live"), helpers.make_test("fake.needs", [("kernel-module-glowforge", "**")], fn=t_pass, requires=("fake.prompt",)), ) # a fake bench tool cls.tooldir = os.path.join(cls.tmp, "bench") os.makedirs(cls.tooldir) with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f: f.write("import os, sys, time\nprint('args', sys.argv[1:])\n" "print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n" "sys.stdout.flush()\n" "if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n") tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board", "ported": True, "desc": "echo", "args": [{"name": "word", "type": "str", "default": "hi", "help": ""}, {"name": "n", "type": "int", "default": 2, "help": ""}]}, {"id": "unported", "title": "U", "script": "nope.py", "safety": "dry", "where": "host", "ported": False, "desc": "", "args": []}, {"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board", "ported": True, "desc": "", "args": []}, {"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board", "ported": True, "desc": "", "args": []}, {"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board", "ported": True, "desc": "", "args": []}] cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir, index_path=os.path.join(cls.tmp, "bench.jsonl")) cls.log = Log(os.path.join(cls.tmp, "results.jsonl")) cls.runner = Runner(cls.log, cls.man, cls.reg, cls.bench) cls.token = server.load_token(os.path.join(cls.tmp, "token")) cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export")) cls.srv = server.make_server(cls.app, "127.0.0.1", 0) cls.port = cls.srv.server_address[1] cls.th = threading.Thread(target=cls.srv.serve_forever, daemon=True) cls.th.start() @classmethod def tearDownClass(cls): cls.srv.shutdown() cls.srv.server_close() shutil.rmtree(cls.tmp, ignore_errors=True) # -- helpers ---------------------------------------------------------- def call(self, method, path, body=None, token=True, headers=None): st, payload, _ = self.call_h(method, path, body, token, headers) return st, payload def call_h(self, method, path, body=None, token=True, headers=None): """As call, plus the response headers.""" url = "http://127.0.0.1:%d%s" % (self.port, path) hdrs = {"Host": "127.0.0.1:%d" % self.port} if token: hdrs["X-ForgeFIRM-Token"] = self.token data = None if body is not None: data = json.dumps(body).encode() hdrs["Content-Type"] = "application/json" if headers: hdrs.update(headers) req = urllib.request.Request(url, data=data, method=method, headers=hdrs) try: with urllib.request.urlopen(req, timeout=10) as r: raw = r.read() st = r.status rh = r.headers except urllib.error.HTTPError as e: raw = e.read() st = e.code rh = e.headers ct = rh.get("Content-Type", "") if "json" in ct: return st, json.loads(raw.decode()), rh return st, raw, rh def wait_idle(self, timeout=10): deadline = time.time() + timeout while time.time() < deadline: st, d = self.call("GET", "/state") if not d["running"]: return d time.sleep(0.1) self.fail("run did not finish") def wait_prompt(self, timeout=10): deadline = time.time() + timeout while time.time() < deadline: st, d = self.call("GET", "/state") if d["running"] and d["running"]["prompt"]: return d["running"]["prompt"] time.sleep(0.05) self.fail("no prompt appeared") # -- tests ----------------------------------------------------------------- def test_01_auth_and_page(self): st, d = self.call("GET", "/state", token=False) self.assertEqual(st, 200) st, d = self.call("GET", "/", token=False) self.assertEqual(st, 200) self.assertIn(self.token.encode(), d) page = d.decode("utf-8") # one self-contained response: nothing linked, the token once, # the theme attribute the head script sets self.assertEqual(page.count(self.token), 1) self.assertNotIn("/bench self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench") for l in state["last_run"]["log"]), state["last_run"]["log"]) st, d = self.call("GET", "/bench") self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK") # a failing tool and an aborted one st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "fail", "n": 1}}) state = self.wait_idle() self.assertEqual(state["last_run"]["finished"]["result"], "EXIT 3") st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "slow", "n": 1}}) self.assertEqual(st, 200) time.sleep(0.5) st, d = self.call("POST", "/abort") state = self.wait_idle() self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED") # a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127) st, d = self.call("POST", "/bench/start", {"tool": "tk"}) self.assertEqual(st, 200, d) state = self.wait_idle(timeout=40) # two unreachable-forgectrl settle waits log = "\n".join(state["last_run"]["log"]) self.assertIn("takeover: pulse device free", log) self.assertIn("takeover: forgectrl start", log) self.assertIn("baseline: forgectrl unreachable for 10 s", log) self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"])) # a scope tool is a takeover too st, d = self.call("POST", "/bench/start", {"tool": "sc"}) self.assertEqual(st, 200, d) state = self.wait_idle(timeout=40) log = "\n".join(state["last_run"]["log"]) self.assertIn("takeover: pulse device free", log) self.assertIn("takeover: forgectrl start", log) # bench runs never touched the acceptance log recs = self.log.read() self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs)) def test_09_recovery_marker(self): marker = os.environ["FORGETEST_MARKER"] with open(marker, "w") as f: f.write("x fake.slow\n") import logging from forgetest.runner import journal seen = [] class Catch(logging.Handler): def emit(self, rec): seen.append(rec.getMessage()) h = Catch() journal.addHandler(h) try: r = Runner(self.log, self.man, self.reg, self.bench) finally: journal.removeHandler(h) # the recovery is a journal line, not a page message self.assertTrue(any("recovered" in m for m in seen), seen) self.assertNotIn("messages", r.state()[0]) self.assertFalse(os.path.exists(marker)) def test_10_state_is_conditional(self): """The page polls; an unchanged state must cost a 304, and the ETag must move as soon as the state does. Runs last because it invalidates: timestamps are whole seconds, and a PASS stamped in the same second as an invalidate is deliberately not inheritable, so an invalidate early in this class would decide the inheritance the earlier tests are checking. """ st, d, hdrs = self.call_h("GET", "/state") self.assertEqual(st, 200) etag = hdrs.get("ETag") self.assertTrue(etag and etag.startswith('"'), "no ETag on /state") st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag}) self.assertEqual(st, 304) self.assertEqual(hdrs2.get("ETag"), etag) # a stale validator must not be honored st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'}) self.assertEqual(st, 200) # and the validator moves with the state self.call("POST", "/invalidate", {"reason": "etag check"}) st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag}) self.assertEqual(st, 200) self.assertNotEqual(hdrs3.get("ETag"), etag) if __name__ == "__main__": unittest.main()