mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 01:01:12 -07:00
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.
This commit is contained in:
+10
-3
@@ -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
|
hunt run with the lid open, and the web-service homing (`$H` with
|
||||||
`homing_mode = gfcloud`) after the switch back.
|
`homing_mode = gfcloud`) after the switch back.
|
||||||
The cloud tests split by what they prove. The service protocol (sign-in,
|
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
|
the firmware check, the WebSocket, the hunt, the image uploads, a
|
||||||
limits reaching the engine) is `cloud.mode-switch` and one real print,
|
print's download and lifecycle as the app sees them) is
|
||||||
`cloud.pause-resume`. The machine's print behavior (the lid and
|
`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
|
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
|
lid, a print longer than the ring with the app's cancel) runs under the
|
||||||
**offline service** (`enter_offline`: the cloud client restarted with
|
**offline service** (`enter_offline`: the cloud client restarted with
|
||||||
|
|||||||
+10
-9
@@ -48,7 +48,7 @@ hardware-validated.**
|
|||||||
modes (cancel-and-return on a lid or interlock open, button pause/resume),
|
modes (cancel-and-return on a lid or interlock open, button pause/resume),
|
||||||
bench-validated 2026-08-17.
|
bench-validated 2026-08-17.
|
||||||
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
|
- **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
|
and a release gate that reads the exported artifact. The latest full
|
||||||
campaign, on dev image `20260822204234`, satisfied 43 of 43 from nothing and
|
campaign, on dev image `20260822204234`, satisfied 43 of 43 from nothing and
|
||||||
authorizes a release; **no release is cut yet.**
|
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
|
YES" for that image's manifest. That authorizes a release; it is not one
|
||||||
until `releases/v<version>/acceptance.json` is committed.
|
until `releases/v<version>/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
|
proven bench drill or a bench-verified check: the always-required core
|
||||||
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
|
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
|
||||||
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
|
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
|
||||||
@@ -587,13 +587,14 @@ until `releases/v<version>/acceptance.json` is committed.
|
|||||||
quiet after motion, a gate setting tripping and off by value, a fan under
|
quiet after motion, a gate setting tripping and off by value, a fan under
|
||||||
its floor), `camera.*`,
|
its floor), `camera.*`,
|
||||||
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
|
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
|
||||||
pause/resume/lid-cancel) and `cloud.*` (the mode round trip with the
|
pause/resume/lid-cancel) and `cloud.*` (the service protocol answered by
|
||||||
lid-open hunt and the web-service homing on it, one real print, and the
|
the emulator in this machine's identity, with only the app to drive; the
|
||||||
job-behavior tests under the offline service: the cloud client driven
|
mode round trip with the lid-open hunt and the web-service homing on it;
|
||||||
from a local socket with a synthesized laser-free job, no account, no
|
one real print; and the job-behavior tests under the offline service:
|
||||||
network, nothing on the bed). Tests that share a setup are merged; the
|
the cloud client driven from a local socket with a synthesized
|
||||||
`auto` tests stay separate for failure isolation. 27 are `auto`, 8
|
laser-free job, no account, no network, nothing on the bed). Tests that
|
||||||
`operator`, 8 `live`.
|
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**
|
- **The operator's part is asked for by name, not by popup**
|
||||||
(`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a
|
(`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
|
timed step, a standing notice the test takes down when the machine shows
|
||||||
|
|||||||
@@ -318,6 +318,93 @@ def mode_switch(ctx):
|
|||||||
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
"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) ------------------
|
# ---- lid / button behavior of a cloud job (the factory's) ------------------
|
||||||
#
|
#
|
||||||
# These tests run IN cloud mode and stay there: enter_cloud() reuses a live
|
# 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_MARKER = "/run/gfcloud-offline"
|
||||||
OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
|
OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
|
||||||
JOB_DIR = "/tmp/forgetest" # the jobs the offline tests write (tmpfs)
|
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)
|
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")
|
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
|
||||||
LOG_TAIL_BYTES = 4 << 20
|
LOG_TAIL_BYTES = 4 << 20
|
||||||
@@ -485,7 +577,10 @@ def session_live(pid):
|
|||||||
in (the client may log under a wrapper's pid)."""
|
in (the client may log under a wrapper's pid)."""
|
||||||
tag = "gfcloud[%s]" % pid if pid else None
|
tag = "gfcloud[%s]" % pid if pid else None
|
||||||
last_pid = last_any = None
|
last_pid = last_any = None
|
||||||
|
emulator = False
|
||||||
for ln in log_tail(GFCLOUD_LOG):
|
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:
|
for m in WS_MARKS:
|
||||||
if m in ln:
|
if m in ln:
|
||||||
last_any = m
|
last_any = m
|
||||||
@@ -495,6 +590,9 @@ def session_live(pid):
|
|||||||
where = "pid %s" % pid if last_pid is not None else "newest lines"
|
where = "pid %s" % pid if last_pid is not None else "newest lines"
|
||||||
if last == OFFLINE_MARK:
|
if last == OFFLINE_MARK:
|
||||||
return False, "%s: offline service, no web session" % where
|
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)
|
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):
|
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:
|
try:
|
||||||
os.remove(OFFLINE_MARKER)
|
got = wait_log(ctx, offset, [EMULATE_MARK], 60)
|
||||||
ctx.log("offline marker removed")
|
ctx.check(got[EMULATE_MARK], "the client did not come up as the emulator within 60 s")
|
||||||
except OSError:
|
finally:
|
||||||
pass
|
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):
|
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")):
|
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"))
|
ctx.log("the offline service is already up (pid %s) - reusing it", m.get("pid"))
|
||||||
return log_size(GFCLOUD_LOG)
|
return log_size(GFCLOUD_LOG)
|
||||||
offset = log_size(GFCLOUD_LOG)
|
offset = restart_client(ctx, marker=OFFLINE_MARKER, who=" offline")
|
||||||
with open(OFFLINE_MARKER, "w") as f:
|
|
||||||
f.write("forgetest %s\n" % ctx.test.id)
|
|
||||||
try:
|
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)
|
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.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:
|
finally:
|
||||||
offline_marker_off(ctx)
|
offline_marker_off(ctx)
|
||||||
ctx.check(wait_quiet(ctx, offset), "the machine was not quiet %d s after the offline start", QUIET_TIMEOUT_S)
|
ctx.check(wait_quiet(ctx, offset), "the machine was not quiet %d s after the offline start", QUIET_TIMEOUT_S)
|
||||||
|
|||||||
@@ -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),
|
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."""
|
wait the service's deferred moves out, and fail for the right reasons."""
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
@@ -170,6 +171,9 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
cloud.Offline = FakeOffline
|
cloud.Offline = FakeOffline
|
||||||
cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker")
|
cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker")
|
||||||
cloud.JOB_DIR = os.path.join(self.tmp, "jobs")
|
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.sent = []
|
||||||
FakeOffline.on_send = None
|
FakeOffline.on_send = None
|
||||||
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
|
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.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.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER,
|
||||||
cloud.JOB_DIR) = self.saved
|
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_SYSFS_ROOT", None)
|
||||||
os.environ.pop("GF_LEDS_ROOT", None)
|
os.environ.pop("GF_LEDS_ROOT", None)
|
||||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
@@ -262,6 +267,84 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertIn("offline", detail)
|
self.assertIn("offline", detail)
|
||||||
self.assertTrue(cloud.client_offline(3100))
|
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 ----------------------------------------------------------
|
# -- the offline entry ----------------------------------------------------------
|
||||||
def test_enter_offline_reuses_a_running_offline_client(self):
|
def test_enter_offline_reuses_a_running_offline_client(self):
|
||||||
self.in_offline()
|
self.in_offline()
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
# because the component entry already identifies the pinned source
|
# because the component entry already identifies the pinned source
|
||||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||||
# meta-glowforge-bsp pins the same repository; move both together.
|
# 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
|
# 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
|
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||||
# backwards.
|
# backwards.
|
||||||
PV = "0.1.17+git"
|
PV = "0.1.18+git"
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ DESCRIPTION = "OpenGlow/ForgeFIRM development image for Glowforge"
|
|||||||
# Strict superset of forgefirm-image: everything the main image ships, plus
|
# Strict superset of forgefirm-image: everything the main image ships, plus
|
||||||
# debug tooling. forgetest is the release acceptance tool (HTTP :8090) and
|
# debug tooling. forgetest is the release acceptance tool (HTTP :8090) and
|
||||||
# the bench diagnostics page; it belongs to the bench, never to a release
|
# 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 += " \
|
IMAGE_INSTALL += " \
|
||||||
forgectrl \
|
forgectrl \
|
||||||
forgetest \
|
forgetest \
|
||||||
|
python3-gfutilities-emulator \
|
||||||
htop \
|
htop \
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user