mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
9585febb7ce3d61a770385b914f3700f182ebd3c
19
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9585febb7c |
Take the fan idle reference from a settled machine, and log the cooldown
cooling.fans-quiet-after-motion sat silent for four minutes on the bench and then failed. Its idle reference was the tachs one second after the baseline saw the engine go idle, while the previous test's fans were still coasting at the cooldown level (exhaust 5030 rpm against a true idle of 0), so it then waited for the fans to come back UP to a level that was never idle. The cooldown wait printed nothing while it waited. The reference now needs the engine idle, the idle duty applied to both fan channels, and three consecutive tach samples that agree; the pass condition is the idle duty back and the tachs at or below that reference (lower is quieter, never a fault); M8 must visibly raise the duty; and every sample of both waits is logged with the phase and the duty, so the run pane shows the fans coasting down rather than a hang. Proof: tests/test_cooling_suite.py replays the test under the real Context against a scripted machine (fake forgectrl, fake sysfs duties, fake Grbl port): an idle machine passes, the bench case (fans coasting when the test starts) passes with the reference taken after the coast, fans left on fail with the reference in the message, and a reference that never settles fails before anything is jogged. No catalog consequence beyond the suite module's own hash. |
||
|
|
54e1689889 |
Let a test declare the controller mode it needs; the runner switches to it
The cloud job tests enter cloud mode and stay there, by design, so a queue (or an operator) that goes on to a motion test reaches it with gfcloud as the controller and no grblHAL process to find: motion.step-timing-under-load failed on exactly that, before it touched the machine. Nothing in the runner put the machine into the mode a test needed; the baseline only preserved the mode it found. A test now declares `mode="grbl"` (or "cloud") in @test. The runner's pre pass, after the leftovers are handled and before the preserved state is captured, switches through POST /mode, waits for the supervisor to settle (controller running, motion verified) and for the Grbl port to answer, and fails the test with the reason when the mode cannot be established. Capturing after the switch means the post pass keeps the mode the test asked for, so the machine changes mode only where the next test asks for it and never between tests of the same mode. The cloud job tests keep managing their own entry (enter_cloud also waits for the service session) and declare nothing. Tagged: every motion.* test but the mode-agnostic liveness probe, the six laser.* tests, cooling.fans-quiet-after-motion, cloud.mode-switch and cloud.gfhome-homing (both start in GRBL mode). controller_pid() now says what mode forgectrl reports when the process is missing. The page shows the declared mode as a badge; the Grbl port probe moved to hw. Proof: tests/test_mode.py (switch_mode against the fake forgectrl, including a refused switch, a controller that never comes up and a port that never opens; the runner end to end from cloud mode, from grbl mode, an undeclared test, and a failed switch). 151 unit tests pass; the coverage lint is clean. No catalog consequence beyond the suite modules' own source hashes: the change is to how a test is started, not to what it proves. |
||
|
|
e810d52c9d |
Put every test group on the same column grid
The subsystems are separate tables, so each sized its own columns from its own content and no two lined up. Reading down the page meant reading down six different layouts, which is what made it look busy. They now share one colgroup and a fixed table layout: Kind, Status, Last result and the Start button are the same width in every group, and the Test column takes the remainder. Two things had to come out of the columns first, because both are long enough to stretch a cell and drag one group's grid out of step with the rest. The details block moves to a full-width row of its own, where the prose and the operator steps have room and opening one no longer widens the Test column. The requires note moves from under the Start button to under Status, which is sized for it: the catalog's longest names three prerequisites in 73 characters, and beneath a button it wrapped into a ragged stack. It reads better there anyway, next to the reason the test is required rather than beneath the control it disables. Also gives a test's description its own block in the details. Without operator steps to separate them it ran straight into the Requires line. |
||
|
|
0258268aae |
Let the page run a campaign's remaining tests as a queue
A campaign is mostly waiting for the next Start. The page now offers two queues, and each takes every test of its kinds the campaign does not already count as satisfied: Unattended for the auto tests, which need nobody in the room, and Operator and live for the ones that need somebody at the machine, since they prompt and they fire the laser. The buttons say how many they would run and ask before starting; the live queue names the tests that fire and takes the acknowledgment once, for all of them. A queue runs one test at a time through the runner's single slot, in prerequisite order. Registration order otherwise, so a run reads down the page, but a prerequisite inside the queue always goes first. It stops on the first result that is not a PASS: a FAIL closes the campaign, and carrying on would only open a second one behind the operator's back. A test the runner refuses to start is skipped with the reason on the page and the rest carry on, which is what happens to an auto test waiting on an operator one: run the attended queue, then the unattended one again. The queue lives in the runner, not in the tab, so reloading the page or closing it leaves the run alone. While one is up it holds the machine between its tests as well as during them, so a single Start and the bench tools are refused rather than cutting in. Stop the queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too, and lands as the non-PASS that stops the queue. Every run a queue starts records which one put it there. Also moves the /state ETag test to the end of its class. It invalidates, timestamps are whole seconds, and a PASS stamped in the same second as an invalidate is deliberately not inheritable, so on a fast run it decided the inheritance an earlier test was checking. forgetest is a dev-only component and can never appear in a coverage map, so this has no acceptance catalog consequence. |
||
|
|
474e14e0e8 |
Make the acceptance page answer the operator, not the timer
Presses on Start and Continue were being swallowed. Every poll rebuilt the whole test table and the prompt buttons with innerHTML, and a button destroyed between mousedown and mouseup raises no click event at all: the press simply vanished. Measured on the page as it stood, a Start button node was replaced 13 times in 40 seconds, and with a poll landing mid-press 8 presses out of 8 were lost. Rows, prompt buttons and tool entries are now built once and afterwards only updated in place through setters that skip the write when the value has not changed; under the same test 8 presses out of 8 land. The page also felt slow because each poll re-read and re-parsed the whole result log and recomputed all 42 domain fingerprints. A result record carries its run log, so the file reaches megabytes over a campaign and the poll cost grew with it. The log now parses each line once and reads only the bytes appended since, and a fingerprint is memoized against the manifest's content hash. On the same manifest, catalog and log, one /state goes from 8.89 ms to 0.10 ms at 0.89 MB and from 41.79 ms to 0.23 ms at 10.62 MB, and no longer grows with the log. Actions now report on the press instead of on the next poll: Start greys every Start and marks the row, Continue and Abort grey themselves, and each pulls the next poll forward. /state carries an ETag so an idle page polls for a 304, the poll ticks faster during a run, and the connection is kept alive rather than handshaking per request. Keeping the connection alive exposed a hazard worth naming: a POST refused before its body was read left that body in the socket, where the next read took it for a request line. A refusal now ends the connection. forgetest is a dev-only component and can never appear in a coverage map, so this has no acceptance catalog consequence; the host tests carry the proof, including one that fails if a per-poll innerHTML rebuild ever comes back. |
||
|
|
aaabfdf9b9 |
Check the progress a print reports, where a print already runs
Two tests already run a print end to end, and progress is a property of a running print, so the checks go there rather than into a test of their own that would cost the operator another job. cloud.pause-resume takes the job that fits the ring: the client names the length it is reporting against, and the operator is asked the question only a person can answer, whether the bar actually moved. cloud.oversize-stream takes the job that does not fit, which is where a moving denominator would show: the kernel's program total grows all run long under a live feed, and the test already samples it growing, so the check is that the figure progress divides by is larger than that - the job, not the count the ring had swallowed when the run started. The forgetest replay plays a captured log from a build that predates the line, so it carries the line where the current build emits it, as it already does for the warm-up and the rest. BRINGUP's cloud item now says a print reports itself again, and what is left on it is a print watched from the app. |
||
|
|
d122a6ff1d |
Check a print's warm-up and rest where a print already runs
cloud.pause-resume runs a print end to end, which is exactly what the job lifecycle needs to be seen: a non-zero hold before the first fire, a non-zero rest after the park, and neither on the connect-time hunt in the same session. Folding the checks in there costs the operator nothing, where a test of its own would cost another print. The forgetest replay noticed first: it plays a real captured log from a build that predates those lines. Rather than editing what the machine said that day, the replay carries the two lines where the current build emits them. BRINGUP's cloud item now names what is actually open on the header keys, the park and the two periods, and records that the pause constants were looked for in the wrong place. |
||
|
|
cd5098b6b3 |
Acceptance catalog: merge the tests that share a setup (39 -> 35, live 10 -> 7)
A sweep of the whole catalog for the overlap the drill work found. The test
applied was "do these share a SETUP", not "do these share a subsystem":
combining only pays where a human waits - an arm press, scrap, a takeover,
a mode entry, a lid choreography - and it costs failure isolation, because
the campaign inherits per test and a merged test invalidates as a unit. The
17 auto tests were left alone for exactly that reason: they cost no operator
time and separate ids give the coverage map and the domain invalidation
finer teeth.
kernel.k3-unlock + kernel.fire-abu -> kernel.fire-line
Four phases behind ONE takeover of the pulse device instead of two:
A/B/U on the FIRE line, then the mid-ramp unlock. Same HV-not-good
gate, same zero duty, same safe state on the way out.
laser.expected-stop + laser.kill-mid-fire -> laser.armed-kill
Both ways an armed job is killed, on one scrap setup: the supervisor's
expected stop (with its separate operator-judged restart) and then a
SIGKILL of the restarted controller. The second phase re-reads the pid
after the restart, so it kills the process the supervisor just spawned.
laser.lid-cancel-mid-fire + laser.pause-resume-live -> laser.pause-resume-lid-cancel
One armed burn in the order the factory uses the machine's controls:
press (pause - emission stops, latch stays UNLOCKED, armed window
stays open), press (resume), lid (cancel, reset without alarm, return
to the job start, button latch SET). One arm press instead of two.
cloud.lid-abort + cloud.interlock-abort-park -> cloud.lid-interlock-abort
Two prints in one test: the lid, then the interlock with the lid opened
during the park. The tail both share - park complete, kernel counters
back at the job start, ':cancelled', latch locked, armed window closed -
is one helper now, so both triggers are judged the same way.
Not merged, though they share code: cloud.gfhome-homing and cloud.hunt-lid-open.
Both drive Machine._hunt (gfhome.py and gfcloud.py build the same gfhardware
machine, so the lid ungating is the same lines), but each is about the opposite
value of the same variable - gfhome's homing is camera-corrected and needs the
lid CLOSED in GRBL mode, the hunt test needs it OPEN through a cloud-mode
connect. Merging would put a mid-test mode switch back into the cloud tests.
Shared live-test prologue (the arm cue, the mark job, the wait for the emission
witness) and the post-kill trail/judging are helpers now.
Host proof: 104 unit tests - the merged cloud test replays the machine's own
lid-abort excerpt twice, once with the interlock substituted for the trigger,
and three negative cases hold it honest (loop already open, a park the lid can
interrupt, a stop that is not edge-driven) - and the coverage lint at 0
uncovered across 35 tests. Catalog time 137 -> 125 minutes, 96 of it attended.
|
||
|
|
0870a835c4 |
Acceptance catalog: the rest of the lid/button/interlock drills, and a pause/resume chain-timing tool
Catalog 34 -> 39. Every remaining bench drill of the lid/button parity work
is now a test, with drills that exercise the same path combined:
motion.lid-cancel-home also cancels from a hold - a job paused on the
button is ended by the lid, never resumed - so the
armed window and the hardware button latch stay in
agreement by construction.
motion.cancel-abort also asserts what a sender abort must NOT do: it
stops where it stopped and never returns home. The
return-to-start belongs to the lid policy alone.
motion.interlock-cancel-park (new) the interlock loop cancels like the lid,
and the lid opened during the return home does not
interrupt it.
motion.lid-policy-hold (new) the other policy: Door park, no cancel, no
return, and a cycle start finishes the move. The
setting is restored on the way out.
laser.pause-resume-live (new) the button pause/resume during a live cut:
emission stops, the latch stays UNLOCKED and the
armed window open (a pause is not a cancel), the
next press resumes and the job finishes.
cloud.interlock-abort-park (new) the same interlock/park pair in cloud mode.
cloud.pause-cancel-paths (new) the two non-finishing ends of a print, each
from the state the factory ends it in: paused on the
button then cancelled by the lid, and cancelled from
the app while running.
The shared cancel tail (reason reported, reset without an alarm, position
kept, head back at the job start with the KERNEL counters confirming it) is
now one helper, so every trigger is judged the same way.
scripts/bench/resume_dark_lead.py: samples LASER_ON, FIRE, HV_ENABLE, the
charge-pump watchdog, the button and the doors off the SoC pads at ~2 kHz
through /dev/mem, with motion dated from the kernel step counters, across a
pause and a resume. Levels are taken at idle and everything after is reported
as a change from that baseline, so no polarity assumption is baked in. Dry by
default, with --auto driving the pause and resume through ! / ~ for an
unattended rehearsal; --run live adds the dark lead between FIRE and LASER_ON,
in milliseconds and in millimeters at the job's feed.
Bench registry: argument specs can name a flag (--feed 600) instead of being
positional, and an optional argument with an empty default is left off the
command line entirely.
Host proof: 104 unit tests (20 in the cloud suite - the two new cloud tests
replay the machine's own lid-abort excerpt, with the interlock and the app
cancel substituted for the trigger, and fail for the right reasons), coverage
lint 0 uncovered across 39 tests.
|
||
|
|
86ce0419e5 |
forgetest: cloud tests stay in cloud mode; the page can ignore prerequisites
The cloud job tests (lid-abort, lid-during-button-wait, hunt-lid-open,
pause-resume) run in cloud mode and leave the machine there: enter_cloud
reuses a live session (pid-scoped from the client's own websocket state
lines) and switches once from GRBL mode, declaring the change to the
baseline; nothing switches back. Each test judges the log from its own
window, the print by its own "print [id]: finished" line, and waits the
service's follow-up moves out (wait_quiet) before it ends. hunt-lid-open
restarts the cloud client through the supervisor's stop/start lever for
a fresh connect. The former switch-back is what failed the last bench run
of hunt-lid-open (409 machine is not idle: the service was still
re-finding the head after the lid closed).
The baseline is mode-aware: in cloud mode the client owns the GRBL
controller's init values, the lid lamp, and the position counters; the
mode itself is preserved unless the run declared the change
(Context.mode_changed); controller_mode is never handed back as a bare
setting (a bare write left the persisted mode out of step with the live
one). The undeclared-change restore now uses the captured state, which
the post pass never saw before.
The acceptance page gets an "Ignore prerequisites" switch (remembered by
the browser): POST /start {ignore_requires} starts a test whose requires
are unmet, and the run records the unmet prerequisites in its evidence
and log; they stay required for the release. The cloud tests' requires
no longer chain through cloud.mode-switch.
Proof: tests/test_cloud_suite.py replays the four tests on the bench's
own gfcloud excerpts (fixtures/) and the run loop's emitted pause/resume
lines under the real runner Context against a fake forgectrl; baseline
mode tests and the server override test; the whole suite (98) and the
coverage lint pass. Catalog consequence: cloud.* fingerprints move with
the module; the catalog hash moves with the requires.
|
||
|
|
9878c8d3b9 |
forgetest: kernel counters prove the return, and ring residue is a leftover that blocks the jog
The lid-cancel tests (motion.lid-cancel-home, laser.lid-cancel-mid-fire) now check that the KERNEL position counters returned to the job start, not only grbl's MPos - grbl's drift read 0.000 while the head had not moved. The baseline reports unplayed bytes queued in the kernel ring (cnc/position total minus processed) as a leftover and refuses its return jog while any exist: a run started on top of them replays them first, which is what put the head into the rail. BRINGUP item 16 records the bench finding and the stream-engine fix. |
||
|
|
b68d790b71 |
baseline: take the fresh-boot reference after the controller applied its config
/mode reports controller=running at the spawn, so the reference dump raced grblHAL's init writes and captured the supervisor's motion-probe values (motor_lock 0, step_freq 10000, y_mode at the module default) instead of the resting state. boot_reference() now waits for the controller's markers (step_freq/motor_lock/y_mode at their fixed values, bounded 20 s, GRBL mode only) plus a 1 s settle before dumping, retakes a saved reference that shows the pre-config state while the boot is still fresh, and marks it otherwise. Tests cover the wait, its timeout, the non-GRBL no-op, the pre-config recognition and the retake. |
||
|
|
cd8a01a3d9 |
bench: every board-runnable tool ported to the bench page
The remaining bench diagnostics run from forgetest's #bench tab. The tools that also run from a LAN host share scripts/bench/gfbench.py: GF_HOST names a remote machine (host mode, sysfs through ssh, Grbl and forgectrl over the LAN); unset, the tool runs on the board itself (local mode, sysfs directly, everything on 127.0.0.1), which is how the page runs them - with GF_HOST=127.0.0.1, the panel token in GF_TOKEN and their data files under <data>/bench/ (FORGETEST_BENCH_DATA). The helper also reads a machine setting from forgectrl, or from the settings file on the board while forgectrl is stopped. Ported: pwm_sweep / pwm_hold (scope = a takeover; the latch relocked, the write refused if FIRE or LASER_ON reads active), pwm_stream_test (PASS/FAIL exit), flow_characterize, flow_recheck_char, flow_warm_validate and flow_matrix (takeovers: forgectrl owns the thermal hardware, so the page's takeover replaces the tools' own controller stop/restart, whose command line predated the supervisor; results and logs in the bench data directory), flow_sustained, fan_test, temp_calibrate (dry; watch bounded in seconds; the threshold and the coolant conversion from the shared code), flow_escalate_drill (cool_confirm_max_s shortened through forgectrl's settings for the drill and restored; the setting's minimum is the default budget), and live_fire_drills (<drill> [S] [F], all six drills, host from GF_HOST, token from the board). flow_matrix joins the registry. What stays unported cannot run against the machine at all: the two null-sink CI harnesses and the .puls decoder. Runner: a scope tool runs inside the takeover wrapper; the bench environment above is passed to every tool. Tests: test_bench_registry (registry <-> scripts/bench consistency, every ported tool builds its command line, every script compiles, gfbench host/local modes) and the server test (scope tool takeover, the environment reaching the tool). Local mode smoke-run on the bench (temp_calibrate watch, setting, token) from /tmp, removed after. No catalog consequence: bench tools are not image components (dev-only forgetest); the acceptance catalog is unchanged. |
||
|
|
b51e695fb1 |
manifest: component pins are not layer content
A component pin bump counted as a platform change: the layer content hash in the platform identity covered the recipe carrying the SRCREV, the platform is folded into every acceptance fingerprint, so every image that carried any component update invalidated the whole catalog (dev image 20260816191951: every test domain-changed after a one-line forgectrl bump; the two manifests differ only in platform.layers.meta-forgefirm). The component entry already identifies the pinned source file by file; the pin double-counted it. Component pins now live in <recipe>-pin.inc (SRCREV and the PV that moves with it, nothing else) - forgectrl, grblhal-glowforge and forgefirm-app here, the BSP components in meta-openglow - and forgefirm-image-manifest.bbclass leaves *-pin.inc out of the layer content (FORGEFIRM_MANIFEST_PIN_SUFFIX). Recipe bodies, patches, config fragments, init scripts and third-party pins with no manifest entry stay layer content; a pin written into a recipe body still hashes (the safe direction). manifest-from-tree.py mirrors the rule and reads pins through the recipe's requires; test_tree_manifest.py proves both (pin bump: hash unchanged; recipe body or inline pin: changed). Bitbake resolves the same SRCREV/PV for every pinned recipe. Docs: ACCEPTANCE.md (what layer content is), kas/README.md (the pin files in the push order), BRINGUP.md (the finding and the bench consequence: the first image built with the pin files is itself a platform change, so its campaign is a full one; pin bumps inherit after it). No catalog consequence: nothing in the image's behavior changes; the change is to the acceptance identity computation, proven by the unit tests and the CI lint on the tree manifest. |
||
|
|
d0291ec7cb |
forgetest: the lid lamp idles at forgectrl's lid_lamp_idle; the liveness test masks and restarts
The lid lamp now has a resting policy in forgectrl (lid_lamp_idle, default 236, asserted at start and at every spawn), so the baseline expects it there instead of preserving whatever level a boot left, and forgectrl.settings-bounds proves it: resting at the setting, 256 / -1 / 'bright' refused, a new level applied to the lamp at once, the cleared key back to the default. Clearing a key goes through the query-string form (an empty JSON value reads as no setting). motion.liveness-probe adds the regression the bench needed: with every axis masked (cnc/motor_lock=15, as a bench tool may leave it) forgectrl is restarted and its fresh probe must read MOTION OK on the first try - the probe unmasks the axes itself - and the controller comes up with the mask cleared. Bench 2026-08-16: MOTION OK at p2p 2047/1341 against the 800 threshold, no ladder. The baseline's settle no longer counts 'probe verified, spawn pending' as settled (the post pass ran between the probe's own writes and the controller's init writes and mis-flagged motor_lock/step_freq): it waits for the controller to be running, with a bounded allowance for a respawn backoff. |
||
|
|
a80d7aabfb |
forgetest: cloud tests prove the session from the client's log and hand the head back
cloud.mode-switch took the optional connect-time firmware probe file as the evidence of a live session and failed on a bench where that check is off; the evidence is now gfcloud's own authenticate/ws-connect lines in the unified log after the switch, the probe recorded when present. Cloud mode's connect clears the kernel position counters at the head's start and its hunt homes the head to the corner: the test tells the runner the counters were re-zeroed (Context.counters_rezeroed) and jogs the head back by the counter-measured displacement, and hands the lid lamp back at the level it found. cloud.gfhome-homing documents that it leaves the machine homed at the corner. Baseline: a displaced head is jogged back along its own path by the kernel-measured X/Y delta through the GRBL controller (bounded 100 mm, waits out a controller respawn backoff); Z is never touched. |
||
|
|
4aaedc8080 |
forgetest: every run starts from, and leaves, the fresh-boot idle state
A baseline pass brackets every test and bench tool: before the run the machine is verified against the fresh-boot idle state and anything off it is restored; after the run - pass, fail, or abort - it is restored again. Fixed items are the resting values the boot establishes (module defaults, forgectrl's start-up writes, the GRBL controller's init writes) and forgectrl's idle picture (controller running with motion verified, no diagnostic, camera and cooling engines idle); preserved items (lid lamp level, position counters, settings map, controller mode) are captured before and handed back after. Deviations are leftovers: in the run pane, in the result's evidence, and on the page - attributed to the previous run when found before, to the run itself when found after. forgetest takes a fresh-boot reference once per boot (within ten minutes of boot, after the supervisor settles) as the session's resting lid-lamp level and the check on the fixed values; the values were confirmed against a fresh boot of the dev image on the bench (step_freq rests at 28160, the controller's default tick, not the probe's 10000). Takeover runs capture the controller-owned kernel attributes on entry and write them back before forgectrl restarts: the bench found the kernel tests leaving motor_lock=15 behind, which masked the supervisor's liveness probe - no motion by construction, a false driver-wedge verdict, the rail-off ladder, and finally motion-fault. The takeover wrapper also waits for the supervisor to settle on both sides (moved into baseline). Catalog consequence: none beyond the runner; the tests' own drills are unchanged. |
||
|
|
799e0e829c |
forgetest: a takeover waits for forgectrl to settle on both sides
forgectrl's supervisor probes motion liveness on every start - a small head move verified by the accelerometer, with a rail-off ladder of up to about 70 s on a dead verdict. The takeover wrapper returned as soon as forgectrl start succeeded, so a test that followed found the machine busy (bench: cooling.flow-verify refused with 409 machine is not idle 13 s after kernel.fire-abu), and back-to-back takeover tests stopped forgectrl mid-probe. The wrapper now waits for /mode to settle - motion verified, motion-fault (logged as a warning), or standby - before it stops forgectrl and again after it starts it, giving up after 10 s when forgectrl does not answer at all (the host, or a daemon that is down). No catalog consequence: the takeover tests' own drills are unchanged. |
||
|
|
c0f53a865f |
forgetest: the release acceptance tool and the bench diagnostics page
A stdlib-only daemon on the dev image (HTTP :8090) that runs the acceptance catalog against the machine from a self-contained page, keeps the append-only result log under /data/forgetest, and exports the release artifact the gate reads. Tests declare kind (auto / operator / live), hardware (api / takeover), coverage globs, prerequisites, and core membership; a test's domain fingerprint is the hash of the manifest files its globs select plus the platform and its own implementation, so a PASS stays valid exactly while nothing it covers changed. Campaign rules: a FAIL ends the campaign, the core (image health, kernel latch and drills, one live emission witness) is never inherited, invalidate-all forces a full campaign, no SKIP. Live tests need the operator acknowledgment and the physical arm press through the controller; takeover tests stop forgectrl for the duration with a crash-recoverable marker; the tool never touches the laser latch. Catalog v1: 24 tests ported from the proven bench drills with their recorded pass criteria (image, kernel K1-K3 and fire A/B/U, forgectrl API and logs, motion incl. dead-man, cooling, live laser, camera, update, cloud). The bench tab lists every scripts/bench tool and runs the board-side ones as subprocesses (takeover tools wrapped). 44 host unit tests, including the gate verification fixtures. Installed only by forgefirm-image-dev, with the bench scripts under /usr/share/forgetest/bench. |