diff --git a/.github/workflows/escl-fixture.yml b/.github/workflows/escl-fixture.yml new file mode 100644 index 000000000..9407488e6 --- /dev/null +++ b/.github/workflows/escl-fixture.yml @@ -0,0 +1,54 @@ +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 -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. + - 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 + run: docker run --rm escl-fixture --mode dnssd diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d63b464a5..6fd0ae9a4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -139,6 +139,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/Justfile b/Justfile index b5a9ef4a8..e030e6703 100644 --- a/Justfile +++ b/Justfile @@ -45,6 +45,8 @@ 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 + 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 0d730d0cd..56535bd74 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 e55de7b8f..fee3af6fc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -132,6 +132,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 9d14d810e..8e5577b01 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -635,6 +635,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 e18570e90..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 | |---|---|---|---| @@ -43,6 +43,7 @@ Generated: 2026-09-25 · schema 1.0 · 43 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..0294644f2 --- /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 -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 +``` + +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..dd1d0dd86 --- /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 -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 +# +# 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..c0019dab5 --- /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 -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 + +# 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"* ]] +}