From 44cd9d3e92e2ecc7ebd93a2b6d802b1283e51fc5 Mon Sep 17 00:00:00 2001 From: sramkrishna Date: Fri, 25 Sep 2026 04:40:28 +0000 Subject: [PATCH 1/5] fix(ci): register test_bluefin_countme.bats in the test recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/test_bluefin_countme.bats has been on main since #1154 but was never added to the Justfile `test` recipe, so tests/test_suite_registration.bats fails on main: suite never runs — add it to the Justfile `test` recipe or declare it excluded: test_bluefin_countme.bats The suite is hermetic — it mocks curl and bootc into a temp PATH — so it belongs in the recipe rather than in the exclusion list. All 12 tests pass, and the registration gate goes green with this line. Found while adding a suite of my own to the same recipe. Hive-Run: projectbluefin/common#1212 Hive-Plan: escl-scanner-fixture Hive-Spec: projectbluefin/common#1212#acceptance Co-Authored-By: Claude Code Signed-off-by: sramkrishna --- Justfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Justfile b/Justfile index 68cf9a6dc..494491d7a 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,7 @@ test: bats tests/test_image_repo.bats bats tests/test_suite_registration.bats bats tests/test_shared_just.bats + bats tests/test_bluefin_countme.bats # Preview Bazaar config from this checkout on the local machine bazaar-preview: From 5c97c869dc4014f4911e1acd8040fe9f5c8e85b6 Mon Sep 17 00:00:00 2001 From: sramkrishna Date: Fri, 25 Sep 2026 04:40:39 +0000 Subject: [PATCH 2/5] feat(testing): add a hardware-free eSCL scanner fixture Adds a container fixture that gives CI a real eSCL HTTP + DNS-SD scanner endpoint with no physical hardware, closing the "no way to exercise driverless scanning" gap in common. Two upstream pieces, neither emulated: - OpenPrinting/go-mfp `cmd/mfp-virtual` serves eSCL over HTTP and can advertise itself over DNS-SD. - sane-airscan + sane-utils provide the real client: the `airscan` SANE backend and the `scanimage` frontend. `scanimage` is the same binary a user runs against a physical scanner. `run-fixture.sh` has two modes: capture Pins sane-airscan at the simulator's eSCL URL via SANE_AIRSCAN_DEVICE, asserts `scanimage -L` lists the device, then scans the platen twice at 600 DPI and asserts both captures are byte-identical PNGs whose IHDR geometry matches the model's platen aspect ratio and whose size clears a truncation floor. This is the acceptance gate. dnssd Brings up dbus + avahi, lets the simulator advertise over DNS-SD, and asserts `airscan-discover` finds it with no pinned device URL. SANE_AIRSCAN_DEVICE is explicitly unset so the mode cannot pass by inheriting a pin. Geometry is asserted by aspect ratio rather than exact pixel count: the default scan region is sane-airscan's choice and its mm/pixel rounding is version-dependent, so pinning an exact count would fail the fixture on a client upgrade that changed nothing about the simulator. Every external command is overridable through the environment, which is what lets tests/test_escl_fixture.bats (21 tests) exercise the orchestration with stubs and no container runtime. Two details that matter and are easy to regress: liveness uses `jobs -r` because `kill -0` reports an unreaped zombie as alive, and teardown escalates SIGTERM to SIGKILL so a simulator that ignores SIGTERM cannot hang the CI job. The DNS-SD CI step runs with continue-on-error: true. In-container dbus and avahi is the one part that could not be validated outside CI; the comment on the step and the README say to remove it once a green run confirms it. No hardware was available, so nothing here is evidence about real device behaviour. Hardware effects are unverified. Per AGENTS.md, this ships the work and the skill doc together: docs/skills/scanner-fixture/SKILL.md, with the router row, the regenerated catalog, and the tests/test_escl_fixture.bats row in docs/TESTING.md. Refs #1212 Hive-Run: projectbluefin/common#1212 Hive-Plan: escl-scanner-fixture Hive-Spec: projectbluefin/common#1212#acceptance Co-Authored-By: Claude Code Signed-off-by: sramkrishna --- .github/workflows/escl-fixture.yml | 55 +++ Justfile | 1 + docs/SKILL.md | 1 + docs/TESTING.md | 1 + docs/skills/index.json | 21 +- docs/skills/index.md | 3 +- docs/skills/scanner-fixture/SKILL.md | 134 ++++++ tests/fixtures/escl-scanner/Containerfile | 79 ++++ tests/fixtures/escl-scanner/README.md | 98 +++++ .../models/virtual-escl-scanner.py | 127 ++++++ tests/fixtures/escl-scanner/run-fixture.sh | 392 ++++++++++++++++++ tests/test_escl_fixture.bats | 360 ++++++++++++++++ 12 files changed, 1270 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/escl-fixture.yml create mode 100644 docs/skills/scanner-fixture/SKILL.md create mode 100644 tests/fixtures/escl-scanner/Containerfile create mode 100644 tests/fixtures/escl-scanner/README.md create mode 100644 tests/fixtures/escl-scanner/models/virtual-escl-scanner.py create mode 100755 tests/fixtures/escl-scanner/run-fixture.sh create mode 100644 tests/test_escl_fixture.bats diff --git a/.github/workflows/escl-fixture.yml b/.github/workflows/escl-fixture.yml new file mode 100644 index 000000000..25b38a1c2 --- /dev/null +++ b/.github/workflows/escl-fixture.yml @@ -0,0 +1,55 @@ +name: eSCL Scanner Fixture + +# Hardware-free eSCL scanner coverage (projectbluefin/common#1212). +# +# Builds the fixture image from tests/fixtures/escl-scanner and drives it with +# a real sane-airscan client. No physical scanner is involved; hardware +# behaviour is explicitly out of scope. + +on: + pull_request: + branches: + - main + paths: + - "tests/fixtures/escl-scanner/**" + - ".github/workflows/escl-fixture.yml" + push: + branches: + - main + paths: + - "tests/fixtures/escl-scanner/**" + - ".github/workflows/escl-fixture.yml" + merge_group: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + escl-fixture: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Build fixture image + run: docker build --tag escl-fixture tests/fixtures/escl-scanner + + # The acceptance gate: a real sane-airscan client detects the simulator + # and captures a byte-deterministic synthetic page. + - name: eSCL capture + run: docker run --rm escl-fixture --mode capture + + # Discovery is exercised through DNS-SD rather than a pinned device URL. + # This is non-gating for now: it depends on dbus and avahi-daemon coming + # up inside the container, which is the one part of the fixture that has + # not been validated outside CI. Remove continue-on-error once a green + # run confirms it, so a regression here fails the job like any other. + - name: DNS-SD discovery + continue-on-error: true + run: docker run --rm escl-fixture --mode dnssd diff --git a/Justfile b/Justfile index 494491d7a..28ef44367 100644 --- a/Justfile +++ b/Justfile @@ -44,6 +44,7 @@ test: bats tests/test_suite_registration.bats bats tests/test_shared_just.bats bats tests/test_bluefin_countme.bats + bats tests/test_escl_fixture.bats # Preview Bazaar config from this checkout on the local machine bazaar-preview: diff --git a/docs/SKILL.md b/docs/SKILL.md index a7fc21657..5ca46f162 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -49,6 +49,7 @@ repository's local catalog with `docs/skills/hive.md` as the preflight guard. | Understand release / promotion | [`release-promotion/SKILL.md`](skills/release-promotion/SKILL.md) | | Understand QA coverage or run tests | [`qa.md`](skills/qa.md) | | Submit a hardware test report | [`hardware-testing.md`](skills/hardware-testing.md) | +| Test driverless scanning without hardware | [`scanner-fixture/SKILL.md`](skills/scanner-fixture/SKILL.md) | | Lab-test a common PR on ghost | [`lab-testing/SKILL.md`](skills/lab-testing/SKILL.md) | | Write or test shell scripts | [`shell-scripts/SKILL.md`](skills/shell-scripts/SKILL.md) | | Work on brew / preinstall packages | [`brew-lifecycle/SKILL.md`](skills/brew-lifecycle/SKILL.md) | diff --git a/docs/TESTING.md b/docs/TESTING.md index 01a85749d..36df079e4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -103,6 +103,7 @@ Do not add exemptions for scripts with branching logic. | `tests/test_apps_just.bats` | `apps.just` — `install-opentabletdriver` pinned-download + sha256 gates (tampered payload, HTTP error, unit-before-enable ordering), install/uninstall branches, and `cncf` | | `tests/test_image_repo.bats` | `usr/libexec/ublue-image-repo` — image-name/tag routing to upstream GitHub repos | | `tests/test_shared_just.bats` | `shared.just` — `powerwash` (double confirmation) and `toggle-tpm2` recipes | +| `tests/test_escl_fixture.bats` | `tests/fixtures/escl-scanner/run-fixture.sh` — eSCL readiness probing, device detection, capture determinism/geometry/size assertions, DNS-SD discovery, with the simulator and client stubbed | ## Quality Epic diff --git a/docs/skills/index.json b/docs/skills/index.json index f6a940d47..fc6c3946c 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-09-23", + "generated_at": "2026-09-25", "schema_version": "1.0", "skills": [ { @@ -615,6 +615,25 @@ "last_updated": "2026-08-08", "doc_type": "reference" }, + { + "id": "scanner-fixture", + "name": "scanner-fixture", + "one_line_purpose": "Test driverless scanning in CI without a physical scanner.", + "entry_point": "docs/skills/scanner-fixture/SKILL.md", + "category": "test-authoring", + "status": "active", + "tags": [ + "testing", + "scanner", + "escl", + "airscan", + "container" + ], + "description": "Hardware-free eSCL scanner fixture for common. Use when testing driverless scanning, sane-airscan, or eSCL/DNS-SD discovery without a physical scanner.", + "version": "1.0", + "last_updated": "2026-09-25", + "doc_type": "reference" + }, { "id": "secrets-policy", "name": "secrets-policy", diff --git a/docs/skills/index.md b/docs/skills/index.md index a23225b1a..e13deeccc 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -3,7 +3,7 @@ This file is a human-readable mirror of `index.json`. Both are generated by `scripts/generate_skill_index.py` — do not hand-edit either file. -Generated: 2026-09-23 · schema 1.0 · 42 skills +Generated: 2026-09-25 · schema 1.0 · 43 skills | id | category | status | one-line purpose | |---|---|---|---| @@ -42,6 +42,7 @@ Generated: 2026-09-23 · schema 1.0 · 42 skills | [qa](qa.md) | test-authoring | active | Run and understand the projectbluefin QA test coverage model. | | [queue-feed](queue-feed.md) | ci-ops | active | Read and validate the Project Bluefin static pull-request queue feed. | | [release-promotion](release-promotion/SKILL.md) | ci-ops | active | Cut releases and verify promotion/hotfix artifacts. | +| [scanner-fixture](scanner-fixture/SKILL.md) | test-authoring | active | Test driverless scanning in CI without a physical scanner. | | [secrets-policy](secrets-policy.md) | meta | active | Verify secrets and credentials against the approved inventory. | | [shell-scripts](shell-scripts/SKILL.md) | test-authoring | active | Write and test shell scripts under system_files/. | | [skill-drift](skill-drift.md) | meta | deprecated | Record why the skill-drift CI check was retired and what replaced it. | diff --git a/docs/skills/scanner-fixture/SKILL.md b/docs/skills/scanner-fixture/SKILL.md new file mode 100644 index 000000000..3d075380e --- /dev/null +++ b/docs/skills/scanner-fixture/SKILL.md @@ -0,0 +1,134 @@ +--- +name: scanner-fixture +version: "1.0" +last_updated: "2026-09-25" +id: scanner-fixture +one_line_purpose: Test driverless scanning in CI without a physical scanner. +entry_point: docs/skills/scanner-fixture/SKILL.md +category: test-authoring +mcp_compliance_level: partial +optimization_status: draft +status: active +dependencies: [] +tags: [testing, scanner, escl, airscan, container] +description: >- + Hardware-free eSCL scanner fixture for common. Use when testing + driverless scanning, sane-airscan, or eSCL/DNS-SD discovery without a + physical scanner. +metadata: + type: reference +--- + +# Scanner Fixture + +## When to Use + +Use when you need to exercise driverless scanning — eSCL/AirScan, the +`sane-airscan` backend, or `_uscan._tcp` DNS-SD discovery — without physical +hardware, or when a change touches scanner configuration and you want a +deterministic client-visible check. + +## When Not to Use + +Do not use this skill for hardware validation: the fixture says nothing about +real device behaviour. Physical scanner results go through +[`../hardware-testing.md`](../hardware-testing.md) and are reported as +unverified here. For general shell-script testing conventions, see +[`../shell-scripts/SKILL.md`](../shell-scripts/SKILL.md). + +## What It Is + +`tests/fixtures/escl-scanner/` is a container image pairing two upstream +projects: + +| Piece | Role | +|---|---| +| [OpenPrinting/go-mfp](https://github.com/OpenPrinting/go-mfp) `cmd/mfp-virtual` | Virtual MFP simulator: serves eSCL over HTTP, can advertise over DNS-SD | +| `sane-airscan` + `sane-utils` | The real client: the `airscan` SANE backend and the `scanimage` frontend | + +Nothing emulates the client. `scanimage` is the same binary a user runs +against a physical scanner, reaching the simulator through the unmodified +`airscan` backend. + +## Running It + +```bash +docker build --tag escl-fixture tests/fixtures/escl-scanner +docker run --rm escl-fixture --mode capture # acceptance gate +docker run --rm escl-fixture --mode dnssd # discovery +``` + +Both modes exit non-zero with a diagnosis on failure; the simulator's log is +dumped automatically when a step fails. + +## Modes + +**`--mode capture`** is the gate. It asserts: + +1. `scanimage -L` lists the virtual scanner — the "detects it" half. +2. Two platen scans at 600 DPI in color produce **byte-identical** PNGs — + the "deterministic synthetic page" half. +3. Both captures are valid PNGs whose IHDR geometry matches the model's + platen aspect ratio (2550:3508 in eSCL's 1/300 inch units), catching a + capture that silently fell back to a default scan region. +4. The capture is above a small size floor, catching truncation. + +**`--mode dnssd`** asserts `airscan-discover` finds the scanner by DNS-SD +alone. `SANE_AIRSCAN_DEVICE` is explicitly unset, so the mode cannot pass by +inheriting a pin. + +## Gotchas + +**Geometry is asserted by aspect ratio, not exact pixels.** The embedded page +is exactly 5100x7016, but the default scan region is sane-airscan's choice and +its mm/pixel rounding is version-dependent. Pinning an exact pixel count would +fail the fixture on a client upgrade that changed nothing about the simulator. + +**The capture gate pins the device URL.** `run-fixture.sh` sets +`SANE_AIRSCAN_DEVICE=escl::`, a documented sane-airscan feature and +the same mechanism `mfp-virtual` uses for child commands. This keeps the gate +off multicast networking; real discovery is covered by `--mode dnssd`. + +**600 DPI is a 1:1 copy.** The virtual scanner reports 600x600 DPI and holds a +5100x7016 page, so a full-platen 600 DPI scan is the embedded page without +resampling. + +**Liveness needs `jobs -r`, not `kill -0`.** An exited child stays a zombie +until reaped, and the PID remains signalable, so `kill -0` reports a crashed +simulator as alive and the readiness loop spins to its timeout. `server_running()` +uses `jobs -rp`; keep it that way when editing the script. + +**Teardown escalates SIGTERM to SIGKILL.** A simulator that ignores SIGTERM +would otherwise hang teardown and leave the CI job waiting on it. + +## Pinned Versions + +The Containerfile pins both base images by digest and the go-mfp checkout by +commit SHA. `.github/renovate.json5` does not manage this file, so bumps are +manual. When bumping `GO_MFP_SHA`, re-check the model file against +`modeling/escl.py` and `modeling/keyword.go` upstream: the model's field names +are matched case-insensitively against the Go struct fields. + +## Scope and Limits + +- **No hardware.** Nothing here has run against a physical scanner; nothing + here is evidence about real device behaviour. +- **Not a scanner application.** A test fixture only — not published as an + image, not part of the `common` OCI layer, not wired into any `ujust` + recipe. +- **DNS-SD is non-gating in CI.** `escl-fixture.yml` runs the DNS-SD step with + `continue-on-error: true` because in-container dbus/avahi is the one part + not validated outside CI. Remove it once a green run confirms it. + +## Tests + +`tests/test_escl_fixture.bats` covers the orchestration logic in +`run-fixture.sh` — readiness probing, device-name parsing, and every +assertion path — with the simulator, `curl`, and `scanimage` replaced by +stubs, so it runs anywhere with no container runtime. The container build +itself is covered by `.github/workflows/escl-fixture.yml`, not by bats. + +Every external command is overridable through the environment +(`MFP_VIRTUAL`, `SCANIMAGE`, `CURL`, `AIRSCAN_DISCOVER`, `DBUS_DAEMON`, +`AVAHI_DAEMON`). Keep that property: it is what makes the script testable +without the image. diff --git a/tests/fixtures/escl-scanner/Containerfile b/tests/fixtures/escl-scanner/Containerfile new file mode 100644 index 000000000..36383f212 --- /dev/null +++ b/tests/fixtures/escl-scanner/Containerfile @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1 +# +# Hardware-free eSCL scanner fixture (projectbluefin/common#1212). +# +# Builds the OpenPrinting go-mfp virtual MFP simulator from pinned source and +# pairs it with the real sane-airscan client, so CI can exercise an eSCL +# scanner end to end without physical hardware. +# +# docker build -t escl-fixture tests/fixtures/escl-scanner +# docker run --rm escl-fixture --mode capture +# docker run --rm escl-fixture --mode dnssd +# +# Both base images are pinned by digest and the go-mfp checkout by commit SHA. +# None of these are Renovate-managed — .github/renovate.json5 enables only the +# github-actions manager plus Containerfile custom.regex patterns that do not +# match this file — so bump them by hand. + +FROM docker.io/library/golang:1.24-trixie@sha256:5835f052b784aa39f2fe9070def3568605c8bc3fcd810f10402066348b61e716 AS builder + +# Build dependencies for cmd/mfp-virtual: the minimal set documented in the +# go-mfp README's WSL2 section, which is also the set that avoids libppd. +# cgo needs avahi for DNS-SD, libjpeg/libpng for image conversion, libusb for +# USB/IP, and libpython for the embedded model interpreter. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + gcc \ + git \ + libavahi-client-dev \ + libjpeg-dev \ + libpng-dev \ + libusb-1.0-0-dev \ + make \ + pkg-config \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Pinned to the commit this fixture was written and reviewed against. +# --filter=blob:none keeps the clone small while still allowing an arbitrary +# reachable SHA to be checked out. +ARG GO_MFP_SHA=e8bcc92c95bb4dc5cfa6532d12f78a0d2ee46a11 +RUN git clone --filter=blob:none --no-checkout \ + https://github.com/OpenPrinting/go-mfp.git /src/go-mfp && \ + git -C /src/go-mfp checkout "${GO_MFP_SHA}" + +RUN cd /src/go-mfp && \ + go build -trimpath -o /out/mfp-virtual ./cmd/mfp-virtual + +FROM docker.io/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a + +# Runtime libraries for the cgo-linked simulator, the real sane-airscan client +# (which also ships airscan-discover), and the dbus/avahi pair the DNS-SD mode +# needs. python3 is required in full rather than just libpython3.13, because +# the embedded interpreter imports the model file's stdlib dependencies — +# `os` and `uuid` — at runtime. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + avahi-daemon \ + avahi-utils \ + ca-certificates \ + curl \ + dbus \ + libavahi-client3 \ + libjpeg62-turbo \ + libpng16-16 \ + libpython3.13 \ + libusb-1.0-0 \ + python3 \ + sane-airscan \ + sane-utils \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/mfp-virtual /usr/local/bin/mfp-virtual + +COPY models/ /opt/escl-fixture/models/ +COPY run-fixture.sh /usr/local/bin/run-fixture.sh +RUN chmod 0755 /usr/local/bin/run-fixture.sh + +ENTRYPOINT ["/usr/local/bin/run-fixture.sh"] +CMD ["--mode", "capture"] diff --git a/tests/fixtures/escl-scanner/README.md b/tests/fixtures/escl-scanner/README.md new file mode 100644 index 000000000..5ba6baf14 --- /dev/null +++ b/tests/fixtures/escl-scanner/README.md @@ -0,0 +1,98 @@ +# eSCL scanner fixture + +A hardware-free eSCL scanner that CI can start, scan from, and tear down. + +The fixture is a container image that pairs two upstream projects: + +| Piece | Role | +|---|---| +| [OpenPrinting/go-mfp](https://github.com/OpenPrinting/go-mfp) `cmd/mfp-virtual` | The virtual MFP simulator: serves eSCL over HTTP and can advertise over DNS-SD | +| [sane-airscan](https://github.com/alexpevzner/sane-airscan) + `sane-utils` | The real client: the `airscan` SANE backend and the `scanimage` frontend | + +Nothing here emulates a client. `scanimage` is the same binary a user runs +against a physical scanner, and it reaches the simulator through the +unmodified `airscan` backend. + +## Why + +`projectbluefin/common` ships scanner-adjacent configuration but has no way to +exercise driverless scanning without physical hardware. This fixture gives CI +a deterministic eSCL endpoint so scanner changes can be tested before they +reach an image. + +## Running it + +```bash +docker build --tag escl-fixture tests/fixtures/escl-scanner + +# Acceptance gate: detect the scanner, capture the page twice, compare. +docker run --rm escl-fixture --mode capture + +# Discovery: find the scanner over DNS-SD with no pinned device URL. +docker run --rm escl-fixture --mode dnssd +``` + +Both modes exit non-zero with a diagnosis on failure; the simulator's log is +dumped automatically when a step fails. + +## What `--mode capture` asserts + +1. `scanimage -L` — the real SANE frontend, through the `airscan` backend, + lists the virtual scanner. This is the "detects it" half. +2. Two platen scans at 600 DPI in color produce **byte-identical** PNGs. This + is the "deterministic synthetic page" half. +3. Both captures are valid PNGs whose IHDR geometry matches the model's + platen aspect ratio (2550:3508 in eSCL's 1/300 inch units), so a capture + that silently fell back to a default scan region is caught. +4. The capture is above a small size floor, which catches truncation. + +## What `--mode dnssd` asserts + +`airscan-discover` finds the scanner by DNS-SD alone. The simulator publishes +a `_uscan._tcp` record and the client resolves it through avahi; no device URL +is passed in, and `SANE_AIRSCAN_DEVICE` is explicitly unset so the mode cannot +pass by accident. + +## Design notes + +**The capture gate pins the device URL.** `run-fixture.sh` sets +`SANE_AIRSCAN_DEVICE=escl::` for the capture mode. That is a +documented sane-airscan feature — the same mechanism `mfp-virtual` itself +uses when it runs a child command — and it keeps the acceptance gate off +multicast networking. Real discovery is covered separately by `--mode dnssd`. + +**Geometry is checked by aspect ratio, not by exact pixel count.** The page +go-mfp embeds is exactly 5100x7016, but the scan region `scanimage` requests +by default is sane-airscan's choice, and its millimetre/pixel rounding is an +implementation detail of the installed version. Pinning an exact pixel count +would make the fixture fail on a client upgrade that changed nothing about +the simulator, so the assertion is on the platen's aspect ratio instead. + +**Scanning at 600 DPI is a 1:1 copy.** The simulator's virtual scanner reports +600x600 DPI and holds a 5100x7016 page, so a 600 DPI full-platen scan is the +embedded page without resampling. + +## Pinned versions + +The Containerfile pins both base images by digest and the go-mfp checkout by +commit SHA. `.github/renovate.json5` does not manage this file, so these are +manual bumps. When bumping `GO_MFP_SHA`, re-check the model file against +`modeling/escl.py` and `modeling/keyword.go` upstream — the model's field +names are matched case-insensitively against the Go struct fields. + +## Scope and limits + +- **No hardware.** Nothing here has been run against a physical scanner, and + nothing here should be read as evidence about real device behaviour. +- **Not a scanner application.** This is a test fixture only. It is not + published as an image, not part of the `common` OCI layer, and not wired + into any `ujust` recipe. +- **DNS-SD is exercised but non-gating.** See the comment on the DNS-SD step + in `.github/workflows/escl-fixture.yml`. + +## Tests + +`tests/test_escl_fixture.bats` covers the orchestration logic in +`run-fixture.sh` — readiness probing, device-name parsing, and each assertion +path — with the simulator, `curl`, and `scanimage` replaced by stubs. The +container build itself is covered by the workflow, not by bats. diff --git a/tests/fixtures/escl-scanner/models/virtual-escl-scanner.py b/tests/fixtures/escl-scanner/models/virtual-escl-scanner.py new file mode 100644 index 000000000..acc9a2451 --- /dev/null +++ b/tests/fixtures/escl-scanner/models/virtual-escl-scanner.py @@ -0,0 +1,127 @@ +# Hardware-free eSCL scanner model for the OpenPrinting go-mfp simulator. +# +# `mfp-virtual -m ` executes this as a Python script with the +# `escl`, `dnssd`, `ipp` and `usb` modules pre-bound as globals; whatever +# globals it assigns are imported back into the Go model. +# +# Geometry. eSCL expresses lengths in 1/300 inch units, so the platen below +# is A4/Letter: +# +# 2550 / 300 = 8.5 in (Letter width) +# 3508 / 300 = 11.69 in (A4 height) +# +# A full-platen capture at 600 DPI is therefore exactly 5100 x 7016 px, +# which is the size of the deterministic synthetic page go-mfp embeds +# (internal/testutils/data/testpage-5100x7016.png) and the resolution its +# virtual scanner template reports. Scanning at 600 DPI is a 1:1 copy of +# that page -- no resampling -- which is what makes the capture byte-stable. +# +# DNS-SD. Advertising over DNS-SD needs a reachable avahi daemon, which is +# not present in every environment, so it is opt-in: the simulator's own +# eSCL HTTP endpoint works without it. Set ESCL_FIXTURE_DNSSD=1 to publish. + +import os +from uuid import UUID + +DEVICE_NAME = 'OpenPrinting Virtual MFP' +DEVICE_UUID = UUID('9f2b7c1e-4a6d-4f3b-8c5e-1d0a2b3c4d5e') + +# Resolutions offered to clients. 600 DPI is the fixture's canonical +# resolution; 300 is kept so the device looks like ordinary hardware and +# so sane-airscan's own default-resolution probing has something to pick. +_RESOLUTIONS = [300, 600] + + +def _discrete(color_mode): + return escl.SupportedResolutions( + ColorMode = color_mode, + DiscreteResolutions = [ + escl.DiscreteResolution(XResolution = dpi, YResolution = dpi) + for dpi in _RESOLUTIONS + ], + ) + + +escl.scanner = escl.ScannerCapabilities( + Version = '2.9', + MakeAndModel = DEVICE_NAME, + SerialNumber = 'VIRTUAL-0001', + Uuid = DEVICE_UUID, + Platen = escl.Platen( + PlatenInputCaps = escl.InputSourceCaps( + MinWidth = 300, + MaxWidth = 2550, + MinHeight = 300, + MaxHeight = 3508, + MaxOpticalXResolution = 600, + MaxOpticalYResolution = 600, + MaxScanRegions = 1, + MaxPhysicalWidth = 2550, + MaxPhysicalHeight = 3508, + SupportedIntents = [ + escl.Document, + escl.TextAndGraphic, + escl.Photo, + escl.Preview, + ], + SettingProfiles = [ + escl.SettingProfile( + ColorModes = [ + escl.RGB24, + escl.Grayscale8, + escl.BlackAndWhite1, + ], + ContentTypes = [ + escl.Photo, + escl.Text, + escl.TextAndPhoto, + ], + DocumentFormats = [ + 'image/jpeg', + 'application/pdf', + ], + DocumentFormatsExt = [ + 'image/jpeg', + 'application/pdf', + ], + SupportedResolutions = [ + _discrete(escl.RGB24), + _discrete(escl.Grayscale8), + _discrete(escl.BlackAndWhite1), + ], + ), + ], + ), + ), +) + +if os.environ.get('ESCL_FIXTURE_DNSSD', '0') == '1': + # sane-airscan discovers eSCL scanners through the _uscan._tcp service + # type; `rs` tells it which path under the advertised host serves eSCL. + # The simulator rewrites each HTTP endpoint's host and port to the + # address it actually listens on, so the placeholder below is fine. + dnssd.device = dnssd.Device( + instance = DEVICE_NAME, + UUID = DEVICE_UUID, + services = [ + dnssd.Service( + types = [ + '_uscan._tcp', + '_uscans._tcp', + ], + TXT = [ + 'txtvers=1', + 'ty=' + DEVICE_NAME, + 'rs=eSCL', + 'pdl=image/jpeg,application/pdf', + 'cs=color,grayscale,binary', + 'is=platen', + 'duplex=F', + 'UUID=' + str(DEVICE_UUID), + ], + endpoints = [ + 'http://127.0.0.1:50000/eSCL', + ], + ), + ], + ) diff --git a/tests/fixtures/escl-scanner/run-fixture.sh b/tests/fixtures/escl-scanner/run-fixture.sh new file mode 100755 index 000000000..17bfca1a6 --- /dev/null +++ b/tests/fixtures/escl-scanner/run-fixture.sh @@ -0,0 +1,392 @@ +#!/usr/bin/env bash +# +# Hardware-free eSCL scanner fixture. +# +# Starts the OpenPrinting go-mfp virtual MFP, points a real sane-airscan +# client at it, and asserts that the client detects the scanner and captures +# a deterministic synthetic page. No physical scanner is involved. +# +# Two modes: +# +# capture Pin sane-airscan to the simulator's eSCL URL, assert the device +# is listed by `scanimage -L`, then scan the platen twice and +# assert both captures are byte-identical PNGs of the expected +# geometry. This is the acceptance gate. +# +# dnssd Bring up dbus + avahi, let the simulator advertise itself over +# DNS-SD, and assert `airscan-discover` finds it without any +# hardcoded device URL. +# +# Every external command is overridable through the environment so the +# orchestration logic can be exercised with stubs; see +# tests/test_escl_fixture.bats. +# +# Usage: run-fixture.sh [--mode capture|dnssd] [--port N] [--model FILE] +# [--workdir DIR] [--help] + +set -euo pipefail + +MFP_VIRTUAL=${MFP_VIRTUAL:-mfp-virtual} +SCANIMAGE=${SCANIMAGE:-scanimage} +CURL=${CURL:-curl} +AIRSCAN_DISCOVER=${AIRSCAN_DISCOVER:-airscan-discover} +DBUS_DAEMON=${DBUS_DAEMON:-dbus-daemon} +AVAHI_DAEMON=${AVAHI_DAEMON:-avahi-daemon} + +MODE=capture +PORT=${ESCL_FIXTURE_PORT:-50000} +MODEL=${ESCL_FIXTURE_MODEL:-/opt/escl-fixture/models/virtual-escl-scanner.py} +WORKDIR=${ESCL_FIXTURE_WORKDIR:-} +RESOLUTION=${ESCL_FIXTURE_RESOLUTION:-600} +READY_TIMEOUT=${ESCL_FIXTURE_READY_TIMEOUT:-60} +DISCOVERY_TIMEOUT=${ESCL_FIXTURE_DISCOVERY_TIMEOUT:-60} +STOP_TIMEOUT=${ESCL_FIXTURE_STOP_TIMEOUT:-10} + +# Name the simulator gives the virtual scanner; it is the SANE device name +# sane-airscan reports and the DNS-SD instance name the model publishes. +DEVICE_NAME='OpenPrinting Virtual MFP' + +# Platen geometry from the model, in eSCL's 1/300 inch units. A capture at +# RESOLUTION DPI must have this aspect ratio. +PLATEN_WIDTH=2550 +PLATEN_HEIGHT=3508 + +# A correctly captured page of the go-mfp synthetic test chart compresses to +# far more than this; an empty or truncated capture does not. This is a floor +# against silent truncation, not a content assertion. +MIN_PNG_BYTES=10000 + +SERVER_PID='' +SERVER_LOG='' +DEVICE='' + +log() { + printf '%s\n' "$*" +} + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + dump_server_log >&2 + exit 1 +} + +dump_server_log() { + [[ -n "$SERVER_LOG" && -s "$SERVER_LOG" ]] || return 0 + printf -- '--- mfp-virtual log (%s) ---\n' "$SERVER_LOG" + tail -n 50 "$SERVER_LOG" + printf -- '--- end mfp-virtual log ---\n' +} + +usage() { + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' +} + +parse_args() { + while (($# > 0)); do + case "$1" in + --mode) + MODE=${2:-} + shift 2 + ;; + --port) + PORT=${2:-} + shift 2 + ;; + --model) + MODEL=${2:-} + shift 2 + ;; + --workdir) + WORKDIR=${2:-} + shift 2 + ;; + --help | -h) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac + done + + case "$MODE" in + capture | dnssd) ;; + *) + printf 'invalid --mode: %s (expected capture or dnssd)\n' "$MODE" >&2 + exit 2 + ;; + esac + + [[ "$PORT" =~ ^[0-9]+$ ]] || { + printf 'invalid --port: %s\n' "$PORT" >&2 + exit 2 + } +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +prepare_workdir() { + if [[ -z "$WORKDIR" ]]; then + WORKDIR=$(mktemp -d) + else + mkdir -p "$WORKDIR" + fi + SERVER_LOG="${WORKDIR}/mfp-virtual.log" +} + +start_server() { + ESCL_FIXTURE_DNSSD="$1" "$MFP_VIRTUAL" -m "$MODEL" -P "$PORT" \ + >"$SERVER_LOG" 2>&1 & + SERVER_PID=$! +} + +# server_running reports whether the simulator is still alive. `kill -0` is +# not enough: an exited child stays a zombie until it is reaped, and the PID +# remains signalable, so the readiness loop would spin until its timeout +# instead of noticing the crash. `jobs -r` lists only running jobs. +server_running() { + [[ -n "$SERVER_PID" ]] || return 1 + local pid + for pid in $(jobs -rp); do + [[ "$pid" == "$SERVER_PID" ]] && return 0 + done + return 1 +} + +stop_server() { + [[ -n "$SERVER_PID" ]] || return 0 + + if server_running; then + kill "$SERVER_PID" 2>/dev/null || true + + local deadline=$((SECONDS + STOP_TIMEOUT)) + while server_running && ((SECONDS < deadline)); do + sleep 0.2 + done + + # Escalate, so a simulator that ignores SIGTERM cannot hang teardown + # and leave the CI job waiting on it. + server_running && kill -KILL "$SERVER_PID" 2>/dev/null + fi + + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID='' +} + +cleanup() { + stop_server +} + +wait_for_escl() { + local url="http://127.0.0.1:${PORT}/eSCL/ScannerCapabilities" + local deadline=$((SECONDS + READY_TIMEOUT)) + + while ((SECONDS < deadline)); do + if "$CURL" -fsS -o /dev/null --max-time 5 "$url" 2>/dev/null; then + log "eSCL endpoint is up: ${url}" + return 0 + fi + server_running || + fail "mfp-virtual exited before ${url} answered" + sleep 0.5 + done + + fail "${url} did not answer within ${READY_TIMEOUT}s" +} + +# detect_device lists scanners through the real SANE frontend and records the +# device name sane-airscan reported, rather than assuming its naming scheme. +detect_device() { + local listing + listing=$("$SCANIMAGE" -L 2>&1) || true + + DEVICE=$( + printf '%s\n' "$listing" | + sed -n "s/^device .\([^']*\)' is a.*/\1/p" | + head -n 1 + ) + + [[ -n "$DEVICE" ]] || fail "no SANE device reported; scanimage -L said: ${listing}" + log "sane-airscan reported device: ${DEVICE}" +} + +scan() { + local out=$1 + shift + + "$SCANIMAGE" \ + --device-name "$DEVICE" \ + --format=png \ + "$@" \ + --output-file "$out" \ + >"${out}.log" 2>&1 || { + cat "${out}.log" >&2 + fail "scanimage failed writing ${out}" + } + + [[ -s "$out" ]] || fail "scanimage produced an empty capture: ${out}" +} + +# png_dimensions validates the PNG signature and prints "WIDTH HEIGHT" read +# from the IHDR chunk, so geometry is checked without an image decoder. +png_dimensions() { + local file=$1 + local sig + + sig=$(od -An -tx1 -N8 "$file" | tr -d ' \n') + [[ "$sig" == '89504e470d0a1a0a' ]] || fail "not a PNG file: ${file}" + + od -An -tu4 -j16 -N8 --endian=big "$file" +} + +assert_geometry() { + local file=$1 width=$2 height=$3 + local lhs rhs tol + + ((width > 0 && height > 0)) || + fail "capture has no area: ${width}x${height} (${file})" + + # width/height must match the platen aspect ratio to within 1%, which + # catches a capture that silently fell back to a default scan region. + lhs=$((width * PLATEN_HEIGHT)) + rhs=$((height * PLATEN_WIDTH)) + tol=$((rhs / 100)) + + if ((lhs > rhs + tol || lhs < rhs - tol)); then + fail "capture ${width}x${height} does not match the platen aspect" \ + "ratio ${PLATEN_WIDTH}:${PLATEN_HEIGHT} (${file})" + fi +} + +assert_size_floor() { + local file=$1 bytes + + bytes=$(wc -c <"$file") + ((bytes >= MIN_PNG_BYTES)) || + fail "capture is only ${bytes} bytes, below the ${MIN_PNG_BYTES}-byte floor: ${file}" +} + +run_capture() { + local png1="${WORKDIR}/capture-1.png" + local png2="${WORKDIR}/capture-2.png" + local dims1 dims2 w1 h1 w2 h2 + + # Pin sane-airscan at the fixture instead of relying on DNS-SD multicast. + # This is what keeps the acceptance gate deterministic in CI; --mode dnssd + # exercises real discovery separately. + export SANE_AIRSCAN_DEVICE="escl:${DEVICE_NAME}:http://127.0.0.1:${PORT}/eSCL" + + detect_device + + log "scanning at ${RESOLUTION} DPI, color, platen ..." + scan "$png1" --resolution "$RESOLUTION" --mode Color + scan "$png2" --resolution "$RESOLUTION" --mode Color + + cmp -s "$png1" "$png2" || + fail "the two captures differ, so the synthetic page is not deterministic" \ + "(${png1} vs ${png2})" + + dims1=$(png_dimensions "$png1") + dims2=$(png_dimensions "$png2") + read -r w1 h1 <<<"$dims1" + read -r w2 h2 <<<"$dims2" + + [[ "$dims1" == "$dims2" ]] || + fail "capture geometry is not stable: ${w1}x${h1} vs ${w2}x${h2}" + + assert_geometry "$png1" "$w1" "$h1" + assert_size_floor "$png1" + + log "OK: deterministic capture ${w1}x${h1} at ${RESOLUTION} DPI" + log " ${png1}" + log " ${png2}" +} + +start_dns_sd_services() { + # Best effort: inside the fixture container this runs as root and the + # daemons need these to exist. If it cannot create them the daemon start + # below fails loudly, so there is nothing to gain from aborting here. + mkdir -p /run/dbus /run/avahi-daemon 2>/dev/null || true + + if [[ ! -S /run/dbus/system_bus_socket ]]; then + "$DBUS_DAEMON" --system --fork || + fail "could not start the system dbus daemon" + fi + + "$AVAHI_DAEMON" --daemonize --no-drop-root || + fail "could not start avahi-daemon" + + log "dbus and avahi-daemon are up" +} + +run_dnssd() { + local deadline=$((SECONDS + DISCOVERY_TIMEOUT)) + local out='' + + # Discovery must stand on its own: an inherited pin would make this pass + # without ever exercising DNS-SD. + unset SANE_AIRSCAN_DEVICE + + while ((SECONDS < deadline)); do + out=$("$AIRSCAN_DISCOVER" 2>&1) || true + + if printf '%s\n' "$out" | grep -qF "$DEVICE_NAME"; then + printf '%s\n' "$out" + log "OK: DNS-SD discovery found ${DEVICE_NAME}" + return 0 + fi + + sleep 1 + done + + printf '%s\n' "$out" >&2 + dump_avahi_state >&2 + fail "airscan-discover did not find '${DEVICE_NAME}' within ${DISCOVERY_TIMEOUT}s" +} + +# dump_avahi_state prints what the daemon actually knows, which is the first +# thing worth seeing when DNS-SD discovery fails. +dump_avahi_state() { + command -v avahi-browse >/dev/null 2>&1 || return 0 + printf -- '--- avahi-browse -art ---\n' + timeout 10 avahi-browse -art 2>&1 || true + printf -- '--- end avahi-browse ---\n' +} + +main() { + parse_args "$@" + prepare_workdir + + trap cleanup EXIT + + require_cmd "$MFP_VIRTUAL" + require_cmd "$CURL" + [[ -f "$MODEL" ]] || fail "model file not found: ${MODEL}" + + if [[ "$MODE" == 'capture' ]]; then + require_cmd "$SCANIMAGE" + start_server 0 + else + require_cmd "$AIRSCAN_DISCOVER" + require_cmd "$DBUS_DAEMON" + require_cmd "$AVAHI_DAEMON" + start_dns_sd_services + start_server 1 + fi + + wait_for_escl + + case "$MODE" in + capture) run_capture ;; + dnssd) run_dnssd ;; + esac + + log "OK: eSCL fixture passed (mode=${MODE})" +} + +main "$@" diff --git a/tests/test_escl_fixture.bats b/tests/test_escl_fixture.bats new file mode 100644 index 000000000..4ac91ea3c --- /dev/null +++ b/tests/test_escl_fixture.bats @@ -0,0 +1,360 @@ +#!/usr/bin/env bats +# +# Tests for tests/fixtures/escl-scanner/run-fixture.sh. +# +# The container build and the real sane-airscan client are covered by +# .github/workflows/escl-fixture.yml. These tests cover the orchestration and +# assertion logic by replacing the simulator, curl, scanimage and +# airscan-discover with stubs, so they run anywhere without a container +# runtime, a scanner, or the fixture image. + +setup() { + FIXTURE="${BATS_TEST_DIRNAME}/fixtures/escl-scanner/run-fixture.sh" + STUB_BIN="${BATS_TEST_TMPDIR}/bin" + STUB_STATE="${BATS_TEST_TMPDIR}/state" + WORKDIR="${BATS_TEST_TMPDIR}/work" + + mkdir -p "$STUB_BIN" "$STUB_STATE" "$WORKDIR" + + MODEL="${BATS_TEST_TMPDIR}/model.py" + printf '# stub model\n' >"$MODEL" + + write_stubs + + export STUB_STATE + export PATH="${STUB_BIN}:${PATH}" + + FIXTURE_ENV=( + "MFP_VIRTUAL=${STUB_BIN}/mfp-virtual" + "SCANIMAGE=${STUB_BIN}/scanimage" + "CURL=${STUB_BIN}/curl" + "AIRSCAN_DISCOVER=${STUB_BIN}/airscan-discover" + "DBUS_DAEMON=${STUB_BIN}/dbus-daemon" + "AVAHI_DAEMON=${STUB_BIN}/avahi-daemon" + "ESCL_FIXTURE_MODEL=${MODEL}" + "ESCL_FIXTURE_WORKDIR=${WORKDIR}" + "ESCL_FIXTURE_READY_TIMEOUT=5" + "ESCL_FIXTURE_DISCOVERY_TIMEOUT=2" + ) +} + +write_stubs() { + cat >"${STUB_BIN}/mfp-virtual" <<'STUB' +#!/usr/bin/env bash +printf 'stub mfp-virtual args: %s\n' "$*" +printf 'stub mfp-virtual dnssd: %s\n' "${ESCL_FIXTURE_DNSSD:-}" +if [[ "${STUB_MFP_EXIT_IMMEDIATELY:-0}" == "1" ]]; then + # Delay briefly so the readiness loop gets a liveness check in while the + # simulator is still up, which is the case the loop has to handle. + sleep 0.3 + exit 1 +fi +# exec, so SIGTERM reaches the sleeping process directly instead of leaving +# the shell waiting on a foreground child. +exec sleep 600 +STUB + + cat >"${STUB_BIN}/curl" <<'STUB' +#!/usr/bin/env bash +count_file="${STUB_STATE}/curl.count" +count=0 +[[ -f "$count_file" ]] && count=$(cat "$count_file") +count=$((count + 1)) +printf '%s' "$count" >"$count_file" +if ((count <= ${STUB_CURL_FAILS:-0})); then + exit 22 +fi +exit 0 +STUB + + cat >"${STUB_BIN}/scanimage" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s' "${SANE_AIRSCAN_DEVICE:-}" >"${STUB_STATE}/scanimage.airscan_device" +printf '%s\n' "$*" >>"${STUB_STATE}/scanimage.args" + +mode=scan +out='' +while (($#)); do + case "$1" in + -L | --list-devices) + mode=list + shift + ;; + --output-file) + out=$2 + shift 2 + ;; + *) shift ;; + esac +done + +if [[ "$mode" == 'list' ]]; then + if [[ "${STUB_SCANIMAGE_NO_DEVICE:-0}" == "1" ]]; then + printf 'No scanners were identified.\n' + exit 0 + fi + printf "device \`airscan:e0:OpenPrinting Virtual MFP' is a OpenPrinting Virtual MFP eSCL scanner\n" + exit 0 +fi + +dims="${STUB_SCAN_DIMS:-5100x7016}" +width=${dims%x*} +height=${dims#*x} + +vary='' +if [[ "${STUB_SCAN_VARY:-0}" == "1" ]]; then + n=0 + [[ -f "${STUB_STATE}/scan.count" ]] && n=$(cat "${STUB_STATE}/scan.count") + n=$((n + 1)) + printf '%s' "$n" >"${STUB_STATE}/scan.count" + vary="run-${n}" +fi + +# Emit a minimal PNG: signature, IHDR with the requested geometry, then an +# arbitrary payload. Only the signature and IHDR are read back. +be32() { + printf '\\x%02x\\x%02x\\x%02x\\x%02x' \ + $((($1 >> 24) & 255)) $((($1 >> 16) & 255)) \ + $((($1 >> 8) & 255)) $(($1 & 255)) +} + +{ + printf '\x89PNG\r\n\x1a\n' + printf '\x00\x00\x00\x0dIHDR' + printf '%b' "$(be32 "$width")" + printf '%b' "$(be32 "$height")" + printf '\x08\x02\x00\x00\x00' + printf '\x00\x00\x00\x00' + printf 'stub-page-%s' "$vary" + head -c "${STUB_SCAN_PAD:-12000}" /dev/zero | tr '\0' 'x' +} >"$out" +STUB + + cat >"${STUB_BIN}/airscan-discover" <<'STUB' +#!/usr/bin/env bash +printf '%s' "${SANE_AIRSCAN_DEVICE:-}" >"${STUB_STATE}/discover.airscan_device" +if [[ "${STUB_DISCOVER_EMPTY:-0}" == "1" ]]; then + printf '[devices]\n' + exit 0 +fi +printf '[devices]\n\tOpenPrinting Virtual MFP = http://127.0.0.1:50000/eSCL, eSCL\n' +STUB + + printf '#!/usr/bin/env bash\nexit 0\n' >"${STUB_BIN}/dbus-daemon" + printf '#!/usr/bin/env bash\nexit 0\n' >"${STUB_BIN}/avahi-daemon" + + chmod 0755 "${STUB_BIN}"/* +} + +@test "capture mode passes when the client detects the device and captures match" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -eq 0 ] + [[ "$output" == *"sane-airscan reported device: airscan:e0:OpenPrinting Virtual MFP"* ]] + [[ "$output" == *"OK: deterministic capture 5100x7016 at 600 DPI"* ]] + [[ "$output" == *"OK: eSCL fixture passed (mode=capture)"* ]] +} + +@test "capture mode pins sane-airscan at the simulator's eSCL URL" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -eq 0 ] + run cat "${STUB_STATE}/scanimage.airscan_device" + [ "$output" = "escl:OpenPrinting Virtual MFP:http://127.0.0.1:50000/eSCL" ] +} + +@test "capture mode scans the platen in PNG at the fixture resolution" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -eq 0 ] + run cat "${STUB_STATE}/scanimage.args" + [[ "$output" == *"--format=png"* ]] + [[ "$output" == *"--resolution 600"* ]] + [[ "$output" == *"--mode Color"* ]] + [ "$(grep -c -- '--output-file' "${STUB_STATE}/scanimage.args")" -eq 2 ] +} + +@test "capture mode fails when the client sees no scanner" { + export STUB_SCANIMAGE_NO_DEVICE=1 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"no SANE device reported"* ]] +} + +@test "capture mode fails when the two captures differ" { + export STUB_SCAN_VARY=1 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"the two captures differ"* ]] +} + +@test "capture mode fails when the capture is not the platen aspect ratio" { + export STUB_SCAN_DIMS=1000x1000 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"does not match the platen aspect ratio 2550:3508"* ]] +} + +@test "capture mode fails when the capture is below the size floor" { + export STUB_SCAN_PAD=16 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"below the 10000-byte floor"* ]] +} + +@test "capture mode fails when the capture is not a PNG" { + export STUB_SCAN_DIMS=5100x7016 + export STUB_SCAN_PAD=12000 + + # Corrupt the signature the stub writes by pointing scanimage at a stub + # that emits a PNM instead. + cat >"${STUB_BIN}/scanimage" <<'STUB' +#!/usr/bin/env bash +out='' +while (($#)); do + case "$1" in + -L | --list-devices) + printf "device \`airscan:e0:OpenPrinting Virtual MFP' is a scanner\n" + exit 0 + ;; + --output-file) + out=$2 + shift 2 + ;; + *) shift ;; + esac +done +printf 'P6\n5100 7016\n255\n' >"$out" +head -c 12000 /dev/zero | tr '\0' 'x' >>"$out" +STUB + chmod 0755 "${STUB_BIN}/scanimage" + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"not a PNG file"* ]] +} + +@test "capture mode waits for the eSCL endpoint instead of failing on the first refusal" { + export STUB_CURL_FAILS=3 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -eq 0 ] + [[ "$output" == *"eSCL endpoint is up"* ]] + [ "$(cat "${STUB_STATE}/curl.count")" -eq 4 ] +} + +@test "capture mode fails when the eSCL endpoint never answers" { + export STUB_CURL_FAILS=9999 + + run env "${FIXTURE_ENV[@]}" "ESCL_FIXTURE_READY_TIMEOUT=1" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"did not answer within 1s"* ]] + [[ "$output" == *"--- mfp-virtual log"* ]] +} + +@test "capture mode fails fast when the simulator exits during startup" { + export STUB_MFP_EXIT_IMMEDIATELY=1 + export STUB_CURL_FAILS=9999 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + # The liveness check must catch this, not the readiness timeout. + [[ "$output" == *"mfp-virtual exited before"* ]] + [[ "$output" != *"did not answer within"* ]] +} + +@test "capture mode fails when the model file is missing" { + run env "${FIXTURE_ENV[@]}" "ESCL_FIXTURE_MODEL=${BATS_TEST_TMPDIR}/absent.py" \ + "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"model file not found"* ]] +} + +@test "capture mode fails when a required command is missing" { + run env "${FIXTURE_ENV[@]}" "SCANIMAGE=${BATS_TEST_TMPDIR}/absent-scanimage" \ + "$FIXTURE" --mode capture + + [ "$status" -ne 0 ] + [[ "$output" == *"required command not found"* ]] +} + +@test "dnssd mode passes when discovery finds the scanner" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode dnssd + + [ "$status" -eq 0 ] + [[ "$output" == *"OpenPrinting Virtual MFP"* ]] + [[ "$output" == *"OK: DNS-SD discovery found OpenPrinting Virtual MFP"* ]] + [[ "$output" == *"OK: eSCL fixture passed (mode=dnssd)"* ]] +} + +@test "dnssd mode turns DNS-SD publishing on for the simulator" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode dnssd + + [ "$status" -eq 0 ] + # The simulator's output goes to its log, not to the fixture's stdout. + run cat "${WORKDIR}/mfp-virtual.log" + [[ "$output" == *"stub mfp-virtual dnssd: 1"* ]] +} + +@test "capture mode leaves DNS-SD publishing off" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode capture + + [ "$status" -eq 0 ] + run cat "${WORKDIR}/mfp-virtual.log" + [[ "$output" == *"stub mfp-virtual dnssd: 0"* ]] +} + +@test "dnssd mode does not inherit a pinned device URL" { + export SANE_AIRSCAN_DEVICE='escl:inherited:http://example.invalid/eSCL' + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode dnssd + + [ "$status" -eq 0 ] + run cat "${STUB_STATE}/discover.airscan_device" + [ -z "$output" ] +} + +@test "dnssd mode fails when discovery never finds the scanner" { + export STUB_DISCOVER_EMPTY=1 + + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode dnssd + + [ "$status" -ne 0 ] + [[ "$output" == *"airscan-discover did not find 'OpenPrinting Virtual MFP'"* ]] +} + +@test "an unknown mode is rejected" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --mode nonsense + + [ "$status" -eq 2 ] + [[ "$output" == *"invalid --mode: nonsense"* ]] +} + +@test "an unknown argument is rejected" { + run env "${FIXTURE_ENV[@]}" "$FIXTURE" --nope + + [ "$status" -eq 2 ] + [[ "$output" == *"unknown argument: --nope"* ]] +} + +@test "--help exits zero and prints usage" { + run "$FIXTURE" --help + + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: run-fixture.sh"* ]] +} From a7d9653f5a0f0f03d69639dcb89eea8081f71227 Mon Sep 17 00:00:00 2001 From: castrojo Date: Fri, 25 Sep 2026 01:33:03 -0400 Subject: [PATCH 3/5] fix(ci): point docker build at Containerfile in escl-fixture workflow --- .github/workflows/escl-fixture.yml | 2 +- tests/fixtures/escl-scanner/Containerfile | 2 +- tests/fixtures/escl-scanner/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/escl-fixture.yml b/.github/workflows/escl-fixture.yml index 25b38a1c2..f3220180b 100644 --- a/.github/workflows/escl-fixture.yml +++ b/.github/workflows/escl-fixture.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build fixture image - run: docker build --tag escl-fixture tests/fixtures/escl-scanner + run: docker build -f tests/fixtures/escl-scanner/Containerfile --tag escl-fixture tests/fixtures/escl-scanner # The acceptance gate: a real sane-airscan client detects the simulator # and captures a byte-deterministic synthetic page. diff --git a/tests/fixtures/escl-scanner/Containerfile b/tests/fixtures/escl-scanner/Containerfile index 36383f212..dd1d0dd86 100644 --- a/tests/fixtures/escl-scanner/Containerfile +++ b/tests/fixtures/escl-scanner/Containerfile @@ -6,7 +6,7 @@ # pairs it with the real sane-airscan client, so CI can exercise an eSCL # scanner end to end without physical hardware. # -# docker build -t escl-fixture tests/fixtures/escl-scanner +# docker build -f tests/fixtures/escl-scanner/Containerfile -t escl-fixture tests/fixtures/escl-scanner # docker run --rm escl-fixture --mode capture # docker run --rm escl-fixture --mode dnssd # diff --git a/tests/fixtures/escl-scanner/README.md b/tests/fixtures/escl-scanner/README.md index 5ba6baf14..c0019dab5 100644 --- a/tests/fixtures/escl-scanner/README.md +++ b/tests/fixtures/escl-scanner/README.md @@ -23,7 +23,7 @@ reach an image. ## Running it ```bash -docker build --tag escl-fixture tests/fixtures/escl-scanner +docker build -f tests/fixtures/escl-scanner/Containerfile --tag escl-fixture tests/fixtures/escl-scanner # Acceptance gate: detect the scanner, capture the page twice, compare. docker run --rm escl-fixture --mode capture From 873835a0eba38189a4ecde5fff2f24dc1d378fd4 Mon Sep 17 00:00:00 2001 From: castrojo Date: Fri, 25 Sep 2026 18:51:07 -0400 Subject: [PATCH 4/5] fix(testing): address review feedback on escl fixture - align build command in skill doc with Containerfile path - gate DNS-SD step now that dbus/avahi container runtime is validated - register test_escl_fixture.bats in unit-tests.yml workflow --- .github/workflows/escl-fixture.yml | 1 - .github/workflows/unit-tests.yml | 4 ++++ docs/skills/scanner-fixture/SKILL.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/escl-fixture.yml b/.github/workflows/escl-fixture.yml index f3220180b..9407488e6 100644 --- a/.github/workflows/escl-fixture.yml +++ b/.github/workflows/escl-fixture.yml @@ -51,5 +51,4 @@ jobs: # not been validated outside CI. Remove continue-on-error once a green # run confirms it, so a regression here fails the job like any other. - name: DNS-SD discovery - continue-on-error: true run: docker run --rm escl-fixture --mode dnssd diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7c5766b95..de5e63aa8 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -136,6 +136,10 @@ jobs: - name: Run bats (apps.just — install-opentabletdriver, cncf) run: bats tests/test_apps_just.bats + - name: Run bats (escl-fixture) + run: bats tests/test_escl_fixture.bats + - name: Run bats (escl-fixture) + run: bats tests/test_escl_fixture.bats - name: Smoke-test brew bundle accepts preinstall Brewfile (real Homebrew) run: | diff --git a/docs/skills/scanner-fixture/SKILL.md b/docs/skills/scanner-fixture/SKILL.md index 3d075380e..0294644f2 100644 --- a/docs/skills/scanner-fixture/SKILL.md +++ b/docs/skills/scanner-fixture/SKILL.md @@ -53,7 +53,7 @@ against a physical scanner, reaching the simulator through the unmodified ## Running It ```bash -docker build --tag escl-fixture tests/fixtures/escl-scanner +docker build -f tests/fixtures/escl-scanner/Containerfile --tag escl-fixture tests/fixtures/escl-scanner docker run --rm escl-fixture --mode capture # acceptance gate docker run --rm escl-fixture --mode dnssd # discovery ``` From 841b4076451cdcb9c19f2f64a94a7c9936322bf1 Mon Sep 17 00:00:00 2001 From: castrojo Date: Fri, 25 Sep 2026 19:04:46 -0400 Subject: [PATCH 5/5] docs(skills): regenerate skill index after merging main --- docs/skills/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/skills/index.md b/docs/skills/index.md index 0abdf6be0..c2024085d 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -3,7 +3,7 @@ This file is a human-readable mirror of `index.json`. Both are generated by `scripts/generate_skill_index.py` — do not hand-edit either file. -Generated: 2026-09-25 · schema 1.0 · 43 skills +Generated: 2026-09-25 · schema 1.0 · 44 skills | id | category | status | one-line purpose | |---|---|---|---|