Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/escl-fixture.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions docs/skills/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion docs/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|
Expand Down Expand Up @@ -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. |
Expand Down
134 changes: 134 additions & 0 deletions docs/skills/scanner-fixture/SKILL.md
Original file line number Diff line number Diff line change
@@ -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:<name>:<url>`, 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.
79 changes: 79 additions & 0 deletions tests/fixtures/escl-scanner/Containerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading