From 1c8197faa3420d3ea759343dad8ff054db4e2ee6 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 22 Aug 2026 20:19:46 -0400 Subject: [PATCH] forgetest: cloud.service-protocol, the service answered by the emulator in this machine's identity The service-protocol half of the cloud catalog on its own test: the cloud client restarted as gfutilities' emulator under the /run/gfcloud-emulate marker signs in, passes the firmware check, opens the WebSocket, answers the connect-time hunt and the image requests with the dev image's canned frames, and runs a print from the app through the real download path to ':completed' - nothing moves, nothing arms, and only the app has to be driven, by a person or an agent through the prompt API. The real client is restarted afterward and its hunt waited out. session_live now knows the emulator's session is not the machine's, so enter_cloud restarts it rather than reusing it; restart_client is the one restart the offline and emulator entries share. The dev image adds python3-gfutilities-emulator (the fixtures, packaged on their own in meta-openglow); forgefirm-app moves to 12ad3b1 (gfcloud --emulate). Catalog: 44 tests, 27 auto / 9 operator / 8 live; the new test covers the gfutilities service layer and examples/, which step 4 will take off the other cloud tests. Replays over the prompt script; contract and BRINGUP updated. A layer change (the dev image recipe): everything re-requires on the next image. --- docs/ACCEPTANCE.md | 13 +- docs/BRINGUP.md | 19 +- forgetest/forgetest/suite/cloud.py | 198 +++++++++++++++--- forgetest/tests/test_cloud_suite.py | 83 ++++++++ .../forgefirm-app/forgefirm-app-pin.inc | 4 +- .../images/forgefirm-image-dev.bb | 4 +- 6 files changed, 281 insertions(+), 40 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b92ca73..bed62ff 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -150,9 +150,16 @@ bench, or one whose `/data` has been wiped, starts from a full campaign. hunt run with the lid open, and the web-service homing (`$H` with `homing_mode = gfcloud`) after the switch back. The cloud tests split by what they prove. The service protocol (sign-in, - the WebSocket, the hunt, the print from the app, progress, the job's - limits reaching the engine) is `cloud.mode-switch` and one real print, - `cloud.pause-resume`. The machine's print behavior (the lid and + the firmware check, the WebSocket, the hunt, the image uploads, a + print's download and lifecycle as the app sees them) is + `cloud.service-protocol`: the cloud client restarted as gfutilities' + emulator in this machine's identity under the `/run/gfcloud-emulate` + marker, answering the real service with the dev image's canned frames + and running the print from the app without hardware, so only the app + has to be driven (by a person or an agent, anywhere). The service and + the machine together are `cloud.mode-switch` and one real print, + `cloud.pause-resume` (progress, the button wait, the job's limits + reaching the engine). The machine's print behavior (the lid and interlock aborts, the button-wait cancel, a paused print ended by the lid, a print longer than the ring with the app's cancel) runs under the **offline service** (`enter_offline`: the cloud client restarted with diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 2e0ee7c..e42c0e5 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -48,7 +48,7 @@ hardware-validated.** modes (cancel-and-return on a lid or interlock open, button pause/resume), bench-validated 2026-08-17. - **Releases are gated by the acceptance tool** (`forgetest`, dev image only): - a 43-test catalog, domain-scoped inheritance, an always-required safety core, + a 44-test catalog, domain-scoped inheritance, an always-required safety core, and a release gate that reads the exported artifact. The latest full campaign, on dev image `20260822204234`, satisfied 43 of 43 from nothing and authorizes a release; **no release is cut yet.** @@ -578,7 +578,7 @@ under the domain model from the day's earlier dev images) and the export reads " YES" for that image's manifest. That authorizes a release; it is not one until `releases/v/acceptance.json` is committed. -- **Catalog: 43 tests** in `forgetest/forgetest/suite/`, every one a port of a +- **Catalog: 44 tests** in `forgetest/forgetest/suite/`, every one a port of a proven bench drill or a bench-verified check: the always-required core (`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`, `kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*` @@ -587,13 +587,14 @@ until `releases/v/acceptance.json` is committed. quiet after motion, a gate setting tripping and off by value, a fan under its floor), `camera.*`, `laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill, - pause/resume/lid-cancel) and `cloud.*` (the mode round trip with the - lid-open hunt and the web-service homing on it, one real print, and the - job-behavior tests under the offline service: the cloud client driven - from a local socket with a synthesized laser-free job, no account, no - network, nothing on the bed). Tests that share a setup are merged; the - `auto` tests stay separate for failure isolation. 27 are `auto`, 8 - `operator`, 8 `live`. + pause/resume/lid-cancel) and `cloud.*` (the service protocol answered by + the emulator in this machine's identity, with only the app to drive; the + mode round trip with the lid-open hunt and the web-service homing on it; + one real print; and the job-behavior tests under the offline service: + the cloud client driven from a local socket with a synthesized + laser-free job, no account, no network, nothing on the bed). Tests that + share a setup are merged; the `auto` tests stay separate for failure + isolation. 27 are `auto`, 9 `operator`, 8 `live`. - **The operator's part is asked for by name, not by popup** (`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a timed step, a standing notice the test takes down when the machine shows diff --git a/forgetest/forgetest/suite/cloud.py b/forgetest/forgetest/suite/cloud.py index f43145d..b3ae9b8 100644 --- a/forgetest/forgetest/suite/cloud.py +++ b/forgetest/forgetest/suite/cloud.py @@ -318,6 +318,93 @@ def mode_switch(ctx): "grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"]) +@test("cloud.service-protocol", title="The service protocol, answered by the emulator in this " + "machine's identity", + subsystem="cloud", kind="operator", est_min=5, + covers=[("python3-gfutilities", "gfutilities/service/**"), + ("python3-gfutilities", "gfutilities/device/basemachine.py"), + ("python3-gfutilities", "gfutilities/device/emulator.py"), + ("python3-gfutilities", "gfutilities/device/settings.py"), + ("python3-gfutilities", "gfutilities/configuration.py"), + ("python3-gfutilities", "gfutilities/_common.py"), + ("python3-gfutilities", "gfutilities/__init__.py"), + ("python3-gfutilities", "examples/**"), + ("forgefirm-app", "gfcloud.py"), ("forgefirm-app", "ffmachine.py"), + ("forgectrl", "src/super.c")], + requires=["forgectrl.auth"], + steps=["Cloud credentials configured; the machine on the network; the app open in a browser " + "(anyone can drive it, at the machine or not: nothing here moves, arms, or fires).", + "When told, set up any small job in the app and press Print; the emulator runs it at " + "once. Then confirm the app shows the print complete."], + description="The Glowforge service, end to end, with the machine emulated: the cloud client " + "restarted as gfutilities' Emulator in this machine's identity signs in, passes " + "the firmware check, opens the WebSocket, answers the connect-time hunt and the " + "service's image requests with canned frames, and runs a print from the app " + "through the same download path a job takes (the pulse header parsed, the " + "serial and format checked) and the same lifecycle events, to ':completed'. " + "What the service accepts is what the app shows. The real client is restarted " + "afterward and its connect-time hunt waited out. The machine's own side of a " + "print is the other cloud tests' to prove.") +def service_protocol(ctx): + ev = ctx.evidence + offset = enter_emulator(ctx) + try: + service_protocol_body(ctx, ev, offset) + finally: + leave_emulator(ctx) + import shutil + shutil.rmtree(EMULATOR_WORK, ignore_errors=True) + + +def service_protocol_body(ctx, ev, offset): + # the service's connect-time hunt, answered at once + hunt = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S) + ev["hunt"] = message(hunt) + ctx.check(hunt, "the service sent no connect-time hunt (or it never finished) within %d s", HUNT_TIMEOUT_S) + ctx.check(COMPLETED in hunt, "the emulator's hunt did not complete: %s", ev["hunt"]) + # and the image it asks for after, uploaded from the canned frames + got = wait_log(ctx, offset, ["img_upload COMPLETE"], 120) + ctx.check(got["img_upload COMPLETE"], "no image upload completed after the hunt") + ctx.notice("In the app the machine shows Ready (its bed image is the emulator's canned frame). " + "Set up any small job and press Print. Nothing moves here; the test watches the " + "service's print reach the emulator and complete.") + try: + got = wait_log(ctx, offset, ["service action request: print"], 600) + ctx.check(got["service action request: print"], "no print reached the machine within 600 s") + fin = wait_action_finished(ctx, offset, "print", 300) + finally: + ctx.clear_notice() + ev["print"] = message(fin) + ctx.check(fin, "the print did not finish within 300 s of reaching the machine") + ctx.check(COMPLETED in fin, "the print did not complete: %s", ev["print"]) + lines = log_lines_since(GFCLOUD_LOG, offset) + ev["images_uploaded"] = sum(1 for ln in lines if "img_upload COMPLETE" in ln) + ev["pulse"] = next((message(ln) for ln in lines if "pulse data is" in ln), None) + ctx.log("print: %s; %d images uploaded; %s", ev["print"], ev["images_uploaded"], ev["pulse"]) + ctx.check(ev["pulse"], "the print's pulse data was not downloaded and parsed") + infos = [] + try: + infos = sorted(f for f in os.listdir(EMULATOR_WORK) if f.endswith(".info")) + except OSError: + pass + ev["downloads"] = infos + header = None + if infos: + try: + with open(os.path.join(EMULATOR_WORK, infos[-1])) as f: + header = (json.load(f) or {}).get("header_data") or {} + except (OSError, ValueError): + header = None + ev["header_tags"] = len(header) if header else 0 + ev["header_stfr"] = (header or {}).get("STfr") + ctx.check(header and header.get("STfr"), "the downloaded job's header was not parsed (%s)", infos) + ctx.log("the job's header: %d tags, STfr %s", ev["header_tags"], ev["header_stfr"]) + ctx.confirm("Does the app show the print complete?") + ctx.log("PASS: session, hunt, %d image upload(s), a print downloaded (%d-tag header) and " + "completed, all against the real service with no hardware behind it", + ev["images_uploaded"], ev["header_tags"]) + + # ---- lid / button behavior of a cloud job (the factory's) ------------------ # # These tests run IN cloud mode and stay there: enter_cloud() reuses a live @@ -337,6 +424,11 @@ OFFLINE_MARK = "OFFLINE service" OFFLINE_MARKER = "/run/gfcloud-offline" OFFLINE_SOCKET = "/run/gfcloud-offline.sock" JOB_DIR = "/tmp/forgetest" # the jobs the offline tests write (tmpfs) +# The emulator (gfcloud --emulate, or the marker at start): the real +# service driven in this machine's identity with no hardware behind it. +EMULATE_MARK = "EMULATE: the emulator" +EMULATE_MARKER = "/run/gfcloud-emulate" +EMULATOR_WORK = "/tmp/gfcloud-emulate" WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK) ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle") LOG_TAIL_BYTES = 4 << 20 @@ -485,7 +577,10 @@ def session_live(pid): in (the client may log under a wrapper's pid).""" tag = "gfcloud[%s]" % pid if pid else None last_pid = last_any = None + emulator = False for ln in log_tail(GFCLOUD_LOG): + if EMULATE_MARK in ln and (not tag or tag in ln): + emulator = True for m in WS_MARKS: if m in ln: last_any = m @@ -495,6 +590,9 @@ def session_live(pid): where = "pid %s" % pid if last_pid is not None else "newest lines" if last == OFFLINE_MARK: return False, "%s: offline service, no web session" % where + if emulator: + # the emulator's session is live, and it is not the machine's + return False, "%s: the emulator, not the machine" % where return last == "RX-EVENT: ready", "%s: last websocket state %s" % (where, last) @@ -605,12 +703,80 @@ def enter_cloud(ctx): def offline_marker_off(ctx): - """No client started from here on comes up offline.""" + """No client started from here on comes up offline, or as the emulator.""" + for path, what in ((OFFLINE_MARKER, "offline"), (EMULATE_MARKER, "emulate")): + try: + os.remove(path) + ctx.log("%s marker removed", what) + except OSError: + pass + + +def restart_client(ctx, marker=None, who=""): + """The cloud client started fresh - restarted in cloud mode, switched to + from GRBL mode (the change declared) - under `marker` if one is given, + which stays in place until the client has read it (the caller waits + for the client's own line and takes the marker down; the supervisor + reports the client running seconds before Python has finished + importing). Returns the log offset from before the start.""" + fc = ctx.forgectrl + st, m = fc.get("/mode") + ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st) + offset = log_size(GFCLOUD_LOG) + if marker: + with open(marker, "w") as f: + f.write("forgetest %s\n" % ctx.test.id) + if m.get("mode") == "cloud": + ctx.log("cloud mode: restarting the client%s (was pid %s)", who, m.get("pid")) + st, body = fc.post("/controller/stop") + ctx.check(st == 200, "controller stop refused: %s %s", st, body) + m = wait_mode(ctx, fc, "cloud", want_controller="standby", timeout=30) + ctx.check(m and m.get("controller") == "standby", "the cloud client did not stop: %s", m) + st, body = fc.post("/controller/start") + ctx.check(st == 200, "controller start refused: %s %s", st, body) + else: + ctx.log("%s mode: switching to cloud%s", m.get("mode"), who) + st, body = fc.post("/mode", data={"controller": "cloud"}) + ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body) + m = wait_mode(ctx, fc, "cloud", timeout=90) + ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running", + "cloud controller did not come up: %s", m) + ctx.mode_changed("cloud") + return offset + + +def enter_emulator(ctx): + """The real service with the emulator in this machine's identity: the + client restarted under the emulate marker (one start), its session + established. Returns the log offset from before the start, so the + caller sees the connect. The caller restarts the real client when + done (leave_emulator).""" + offset = restart_client(ctx, marker=EMULATE_MARKER, who=" as the emulator") try: - os.remove(OFFLINE_MARKER) - ctx.log("offline marker removed") - except OSError: - pass + got = wait_log(ctx, offset, [EMULATE_MARK], 60) + ctx.check(got[EMULATE_MARK], "the client did not come up as the emulator within 60 s") + finally: + offline_marker_off(ctx) + session = wait_session(ctx, offset) + ctx.check(session_established(session), + "the emulator never established its service session (no credentials, no network, " + "or the service refused)") + ctx.log("emulator session established: %s", "; ".join(ln.split(" ", 1)[-1][:80] for ln in session)) + return offset + + +def leave_emulator(ctx): + """The real client back, its session waited for and its connect-time + hunt waited out, so the machine is what the next test expects.""" + offline_marker_off(ctx) + offset = restart_client(ctx, who=" with the machine") + session = wait_session(ctx, offset) + ctx.check(session_established(session), "the real client did not establish its session after the emulator") + hunt = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S) + ctx.check(hunt, "the real client's connect-time hunt did not finish within %d s", HUNT_TIMEOUT_S) + ctx.check(wait_quiet(ctx, offset), "the service was still moving the head %d s after the real " + "client came back", QUIET_TIMEOUT_S) + ctx.log("the machine is back under the real client") def enter_offline(ctx): @@ -627,29 +793,11 @@ def enter_offline(ctx): if m.get("mode") == "cloud" and m.get("controller") == "running" and client_offline(m.get("pid")): ctx.log("the offline service is already up (pid %s) - reusing it", m.get("pid")) return log_size(GFCLOUD_LOG) - offset = log_size(GFCLOUD_LOG) - with open(OFFLINE_MARKER, "w") as f: - f.write("forgetest %s\n" % ctx.test.id) + offset = restart_client(ctx, marker=OFFLINE_MARKER, who=" offline") try: - if m.get("mode") == "cloud": - ctx.log("cloud mode: restarting the client offline (was pid %s)", m.get("pid")) - st, body = fc.post("/controller/stop") - ctx.check(st == 200, "controller stop refused: %s %s", st, body) - m = wait_mode(ctx, fc, "cloud", want_controller="standby", timeout=30) - ctx.check(m and m.get("controller") == "standby", "the cloud client did not stop: %s", m) - st, body = fc.post("/controller/start") - ctx.check(st == 200, "controller start refused: %s %s", st, body) - else: - ctx.log("%s mode: switching to cloud, offline", m.get("mode")) - st, body = fc.post("/mode", data={"controller": "cloud"}) - ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body) - m = wait_mode(ctx, fc, "cloud", timeout=90) - ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running", - "cloud controller did not come up: %s", m) - ctx.mode_changed("cloud") got = wait_log(ctx, offset, [OFFLINE_MARK], 60) ctx.check(got[OFFLINE_MARK], "the client did not come up as the offline service within 60 s") - ctx.log("offline service up (pid %s): %s", m.get("pid"), message(got[OFFLINE_MARK])) + ctx.log("offline service up: %s", message(got[OFFLINE_MARK])) finally: offline_marker_off(ctx) ctx.check(wait_quiet(ctx, offset), "the machine was not quiet %d s after the offline start", QUIET_TIMEOUT_S) diff --git a/forgetest/tests/test_cloud_suite.py b/forgetest/tests/test_cloud_suite.py index 52baa84..3285da1 100644 --- a/forgetest/tests/test_cloud_suite.py +++ b/forgetest/tests/test_cloud_suite.py @@ -13,6 +13,7 @@ reuse an existing cloud session and never switch back, restart the client for a fresh hunt, judge the print's own finish line (not another action's), wait the service's deferred moves out, and fail for the right reasons.""" import contextlib +import json import os import shutil import struct @@ -170,6 +171,9 @@ class CloudSuiteTests(unittest.TestCase): cloud.Offline = FakeOffline cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker") cloud.JOB_DIR = os.path.join(self.tmp, "jobs") + self.saved_emu = (cloud.EMULATE_MARKER, cloud.EMULATOR_WORK) + cloud.EMULATE_MARKER = os.path.join(self.tmp, "emulate-marker") + cloud.EMULATOR_WORK = os.path.join(self.tmp, "emu-work") FakeOffline.sent = [] FakeOffline.on_send = None self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing @@ -188,6 +192,7 @@ class CloudSuiteTests(unittest.TestCase): (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S, cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER, cloud.JOB_DIR) = self.saved + (cloud.EMULATE_MARKER, cloud.EMULATOR_WORK) = self.saved_emu os.environ.pop("GF_SYSFS_ROOT", None) os.environ.pop("GF_LEDS_ROOT", None) shutil.rmtree(self.tmp, ignore_errors=True) @@ -262,6 +267,84 @@ class CloudSuiteTests(unittest.TestCase): self.assertIn("offline", detail) self.assertTrue(cloud.client_offline(3100)) + # -- the emulator ------------------------------------------------------------------ + EMU_PID = 4100 + EMU_START = ["2026-08-22T23:50:00.100000+00:00 gfcloud[4100] INFO ffmachine:build_emulator EMULATE: the " + "emulator in this machine's identity, frames from /usr/share/gfutilities/emulator, no hardware", + "2026-08-22T23:50:02.000000+00:00 gfcloud[4100] INFO authentication:authenticate_machine SUCCESS", + "2026-08-22T23:50:03.000000+00:00 gfcloud[4100] INFO websocket:ws_connect ESTABLISHED", + "2026-08-22T23:50:05.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action hunt [1578300001]: " + "finished with event \":completed\"", + "2026-08-22T23:50:06.000000+00:00 gfcloud[4100] INFO websocket:img_upload COMPLETE", + "2026-08-22T23:50:06.500000+00:00 gfcloud[4100] INFO basemachine:_finish_action lid_image [1578300002]: " + "finished with event \":completed\""] + EMU_PRINT = ["2026-08-22T23:51:00.000000+00:00 gfcloud[4100] INFO gfuiservice:run service action request: print (ready)", + "2026-08-22T23:51:01.000000+00:00 gfcloud[4100] INFO websocket:fetch_motion pulse data is uncompressed, " + "47848 byte body", + "2026-08-22T23:51:02.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action print [1578300003]: " + "finished with event \":completed\""] + REAL_BACK = ["2026-08-22T23:52:02.000000+00:00 gfcloud[4200] INFO authentication:authenticate_machine SUCCESS", + "2026-08-22T23:52:03.000000+00:00 gfcloud[4200] INFO websocket:ws_connect ESTABLISHED", + "2026-08-22T23:52:03.500000+00:00 gfcloud[4200] INFO websocket:_on_open RX-EVENT: ready", + "2026-08-22T23:52:15.000000+00:00 gfcloud[4200] INFO basemachine:_finish_action hunt [1578300010]: " + "finished with event \":completed\""] + + def emulator_setup(self, print_lines=None, info=True): + self.in_cloud(pid=2278) + self.append(["2026-08-22T23:49:00.500000+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready"]) + starts = [] + + def on_post(path, form): + if path == "/controller/stop": + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0) + elif path == "/controller/start": + starts.append(os.path.exists(cloud.EMULATE_MARKER)) + if len(starts) == 1: + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=self.EMU_PID) + self.append(self.EMU_START, delay=0.2) + else: + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=4200) + self.append(self.REAL_BACK, delay=0.2) + return None + self.fc.on_post = on_post + + def at_print(): + os.makedirs(cloud.EMULATOR_WORK, exist_ok=True) + if info: + with open(os.path.join(cloud.EMULATOR_WORK, "2026-08-22_235101_print.info"), "w") as f: + json.dump({"header_data": {"STfr": 10000, "MCsn": 0, "PDfm": 0}}, f) + self.append(print_lines if print_lines is not None else self.EMU_PRINT, delay=0.1) + return starts, {"Set up any small job": at_print} + + def test_service_protocol_against_the_emulator(self): + starts, hooks = self.emulator_setup() + run = self.run_test(cloud.service_protocol, hooks=hooks, test_id="cloud.service-protocol") + ev = run.evidence + self.assertEqual(starts, [True, False]) # emulator under the marker, the real client without + self.assertFalse(os.path.exists(cloud.EMULATE_MARKER)) + self.assertEqual([p for p, _ in self.fc.posts], + ["/controller/stop", "/controller/start", "/controller/stop", "/controller/start"]) + self.assertIn(":completed", ev["hunt"]) + self.assertIn(":completed", ev["print"]) + self.assertEqual(ev["images_uploaded"], 1) + self.assertEqual(ev["header_stfr"], 10000) + self.assertEqual(self.script.asked, ["Does the app show the print complete?"]) + self.assertTrue(any("PASS: session, hunt" in l for l in run.lines), run.lines[-5:]) + self.assertFalse(os.path.exists(cloud.EMULATOR_WORK)) + + def test_service_protocol_fails_when_the_print_does_not_complete_and_still_restores_the_client(self): + failed = [l.replace(':completed"', ':failed"') for l in self.EMU_PRINT] + starts, hooks = self.emulator_setup(print_lines=failed) + self.assertFails(cloud.service_protocol, "did not complete", hooks=hooks) + self.assertEqual(starts, [True, False]) # the real client came back regardless + + def test_the_emulators_session_is_not_the_machines(self): + self.append(self.EMU_START + ["2026-08-22T23:50:07.000000+00:00 gfcloud[4100] INFO websocket:_on_open " + "RX-EVENT: ready"]) + live, detail = cloud.session_live(4100) + self.assertFalse(live) + self.assertIn("emulator", detail) + # -- the offline entry ---------------------------------------------------------- def test_enter_offline_reuses_a_running_offline_client(self): self.in_offline() diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc index 6d20182..e507ee3 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc @@ -4,9 +4,9 @@ # because the component entry already identifies the pinned source # (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in # meta-glowforge-bsp pins the same repository; move both together. -SRCREV = "a3ca36f68b01fab968f6062183d2d94ab53e3583" +SRCREV = "12ad3b19166082c93b497a722cbb7a2fb91f810d" # Bump PV with every SRCREV move: the hash-derived package version is not # monotonic on its own and buildhistory QA fails the build when it sorts # backwards. -PV = "0.1.17+git" +PV = "0.1.18+git" diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb index 82eab32..4d31e79 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb @@ -5,10 +5,12 @@ DESCRIPTION = "OpenGlow/ForgeFIRM development image for Glowforge" # Strict superset of forgefirm-image: everything the main image ships, plus # debug tooling. forgetest is the release acceptance tool (HTTP :8090) and # the bench diagnostics page; it belongs to the bench, never to a release -# image (docs/ACCEPTANCE.md). +# image (docs/ACCEPTANCE.md). The gfutilities emulator fixtures are the +# canned frames its service-protocol test answers the service with. IMAGE_INSTALL += " \ forgectrl \ forgetest \ + python3-gfutilities-emulator \ htop \ "