The direct branch of main()'s per-test setjmp loop — taken when a test
runs normally without longjmping back — now prints "RUN: <name>" instead
of "PASS: <name>". This is the same neutral marker family child 1
(bf-52k2) chose for the else branch ("RUN: <name> (assertion failed)"),
so a passing test prints "RUN: <name>" and a failing test prints
"RUN: <name> (assertion failed)".
After this child, NEITHER branch emits PASS/FAIL; each test emits exactly
one neutral line. The internal passed++ counter is unchanged — it feeds
the run summary ("N passed, M failed of T"), not the per-test output, so
it is not an "observable label". g_failure_count accounting is likewise
untouched.
Child 2 of 4 for bf-38e9 (split-child 2 of bf-53ut).
Co-Authored-By: Claude <noreply@anthropic.com>
Reconciles divergence: local 8fbc2e8 (volatile doc) and origin's
4b0eaba (same) plus 5e58859 (nvs/csi/serial_prov host tests + CI
wiring) and fbb86fb (gitignore). Clean merge: both branches agree on
test_runner.c content; origin additionally adds host tests and CI.
The else branch of main()'s per-test setjmp loop — taken when a failed
assertion longjmps back — now prints "RUN: <name> (assertion failed)"
instead of "FAIL: <name>". The marker still names the failing test and
its own per-test `failed` counter, but contains no PASS/FAIL outcome
token (outcome labels are deferred to a later sibling of bf-53ut).
g_failure_count accounting is unchanged: test_record_failure() already
bumped it before the longjmp, so this branch only prints.
Child 1 of 4 for bf-38e9 (split-child 2 of bf-53ut).
Co-Authored-By: Claude <noreply@anthropic.com>
The ESP-IDF host-test path (idf.py test --target linux) was explicitly rejected
(decision record: docs/notes/firmware-host-test-approach.md) because firmware/main
builds as one idf_component_register whose REQUIRES names esp_wifi/bt/driver —
components with no host build, so the whole component is unhostable. Firmware host
tests instead run as a plain gcc harness under firmware/test/ (wired into CI via
the Dockerfile's 'RUN make -C test test').
firmware/test_apps/host_tests/ was leftover CMake/ninja build output from an
abandoned idf.py linux experiment (no source files). Gitignore firmware/test_apps/
so the rejected approach's artifacts can't be committed by accident.
Refs bead bf-31bp (host-test intent satisfied by the committed gcc harness).
Co-Authored-By: Claude <noreply@anthropic.com>
Adds the three firmware host-test modules required by the Testing Strategy as a
plain gcc harness under firmware/test/ — NOT idf.py --target linux. That path was
rejected (docs/notes/firmware-host-test-approach.md, bf-21t): firmware/main
cannot host-link because csi.c pulls in esp_wifi.h and provision.c pulls in
driver/uart.h, and the single `main` component REQUIRES esp_wifi/bt/driver,
which have no linux build — so even nvs_migration.c (hostable in isolation) is
unhostable as part of the component. The harness therefore tests dependency-free
logic extractions and binary-format/wire contracts instead of linking the
firmware source.
- test_nvs_migration.c: fresh-install init to v1, no-downgrade guard, forward
migration loop dispatch (v→v+1 at index v−1), and the concrete v1→v2 step
(rename ms_ip→mothership_ip, default ntp_server), driven against an in-memory
NVS store. Mirrors nvs_migration.c decision-for-decision.
- test_csi_frame.c: 24-byte header field round-trip, explicit little-endian
timestamp byte order, signed-RSSI (uint8_t) reinterpretation, I/Q payload
copy, n_sub=0 header-only probe, and the ingestion-side validation rules
(too-short / payload-mismatch / n_sub>128 / bad channel). Mirrors the
websocket.c encoder contract (offset/byte for offset/byte).
- test_serial_prov.c: provisioning JSON parser + NVS-mapping mirror of
provision.c (all four protocol branches + every field mapping), shipping a
bounded recursive-descent JSON decoder as the fuzz target. The fuzz pass
(4000 random byte streams, a tricky-input corpus, 500 deep-nesting cases)
proves the parser never crashes and the protocol always answers a single
well-formed {"ok":...} line on any UART input.
- Makefile: gcc build/run recipe that globs every test_*.c + test_runner.c.
CI wiring: the Dockerfile firmware-builder stage now runs `make -C test test`
before the expensive ESP-IDF build, so a logic/format-contract regression fails
the image build fast. .gitignore + .dockerignore exclude the regenerable
host_tests binary.
docs/plan/plan.md Testing Strategy updated from the idf.py description to the
gcc harness (matching the decision record).
28 tests, all passing. go test ./... and go vet ./... unchanged (firmware-only).
Co-Authored-By: Claude <noreply@anthropic.com>
Add a comment next to the per-test setjmp(g_test_jmp) guard in main()'s
loop recording the C11 7.13.2.1 volatile analysis: the loop index i is
read in the post-longjmp path (g_tests[i].name) but is not modified
between the setjmp() call and a possible longjmp() — the body only reads
i, and the only write is the for-loop increment, which runs after control
returns. So no volatile qualifier is needed. (bf-2ftl)
Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-2ftl
Add a comment next to the per-test setjmp(g_test_jmp) guard in main()'s
loop recording the C11 7.13.2.1 volatile analysis: the loop index i is
read in the post-longjmp path (g_tests[i].name) but is not modified
between the setjmp() call and a possible longjmp() — the body only reads
i, and the only write is the for-loop increment, which runs after control
returns. So no volatile qualifier is needed. (bf-2ftl)
Co-Authored-By: Claude <noreply@anthropic.com>
The per-test setjmp/longjmp recovery loop in test_runner.c (main()) was
already delivered by sibling bead bf-bq9 (commit 549dc1f) and verified to
satisfy bf-53ut's contract: a failing assertion longjmps back to main()'s
setjmp, which falls through to advance the loop, so a failure in test N
never blocks tests N+1..end (proven with a temp failing-test followed by a
passing test — both ran and the process completed without aborting).
This commit lands the three harness files that were staged but absent from
HEAD (Makefile, .gitignore, test_sanity.c) so that 'make -C firmware/test
test' — the command bf-53ut's acceptance invokes — actually builds and runs
against the committed tree. Without them the committed test_runner.c could
not be built from a fresh clone.
Verified against the bf-53ut acceptance criteria:
- gcc -std=c11 -Wall -Wextra (-Werror): clean; no -Wclobbered/setjmp warnings
- normal suite: PASS, exit 0
- failing test + later passing test: both per-test lines print, process
completes (non-fatal assertions)
- no changes to firmware/main/* or firmware/CMakeLists.txt
Co-Authored-By: Claude <noreply@anthropic.com>
Completes the runner (bf-bq9, child of bf-2i4). main() qsort()s the
registered tests by name for deterministic order regardless of constructor
or link order, drives each through the per-test setjmp/longjmp recovery
loop (print PASS on normal return, FAIL on longjmp return so one test's
failure never blocks the rest), prints a passed/failed/total summary, and
returns 1 iff g_failure_count > 0 — the non-zero-on-failure exit code CI
relies on via `make -C firmware/test test`.
Co-Authored-By: Claude <noreply@anthropic.com>
bf-3id (child of bf-2i4). Adds the per-test failure-recovery machinery to
firmware/test/test_runner.c:
- static jmp_buf g_test_jmp at file scope — the live setjmp() target the
header ASSERT_* macros longjmp into; declared at file scope so the main()
landing in bf-bq9 can setjmp() it directly before each test.
- static int g_failure_count — a run-wide counter test_record_failure()
bumps on each failure, so main() can return non-zero on any failure.
- test_record_failure(file, line, fmt, ...) — prints file:line plus the
vfprintf-formatted detail to stderr, bumps the counter, and longjmp()s
into g_test_jmp so the current test aborts but the runner continues.
main() (the setjmp caller + PASS/FAIL reporting + non-zero exit) is
intentionally absent — it lands in the sibling bead bf-bq9. Until then this
TU compiles to an object (gcc -std=c11 -Wall -Wextra -c) but has no live
setjmp target for the longjmp, and does not link into a runnable harness.
Co-Authored-By: Claude <noreply@anthropic.com>
Implement test_register() in firmware/test/test_runner.c, child 3 of the
bf-lfz sub-split. Appends each test entry at index g_test_count in GCC
constructor order and bumps the count, so the registry is fully populated
before main() runs. On a full registry (g_test_count >= MAX_TESTS) it logs
to stderr naming the skipped test and the cap and returns without writing
past the end — never overflows the array.
The static registry storage (g_tests/g_test_count) is now referenced, so the
__attribute__((unused)) it carried through child 2 (bf-uvv) is dropped.
test_record_failure() and main() remain intentionally absent (siblings
bf-3id and bf-bq9). Compiles cleanly to an object with zero warnings:
gcc -std=c11 -Wall -Wextra -Werror -c (no link, no main).
Co-Authored-By: Claude <noreply@anthropic.com>
Child 2 of the bf-lfz sub-split (registry storage). Adds to
firmware/test/test_runner.c: #define MAX_TESTS 128, the static
test_entry_t g_tests[] array, and static g_test_count, with comments on why a
fixed static array is appropriate (GCC constructors from TEST() populate it
before main(); keeps the harness dependency-free; 128 is far more than the
handful of pure-logic host tests this harness targets).
The two statics are marked __attribute__((unused)): nothing reads or writes
the registry until child 3's test_register() lands, and gcc 14 (unlike the
older gcc the original "static file-scope symbols do not warn when unused"
assumption rested on) DOES warn on unused file-scope statics under
-Wall -Wextra. Without the attribute the object emits two -Wunused-variable
warnings and fails the bead's zero-warning build gate.
Compiles cleanly to an object with zero warnings:
gcc -std=c11 -Wall -Wextra -c firmware/test/test_runner.c
No changes to firmware/main or firmware/CMakeLists.txt.
Co-Authored-By: Claude <noreply@anthropic.com>
Child 1 of 3 of the bf-lfz sub-split (bf-lfz is itself a child of bf-2i4;
grandparent bf-56v; great-grandparent bf-4ne; header API in bf-1xs). bf-lfz
failed three times as a single big-bang change, so its registry work is
decomposed into three atomic, independently-compilable pieces. This is the
skeleton (child 1):
- A top-of-file comment block documenting the incremental build-out: child 2
adds the registry storage (the array + count), child 3 adds test_register(),
and the failure handler + main() arrive in the sibling beads bf-3id and
bf-bq9.
- Exactly the five includes the full runner will need: "test_runner.h" then
<setjmp.h>, <stdio.h>, <stdlib.h>, <string.h> — libc only, no includes from
firmware/main, by design (see test_runner.h's header comment and the bf-21t
decision record).
With only the skeleton present this TU compiles cleanly to an object
(gcc -std=c11 -Wall -Wextra -c, zero warnings) but is not yet linkable into a
runnable harness: test_register() and test_record_failure() are still undefined
and there is no main(). Those arrive with the later children and siblings. No
registry array, count, or test_register() yet.
Co-Authored-By: Claude <noreply@anthropic.com>
Child 1 of the bf-56v harness split (grandparent bf-4ne). Establishes
the public header API (firmware/test/test_runner.h) that every test unit
and the runner compile against:
- TEST(name): declares a void body plus a GCC constructor
(__attribute__((constructor))) that self-registers it via
test_register(name, fn) before main() runs — so a new test_*.c is
picked up with zero SOURCES edits.
- ASSERT_EQ / ASSERT_TRUE / ASSERT_FALSE: on mismatch, call
test_record_failure(file, line, fmt...) which longjmp()s back to the
per-test setjmp (the live jmp target lives in test_runner.c, a later
child) — aborting only the current test, not the whole suite.
- Prototypes for test_register(const char*, test_fn) and
test_record_failure(...).
Header comment records the bf-21t decision (gcc host harness, not
ESP-IDF --target linux: csi.c/provision.c are blocked by esp_wifi.h /
driver/uart.h, which have no linux build, and main is one component)
and names the single run command ('make -C firmware/test test') that a
later child makes real.
Self-contained: only libc (setjmp.h, stdbool.h, stdint.h); no
firmware/main/* includes; no main(). A trivial includer using TEST(x){}
compiles cleanly with 'gcc -std=c11 -Wall -Wextra -c'.
test_runner.c, the Makefile, and the sanity test belong to sibling
beads and are intentionally not part of this commit.
Co-Authored-By: Claude <noreply@anthropic.com>
Decision spike for the firmware/test/ host harness (split of bf-4ne).
Records why the ESP-IDF --target linux / Unity-host path was rejected:
csi.c is blocked by esp_wifi.h and provision.c by driver/uart.h, and
firmware/main builds as one component whose REQUIRES line names
esp_wifi/bt/driver — none of which have a linux build. Falls back to a
plain gcc harness under firmware/test/ that does not link main/*.c.
Reason must be carried into bf-4ne's test-runner header comment.
Co-Authored-By: Claude <noreply@anthropic.com>
Update docs/plan/plan.md to reflect the actual repo (VERSION 0.1.357):
- Go Module Layout: go.work stitches 3 modules (mothership, cmd/sim,
test/acceptance); no test/integration/ — tests live in test/acceptance/,
mothership/test/acceptance/, and tests/e2e/run.sh
- Dockerfile: espressif/idf:v5.2 firmware stage + golang:1.25-bookworm,
GOOS/GOARCH pinned to linux/amd64 (single-arch, deliberate), image
published as ronaldraygun/spaxel via the spaxel-build WorkflowTemplate
- Quality Gates item 4: document the amd64-only build decision (arm64 is
future work) instead of the contradictory multi-arch gate
- Integration Tests: correct location to the real test dirs
- Open Questions: remove the duplicated Multi-installation coordination bullet
- Bump Last updated to 2026-07-03; Status → maintenance mode
Co-Authored-By: Claude <noreply@anthropic.com>
Covers Spaxel overview, realistic CSI capabilities, privacy framing, the
go.work multi-module repo layout, docker-compose quickstart with key env
vars, and pointers to docs/plan, docs/notes, docs/research, and the
dashboard README. Accurate to the current tree (image ronaldraygun/spaxel);
no secrets or host paths.
Co-Authored-By: Claude <noreply@anthropic.com>
Both are implemented (mothership/internal/recording/buffer.go and
mothership/internal/ingestion/ratecontrol.go). Phase 2 is now complete.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 table showed "Dashboard presence indicator" as Pending, but it
is implemented in dashboard/live.html and dashboard/js/app.js
(updatePresenceIndicator, #presence-indicator styling). Update the
table row to Done and drop it from the "Remaining for Phase 2" list.
Co-Authored-By: Claude <noreply@anthropic.com>
Previously the ready signal was sent exactly once at window open, so the
browser had to have the serial port open at that exact millisecond after
reboot. Now the firmware broadcasts SPAXEL READY every second for the
full provisioning window (2 min fresh / 15 s reprov), giving the host
ample time to open the port and catch the handshake.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The no-docker path created the cmd but never called cmd.Start(), so
waitForMothership always timed out. Add Start(), stdout/stderr wiring,
SPAXEL_BIND_ADDR, and SPAXEL_MDNS_ENABLED=false for CI headless operation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 0.1.352 Docker image contained firmware compiled with CONFIG_ESPTOOLPY_FLASHSIZE=16MB
despite sdkconfig.defaults being updated to 4MB in d837598. Kaniko served a cached
firmware layer, bypassing the sdkconfig.defaults change.
Result: ESP32-S3 (4MB flash) flashed via Web Serial crashed on every boot:
spi_flash: Detected size(4096k) smaller than binary image header(16384k). Probe failed.
Fix:
- Add FIRMWARE_CACHE_BUST ARG before COPY in firmware stage (guarantees cache miss)
- Add RUN rm -f sdkconfig sdkconfig.old so idf.py set-target regenerates from
sdkconfig.defaults (CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y) on every build
Bumps version to 0.1.354 to trigger a fresh CI build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
serveEmbeddedFile asserted the embedded file implemented
`interface { Len() int64; io.ReadSeeker }`, but *embed.openFile has no
Len() method, so http.ServeContent panicked (caught by chi Recoverer ->
500) on every embedded page: /fleet, /ambient, /live, /setup, /simple, /.
http.ServeContent only needs an io.ReadSeeker, which embed files satisfy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explicit VERSION bump so resolve-version skips its auto-bump git push
(which was racing with the e2e workflow), letting docker-build run with
the GOOS/GOARCH fix and push 0.1.352.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The multi-arch change (2cd4410) derived GOOS/GOARCH from TARGETPLATFORM
with wrong cut field indices (-f2/-f3), yielding the invalid pair
amd64/amd64 -> `go: unsupported GOOS/GOARCH pair amd64/amd64`, failing
every CI image build since May 24. CI builds amd64 only (ESP-IDF firmware
is x86_64-only), so pin linux/amd64 explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty commit to fire the github webhook -> spaxel-build, so the failing
build step can be observed before podGC deletes the pod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins a deterministic tag for the image that includes the simulator binary
(added in 3ca6e8f), avoiding ambiguity with any in-flight 0.1.347 build
that predates the Dockerfile change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds cmd/sim alongside the mothership and copies /spaxel-sim into the
final image so the same image can drive a synthetic-node CSI load against
a deployed mothership. Default ENTRYPOINT still runs the mothership.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix timezone mismatch between local time formatting and SQLite's UTC-based
date() function. AggregateDaily, GetWeeklyTrend, and GetAllWeeklyTrends now
use .UTC() before formatting dates to match SQLite's date(timestamp, 'unixepoch').
Closes: bf-26eg
Tests: TestHealthStore_DailyAggregation, TestHealthStore_GetAllWeeklyTrends now pass
- Fix SQL syntax errors caused by //nolint:errcheck comments inside raw string literals in anomaly.go
- Fix TestFlowAccumulator_DwellWhilePaused by establishing last waypoint before pausing
- Fix predictor_test.go compilation errors:
- Remove unused \"os\" import
- Replace AddTransitionSample with RecordTransition using ZoneTransition struct
- Fix map literal with missing key name
All tests now pass.
Closes: spaxel-test-fixes
- Convert pretty-printed JSON to proper line-delimited JSONL
- Fix bf-awtza bead: set closed_at for closed status
- Enables br sync --import-only to rebuild database
Implement IO-1 (Fresh install / first boot) and IO-2 (Idempotent restart)
acceptance tests for the hardware-free install and onboarding journey.
IO-1 validates:
- Fresh install starts with empty data volume
- First-run setup is accessible before PIN configuration
- PIN setup completes successfully
- Migrations run (detected in logs)
- PIN persists after setup
- Health check returns green
- No nodes are attached on fresh install
IO-2 validates:
- Configured install (PIN, node, zone) persists across restart
- Same data directory is reused after restart
- No re-setup prompt appears after restart
- Node label and position persist correctly
- Zone configuration persists correctly
- Mothership remains healthy after restart
These tests complete the IO-1..IO-11 acceptance test suite as specified
in docs/plan/plan.md, enabling hardware-free CI validation of the
installation and onboarding journey.
Closes: bf-2hi0h
Implements acceptance tests for failure scenarios and edge cases during
node onboarding per the plan specification:
- IO-7: Provisioning timeout - node that goes silent is marked offline
within heartbeat window (60s) and surfaced in /api/fleet; no crash
- IO-8: Bad/expired token - invalid token rejected with clear error;
node never enters fleet; no zombie row
- IO-9: Duplicate MAC - second connection with same MAC handled
(disconnects first or rejects second); no duplicate rows
- IO-10: Drop mid-onboard - killing simulator during onboarding leaves
node re-onboardable; no half-provisioned lock
- IO-11: Firmware-version skew - old firmware nodes onboard successfully
and OTA can be initiated
Tests use the acceptance harness with spaxel-sim and verify proper
handling of each scenario without mothership crashes or data corruption.
Closes: bf-1922s
Implements IO-6: Full new-user E2E (happy path) — HARD GATE.
The test verifies the complete onboarding journey from fresh install
to live events:
1. Fresh install + PIN setup
2. 6-node fleet onboarding via spaxel-sim
3. Define 2 zones + 1 portal
4. Run walker simulation
5. Verify blob detection, zone-presence events, portal-crossing
events, timeline entries, and MQTT/HA integration status
Added helper methods to TestHarness:
- CreateZone, CreatePortal, GetPortalCrossings
- GetTimeline, GetMQTTStatus
Closes: bf-1rifr
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement acceptance tests for single-node and multi-node fleet onboarding:
- IO-3 (TestIO3_SingleNodeOnboarding): Validates end-to-end onboarding of a
single simulated ESP32 node via spaxel-sim. Verifies node transitions from
discovered to online, appears in /api/nodes within 10s, and that label/position
assignments persist via REST API (PUT /api/nodes/{mac}/position and PATCH
/api/nodes/{mac}/label).
- IO-4 (TestIO4_MultiNodeFleetBringup): Validates multi-node fleet bring-up with
6 nodes. Verifies all nodes reach online status, no TX-slot collision warnings
in logs, /api/nodes shows all 6 online, and fleet telemetry data is available
via /api/fleet endpoint.
Also fixes a context leak in TestIO5_DeviceIdentityBLEOnboarding by ensuring
the simulator context cancel function is called with defer.
Closes: bf-4jcjg
Quality gate #7: Verify nodes without valid tokens are rejected with HTTP 401.
- Created as7_auth_reject_test.go following AS1-AS6 pattern
- Tests WebSocket connection without X-Spaxel-Token header
- Verifies HTTP 401 Unauthorized response
- Validates simulator exits non-zero with invalid token
- Confirms no zombie nodes in fleet after rejection
- Registered AS7_AuthRejectIntegration in test runner
Closes: bf-2d9fj
Add migration_018 that creates all 8 prediction subsystem tables in the main
database, consolidating the separate prediction.db and prediction_accuracy.db
files. Add NewModelStoreWithDB and NewAccuracyTrackerWithDB constructors that
accept an existing *sql.DB connection, and update main.go to use the main
database connection instead of separate files.
- Added migration_018_add_prediction_tables with all prediction tables
- Added NewModelStoreWithDB() to accept shared DB connection
- Added NewAccuracyTrackerWithDB() to accept shared DB connection
- Updated Close() to only close DB when we own it (path != "")
- Updated main.go prediction init to use mainDB
The legacy NewModelStore() and NewAccuracyTracker() constructors remain for
backward compatibility (tests continue using their own migrations).
Closes: bf-38wcp