diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index a0986c2..79b01b4 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -40,7 +40,7 @@ jobs: - name: Run mutation baseline (floor from docs/mutation-baseline.md) env: # Keep in sync with docs/mutation-baseline.md "CI floor". - MUTATION_SCORE_FLOOR: "40" + MUTATION_SCORE_FLOOR: "48" FORCE_CLEAN: "1" CI: "true" run: ./scripts/run_mutation_baseline.sh diff --git a/docs/api.md b/docs/api.md index dd6aa5c..d6cfedb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -121,7 +121,7 @@ table omits one. Payloads are validated against the published files, mostly from real `--sim` invocations rather than hand-written fixtures. Contract tests: `tests/contracts/test_schema_validation.py` and -`tests/test_json_contracts.py`. +`tests/test_json_contract_*.py`. ## Command payloads diff --git a/docs/job-hero.mp4 b/docs/job-hero.mp4 new file mode 100644 index 0000000..74ddfa2 Binary files /dev/null and b/docs/job-hero.mp4 differ diff --git a/docs/mutation-baseline.md b/docs/mutation-baseline.md index be961ad..50a1ca5 100644 --- a/docs/mutation-baseline.md +++ b/docs/mutation-baseline.md @@ -60,36 +60,69 @@ Focused tests (also listed in `[tool.mutmut].pytest_add_cli_args_test_selection` | Total mutants | 1480 | | Killed | 610 | | Survived | 870 | -| Timeout / suspicious / no_tests | 0 | | **Score** | **610 / 1480 ≈ 41.2%** | -Per-module (approx. killed / accounted on a clean run): +### Measured 2026-08-04 (current) — same scope, re-run on a clean tree -| Module | Score | Note | -|--------|------:|------| -| `job/payload.py` | ~65–69% | AMS + payload generation well tested | -| `slicer/options.py` | ~60–63% | Bounds + property tests | -| `netsafety.py` | ~58% | Private-IP refusal strong; cache/handler cosmetics survive | -| `download/naming.py` | ~55–56% | Injection + sanitize properties; CD/header edges survive | -| `job/predict.py` | ~33% | Dry-run heuristics under-specified; many equivalent branches | -| `download/validation.py` | ~30–31% | Message/emit paths + normalize edge strings | -| `slicer/output.py` | ~21% baseline; **not yet re-measured** since the C.4 hermetic Orca stub landed | **Was low:** `_finalize_slice` I/O/logging mutated with little unit signal. `tests/test_slice_stub_integration.py` (roadmap C.4) now drives its benign-GL / empty / corrupt / missing-output / real-error branches through the real slicer subprocess (line coverage 79.8%→~93%), so a re-run of `mutmut` on this module should score materially higher — update this row after the next mutation run. | - -**Honest reading:** the overall score **dropped vs Phase 1** because scope **widened** into harder modules (especially `output._finalize_slice` and `predict`/`validation` emit paths). That is intentional. Do **not** restore a high score by shrinking back to only well-covered files. +| Metric | Count | +|--------|------:| +| Total mutants | 2091 | +| Killed | 1061 | +| Survived | 1027 | +| Timeout | 3 | +| **Score** | **1061 / 2091 = 50.7%** | + +Per-module, derived from the mutant sources and survivor list (these reconcile +to the 50.7% total, so they are measured rather than estimated): + +| Module | Total | Survived | Killed | Score | vs 2026-07-09 | +|--------|------:|---------:|-------:|------:|--------------:| +| `job/payload.py` | 180 | 55 | 125 | **69.4%** | ~65–69% → flat | +| `netsafety.py` | 153 | 50 | 103 | **67.3%** | ~58% → **+9** | +| `slicer/options.py` | 433 | 155 | 278 | **64.2%** | ~60–63% → +2 | +| `download/naming.py` | 201 | 77 | 124 | **61.7%** | ~55–56% → **+6** | +| `job/predict.py` | 379 | 193 | 186 | **49.1%** | ~33% → **+16** | +| `download/validation.py` | 320 | 191 | 129 | **40.3%** | ~30–31% → **+10** | +| `slicer/output.py` | 344 | 269 | 75 | **21.8%** | ~21% → **+0.8** | + +**The C.4 prediction did not pan out — correcting it here.** The previous +revision of this file said the hermetic Orca stub "should score materially +higher" for `slicer/output.py` and asked for the row to be updated after a +re-run. Re-run: **21.8%**, essentially unchanged. Line coverage rose (79.8% → +92.7%) while the mutation score did not, which is the textbook signal that the +new tests *execute* `_finalize_slice` without *constraining* it — they assert +the command succeeds, not what it wrote. `slicer/output.py` now holds 269 of the +1027 survivors (26%), the single largest pocket. + +The honest options for that module are (a) extract the pure decision logic out +of `_finalize_slice` so it can be asserted directly, or (b) accept it and stop +counting it. Adding more end-to-end tests will not move it. Not attempted here: +it is a production refactor, not a test change. + +`download/validation.py`'s +10 comes from `tests/test_download_validation_boundary.py`, +which covers the `_reject_*` functions that previously had no direct tests. ## CI floor | Setting | Value | |---------|------:| -| `MUTATION_SCORE_FLOOR` | **40** | +| `MUTATION_SCORE_FLOOR` | **48** | | Formula | `100 * killed / (killed + survived + timeout + suspicious + no_tests)` | -| Rationale | Just under the honest Phase 3 score (~41.2%), same discipline as coverage `fail_under` — catch real regressions without flaking on one equivalent mutant | +| Rationale | Just under the measured 2026-08-04 score (50.7%), same discipline as coverage `fail_under` — catch real regressions without flaking on one equivalent mutant. Raised from 40, which was set against the older 41.2% and had ~10 points of silent-drift room. | + +> **Fixed 2026-08-04:** the floor was previously assigned in +> `run_mutation_baseline.sh` **without `export`**, so the score check — which runs +> in a child python process — never saw it and fell back to its own hardcoded +> default. A local run printed `floor: 40%` in its header and then enforced a +> different number a few lines later. CI was unaffected only because the workflow +> sets the variable at job level. There is now one value, exported, and the child +> errors out rather than inventing a default. Enforced by `./scripts/run_mutation_baseline.sh` after `mutmut export-cicd-stats`. Nightly / manual workflow fails if the score falls below the floor. ## Surviving mutants (accepted / deferred) -Categories (not an exhaustive dump of 861 IDs): +Categories (not an exhaustive dump of the 1027 survivors): 1. **Equivalent / cosmetic** — error-message string literals, log format, `getattr` default when tests always set the attribute, `ZipFile(..., "r")` vs default mode. 2. **`_finalize_slice` (output.py)** — subprocess exit interpretation, JSON emit, path display. **Addressed (C.4):** `tests/fakes/orca_stub` + `tests/test_slice_stub_integration.py` now run these branches against a real fake-slicer subprocess. Residual survivors here should be cosmetic (log strings / path display); re-measure before treating any as "accepted". @@ -112,7 +145,7 @@ Safety gates that **do** kill well under the widened suite: # from repo root uv pip install '.[test]' # mutmut + hypothesis FORCE_CLEAN=1 ./scripts/run_mutation_baseline.sh -# optional: MUTATION_SCORE_FLOOR=40 (default in script / CI) +# optional: MUTATION_SCORE_FLOOR=48 (default in script / CI) ``` Artifacts (`mutants/`, `.mutmut-cache`, `.hypothesis/`) are gitignored. diff --git a/docs/plans/post-audit-gameplan.md b/docs/plans/post-audit-gameplan.md new file mode 100644 index 0000000..76a5783 --- /dev/null +++ b/docs/plans/post-audit-gameplan.md @@ -0,0 +1,380 @@ +# Gameplan: post-audit hardening + `feat/tui` merge prep + +**Audience:** coding agents (Claude / Grok / etc.) executing work in this repo. +**Source:** 2026-07-31 deep read-only audit (parent session + 3× explore agents on Grok 4.5). +**Branch at audit:** `feat/tui` @ `0d63378` (12 commits ahead of `main`). +**Measured suite (Linux, that session):** `1314 passed`, `1 deselected` (live), **88.6%** branch coverage. +**Version:** `0.5.0.dev0` — pre-1.0 Beta. + +> **Revision 2026-07-31 (cross-check pass).** A second independent audit re-verified this plan's premises and found the plan **missing a confirmed merge blocker** plus eight other findings; the new items are folded into the residual table and into **WS-B** below. S1/S2/S3 were re-verified in code and hold — **S2 is a genuine catch this plan surfaced that the other audit missed**. **Q2's diagnosis was wrong and is corrected below.** Post-fix suite is `1321 passed`, **88.58%**. + +This is an **execution plan**, not a re-audit. Prefer implementation + green gates over more research. + +--- + +## 0. Non-negotiable constraints (read first) + +Copy these into every work session: + +1. **Architecture / printer safety:** [AGENTS.md](../../AGENTS.md), [SECURITY.md](../../SECURITY.md). +2. **Quality truth sources:** [docs/quality-roadmap.md](../quality-roadmap.md), [docs/test-backlog.md](../test-backlog.md). Prefer these over older prose. +3. **Never** run printer commands with `--confirm`, or `BAMBU_LIVE=1` / `BAMBU_LIVE_PRINT_CONFIRM`, without explicit human approval. +4. **`sys.exit` only in `bambu_cli/cli.py`.** Domain raises `BambuError` / `abort`. CI greps this. +5. **No `@mockable`**, no `isinstance(..., Mock)` / test-awareness branches in production. +6. **Do not** hand-maintain package / py_compile / help-command lists (setuptools + smokes auto-discover). +7. **Do not** add Claude-Session or similar trailers to commits/PRs. +8. **LOCAL-ONLY:** `CLAUDE.md` is gitignored via `.git/info/exclude` — never commit it. +9. **TUI is human-only:** no machine contract; never try to drive `tui`/`go` via `--json`. +10. **Confirm choke point:** under `bambu_cli/tui/`, `confirm=True` must appear only in `screens/confirm.py` (the Start print path). Preserve this invariant; add a CI grep if you touch confirm. +11. **No raw `str` in Rich sinks.** Any printer- or user-supplied value reaching a Rich `Table` cell, `Select`/`OptionList` prompt, or a `Static` without `markup=False` must be wrapped in `rich.text.Text` — a `str` is markup-parsed, silently eating `[...]` and raising `MarkupError` on `[/...]`. Filenames routinely contain brackets. See WS-B. + +### Canonical gates (run before claiming done) + +```bash +uvx ruff check bambu_cli +uvx ruff format --check bambu_cli +uvx mypy -p bambu_cli +uvx bandit -c pyproject.toml -r bambu_cli -ll +uv run python -W error::ResourceWarning -m pytest tests/ -m "not live" \ + --cov=bambu_cli --cov-report=term-missing --cov-fail-under=83 +# After any ci.yml or CLI subcommand / floor change: +uv run python tests/ci_workflow_smoke.py +python scripts/syntax_smoke.py +python scripts/cli_help_smoke.py +# After packaging / package-data (e.g. tcss) changes: +uv build && uv run python tests/package_contents_smoke.py +``` + +A green pytest alone is **not** evidence ruff/mypy/bandit passed. + +### What is already done (do not re-litigate) + +- TLS pin single-sourced in `bambu_cli/tlspin.py` (B.5); mqtt/ftps/camera call it. +- SSRF layer + proxy disable + redirect hop cap; `allow_private_ips` CLI-only. +- ZIP extract: basename, skip symlink mode, size cap, noncolliding paths. +- Error model: domain `abort` / `BambuError`; entry-only `sys.exit`. +- Full-package mypy + `check_untyped_defs`. +- TUI phases 1–5 on `feat/tui` (dashboard → prepare → confirm → monitor → advanced settings). +- Shared wizard/TUI core in `interactive/core.py`. +- Camera: pin mismatch + pin+`ssl.SSLError` fail closed; `camera_direct_only` choke point; loopback default bind. +- Deep-audit fix wave already landed (history: `#93`–`#96` family commits). + +### Honest residual (from audit) — this plan targets these + +| ID | Residual | Severity | +|----|----------|----------| +| S1 | Default camera path can fall through to **unpinned** Docker streamer even when pin is set (empty/failed direct grab); only `camera_direct_only` closes it | High residual | +| S2 | `insecure_tls` + pin: MQTT/camera ignore pin; FTPS still verifies pin if present | Medium | +| S3 | Streamer JPEG `resp.read()` unbounded | Medium (local DoS) | +| S4 | HTTP downloads accepted (SSRF yes, integrity no) | Documented residual | +| A1 | Domain → `cli.build_parser` for job namespaces (`interactive/core.py`, `presets.py`) | Architecture debt | +| A2 | Process globals for JSON emit state (`utils._JSON_*`) | Architecture debt | +| A3 | `protocols/mqtt.py` ~885 LOC hotspot | Maintainability | +| Q1 | CI floor **83** vs measured **~88.6%** (~5pt silent-drift room); Windows binding leg ~88.09% | Quality | +| Q2 | ~~Docs mention 1307/1308 with 1 fail~~ — **CORRECTED: there is no phantom failing test.** `docs/test-backlog.md:13` and `quality-roadmap.md:19,64` say "1308 collected / 1307 passing"; the 1-test delta **is the deselected live test**, which is correct accounting, not a failure. The real issue is only that the numbers are **stale** (1307 → 1321, 88.53% → 88.58%). Refresh the numbers; do not "fix" a failure that never existed | Docs staleness (low) | +| Q3 | Roadmap Architecture **A** / AGENTS “no architecture debt” slightly overstated → **A−** fair | Docs honesty | +| T1 | Textual pinned `>=0.86,<2.0` (8.x breaks dashboard pilots) | Dep risk | +| T2 | Confirm modal keyboard ergonomics / quit-during-prepare thinner than job-in-flight | UX polish | +| R1 | Untracked `docs/job-hero.mp4` (2.3M); marketing media hygiene. **Note: it is untracked AND unignored** — `git check-ignore` covers every other stray artifact but not this one, so `git add .` commits it | Repo hygiene | +| **B1** | **MERGE BLOCKER — Rich markup injection in TUI table cells.** `status_panel.py:30`, `confirm.py:249`, `ams_panel.py:37`, `settings.py:306` passed raw `str` into Rich sinks, which markup-parse it. Repro: `model [remix].stl` rendered as `model .stl`; `a[/b]c.gcode` raised `MarkupError`. `download/naming.py` strips `<>:"/\|?*` but **not** brackets, so ordinary Printables names reach it. Worst case: the confirm modal — the only screen starting a physical action — displays a different filename than the one that prints | **Blocker — FIXED, in working tree, uncommitted** | +| S5 | `utils._redact_url_credentials` (`utils.py:62`) guards **every** string in emitted JSON via `_compact_all_strings`, but is the weaker of the two redactors and its body (`utils.py:70-82`) has **0% test coverage** — no test executes it. Repro: `bob:secret123@192.168.1.5` passes through unredacted; the stronger `jsonio` version strips it. `utils.py` is also outside the mutation scope | High | +| P1 | **Temperature bound fails open.** `--set-filament nozzle_temperature=400abc` → `_effective_override_temps` returns `([], [])` → validation passes, where plain `400` correctly errors. `_numeric_values` (`options.py:224-228`) `continue`s on unparseable values instead of rejecting. `options.py` *is* in the mutation scope — mutation testing mutates existing code and structurally cannot find a **missing** guard | High (printer safety) | +| Q4 | **The MQTT layer has never run against real paho.** 81 `sys.modules.setdefault("paho…")` calls across 33 test files; zero tests import the real library. Pin is `>=2.0,<3.0` — a binding change inside that range ships green. Same failure class as the textual 8.x break | High | +| Q5 | Two safety tests cannot fail as intended: `test_cmd_print_dry_run_success` (`test_doctor_and_safety.py:194`) never asserts the print publish did **not** happen; `cmd_stop` asserts only `assert_called_once()` (`test_printer_commands.py:277`) with no payload check, unlike its `cmd_pause`/`cmd_light` siblings | High | +| Q6 | ~13 contract tests build payloads **by hand** and validate them against the schema (`tests/contracts/test_schema_validation.py:302` et al) — no `bambu_cli` code runs, so emitter and schema can drift together. The file's own docstring at `:414` names the correct pattern | Medium | +| A4 | `mqtt_port` is a dead setting that `doctor` **lies about**: 3 references (`context.py:94,141`, `doctor.py:142`), never in a connect — `mqtt.py:167` hard-codes 8883. Doctor prints the configured port while connecting elsewhere | Medium | +| R2 | `privacy_smoke` is a crying-wolf gate: exit 1 locally on correctly-gitignored files (it walks the filesystem without consulting git), and on CI runners the account-name patterns resolve to the filtered generic `runner`, so its two best checks never build. Red locally, disarmed remotely | Medium | +| R3 | `CLAUDE.md:16` prints `--cov-fail-under=81`; CI is **83** (`ci.yml:77`, `CONTRIBUTING.md:25`, `AGENTS.md:104`). An agent following CLAUDE.md runs a weaker gate than CI | Medium | + +--- + +## 1. Outcome goals + +After this gameplan: + +1. **`feat/tui` is merge-ready** with honest docs, green multi-OS CI, and no phantom “1 failing test”. +2. **Security residuals S1–S3** are either fixed or explicitly deferred with tests + SECURITY.md updates (no silent status). +3. **Coverage floor ratcheted to 85** (data supports it; 88 is too tight for Windows). +4. **Optional stretch:** architecture A1 (parser decoupling) if time; not a merge blocker. + +**Not goals of this plan:** `v1.0.0`, 92% coverage, Textual 8.x support, live printer lab, dropping Python 3.9. + +--- + +## 2. Workstreams (ordered) + +Execute **WS-B → WS0 → WS1 → WS2** in order. WS3 is optional post-merge or parallel only if WS0–WS2 green. WS4 is polish. + +--- + +### WS-B — Merge blocker (do before anything else) — **DONE, awaiting review** + +**Goal:** `feat/tui` must not merge while the TUI can mis-render or crash on a printer-supplied filename. + +| Task | Detail | Status | +|------|--------|--------| +| WB.1 | Wrap Rich cell/prompt values in `rich.text.Text` at `tui/widgets/status_panel.py:30`, `tui/screens/confirm.py:249`, `tui/widgets/ams_panel.py:37`, `tui/screens/settings.py:306` | ✅ done (uncommitted) | +| WB.2 | Regression tests asserting bracketed values render verbatim and markup-shaped values do not raise, in `test_tui_dashboard.py` / `test_tui_confirm.py` / `test_tui_settings.py` | ✅ done — red-before-green verified by reverting each fix individually | +| WB.3 | Audit **every** markup sink under `bambu_cli/tui/` (`add_row`, `Static` without `markup=False`, `.update`, OptionList/Select prompts) and record safe/unsafe per site | ✅ done — this is what turned up the 4th site (`settings.py:306`) | + +> **Why this workstream exists.** Commit `36a3d20` fixed this exact bug class in the OptionList prompts and claimed a full visual pass — but only patched the site where it was discovered. Four more sinks survived. **Rule: when an escaping bug surfaces, enumerate every sink of that kind before declaring the fix complete.** `grep -rn "add_row" bambu_cli/tui/` finds them all in one command. Text assertions cannot see markup — this class is invisible to the existing test style, which is why WB.2's tests assert against **rendered** output via a real `Console`. + +**Gates:** all five canonical gates green post-fix — `1321 passed`, 88.58%. + +--- + +### WS0 — Truth, hygiene, merge baseline + +**Goal:** Docs and branch state match reality; no mystery failures; media not accidental. + +| Task | Detail | Acceptance | +|------|--------|------------| +| W0.1 | Re-measure suite on current tip; record exact pass/fail | `pytest -m "not live"` green; paste final line into PR/notes | +| W0.2 | Refresh [quality-roadmap.md](../quality-roadmap.md) scoreboard: tests **1314+** all green (or current N), coverage measured, drop “1307/1 fail” wording; Architecture grade **A−** unless A1 lands | Docs consistency tests still pass | +| W0.3 | Refresh [test-backlog.md](../test-backlog.md) snapshot to match | Same | +| W0.4 | Soften [AGENTS.md](../../AGENTS.md) “no remaining architecture debt” to name residual seams (domain→`build_parser`, utils JSON globals, mqtt size) **or** leave and open follow-up issue — prefer one honest sentence | No false “zero debt” claim | +| W0.5 | `docs/job-hero.mp4`: either gitignore large raw mp4s, commit a intentional small asset, or delete — do **not** leave multi‑MB untracked with possible LAN leakage from tapes | `git status` clean of surprise binaries; tape warnings stay | +| W0.6 | Open/update PR `feat/tui` → `main` description from CHANGELOG Unreleased + this plan’s merge checklist | Human can review | + +**Do not change behavior in WS0 except media/docs.** + +**Gates:** ruff/mypy/bandit + pytest (floor still 83 until W1.1) + `test_docs_consistency` if it greps floors/numbers. + +--- + +### WS1 — Coverage floor ratchet (83 → 85) + +**Goal:** Deny ~5 points of silent coverage rot. Roadmap already says 85 is supported; Windows ~88.09% is the binding leg. + +| Task | Detail | Acceptance | +|------|--------|------------| +| W1.1 | Bump `--cov-fail-under` **83 → 85** in `.github/workflows/ci.yml` | CI config changed | +| W1.2 | Update every enforced citation together: `docs/quality-roadmap.md`, `docs/test-backlog.md`, and any test that greps the floor (`tests/test_docs_consistency.py`, `tests/ci_workflow_smoke.py`) | Local `ci_workflow_smoke` + docs consistency green | +| W1.3 | Do **not** jump to 88 in this PR — Windows margin is ~0.09pt and will flake | Floor is 85 | + +**Gates:** full non-live pytest with `--cov-fail-under=85` + `ci_workflow_smoke.py`. + +**If something fails the new floor:** fix coverage with real tests on residual paths (prefer mqtt/ftps/netsafety/camera decision branches), not `# pragma: no cover` on pure helpers. + +--- + +### WS2 — Security product fixes (S1–S3) + +**Goal:** Close the highest-value residuals without breaking X1 users who need the streamer. + +#### W2.1 — Pin implies no unpinned streamer (S1) — **preferred default** + +**Current:** pin mismatch / pin+SSLError abort; empty direct grab still falls through unless `camera_direct_only`. + +> ⚠️ **Product decision, not a bug fix — do not let an agent land this unasked.** S1 is a *documented, accepted* residual whose planned mitigation (`camera_direct_only`) already shipped. Changing the default means **X1-series users lose snapshots on upgrade**, since those printers require the streamer and have no port 6000. That is a breaking change in a minor release. The lower-risk alternative, which this plan should consider before flipping any default: **keep the fallback but emit a loud human + JSON warning** when a pin is set and the unpinned streamer is used — closing the silent part of the residual without breaking anyone. Get a human product call before implementing either. + +**Target behavior (recommended):** + +- When `cert_fingerprint` is set **and** `insecure_tls` is false: **do not** fall back to Docker streamer (same as `camera_direct_only` for that case), **unless** an explicit opt-in is set. +- Opt-in name (pick one; document in SECURITY.md + config/setup preserve-unknown-keys path): + - `camera_allow_streamer: true` (new, default false), **or** + - keep `camera_direct_only` but **default it true when pin is present** (more surprising for X1). +- Prefer **new key `camera_allow_streamer` default false** when pin is set: clearest semantics + “pin = verified direct only; streamer requires explicit allow”. +- X1 users without port 6000: set `camera_allow_streamer: true` (and understand streamer is unpinned), or leave pin unset + accept residual (document). + +**Tests (required):** + +- Pin set, direct returns no frame → abort, **no** streamer call (mock streamer/docker). +- Pin set + `camera_allow_streamer=true` → streamer allowed (existing path). +- No pin, not `camera_direct_only` → streamer still allowed (X1 / legacy). +- Pin mismatch still hard-aborts (regression). +- Pin + SSLError still hard-aborts (regression). + +**Docs:** SECURITY.md known-limitations table; AGENTS camera paragraph; config help if any. + +#### W2.2 — Unify pin + `insecure_tls` policy (S2) + +**Current mismatch — re-verified in code 2026-07-31, this is real and is the strongest finding in this plan:** + +- MQTT `protocols/mqtt.py:110-113` — `if printer.insecure_tls: tls_set(CERT_NONE)` / `elif printer.cert_fingerprint:` → **insecure_tls wins, pin silently skipped.** +- Camera `camera.py:206` — `if not printer.insecure_tls and printer.cert_fingerprint:` → **insecure_tls wins, pin silently skipped.** +- FTPS `ftps.py:103-116` — `if pin or insecure_tls: CERT_NONE` then `if pin: verify_cert_fingerprint(...)` → **pin wins, still verified.** + +Three transports, two opposite policies. A user who pins and later sets `insecure_tls` to debug something keeps pin verification on FTPS while silently losing it on MQTT and camera. FTPS has the safe behavior; **Policy A generalizes FTPS's rule to the other two**, which is the right direction. + +**Target (pick and implement one policy — recommend A):** + +| Policy | Rule | +|--------|------| +| **A (recommended)** | If pin present → always verify pin; `insecure_tls` only affects CA/hostname when pin absent. Warn if both set. | +| B | If `insecure_tls` → refuse to also set pin (config error / doctor). | + +**Tests:** mqtt + ftps + camera fixtures for pin+`insecure_tls` combinations; doctor/preflight message if policy B. + +#### W2.3 — Cap streamer body size (S3) + +- Bound `resp.read()` (or chunked read) for streamer JPEG to a sane max (align with direct-grab frame sanity / ~12MB-class limit already used elsewhere if present). +- On oversize: structured error, no partial write as success. +- Test with oversized fake streamer body. + +#### W2.4 — Optional low-effort (same PR or follow-up) + +- Human + JSON **warn** on `http://` downloads (S4) without breaking HTTP (residual stays but visible). +- Do **not** flip default to HTTPS-only without product decision. + +**Gates:** existing camera suites + new cases; bandit still green; SECURITY honesty table updated (move fixed rows to Fixed). + +--- + +### WS3 — Architecture polish (optional, not merge-blocking) + +Do **after** WS0–WS2 green, or as a separate PR on main. + +| Task | Detail | Acceptance | +|------|--------|------------| +| W3.1 | Extract job-namespace construction so `interactive` does not import `bambu_cli.cli.build_parser` | `rg "from bambu_cli.cli import build_parser" bambu_cli` empty (or only cli tests); wizard/TUI job args unchanged (contract tests) | +| W3.2 | Move `_JSON_EMITTED` / last-error payload state toward `RuntimeContext` (finish dual-write migration in `job/support.py`) | No behavior change; JSON envelope order tests still pass | +| W3.3 | Split `protocols/mqtt.py` (status wait / print execute / monitor / pin helpers) **behavior-preserving** | Same public functions; mqtt tests green | +| W3.4 | CI grep: `confirm=True` under `bambu_cli/tui` only in `screens/confirm.py` | Lint job fails if second site appears | + +--- + +### WS4 — TUI polish (optional) + +| Task | Detail | +|------|--------| +| W4.1 | Confirm modal: keyboard mnemonics or documented Tab order; pilot if bindings added | +| W4.2 | Soft quit guard or status text while **prepare** worker running (not only job) | +| W4.3 | Spike Textual ≥2 / 8.x pilot fixes on a branch — **do not** raise pin until green | + +--- + +## 3. Suggested PR slice order + +Keep PRs reviewable and gate-safe: + +| PR | Title (suggested) | Contains | Blocks merge of | +|----|-------------------|----------|-----------------| +| **PR-A** | `docs: post-audit truth + coverage floor 85` | WS0 docs/hygiene + WS1 floor | nothing hard | +| **PR-B** | `fix: camera pin no streamer fallback by default` | W2.1 + tests + SECURITY | — | +| **PR-C** | `fix: unify pin/insecure_tls + streamer size cap` | W2.2 + W2.3 | — | +| **PR-D** | `feat: plate tui` (or final polish on `feat/tui`) | Existing TUI branch + any leftover WS0; merge to main when CI green | — | +| **PR-E** (later) | architecture: job namespace + mqtt split | WS3 | not required for TUI | + +**Merge order recommendation:** +Finish **PR-D (`feat/tui`)** with WS0 honesty + green CI first if product wants TUI out. +Security WS2 can land on `main` before or after TUI; **prefer before or with** if you want security A closer to A+. +Floor ratchet (PR-A) is safe anytime measured legs stay ≥88 — i.e. the ratchet target is **85**, chosen to leave ~3pt of headroom above the binding Windows leg (88.09%). Do not read "≥88" as the floor. + +Alternatively: one combined PR on `feat/tui` with WS0+WS1+WS2 if the human wants a single land — only if the diff stays reviewable. + +--- + +## 4. `feat/tui` merge checklist (for the human + agent) + +Before merging to `main`: + +- [ ] **WS-B markup fix committed** — no raw `str` in any Rich sink under `bambu_cli/tui/`; `grep -rn "add_row" bambu_cli/tui/` shows `Text(...)` at every site +- [ ] **Human smoke with a bracketed filename** — prepare a file literally named `model [remix].stl` and confirm the dashboard + confirm modal show the name in full +- [ ] `git status` clean of accidental multi‑MB media (incl. the untracked-AND-unignored `docs/job-hero.mp4`) +- [ ] CHANGELOG Unreleased accurately describes TUI +- [ ] `uvx ruff check/format`, `mypy`, `bandit` green +- [ ] `pytest -m "not live"` green with ResourceWarning as error +- [ ] Coverage floor (83 or 85 after W1) holds on **Windows** CI leg +- [ ] `uv build` + `package_contents_smoke` — `*.tcss` in wheel +- [ ] `ci_workflow_smoke` if parser/CI touched +- [ ] Multi-OS GitHub Actions green on tip (not only an older SHA like `cc6f78c`) +- [ ] Human smoke: `plate tui --sim` on a real TTY (not agent-driven) +- [ ] No Claude-Session trailers; no force-push of shared history without ask +- [ ] Version remains `.dev0` until release tag process ([docs/releasing.md](../releasing.md)) + +**Do not auto-merge.** Human review required (plan precedent). + +--- + +## 5. Implementation notes for agents + +### Camera change (W2.1) — where to look + +- Fallthrough choke: `bambu_cli/camera.py` after direct grab fails/empty, before streamer. +- Settings: `bambu_cli/context.py` (`camera_direct_only`, add allow-streamer if chosen). +- Config load/preserve unknown keys: setup already preserves unmanaged keys — keep that. +- Tests: `tests/test_camera_cmd.py` (large; follow existing pin/fallback patterns). + +### Policy unification (W2.2) + +- `bambu_cli/protocols/mqtt.py` `create_mqtt_client` +- `bambu_cli/protocols/ftps.py` TLS setup +- `bambu_cli/camera.py` direct grab pin branch +- Prefer **one helper** or one documented order: pin first, then insecure_tls CA skip. + +### Floor ratchet (W1) + +- `tests/test_docs_consistency.py` and `tests/ci_workflow_smoke.py` **enforce** floor citations stay in sync — update all in one commit. + +### TUI + +- Shared logic lives in `interactive/core.py` — **do not** reimplement AMS/job rules in screens. +- `confirm=True` only in `tui/screens/confirm.py`. +- Optional extra: `platecli[tui]`; import-guard in `tui/entry.py`. + +### Commit style + +- Conventional, complete sentences in body when non-obvious. +- No AI trailers. +- Prefer small commits matching PR slices. + +--- + +## 6. Out of scope / refuse + +- Live printer work without human OK. +- Raising floor to 88 or 92 in this plan. +- Making TUI an agent surface (`--json`). +- Enabling `insecure_tls` by default or weakening pin. +- Worktrees from `$HOME` (user rule: never; use project cwd). +- “Fixing” accepted residuals (Windows ACLs, TOFU on hostile LAN, access code = full control) without product design. + +--- + +## 7. Success criteria (plan complete) + +| Criterion | Evidence | +|-----------|----------| +| **TUI markup blocker closed** | Every Rich sink under `bambu_cli/tui/` uses `Text`; regression tests assert rendered output, verified red-before-green | +| Docs honest | Test counts current (staleness only — there was never a failing test); Architecture A− or debt fixed | +| Floor 85 | CI + docs + smokes agree | +| Camera pin story | Default path with pin set does not use unpinned streamer without opt-in; tests prove it | +| Pin/insecure_tls | One policy across transports; tests prove it | +| Streamer size cap | Oversized body fails closed | +| TUI | Merged or PR approved with multi-OS green + human `--sim` smoke | +| Gates | ruff, format, mypy, bandit, pytest+ResourceWarning all green | + +--- + +## 8. Pasteable brief for a new Claude session + +```text +You are working in /home/dylanr/Projects/platecli on branch feat/tui (or main if TUI already merged). + +Execute docs/plans/post-audit-gameplan.md. + +Priority order: WS-B (TUI markup blocker — already fixed in the working tree, verify + commit) → WS0 (docs/truth) → WS1 (cov floor 83→85) → WS2 (unify pin+insecure_tls; streamer size cap; camera streamer default is a HUMAN product call, do not flip it unasked). WS3/WS4 optional. + +Also open and unassigned: S5 (utils redactor weaker + 0% covered), P1 (temperature override fails open on non-numeric values), Q4 (paho never exercised against the real library), Q5 (dry-run and cmd_stop tests cannot fail as intended). See the residual table. + +Constraints: AGENTS.md + SECURITY.md; no --confirm / BAMBU_LIVE without asking; sys.exit only in cli.py; no @mockable; no Claude-Session trailers; do not commit CLAUDE.md. + +Verify with ruff + ruff format + mypy + bandit + pytest -m "not live" (ResourceWarning error) at the stated cov floor. After ci.yml changes, run tests/ci_workflow_smoke.py. + +Prefer small PRs as in §3 of the gameplan. Do not invent live printer tests. Keep tui confirm=True single-site invariant. +``` + +--- + +## 9. Audit evidence pointers (for implementers) + +- Security agent findings: camera fallthrough `camera.py:431+`; MQTT insecure_tls `mqtt.py:110+`; FTPS pin despite insecure `ftps.py:104+`; streamer `read()` ~`camera.py:580`. +- Architecture: `interactive/core.py` / `presets.py` → `build_parser`; `utils.py` JSON globals; `mqtt.py` size. +- TUI: single confirm `tui/screens/confirm.py:113`; entry guards `tui/entry.py:41–57`. +- Prior full suite measurement: 1314 passed, 88.6% branch (Linux, 2026-07-31 session). Re-measure before claiming numbers. + +--- + +*End of gameplan. Update this file’s “done” checkboxes in a follow-up commit only if you want living status; otherwise tick progress in the PR body.* diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md index ab992a6..899728b 100644 --- a/docs/quality-roadmap.md +++ b/docs/quality-roadmap.md @@ -61,7 +61,7 @@ security is not yet **A+**. | Correctness / bugs | **A** | dead flags fixed (global `--json` before subcommand); structured errors; purity greps; version single-sourced | | Typing | **A** | `uvx mypy -p bambu_cli` full package with `check_untyped_defs = true`; no residual excludes | | Error model | **A** | `sys.exit` only in `cli.py` (errors.py hits are docstrings); domain uses `abort` / `BambuError` | -| Tests | **A−** | **1374** non-live tests collected / **1373** passing (2026-08-05; the Textual TUI phases 1-5 plus the structural refactor wave: layer-boundary enforcement, the Printables adapter's malformed-payload containment sweep, and round-trip tests proving each generated schema matches what its contract emits); **89.0%** coverage measured 2026-08-05 on Linux; CI floor **83**; per-module floors not enforced | +| Tests | **A−** | **1406** non-live tests collected / **1405** passing (2026-08-05; the Textual TUI phases 1-5 plus the structural refactor wave: layer-boundary enforcement, the Printables adapter's malformed-payload containment sweep, and round-trip tests proving each generated schema matches what its contract emits); **89.1%** coverage measured 2026-08-05 on Linux; CI floor **83**; per-module floors not enforced | | CI / release | **A−** | single pytest path; purity greps; bandit/audit/mypy blocking; **`--cov-fail-under=83`** (A+ target remains 92) | | Docs / governance | **A−** | roadmap + backlog + SECURITY + AGENTS aligned (2026-07-24); prior AGENTS mypy-blocklist / backlog ≥98% claims corrected | | Product polish | **B+** | quality gates in place; still pre-1.0 Beta (version is single-sourced from `pyproject.toml`); coverage ratchet + camera defaults remain for 1.0 A+ | @@ -169,7 +169,7 @@ Every new or touched command path must satisfy: │ ┌─────────────▼─────────────┐ │ contract tests │ schema + full payload per command - │ (test_json_contracts + │ + │ (test_json_contract_* + │ │ schemas/*.json) │ └─────────────┬─────────────┘ │ diff --git a/docs/test-backlog.md b/docs/test-backlog.md index 2b844f8..2a4bfea 100644 --- a/docs/test-backlog.md +++ b/docs/test-backlog.md @@ -10,8 +10,8 @@ Do not treat historical “≥98% coverage” claims as current — see the snap | Metric | Current (honest) | A+ / 1.0 target | |--------|------------------|-----------------| -| Non-live tests collected | **1374** collected / **1373** passing (measured 2026-08-05 on Linux; the Textual TUI phases 1-5 plus the structural refactor wave: layer-boundary enforcement, Printables-adapter containment, and generated-schema contract tests) | ≥550 with zero known flakes ✅ size | -| Line/branch coverage (CI) | **89.0%** Linux measured 2026-08-05; **88.09%** Windows measured 2026-07-31 (not re-measured since); **floor 83** (Windows is the binding leg) | **≥92%** total; optional module floors | +| Non-live tests collected | **1406** collected / **1405** passing (measured 2026-08-05 on Linux; the Textual TUI phases 1-5 plus the structural refactor wave: layer-boundary enforcement, Printables-adapter containment, and generated-schema contract tests) | ≥550 with zero known flakes ✅ size | +| Line/branch coverage (CI) | **89.1%** Linux measured 2026-08-05; **88.09%** Windows measured 2026-07-31 (not re-measured since); **floor 83** (Windows is the binding leg) | **≥92%** total; optional module floors | | Typing | Full package mypy + `check_untyped_defs` | keep; optional full `strict` later | | Error model | `sys.exit` only in `cli.py` | keep | | `@mockable` / test-awareness | **0** (CI greps) | keep | @@ -41,7 +41,7 @@ Tracked in [SECURITY.md](../SECURITY.md) known limitations: | Gap | Notes | |-----|-------| | Camera Docker bind default | **Done.** Defaults to `127.0.0.1:…` publish; `camera_port` → stream URL parsing fixed; bind-parse tests in place | -| Camera pin soft-fallback | **Done.** Aborts on pin mismatch and on `ssl.SSLError` from the handshake when a pin is configured; no Docker fallthrough in either case; regression tests in `tests/test_camera_cmd.py` | +| Camera pin soft-fallback | **Done.** Aborts on pin mismatch and on `ssl.SSLError` from the handshake when a pin is configured; no Docker fallthrough in either case; regression tests in `tests/test_camera_capture.py` / `tests/test_cmd_snapshot.py` | | Single TLS pin helper | **Done.** One `verify_cert_fingerprint` (`bambu_cli/tlspin.py`, constant-time compare) used by mqtt/ftps/camera; direct unit suite in `tests/test_tlspin.py` + per-transport fail-closed tests | ### P1 — Coverage ratchet & transport residual @@ -64,7 +64,7 @@ Tracked in [SECURITY.md](../SECURITY.md) known limitations: | Gap | Notes | |-----|-------| -| Giant unittest-style modules | e.g. `test_printer_commands.py`, `test_download_cmd.py` — split by family over time | +| Giant unittest-style modules | **Done (2026-08-04).** `test_printer_commands.py` (1333), `test_json_contracts.py` (1031) and `test_camera_cmd.py` (927) split by command surface; largest remaining is `test_job.py` (1246) | | `tests/fakes/` package | Shared TLS/FTP/MQTT fakes (roadmap A.3) | | Mutation survivors | Honest ~30–33% on some `predict` / `validation` emit paths; cosmetic/equivalent accepted | | Phase E | Weekly fuzz (ZIP/URL), SBOM, Dependabot, optional scheduled live lab | diff --git a/pyproject.toml b/pyproject.toml index ddbd889..c767755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,6 +179,7 @@ only_mutate = [ # Focused suite for mutation speed (not the full 500+ suite). pytest_add_cli_args_test_selection = [ "tests/test_naming_and_validation.py", + "tests/test_download_validation_boundary.py", "tests/test_properties_safety.py", "tests/test_slicer_pure.py", "tests/test_job.py", diff --git a/scripts/run_mutation_baseline.sh b/scripts/run_mutation_baseline.sh index 71d53ec..771eb3a 100755 --- a/scripts/run_mutation_baseline.sh +++ b/scripts/run_mutation_baseline.sh @@ -12,7 +12,12 @@ cd "$ROOT" # Documented in docs/mutation-baseline.md; keep CI and docs in sync. # Score = 100 * killed / max(1, killed + survived + timeout + suspicious + no_tests) # (mutmut "skipped"/equivalent rows are omitted from the denominator when absent). -MUTATION_SCORE_FLOOR="${MUTATION_SCORE_FLOOR:-40}" +# +# MUST be exported: the score check below runs in a child python process, which +# only inherits exported vars. Without this the child fell back to its own +# hardcoded default, so a local run printed "floor: 40%" in this header and then +# enforced a different number a few lines later. +export MUTATION_SCORE_FLOOR="${MUTATION_SCORE_FLOOR:-48}" if [[ -x .venv/bin/mutmut ]]; then MUTMUT=(.venv/bin/mutmut) @@ -50,7 +55,11 @@ import os import sys from pathlib import Path -floor = float(os.environ.get("MUTATION_SCORE_FLOOR", "48")) +floor_raw = os.environ.get("MUTATION_SCORE_FLOOR") +if not floor_raw: + print("ERROR: MUTATION_SCORE_FLOOR not set — the caller must export it", file=sys.stderr) + sys.exit(2) +floor = float(floor_raw) stats_path = Path("mutants/mutmut-cicd-stats.json") if not stats_path.is_file(): print("ERROR: mutants/mutmut-cicd-stats.json missing after mutmut run", file=sys.stderr) diff --git a/scripts/syntax_smoke.py b/scripts/syntax_smoke.py index ba74142..8ddde45 100755 --- a/scripts/syntax_smoke.py +++ b/scripts/syntax_smoke.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Compile every runtime module under bambu_cli/ (auto-discovered). +"""Compile every Python file in the repo that ships or runs in CI. -Also compiles the legacy scripts/bambu.py entry and a fixed set of CI smoke -modules under tests/. Replaces the hand-maintained py_compile file list in -ci.yml — adding a package module no longer requires editing the workflow. +All of bambu_cli/, scripts/ and tests/ are auto-discovered. The tests/ list used +to be a hand-curated sample, which went stale the moment a test module was +renamed or split — the same failure mode CLAUDE.md warns about for package and +help-command inventories. Nothing here is hand-maintained now. """ from __future__ import annotations @@ -14,29 +15,15 @@ ROOT = Path(__file__).resolve().parents[1] -# Extra non-package paths still syntax-checked in CI (not auto-discoverable -# as package modules, but required for release/agent smoke). -EXTRA_PATHS = ( - "scripts/bambu.py", - "scripts/__init__.py", - "tests/bambu_test_base.py", - "tests/agent_cli_smoke.py", - "tests/ci_workflow_smoke.py", - "tests/dependency_resolution_smoke.py", - "tests/live_printer_smoke.py", - "tests/package_contents_smoke.py", - "tests/privacy_smoke.py", - "tests/python_compat_smoke.py", - "tests/release_readiness_smoke.py", - "tests/test_config_and_logging.py", - "tests/test_protocol_clients.py", - "tests/test_cli_entry.py", - "tests/test_printer_commands.py", - "tests/test_slice_cmd.py", - "tests/test_download_cmd.py", - "tests/test_camera_cmd.py", - "tests/test_doctor_and_safety.py", -) + +def _discover(*dirs: str) -> list[Path]: + """Every .py under the given top-level directories, sorted.""" + out: list[Path] = [] + for name in dirs: + base = ROOT / name + if base.is_dir(): + out += [p for p in base.rglob("*.py") if "__pycache__" not in p.parts] + return sorted(out) def package_modules() -> list[Path]: @@ -46,11 +33,7 @@ def package_modules() -> list[Path]: def all_targets() -> list[Path]: - targets = package_modules() - for rel in EXTRA_PATHS: - path = ROOT / rel - if path.is_file(): - targets.append(path) + targets = package_modules() + _discover("scripts", "tests") # de-dupe while preserving order seen: set[Path] = set() unique: list[Path] = [] diff --git a/tests/contracts/test_schema_validation.py b/tests/contracts/test_schema_validation.py index ed41d86..23d1d91 100644 --- a/tests/contracts/test_schema_validation.py +++ b/tests/contracts/test_schema_validation.py @@ -493,7 +493,7 @@ def test_resume_confirmation_fixture_matches_schema(): def test_snapshot_success_fixture_matches_schema(): """Hand-written fixture: snapshot requires injecting a real grab_frame + camera - TLS stack; the hermetic seam exists (tests/test_camera_cmd.py:855) but is not + TLS stack; the hermetic seam exists (tests/test_snapshot_output.py) but is not imported here to keep the contract suite's dependency footprint minimal. The fixture guards schema shape; the camera cmd test guards the real emitter. """ diff --git a/tests/json_contract_base.py b/tests/json_contract_base.py new file mode 100644 index 0000000..6e00001 --- /dev/null +++ b/tests/json_contract_base.py @@ -0,0 +1,146 @@ +"""Shared harness for the `--json` contract regression tests. + +Extracted from the former 1031-line test_json_contracts.py so the per-command +contract modules share one copy of the shape checker and the main() driver +rather than duplicating them. + +These are SHAPE-locking regression tests, not spec tests: where docs/api.md +disagrees with actual CLI output we assert the actual output and flag the +discrepancy, so an accidental shape change is caught here. + +Ground rules (docs/test-backlog.md): never touch a real printer/network -- use +`--sim` and a scratch config path; drive the real argv/parser path through +`bambu_cli.cli.main()`. +""" + +import argparse +import json +import sys +import zipfile +from unittest.mock import MagicMock + +import pytest + +# paho-mqtt is an optional/heavy dep; stub it the same way other tests do so +# importing the package never fails on environments without it installed. +_mock_mqtt = MagicMock() +sys.modules.setdefault("paho", _mock_mqtt) +sys.modules.setdefault("paho.mqtt", _mock_mqtt) +sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) + +from bambu_cli import bambu # noqa: E402 +from bambu_cli import utils # noqa: E402 +from bambu_cli.cli import build_parser, main # noqa: E402 + + +# --------------------------------------------------------------------------- +# assert_shape: a small, self-contained schema-shape checker (no jsonschema +# dependency available/allowed). +# --------------------------------------------------------------------------- + + +def assert_shape(payload, spec, path="$"): + """Validate `payload` against a small hand-rolled spec. + + spec keys: + - "type": a type or tuple of types the value must be an instance of. + - "required": {key: subspec, ...} keys that MUST be present. + - "optional": {key: subspec, ...} keys that MAY be present; validated + only if present. + - "enum": iterable of allowed values for this exact node. + - "items": subspec applied to every element when type is list. + """ + assert isinstance(payload, dict) or "type" in spec or True, path + + if "type" in spec: + expected_type = spec["type"] + assert isinstance(payload, expected_type), ( + f"{path}: expected type {expected_type}, got {type(payload).__name__} ({payload!r})" + ) + + if "enum" in spec: + assert payload in spec["enum"], f"{path}: {payload!r} not in allowed enum {spec['enum']!r}" + + if isinstance(payload, dict): + required = spec.get("required", {}) + for key, subspec in required.items(): + assert key in payload, f"{path}: missing required key {key!r} in {sorted(payload.keys())}" + assert_shape(payload[key], subspec, path=f"{path}.{key}") + optional = spec.get("optional", {}) + for key, subspec in optional.items(): + if key in payload: + assert_shape(payload[key], subspec, path=f"{path}.{key}") + + if isinstance(payload, list) and "items" in spec: + for idx, item in enumerate(payload): + assert_shape(item, spec["items"], path=f"{path}[{idx}]") + + +ANY = {} +STR = {"type": str} +BOOL = {"type": bool} +INT = {"type": int} +NUM = {"type": (int, float)} +DICT = {"type": dict} +LIST = {"type": list} + +BASE_OK = {"type": dict, "required": {"status": {"enum": ["ok"]}, "command": STR}} + + +def base_error_spec(command=None, require_failed_step=True): + required = { + "status": {"enum": ["error"]}, + "command": {"enum": [command]} if command else STR, + "exit_code": INT, + "error": STR, + } + if require_failed_step: + required["failed_step"] = STR + return {"type": dict, "required": required} + + + +# --------------------------------------------------------------------------- +# Harness +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_json_state(): + utils._JSON_EMITTED = False + utils._LAST_ERROR_PAYLOAD = None + utils._LAST_DOWNLOAD_PAYLOAD = None + yield + utils._JSON_EMITTED = False + utils._LAST_ERROR_PAYLOAD = None + utils._LAST_DOWNLOAD_PAYLOAD = None + + +def run_main(monkeypatch, tmp_path, argv, config_path=None): + """Drive bambu_cli.cli.main() with a scratch config path so no real + on-disk config is ever touched, and return the SystemExit (or None).""" + import bambu_cli.cli as cli_mod + import bambu_cli.config as config_mod + + monkeypatch.setattr(sys, "argv", ["plate"] + list(argv)) + monkeypatch.setattr(config_mod, "CONFIG_PATH", config_path or str(tmp_path / "no-such-config" / "config.json")) + monkeypatch.setattr(cli_mod, "setup_logging", lambda *a, **k: None) + exc = None + try: + main() + except SystemExit as e: + exc = e + return exc + + +def read_json(capsys): + out = capsys.readouterr().out + return json.loads(out) + + +def make_ready_file(tmp_path, name="ready.3mf", content="simulated 3mf content"): + path = tmp_path / name + path.write_text(content, encoding="utf-8") + return path + + diff --git a/tests/package_contents_smoke.py b/tests/package_contents_smoke.py index 78fa0fd..048383b 100644 --- a/tests/package_contents_smoke.py +++ b/tests/package_contents_smoke.py @@ -47,10 +47,11 @@ "tests/test_config_and_logging.py", "tests/test_protocol_clients.py", "tests/test_cli_entry.py", - "tests/test_printer_commands.py", + "tests/test_cmd_status.py", + "tests/test_cmd_print.py", "tests/test_slice_cmd.py", "tests/test_download_cmd.py", - "tests/test_camera_cmd.py", + "tests/test_camera_capture.py", "tests/test_doctor_and_safety.py", "tests/contracts/test_schema_validation.py", } diff --git a/tests/release_readiness_smoke.py b/tests/release_readiness_smoke.py index fee4d9e..81c7c32 100644 --- a/tests/release_readiness_smoke.py +++ b/tests/release_readiness_smoke.py @@ -30,10 +30,11 @@ "tests/test_config_and_logging.py", "tests/test_protocol_clients.py", "tests/test_cli_entry.py", - "tests/test_printer_commands.py", + "tests/test_cmd_status.py", + "tests/test_cmd_print.py", "tests/test_slice_cmd.py", "tests/test_download_cmd.py", - "tests/test_camera_cmd.py", + "tests/test_camera_capture.py", "tests/test_doctor_and_safety.py", } diff --git a/tests/test_camera_capture.py b/tests/test_camera_capture.py new file mode 100644 index 0000000..7bde198 --- /dev/null +++ b/tests/test_camera_capture.py @@ -0,0 +1,187 @@ +"""Direct port-6000 TLS camera grab: port validation, pin enforcement, fail-closed paths. + +Split out of the former 927-line test_camera_cmd.py.""" + +import hashlib + +from tests.bambu_test_base import * # noqa: F401,F403 +from bambu_cli.errors import BambuError + +class TestCameraPortIsValid(unittest.TestCase): + def test_rejects_out_of_range_container_port(self): + """A container port above 65535 must be rejected: \\d{1,5} alone lets + '99999' match the regex even though it is not a valid port number.""" + from bambu_cli.protocols.camera import _camera_port_is_valid + + self.assertFalse(_camera_port_is_valid("1985:99999")) + self.assertFalse(_camera_port_is_valid("0")) + self.assertFalse(_camera_port_is_valid("70000-70005")) + + def test_accepts_valid_container_ports(self): + from bambu_cli.protocols.camera import _camera_port_is_valid + + self.assertTrue(_camera_port_is_valid("127.0.0.1:1985:1984")) + self.assertTrue(_camera_port_is_valid("1984")) + self.assertTrue(_camera_port_is_valid("1984/tcp")) + self.assertTrue(_camera_port_is_valid("1984-1989/udp")) + +class TestGrabCameraFrameDirect(unittest.TestCase): + def _mock_net(self): + mock_sock = MagicMock() + mock_tls = MagicMock() + mock_ctx = MagicMock() + mock_ctx.wrap_socket.return_value = mock_tls + mock_tls.recv.side_effect = [ + # first recv: size header (16 bytes) + (4).to_bytes(4, "little") + b"\x00" * 12, + # second recv: 4 bytes data representing valid JPEG + b"\xff\xd8\xff\xd9", + ] + create_connection = MagicMock(return_value=mock_sock) + ssl_context_factory = MagicMock(return_value=mock_ctx) + return create_connection, ssl_context_factory, mock_sock, mock_tls, mock_ctx + + def test_grab_camera_frame_direct_no_pin_fails_closed(self): + """Without a pinned fingerprint (and insecure_tls unset) the camera + connection must fail closed before the access code is sent.""" + import ssl as ssl_mod + from bambu_cli.protocols.camera import _grab_camera_frame_direct + + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + printer = _test_printer(ip="192.168.1.100", access_code="my_secret_code") + + with self.assertRaises(ssl_mod.SSLError): + _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + mock_tls.sendall.assert_not_called() + + def test_grab_camera_frame_direct_insecure(self): + from bambu_cli.protocols.camera import _grab_camera_frame_direct + + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + printer = _test_printer(ip="192.168.1.100", access_code="my_secret_code", insecure_tls=True) + + res = _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + self.assertEqual(res, b"\xff\xd8\xff\xd9") + + create_connection.assert_called_once_with(("192.168.1.100", 6000), timeout=12) + mock_ctx.wrap_socket.assert_called_once_with(mock_sock, server_hostname="192.168.1.100") + mock_tls.sendall.assert_called_once() + mock_tls.getpeercert.assert_not_called() + # wrap_socket detaches the fd into the SSLSocket, so the wrapped object + # (not the bare socket) must be closed or the fd leaks. + mock_tls.close.assert_called_once() + + def test_grab_camera_frame_direct_with_pin(self): + from bambu_cli.protocols.camera import _grab_camera_frame_direct + + der = b"der_cert" + good_fp = hashlib.sha256(der).hexdigest() + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + mock_tls.getpeercert.return_value = der + printer = _test_printer(ip="192.168.1.100", access_code="my_secret_code", cert_fingerprint=good_fp) + + res = _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + self.assertEqual(res, b"\xff\xd8\xff\xd9") + + mock_tls.getpeercert.assert_called_once_with(binary_form=True) + + def test_grab_camera_frame_direct_pin_mismatch(self): + from bambu_cli.protocols.camera import _CameraPinMismatch, _grab_camera_frame_direct + + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + mock_tls.getpeercert.return_value = b"der_cert" + # Pin the SHA-256 of a *different* cert so the real compare fails. + printer = _test_printer( + ip="192.168.1.100", + access_code="my_secret_code", + cert_fingerprint=hashlib.sha256(b"a_different_cert").hexdigest(), + ) + + # A mismatching pin raises a dedicated security error (not a generic + # SSLError) so the snapshot command can fail closed instead of falling + # back to the Docker streamer, which would ignore the pin. + with self.assertRaises(_CameraPinMismatch): + _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + mock_tls.sendall.assert_not_called() + + def test_grab_camera_frame_direct_pin_no_peer_cert(self): + """A pin configured but no peer cert must fail closed (missing cert is not + a Docker-fallback signal). Regression: the old code called .lower() on a + None fingerprint and crashed with AttributeError instead of a clean pin + failure that the snapshot command recognizes as fail-closed.""" + from bambu_cli.protocols.camera import _CameraPinMismatch, _grab_camera_frame_direct + + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + mock_tls.getpeercert.return_value = None + printer = _test_printer( + ip="192.168.1.100", + access_code="my_secret_code", + cert_fingerprint=hashlib.sha256(b"der_cert").hexdigest(), + ) + + with self.assertRaises(_CameraPinMismatch): + _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + mock_tls.sendall.assert_not_called() + + def test_grab_camera_frame_direct_malformed_nonascii_pin(self): + """A malformed/non-ASCII pin must raise _CameraPinMismatch (fail closed), + NOT a raw TypeError from hmac.compare_digest that would escape into the + broad except-Exception fallback and silently use the unpinned Docker + streamer.""" + from bambu_cli.protocols.camera import _CameraPinMismatch, _grab_camera_frame_direct + + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + mock_tls.getpeercert.return_value = b"der_cert" + # 64 chars but with a Cyrillic 'а' — survives normalize, non-ASCII. + printer = _test_printer( + ip="192.168.1.100", + access_code="my_secret_code", + cert_fingerprint="а" + "b" * 63, + ) + + with self.assertRaises(_CameraPinMismatch): + _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + mock_tls.sendall.assert_not_called() + + def test_grab_camera_frame_direct_oversized_header_aborts(self): + """An implausibly large frame length means the stream is desynced; the + grab must give up (return None) instead of reading the skipped body as + the next frame header for the rest of the loop.""" + from bambu_cli.protocols.camera import _grab_camera_frame_direct + + create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() + mock_tls.recv.side_effect = [(99_000_000).to_bytes(4, "little") + b"\x00" * 12] + printer = _test_printer(ip="192.168.1.100", access_code="c", insecure_tls=True) + + res = _grab_camera_frame_direct( + printer, + create_connection=create_connection, + ssl_context_factory=ssl_factory, + ) + self.assertIsNone(res) + # Only the one bogus header was read — no attempt to drain/parse a body. + self.assertEqual(mock_tls.recv.call_count, 1) diff --git a/tests/test_cmd_device.py b/tests/test_cmd_device.py new file mode 100644 index 0000000..dda24d8 --- /dev/null +++ b/tests/test_cmd_device.py @@ -0,0 +1,116 @@ +"""Device state commands: light, pause, resume, stop -- including the --confirm gate.""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +class TestBambuCmdLight(unittest.TestCase): + @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_light_on(self, mock_logger, mock_send_command, mock_seq): + args = MagicMock() + args.action = "on" + + cmd_light(args) + + # Expected payload + expected_payload = json.dumps( + { + "system": { + "sequence_id": "0", + "command": "ledctrl", + "led_node": "chamber_light", + "led_mode": "on", + "led_on_time": 500, + "led_off_time": 500, + } + } + ) + + mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) + mock_logger.info.assert_called_once_with("💡 Light turned on") + + @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_light_off(self, mock_logger, mock_send_command, mock_seq): + args = MagicMock() + args.action = "off" + + cmd_light(args) + + # Expected payload + expected_payload = json.dumps( + { + "system": { + "sequence_id": "0", + "command": "ledctrl", + "led_node": "chamber_light", + "led_mode": "off", + "led_on_time": 500, + "led_off_time": 500, + } + } + ) + + mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) + mock_logger.info.assert_called_once_with("💡 Light turned off") + +class TestBambuCmdResume(unittest.TestCase): + @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_resume(self, mock_logger, mock_send_command, mock_seq): + from bambu_cli.commands import cmd_resume + + args = MagicMock() + + cmd_resume(args) + + expected_payload = json.dumps({"print": {"sequence_id": "0", "command": "resume"}}) + mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) + mock_logger.info.assert_called_once_with("▶️ Print resumed") + +class TestBambuCmdPause(unittest.TestCase): + @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_pause(self, mock_logger, mock_send_command, mock_seq): + from bambu_cli.commands import cmd_pause + + args = MagicMock() + + cmd_pause(args) + + expected_payload = json.dumps({"print": {"sequence_id": "0", "command": "pause"}}) + mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) + mock_logger.info.assert_called_once_with("⏸️ Print paused") + +class TestBambuCmdStop(unittest.TestCase): + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_stop_without_confirm(self, mock_logger, mock_send_command): + # Create a mock args object with confirm=False + args = MagicMock() + args.confirm = False + + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_stop(args) + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 5) + + # Assert that send_command was NOT called + mock_send_command.assert_not_called() + + # Assert that the correct message was logged + mock_logger.warning.assert_called_once_with("⚠️ This will STOP the current print. Add --confirm to proceed.") + + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_stop_with_confirm(self, mock_logger, mock_send_command): + # Create a mock args object with confirm=True + args = MagicMock() + args.confirm = True + + cmd_stop(args) + + # Assert that send_command WAS called + mock_send_command.assert_called_once() diff --git a/tests/test_cmd_download_file.py b/tests/test_cmd_download_file.py new file mode 100644 index 0000000..8681193 --- /dev/null +++ b/tests/test_cmd_download_file.py @@ -0,0 +1,96 @@ +"""Printer-side file download (FTPS retrieve), distinct from the URL downloader.""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +class TestBambuDownloadFile(unittest.TestCase): + """download_file streams to a temp sibling then atomically replaces, so a + failed transfer never corrupts an existing file at local_path.""" + + def _printer_with_ftp(self, mock_ftp): + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + return printer + + def test_download_file_success_writes_content_no_temp_left(self): + import tempfile + + d = tempfile.mkdtemp() + local = os.path.join(d, "out.gcode") + content = b"new content" + mock_ftp = MagicMock() + mock_ftp.retrbinary.side_effect = lambda cmd, cb, blocksize=None: cb(content) + mock_ftp.size.return_value = len(content) + + ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) + + self.assertTrue(ok) + with open(local, "rb") as f: + self.assertEqual(f.read(), content) + self.assertEqual([p for p in os.listdir(d) if p.endswith(".part")], []) + + def test_download_file_failure_preserves_existing_and_cleans_temp(self): + import ftplib + import tempfile + + d = tempfile.mkdtemp() + local = os.path.join(d, "out.gcode") + with open(local, "wb") as f: + f.write(b"original good file") + + mock_ftp = MagicMock() + mock_ftp.retrbinary.side_effect = ftplib.error_temp("connection dropped") + + ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) + + self.assertFalse(ok) + with open(local, "rb") as f: + self.assertEqual(f.read(), b"original good file") # untouched + self.assertEqual([p for p in os.listdir(d) if p.endswith(".part")], []) + + def test_download_file_truncated_vs_remote_size_fails_without_replace(self): + """A short RETR must not replace local_path when remote SIZE is larger. + + Bambu FTPS skips TLS close-notify on the data channel, so a dropped + transfer can still return from retrbinary; size verification is required. + """ + import tempfile + + d = tempfile.mkdtemp() + local = os.path.join(d, "out.gcode") + with open(local, "wb") as f: + f.write(b"original good file") + + mock_ftp = MagicMock() + # RETR writes only 4 bytes, but SIZE claims 100. + mock_ftp.retrbinary.side_effect = lambda cmd, cb, blocksize=None: cb(b"trunc") + mock_ftp.size.return_value = 100 + + ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) + + self.assertFalse(ok) + with open(local, "rb") as f: + self.assertEqual(f.read(), b"original good file") # not replaced + self.assertEqual([p for p in os.listdir(d) if p.endswith(".part")], []) + + def test_download_file_size_match_succeeds(self): + import tempfile + + d = tempfile.mkdtemp() + local = os.path.join(d, "out.gcode") + content = b"full content here" + + mock_ftp = MagicMock() + mock_ftp.retrbinary.side_effect = lambda cmd, cb, blocksize=None: cb(content) + mock_ftp.size.return_value = len(content) + + ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) + + self.assertTrue(ok) + with open(local, "rb") as f: + self.assertEqual(f.read(), content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cmd_files.py b/tests/test_cmd_files.py new file mode 100644 index 0000000..ddd6387 --- /dev/null +++ b/tests/test_cmd_files.py @@ -0,0 +1,164 @@ +"""Remote file commands: listing and deletion.""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +class TestBambuCmdFiles(unittest.TestCase): + def _printer_with_ftp(self, mock_get_printer, mock_get_ftp): + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + return printer + + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_files_success(self, mock_logger, mock_get_printer): + from bambu_cli.commands import cmd_files + + args = MagicMock() + args.json = False + mock_ftp = MagicMock() + mock_get_ftp = MagicMock() + # Mock the context manager behavior + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + mock_ftp.nlst.return_value = ["file1.3mf", "file2.3mf"] + self._printer_with_ftp(mock_get_printer, mock_get_ftp) + + cmd_files(args) + + mock_get_ftp.assert_called_once() + mock_ftp.nlst.assert_called_once_with("/model/") + # __exit__ should be called when using context manager + mock_get_ftp.return_value.__exit__.assert_called_once() + mock_logger.info.assert_any_call("📁 Files on printer:") + mock_logger.info.assert_any_call(" file1.3mf") + mock_logger.info.assert_any_call(" file2.3mf") + + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_files_empty(self, mock_logger, mock_get_printer): + from bambu_cli.commands import cmd_files + + args = MagicMock() + args.json = False + mock_ftp = MagicMock() + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + mock_ftp.nlst.return_value = [] + self._printer_with_ftp(mock_get_printer, mock_get_ftp) + + cmd_files(args) + + mock_get_ftp.assert_called_once() + mock_ftp.nlst.assert_called_once_with("/model/") + mock_get_ftp.return_value.__exit__.assert_called_once() + mock_logger.info.assert_called_with("No files on printer.") + + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_files_error(self, mock_exit, mock_logger, mock_get_printer): + from bambu_cli.commands import cmd_files + + args = MagicMock() + args.json = False + mock_ftp = MagicMock() + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + mock_ftp.nlst.side_effect = OSError("FTP Error") + self._printer_with_ftp(mock_get_printer, mock_get_ftp) + mock_exit.side_effect = SystemExit(2) + + with self.assertRaises((SystemExit, BambuError)): + cmd_files(args) + + mock_get_ftp.assert_called_once() + mock_ftp.nlst.assert_called_once_with("/model/") + mock_logger.error.assert_called_with("Error listing files: Failed to list files via printer API") + + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_files_get_ftp_error(self, mock_exit, mock_logger, mock_get_printer): + from bambu_cli.commands import cmd_files + + args = MagicMock() + args.json = False + mock_get_ftp = MagicMock(side_effect=OSError("Connection Failed")) + self._printer_with_ftp(mock_get_printer, mock_get_ftp) + mock_exit.side_effect = SystemExit(2) + + with self.assertRaises((SystemExit, BambuError)): + cmd_files(args) + + mock_get_ftp.assert_called_once() + mock_logger.error.assert_called_with("Error listing files: Failed to list files via printer API") + +class TestBambuCmdDelete(unittest.TestCase): + @patch("bambu_cli.protocols.ftps.get_ftp") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_delete_no_confirm(self, mock_exit, mock_logger, mock_get_ftp): + from bambu_cli.commands import cmd_delete + + args = MagicMock() + args.file = "test.3mf" + args.confirm = False + + mock_exit.side_effect = SystemExit(5) + + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_delete(args) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 5) + mock_get_ftp.assert_not_called() + mock_logger.warning.assert_called_once_with( + "⚠️ This will DELETE 'test.3mf' from the printer. Add --confirm to proceed." + ) + + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_delete_success(self, mock_logger, mock_get_printer): + from bambu_cli.commands import cmd_delete + + args = MagicMock() + args.file = "test.3mf" + args.confirm = True + args.json = False + mock_ftp = MagicMock() + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + + cmd_delete(args) + + mock_get_ftp.assert_called_once() + mock_ftp.delete.assert_called_once_with("/model/test.3mf") + mock_logger.info.assert_called_once_with("🗑️ Deleted test.3mf from printer") + + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_delete_error(self, mock_exit, mock_logger, mock_get_printer): + from bambu_cli.commands import cmd_delete + + args = MagicMock() + args.file = "test.3mf" + args.confirm = True + args.json = False + mock_ftp = MagicMock() + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + mock_ftp.delete.side_effect = OSError("Delete Error") + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + mock_exit.side_effect = SystemExit(2) + + with self.assertRaises((SystemExit, BambuError)): + cmd_delete(args) + + mock_get_ftp.assert_called_once() + mock_ftp.delete.assert_called_once_with("/model/test.3mf") + mock_logger.error.assert_called_with("Delete failed: Delete operation failed in printer client.") diff --git a/tests/test_cmd_gcode.py b/tests/test_cmd_gcode.py new file mode 100644 index 0000000..e52e775 --- /dev/null +++ b/tests/test_cmd_gcode.py @@ -0,0 +1,114 @@ +"""Raw G-code command, including the command-injection rejection path.""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +class TestBambuCmdGcode(unittest.TestCase): + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("sys.exit") + def test_cmd_gcode_send_command_fail(self, mock_exit, mock_send): + from bambu_cli.commands import cmd_gcode + + mock_send.return_value = False + args = MagicMock() + args.code = "G28" + args.confirm = True + args.json = False + + mock_exit.side_effect = SystemExit(2) + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_gcode(args) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) + + @patch("bambu_cli.commands.gcode.get_sequence_id", return_value="0") + @patch("bambu_cli.protocols.mqtt.send_command") + def test_cmd_gcode(self, mock_send_command, mock_seq): + from bambu_cli.commands import cmd_gcode + + args = MagicMock() + args.code = "M104 S220" + args.confirm = True + args.json = False + + cmd_gcode(args) + + # Expected payload + expected_payload = json.dumps({"print": {"sequence_id": "0", "command": "gcode_line", "param": "M104 S220"}}) + + mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) + + @patch("bambu_cli.protocols.mqtt.send_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_gcode_no_confirm_aborts_without_send(self, mock_logger, mock_send): + """Raw G-code is a physical action: require --confirm before MQTT send.""" + from bambu_cli.commands import cmd_gcode + from bambu_cli.constants import EXIT_COMMAND_ERROR + + args = MagicMock() + args.code = "G28" + args.confirm = False + args.json = False + + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_gcode(args) + + self.assertEqual( + getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), + EXIT_COMMAND_ERROR, + ) + mock_send.assert_not_called() + self.assertTrue(any("Add --confirm to proceed" in str(call) for call in mock_logger.warning.call_args_list)) + + @patch("bambu_cli.commands.gcode.get_sequence_id", return_value="0") + @patch("bambu_cli.protocols.mqtt.send_command") + def test_cmd_gcode_with_confirm_sends(self, mock_send, mock_seq): + from bambu_cli.commands import cmd_gcode + + mock_send.return_value = True + args = MagicMock() + args.code = "G28" + args.confirm = True + args.json = False + + cmd_gcode(args) + + mock_send.assert_called_once() + payload = mock_send.call_args[0][1] + self.assertIn("G28", payload) + + @patch("bambu_cli.protocols.mqtt.send_command") + def test_cmd_gcode_rejects_empty_code(self, mock_send): + from bambu_cli.commands import cmd_gcode + from bambu_cli.constants import EXIT_COMMAND_ERROR + + for bad in ("", " ", "\t"): + args = MagicMock() + args.code = bad + args.confirm = True + args.json = False + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_gcode(args) + self.assertEqual( + getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), + EXIT_COMMAND_ERROR, + ) + mock_send.assert_not_called() + + @patch("bambu_cli.protocols.mqtt.send_command") + def test_cmd_gcode_rejects_control_chars(self, mock_send): + """CR/LF/NUL in G-code can smuggle extra MQTT/serial commands.""" + from bambu_cli.commands import cmd_gcode + from bambu_cli.constants import EXIT_COMMAND_ERROR + + for bad in ("G28\nM104 S999", "G28\rM104", "G28\x00M104"): + args = MagicMock() + args.code = bad + args.confirm = True + args.json = False + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_gcode(args) + self.assertEqual( + getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), + EXIT_COMMAND_ERROR, + ) + mock_send.assert_not_called() diff --git a/tests/test_cmd_print.py b/tests/test_cmd_print.py new file mode 100644 index 0000000..1976d44 --- /dev/null +++ b/tests/test_cmd_print.py @@ -0,0 +1,241 @@ +"""Print command: the physical-action path and its --confirm gate.""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +class TestBambuCmdPrint(unittest.TestCase): + @patch("bambu_cli.protocols.mqtt.get_status") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_execute_print_command_dry_run_file_not_found(self, mock_exit, mock_logger, mock_get_status): + from bambu_cli.protocols.mqtt import execute_print_command + + mock_ftp = MagicMock() + mock_ftp.nlst.return_value = ["other.3mf"] + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + + mock_exit.side_effect = SystemExit(3) + with self.assertRaises((SystemExit, BambuError)) as cm: + execute_print_command(printer, "payload", "missing.3mf", dry_run=True) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 3) + mock_logger.error.assert_any_call(" ❌ File missing.3mf NOT found on printer. Upload it first.") + + @patch("bambu_cli.protocols.mqtt.get_status") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_execute_print_command_dry_run_mqtt_fail(self, mock_exit, mock_logger, mock_get_status): + from bambu_cli.protocols.mqtt import execute_print_command + + mock_ftp = MagicMock() + mock_ftp.nlst.return_value = ["test.3mf"] + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + + mock_get_status.return_value = None + + mock_exit.side_effect = SystemExit(2) + with self.assertRaises((SystemExit, BambuError)) as cm: + execute_print_command(printer, "payload", "test.3mf", dry_run=True) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) + mock_logger.error.assert_any_call(" ❌ MQTT connection failed.") + + @patch("bambu_cli.protocols.mqtt.get_status") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_execute_print_command_dry_run_exception(self, mock_exit, mock_logger, mock_get_status): + from bambu_cli.protocols.mqtt import execute_print_command + + printer = _test_printer() + printer.get_ftp_client = MagicMock(side_effect=OSError("FTP Error")) + + mock_exit.side_effect = SystemExit(2) + with self.assertRaises((SystemExit, BambuError)) as cm: + execute_print_command(printer, "payload", "test.3mf", dry_run=True) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) + mock_logger.error.assert_any_call("Dry run failed: FTP Error") + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.protocols.mqtt.time.sleep") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_execute_print_command_non_sd_error(self, mock_exit, mock_logger, mock_sleep, mock_create): + from bambu_cli.protocols.mqtt import execute_print_command + + mock_client = MagicMock() + mock_create.return_value = mock_client + + def fake_connect(ip, port, keepalive): + # simulate receiving message with error 1234 + msg = MagicMock() + msg.payload = b'{"print": {"print_error": 1234}}' + mock_client.on_message(mock_client, None, msg) + + mock_client.connect.side_effect = fake_connect + + mock_exit.side_effect = SystemExit(4) + + with self.assertRaises((SystemExit, BambuError)) as cm: + execute_print_command(_test_printer(), "payload", "test.3mf", dry_run=False) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 4) + mock_logger.error.assert_called_with("Print failed with error code 1234 (hex 0x000004D2)") + + def test_generate_print_payload(self): + from bambu_cli.job import generate_print_payload + import json + + basename = "test_model.gcode" + payload = generate_print_payload(basename) + + parsed = json.loads(payload) + self.assertIn("print", parsed) + self.assertEqual(parsed["print"]["subtask_name"], "test_model.gcode") + self.assertEqual(parsed["print"]["url"], "file:///sdcard/model/test_model.gcode") + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("time.sleep") + def test_execute_print_command_success(self, mock_sleep, mock_logger, mock_create_mqtt): + from bambu_cli.protocols.mqtt import execute_print_command + import json + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + + # Simulate on_connect + def trigger_on_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + msg = MagicMock() + msg.payload = b'{"print": {"command": "project_file"}}' + mock_client.on_message(mock_client, None, msg) + + mock_client.connect.side_effect = trigger_on_connect + + payload = '{"test": "payload"}' + basename = "test_model.gcode" + + printer = _test_printer() + execute_print_command(printer, payload, basename) + + mock_create_mqtt.assert_called_once_with(printer, "bambu_print") + mock_client.connect.assert_called_once() + mock_client.loop_start.assert_called_once() + mock_client.loop_stop.assert_called_once() + mock_client.disconnect.assert_called_once() + + # Check success log + self.assertTrue(any(f"🖨️ Print started: {basename}" in call[0][0] for call in mock_logger.info.call_args_list)) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("time.sleep") + @patch("sys.exit") + def test_execute_print_command_with_error(self, mock_exit, mock_sleep, mock_logger, mock_create_mqtt): + from bambu_cli.protocols.mqtt import execute_print_command + import json + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + mock_exit.side_effect = SystemExit(3) + + # Simulate receiving an error message + def trigger_on_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + # Simulate on_message with error code + msg = MagicMock() + msg.payload = json.dumps({"print": {"print_error": 83935248}}).encode() + mock_client.on_message(mock_client, None, msg) + + mock_client.connect.side_effect = trigger_on_connect + + payload = '{"test": "payload"}' + basename = "test_model.gcode" + + with self.assertRaises((SystemExit, BambuError)): + execute_print_command(_test_printer(), payload, basename) + + self.assertTrue( + any("Print failed with error code 83935248" in call[0][0] for call in mock_logger.error.call_args_list) + ) + self.assertTrue( + any("File not found on printer SD card" in call[0][0] for call in mock_logger.info.call_args_list) + ) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + @patch("time.sleep") + def test_execute_print_command_exception(self, mock_sleep, mock_exit, mock_logger, mock_create_mqtt): + from bambu_cli.protocols.mqtt import execute_print_command + import json + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + mock_client.connect.side_effect = OSError("Connection refused") + mock_exit.side_effect = SystemExit(2) + + payload = '{"test": "payload"}' + basename = "test_model.gcode" + + with self.assertRaises((SystemExit, BambuError)): + execute_print_command(_test_printer(), payload, basename) + + self.assertTrue(any("Error: Connection refused" in call[0][0] for call in mock_logger.error.call_args_list)) + + @patch("bambu_cli.job.generate_print_payload") + @patch("bambu_cli.protocols.mqtt.execute_print_command") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_print_no_confirm(self, mock_logger, mock_execute, mock_generate): + from bambu_cli.commands import cmd_print + + args = MagicMock() + args.confirm = False + args.file = "test.gcode" + args.dry_run = False + args.ams_mapping = None + args.use_ams = False + + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_print(args) + self.assertEqual(cm.exception.exit_code, 5) + + mock_generate.assert_not_called() + mock_execute.assert_not_called() + + self.assertTrue( + any( + "⚠️ This will START a print. Add --confirm to proceed." in call[0][0] + for call in mock_logger.warning.call_args_list + ) + ) + + @patch("bambu_cli.commands.print_cmd.generate_print_payload") + @patch("bambu_cli.protocols.mqtt.execute_print_command") + def test_cmd_print_with_confirm(self, mock_execute, mock_generate): + from bambu_cli.commands import cmd_print + + args = MagicMock() + args.confirm = True + args.file = "test.gcode" + args.dry_run = False + args.ams_mapping = None + args.use_ams = False + args.timelapse = False + args.skip_bed_leveling = True + args.skip_flow_cali = True + + mock_generate.return_value = "test_payload" + + cmd_print(args) + + mock_generate.assert_called_once_with( + "test.gcode", use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=False, flow_cali=False + ) + mock_execute.assert_called_once_with(ANY, "test_payload", "test.gcode", dry_run=False) diff --git a/tests/test_camera_cmd.py b/tests/test_cmd_snapshot.py similarity index 60% rename from tests/test_camera_cmd.py rename to tests/test_cmd_snapshot.py index a783be7..5d57f07 100644 --- a/tests/test_camera_cmd.py +++ b/tests/test_cmd_snapshot.py @@ -1,190 +1,10 @@ +"""The snapshot command itself: direct grab, Docker streamer fallback, and error paths.""" + import hashlib from tests.bambu_test_base import * # noqa: F401,F403 from bambu_cli.errors import BambuError - -class TestCameraPortIsValid(unittest.TestCase): - def test_rejects_out_of_range_container_port(self): - """A container port above 65535 must be rejected: \\d{1,5} alone lets - '99999' match the regex even though it is not a valid port number.""" - from bambu_cli.protocols.camera import _camera_port_is_valid - - self.assertFalse(_camera_port_is_valid("1985:99999")) - self.assertFalse(_camera_port_is_valid("0")) - self.assertFalse(_camera_port_is_valid("70000-70005")) - - def test_accepts_valid_container_ports(self): - from bambu_cli.protocols.camera import _camera_port_is_valid - - self.assertTrue(_camera_port_is_valid("127.0.0.1:1985:1984")) - self.assertTrue(_camera_port_is_valid("1984")) - self.assertTrue(_camera_port_is_valid("1984/tcp")) - self.assertTrue(_camera_port_is_valid("1984-1989/udp")) - - -class TestGrabCameraFrameDirect(unittest.TestCase): - def _mock_net(self): - mock_sock = MagicMock() - mock_tls = MagicMock() - mock_ctx = MagicMock() - mock_ctx.wrap_socket.return_value = mock_tls - mock_tls.recv.side_effect = [ - # first recv: size header (16 bytes) - (4).to_bytes(4, "little") + b"\x00" * 12, - # second recv: 4 bytes data representing valid JPEG - b"\xff\xd8\xff\xd9", - ] - create_connection = MagicMock(return_value=mock_sock) - ssl_context_factory = MagicMock(return_value=mock_ctx) - return create_connection, ssl_context_factory, mock_sock, mock_tls, mock_ctx - - def test_grab_camera_frame_direct_no_pin_fails_closed(self): - """Without a pinned fingerprint (and insecure_tls unset) the camera - connection must fail closed before the access code is sent.""" - import ssl as ssl_mod - from bambu_cli.protocols.camera import _grab_camera_frame_direct - - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - printer = _test_printer(ip="192.168.1.100", access_code="my_secret_code") - - with self.assertRaises(ssl_mod.SSLError): - _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - mock_tls.sendall.assert_not_called() - - def test_grab_camera_frame_direct_insecure(self): - from bambu_cli.protocols.camera import _grab_camera_frame_direct - - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - printer = _test_printer(ip="192.168.1.100", access_code="my_secret_code", insecure_tls=True) - - res = _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - self.assertEqual(res, b"\xff\xd8\xff\xd9") - - create_connection.assert_called_once_with(("192.168.1.100", 6000), timeout=12) - mock_ctx.wrap_socket.assert_called_once_with(mock_sock, server_hostname="192.168.1.100") - mock_tls.sendall.assert_called_once() - mock_tls.getpeercert.assert_not_called() - # wrap_socket detaches the fd into the SSLSocket, so the wrapped object - # (not the bare socket) must be closed or the fd leaks. - mock_tls.close.assert_called_once() - - def test_grab_camera_frame_direct_with_pin(self): - from bambu_cli.protocols.camera import _grab_camera_frame_direct - - der = b"der_cert" - good_fp = hashlib.sha256(der).hexdigest() - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - mock_tls.getpeercert.return_value = der - printer = _test_printer(ip="192.168.1.100", access_code="my_secret_code", cert_fingerprint=good_fp) - - res = _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - self.assertEqual(res, b"\xff\xd8\xff\xd9") - - mock_tls.getpeercert.assert_called_once_with(binary_form=True) - - def test_grab_camera_frame_direct_pin_mismatch(self): - from bambu_cli.protocols.camera import _CameraPinMismatch, _grab_camera_frame_direct - - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - mock_tls.getpeercert.return_value = b"der_cert" - # Pin the SHA-256 of a *different* cert so the real compare fails. - printer = _test_printer( - ip="192.168.1.100", - access_code="my_secret_code", - cert_fingerprint=hashlib.sha256(b"a_different_cert").hexdigest(), - ) - - # A mismatching pin raises a dedicated security error (not a generic - # SSLError) so the snapshot command can fail closed instead of falling - # back to the Docker streamer, which would ignore the pin. - with self.assertRaises(_CameraPinMismatch): - _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - mock_tls.sendall.assert_not_called() - - def test_grab_camera_frame_direct_pin_no_peer_cert(self): - """A pin configured but no peer cert must fail closed (missing cert is not - a Docker-fallback signal). Regression: the old code called .lower() on a - None fingerprint and crashed with AttributeError instead of a clean pin - failure that the snapshot command recognizes as fail-closed.""" - from bambu_cli.protocols.camera import _CameraPinMismatch, _grab_camera_frame_direct - - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - mock_tls.getpeercert.return_value = None - printer = _test_printer( - ip="192.168.1.100", - access_code="my_secret_code", - cert_fingerprint=hashlib.sha256(b"der_cert").hexdigest(), - ) - - with self.assertRaises(_CameraPinMismatch): - _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - mock_tls.sendall.assert_not_called() - - def test_grab_camera_frame_direct_malformed_nonascii_pin(self): - """A malformed/non-ASCII pin must raise _CameraPinMismatch (fail closed), - NOT a raw TypeError from hmac.compare_digest that would escape into the - broad except-Exception fallback and silently use the unpinned Docker - streamer.""" - from bambu_cli.protocols.camera import _CameraPinMismatch, _grab_camera_frame_direct - - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - mock_tls.getpeercert.return_value = b"der_cert" - # 64 chars but with a Cyrillic 'а' — survives normalize, non-ASCII. - printer = _test_printer( - ip="192.168.1.100", - access_code="my_secret_code", - cert_fingerprint="а" + "b" * 63, - ) - - with self.assertRaises(_CameraPinMismatch): - _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - mock_tls.sendall.assert_not_called() - - def test_grab_camera_frame_direct_oversized_header_aborts(self): - """An implausibly large frame length means the stream is desynced; the - grab must give up (return None) instead of reading the skipped body as - the next frame header for the rest of the loop.""" - from bambu_cli.protocols.camera import _grab_camera_frame_direct - - create_connection, ssl_factory, mock_sock, mock_tls, mock_ctx = self._mock_net() - mock_tls.recv.side_effect = [(99_000_000).to_bytes(4, "little") + b"\x00" * 12] - printer = _test_printer(ip="192.168.1.100", access_code="c", insecure_tls=True) - - res = _grab_camera_frame_direct( - printer, - create_connection=create_connection, - ssl_context_factory=ssl_factory, - ) - self.assertIsNone(res) - # Only the one bogus header was read — no attempt to drain/parse a body. - self.assertEqual(mock_tls.recv.call_count, 1) - - class TestBambuCmdSnapshot(unittest.TestCase): def _logger_patch(self): return patch("bambu_cli.logging_utils.logger", new=MagicMock()) @@ -739,189 +559,3 @@ def test_cmd_snapshot_running_container_exposed_warns(self): sleep=MagicMock(), ) self.assertTrue(any("docker rm -f" in c[0][0] for c in mock_logger.warning.call_args_list)) - - -class TestSnapshotUniqueNaming(unittest.TestCase): - """--unique flag produces timestamped filenames without wall-clock dependency.""" - - def _snap_args(self, output=None, unique=False): - args = MagicMock() - args.output = output - args.unique = unique - args.json = False - return args - - def test_unique_flag_no_output_uses_timestamp(self): - """With --unique and no --output, filename is printer_snapshot_.jpg.""" - import datetime - from bambu_cli.protocols.camera import _utc_stamp - - fixed_dt = datetime.datetime(2026, 7, 24, 19, 15, 30, tzinfo=datetime.timezone.utc) - stamp = _utc_stamp(fixed_dt) - self.assertEqual(stamp, "20260724T191530Z") - - from bambu_cli.commands import cmd_snapshot - - saved_paths = [] - - def _fake_write(path, data): - saved_paths.append(path) - - args = self._snap_args(output=None, unique=True) - - with ( - patch("bambu_cli.protocols.camera._write_snapshot_atomic", side_effect=_fake_write), - patch("bambu_cli.logging_utils._BACKEND", MagicMock()), - patch("os.path.getsize", return_value=1024), - patch("bambu_cli.protocols.camera._ensure_parent_dir"), - ): - cmd_snapshot( - args, - grab_frame=lambda printer: b"\xff\xd8\xff\xd9", - now=fixed_dt, - ) - - self.assertEqual(len(saved_paths), 1) - self.assertIn("20260724T191530Z", saved_paths[0]) - self.assertTrue(saved_paths[0].endswith(".jpg")) - self.assertIn("printer_snapshot_", saved_paths[0]) - - def test_unique_flag_with_output_inserts_timestamp_before_ext(self): - """With --unique and --output cam.jpg, result is cam_.jpg.""" - import datetime - from bambu_cli.commands import cmd_snapshot - - fixed_dt = datetime.datetime(2026, 7, 24, 19, 15, 30, tzinfo=datetime.timezone.utc) - saved_paths = [] - - def _fake_write(path, data): - saved_paths.append(path) - - args = self._snap_args(output="cam.jpg", unique=True) - - with ( - patch("bambu_cli.protocols.camera._write_snapshot_atomic", side_effect=_fake_write), - patch("bambu_cli.logging_utils._BACKEND", MagicMock()), - patch("os.path.getsize", return_value=1024), - patch("bambu_cli.protocols.camera._ensure_parent_dir"), - ): - cmd_snapshot( - args, - grab_frame=lambda printer: b"\xff\xd8\xff\xd9", - now=fixed_dt, - ) - - self.assertEqual(len(saved_paths), 1) - self.assertTrue(saved_paths[0].endswith("20260724T191530Z.jpg")) - self.assertTrue(saved_paths[0].startswith("cam_") or "cam_" in saved_paths[0]) - - def test_no_unique_flag_uses_default_name(self): - """Without --unique, saves to the given --output name unchanged.""" - from bambu_cli.commands import cmd_snapshot - - saved_paths = [] - - def _fake_write(path, data): - saved_paths.append(path) - - args = self._snap_args(output="myshot.jpg", unique=False) - - with ( - patch("bambu_cli.protocols.camera._write_snapshot_atomic", side_effect=_fake_write), - patch("bambu_cli.logging_utils._BACKEND", MagicMock()), - patch("os.path.getsize", return_value=1024), - patch("bambu_cli.protocols.camera._ensure_parent_dir"), - ): - cmd_snapshot( - args, - grab_frame=lambda printer: b"\xff\xd8\xff\xd9", - ) - - self.assertEqual(len(saved_paths), 1) - self.assertTrue(saved_paths[0].endswith("myshot.jpg")) - self.assertNotIn("Z.jpg", saved_paths[0]) - - -class TestSnapshotJsonMetadata(unittest.TestCase): - """captured_at and sha256 appear in --json output on every successful capture.""" - - def _snap_args(self, output="snap.jpg", unique=False): - args = MagicMock() - args.output = output - args.unique = unique - args.json = True - return args - - def test_direct_path_json_includes_captured_at_and_sha256(self, capsys=None): - """Direct grab path: JSON output must include captured_at and sha256.""" - import io - import contextlib - import hashlib - from bambu_cli.commands import cmd_snapshot - - frame_data = b"\xff\xd8\xff\xd9" - expected_sha = hashlib.sha256(frame_data).hexdigest() - args = self._snap_args() - - buf = io.StringIO() - with ( - patch("bambu_cli.protocols.camera._write_snapshot_atomic"), - patch("bambu_cli.logging_utils._BACKEND", MagicMock()), - patch("os.path.getsize", return_value=len(frame_data)), - patch("bambu_cli.protocols.camera._ensure_parent_dir"), - patch("bambu_cli.protocols.camera.emit_json", side_effect=lambda d: buf.write(json.dumps(d))), - ): - cmd_snapshot( - args, - grab_frame=lambda printer: frame_data, - ) - - payload = json.loads(buf.getvalue()) - self.assertIn("captured_at", payload) - self.assertIn("sha256", payload) - self.assertEqual(payload["sha256"], expected_sha) - # captured_at should look like ISO-8601 UTC - self.assertRegex(payload["captured_at"], r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z") - - def test_docker_path_json_includes_captured_at_and_sha256(self): - """Docker streamer path: JSON output must include captured_at and sha256.""" - import io - import hashlib - from bambu_cli.commands import cmd_snapshot - - frame_data = b"\xff\xd8fake_image_data\xff\xd9" - expected_sha = hashlib.sha256(frame_data).hexdigest() - args = self._snap_args() - - mock_response = MagicMock() - mock_response.read.return_value = frame_data - mock_urlopen = MagicMock() - mock_urlopen.return_value.__enter__.return_value = mock_response - mock_run = MagicMock(return_value=MagicMock(returncode=0, stdout="true")) - - buf = io.StringIO() - with ( - patch("bambu_cli.protocols.camera._write_snapshot_atomic"), - patch("bambu_cli.logging_utils._BACKEND", MagicMock()), - patch("os.path.getsize", return_value=len(frame_data)), - patch("bambu_cli.protocols.camera._ensure_parent_dir"), - patch("bambu_cli.protocols.camera.emit_json", side_effect=lambda d: buf.write(json.dumps(d))), - ): - cmd_snapshot( - args, - grab_frame=lambda printer: None, # force Docker path - which=lambda name: "/usr/bin/docker", - subprocess_run=mock_run, - urlopen=mock_urlopen, - sleep=MagicMock(), - ) - - payload = json.loads(buf.getvalue()) - self.assertIn("captured_at", payload) - self.assertIn("sha256", payload) - self.assertEqual(payload["sha256"], expected_sha) - self.assertRegex(payload["captured_at"], r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_cmd_status.py b/tests/test_cmd_status.py new file mode 100644 index 0000000..26ecdbf --- /dev/null +++ b/tests/test_cmd_status.py @@ -0,0 +1,405 @@ +"""Status command: one-shot query and the --monitor NDJSON stream.""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +def _full_status_snapshot(**overrides): + """A pushall reply: carries every key `status` treats as always-present.""" + snapshot = { + "gcode_state": "RUNNING", + "mc_percent": 37, + "layer_num": 74, + "total_layer_num": 200, + "bed_temper": 60.0, + "bed_target_temper": 60.0, + "nozzle_temper": 219.9375, + "nozzle_target_temper": 220.0, + } + snapshot.update(overrides) + return snapshot + + +def _mqtt_message(print_payload): + msg = MagicMock() + msg.payload = json.dumps({"print": print_payload}).encode() + return msg + + +class TestBambuGetStatus(unittest.TestCase): + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + def test_get_status_on_connect_rc_error(self, mock_logger, mock_create): + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create.return_value = mock_client + + def side_effect_connect(host, port, keepalive): + mock_client.on_connect(mock_client, None, None, 5) + + mock_client.connect.side_effect = side_effect_connect + + result = get_status(_test_printer(), timeout=0.1) + + self.assertIsNone(result) + mock_logger.error.assert_called_with("Connection failed: rc=5") + + @patch("bambu_cli.protocols.mqtt.get_status") + def test_cmd_status_connect_fail(self, mock_get_status): + from bambu_cli.commands import cmd_status + from bambu_cli.errors import PrinterConnectionError + + mock_get_status.return_value = None + + with self.assertRaises(PrinterConnectionError) as cm: + cmd_status(MagicMock()) + + self.assertEqual(str(cm.exception), "Could not connect to printer.") + self.assertEqual(cm.exception.exit_code, 2) + self.assertEqual(cm.exception.failed_step, "mqtt") + + @patch("bambu_cli.commands.status.emit_json") + @patch("bambu_cli.protocols.mqtt.get_status") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_status_json_output(self, mock_logger, mock_get_status, mock_emit_json): + from bambu_cli.commands import cmd_status + + mock_get_status.return_value = {"gcode_state": "IDLE"} + + args = MagicMock() + args.json = True + args.monitor = False + + cmd_status(args) + + mock_emit_json.assert_called_once() + payload = mock_emit_json.call_args[0][0] + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["command"], "status") + self.assertEqual(payload["gcode_state"], "IDLE") + + @patch("bambu_cli.commands.status.emit_json") + @patch("bambu_cli.protocols.mqtt.get_status") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_status_json_never_emits_partial_printer(self, mock_logger, mock_get_status, mock_emit_json): + """`--json status` must error rather than hand agents a printer map with no gcode_state.""" + from bambu_cli.commands import cmd_status + from bambu_cli.errors import PrinterStatusIncomplete + + mock_get_status.side_effect = PrinterStatusIncomplete( + "Printer returned only partial status updates, never a full snapshot (missing gcode_state).", + detail={"missing_keys": ["gcode_state"], "received_keys": ["nozzle_temper"]}, + ) + + args = MagicMock() + args.json = True + args.monitor = False + + with self.assertRaises(PrinterStatusIncomplete): + cmd_status(args) + + mock_emit_json.assert_not_called() + + @patch("bambu_cli.protocols.mqtt.get_status") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_status_running_formatting(self, mock_logger, mock_get_status): + from bambu_cli.commands import cmd_status + + mock_get_status.return_value = { + "gcode_state": "RUNNING", + "gcode_file": "test.gcode", + "mc_percent": 50, + "layer_num": 10, + "total_layer_num": 20, + "mc_remaining_time": 125, + "bed_temper": 60, + "bed_target_temper": 60, + "nozzle_temper": 220, + "nozzle_target_temper": 220, + "cooling_fan_speed": 100, + "wifi_signal": "-50dBm", + } + + args = MagicMock() + args.json = False + + cmd_status(args) + + mock_logger.info.assert_any_call(" File: test.gcode") + mock_logger.info.assert_any_call(" Progress: 50% | Layer 10/20") + mock_logger.info.assert_any_call(" Time left: 2h 5m") + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("time.sleep") + def test_get_status_success(self, mock_sleep, mock_create_mqtt): + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + snapshot = _full_status_snapshot(gcode_state="IDLE", mc_percent=0) + + def mock_connect(*args, **kwargs): + # Call on_connect directly + mock_client.on_connect(mock_client, None, None, 0) + + # Simulate the pushall reply arriving with 'print' data + mock_client.on_message(mock_client, None, _mqtt_message(snapshot)) + + mock_client.connect.side_effect = mock_connect + + result = get_status(_test_printer(), timeout=1) + + self.assertEqual(result, snapshot) + mock_create_mqtt.assert_called_once() + mock_client.connect.assert_called_once() + mock_client.subscribe.assert_called_once() + mock_client.publish.assert_called_once() + mock_client.disconnect.assert_called() + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("time.sleep") + @patch("bambu_cli.logging_utils._BACKEND") + def test_get_status_timeout(self, mock_logger, mock_sleep, mock_create_mqtt): + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + + def mock_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + + mock_client.connect.side_effect = mock_connect + + # No status message ever arrives -> 3 attempts (2 retries) + result = get_status(_test_printer(), timeout=0.0001) + + self.assertIsNone(result) + self.assertEqual(mock_client.connect.call_count, 3) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("time.sleep") + def test_get_status_connection_failure(self, mock_sleep, mock_logger, mock_create_mqtt): + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + + # Mock connect to raise an exception + mock_client.connect.side_effect = OSError("Connection error") + + result = get_status(_test_printer(), timeout=0.0001) + + self.assertIsNone(result) + self.assertTrue( + any("MQTT status error: Connection error" in call[0][0] for call in mock_logger.error.call_args_list) + ) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + def test_get_status_ignore_non_print_messages(self, mock_create_mqtt): + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + snapshot = _full_status_snapshot(gcode_state="RUNNING") + + def mock_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + + # Send message without 'print' key + msg1 = MagicMock() + msg1.payload = json.dumps({"other": "data"}).encode() + mock_client.on_message(mock_client, None, msg1) + + # Send invalid JSON + msg2 = MagicMock() + msg2.payload = b"invalid json" + mock_client.on_message(mock_client, None, msg2) + + # Send valid print message + mock_client.on_message(mock_client, None, _mqtt_message(snapshot)) + + mock_client.connect.side_effect = mock_connect + + with patch("time.sleep"): + result = get_status(_test_printer(), timeout=1) + + self.assertEqual(result, snapshot) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("time.sleep") + def test_get_status_waits_through_delta_for_full_snapshot(self, mock_sleep, mock_create_mqtt): + """A delta arriving before the pushall reply must not be returned as the state. + + Reproduces the live-printer intermittent: mid-print the report topic + delivers a lone nozzle_temper reading first, and returning it hands + agents a `printer` object with no gcode_state. + """ + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + snapshot = _full_status_snapshot() + + def mock_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + # Incremental delta first — exactly what was observed at ~37%. + mock_client.on_message(mock_client, None, _mqtt_message({"nozzle_temper": 219.9375})) + # Then the pushall reply. + mock_client.on_message(mock_client, None, _mqtt_message(snapshot)) + + mock_client.connect.side_effect = mock_connect + + result = get_status(_test_printer(), timeout=1) + + self.assertIn("gcode_state", result) + self.assertEqual(result["gcode_state"], "RUNNING") + self.assertEqual(result["mc_percent"], 37) + self.assertEqual(result["total_layer_num"], 200) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("time.sleep") + def test_get_status_merges_delta_over_earlier_snapshot(self, mock_sleep, mock_create_mqtt): + """Later values win when a delta follows the snapshot in the same window.""" + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + + def mock_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + # Snapshot missing one required key, so the wait continues... + partial_snapshot = _full_status_snapshot() + del partial_snapshot["bed_temper"] + mock_client.on_message(mock_client, None, _mqtt_message(partial_snapshot)) + # ...and the next delta both completes and freshens the state. + mock_client.on_message(mock_client, None, _mqtt_message({"bed_temper": 61.0, "mc_percent": 38})) + + mock_client.connect.side_effect = mock_connect + + result = get_status(_test_printer(), timeout=1) + + self.assertEqual(result["bed_temper"], 61.0) + self.assertEqual(result["mc_percent"], 38) + self.assertEqual(result["gcode_state"], "RUNNING") + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("time.sleep") + def test_get_status_deltas_only_raises_instead_of_returning_partial( + self, mock_sleep, mock_logger, mock_create_mqtt + ): + """If no full snapshot ever arrives, error clearly rather than emit a partial.""" + from bambu_cli.errors import PrinterStatusIncomplete + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + + def mock_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + mock_client.on_message(mock_client, None, _mqtt_message({"nozzle_temper": 219.9375})) + + mock_client.connect.side_effect = mock_connect + + with self.assertRaises(PrinterStatusIncomplete) as cm: + get_status(_test_printer(), timeout=0.05, retries=1) + + self.assertEqual(cm.exception.exit_code, 6) + self.assertEqual(cm.exception.failed_step, "status") + self.assertIn("gcode_state", cm.exception.detail["missing_keys"]) + self.assertEqual(cm.exception.detail["received_keys"], ["nozzle_temper"]) + # Every attempt re-issues pushall rather than settling for the delta. + self.assertEqual(mock_client.connect.call_count, 2) + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("time.sleep") + def test_get_status_liveness_probe_accepts_partial(self, mock_sleep, mock_create_mqtt): + """doctor / --dry-run only prove MQTT works, so a delta is good enough.""" + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + + def mock_connect(*args, **kwargs): + mock_client.on_connect(mock_client, None, None, 0) + mock_client.on_message(mock_client, None, _mqtt_message({"nozzle_temper": 219.9375})) + + mock_client.connect.side_effect = mock_connect + + result = get_status(_test_printer(), timeout=1, require_complete=False) + + self.assertEqual(result, {"nozzle_temper": 219.9375}) + mock_client.connect.assert_called_once() + + @patch("bambu_cli.protocols.mqtt.create_mqtt_client") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("time.sleep") + def test_get_status_exception(self, mock_sleep, mock_logger, mock_create_mqtt): + from bambu_cli.protocols.mqtt import get_status + + mock_client = MagicMock() + mock_create_mqtt.return_value = mock_client + mock_client.connect.side_effect = OSError("Network error") + + result = get_status(_test_printer(), timeout=1) + + self.assertIsNone(result) + self.assertTrue( + any("MQTT status error: Network error" in call[0][0] for call in mock_logger.error.call_args_list) + ) + +class TestMonitorStatusStreaming(unittest.TestCase): + """`status --monitor --json` streams one NDJSON event per change (agent contract).""" + + def test_status_event_shape_and_coercion(self): + from bambu_cli.protocols.mqtt import _status_event + + p = { + "gcode_state": "RUNNING", + "mc_percent": "42", # firmware sometimes sends numbers as strings + "layer_num": 10, + "total_layer_num": 200, + "mc_remaining_time": "33", + "nozzle_temper": 220, + "bed_temper": 60, + "gcode_file": "model.gcode", + } + ev = _status_event(p, "update") + self.assertEqual(ev["event"], "update") + self.assertEqual(ev["command"], "status") + self.assertEqual(ev["gcode_state"], "RUNNING") + self.assertEqual(ev["mc_percent"], 42) # coerced to int + self.assertEqual(ev["mc_remaining_time"], 33) # coerced to int + self.assertEqual(ev["layer_num"], 10) + self.assertEqual(ev["total_layer_num"], 200) + self.assertEqual(ev["gcode_file"], "model.gcode") + # Missing/garbage numeric fields degrade to 0 rather than raising. + self.assertEqual(_status_event({}, "update")["mc_percent"], 0) + self.assertEqual(_status_event({"mc_percent": "?"}, "update")["mc_percent"], 0) + + def test_sim_monitor_streams_ndjson_events(self): + import contextlib + import io + import json + import types + + from bambu_cli.printer import get_printer + from bambu_cli.protocols import mqtt + + args = types.SimpleNamespace(json=True, monitor=True, sim=True) + with settings_ctx(simulation=True), patch.object(mqtt.time, "sleep"): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + mqtt.monitor_status(args, get_printer()) + + events = [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] + self.assertEqual( + [(e["event"], e["gcode_state"], e["mc_percent"]) for e in events], + [("update", "PREPARE", 0), ("update", "RUNNING", 50), ("terminal", "FINISH", 100)], + ) + # Every streamed line is a self-contained one-line JSON object (NDJSON). + for line in buf.getvalue().splitlines(): + if line.strip(): + self.assertNotIn("\n", line) + obj = json.loads(line) + self.assertEqual(obj["command"], "status") diff --git a/tests/test_cmd_upload.py b/tests/test_cmd_upload.py new file mode 100644 index 0000000..efaa930 --- /dev/null +++ b/tests/test_cmd_upload.py @@ -0,0 +1,207 @@ +"""Upload command: path validation, size/name limits, and the resume/retry path. + +Split out of the former 1333-line test_printer_commands.py (docs/test-backlog.md: +one module per command surface, so a failure names the command it broke).""" + +from tests.bambu_test_base import * # noqa: F401,F403 + +class TestBambuCmdUploadEdgeCases(unittest.TestCase): + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_upload_invalid_filepath(self, mock_exit, mock_logger): + from bambu_cli.commands import cmd_upload + + args = MagicMock() + args.file = "-invalid.gcode" + mock_exit.side_effect = SystemExit(3) + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_upload(args) + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 3) + mock_logger.error.assert_called_with("Invalid filepath: -invalid.gcode") + + @patch("os.path.exists") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_upload_file_not_found(self, mock_exit, mock_logger, mock_exists): + from bambu_cli.commands import cmd_upload + + mock_exists.return_value = False + args = MagicMock() + args.file = "missing.gcode" + mock_exit.side_effect = SystemExit(3) + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_upload(args) + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 3) + mock_logger.error.assert_called_with("File not found: missing.gcode") + + @patch("os.path.exists") + @patch("os.path.getsize") + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + def test_cmd_upload_dry_run_success(self, mock_logger, mock_get_printer, mock_getsize, mock_exists): + from bambu_cli.commands import cmd_upload + + mock_exists.return_value = True + mock_getsize.return_value = 1024 + args = MagicMock() + args.file = "test.gcode" + args.dry_run = True + + mock_ftp = MagicMock() + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + + cmd_upload(args) + mock_logger.info.assert_any_call(" ✅ Printer reachable.") + mock_logger.info.assert_any_call(" ✅ Local file test.gcode exists (1KB)") + + @patch("os.path.exists") + @patch("os.path.getsize") + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + def test_cmd_upload_dry_run_fail(self, mock_exit, mock_logger, mock_get_printer, mock_getsize, mock_exists): + from bambu_cli.commands import cmd_upload + + mock_exists.return_value = True + mock_getsize.return_value = 1024 + args = MagicMock() + args.file = "test.gcode" + args.dry_run = True + + mock_get_ftp = MagicMock(side_effect=OSError("FTP Error")) + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + mock_exit.side_effect = SystemExit(2) + + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_upload(args) + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) + # The dry-run now surfaces the real cause instead of a fixed, misleading + # "Could not reach printer." (a cert-pin mismatch must be distinguishable + # from an off printer) — see fix/audit-cli-json-camera. + mock_logger.error.assert_called_with("Dry run failed: could not reach printer: FTP Error") + + @patch("os.path.exists") + @patch("os.path.getsize") + @patch("time.sleep") + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.printer.logger") + @patch("builtins.open", new_callable=mock_open) + def test_cmd_upload_resume_offset( + self, mock_file, mock_logger, mock_get_printer, mock_sleep, mock_getsize, mock_exists + ): + from bambu_cli.commands import cmd_upload + + mock_exists.return_value = True + mock_getsize.return_value = 2048 + args = MagicMock() + args.file = "test.gcode" + args.dry_run = False + + mock_ftp1 = MagicMock() + mock_ftp1.storbinary.side_effect = OSError("Upload interrupted") + mock_ftp1.size.return_value = 1024 + + mock_ftp2 = MagicMock() + mock_ftp2.size.return_value = 2048 + + mock_get_ftp = MagicMock( + side_effect=[ + MagicMock(__enter__=MagicMock(return_value=mock_ftp1)), + MagicMock(__enter__=MagicMock(return_value=mock_ftp1)), + MagicMock(__enter__=MagicMock(return_value=mock_ftp2)), + ] + ) + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + + cmd_upload(args) + + mock_logger.info.assert_any_call("🔄 Resuming from 1KB...") + mock_file().seek.assert_called_with(1024) + mock_ftp2.storbinary.assert_called_with( + "STOR /model/test.gcode", mock_file(), blocksize=1048576, rest=1024, callback=None + ) + + @patch("os.path.exists") + @patch("os.path.getsize") + @patch("time.sleep") + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.logging_utils._BACKEND") + @patch("sys.exit") + @patch("builtins.open", new_callable=mock_open) + def test_cmd_upload_max_retries_exhausted( + self, mock_file, mock_exit, mock_logger, mock_get_printer, mock_sleep, mock_getsize, mock_exists + ): + from bambu_cli.commands import cmd_upload + + mock_exists.return_value = True + mock_getsize.return_value = 2048 + args = MagicMock() + args.file = "test.gcode" + args.dry_run = False + + mock_ftp = MagicMock() + mock_ftp.storbinary.side_effect = OSError("Upload always fails") + mock_ftp.size.side_effect = OSError("Can't get size") + + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + mock_exit.side_effect = SystemExit(2) + + with self.assertRaises((SystemExit, BambuError)) as cm: + cmd_upload(args) + + self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) + mock_logger.error.assert_called_with("❌ Upload failed after 4 attempts.") + +class TestBambuUploadRetry(unittest.TestCase): + @patch("bambu_cli.printer.get_printer") + @patch("bambu_cli.printer.logger") + @patch("os.path.exists") + @patch("os.path.getsize") + @patch("builtins.open", new_callable=mock_open) + @patch("bambu_cli.logging_utils._BACKEND") + @patch("time.sleep") + def test_cmd_upload_retry_success( + self, mock_sleep, mock_logger, mock_file_open, mock_getsize, mock_exists, mock_printer_logger, mock_get_printer + ): + from bambu_cli.commands import cmd_upload + + args = MagicMock() + args.file = "test.3mf" + args.dry_run = False + + mock_exists.return_value = True + mock_getsize.return_value = 2048 + + mock_ftp = MagicMock() + # Fail once, then succeed + mock_ftp.storbinary.side_effect = [OSError("Timeout"), None] + # First size() call is the mid-failure resume probe (mismatch keeps + # uploaded_bytes at 0); second is the post-success verification. + mock_ftp.size.side_effect = [0, 2048] + mock_get_ftp = MagicMock() + mock_get_ftp.return_value.__enter__.return_value = mock_ftp + printer = _test_printer() + printer.get_ftp_client = mock_get_ftp + mock_get_printer.return_value = printer + + cmd_upload(args) + + self.assertEqual(mock_ftp.storbinary.call_count, 2) + self.assertTrue( + any("⚠️ Upload attempt 1 failed" in call[0][0] for call in mock_printer_logger.warning.call_args_list) + ) + self.assertTrue( + any("✅ Uploaded test.3mf to printer" in call[0][0] for call in mock_logger.info.call_args_list) + ) diff --git a/tests/test_download_validation_boundary.py b/tests/test_download_validation_boundary.py new file mode 100644 index 0000000..7b78b57 --- /dev/null +++ b/tests/test_download_validation_boundary.py @@ -0,0 +1,196 @@ +"""Boundary tests for the download rejection path. + +``download/validation.py`` is in the mutation scope (`[tool.mutmut].only_mutate`), +but half its surface — the ``_reject_*`` functions that actually stop a bad +download — had no direct tests. They were only reached incidentally through +whole-command tests, which is why mutants inside them survived: nothing asserted +what they *do*. + +These tests drive each rejection through its real inputs and assert only what a +caller can observe: + +* whether it aborts at all (some inputs are deliberately ambiguous and must pass) +* the exit code +* the ``failed_step`` and the machine-readable fields in the JSON envelope +* that credentials in the URL are redacted before they reach that envelope + +Deliberately **not** asserted: the prose of the error message. Pinning message +text couples tests to copy-editing and is the kind of brittleness that makes a +suite expensive without making it safer — mutants that only change wording are +recorded as equivalent in docs/mutation-baseline.md rather than chased. + +Ground rules (docs/test-backlog.md): no network, no printer. +""" + +from __future__ import annotations + +import json +import sys +from argparse import Namespace +from unittest.mock import MagicMock + +import pytest + +_mock_mqtt = MagicMock() +sys.modules.setdefault("paho", _mock_mqtt) +sys.modules.setdefault("paho.mqtt", _mock_mqtt) +sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) + +from bambu_cli import utils # noqa: E402 +from bambu_cli.constants import EXIT_FILE_ERROR # noqa: E402 +from bambu_cli.download import validation as V # noqa: E402 +from bambu_cli.errors import BambuError # noqa: E402 + +URL = "https://example.com/model.rar" + + +@pytest.fixture(autouse=True) +def _reset_json_state(): + utils._JSON_EMITTED = False + utils._LAST_ERROR_PAYLOAD = None + yield + utils._JSON_EMITTED = False + utils._LAST_ERROR_PAYLOAD = None + + +def _args(**kw): + return Namespace(json=True, **kw) + + +def _payload(capsys): + return json.loads(capsys.readouterr().out) + + +# --- unsupported source extension ------------------------------------------- + + +@pytest.mark.parametrize("value", ["archive.rar", "notes.pdf", "/path/to/render.png", "a.tar", "b.7z"]) +def test_clearly_unsupported_extension_is_named(value): + """The extension is reported so a caller can say *which* type was refused.""" + assert V._known_unsupported_download_extension(value) is not None + + +@pytest.mark.parametrize( + "value", + [ + "model.stl", # supported + "model.3mf", # supported + "", # nothing to judge + None, + "https://example.com/download?id=123", # extensionless: ambiguous, must not guess + "model.unknownext", # unknown but not on the refuse-list + # Deliberately absent from the refuse-list: it names archive/document/ + # image types, not a security allowlist. Print-readiness is enforced + # downstream by _reject_non_print_ready. + "installer.exe", + ], +) +def test_ambiguous_or_supported_extensions_are_not_rejected(value): + """Guessing wrong here would refuse a legitimate download.""" + assert V._known_unsupported_download_extension(value) is None + + +def test_extension_is_read_after_percent_decoding(): + # A percent-encoded name must not smuggle a refused type past the check. + assert V._known_unsupported_download_extension("https://example.com/notes%2Epdf") is not None + + +def test_reject_unsupported_extension_aborts_with_file_error(capsys): + with pytest.raises(BambuError) as excinfo: + V._reject_unsupported_download_extension(_args(), URL, None, URL, "archive.rar") + assert getattr(excinfo.value, "exit_code", None) == EXIT_FILE_ERROR + + payload = _payload(capsys) + assert payload["status"] == "error" + assert payload["command"] == "download" + assert payload["failed_step"] == "validate" + assert payload["extension"] == ".rar" + + +def test_reject_unsupported_extension_honours_the_caller_step(capsys): + # The same refusal happens mid-download after a redirect; the step must say so. + with pytest.raises(BambuError): + V._reject_unsupported_download_extension(_args(), URL, None, URL, "archive.rar", failed_step="download") + assert _payload(capsys)["failed_step"] == "download" + + +def test_reject_unsupported_extension_is_a_no_op_for_supported_types(capsys): + V._reject_unsupported_download_extension(_args(), URL, None, URL, "model.stl") + assert capsys.readouterr().out == "" + + +def test_rejection_redacts_credentials_in_the_url(capsys): + # Username-only + IP host: exercises the userinfo-stripping path without + # writing a literal `user:pass@host` or email into the repo, which + # tests/privacy_smoke.py rejects. Same convention as the sibling tests in + # test_job.py and test_mqtt_print_and_setup.py. + creds = "http://user@127.0.0.1/archive.rar" + with pytest.raises(BambuError): + V._reject_unsupported_download_extension(_args(), creds, None, creds, "archive.rar") + emitted = capsys.readouterr().out + assert "user@" not in emitted, "userinfo leaked into the error envelope" + + +# --- unsupported content type ------------------------------------------------ + + +@pytest.mark.parametrize( + "content_type", + ["image/png", "image/jpeg", "IMAGE/PNG", "image/png; charset=binary", "application/pdf", "text/plain"], +) +def test_clearly_unsupported_content_types_are_named(content_type): + assert V._known_unsupported_content_type(content_type) is not None + + +@pytest.mark.parametrize( + "content_type", + [ + "", + None, + "application/octet-stream", + "model/stl", + # text/html must pass: it is the HTML-scrape path, where the page is + # parsed for a direct model-file link rather than refused. + "text/html", + ], +) +def test_ambiguous_content_types_are_allowed_through(content_type): + """Most servers send octet-stream for model files; refusing it breaks downloads.""" + assert V._known_unsupported_content_type(content_type) is None + + +def test_content_type_parameters_are_ignored_when_matching(): + assert V._known_unsupported_content_type("image/png; charset=utf-8") == "image/png" + + +def test_reject_unsupported_content_type_reports_the_download_step(capsys): + with pytest.raises(BambuError) as excinfo: + V._reject_unsupported_content_type(_args(), URL, None, URL, "image/png") + assert getattr(excinfo.value, "exit_code", None) == EXIT_FILE_ERROR + + payload = _payload(capsys) + # It failed after the request went out, so this is `download`, not `validate`. + assert payload["failed_step"] == "download" + assert payload["content_type"] == "image/png" + + +def test_reject_unsupported_content_type_is_a_no_op_when_ambiguous(capsys): + V._reject_unsupported_content_type(_args(), URL, None, URL, "application/octet-stream") + assert capsys.readouterr().out == "" + + +# --- the error envelope is recorded even without --json ---------------------- + + +def test_failure_detail_is_recorded_for_non_json_callers(capsys): + """`job` reads the last error payload to build its own envelope. + + Without --json nothing is printed, but the detail must still be captured or + a pipeline failure loses the reason it failed. + """ + with pytest.raises(BambuError): + V._reject_unsupported_download_extension(Namespace(json=False), URL, None, URL, "archive.rar") + assert capsys.readouterr().out == "" + assert utils._LAST_ERROR_PAYLOAD is not None + assert utils._LAST_ERROR_PAYLOAD["failed_step"] == "validate" + assert utils._LAST_ERROR_PAYLOAD["extension"] == ".rar" diff --git a/tests/test_json_contract_cli.py b/tests/test_json_contract_cli.py new file mode 100644 index 0000000..a3e1e15 --- /dev/null +++ b/tests/test_json_contract_cli.py @@ -0,0 +1,183 @@ +"""Entry-level contracts: --version, parser errors, missing subcommand, config errors, and the --confirm gate.""" + +from tests.json_contract_base import * # noqa: F401,F403 + + +# --------------------------------------------------------------------------- +# JsonArgumentParser bad-argument contract +# --------------------------------------------------------------------------- + + +def test_bad_argument_parse_error_shape(monkeypatch, tmp_path, capsys): + # slice requires a positional "file"; omit it under --json to trigger + # argparse's own error() path (JsonArgumentParser.error). + exc = run_main(monkeypatch, tmp_path, ["slice", "--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["error"]}, + "command": {"enum": ["slice"]}, + "failed_step": {"enum": ["parse"]}, + "exit_code": {"enum": [5]}, + "error": STR, + }, + }, + ) + assert capsys.readouterr().err.strip() == "" + + +def test_bad_argument_parse_error_shape_global_json_flag(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--json", "job"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert payload["status"] == "error" + assert payload["failed_step"] == "parse" + assert payload["command"] == "job" + + +# --------------------------------------------------------------------------- +# main(): missing-subcommand contract +# --------------------------------------------------------------------------- + + +def test_missing_subcommand_json_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["error"]}, + "command": {"enum": ["main"]}, + "failed_step": {"enum": ["parse"]}, + "exit_code": {"enum": [5]}, + "error": STR, + }, + }, + ) + + +def test_missing_subcommand_without_json_prints_usage_not_json(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, []) + assert exc is not None and exc.code == 5 + out, err = capsys.readouterr() + assert out.strip() == "" + assert "usage:" in err.lower() + + +# --------------------------------------------------------------------------- +# --version +# --------------------------------------------------------------------------- + + +def test_version_json_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--json", "--version"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["ok"]}, + "command": {"enum": ["version"]}, + "version": STR, + }, + }, + ) + assert payload["version"] == __import__("bambu_cli.constants", fromlist=["VERSION"]).VERSION + + +# --------------------------------------------------------------------------- +# config-error contract (printer-network command, no config, no --sim) +# --------------------------------------------------------------------------- + + +def test_config_error_shape_for_network_command(monkeypatch, tmp_path, capsys): + # Force the "never configured" state (default printer_ip 0.0.0.0) + # explicitly so this test doesn't depend on run order. + from bambu_cli import context + from bambu_cli.context import RuntimeContext + + context.set_current(RuntimeContext()) + exc = run_main(monkeypatch, tmp_path, ["status", "--json"]) + assert exc is not None and exc.code == 1 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("status")) + assert payload["failed_step"] == "config" + + +# --------------------------------------------------------------------------- +# Parser-driven --confirm gate: locks the whole refusal contract in one place. +# --------------------------------------------------------------------------- + +# subcommand -> (extra argv, payload key that must be False in the refusal) +PHYSICAL_COMMANDS = { + "print": (["ready.3mf"], "printed"), + "stop": ([], "stopped"), + "pause": ([], "paused"), + "resume": ([], "resumed"), + "gcode": (["M105"], "sent"), + "delete": (["old.3mf"], "deleted"), +} + +# Subcommands that expose --confirm but do NOT refuse without it: for job/send +# the download/slice/upload really happened, so they exit 0 with +# "uploaded_not_printed" (bambu_cli/job/orchestrate.py). Deliberate. +NON_REFUSING_CONFIRM_COMMANDS = {"job", "send"} + + +def _subcommands_with_confirm(): + parser = build_parser() + names = set() + for action in parser._actions: + if not isinstance(action, argparse._SubParsersAction): + continue + for name, subparser in action.choices.items(): + if any("--confirm" in a.option_strings for a in subparser._actions): + names.add(name) + return names + + +def test_confirm_flag_inventory_matches_physical_commands(): + """A new --confirm command must be classified here, or this fails.""" + assert _subcommands_with_confirm() == set(PHYSICAL_COMMANDS) | NON_REFUSING_CONFIRM_COMMANDS + + +@pytest.mark.parametrize("cmd", sorted(PHYSICAL_COMMANDS)) +def test_physical_commands_refuse_without_confirm(cmd, monkeypatch, tmp_path, capsys): + extra, false_key = PHYSICAL_COMMANDS[cmd] + exc = run_main(monkeypatch, tmp_path, ["--sim", cmd, *extra, "--json"]) + assert exc is not None and exc.code == 5, f"{cmd} must refuse with EXIT_COMMAND_ERROR" + payload = read_json(capsys) + assert payload["status"] == "confirmation_required" + assert payload["command"] == cmd + assert payload[false_key] is False + assert "--confirm" in payload["next_command"] + + +# --------------------------------------------------------------------------- +# tui: interactive-only error-envelope contract (mirrors go) +# --------------------------------------------------------------------------- + + +def test_tui_json_error_envelope_shape(monkeypatch, tmp_path, capsys): + # `plate tui --json` never launches the UI: it emits the standard error + # envelope (exit 5, failed_step parse) exactly like `go`. + exc = run_main(monkeypatch, tmp_path, ["--json", "tui"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("tui")) + assert payload["failed_step"] == "parse" + + +def test_tui_non_tty_stdin_exits_5(monkeypatch, tmp_path): + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + exc = run_main(monkeypatch, tmp_path, ["tui"]) + assert exc is not None and exc.code == 5 diff --git a/tests/test_json_contract_device.py b/tests/test_json_contract_device.py new file mode 100644 index 0000000..d8811ad --- /dev/null +++ b/tests/test_json_contract_device.py @@ -0,0 +1,187 @@ +"""Device-state contracts: light/pause/resume, and the stop/delete confirmation gate.""" + +from tests.json_contract_base import * # noqa: F401,F403 + + +# --------------------------------------------------------------------------- +# light / pause / resume +# --------------------------------------------------------------------------- + + +def test_light_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "light", "on", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["light_changed"]}, + "command": {"enum": ["light"]}, + "action": {"enum": ["on"]}, + "changed": {"enum": [True]}, + }, + }, + ) + + +def test_pause_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "pause", "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["paused"]}, + "command": {"enum": ["pause"]}, + "paused": {"enum": [True]}, + }, + }, + ) + + +def test_resume_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "resume", "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["resumed"]}, + "command": {"enum": ["resume"]}, + "resumed": {"enum": [True]}, + }, + }, + ) + + +def test_pause_confirmation_required_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "pause", "--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["confirmation_required"]}, + "command": {"enum": ["pause"]}, + "paused": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + assert payload["next_command"] == ["pause", "--confirm", "--json"] + + +def test_resume_confirmation_required_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "resume", "--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["confirmation_required"]}, + "command": {"enum": ["resume"]}, + "resumed": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + assert payload["next_command"] == ["resume", "--confirm", "--json"] + + +# --------------------------------------------------------------------------- +# stop / delete: confirmation-required contract (no --confirm) +# --------------------------------------------------------------------------- + + +def test_stop_confirmation_required_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "stop", "--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["confirmation_required"]}, + "command": {"enum": ["stop"]}, + "stopped": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + assert payload["next_command"] == ["stop", "--confirm", "--json"] + + +def test_stop_confirmed_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "stop", "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["stopped"]}, + "command": {"enum": ["stop"]}, + "stopped": {"enum": [True]}, + }, + }, + ) + + +def test_delete_confirmation_required_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "delete", "old.3mf", "--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["confirmation_required"]}, + "command": {"enum": ["delete"]}, + "file": {"enum": ["old.3mf"]}, + "deleted": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + assert payload["next_command"] == ["delete", "old.3mf", "--confirm", "--json"] + + +def test_delete_confirmed_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "delete", "old.3mf", "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["deleted"]}, + "command": {"enum": ["delete"]}, + "file": STR, + "deleted": {"enum": [True]}, + }, + }, + ) + + +def test_delete_unsafe_name_error_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "delete", "../evil.3mf", "--confirm", "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("delete")) + assert payload["failed_step"] == "validate" + + diff --git a/tests/test_json_contract_pipeline.py b/tests/test_json_contract_pipeline.py new file mode 100644 index 0000000..e38d3d2 --- /dev/null +++ b/tests/test_json_contract_pipeline.py @@ -0,0 +1,168 @@ +"""Pipeline contracts: job/send, slice, download.""" + +from tests.json_contract_base import * # noqa: F401,F403 + + +# --------------------------------------------------------------------------- +# job / send +# --------------------------------------------------------------------------- + + +def test_job_dry_run_local_shape(monkeypatch, tmp_path, capsys): + ready = make_ready_file(tmp_path) + exc = run_main(monkeypatch, tmp_path, ["job", str(ready), "--confirm", "--dry-run", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["dry_run_local_skipped"]}, + "command": {"enum": ["job"]}, + "would_upload": {"enum": [True]}, + "would_print": {"enum": [True]}, + }, + }, + ) + assert not payload.get("uploaded") and not payload.get("printed") + + +def test_job_sim_printed_success_shape(monkeypatch, tmp_path, capsys): + ready = make_ready_file(tmp_path) + exc = run_main(monkeypatch, tmp_path, ["--sim", "job", str(ready), "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["printed"]}, + "command": {"enum": ["job"]}, + "uploaded": {"enum": [True]}, + "printed": {"enum": [True]}, + }, + }, + ) + + +def test_job_sim_uploaded_not_printed_shape(monkeypatch, tmp_path, capsys): + ready = make_ready_file(tmp_path, name="ready2.3mf") + exc = run_main(monkeypatch, tmp_path, ["--sim", "job", str(ready), "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["uploaded_not_printed"]}, + "command": {"enum": ["job"]}, + "uploaded": {"enum": [True]}, + "printed": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + assert payload["next_command"][0] == "print" + + +def test_send_alias_uploaded_only_shape(monkeypatch, tmp_path, capsys): + ready = make_ready_file(tmp_path, name="ready3.3mf") + exc = run_main(monkeypatch, tmp_path, ["--sim", "send", str(ready), "--upload-only", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["uploaded"]}, + "command": {"enum": ["send"]}, + "uploaded": {"enum": [True]}, + "printed": {"enum": [False]}, + }, + }, + ) + + +def test_job_url_dry_run_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["job", "printables.com/model/12345-contract", "--dry-run", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["dry_run_url_skipped"]}, + "command": {"enum": ["job"]}, + "normalized_source": STR, + "would_download": {"enum": [True]}, + }, + }, + ) + + +def test_job_download_rejection_error_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["job", "https://example.com/archive.rar", "--dry-run", "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("job")) + assert payload["failed_step"] == "validate" + assert payload["extension"] == ".rar" + + +def test_job_local_zip_extract_error_shape(monkeypatch, tmp_path, capsys): + archive_path = tmp_path / "empty-bundle.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("readme.txt", "not a model") + exc = run_main(monkeypatch, tmp_path, ["job", str(archive_path), "--dry-run", "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("job")) + assert payload["failed_step"] == "extract" + + +# --------------------------------------------------------------------------- +# slice +# --------------------------------------------------------------------------- + + +def test_slice_missing_file_error_shape(monkeypatch, tmp_path, capsys): + missing = tmp_path / "missing.stl" + exc = run_main(monkeypatch, tmp_path, ["slice", str(missing), "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("slice")) + assert payload["failed_step"] == "validate" + + +# --------------------------------------------------------------------------- +# download +# --------------------------------------------------------------------------- + + +def test_download_rejects_non_model_error_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["download", "https://example.com/archive.rar", "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("download")) + assert payload["failed_step"] == "validate" + assert payload["extension"] == ".rar" + + +def test_download_credential_url_rejected_and_redacted_shape(monkeypatch, tmp_path, capsys): + # Assembled from pieces so the repo's privacy smoke doesn't flag a + # credential-bearing URL / email-like literal in this file. + credentialed_url = "https://" + "agent:" + "secret" + "@" + "example.com/model.stl" + exc = run_main(monkeypatch, tmp_path, ["download", credentialed_url, "--json"]) + assert exc is not None and exc.code == 5 + out = capsys.readouterr().out + assert "secret" not in out + payload = json.loads(out) + assert_shape(payload, base_error_spec("download")) + assert payload["source"] == "https://example.com/model.stl" + + diff --git a/tests/test_json_contract_print.py b/tests/test_json_contract_print.py new file mode 100644 index 0000000..2c17e8e --- /dev/null +++ b/tests/test_json_contract_print.py @@ -0,0 +1,170 @@ +"""Physical-action contracts: print, upload, gcode.""" + +from tests.json_contract_base import * # noqa: F401,F403 + + +# --------------------------------------------------------------------------- +# print +# --------------------------------------------------------------------------- + + +def test_print_confirmation_required_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "ready.3mf", "--json"]) + assert exc is not None and exc.code == 5 # refusal == EXIT_COMMAND_ERROR, same as stop/delete + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["confirmation_required"]}, + "command": {"enum": ["print"]}, + "file": STR, + "printed": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + + +def test_print_started_success_shape(monkeypatch, tmp_path, capsys): + # The simulated printer tracks uploaded files, so print requires an + # upload first (matches tests/agent_cli_smoke.py sim-job flow). + ready = make_ready_file(tmp_path) + upload_exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(ready), "--json"]) + assert upload_exc is None + capsys.readouterr() # discard the upload payload + exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "ready.3mf", "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["print_started"]}, + "command": {"enum": ["print"]}, + "file": STR, + "printed": {"enum": [True]}, + "dry_run": {"enum": [False]}, + }, + }, + ) + + +def test_print_unsafe_name_error_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "folder/model.3mf", "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("print")) + assert payload["failed_step"] == "validate" + assert payload["file"] == "folder/model.3mf" + + +def test_print_non_print_ready_extension_error_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "model.stl", "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("print")) + assert payload["failed_step"] == "validate" + + +# --------------------------------------------------------------------------- +# upload +# --------------------------------------------------------------------------- + + +def test_upload_success_shape(monkeypatch, tmp_path, capsys): + ready = make_ready_file(tmp_path) + exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(ready), "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["uploaded"]}, + "command": {"enum": ["upload"]}, + "file": STR, + "remote_name": STR, + "bytes": INT, + "uploaded": {"enum": [True]}, + }, + }, + ) + + +def test_upload_dry_run_shape(monkeypatch, tmp_path, capsys): + ready = make_ready_file(tmp_path) + exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(ready), "--dry-run", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["dry_run_ok"]}, + "command": {"enum": ["upload"]}, + "file": STR, + "remote_name": STR, + "bytes": INT, + "uploaded": {"enum": [False]}, + }, + }, + ) + + +def test_upload_missing_file_error_shape(monkeypatch, tmp_path, capsys): + missing = tmp_path / "missing.3mf" + exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(missing), "--json"]) + assert exc is not None and exc.code == 3 + payload = read_json(capsys) + assert_shape(payload, base_error_spec("upload")) + assert payload["failed_step"] == "validate" + + +# --------------------------------------------------------------------------- +# gcode +# --------------------------------------------------------------------------- + + +def test_gcode_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "gcode", "M104 S220", "--confirm", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["sent"]}, + "command": {"enum": ["gcode"]}, + "gcode": {"enum": ["M104 S220"]}, + "sent": {"enum": [True]}, + }, + }, + ) + + +def test_gcode_confirmation_required_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "gcode", "M104 S220", "--json"]) + assert exc is not None and exc.code == 5 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["confirmation_required"]}, + "command": {"enum": ["gcode"]}, + "gcode": {"enum": ["M104 S220"]}, + "sent": {"enum": [False]}, + "next_command": {"type": list, "items": STR}, + }, + }, + ) + assert payload["next_command"] == ["gcode", "M104 S220", "--confirm", "--json"] + + diff --git a/tests/test_json_contract_query.py b/tests/test_json_contract_query.py new file mode 100644 index 0000000..4e35648 --- /dev/null +++ b/tests/test_json_contract_query.py @@ -0,0 +1,58 @@ +"""Read-only query contracts: status, files.""" + +from tests.json_contract_base import * # noqa: F401,F403 + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +def test_status_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "status", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["ok"]}, + "command": {"enum": ["status"]}, + "printer": DICT, + "gcode_state": STR, + }, + }, + ) + assert payload["printer"].get("gcode_state") == "IDLE" + + +# --------------------------------------------------------------------------- +# files +# --------------------------------------------------------------------------- + + +def test_files_success_shape(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["--sim", "files", "--json"]) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["ok"]}, + "command": {"enum": ["files"]}, + "count": INT, + "files": { + "type": list, + "items": { + "type": dict, + "required": {"name": STR, "path": STR}, + }, + }, + }, + }, + ) + + diff --git a/tests/test_json_contract_setup.py b/tests/test_json_contract_setup.py new file mode 100644 index 0000000..7377275 --- /dev/null +++ b/tests/test_json_contract_setup.py @@ -0,0 +1,171 @@ +"""Local diagnostic contracts: doctor, preflight, setup.""" + +from tests.json_contract_base import * # noqa: F401,F403 + + +# --------------------------------------------------------------------------- +# doctor +# --------------------------------------------------------------------------- + + +def _write_valid_config(config_path): + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps( + { + "printer_ip": "127.0.0.1", + "serial": "CONTRACTTESTSERIAL", + "access_code": "CONTRACTTESTCODE", + "model": "P1P", + "nozzle": "0.4", + } + ), + encoding="utf-8", + ) + + +def test_doctor_success_shape(monkeypatch, tmp_path, capsys): + out_path = tmp_path / "caps.json" + config_path = tmp_path / "config" / "config.json" + _write_valid_config(config_path) + exc = run_main( + monkeypatch, tmp_path, ["--sim", "doctor", "--output", str(out_path), "--json"], config_path=str(config_path) + ) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["ok"]}, + "command": {"enum": ["doctor"]}, + "ok": {"enum": [True]}, + "output": STR, + "printer_ip": STR, + "capabilities": { + "type": dict, + "required": { + "model": STR, + "firmware": STR, + "serial": STR, + "capabilities": { + "type": dict, + "required": { + "ams": BOOL, + "chamber_light": BOOL, + "camera_snapshot": BOOL, + "camera_snapshot_note": STR, + }, + }, + }, + }, + }, + "optional": {"certificate_fingerprint": {"type": (str, type(None))}}, + }, + ) + # docs/api.md shows printer_ip: "" always; actual behavior redacts + # unless --verbose is passed (see bambu_cli/commands/doctor.py cmd_doctor). We are + # not passing --verbose here, so this locks the documented redaction. + assert payload["printer_ip"] == "" + + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- + + +def test_preflight_error_shape_no_config(monkeypatch, tmp_path, capsys): + exc = run_main(monkeypatch, tmp_path, ["preflight", "--json"]) + assert exc is not None and exc.code == 1 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["error"]}, + "command": {"enum": ["preflight"]}, + "exit_code": INT, + "ok": {"enum": [False]}, + "errors": INT, + "warnings": INT, + "strict": BOOL, + "checks": { + "type": list, + "items": { + "type": dict, + "required": {"name": STR, "status": {"enum": ["ok", "warning", "error"]}, "message": STR}, + }, + }, + }, + }, + ) + check_names = {c["name"] for c in payload["checks"]} + assert "config" in check_names + + +# --------------------------------------------------------------------------- +# setup (non-interactive) +# --------------------------------------------------------------------------- + + +def test_setup_success_shape(monkeypatch, tmp_path, capsys): + access_code_file = tmp_path / "secrets" / "access_code" + monkeypatch.setenv("BAMBU_SETUP_ACCESS_CODE", "contract-test-secret") + exc = run_main( + monkeypatch, + tmp_path, + [ + "setup", + "--printer-ip", + "printer.local", + "--serial", + "CONTRACTTESTSERIAL", + "--access-code-env", + "BAMBU_SETUP_ACCESS_CODE", + "--access-code-file", + str(access_code_file), + "--model", + "P1P", + "--nozzle", + "0.4", + "--json", + ], + ) + assert exc is None + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["configured"]}, + "command": {"enum": ["setup"]}, + }, + }, + ) + assert "CONTRACTTESTSERIAL" not in json.dumps(payload) + assert "contract-test-secret" not in json.dumps(payload) + + +def test_setup_missing_values_error_shape(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda self: False})()) + exc = run_main(monkeypatch, tmp_path, ["setup", "--json"]) + assert exc is not None and exc.code == 1 + payload = read_json(capsys) + assert_shape( + payload, + { + "type": dict, + "required": { + "status": {"enum": ["error"]}, + "command": {"enum": ["setup"]}, + "failed_step": {"enum": ["validate"]}, + "exit_code": {"enum": [1]}, + "missing": {"type": list, "items": STR}, + }, + }, + ) + + diff --git a/tests/test_json_contracts.py b/tests/test_json_contracts.py deleted file mode 100644 index f1be964..0000000 --- a/tests/test_json_contracts.py +++ /dev/null @@ -1,1052 +0,0 @@ -"""Contract regression tests for every `--json` payload the CLI documents in -docs/api.md / AGENTS.md as its agent-facing API surface. - -These are SHAPE-locking regression tests, not spec tests: where docs/api.md -disagrees with the actual current CLI output, we assert the actual output -(and flag the discrepancy in a comment) so a future accidental shape change -gets caught here. - -Ground rules followed (docs/test-backlog.md): -- Never touch a real printer/network: use `--sim` and a scratch config path. -- Patch runtime state on real modules (`bambu_cli.config.CONFIG_PATH`, etc.). -- Drive the real argv/parser path via `bambu_cli.cli.main()`, catch - `SystemExit`, capture stdout with `capsys`, and assert full payload shapes. -""" - -import argparse -import json -import sys -import zipfile -from unittest.mock import MagicMock - -import pytest - -# paho-mqtt is an optional/heavy dep; stub it the same way other tests do so -# importing the package never fails on environments without it installed. -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - -from bambu_cli import bambu # noqa: E402 -from bambu_cli import utils # noqa: E402 -from bambu_cli.cli import build_parser, main # noqa: E402 - - -# --------------------------------------------------------------------------- -# assert_shape: a small, self-contained schema-shape checker (no jsonschema -# dependency available/allowed). -# --------------------------------------------------------------------------- - - -def assert_shape(payload, spec, path="$"): - """Validate `payload` against a small hand-rolled spec. - - spec keys: - - "type": a type or tuple of types the value must be an instance of. - - "required": {key: subspec, ...} keys that MUST be present. - - "optional": {key: subspec, ...} keys that MAY be present; validated - only if present. - - "enum": iterable of allowed values for this exact node. - - "items": subspec applied to every element when type is list. - """ - assert isinstance(payload, dict) or "type" in spec or True, path - - if "type" in spec: - expected_type = spec["type"] - assert isinstance(payload, expected_type), ( - f"{path}: expected type {expected_type}, got {type(payload).__name__} ({payload!r})" - ) - - if "enum" in spec: - assert payload in spec["enum"], f"{path}: {payload!r} not in allowed enum {spec['enum']!r}" - - if isinstance(payload, dict): - required = spec.get("required", {}) - for key, subspec in required.items(): - assert key in payload, f"{path}: missing required key {key!r} in {sorted(payload.keys())}" - assert_shape(payload[key], subspec, path=f"{path}.{key}") - optional = spec.get("optional", {}) - for key, subspec in optional.items(): - if key in payload: - assert_shape(payload[key], subspec, path=f"{path}.{key}") - - if isinstance(payload, list) and "items" in spec: - for idx, item in enumerate(payload): - assert_shape(item, spec["items"], path=f"{path}[{idx}]") - - -ANY = {} -STR = {"type": str} -BOOL = {"type": bool} -INT = {"type": int} -NUM = {"type": (int, float)} -DICT = {"type": dict} -LIST = {"type": list} - -BASE_OK = {"type": dict, "required": {"status": {"enum": ["ok"]}, "command": STR}} - - -def base_error_spec(command=None, require_failed_step=True): - required = { - "status": {"enum": ["error"]}, - "command": {"enum": [command]} if command else STR, - "exit_code": INT, - "error": STR, - } - if require_failed_step: - required["failed_step"] = STR - return {"type": dict, "required": required} - - -# --------------------------------------------------------------------------- -# Harness -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _reset_json_state(): - utils._JSON_EMITTED = False - utils._LAST_ERROR_PAYLOAD = None - utils._LAST_DOWNLOAD_PAYLOAD = None - yield - utils._JSON_EMITTED = False - utils._LAST_ERROR_PAYLOAD = None - utils._LAST_DOWNLOAD_PAYLOAD = None - - -def run_main(monkeypatch, tmp_path, argv, config_path=None): - """Drive bambu_cli.cli.main() with a scratch config path so no real - on-disk config is ever touched, and return the SystemExit (or None).""" - import bambu_cli.cli as cli_mod - import bambu_cli.config as config_mod - - monkeypatch.setattr(sys, "argv", ["plate"] + list(argv)) - monkeypatch.setattr(config_mod, "CONFIG_PATH", config_path or str(tmp_path / "no-such-config" / "config.json")) - monkeypatch.setattr(cli_mod, "setup_logging", lambda *a, **k: None) - exc = None - try: - main() - except SystemExit as e: - exc = e - return exc - - -def read_json(capsys): - out = capsys.readouterr().out - return json.loads(out) - - -def make_ready_file(tmp_path, name="ready.3mf", content="simulated 3mf content"): - path = tmp_path / name - path.write_text(content, encoding="utf-8") - return path - - -# --------------------------------------------------------------------------- -# status -# --------------------------------------------------------------------------- - - -def test_status_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "status", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["ok"]}, - "command": {"enum": ["status"]}, - "printer": DICT, - "gcode_state": STR, - }, - }, - ) - assert payload["printer"].get("gcode_state") == "IDLE" - - -# --------------------------------------------------------------------------- -# files -# --------------------------------------------------------------------------- - - -def test_files_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "files", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["ok"]}, - "command": {"enum": ["files"]}, - "count": INT, - "files": { - "type": list, - "items": { - "type": dict, - "required": {"name": STR, "path": STR}, - }, - }, - }, - }, - ) - - -# --------------------------------------------------------------------------- -# light / pause / resume -# --------------------------------------------------------------------------- - - -def test_light_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "light", "on", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["light_changed"]}, - "command": {"enum": ["light"]}, - "action": {"enum": ["on"]}, - "changed": {"enum": [True]}, - }, - }, - ) - - -def test_pause_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "pause", "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["paused"]}, - "command": {"enum": ["pause"]}, - "paused": {"enum": [True]}, - }, - }, - ) - - -def test_resume_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "resume", "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["resumed"]}, - "command": {"enum": ["resume"]}, - "resumed": {"enum": [True]}, - }, - }, - ) - - -def test_pause_confirmation_required_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "pause", "--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["confirmation_required"]}, - "command": {"enum": ["pause"]}, - "paused": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - assert payload["next_command"] == ["pause", "--confirm", "--json"] - - -def test_resume_confirmation_required_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "resume", "--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["confirmation_required"]}, - "command": {"enum": ["resume"]}, - "resumed": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - assert payload["next_command"] == ["resume", "--confirm", "--json"] - - -# --------------------------------------------------------------------------- -# stop / delete: confirmation-required contract (no --confirm) -# --------------------------------------------------------------------------- - - -def test_stop_confirmation_required_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "stop", "--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["confirmation_required"]}, - "command": {"enum": ["stop"]}, - "stopped": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - assert payload["next_command"] == ["stop", "--confirm", "--json"] - - -def test_stop_confirmed_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "stop", "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["stopped"]}, - "command": {"enum": ["stop"]}, - "stopped": {"enum": [True]}, - }, - }, - ) - - -def test_delete_confirmation_required_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "delete", "old.3mf", "--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["confirmation_required"]}, - "command": {"enum": ["delete"]}, - "file": {"enum": ["old.3mf"]}, - "deleted": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - assert payload["next_command"] == ["delete", "old.3mf", "--confirm", "--json"] - - -def test_delete_confirmed_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "delete", "old.3mf", "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["deleted"]}, - "command": {"enum": ["delete"]}, - "file": STR, - "deleted": {"enum": [True]}, - }, - }, - ) - - -def test_delete_unsafe_name_error_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "delete", "../evil.3mf", "--confirm", "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("delete")) - assert payload["failed_step"] == "validate" - - -# --------------------------------------------------------------------------- -# print -# --------------------------------------------------------------------------- - - -def test_print_confirmation_required_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "ready.3mf", "--json"]) - assert exc is not None and exc.code == 5 # refusal == EXIT_COMMAND_ERROR, same as stop/delete - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["confirmation_required"]}, - "command": {"enum": ["print"]}, - "file": STR, - "printed": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - - -def test_print_started_success_shape(monkeypatch, tmp_path, capsys): - # The simulated printer tracks uploaded files, so print requires an - # upload first (matches tests/agent_cli_smoke.py sim-job flow). - ready = make_ready_file(tmp_path) - upload_exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(ready), "--json"]) - assert upload_exc is None - capsys.readouterr() # discard the upload payload - exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "ready.3mf", "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["print_started"]}, - "command": {"enum": ["print"]}, - "file": STR, - "printed": {"enum": [True]}, - "dry_run": {"enum": [False]}, - }, - }, - ) - - -def test_print_unsafe_name_error_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "folder/model.3mf", "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("print")) - assert payload["failed_step"] == "validate" - assert payload["file"] == "folder/model.3mf" - - -def test_print_non_print_ready_extension_error_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "print", "model.stl", "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("print")) - assert payload["failed_step"] == "validate" - - -# --------------------------------------------------------------------------- -# upload -# --------------------------------------------------------------------------- - - -def test_upload_success_shape(monkeypatch, tmp_path, capsys): - ready = make_ready_file(tmp_path) - exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(ready), "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["uploaded"]}, - "command": {"enum": ["upload"]}, - "file": STR, - "remote_name": STR, - "bytes": INT, - "uploaded": {"enum": [True]}, - }, - }, - ) - - -def test_upload_dry_run_shape(monkeypatch, tmp_path, capsys): - ready = make_ready_file(tmp_path) - exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(ready), "--dry-run", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["dry_run_ok"]}, - "command": {"enum": ["upload"]}, - "file": STR, - "remote_name": STR, - "bytes": INT, - "uploaded": {"enum": [False]}, - }, - }, - ) - - -def test_upload_missing_file_error_shape(monkeypatch, tmp_path, capsys): - missing = tmp_path / "missing.3mf" - exc = run_main(monkeypatch, tmp_path, ["--sim", "upload", str(missing), "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("upload")) - assert payload["failed_step"] == "validate" - - -# --------------------------------------------------------------------------- -# gcode -# --------------------------------------------------------------------------- - - -def test_gcode_success_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "gcode", "M104 S220", "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["sent"]}, - "command": {"enum": ["gcode"]}, - "gcode": {"enum": ["M104 S220"]}, - "sent": {"enum": [True]}, - }, - }, - ) - - -def test_gcode_confirmation_required_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--sim", "gcode", "M104 S220", "--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["confirmation_required"]}, - "command": {"enum": ["gcode"]}, - "gcode": {"enum": ["M104 S220"]}, - "sent": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - assert payload["next_command"] == ["gcode", "M104 S220", "--confirm", "--json"] - - -# --------------------------------------------------------------------------- -# doctor -# --------------------------------------------------------------------------- - - -def _write_valid_config(config_path): - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( - json.dumps( - { - "printer_ip": "127.0.0.1", - "serial": "CONTRACTTESTSERIAL", - "access_code": "CONTRACTTESTCODE", - "model": "P1P", - "nozzle": "0.4", - } - ), - encoding="utf-8", - ) - - -def test_doctor_success_shape(monkeypatch, tmp_path, capsys): - out_path = tmp_path / "caps.json" - config_path = tmp_path / "config" / "config.json" - _write_valid_config(config_path) - exc = run_main( - monkeypatch, tmp_path, ["--sim", "doctor", "--output", str(out_path), "--json"], config_path=str(config_path) - ) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["ok"]}, - "command": {"enum": ["doctor"]}, - "ok": {"enum": [True]}, - "output": STR, - "printer_ip": STR, - "capabilities": { - "type": dict, - "required": { - "model": STR, - "firmware": STR, - "serial": STR, - "capabilities": { - "type": dict, - "required": { - "ams": BOOL, - "chamber_light": BOOL, - "camera_snapshot": BOOL, - "camera_snapshot_note": STR, - }, - }, - }, - }, - }, - "optional": {"certificate_fingerprint": {"type": (str, type(None))}}, - }, - ) - # docs/api.md shows printer_ip: "" always; actual behavior redacts - # unless --verbose is passed (see bambu_cli/commands/doctor.py cmd_doctor). We are - # not passing --verbose here, so this locks the documented redaction. - assert payload["printer_ip"] == "" - - -# --------------------------------------------------------------------------- -# preflight -# --------------------------------------------------------------------------- - - -def test_preflight_error_shape_no_config(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["preflight", "--json"]) - assert exc is not None and exc.code == 1 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["error"]}, - "command": {"enum": ["preflight"]}, - "exit_code": INT, - "ok": {"enum": [False]}, - "errors": INT, - "warnings": INT, - "strict": BOOL, - "checks": { - "type": list, - "items": { - "type": dict, - "required": {"name": STR, "status": {"enum": ["ok", "warning", "error"]}, "message": STR}, - }, - }, - }, - }, - ) - check_names = {c["name"] for c in payload["checks"]} - assert "config" in check_names - - -# --------------------------------------------------------------------------- -# setup (non-interactive) -# --------------------------------------------------------------------------- - - -def test_setup_success_shape(monkeypatch, tmp_path, capsys): - access_code_file = tmp_path / "secrets" / "access_code" - monkeypatch.setenv("BAMBU_SETUP_ACCESS_CODE", "contract-test-secret") - exc = run_main( - monkeypatch, - tmp_path, - [ - "setup", - "--printer-ip", - "printer.local", - "--serial", - "CONTRACTTESTSERIAL", - "--access-code-env", - "BAMBU_SETUP_ACCESS_CODE", - "--access-code-file", - str(access_code_file), - "--model", - "P1P", - "--nozzle", - "0.4", - "--json", - ], - ) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["configured"]}, - "command": {"enum": ["setup"]}, - }, - }, - ) - assert "CONTRACTTESTSERIAL" not in json.dumps(payload) - assert "contract-test-secret" not in json.dumps(payload) - - -def test_setup_missing_values_error_shape(monkeypatch, tmp_path, capsys): - monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda self: False})()) - exc = run_main(monkeypatch, tmp_path, ["setup", "--json"]) - assert exc is not None and exc.code == 1 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["error"]}, - "command": {"enum": ["setup"]}, - "failed_step": {"enum": ["validate"]}, - "exit_code": {"enum": [1]}, - "missing": {"type": list, "items": STR}, - }, - }, - ) - - -# --------------------------------------------------------------------------- -# job / send -# --------------------------------------------------------------------------- - - -def test_job_dry_run_local_shape(monkeypatch, tmp_path, capsys): - ready = make_ready_file(tmp_path) - exc = run_main(monkeypatch, tmp_path, ["job", str(ready), "--confirm", "--dry-run", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["dry_run_local_skipped"]}, - "command": {"enum": ["job"]}, - "would_upload": {"enum": [True]}, - "would_print": {"enum": [True]}, - }, - }, - ) - assert not payload.get("uploaded") and not payload.get("printed") - - -def test_job_sim_printed_success_shape(monkeypatch, tmp_path, capsys): - ready = make_ready_file(tmp_path) - exc = run_main(monkeypatch, tmp_path, ["--sim", "job", str(ready), "--confirm", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["printed"]}, - "command": {"enum": ["job"]}, - "uploaded": {"enum": [True]}, - "printed": {"enum": [True]}, - }, - }, - ) - - -def test_job_sim_uploaded_not_printed_shape(monkeypatch, tmp_path, capsys): - ready = make_ready_file(tmp_path, name="ready2.3mf") - exc = run_main(monkeypatch, tmp_path, ["--sim", "job", str(ready), "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["uploaded_not_printed"]}, - "command": {"enum": ["job"]}, - "uploaded": {"enum": [True]}, - "printed": {"enum": [False]}, - "next_command": {"type": list, "items": STR}, - }, - }, - ) - assert payload["next_command"][0] == "print" - - -def test_send_alias_uploaded_only_shape(monkeypatch, tmp_path, capsys): - ready = make_ready_file(tmp_path, name="ready3.3mf") - exc = run_main(monkeypatch, tmp_path, ["--sim", "send", str(ready), "--upload-only", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["uploaded"]}, - "command": {"enum": ["send"]}, - "uploaded": {"enum": [True]}, - "printed": {"enum": [False]}, - }, - }, - ) - - -def test_job_url_dry_run_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["job", "printables.com/model/12345-contract", "--dry-run", "--json"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["dry_run_url_skipped"]}, - "command": {"enum": ["job"]}, - "normalized_source": STR, - "would_download": {"enum": [True]}, - }, - }, - ) - - -def test_job_download_rejection_error_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["job", "https://example.com/archive.rar", "--dry-run", "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("job")) - assert payload["failed_step"] == "validate" - assert payload["extension"] == ".rar" - - -def test_job_local_zip_extract_error_shape(monkeypatch, tmp_path, capsys): - archive_path = tmp_path / "empty-bundle.zip" - with zipfile.ZipFile(archive_path, "w") as archive: - archive.writestr("readme.txt", "not a model") - exc = run_main(monkeypatch, tmp_path, ["job", str(archive_path), "--dry-run", "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("job")) - assert payload["failed_step"] == "extract" - - -# --------------------------------------------------------------------------- -# slice -# --------------------------------------------------------------------------- - - -def test_slice_missing_file_error_shape(monkeypatch, tmp_path, capsys): - missing = tmp_path / "missing.stl" - exc = run_main(monkeypatch, tmp_path, ["slice", str(missing), "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("slice")) - assert payload["failed_step"] == "validate" - - -# --------------------------------------------------------------------------- -# download -# --------------------------------------------------------------------------- - - -def test_download_rejects_non_model_error_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["download", "https://example.com/archive.rar", "--json"]) - assert exc is not None and exc.code == 3 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("download")) - assert payload["failed_step"] == "validate" - assert payload["extension"] == ".rar" - - -def test_download_credential_url_rejected_and_redacted_shape(monkeypatch, tmp_path, capsys): - # Assembled from pieces so the repo's privacy smoke doesn't flag a - # credential-bearing URL / email-like literal in this file. - credentialed_url = "https://" + "agent:" + "secret" + "@" + "example.com/model.stl" - exc = run_main(monkeypatch, tmp_path, ["download", credentialed_url, "--json"]) - assert exc is not None and exc.code == 5 - out = capsys.readouterr().out - assert "secret" not in out - payload = json.loads(out) - assert_shape(payload, base_error_spec("download")) - assert payload["source"] == "https://example.com/model.stl" - - -# --------------------------------------------------------------------------- -# tui: interactive-only error-envelope contract (mirrors go) -# --------------------------------------------------------------------------- - - -def test_tui_json_error_envelope_shape(monkeypatch, tmp_path, capsys): - # `plate tui --json` never launches the UI: it emits the standard error - # envelope (exit 5, failed_step parse) exactly like `go`. - exc = run_main(monkeypatch, tmp_path, ["--json", "tui"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("tui")) - assert payload["failed_step"] == "parse" - - -def test_tui_non_tty_stdin_exits_5(monkeypatch, tmp_path): - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - exc = run_main(monkeypatch, tmp_path, ["tui"]) - assert exc is not None and exc.code == 5 - - -# --------------------------------------------------------------------------- -# JsonArgumentParser bad-argument contract -# --------------------------------------------------------------------------- - - -def test_bad_argument_parse_error_shape(monkeypatch, tmp_path, capsys): - # slice requires a positional "file"; omit it under --json to trigger - # argparse's own error() path (JsonArgumentParser.error). - exc = run_main(monkeypatch, tmp_path, ["slice", "--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["error"]}, - "command": {"enum": ["slice"]}, - "failed_step": {"enum": ["parse"]}, - "exit_code": {"enum": [5]}, - "error": STR, - }, - }, - ) - assert capsys.readouterr().err.strip() == "" - - -def test_bad_argument_parse_error_shape_global_json_flag(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--json", "job"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert payload["status"] == "error" - assert payload["failed_step"] == "parse" - assert payload["command"] == "job" - - -# --------------------------------------------------------------------------- -# main(): missing-subcommand contract -# --------------------------------------------------------------------------- - - -def test_missing_subcommand_json_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--json"]) - assert exc is not None and exc.code == 5 - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["error"]}, - "command": {"enum": ["main"]}, - "failed_step": {"enum": ["parse"]}, - "exit_code": {"enum": [5]}, - "error": STR, - }, - }, - ) - - -def test_missing_subcommand_without_json_prints_usage_not_json(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, []) - assert exc is not None and exc.code == 5 - out, err = capsys.readouterr() - assert out.strip() == "" - assert "usage:" in err.lower() - - -# --------------------------------------------------------------------------- -# --version -# --------------------------------------------------------------------------- - - -def test_version_json_shape(monkeypatch, tmp_path, capsys): - exc = run_main(monkeypatch, tmp_path, ["--json", "--version"]) - assert exc is None - payload = read_json(capsys) - assert_shape( - payload, - { - "type": dict, - "required": { - "status": {"enum": ["ok"]}, - "command": {"enum": ["version"]}, - "version": STR, - }, - }, - ) - assert payload["version"] == __import__("bambu_cli.constants", fromlist=["VERSION"]).VERSION - - -# --------------------------------------------------------------------------- -# config-error contract (printer-network command, no config, no --sim) -# --------------------------------------------------------------------------- - - -def test_config_error_shape_for_network_command(monkeypatch, tmp_path, capsys): - # Force the "never configured" state (default printer_ip 0.0.0.0) - # explicitly so this test doesn't depend on run order. - from bambu_cli import context - from bambu_cli.context import RuntimeContext - - context.set_current(RuntimeContext()) - exc = run_main(monkeypatch, tmp_path, ["status", "--json"]) - assert exc is not None and exc.code == 1 - payload = read_json(capsys) - assert_shape(payload, base_error_spec("status")) - assert payload["failed_step"] == "config" - - -# --------------------------------------------------------------------------- -# Parser-driven --confirm gate: locks the whole refusal contract in one place. -# --------------------------------------------------------------------------- - -# subcommand -> (extra argv, payload key that must be False in the refusal) -PHYSICAL_COMMANDS = { - "print": (["ready.3mf"], "printed"), - "stop": ([], "stopped"), - "pause": ([], "paused"), - "resume": ([], "resumed"), - "gcode": (["M105"], "sent"), - "delete": (["old.3mf"], "deleted"), -} - -# Subcommands that expose --confirm but do NOT refuse without it: for job/send -# the download/slice/upload really happened, so they exit 0 with -# "uploaded_not_printed" (bambu_cli/job/orchestrate.py). Deliberate. -NON_REFUSING_CONFIRM_COMMANDS = {"job", "send"} - - -def _subcommands_with_confirm(): - parser = build_parser() - names = set() - for action in parser._actions: - if not isinstance(action, argparse._SubParsersAction): - continue - for name, subparser in action.choices.items(): - if any("--confirm" in a.option_strings for a in subparser._actions): - names.add(name) - return names - - -def test_confirm_flag_inventory_matches_physical_commands(): - """A new --confirm command must be classified here, or this fails.""" - assert _subcommands_with_confirm() == set(PHYSICAL_COMMANDS) | NON_REFUSING_CONFIRM_COMMANDS - - -@pytest.mark.parametrize("cmd", sorted(PHYSICAL_COMMANDS)) -def test_physical_commands_refuse_without_confirm(cmd, monkeypatch, tmp_path, capsys): - extra, false_key = PHYSICAL_COMMANDS[cmd] - exc = run_main(monkeypatch, tmp_path, ["--sim", cmd, *extra, "--json"]) - assert exc is not None and exc.code == 5, f"{cmd} must refuse with EXIT_COMMAND_ERROR" - payload = read_json(capsys) - assert payload["status"] == "confirmation_required" - assert payload["command"] == cmd - assert payload[false_key] is False - assert "--confirm" in payload["next_command"] diff --git a/tests/test_printer_commands.py b/tests/test_printer_commands.py deleted file mode 100644 index 6a65e61..0000000 --- a/tests/test_printer_commands.py +++ /dev/null @@ -1,1333 +0,0 @@ -from tests.bambu_test_base import * # noqa: F401,F403 - - -class TestBambuCmdUploadEdgeCases(unittest.TestCase): - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_upload_invalid_filepath(self, mock_exit, mock_logger): - from bambu_cli.commands import cmd_upload - - args = MagicMock() - args.file = "-invalid.gcode" - mock_exit.side_effect = SystemExit(3) - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_upload(args) - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 3) - mock_logger.error.assert_called_with("Invalid filepath: -invalid.gcode") - - @patch("os.path.exists") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_upload_file_not_found(self, mock_exit, mock_logger, mock_exists): - from bambu_cli.commands import cmd_upload - - mock_exists.return_value = False - args = MagicMock() - args.file = "missing.gcode" - mock_exit.side_effect = SystemExit(3) - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_upload(args) - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 3) - mock_logger.error.assert_called_with("File not found: missing.gcode") - - @patch("os.path.exists") - @patch("os.path.getsize") - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_upload_dry_run_success(self, mock_logger, mock_get_printer, mock_getsize, mock_exists): - from bambu_cli.commands import cmd_upload - - mock_exists.return_value = True - mock_getsize.return_value = 1024 - args = MagicMock() - args.file = "test.gcode" - args.dry_run = True - - mock_ftp = MagicMock() - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - - cmd_upload(args) - mock_logger.info.assert_any_call(" ✅ Printer reachable.") - mock_logger.info.assert_any_call(" ✅ Local file test.gcode exists (1KB)") - - @patch("os.path.exists") - @patch("os.path.getsize") - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_upload_dry_run_fail(self, mock_exit, mock_logger, mock_get_printer, mock_getsize, mock_exists): - from bambu_cli.commands import cmd_upload - - mock_exists.return_value = True - mock_getsize.return_value = 1024 - args = MagicMock() - args.file = "test.gcode" - args.dry_run = True - - mock_get_ftp = MagicMock(side_effect=OSError("FTP Error")) - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - mock_exit.side_effect = SystemExit(2) - - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_upload(args) - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) - # The dry-run now surfaces the real cause instead of a fixed, misleading - # "Could not reach printer." (a cert-pin mismatch must be distinguishable - # from an off printer) — see fix/audit-cli-json-camera. - mock_logger.error.assert_called_with("Dry run failed: could not reach printer: FTP Error") - - @patch("os.path.exists") - @patch("os.path.getsize") - @patch("time.sleep") - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.printer.logger") - @patch("builtins.open", new_callable=mock_open) - def test_cmd_upload_resume_offset( - self, mock_file, mock_logger, mock_get_printer, mock_sleep, mock_getsize, mock_exists - ): - from bambu_cli.commands import cmd_upload - - mock_exists.return_value = True - mock_getsize.return_value = 2048 - args = MagicMock() - args.file = "test.gcode" - args.dry_run = False - - mock_ftp1 = MagicMock() - mock_ftp1.storbinary.side_effect = OSError("Upload interrupted") - mock_ftp1.size.return_value = 1024 - - mock_ftp2 = MagicMock() - mock_ftp2.size.return_value = 2048 - - mock_get_ftp = MagicMock( - side_effect=[ - MagicMock(__enter__=MagicMock(return_value=mock_ftp1)), - MagicMock(__enter__=MagicMock(return_value=mock_ftp1)), - MagicMock(__enter__=MagicMock(return_value=mock_ftp2)), - ] - ) - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - - cmd_upload(args) - - mock_logger.info.assert_any_call("🔄 Resuming from 1KB...") - mock_file().seek.assert_called_with(1024) - mock_ftp2.storbinary.assert_called_with( - "STOR /model/test.gcode", mock_file(), blocksize=1048576, rest=1024, callback=None - ) - - @patch("os.path.exists") - @patch("os.path.getsize") - @patch("time.sleep") - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - @patch("builtins.open", new_callable=mock_open) - def test_cmd_upload_max_retries_exhausted( - self, mock_file, mock_exit, mock_logger, mock_get_printer, mock_sleep, mock_getsize, mock_exists - ): - from bambu_cli.commands import cmd_upload - - mock_exists.return_value = True - mock_getsize.return_value = 2048 - args = MagicMock() - args.file = "test.gcode" - args.dry_run = False - - mock_ftp = MagicMock() - mock_ftp.storbinary.side_effect = OSError("Upload always fails") - mock_ftp.size.side_effect = OSError("Can't get size") - - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - mock_exit.side_effect = SystemExit(2) - - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_upload(args) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) - mock_logger.error.assert_called_with("❌ Upload failed after 4 attempts.") - - -class TestBambuCmdLight(unittest.TestCase): - @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_light_on(self, mock_logger, mock_send_command, mock_seq): - args = MagicMock() - args.action = "on" - - cmd_light(args) - - # Expected payload - expected_payload = json.dumps( - { - "system": { - "sequence_id": "0", - "command": "ledctrl", - "led_node": "chamber_light", - "led_mode": "on", - "led_on_time": 500, - "led_off_time": 500, - } - } - ) - - mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) - mock_logger.info.assert_called_once_with("💡 Light turned on") - - @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_light_off(self, mock_logger, mock_send_command, mock_seq): - args = MagicMock() - args.action = "off" - - cmd_light(args) - - # Expected payload - expected_payload = json.dumps( - { - "system": { - "sequence_id": "0", - "command": "ledctrl", - "led_node": "chamber_light", - "led_mode": "off", - "led_on_time": 500, - "led_off_time": 500, - } - } - ) - - mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) - mock_logger.info.assert_called_once_with("💡 Light turned off") - - -class TestBambuCmdResume(unittest.TestCase): - @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_resume(self, mock_logger, mock_send_command, mock_seq): - from bambu_cli.commands import cmd_resume - - args = MagicMock() - - cmd_resume(args) - - expected_payload = json.dumps({"print": {"sequence_id": "0", "command": "resume"}}) - mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) - mock_logger.info.assert_called_once_with("▶️ Print resumed") - - -class TestBambuCmdPause(unittest.TestCase): - @patch("bambu_cli.commands.device.get_sequence_id", return_value="0") - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_pause(self, mock_logger, mock_send_command, mock_seq): - from bambu_cli.commands import cmd_pause - - args = MagicMock() - - cmd_pause(args) - - expected_payload = json.dumps({"print": {"sequence_id": "0", "command": "pause"}}) - mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) - mock_logger.info.assert_called_once_with("⏸️ Print paused") - - -class TestBambuCmdStop(unittest.TestCase): - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_stop_without_confirm(self, mock_logger, mock_send_command): - # Create a mock args object with confirm=False - args = MagicMock() - args.confirm = False - - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_stop(args) - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 5) - - # Assert that send_command was NOT called - mock_send_command.assert_not_called() - - # Assert that the correct message was logged - mock_logger.warning.assert_called_once_with("⚠️ This will STOP the current print. Add --confirm to proceed.") - - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_stop_with_confirm(self, mock_logger, mock_send_command): - # Create a mock args object with confirm=True - args = MagicMock() - args.confirm = True - - cmd_stop(args) - - # Assert that send_command WAS called - mock_send_command.assert_called_once() - - -class TestBambuCmdFiles(unittest.TestCase): - def _printer_with_ftp(self, mock_get_printer, mock_get_ftp): - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - return printer - - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_files_success(self, mock_logger, mock_get_printer): - from bambu_cli.commands import cmd_files - - args = MagicMock() - args.json = False - mock_ftp = MagicMock() - mock_get_ftp = MagicMock() - # Mock the context manager behavior - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - mock_ftp.nlst.return_value = ["file1.3mf", "file2.3mf"] - self._printer_with_ftp(mock_get_printer, mock_get_ftp) - - cmd_files(args) - - mock_get_ftp.assert_called_once() - mock_ftp.nlst.assert_called_once_with("/model/") - # __exit__ should be called when using context manager - mock_get_ftp.return_value.__exit__.assert_called_once() - mock_logger.info.assert_any_call("📁 Files on printer:") - mock_logger.info.assert_any_call(" file1.3mf") - mock_logger.info.assert_any_call(" file2.3mf") - - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_files_empty(self, mock_logger, mock_get_printer): - from bambu_cli.commands import cmd_files - - args = MagicMock() - args.json = False - mock_ftp = MagicMock() - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - mock_ftp.nlst.return_value = [] - self._printer_with_ftp(mock_get_printer, mock_get_ftp) - - cmd_files(args) - - mock_get_ftp.assert_called_once() - mock_ftp.nlst.assert_called_once_with("/model/") - mock_get_ftp.return_value.__exit__.assert_called_once() - mock_logger.info.assert_called_with("No files on printer.") - - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_files_error(self, mock_exit, mock_logger, mock_get_printer): - from bambu_cli.commands import cmd_files - - args = MagicMock() - args.json = False - mock_ftp = MagicMock() - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - mock_ftp.nlst.side_effect = OSError("FTP Error") - self._printer_with_ftp(mock_get_printer, mock_get_ftp) - mock_exit.side_effect = SystemExit(2) - - with self.assertRaises((SystemExit, BambuError)): - cmd_files(args) - - mock_get_ftp.assert_called_once() - mock_ftp.nlst.assert_called_once_with("/model/") - mock_logger.error.assert_called_with("Error listing files: Failed to list files via printer API") - - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_files_get_ftp_error(self, mock_exit, mock_logger, mock_get_printer): - from bambu_cli.commands import cmd_files - - args = MagicMock() - args.json = False - mock_get_ftp = MagicMock(side_effect=OSError("Connection Failed")) - self._printer_with_ftp(mock_get_printer, mock_get_ftp) - mock_exit.side_effect = SystemExit(2) - - with self.assertRaises((SystemExit, BambuError)): - cmd_files(args) - - mock_get_ftp.assert_called_once() - mock_logger.error.assert_called_with("Error listing files: Failed to list files via printer API") - - -class TestBambuCmdDelete(unittest.TestCase): - @patch("bambu_cli.protocols.ftps.get_ftp") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_delete_no_confirm(self, mock_exit, mock_logger, mock_get_ftp): - from bambu_cli.commands import cmd_delete - - args = MagicMock() - args.file = "test.3mf" - args.confirm = False - - mock_exit.side_effect = SystemExit(5) - - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_delete(args) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 5) - mock_get_ftp.assert_not_called() - mock_logger.warning.assert_called_once_with( - "⚠️ This will DELETE 'test.3mf' from the printer. Add --confirm to proceed." - ) - - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_delete_success(self, mock_logger, mock_get_printer): - from bambu_cli.commands import cmd_delete - - args = MagicMock() - args.file = "test.3mf" - args.confirm = True - args.json = False - mock_ftp = MagicMock() - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - - cmd_delete(args) - - mock_get_ftp.assert_called_once() - mock_ftp.delete.assert_called_once_with("/model/test.3mf") - mock_logger.info.assert_called_once_with("🗑️ Deleted test.3mf from printer") - - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_cmd_delete_error(self, mock_exit, mock_logger, mock_get_printer): - from bambu_cli.commands import cmd_delete - - args = MagicMock() - args.file = "test.3mf" - args.confirm = True - args.json = False - mock_ftp = MagicMock() - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - mock_ftp.delete.side_effect = OSError("Delete Error") - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - mock_exit.side_effect = SystemExit(2) - - with self.assertRaises((SystemExit, BambuError)): - cmd_delete(args) - - mock_get_ftp.assert_called_once() - mock_ftp.delete.assert_called_once_with("/model/test.3mf") - mock_logger.error.assert_called_with("Delete failed: Delete operation failed in printer client.") - - -class TestBambuCmdGcode(unittest.TestCase): - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("sys.exit") - def test_cmd_gcode_send_command_fail(self, mock_exit, mock_send): - from bambu_cli.commands import cmd_gcode - - mock_send.return_value = False - args = MagicMock() - args.code = "G28" - args.confirm = True - args.json = False - - mock_exit.side_effect = SystemExit(2) - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_gcode(args) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) - - @patch("bambu_cli.commands.gcode.get_sequence_id", return_value="0") - @patch("bambu_cli.protocols.mqtt.send_command") - def test_cmd_gcode(self, mock_send_command, mock_seq): - from bambu_cli.commands import cmd_gcode - - args = MagicMock() - args.code = "M104 S220" - args.confirm = True - args.json = False - - cmd_gcode(args) - - # Expected payload - expected_payload = json.dumps({"print": {"sequence_id": "0", "command": "gcode_line", "param": "M104 S220"}}) - - mock_send_command.assert_called_once_with(ANY, expected_payload, timeout=None, retries=2) - - @patch("bambu_cli.protocols.mqtt.send_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_gcode_no_confirm_aborts_without_send(self, mock_logger, mock_send): - """Raw G-code is a physical action: require --confirm before MQTT send.""" - from bambu_cli.commands import cmd_gcode - from bambu_cli.constants import EXIT_COMMAND_ERROR - - args = MagicMock() - args.code = "G28" - args.confirm = False - args.json = False - - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_gcode(args) - - self.assertEqual( - getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), - EXIT_COMMAND_ERROR, - ) - mock_send.assert_not_called() - self.assertTrue(any("Add --confirm to proceed" in str(call) for call in mock_logger.warning.call_args_list)) - - @patch("bambu_cli.commands.gcode.get_sequence_id", return_value="0") - @patch("bambu_cli.protocols.mqtt.send_command") - def test_cmd_gcode_with_confirm_sends(self, mock_send, mock_seq): - from bambu_cli.commands import cmd_gcode - - mock_send.return_value = True - args = MagicMock() - args.code = "G28" - args.confirm = True - args.json = False - - cmd_gcode(args) - - mock_send.assert_called_once() - payload = mock_send.call_args[0][1] - self.assertIn("G28", payload) - - @patch("bambu_cli.protocols.mqtt.send_command") - def test_cmd_gcode_rejects_empty_code(self, mock_send): - from bambu_cli.commands import cmd_gcode - from bambu_cli.constants import EXIT_COMMAND_ERROR - - for bad in ("", " ", "\t"): - args = MagicMock() - args.code = bad - args.confirm = True - args.json = False - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_gcode(args) - self.assertEqual( - getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), - EXIT_COMMAND_ERROR, - ) - mock_send.assert_not_called() - - @patch("bambu_cli.protocols.mqtt.send_command") - def test_cmd_gcode_rejects_control_chars(self, mock_send): - """CR/LF/NUL in G-code can smuggle extra MQTT/serial commands.""" - from bambu_cli.commands import cmd_gcode - from bambu_cli.constants import EXIT_COMMAND_ERROR - - for bad in ("G28\nM104 S999", "G28\rM104", "G28\x00M104"): - args = MagicMock() - args.code = bad - args.confirm = True - args.json = False - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_gcode(args) - self.assertEqual( - getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), - EXIT_COMMAND_ERROR, - ) - mock_send.assert_not_called() - - -def _full_status_snapshot(**overrides): - """A pushall reply: carries every key `status` treats as always-present.""" - snapshot = { - "gcode_state": "RUNNING", - "mc_percent": 37, - "layer_num": 74, - "total_layer_num": 200, - "bed_temper": 60.0, - "bed_target_temper": 60.0, - "nozzle_temper": 219.9375, - "nozzle_target_temper": 220.0, - } - snapshot.update(overrides) - return snapshot - - -def _mqtt_message(print_payload): - msg = MagicMock() - msg.payload = json.dumps({"print": print_payload}).encode() - return msg - - -class TestBambuGetStatus(unittest.TestCase): - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_status_on_connect_rc_error(self, mock_logger, mock_create): - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create.return_value = mock_client - - def side_effect_connect(host, port, keepalive): - mock_client.on_connect(mock_client, None, None, 5) - - mock_client.connect.side_effect = side_effect_connect - - result = get_status(_test_printer(), timeout=0.1) - - self.assertIsNone(result) - mock_logger.error.assert_called_with("Connection failed: rc=5") - - @patch("bambu_cli.protocols.mqtt.get_status") - def test_cmd_status_connect_fail(self, mock_get_status): - from bambu_cli.commands import cmd_status - from bambu_cli.errors import PrinterConnectionError - - mock_get_status.return_value = None - - with self.assertRaises(PrinterConnectionError) as cm: - cmd_status(MagicMock()) - - self.assertEqual(str(cm.exception), "Could not connect to printer.") - self.assertEqual(cm.exception.exit_code, 2) - self.assertEqual(cm.exception.failed_step, "mqtt") - - @patch("bambu_cli.commands.status.emit_json") - @patch("bambu_cli.protocols.mqtt.get_status") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_status_json_output(self, mock_logger, mock_get_status, mock_emit_json): - from bambu_cli.commands import cmd_status - - mock_get_status.return_value = {"gcode_state": "IDLE"} - - args = MagicMock() - args.json = True - args.monitor = False - - cmd_status(args) - - mock_emit_json.assert_called_once() - payload = mock_emit_json.call_args[0][0] - self.assertEqual(payload["status"], "ok") - self.assertEqual(payload["command"], "status") - self.assertEqual(payload["gcode_state"], "IDLE") - - @patch("bambu_cli.commands.status.emit_json") - @patch("bambu_cli.protocols.mqtt.get_status") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_status_json_never_emits_partial_printer(self, mock_logger, mock_get_status, mock_emit_json): - """`--json status` must error rather than hand agents a printer map with no gcode_state.""" - from bambu_cli.commands import cmd_status - from bambu_cli.errors import PrinterStatusIncomplete - - mock_get_status.side_effect = PrinterStatusIncomplete( - "Printer returned only partial status updates, never a full snapshot (missing gcode_state).", - detail={"missing_keys": ["gcode_state"], "received_keys": ["nozzle_temper"]}, - ) - - args = MagicMock() - args.json = True - args.monitor = False - - with self.assertRaises(PrinterStatusIncomplete): - cmd_status(args) - - mock_emit_json.assert_not_called() - - @patch("bambu_cli.protocols.mqtt.get_status") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_status_running_formatting(self, mock_logger, mock_get_status): - from bambu_cli.commands import cmd_status - - mock_get_status.return_value = { - "gcode_state": "RUNNING", - "gcode_file": "test.gcode", - "mc_percent": 50, - "layer_num": 10, - "total_layer_num": 20, - "mc_remaining_time": 125, - "bed_temper": 60, - "bed_target_temper": 60, - "nozzle_temper": 220, - "nozzle_target_temper": 220, - "cooling_fan_speed": 100, - "wifi_signal": "-50dBm", - } - - args = MagicMock() - args.json = False - - cmd_status(args) - - mock_logger.info.assert_any_call(" File: test.gcode") - mock_logger.info.assert_any_call(" Progress: 50% | Layer 10/20") - mock_logger.info.assert_any_call(" Time left: 2h 5m") - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("time.sleep") - def test_get_status_success(self, mock_sleep, mock_create_mqtt): - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - snapshot = _full_status_snapshot(gcode_state="IDLE", mc_percent=0) - - def mock_connect(*args, **kwargs): - # Call on_connect directly - mock_client.on_connect(mock_client, None, None, 0) - - # Simulate the pushall reply arriving with 'print' data - mock_client.on_message(mock_client, None, _mqtt_message(snapshot)) - - mock_client.connect.side_effect = mock_connect - - result = get_status(_test_printer(), timeout=1) - - self.assertEqual(result, snapshot) - mock_create_mqtt.assert_called_once() - mock_client.connect.assert_called_once() - mock_client.subscribe.assert_called_once() - mock_client.publish.assert_called_once() - mock_client.disconnect.assert_called() - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("time.sleep") - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_status_timeout(self, mock_logger, mock_sleep, mock_create_mqtt): - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - - def mock_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - - mock_client.connect.side_effect = mock_connect - - # No status message ever arrives -> 3 attempts (2 retries) - result = get_status(_test_printer(), timeout=0.0001) - - self.assertIsNone(result) - self.assertEqual(mock_client.connect.call_count, 3) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("time.sleep") - def test_get_status_connection_failure(self, mock_sleep, mock_logger, mock_create_mqtt): - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - - # Mock connect to raise an exception - mock_client.connect.side_effect = OSError("Connection error") - - result = get_status(_test_printer(), timeout=0.0001) - - self.assertIsNone(result) - self.assertTrue( - any("MQTT status error: Connection error" in call[0][0] for call in mock_logger.error.call_args_list) - ) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - def test_get_status_ignore_non_print_messages(self, mock_create_mqtt): - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - snapshot = _full_status_snapshot(gcode_state="RUNNING") - - def mock_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - - # Send message without 'print' key - msg1 = MagicMock() - msg1.payload = json.dumps({"other": "data"}).encode() - mock_client.on_message(mock_client, None, msg1) - - # Send invalid JSON - msg2 = MagicMock() - msg2.payload = b"invalid json" - mock_client.on_message(mock_client, None, msg2) - - # Send valid print message - mock_client.on_message(mock_client, None, _mqtt_message(snapshot)) - - mock_client.connect.side_effect = mock_connect - - with patch("time.sleep"): - result = get_status(_test_printer(), timeout=1) - - self.assertEqual(result, snapshot) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("time.sleep") - def test_get_status_waits_through_delta_for_full_snapshot(self, mock_sleep, mock_create_mqtt): - """A delta arriving before the pushall reply must not be returned as the state. - - Reproduces the live-printer intermittent: mid-print the report topic - delivers a lone nozzle_temper reading first, and returning it hands - agents a `printer` object with no gcode_state. - """ - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - snapshot = _full_status_snapshot() - - def mock_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - # Incremental delta first — exactly what was observed at ~37%. - mock_client.on_message(mock_client, None, _mqtt_message({"nozzle_temper": 219.9375})) - # Then the pushall reply. - mock_client.on_message(mock_client, None, _mqtt_message(snapshot)) - - mock_client.connect.side_effect = mock_connect - - result = get_status(_test_printer(), timeout=1) - - self.assertIn("gcode_state", result) - self.assertEqual(result["gcode_state"], "RUNNING") - self.assertEqual(result["mc_percent"], 37) - self.assertEqual(result["total_layer_num"], 200) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("time.sleep") - def test_get_status_merges_delta_over_earlier_snapshot(self, mock_sleep, mock_create_mqtt): - """Later values win when a delta follows the snapshot in the same window.""" - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - - def mock_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - # Snapshot missing one required key, so the wait continues... - partial_snapshot = _full_status_snapshot() - del partial_snapshot["bed_temper"] - mock_client.on_message(mock_client, None, _mqtt_message(partial_snapshot)) - # ...and the next delta both completes and freshens the state. - mock_client.on_message(mock_client, None, _mqtt_message({"bed_temper": 61.0, "mc_percent": 38})) - - mock_client.connect.side_effect = mock_connect - - result = get_status(_test_printer(), timeout=1) - - self.assertEqual(result["bed_temper"], 61.0) - self.assertEqual(result["mc_percent"], 38) - self.assertEqual(result["gcode_state"], "RUNNING") - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("time.sleep") - def test_get_status_deltas_only_raises_instead_of_returning_partial( - self, mock_sleep, mock_logger, mock_create_mqtt - ): - """If no full snapshot ever arrives, error clearly rather than emit a partial.""" - from bambu_cli.errors import PrinterStatusIncomplete - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - - def mock_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - mock_client.on_message(mock_client, None, _mqtt_message({"nozzle_temper": 219.9375})) - - mock_client.connect.side_effect = mock_connect - - with self.assertRaises(PrinterStatusIncomplete) as cm: - get_status(_test_printer(), timeout=0.05, retries=1) - - self.assertEqual(cm.exception.exit_code, 6) - self.assertEqual(cm.exception.failed_step, "status") - self.assertIn("gcode_state", cm.exception.detail["missing_keys"]) - self.assertEqual(cm.exception.detail["received_keys"], ["nozzle_temper"]) - # Every attempt re-issues pushall rather than settling for the delta. - self.assertEqual(mock_client.connect.call_count, 2) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("time.sleep") - def test_get_status_liveness_probe_accepts_partial(self, mock_sleep, mock_create_mqtt): - """doctor / --dry-run only prove MQTT works, so a delta is good enough.""" - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - - def mock_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - mock_client.on_message(mock_client, None, _mqtt_message({"nozzle_temper": 219.9375})) - - mock_client.connect.side_effect = mock_connect - - result = get_status(_test_printer(), timeout=1, require_complete=False) - - self.assertEqual(result, {"nozzle_temper": 219.9375}) - mock_client.connect.assert_called_once() - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("time.sleep") - def test_get_status_exception(self, mock_sleep, mock_logger, mock_create_mqtt): - from bambu_cli.protocols.mqtt import get_status - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - mock_client.connect.side_effect = OSError("Network error") - - result = get_status(_test_printer(), timeout=1) - - self.assertIsNone(result) - self.assertTrue( - any("MQTT status error: Network error" in call[0][0] for call in mock_logger.error.call_args_list) - ) - - -class TestBambuCmdPrint(unittest.TestCase): - @patch("bambu_cli.protocols.mqtt.get_status") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_execute_print_command_dry_run_file_not_found(self, mock_exit, mock_logger, mock_get_status): - from bambu_cli.protocols.mqtt import execute_print_command - - mock_ftp = MagicMock() - mock_ftp.nlst.return_value = ["other.3mf"] - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - - mock_exit.side_effect = SystemExit(3) - with self.assertRaises((SystemExit, BambuError)) as cm: - execute_print_command(printer, "payload", "missing.3mf", dry_run=True) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 3) - mock_logger.error.assert_any_call(" ❌ File missing.3mf NOT found on printer. Upload it first.") - - @patch("bambu_cli.protocols.mqtt.get_status") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_execute_print_command_dry_run_mqtt_fail(self, mock_exit, mock_logger, mock_get_status): - from bambu_cli.protocols.mqtt import execute_print_command - - mock_ftp = MagicMock() - mock_ftp.nlst.return_value = ["test.3mf"] - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - - mock_get_status.return_value = None - - mock_exit.side_effect = SystemExit(2) - with self.assertRaises((SystemExit, BambuError)) as cm: - execute_print_command(printer, "payload", "test.3mf", dry_run=True) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) - mock_logger.error.assert_any_call(" ❌ MQTT connection failed.") - - @patch("bambu_cli.protocols.mqtt.get_status") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_execute_print_command_dry_run_exception(self, mock_exit, mock_logger, mock_get_status): - from bambu_cli.protocols.mqtt import execute_print_command - - printer = _test_printer() - printer.get_ftp_client = MagicMock(side_effect=OSError("FTP Error")) - - mock_exit.side_effect = SystemExit(2) - with self.assertRaises((SystemExit, BambuError)) as cm: - execute_print_command(printer, "payload", "test.3mf", dry_run=True) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2) - mock_logger.error.assert_any_call("Dry run failed: FTP Error") - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.protocols.mqtt.time.sleep") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - def test_execute_print_command_non_sd_error(self, mock_exit, mock_logger, mock_sleep, mock_create): - from bambu_cli.protocols.mqtt import execute_print_command - - mock_client = MagicMock() - mock_create.return_value = mock_client - - def fake_connect(ip, port, keepalive): - # simulate receiving message with error 1234 - msg = MagicMock() - msg.payload = b'{"print": {"print_error": 1234}}' - mock_client.on_message(mock_client, None, msg) - - mock_client.connect.side_effect = fake_connect - - mock_exit.side_effect = SystemExit(4) - - with self.assertRaises((SystemExit, BambuError)) as cm: - execute_print_command(_test_printer(), "payload", "test.3mf", dry_run=False) - - self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 4) - mock_logger.error.assert_called_with("Print failed with error code 1234 (hex 0x000004D2)") - - def test_generate_print_payload(self): - from bambu_cli.job import generate_print_payload - import json - - basename = "test_model.gcode" - payload = generate_print_payload(basename) - - parsed = json.loads(payload) - self.assertIn("print", parsed) - self.assertEqual(parsed["print"]["subtask_name"], "test_model.gcode") - self.assertEqual(parsed["print"]["url"], "file:///sdcard/model/test_model.gcode") - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("time.sleep") - def test_execute_print_command_success(self, mock_sleep, mock_logger, mock_create_mqtt): - from bambu_cli.protocols.mqtt import execute_print_command - import json - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - - # Simulate on_connect - def trigger_on_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - msg = MagicMock() - msg.payload = b'{"print": {"command": "project_file"}}' - mock_client.on_message(mock_client, None, msg) - - mock_client.connect.side_effect = trigger_on_connect - - payload = '{"test": "payload"}' - basename = "test_model.gcode" - - printer = _test_printer() - execute_print_command(printer, payload, basename) - - mock_create_mqtt.assert_called_once_with(printer, "bambu_print") - mock_client.connect.assert_called_once() - mock_client.loop_start.assert_called_once() - mock_client.loop_stop.assert_called_once() - mock_client.disconnect.assert_called_once() - - # Check success log - self.assertTrue(any(f"🖨️ Print started: {basename}" in call[0][0] for call in mock_logger.info.call_args_list)) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("time.sleep") - @patch("sys.exit") - def test_execute_print_command_with_error(self, mock_exit, mock_sleep, mock_logger, mock_create_mqtt): - from bambu_cli.protocols.mqtt import execute_print_command - import json - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - mock_exit.side_effect = SystemExit(3) - - # Simulate receiving an error message - def trigger_on_connect(*args, **kwargs): - mock_client.on_connect(mock_client, None, None, 0) - # Simulate on_message with error code - msg = MagicMock() - msg.payload = json.dumps({"print": {"print_error": 83935248}}).encode() - mock_client.on_message(mock_client, None, msg) - - mock_client.connect.side_effect = trigger_on_connect - - payload = '{"test": "payload"}' - basename = "test_model.gcode" - - with self.assertRaises((SystemExit, BambuError)): - execute_print_command(_test_printer(), payload, basename) - - self.assertTrue( - any("Print failed with error code 83935248" in call[0][0] for call in mock_logger.error.call_args_list) - ) - self.assertTrue( - any("File not found on printer SD card" in call[0][0] for call in mock_logger.info.call_args_list) - ) - - @patch("bambu_cli.protocols.mqtt.create_mqtt_client") - @patch("bambu_cli.logging_utils._BACKEND") - @patch("sys.exit") - @patch("time.sleep") - def test_execute_print_command_exception(self, mock_sleep, mock_exit, mock_logger, mock_create_mqtt): - from bambu_cli.protocols.mqtt import execute_print_command - import json - - mock_client = MagicMock() - mock_create_mqtt.return_value = mock_client - mock_client.connect.side_effect = OSError("Connection refused") - mock_exit.side_effect = SystemExit(2) - - payload = '{"test": "payload"}' - basename = "test_model.gcode" - - with self.assertRaises((SystemExit, BambuError)): - execute_print_command(_test_printer(), payload, basename) - - self.assertTrue(any("Error: Connection refused" in call[0][0] for call in mock_logger.error.call_args_list)) - - @patch("bambu_cli.job.generate_print_payload") - @patch("bambu_cli.protocols.mqtt.execute_print_command") - @patch("bambu_cli.logging_utils._BACKEND") - def test_cmd_print_no_confirm(self, mock_logger, mock_execute, mock_generate): - from bambu_cli.commands import cmd_print - - args = MagicMock() - args.confirm = False - args.file = "test.gcode" - args.dry_run = False - args.ams_mapping = None - args.use_ams = False - - with self.assertRaises((SystemExit, BambuError)) as cm: - cmd_print(args) - self.assertEqual(cm.exception.exit_code, 5) - - mock_generate.assert_not_called() - mock_execute.assert_not_called() - - self.assertTrue( - any( - "⚠️ This will START a print. Add --confirm to proceed." in call[0][0] - for call in mock_logger.warning.call_args_list - ) - ) - - @patch("bambu_cli.commands.print_cmd.generate_print_payload") - @patch("bambu_cli.protocols.mqtt.execute_print_command") - def test_cmd_print_with_confirm(self, mock_execute, mock_generate): - from bambu_cli.commands import cmd_print - - args = MagicMock() - args.confirm = True - args.file = "test.gcode" - args.dry_run = False - args.ams_mapping = None - args.use_ams = False - args.timelapse = False - args.skip_bed_leveling = True - args.skip_flow_cali = True - - mock_generate.return_value = "test_payload" - - cmd_print(args) - - mock_generate.assert_called_once_with( - "test.gcode", use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=False, flow_cali=False - ) - mock_execute.assert_called_once_with(ANY, "test_payload", "test.gcode", dry_run=False) - - -class TestBambuUploadRetry(unittest.TestCase): - @patch("bambu_cli.printer.get_printer") - @patch("bambu_cli.printer.logger") - @patch("os.path.exists") - @patch("os.path.getsize") - @patch("builtins.open", new_callable=mock_open) - @patch("bambu_cli.logging_utils._BACKEND") - @patch("time.sleep") - def test_cmd_upload_retry_success( - self, mock_sleep, mock_logger, mock_file_open, mock_getsize, mock_exists, mock_printer_logger, mock_get_printer - ): - from bambu_cli.commands import cmd_upload - - args = MagicMock() - args.file = "test.3mf" - args.dry_run = False - - mock_exists.return_value = True - mock_getsize.return_value = 2048 - - mock_ftp = MagicMock() - # Fail once, then succeed - mock_ftp.storbinary.side_effect = [OSError("Timeout"), None] - # First size() call is the mid-failure resume probe (mismatch keeps - # uploaded_bytes at 0); second is the post-success verification. - mock_ftp.size.side_effect = [0, 2048] - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - mock_get_printer.return_value = printer - - cmd_upload(args) - - self.assertEqual(mock_ftp.storbinary.call_count, 2) - self.assertTrue( - any("⚠️ Upload attempt 1 failed" in call[0][0] for call in mock_printer_logger.warning.call_args_list) - ) - self.assertTrue( - any("✅ Uploaded test.3mf to printer" in call[0][0] for call in mock_logger.info.call_args_list) - ) - - -class TestMonitorStatusStreaming(unittest.TestCase): - """`status --monitor --json` streams one NDJSON event per change (agent contract).""" - - def test_status_event_shape_and_coercion(self): - from bambu_cli.protocols.mqtt import _status_event - - p = { - "gcode_state": "RUNNING", - "mc_percent": "42", # firmware sometimes sends numbers as strings - "layer_num": 10, - "total_layer_num": 200, - "mc_remaining_time": "33", - "nozzle_temper": 220, - "bed_temper": 60, - "gcode_file": "model.gcode", - } - ev = _status_event(p, "update") - self.assertEqual(ev["event"], "update") - self.assertEqual(ev["command"], "status") - self.assertEqual(ev["gcode_state"], "RUNNING") - self.assertEqual(ev["mc_percent"], 42) # coerced to int - self.assertEqual(ev["mc_remaining_time"], 33) # coerced to int - self.assertEqual(ev["layer_num"], 10) - self.assertEqual(ev["total_layer_num"], 200) - self.assertEqual(ev["gcode_file"], "model.gcode") - # Missing/garbage numeric fields degrade to 0 rather than raising. - self.assertEqual(_status_event({}, "update")["mc_percent"], 0) - self.assertEqual(_status_event({"mc_percent": "?"}, "update")["mc_percent"], 0) - - def test_sim_monitor_streams_ndjson_events(self): - import contextlib - import io - import json - import types - - from bambu_cli.printer import get_printer - from bambu_cli.protocols import mqtt - - args = types.SimpleNamespace(json=True, monitor=True, sim=True) - with settings_ctx(simulation=True), patch.object(mqtt.time, "sleep"): - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - mqtt.monitor_status(args, get_printer()) - - events = [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] - self.assertEqual( - [(e["event"], e["gcode_state"], e["mc_percent"]) for e in events], - [("update", "PREPARE", 0), ("update", "RUNNING", 50), ("terminal", "FINISH", 100)], - ) - # Every streamed line is a self-contained one-line JSON object (NDJSON). - for line in buf.getvalue().splitlines(): - if line.strip(): - self.assertNotIn("\n", line) - obj = json.loads(line) - self.assertEqual(obj["command"], "status") - - -class TestBambuDownloadFile(unittest.TestCase): - """download_file streams to a temp sibling then atomically replaces, so a - failed transfer never corrupts an existing file at local_path.""" - - def _printer_with_ftp(self, mock_ftp): - mock_get_ftp = MagicMock() - mock_get_ftp.return_value.__enter__.return_value = mock_ftp - printer = _test_printer() - printer.get_ftp_client = mock_get_ftp - return printer - - def test_download_file_success_writes_content_no_temp_left(self): - import tempfile - - d = tempfile.mkdtemp() - local = os.path.join(d, "out.gcode") - content = b"new content" - mock_ftp = MagicMock() - mock_ftp.retrbinary.side_effect = lambda cmd, cb, blocksize=None: cb(content) - mock_ftp.size.return_value = len(content) - - ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) - - self.assertTrue(ok) - with open(local, "rb") as f: - self.assertEqual(f.read(), content) - self.assertEqual([p for p in os.listdir(d) if p.endswith(".part")], []) - - def test_download_file_failure_preserves_existing_and_cleans_temp(self): - import ftplib - import tempfile - - d = tempfile.mkdtemp() - local = os.path.join(d, "out.gcode") - with open(local, "wb") as f: - f.write(b"original good file") - - mock_ftp = MagicMock() - mock_ftp.retrbinary.side_effect = ftplib.error_temp("connection dropped") - - ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) - - self.assertFalse(ok) - with open(local, "rb") as f: - self.assertEqual(f.read(), b"original good file") # untouched - self.assertEqual([p for p in os.listdir(d) if p.endswith(".part")], []) - - def test_download_file_truncated_vs_remote_size_fails_without_replace(self): - """A short RETR must not replace local_path when remote SIZE is larger. - - Bambu FTPS skips TLS close-notify on the data channel, so a dropped - transfer can still return from retrbinary; size verification is required. - """ - import tempfile - - d = tempfile.mkdtemp() - local = os.path.join(d, "out.gcode") - with open(local, "wb") as f: - f.write(b"original good file") - - mock_ftp = MagicMock() - # RETR writes only 4 bytes, but SIZE claims 100. - mock_ftp.retrbinary.side_effect = lambda cmd, cb, blocksize=None: cb(b"trunc") - mock_ftp.size.return_value = 100 - - ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) - - self.assertFalse(ok) - with open(local, "rb") as f: - self.assertEqual(f.read(), b"original good file") # not replaced - self.assertEqual([p for p in os.listdir(d) if p.endswith(".part")], []) - - def test_download_file_size_match_succeeds(self): - import tempfile - - d = tempfile.mkdtemp() - local = os.path.join(d, "out.gcode") - content = b"full content here" - - mock_ftp = MagicMock() - mock_ftp.retrbinary.side_effect = lambda cmd, cb, blocksize=None: cb(content) - mock_ftp.size.return_value = len(content) - - ok = self._printer_with_ftp(mock_ftp).download_file("/model/out.gcode", local) - - self.assertTrue(ok) - with open(local, "rb") as f: - self.assertEqual(f.read(), content) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_snapshot_output.py b/tests/test_snapshot_output.py new file mode 100644 index 0000000..14b6bc5 --- /dev/null +++ b/tests/test_snapshot_output.py @@ -0,0 +1,191 @@ +"""Snapshot output handling: non-colliding filenames and the JSON metadata envelope.""" + +import hashlib + +from tests.bambu_test_base import * # noqa: F401,F403 +from bambu_cli.errors import BambuError + + +class TestSnapshotUniqueNaming(unittest.TestCase): + """--unique flag produces timestamped filenames without wall-clock dependency.""" + + def _snap_args(self, output=None, unique=False): + args = MagicMock() + args.output = output + args.unique = unique + args.json = False + return args + + def test_unique_flag_no_output_uses_timestamp(self): + """With --unique and no --output, filename is printer_snapshot_.jpg.""" + import datetime + from bambu_cli.protocols.camera import _utc_stamp + + fixed_dt = datetime.datetime(2026, 7, 24, 19, 15, 30, tzinfo=datetime.timezone.utc) + stamp = _utc_stamp(fixed_dt) + self.assertEqual(stamp, "20260724T191530Z") + + from bambu_cli.commands import cmd_snapshot + + saved_paths = [] + + def _fake_write(path, data): + saved_paths.append(path) + + args = self._snap_args(output=None, unique=True) + + with ( + patch("bambu_cli.protocols.camera._write_snapshot_atomic", side_effect=_fake_write), + patch("bambu_cli.logging_utils._BACKEND", MagicMock()), + patch("os.path.getsize", return_value=1024), + patch("bambu_cli.protocols.camera._ensure_parent_dir"), + ): + cmd_snapshot( + args, + grab_frame=lambda printer: b"\xff\xd8\xff\xd9", + now=fixed_dt, + ) + + self.assertEqual(len(saved_paths), 1) + self.assertIn("20260724T191530Z", saved_paths[0]) + self.assertTrue(saved_paths[0].endswith(".jpg")) + self.assertIn("printer_snapshot_", saved_paths[0]) + + def test_unique_flag_with_output_inserts_timestamp_before_ext(self): + """With --unique and --output cam.jpg, result is cam_.jpg.""" + import datetime + from bambu_cli.commands import cmd_snapshot + + fixed_dt = datetime.datetime(2026, 7, 24, 19, 15, 30, tzinfo=datetime.timezone.utc) + saved_paths = [] + + def _fake_write(path, data): + saved_paths.append(path) + + args = self._snap_args(output="cam.jpg", unique=True) + + with ( + patch("bambu_cli.protocols.camera._write_snapshot_atomic", side_effect=_fake_write), + patch("bambu_cli.logging_utils._BACKEND", MagicMock()), + patch("os.path.getsize", return_value=1024), + patch("bambu_cli.protocols.camera._ensure_parent_dir"), + ): + cmd_snapshot( + args, + grab_frame=lambda printer: b"\xff\xd8\xff\xd9", + now=fixed_dt, + ) + + self.assertEqual(len(saved_paths), 1) + self.assertTrue(saved_paths[0].endswith("20260724T191530Z.jpg")) + self.assertTrue(saved_paths[0].startswith("cam_") or "cam_" in saved_paths[0]) + + def test_no_unique_flag_uses_default_name(self): + """Without --unique, saves to the given --output name unchanged.""" + from bambu_cli.commands import cmd_snapshot + + saved_paths = [] + + def _fake_write(path, data): + saved_paths.append(path) + + args = self._snap_args(output="myshot.jpg", unique=False) + + with ( + patch("bambu_cli.protocols.camera._write_snapshot_atomic", side_effect=_fake_write), + patch("bambu_cli.logging_utils._BACKEND", MagicMock()), + patch("os.path.getsize", return_value=1024), + patch("bambu_cli.protocols.camera._ensure_parent_dir"), + ): + cmd_snapshot( + args, + grab_frame=lambda printer: b"\xff\xd8\xff\xd9", + ) + + self.assertEqual(len(saved_paths), 1) + self.assertTrue(saved_paths[0].endswith("myshot.jpg")) + self.assertNotIn("Z.jpg", saved_paths[0]) + +class TestSnapshotJsonMetadata(unittest.TestCase): + """captured_at and sha256 appear in --json output on every successful capture.""" + + def _snap_args(self, output="snap.jpg", unique=False): + args = MagicMock() + args.output = output + args.unique = unique + args.json = True + return args + + def test_direct_path_json_includes_captured_at_and_sha256(self, capsys=None): + """Direct grab path: JSON output must include captured_at and sha256.""" + import io + import contextlib + import hashlib + from bambu_cli.commands import cmd_snapshot + + frame_data = b"\xff\xd8\xff\xd9" + expected_sha = hashlib.sha256(frame_data).hexdigest() + args = self._snap_args() + + buf = io.StringIO() + with ( + patch("bambu_cli.protocols.camera._write_snapshot_atomic"), + patch("bambu_cli.logging_utils._BACKEND", MagicMock()), + patch("os.path.getsize", return_value=len(frame_data)), + patch("bambu_cli.protocols.camera._ensure_parent_dir"), + patch("bambu_cli.protocols.camera.emit_json", side_effect=lambda d: buf.write(json.dumps(d))), + ): + cmd_snapshot( + args, + grab_frame=lambda printer: frame_data, + ) + + payload = json.loads(buf.getvalue()) + self.assertIn("captured_at", payload) + self.assertIn("sha256", payload) + self.assertEqual(payload["sha256"], expected_sha) + # captured_at should look like ISO-8601 UTC + self.assertRegex(payload["captured_at"], r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z") + + def test_docker_path_json_includes_captured_at_and_sha256(self): + """Docker streamer path: JSON output must include captured_at and sha256.""" + import io + import hashlib + from bambu_cli.commands import cmd_snapshot + + frame_data = b"\xff\xd8fake_image_data\xff\xd9" + expected_sha = hashlib.sha256(frame_data).hexdigest() + args = self._snap_args() + + mock_response = MagicMock() + mock_response.read.return_value = frame_data + mock_urlopen = MagicMock() + mock_urlopen.return_value.__enter__.return_value = mock_response + mock_run = MagicMock(return_value=MagicMock(returncode=0, stdout="true")) + + buf = io.StringIO() + with ( + patch("bambu_cli.protocols.camera._write_snapshot_atomic"), + patch("bambu_cli.logging_utils._BACKEND", MagicMock()), + patch("os.path.getsize", return_value=len(frame_data)), + patch("bambu_cli.protocols.camera._ensure_parent_dir"), + patch("bambu_cli.protocols.camera.emit_json", side_effect=lambda d: buf.write(json.dumps(d))), + ): + cmd_snapshot( + args, + grab_frame=lambda printer: None, # force Docker path + which=lambda name: "/usr/bin/docker", + subprocess_run=mock_run, + urlopen=mock_urlopen, + sleep=MagicMock(), + ) + + payload = json.loads(buf.getvalue()) + self.assertIn("captured_at", payload) + self.assertIn("sha256", payload) + self.assertEqual(payload["sha256"], expected_sha) + self.assertRegex(payload["captured_at"], r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tlspin.py b/tests/test_tlspin.py index d1ca5cb..38a0814 100644 --- a/tests/test_tlspin.py +++ b/tests/test_tlspin.py @@ -2,7 +2,7 @@ ``bambu_cli.tlspin.verify_cert_fingerprint`` is the single source of truth that MQTT, FTPS, and the direct camera grab all call. These tests exercise it -directly; the per-transport suites (``test_tls_pinning.py``, ``test_camera_cmd``) +directly; the per-transport suites (``test_tls_pinning.py``, ``test_camera_capture``) prove each call site still fails closed through its own path. """