Everything on this page is Implemented — it exists in the tree today and the commands were run
against it. The test suite covers the Python backend, nilo-server, only. There is no Java, mobile
or frontend code in the repository and therefore no test suite for any of those
(migration.md lists what was removed).
Current state: all passing, in main/nilo-server/tests, plus a lint pass, a
robot/-scoped type check, a Compose validation and a runtime smoke script. The simulator
added three files: two socket-free suites and the end-to-end one
(robot-simulator.md Sect. 13).
Tests live in main/nilo-server/tests and pytest is configured in
main/nilo-server/pyproject.toml (testpaths = ["tests"], asyncio_mode = "auto",
addopts = "-ra"). All commands below assume main/nilo-server as the working directory unless
stated otherwise.
cd main/nilo-server
pip install -r requirements-dev.txt # tooling + the small runtime slice the tests import
pytest -q # 1283 passed, 11 skipped on this slice| Command | Where | What it does |
|---|---|---|
pytest -q |
main/nilo-server |
Runs the whole suite |
make e2e |
repository root | The ten end-to-end scenarios, and writes main/nilo-server/tmp/e2e-report.md |
make test |
repository root | cd main/nilo-server && python -m pytest -q; override the interpreter with make test PY=.venv/bin/python |
pytest -q tests/robot |
main/nilo-server |
One directory |
pytest -q tests/robot/test_protocol.py |
main/nilo-server |
One file |
pytest -k "strict or retired" |
main/nilo-server |
Select by test-name substring |
pytest -x -q |
main/nilo-server |
Stop at the first failure |
pytest -q -rs |
main/nilo-server |
Print the reason for every skip — useful on the dev-only slice |
asyncio_mode = "auto" means async def test_* functions run without an explicit
@pytest.mark.asyncio; pytest-asyncio is in requirements-dev.txt. Tests that drive async code
from a sync test call asyncio.run(...) directly (for example tests/core/test_ws_path_gate.py).
| File | Tests | Covers |
|---|---|---|
tests/test_smoke.py |
2 | pytest collects from the directory; tmp_path is writable |
tests/test_imports.py |
36 | Walks config, core, plugins, plugins_func, robot with pkgutil.walk_packages and imports every module found — catches broken renames. A ModuleNotFoundError is treated as a missing optional dependency and skipped, not failed. The count is dynamic: one parameter per module discovered. Discovery stops at directories without an __init__.py, so the namespace packages core/utils/, core/providers/, core/handle/, core/api/ and plugins_func/functions/ are never walked |
tests/test_compose.py |
2 | Parses docker-compose.yml with PyYAML (no Docker daemon): the only service is nilo-server, image ghcr.io/aa-box/nilo-server:latest, ports 8000/8003, the four NILO_* variables are present, ./data is mounted at /opt/nilo-server/data; and the file carries none of the retired upstream service names (the legacy server, manager, xinnan) |
tests/config/test_config_loader.py |
7 | config/config_loader.py:merge_configs — override, add, recursive and deep-recursive merge, dict↔scalar replacement, and that the defaults dict is not mutated |
tests/config/test_nilo_config.py |
6 | apply_env_overrides (the NILO_SERVER_HOST/PORT, NILO_HTTP_PORT, NILO_LOG_LEVEL mapping, ignoring unset/empty values, creating a missing section), apply_deprecated_aliases now being a no-op because config_loader.DEPRECATED_KEYS is empty, custom_config_path() honouring NILO_CONFIG and falling back to data/.config.yaml, and one end-to-end load_config() that proves a user hello: override and an env override land in the merged config alongside protocols.nilo.enabled and protocols.strict |
tests/core/test_http_routes.py |
5 | core/http_server.py:SimpleHttpServer._build_app — the route table is /nilo/ota/, /nilo/ota/download/{filename} and /mcp/vision/explain, and no retired route is registered alongside them; disabling protocols.nilo or setting read_config_from_api: true leaves only /mcp/vision/explain; _get_websocket_url advertises ws://host:port/nilo/v1/ unless server.websocket overrides it |
tests/core/test_ws_path_gate.py |
4 | core/websocket_server.py:WebSocketServer._http_response — the pre-handshake gate accepts /nilo/v1/ (including with a query string and a keep-alive, Upgrade connection header), returns 404 for retired and unknown paths, accepts an unknown path again once protocols.strict is false, and answers a plain HTTP probe with 200 and a body naming nilo-server. The server object is built with __new__ to skip VAD/ASR model loading |
tests/core/utils/test_current_time.py |
7 | core/utils/current_time.py under freezegun — HH:MM and ISO date formatting, the English WEEKDAY_MAP (all seven days), the four-tuple from get_current_time_info(), and the cnlunar lunar string |
tests/core/utils/test_dialogue.py |
11 | core/utils/dialogue.py — Message defaults (uniq_id, is_temporary, tool_call_id), Dialogue.put/get_llm_dialogue, update_system_message replacing rather than appending, and <memory></memory> substitution in get_llm_dialogue_with_memory |
tests/core/utils/test_instance_creators.py |
6 | The create_instance factories in core/utils/{intent,llm,memory}.py raise ValueError ("Unsupported…") for an unknown provider name and accept *args/**kwargs |
tests/core/utils/test_output_counter.py |
7 | core/utils/output_counter.py — per-device character accumulation, isolation between devices, check_device_output_limit at/over/under the limit, empty and None device ids, and reset_device_output |
tests/core/utils/test_textUtils.py |
11 | core/utils/textUtils.py — is_emoji, is_punctuation_or_emoji, get_string_no_punctuation_or_emoji, check_emoji |
tests/plugins/test_register.py |
20 | plugins/register.py — FunctionRegistry is constructible (regression: it used to raise NameError: setup_logging), register/unregister/lookup, fallback to all_function_registry, the @register_function decorator populating module_func_map, and the numeric codes of every Action and ToolType member, which executors branch on |
tests/plugins_func/test_loadplugins.py |
1 | Imports plugins_func/functions/get_time.py as plugins_func.functions.get_time and checks the attribute is there — the plugin subpackage still imports and its @register_function decorator (which registers get_lunar) runs at import time |
tests/robot/test_protocol.py |
13 | robot/protocol/ — the nilo route constants, nilo being the only entry in ALL_PROTOCOLS, ProtocolSpec rejecting unslashed paths, registry_from_config enabling nilo by default and raising RuntimeError once it is disabled, WebSocket path matching that ignores query strings and trailing slashes, strict defaulting to true so retired and unknown paths are rejected (and accepted again when it is false), a malformed protocols: block raising ValueError, and RESERVED_MESSAGE_TYPES covering the wire vocabulary |
| tests/robot/test_state.py | 16 | robot/state/ — robot-id normalisation, frozen models, bounded fields, the age carried by every timestamped entry and require_fresh raising StaleStateError rather than returning stale data, telemetry merge and change detection, and the in-memory store including its read-modify-write mutate |
| tests/robot/test_events.py | 12 | robot/events/ — fan-out to multiple subscribers, selection by type and by a tuple of types, sync and async handlers, unsubscribe, a handler that raises being isolated from the publisher and its peers, drop-oldest under a producer faster than its consumer, a slow subscriber not blocking a fast one, publish never waiting on a handler, and shutdown refusing further use |
| tests/robot/test_registry.py | 19 | robot/devices/registry.py — registration and its event, connected-only listing, a second session superseding the first, an idempotent re-register, a reconnect keeping telemetry and capabilities until discovery refreshes them, disconnect clearing live capabilities, double teardown and a teardown from a superseded session both being no-ops, telemetry merge with per-field events, liveness refresh, capability refresh with ToolDiscovered/CapabilitiesRefreshed, identity fields filled in from what the device reported, and 25 robots connecting, reporting and disconnecting concurrently |
| tests/robot/test_mcp.py | 21 | robot/devices/mcp.py against a fake device — the handshake, tools/list with and without pagination, a repeated cursor rejected instead of looping, a silent device timing out with no pending request left behind, malformed and duplicate definitions skipped and counted, protocol errors, tool calls and result unwrapping, device-reported tool errors, request ids starting above the inherited handshake ids, foreign responses ignored, and close rejecting in-flight requests |
| tests/robot/test_session.py | 23 | robot/runtime.py and robot/session.py end to end over fakes — attach, background discovery, discovery timeout, tool calls with their start/complete/fail events, a tool the robot never published being refused before it reaches the device, disconnect cleanup, reconnect rediscovery, a duplicate connection closing the superseded channel, runtime shutdown, and the session seam on a fake ConnectionHandler (attach, detach, no device id, a hostile connection object, a device without MCP, and the short explicit call timeout) |
| tests/robot/test_layering.py | 11 | robot/ imports in a subprocess with a clean working directory and no NILO_CONFIG, and importing it pulls in no core, config or plugins module; plus the whole import table of robot-architecture.md Sect. 7, enforced by parsing every module under robot/ — including that robot/safety imports only robot/state, so no layer above it can weaken it, and that nothing imports core at module scope |
| tests/robot/test_simulator.py | 110 | robot/simulator/ without a socket, on a hand-driven clock: the manual and accelerated clocks, ray-cast distance sensors, cliff regions, walls blocking a path, field-of-view visibility, the dock, a move that takes exactly its duration rather than teleporting, turns, cancellation, supersede, obstacle and cliff and motor and flat-battery outcomes, battery drain and charging, seeded sensor noise, the PNG the camera writes, the fixture camera, every published tool schema against the no-float and no-raw-actuator rules, every tool handler, the scenario runner's once-and-in-order guarantee, scenario files, and a scenario that replays byte-identically twice |
| tests/robot/test_telemetry.py | 24 | robot/telemetry.py — which notification methods are claimed, millimetres and degrees and millivolts converted to the world model's units, readings filtered to numbers, an unknown activity recorded as idle, a composite frame filling every slice, four shapes of malformed field falling back instead of raising, MotionCompleted / MotionFailed and a motion notification with no action id producing no event, ingestion into a runtime with its events, an unregistered robot, and the session seam claiming only telemetry and never raising on a hostile payload |
| tests/robot/test_actions.py | 138 | robot/actions/model.py and the vocabulary in robot/state/actions.py — all ten specs present and each declaring its type, resources and both tool names; every server tool name being exactly the sanitization of its device name; no float and no raw-actuator parameter anywhere; duration estimates; the exhaustive 64-pair transition table (legal pairs succeed, illegal ones raise and change nothing); 2000 seeded random walks in which no sequence reaches an illegal state, started_at never moves, and a terminal action is exactly one that is done; a terminal action refusing a second completion; and the record carrying every required field as an immutable snapshot |
| tests/robot/test_queue.py | 24 | robot/actions/queue.py — priority-then-age ordering, per-robot queues, claims and idempotent releases, conflicts naming the holder, a follow contending for drive, head and camera at once, an expression and an animation contending for the one face, head-of-line blocking only on the contended subsystem, preemption strictly by priority so peers never cancel each other in a loop, draining leaving running claims alone, and a 1000-step random interleaving of claims and releases asserting that no subsystem is ever double-booked and no terminal action still holds one |
| tests/robot/test_safety.py | 114 | robot/safety/ — every rejection reason, almost all of them parameterized over all five ActionSource values so "the LLM cannot override a rejection" is checked rather than assumed; determinism over 200 repeats of an identical request; the allow-list refusing a tool the device never published; cliff, pickup, stale and missing sensors, bump and obstacle gating (with reversing away still allowed); the battery floor; distance, angle, speed, duration and percentage bounds rejected rather than clamped; TTL and rate limits; the emergency-stop latch including the one asymmetry (SAFETY may still stop) and that clearing is explicit; the supervisory hazard_for being narrower than admission; and limits as configuration, where a typo'd key and an unparsable file both raise instead of silently leaving the default ceiling |
| tests/robot/test_watchdog.py | 16 | robot/safety/watchdog.py — deadlines firing once, disarm and re-arm, several firing together, a raising callback not stopping its peers, a callback re-arming from inside without deadlocking, periodic sweeps keeping their schedule, the callback running on a thread that is not the caller's, and — the property the design rests on — a deadline firing while the event loop is blocked in a synchronous sleep |
| tests/robot/test_executor.py | 63 | robot/actions/executor.py against a scriptable device: submit returning before any hardware is touched, dispatch with an explicit short timeout, an action whose reply is its completion, safety rejection from every source with nothing dispatched, the second safety evaluation catching a cliff that appeared while an action was queued, TTL expiry in the queue, rate limiting, one-at-a-time conflicts and concurrent work on free subsystems, priority order, preemption with a stop attempted, queryable conflict detection, watchdog timeout and dispatch timeout each with its own reason, a device error, an accepted command with no action_id, device failure and cancellation notifications, duplicate and replayed completions dropped without settling anything twice, cancellation and cancel_all, a stop bypassing a full queue and accepted with a cliff asserted, the emergency stop cancelling everything and refusing every source until explicitly cleared, disconnect cleanup, the supervisory sweep stopping a running motion on a lapsed heartbeat or a new cliff, the event stream, and the semantic handle including wait=False |
| tests/integration/test_actions_e2e.py | 18 | The action layer against a server started in-process and the real simulator, over a real socket, with the real threaded watchdog: a move that actually moves the robot and updates the world model, concurrent turn and head work, device-side following closing the distance, a cliff the robot reports rejecting a move from every source with nothing reaching the device, an over-limit move never dispatched, sensors going stale on a session that still looks healthy, a drop_motion_completion fault leaving an action that only the watchdog can end (TIMED_OUT, and a stop that does reach the device), a device that never acknowledges, a link severed mid-action, two drive actions serialised, a user request preempting a running behaviour, an emergency stop that halts the robot and refuses every source until cleared, a stop dispatched ahead of a full queue, a replayed motion_completed frame injected on the real channel disturbing nothing, and the runtime's own runtime.robot(id) handle |
| tests/integration/test_simulator_e2e.py | 17 | The simulator against a server started in-process over a real socket: registration, two robots being two entries, capability discovery (16 tools, dotted names sanitized, raw names preserved) including across several tools/list pages, telemetry landing in the world state, a tool call round trip, a camera frame the server's own image sniffer accepts, an unpublished tool refused before it reaches the device, a move reporting moving then MotionCompleted, cancellation, an obstacle and a cliff appearing mid-move, an injected tool timeout and tool error and motor failure, an abrupt disconnect clearing the entry, and a reconnect that rediscovers. See robot-simulator.md |
The action vocabulary, behaviour engine, personality, robot memory and safety policy described in robot-architecture.md and robot-roadmap.md are Planned — there is no code and no test for them. The simulator is not: see robot-simulator.md. What the robot tests do cover is described in robot-domain.md.
tests/e2e/ is the newest of the three levels and the only one that starts at "a person
said something" and ends at "the device moved". It runs the real WebSocket server in
process, the real session handler, the real device MCP channel and the real simulator over
a real socket; what it replaces is exactly four things, each at a seam the production code
already has:
| Faked | How | What stays real |
|---|---|---|
| ASR | the simulator sends the recognizer's output as a listen/detect frame |
everything the server does with the text |
| TTS | tests/e2e/fakes.py:FakeTTS at the conn.tts seam |
the speech arbiter, the sink, the audio state machine, the tts frames the device sees |
| LLM | tests/e2e/fakes.py:RuleBasedLLM, the same protocol every inherited provider satisfies |
the agent, its tools, its permissions, its context |
| vision | robot/vision/providers.py:ColourBlobDetector, which reads the pixels the simulator's camera rendered |
capture over device MCP, decoding, tracking, the world fold |
Ten scenarios, one per file section, each recording every state transition the system published while it ran:
| Scenario | What it proves |
|---|---|
| A — startup | connect, authenticate, MCP, discovery, registration, telemetry, world state |
| B — conversation | utterance in, answer out, one speech path, the face follows |
| C — voice-commanded movement | "come a little closer" → model → safety → executor → device → completion → reply |
| D — autonomous greeting | a person appears, vision sees them, greet_person wins once, the cooldown holds |
| E — boredom | idle time on a controlled clock, bored wins, and it claims the display only |
| F — cliff protection | the device stops itself, the action fails with its reason, and the model cannot override it |
| G — low battery | go_to_charger outranks the sociable behaviours on priority |
| H — interruption | barge-in: speaking → interrupted → listening, and the next utterance is answered |
| I — backend/LLM failure | with no model: connects, reports, obeys safety, runs deterministic behaviours |
| J — reconnect | the link dies mid-move, the device stops itself, and the robot comes back with fresh capabilities |
Two more files sit beside them: tests/e2e/test_robustness.py (connect/disconnect churn,
leaked tasks, clean shutdown, malformed frames, duplicate responses, timeouts, stale
telemetry, cancellation races) and tests/e2e/test_observability.py (metrics, correlation
ids and the trace).
The run writes main/nilo-server/tmp/e2e-report.md — every transition of all ten
scenarios, with the correlation id — which CI keeps as an artifact. See
observability.md.
The suite is deliberately runnable against requirements-dev.txt alone, so a contributor working
on config, protocol or utility code never has to install torch, funasr or modelscope.
| Slice | Install | Result |
|---|---|---|
| Dev only | pip install -r requirements-dev.txt |
1283 passed + 11 skipped — the tests needing the full runtime skip themselves |
| Full | pip install -r requirements.txt -r requirements-dev.txt |
1429 passed |
requirements-dev.txt pins the tooling (pytest, pytest-asyncio, freezegun, ruff, mypy,
and types-PyYAML because mypy is strict over robot/ and the scenario loader reads YAML)
plus the minimal runtime slice the tests import: PyYAML, httpx, cnlunar, pydantic,
loguru.
Three files guard themselves with pytest.importorskip at module level. When the import is
missing, the whole module is reported as one skip and contributes no collected tests:
| File | Guarded on |
|---|---|
tests/core/test_http_routes.py |
aiohttp, opuslib_next, numpy, pydub |
tests/core/test_ws_path_gate.py |
websockets, opuslib_next, numpy |
tests/plugins_func/test_loadplugins.py |
opuslib_next, numpy |
tests/integration/conftest.py |
websockets, opuslib_next, numpy — the guard sits in the conftest, so the whole directory skips as one |
tests/e2e/conftest.py |
the same three, for the same reason |
robot/simulator/ imports websockets lazily, inside the session, so
tests/robot/test_simulator.py runs on the dev slice even though the simulator cannot connect
there.
tests/test_imports.py skips at a finer grain: each parametrised module that raises
ModuleNotFoundError is skipped individually with the missing dependency named, so a module that
needs a package outside the dev slice (core.websocket_server without websockets, for example)
does not fail the run.
main/nilo-server/tests/conftest.py runs before any test imports project code and does exactly
two things:
- Inserts the
main/nilo-serverdirectory at the front ofsys.path, so the implicit top-level imports the inherited code uses (from core.utils.textUtils import ...) resolve regardless of where pytest was started. os.environ.setdefault("NILO_CONFIG", <tests/fixtures/test_config.yaml>).
tests/fixtures/test_config.yaml is a committed file containing {} — an empty override. This
matters because several modules call setup_logging() at import time, which loads the
configuration; without the fixture the suite would read (and depend on) the developer's own
data/.config.yaml. Because it is setdefault, an explicit NILO_CONFIG in your environment
still wins. Individual tests that need a different file set it with
monkeypatch.setenv("NILO_CONFIG", ...) and clear the config cache entry first — see
tests/config/test_nilo_config.py:test_load_config_applies_alias_and_env.
NILO_CONFIG is one of the five environment variables the server understands; the rest are
described in configuration.md.
cd main/nilo-server
ruff check . # All checks passed!
mypy # Success: no issues found in 4 source filesFrom the repository root: make lint and make typecheck run the same two commands, and
make check-docs runs the docs/branding validator that CI's lint job runs as its last step.
Both tools are configured in files separate from the vendored pyproject.toml, so upstream syncs
do not conflict with them (upstream.md):
| Tool | Config | Scope |
|---|---|---|
| Ruff | main/nilo-server/.ruff.toml |
Whole server, py312, line length 120, excluding data, tmp, models, libs, .venv. The rule set is deliberately bug-only (E9, F63, F7, F82, F811, PLE, B002, B011, B018) so it passes on ~30k lines of inherited Python and can gate CI today |
| mypy | main/nilo-server/mypy.ini |
files = robot — strict (disallow_untyped_defs, disallow_any_generics, warn_return_any, …) for Nilo-owned code only. core.*, config.*, plugins.* and plugins_func.* are ignore_errors + follow_imports = skip, because the inherited tree is untyped |
Bare mypy with no arguments is correct — the files key in mypy.ini supplies the target. A
stricter robot/.ruff.toml for new code is planned with the first non-protocol robot code
(robot-roadmap.md); it does not exist yet.
scripts/smoke_check.py (repository root) is not part of the pytest suite: it exercises a
running server over the network, using only the standard library plus websockets.
python scripts/smoke_check.py --host 127.0.0.1 --ws-port 8000 --http-port 8003or, from the repository root, make smoke (which reads HOST, WS_PORT and HTTP_PORT from the
environment and defaults to the same values).
For the one protocol — nilo (/nilo/v1/, /nilo/ota/) — it performs three checks with
device-id: 00:11:22:33:44:55 and client-id: smoke-check headers:
GET {ota_path}returns200with aws://orwss://URL in the body.POST {ota_path}with{}returns200and a body containing"websocket".- A WebSocket connection to
{ws_path}completes the handshake, sends ahelloframe (Opus, 16 kHz, mono, 60 ms) and gets ahelloframe back.
It then probes the two retired routes (named in migration.md) and fails unless
they are gone: the retired OTA route must answer 404 and the retired WebSocket handshake must be
rejected. Finally it probes /not-a-protocol/ and reports what happened, which tells you whether
protocols.strict is still on.
| Flag | Default | Meaning |
|---|---|---|
--host |
127.0.0.1 |
Server host |
--ws-port |
8000 |
WebSocket port (server.port) |
--http-port |
8003 |
HTTP port (server.http_port) |
--expect-disabled |
empty | Comma-separated protocol names (only nilo exists today) that must be rejected. For a listed protocol, the check passes only when the OTA route returns 404 and the WebSocket handshake is rejected |
Output against a stock server (the retired paths are elided here; the script prints them in full):
[PASS] nilo GET /nilo/ota/ -> 200; POST -> 200; ws /nilo/v1/ -> ok
[PASS] retired <ws path> ws -> rejected (404)
[PASS] retired <ota path> GET -> 404
[info] unknown ws path -> rejected (404) (rejected unless protocols.strict is false)
Exit code is 0 when every route matched expectations — the nilo routes answering and the retired
routes gone — and 1 otherwise, so it works as a deployment gate. Route semantics are described in
protocol.md.
make compose-validate # cd main/nilo-server && docker compose -f docker-compose.yml config --quiet
# -> docker-compose.yml OK
make docker-build # builds ghcr.io/aa-box/nilo-server:base then :latest from the root contextmake compose-validate only parses and resolves the Compose file; it needs the docker CLI but
never starts a container. make docker-build builds Dockerfile-server-base (python:3.12-slim +
libopus0 + ffmpeg + requirements.txt) and then Dockerfile-server (application code on top),
and does need a working Docker daemon. tests/test_compose.py asserts the content of the Compose
file with PyYAML and needs neither. Deployment itself is covered in
deployment.md.
Three workflows live in .github/workflows/.
Triggers on pushes to main and develop and on every pull request, with in-progress runs for the
same ref cancelled. Every job runs on ubuntu-latest with working-directory: main/nilo-server;
the four Python jobs pin Python 3.12 (the docker job needs no interpreter).
| Job | Installs | Runs |
|---|---|---|
lint — "Lint and type-check" |
requirements-dev.txt |
ruff check ., mypy, then python scripts/check_docs.py (that step runs from the repository root) |
test — "Python 3.12, full dependencies" |
requirements.txt then requirements-dev.txt (pip cache keyed on requirements*.txt) |
pytest -q |
test-dev-slice — "Python 3.12, dev dependencies only" |
requirements-dev.txt (pip cache keyed on requirements-dev.txt) |
pytest -q — the heavy tests skip themselves |
e2e — "End-to-end scenarios" |
requirements.txt then requirements-dev.txt, plus libopus0 and ffmpeg |
pytest tests/e2e -q, and uploads tmp/e2e-report.md as an artifact even when the job fails |
docker — "Docker Compose validates" |
nothing | docker compose -f docker-compose.yml config --quiet |
The test / test-dev-slice pair is what keeps the importorskip guards honest: a test that
quietly grew a torch dependency fails the dev-slice job. The e2e job runs the same
scenarios a second time on purpose — they are already inside pytest -q — so that the
state-transition report is produced and kept even when the rest of the suite is red.
flowchart LR
A["push to main touching<br/>requirements.txt, Dockerfile-server-base,<br/>or build-base-image.yml"] --> B["Build Base Image<br/>→ ghcr.io/aa-box/nilo-server:base"]
B -- "workflow_run, on success" --> C["Release Docker Image<br/>→ :latest"]
D["push tag v*.*.*"] --> C
E["workflow_dispatch"] --> B
E --> C
C --> F["ghcr.io/aa-box/nilo-server:X.Y.Z + :latest"]
build-base-image.yml("Build Base Image") — pushes tomainthat touchmain/nilo-server/requirements.txt,Dockerfile-server-baseor the workflow itself, plus manualworkflow_dispatch. BuildsDockerfile-server-baseforlinux/amd64,linux/arm64and pushesghcr.io/aa-box/nilo-server:base, using a GitHub Actions cache scoped toserver-base.docker-image.yml("Release Docker Image") —v*.*.*tags,workflow_dispatch, or aworkflow_runcompletion of "Build Base Image" (the job is skipped unless that run succeeded). BuildsDockerfile-serverwithBASE_IMAGE=ghcr.io/aa-box/nilo-server:basefor both architectures. On a version tag it pushes:X.Y.Zand:latest; otherwise:latestonly.
Neither image workflow runs any test.
GitHub Actions is enabled and the Tests workflow runs on every pull request. Run
ruff check ., mypy, pytest -q and make compose-validate locally first anyway, but note
two things a local run will not reproduce:
- The
lintjob installsrequirements-dev.txtonly. A development machine usually has the full requirements, somypythere resolves imports that CI cannot.robot/simulator/importswebsocketsfrom the full requirements, which is whymypy.inicarries anignore_missing_importsentry for it. Reproduce the lint environment with a second virtualenv that has the dev slice alone. - CI runners are slower and more contended. A test that waits on an event has to subscribe
before the thing that emits it starts, not merely before the assertion — on an accelerated
simulator clock, a scenario step two simulated seconds in fires while the handshake is still
finishing.
tests/integration/test_simulator_e2e.py:recordingexists for exactly that.
- No hardware, anywhere, by design. Every robot and action test runs against the
simulator (
robot/simulator/) or a scriptable fake. A test that needs a real robot is not a test, it is a bring-up procedure. This is also why "the backend stops the robot" is never asserted: the suite can prove a stop was dispatched and that the simulator obeyed it, and nothing more. The guarantee is the firmware watchdog, which no test in this repository can exercise. See safety-model.md. - The backend safety layer is tested; firmware safety is not.
tests/robot/test_safety.pyproves what the policy refuses. It cannot prove a robot stops at a cliff — only that the server declined to ask it to move. - No end-to-end audio test.
tests/e2e/runs the whole turn — utterance, model, tools, action, device, answer — but nothing in the suite streams Opus or exercises a real VAD, recognizer or synthesizer. The four fakes are listed above, and each one is a place the real thing could behave differently.scripts/smoke_check.pygets as far as thehelloexchange. See audio.md. - No provider integration tests. No test imports a vendor adapter under
core/providers/:tests/test_imports.pynever reaches them (no__init__.py, sowalk_packagesskips the directory), and only the provider base classes come along transitively whencore.connectionis imported. Nothing calls a vendor API. See architecture.md. - The device MCP transport is now exercised, by
tests/integration/test_simulator_e2e.py: the handshake, pagedtools/listandtools/callall run over a real WebSocket. What is still untested is server-side MCP (remote endpoints, server MCP servers) and tool registration beyond the registry level (tests/plugins/test_register.py). See mcp.md. performance_tester/is not a test suite.main/nilo-server/performance_tester/holds six standalone benchmark scripts —performance_tester_asr.py,_stream_asr.py,_llm.py,_tts.py,_stream_tts.py,_vllm.py. They load real credentials through the normal config path, must be run withmain/nilo-serveras the working directory, and are excluded from pytest bytestpaths = ["tests"]. They measure providers; they assert nothing.
- Put it under the directory mirroring the module you are testing (
tests/robot/…forrobot/…). Every test directory has an__init__.py; add one for a new directory. A test that opens a socket or starts a server goes undertests/integration/, nottests/robot/— that package is socket-free by contract, which is what keeps it fast. - Do not add a
conftest.pyto setsys.pathorNILO_CONFIG— the roottests/conftest.pyalready does both. - If the test needs anything outside
requirements-dev.txt(aiohttp,numpy,opuslib_next,pydub,websockets,torch, …), open the module withpytest.importorskip("<package>")and a# noqa: E402on the imports that follow, so the dev-slice CI job stays green. - If the test touches the config cache, delete the entry before and after:
cache_manager.delete(CacheType.CONFIG, "main_config"). - New code under
robot/must be fully annotated —mypyis strict there.
Conventions for the code itself are in development.md.