From d2d1a748f101d422b817ce42590686a98a22a37f Mon Sep 17 00:00:00 2001 From: Raphael Zimmermann Date: Mon, 14 Sep 2026 13:02:10 +0200 Subject: [PATCH 1/4] Acceptance suite driving the docker image against golden samples Black-box tests over HTTP against the built image. A case is a vitest test that says what to do and asserts nothing; the harness records every request, response payload and log line and pins all of it under cases//golden/. Catches what unit tests on the host cannot: rendering drift from a playwright or base-image bump, wire-format changes, and broken or newly noisy logging. Goldens are docker-only, so pixels can be byte-exact. Volatile log fields (durations, ports, workspace paths) become placeholders so the field itself stays pinned while its value stops mattering. browserVersion stays verbatim - it is how a playwright bump announces itself. Readiness waits for the server's own "Server listening" line rather than probing a route, so no harness request pollutes a log golden. Regenerate with `npm run test:acceptance -- -u`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015Mx7PVadB4KQULCMucVCFu --- .github/workflows/build-docker-image.yml | 45 --- .gitignore | 3 +- docs/developer-setup.md | 12 + nix/devshell.nix | 3 +- server/acceptance/README.md | 150 +++++++++ .../cases/default-log-level/case.test.js | 13 + .../golden/01-render.actual.png | Bin 0 -> 9497 bytes .../cases/default-log-level/golden/logs.jsonl | 7 + .../default-log-level/golden/transcript.json | 27 ++ server/acceptance/cases/form/case.test.js | 15 + .../cases/form/golden/01-render.actual.png | Bin 0 -> 13500 bytes .../cases/form/golden/02-check.actual.png | Bin 0 -> 13500 bytes .../cases/form/golden/02-check.diff.png | Bin 0 -> 14596 bytes .../cases/form/golden/02-check.expected.png | Bin 0 -> 13500 bytes .../acceptance/cases/form/golden/logs.jsonl | 22 ++ .../cases/form/golden/transcript.json | 51 ++++ server/acceptance/cases/form/input.mhtml | 68 +++++ server/acceptance/cases/fullpage/case.test.js | 12 + .../fullpage/golden/01-render.actual.png | Bin 0 -> 11450 bytes .../fullpage/golden/02-render.actual.png | Bin 0 -> 25863 bytes .../cases/fullpage/golden/logs.jsonl | 22 ++ .../cases/fullpage/golden/transcript.json | 48 +++ server/acceptance/cases/fullpage/input.mhtml | 33 ++ .../cases/invalid-request/case.test.js | 9 + .../cases/invalid-request/golden/logs.jsonl | 7 + .../invalid-request/golden/transcript.json | 24 ++ server/acceptance/cases/mismatch/case.test.js | 22 ++ .../mismatch/golden/01-render.actual.png | Bin 0 -> 13500 bytes .../cases/mismatch/golden/02-check.actual.png | Bin 0 -> 14163 bytes .../cases/mismatch/golden/02-check.diff.png | Bin 0 -> 14369 bytes .../mismatch/golden/02-check.expected.png | Bin 0 -> 13500 bytes .../cases/mismatch/golden/logs.jsonl | 22 ++ .../cases/mismatch/golden/transcript.json | 51 ++++ server/acceptance/cases/mismatch/input.mhtml | 68 +++++ server/acceptance/cases/plain/case.test.js | 11 + .../cases/plain/golden/01-render.actual.png | Bin 0 -> 9497 bytes .../cases/plain/golden/02-check.actual.png | Bin 0 -> 9497 bytes .../cases/plain/golden/02-check.diff.png | Bin 0 -> 10956 bytes .../cases/plain/golden/02-check.expected.png | Bin 0 -> 9497 bytes .../acceptance/cases/plain/golden/logs.jsonl | 22 ++ .../cases/plain/golden/transcript.json | 51 ++++ server/acceptance/cases/plain/input.mhtml | 26 ++ server/acceptance/lib/container.js | 155 ++++++++++ server/acceptance/lib/image.js | 30 ++ server/acceptance/lib/pixelpact.js | 119 ++++++++ server/acceptance/lib/transcript.js | 284 ++++++++++++++++++ server/package.json | 3 +- server/vitest.acceptance.config.js | 16 + 48 files changed, 1403 insertions(+), 48 deletions(-) delete mode 100644 .github/workflows/build-docker-image.yml create mode 100644 server/acceptance/README.md create mode 100644 server/acceptance/cases/default-log-level/case.test.js create mode 100644 server/acceptance/cases/default-log-level/golden/01-render.actual.png create mode 100644 server/acceptance/cases/default-log-level/golden/logs.jsonl create mode 100644 server/acceptance/cases/default-log-level/golden/transcript.json create mode 100644 server/acceptance/cases/form/case.test.js create mode 100644 server/acceptance/cases/form/golden/01-render.actual.png create mode 100644 server/acceptance/cases/form/golden/02-check.actual.png create mode 100644 server/acceptance/cases/form/golden/02-check.diff.png create mode 100644 server/acceptance/cases/form/golden/02-check.expected.png create mode 100644 server/acceptance/cases/form/golden/logs.jsonl create mode 100644 server/acceptance/cases/form/golden/transcript.json create mode 100644 server/acceptance/cases/form/input.mhtml create mode 100644 server/acceptance/cases/fullpage/case.test.js create mode 100644 server/acceptance/cases/fullpage/golden/01-render.actual.png create mode 100644 server/acceptance/cases/fullpage/golden/02-render.actual.png create mode 100644 server/acceptance/cases/fullpage/golden/logs.jsonl create mode 100644 server/acceptance/cases/fullpage/golden/transcript.json create mode 100644 server/acceptance/cases/fullpage/input.mhtml create mode 100644 server/acceptance/cases/invalid-request/case.test.js create mode 100644 server/acceptance/cases/invalid-request/golden/logs.jsonl create mode 100644 server/acceptance/cases/invalid-request/golden/transcript.json create mode 100644 server/acceptance/cases/mismatch/case.test.js create mode 100644 server/acceptance/cases/mismatch/golden/01-render.actual.png create mode 100644 server/acceptance/cases/mismatch/golden/02-check.actual.png create mode 100644 server/acceptance/cases/mismatch/golden/02-check.diff.png create mode 100644 server/acceptance/cases/mismatch/golden/02-check.expected.png create mode 100644 server/acceptance/cases/mismatch/golden/logs.jsonl create mode 100644 server/acceptance/cases/mismatch/golden/transcript.json create mode 100644 server/acceptance/cases/mismatch/input.mhtml create mode 100644 server/acceptance/cases/plain/case.test.js create mode 100644 server/acceptance/cases/plain/golden/01-render.actual.png create mode 100644 server/acceptance/cases/plain/golden/02-check.actual.png create mode 100644 server/acceptance/cases/plain/golden/02-check.diff.png create mode 100644 server/acceptance/cases/plain/golden/02-check.expected.png create mode 100644 server/acceptance/cases/plain/golden/logs.jsonl create mode 100644 server/acceptance/cases/plain/golden/transcript.json create mode 100644 server/acceptance/cases/plain/input.mhtml create mode 100644 server/acceptance/lib/container.js create mode 100644 server/acceptance/lib/image.js create mode 100644 server/acceptance/lib/pixelpact.js create mode 100644 server/acceptance/lib/transcript.js create mode 100644 server/vitest.acceptance.config.js diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml deleted file mode 100644 index fdf235d..0000000 --- a/.github/workflows/build-docker-image.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Create and publish pixelpact docker image - -on: - push: - branches: - - main - create: - tags: - - v* - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push-image: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Log in to the Container registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push Docker image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 - with: - context: server - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 1e1be6b..8f76519 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ .idea pixelpact/coverage -.direnv/ \ No newline at end of file +.direnv/ +server/acceptance/out/ diff --git a/docs/developer-setup.md b/docs/developer-setup.md index 0fb867e..86952b6 100644 --- a/docs/developer-setup.md +++ b/docs/developer-setup.md @@ -12,3 +12,15 @@ npm ci npx playwright install chromium-headless-shell start-server ``` + +## Tests + +```bash +cd server +npm test # unit and integration tests against src/ +npm run test:acceptance # black-box tests against the docker image +``` + +The acceptance suite builds the image and drives it over HTTP against a set of +golden samples. It needs docker, and it is what gates the published image in CI. +See [server/acceptance/README.md](../server/acceptance/README.md). diff --git a/nix/devshell.nix b/nix/devshell.nix index 2b09335..ed6b00c 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -16,7 +16,8 @@ in { programs.alejandra.enable = true; programs.prettier.enable = true; programs.prettier.package = pkgs.prettier; - settings.global.excludes = ["*-lock.json"]; + # Acceptance goldens are generated output; prettier must not rewrite them. + settings.global.excludes = ["*-lock.json" "server/acceptance/cases/*/golden/*"]; }; packages = [pkgs.nodejs]; diff --git a/server/acceptance/README.md b/server/acceptance/README.md new file mode 100644 index 0000000..c2bf969 --- /dev/null +++ b/server/acceptance/README.md @@ -0,0 +1,150 @@ +# Acceptance tests + +Black-box tests that drive the **docker image** over HTTP against a growing set +of golden samples. They exist so a dependency bump that changes rendering, the +API, or the logging cannot reach ghcr.io unnoticed — which is what makes +Renovate's automerge safe. + +Unit and integration tests (`npm test`) cover `src/` on the host. These cover +the artifact users actually run. Nothing in here imports from `src/`. + +## Running + +```bash +npm run test:acceptance # assert against the goldens +npm run test:acceptance -- -u # regenerate them +``` + +The image is built once per run and left cached. To drive an image you already +have — this is what CI does — pass it in: + +```bash +PIXELPACT_IMAGE=pixelpact:ci npm run test:acceptance +``` + +An image passed in this way is never rebuilt or removed. + +Goldens follow vitest's own snapshot rules: `-u` rewrites them, a normal local +run fills in whatever is missing, and on CI a missing golden is a failure. + +## What a case looks like + +One directory per case, and the case _is_ a vitest test. It says what to do; it +asserts nothing. + +```js +import { pixelpactCase } from "../../lib/pixelpact.js"; + +const { scenario, render, check, fixture } = pixelpactCase(import.meta.dirname); +const viewport = { width: 800, height: 600 }; + +scenario("checks a rendered page against itself", async () => { + const actualHtml = fixture("input.mhtml"); + + const reference = await render({ actualHtml, viewport }); + await check({ actualHtml, expected: reference.png, viewport }); +}); +``` + +`pixelpactCase(dir, env?)` gives you: + +| | | +| ----------------------------------------------------- | --------------------------------------- | +| `scenario(name, body)` | `it` plus the golden pinning | +| `render({ actualHtml, viewport, fullpage?, style? })` | `{ status, body, png }` | +| `check({ actualHtml, expected, viewport, ... })` | `{ status, body, png, expected, diff }` | +| `post(route, body)` | raw escape hatch | +| `fixture(relativePath)` | a `Buffer` read next to the case | + +Buffers in, Buffers out — base64 never appears in a case. Nothing throws on a +non-2xx; the status is simply part of what gets pinned. `env` defaults to +`{ LOG_LEVEL: "debug" }`. + +Each test gets its own container on an ephemeral port, so the whole log stream +belongs to that one test and cases cannot leak into each other. + +### Two rules + +- **One `scenario` per case file.** Goldens are flat under `golden/`, so a second + one would collide. A variant is a new case directory. +- **Never hand-edit `golden/`.** It is generated output; review it in the diff. + +## What gets pinned + +Everything the case did, without the case naming any of it: + +``` +cases/form/golden/ + transcript.json every request and response, payloads elided + 01-render.actual.png each response payload, byte-exact + 02-check.actual.png + 02-check.expected.png + 02-check.diff.png + logs.jsonl the container's whole log stream, normalized +``` + +`transcript.json` also records the container's exit code, so a broken shutdown +path shows up. Response payloads over 256 bytes become a descriptor plus their +own file: + +```json +"actual": " 01-render.actual.png>" +``` + +Request payloads get a descriptor but no file — they are either a fixture in +this repository or a previous response, and the sha makes that linkage visible. + +### Why byte-exact pixels work + +Goldens are only ever generated inside the image. There is no host-rendering +path, so fontconfig and the bundled browser are fixed and a render is +reproducible. A PNG that changes by one byte is a real change worth looking at. + +### What the logs drop, and why + +`time`, `pid` and `hostname` go away. Values that differ on every run are +replaced by a placeholder rather than removed, so the _field_ stays pinned and +one appearing or disappearing is still a failure: + +- `responseTime` and anything `*DurationMs` → `` +- `req.host` → ``, `remoteAddress` → `
`, `remotePort` → `` +- the render workspace (a fresh `mkdtemp`) → `` +- the listen address → `
` + +Stable values stay verbatim on purpose. `browserVersion` is the useful one: it +is how a playwright bump announces itself. + +A line that is not JSON is kept as `{"raw": "..."}` rather than dropped — a +stray `console.log` or a half-formatted stack is broken logging and should fail. +Anything on stderr is marked `{"stream": "stderr"}`; it means a crash, not +logging. + +Readiness is detected by waiting for the server's own "Server listening" line +instead of probing a route, so no harness request pollutes the log golden or +shifts the request ids. A case therefore has to run at a level where `info` is +emitted. + +## Adding a case + +1. `mkdir cases//` and drop in an `input.mhtml`. +2. Write `case.test.js` as above. +3. `npm run test:acceptance -- -u`. +4. **Read the generated goldens** before committing. That review is the test. + +To produce an `input.mhtml`, either save a page from Chrome DevTools +(⋮ → More tools → Save page as → Webpage, Single File) or call the CDP +`Page.captureSnapshot` the way `clients/js/playwright` does. + +`.gitattributes` pins `*.mhtml` to CRLF — MIME boundary parsing depends on it, +so keep an editor from rewriting the line endings. + +A case that needs no fixture of its own can read another's: +`fixture("../plain/input.mhtml")`. What a case must **not** do is read another +case's golden — every case renders its own reference, so goldens stay pure +outputs that regenerate in any order. + +## When something fails + +Text goldens diff inline in the vitest output. For a PNG, both sides plus a +pixelmatch visualisation land in `acceptance/out//` (gitignored; CI +uploads it as the `acceptance-failures` artifact). diff --git a/server/acceptance/cases/default-log-level/case.test.js b/server/acceptance/cases/default-log-level/case.test.js new file mode 100644 index 0000000..505c941 --- /dev/null +++ b/server/acceptance/cases/default-log-level/case.test.js @@ -0,0 +1,13 @@ +import { pixelpactCase } from "../../lib/pixelpact.js"; + +// Every other case runs at debug. This one runs at the level the image actually +// ships, so the goldens pin what an operator sees in production - and a render +// that starts warning, or stops reporting itself, shows up here. +const { scenario, render, fixture } = pixelpactCase(import.meta.dirname, { + LOG_LEVEL: "info", +}); +const viewport = { width: 800, height: 600 }; + +scenario("logs a request and its result at the shipped log level", async () => { + await render({ actualHtml: fixture("../plain/input.mhtml"), viewport }); +}); diff --git a/server/acceptance/cases/default-log-level/golden/01-render.actual.png b/server/acceptance/cases/default-log-level/golden/01-render.actual.png new file mode 100644 index 0000000000000000000000000000000000000000..a60d7de5f00ea2c1475c802a8ed06be0883227f7 GIT binary patch literal 9497 zcmeHt`&ZKG`Zm+a*_m0h%gRz`rtf5FO6K`o&5VwgQl@xDtH}c@q$9G9KlkT)uIIk5 z>-puXzqi3&C+u^l;l=&(5eZ~)-*-NnGns!u{s5<0eX zGP$w+X+{5w^VchWu&fY}L?cEw>K@0i0#Ef=Tsd63_qqY$VQT8PFHo229w%L@>*9P7 zkLwDyjEsov2#?6Fyj<5~Qg4*e+V3Hs(oB8mU0xm>E-F!F+XbWyO=-LJPj=p`QRb-f zB+b z&^a%{DbF6whGlhV!ojO!9|rj-#c*wi899A7U{dZC7#(@lM|=_+(FZMzA+k+=eM_C4 z`_k!AXvPXK3Yo3&{pu~uIkAP=s$5zw@m4Zbkwi}<K-JJ1n?kjxz4?NuP1E7ETBKQp}c;m{3JA96Zg5D(s;|2r)Gt>QMc zN~#p;m`JS}d)%Cf+>n~f9TAit?+^mXL6jQJ9bDKzKZRNk06?alLvSSb6T0O&9?xvL=c8JPoTY=c0W{umM}5zln_Vw?mej~!5m>$5fXus$IpXI9v%ae6J;ree*BQ&n84AZFQZ5krbCXvf941{iO;~-C?@3J z%jJPsP9>?PIp}U49D#E{ghG~aV zZTNTz%$al;)1L6h9Zws2_`b&2{ah%cyf!j{%kCP=AO|Eg>6J`t`DAg)MSPHmd0-*V z-etOHUDZ1?lT7onbZ_G zO);eTEj&}*v(@uyC`zey0!*qP@uY0CMnIhZ<-ks_TVdx6g+ z8Y$?8rtWIlMYV|GTEZ(+_?Tnj&Vs|H@5O&eLZ+`D%O*XeZ6e24^F_HCjaqr9n&lc* zjeIwqZz#)A_}mQ-yTUK4XdNe;SHFcbohw$(Mr5^&ygbAm==nq!M;&OZFyxa#a+%p3 zt~-UcB_4laSv6+PNs^W$y8mqv~QDk0racBDQLTAfa| zsnX&2Nf^@F1slngmo<@Go4^=vb1#eLPrVN ze43sg>{W|RQxZkwbX-qsXzma@OTI1@eiUqGu%WVSo7Oc|>L?jCDtSM@--B<^>CBi| z?PG{^i`9zo+?9FyrYKh_NPF&P#=RV$QGh#yq6^iDgf|?e2Xw5z=Mo`+a@20uBx@P% z{mOyZqn_+d2xTC6*WbajHDx-w@N8_!a@_fF>n+U`t%T{OPCr+kw624k#eepKl-;$0 z3nPl)v3kt5lxeOvazi8W)Ttz%6P?LRo4um_z%=Bj^yUW`hRdM|qP7x@wZ}lU3cJ0l zD*6!-*YX=)s9xkfIf$+L(XD0(Z*H#h>`=U~rjPEBzJ-Gp7uESux~$G8$u7GjNS%6p z!RB<3q!2Ot#&0g~>e|Zp^y_sF^gNh!v5QjAR8I9G)#pu~dgf$KM?dLRGL35 zu^BUKV^goBX$vcUBA5O)R%iMt*q(f9Ok%go2mpUP=JGQwyw-3!&!$vTXs5Z;_WfnK zgMgMd{4Rz3B$9DgnK?f@mC`U_AGMOcFBmGEnva_#zJtc#yrk?b^KM(oER;^vE=}@b z?k4*{Wvz&;bBVFQ@t>qAcCB~MKTG{4!D0PY5RSxaSMl<+zX()4C@C+uchsw0$lhH) zAu}BEYFW9{UqAHi@C`ceSW(s{eq`{Ggl~>Fca8M(P8hGB(ea^y`ZOiei4l0iK1#~> zrm$en$NWLcW-i~*G6+iZo!T1d#*7~al}bPTtnW&3)SJGhoma*;L`w7E{#?arMymka zL;DS&`Bz8%L6R`W#-u3>Gwxb>vptVQ^X{7knE+%PVR;56< z9&(j^V}jU03fTwHG<7T76v#M-=}J_M!ePO_E9y} z;-LCKAlq4R*Y-tEKttxbT#vEwBP}FJpR)q9#Z>RUZ2XV98u`dgnmhUs1oi%v7&9tU zA^C#(BYMhro>^Y`j^xb7NU`&Jx1J;bKlj<|N(l;d5W+~NhPllD+xHuyV>qkKII}OG z`LKH=v27yJq1cr9kFt5WgJi9%A^?{MR{!*%xsO-e(|vpBn}Kv{pI*|amqYBq414?N zslxQ!mkV^@1ah-L-}FbPz4tN9V3QD4@T?|D86dLqX1$eztc&T}$IZ-zj4&2f7`hBY zEH~6{_63=J2g~jzC&PA0H0AG-A5;mvww4?b5wXhm-$_-?9$*r<(Eqa~sf{c(!&yR4H`QPWS4Q6b)2CN9Kft_n>G`_B!)k?O4$;WUt%sCzPd8agC@C zFkQGFheYlMY|s^axSjebx1g_!FvCGe4cd&EAr@x-ACiD+rs)ltu;+KXQmtS0&!*H^ zsB+}Pvgb>gR9+|VK@eh#z4JcP5}p|?ZI-a~hsj&lo>|cPc?K1xZ5AQSwY7k~ z{$+V{C2MZgFxY~I739r2uGLjpG0;FDvRGv{-A})XK*U~b4JA8%p?{wVf~!C0wn_S| z?<)T_h6_n*g2py9RSkiG6D>D!6NCOo5KUp#I@6NOy60a={eYRMP z9WfjS*mK0ZEw@l0n4+cMdNhkrd}_vlWQAS|zpp};$L%D?S04iI@7zxMuw^LNS-a4e z;@~+{uKBscOiA5FPg*!WBM*_n6v_NSG6=1F*b!t6dC7~fnfMr=wvyURT+pm;rq|>1 ztHzmMC%q6kGtOvAHZk_b!oDddt^*UjJl|`F32|;M=*GG-WOrOZF(o&U9obf85F<<0 z5YOg}+s5ylMbq5fH~0dM$e}LiY!DGeH8Z=?>#bY@B}{BsUjj^=NdAcy$-+;M#Mwof z<(5*!*seQvL(B_vDn+OBOju|bkmji2g&jhggued2zA~R3VZYQ z2iGN{BqQp=O7!^52-Kn4OnB`p>7-`KArQn>l;rf6%r6=Ft1xrG6Q5Z)s=Ga_IKz1q zk4$`E{j&mQrdWGdq*4B8v$*D^&?*Q``6Zs68EYF0*?f|tKpMkX<}GWzDMEGjd8kQM z9Jp?+ce)tVva+#tgsJ5Kk;&?B`rx|r5`QJ=_rQb}m}z$5U`B?AYEnDn$keV5<(cQx zZDmTO=y}J0p+>#rR4dK_|5CtGVQEO8m7UJ6ZhxqTdyy8;D)Sb~%xZ+TYZFJzG}8ie z*Uf?!q{=^`RkRXggE29kN`h2wVp;}z64PF7`Kj3-XIR(!U4&Hl=N8#Yjvmu3>@JO= zyJP~${Ab6SRw+92bz-dalcUHjduwOCa7&TGw}^T*(~-5*V;KwET8=7`FTIK^V&54| zR88KP*s?(SwB49}@<4s;HU^H^Me$llZw zRcEig%_7Tw6SXZ30$JT3`GZo)H1#*C^z|6M#j!o6g33pZfZ_<=e zxxp!gUTjv@teRdJLuV(6v*_W4Xr5f|k@bhJ@dT%|`qTD)a!;Btu2xU~#1KN!l-%^} zjnOAW$FN@Fp8Jj$L!v(eyi(R;X7&S?N>2Vaj6MH5dudPXNT458hS35D4k5C=8mU7; za!cr$(r~ulR4*;k4?%_!-XEnfn*O{6pT6b~x}aJn>Y8GK3ROo%S^`QP-C)&03Lpz5 zT+5!z9@#B5`@?7};Xd4)n7j1Z-@w@t@;3dL4(XCS632*mPgrxoHT)Bu225~<3Dfg2*oF`=^@WcfBzOvHWYyobA*dCtV0R#2&mYs?O44UiOh z3DwB$>ELA~qg~e!QJ3b0g-nNSGqOWXjTultuEnalFHIrto-=N)Lp9C6s*G$?Ly+xG zHf9-hQ^W8U$Y2=|gKJatZtrk77%6-Nxc@EX%U1zWQfM`d*j^2qD+7`OxW_>G&axG(PYhc@I&_=eVD zbq?szJC|TFd9vtE6>loG}r1<`rNPe#9)KvL~^OQx}R272@8sJ0Tt7caX0Q zG@dQ|%`Si8dQQnH(c( zKEHgsxvmNAc>qLb-E6SFy+&GH?W}#?(DLXE16{ZcqCRvU&!a40r71ym;CcYOc=S66 zS~zGHQJ0Zb?FA-mjp^1!SyEZBQobL(<|Y2p{<`}cv&S)#$y>U$6ki@!KqqfN5;%>gI9aa^*1*Z=rBbyRH zr316j3CfWSZ@l(}4J9Y*#V@T8D#Bh{Z;Q4nqeAX^j3Yf{rC9$Ai0NRkgav(nI;n;J zhI)@?oYBr+ywe$Gg_jCnvMcL>GiL z>U-q5NIZ{~OG(aYfgD-XF27V@bkoR%HMApzF;+Pgi=7LFmO`Z$nQ6ASLDbO8!?CV% z$4YOE21ly+9XldvNpKu0y4S#8R4$HD9QUw$Qs14HXOPQjdrEo`&hapO9{xNbBz}#) zmat&MY8pg1*Hd~odq0(S`^N@+x>82uKs^Ra37=tH!nb&i7uvXsRe%0KAAGo{^87RQ z329=DXX|-MleN<|N)?(KWR=LJlU$9Ji#UDJo+BO0E8ntZ))v~Pb z605o&7Oo7%Zvs4Tjr>Hb8BV8RFX942$cZiG5$EnVUB-Ya*AOMhE2V6wAnBVIXM*ZD zDTF|uJP*_x2jm^&U1-Xq(36&841(rGYZ6js#`}ZW%Z(wf_pXvV!mYVS42_;OrqjWh z=iH5d;ge&g)5vLUwsi%w2z;`3WO>)L1MPYPj|5q@Go_r$gN#axONTK)tGVf9)x+zj zQ=z>lkv-6?cS9>8BZVi-K`?!?;^FG(2MBk5eN<1Qc=5C@cy!=Nu`;oYty-jOf0e=* zA*kY_p#sd0xG*2e1zPDtVY)y+_N#)0pt@&8W>l8)oJsFOoy;MK02ccm#oBjDb%-syH8;YCJd7mz&B5<$JlxBe+e`9f;Q4X1d zO@9IW4B#20yZSi|awS;u@g>v6g&2GNtjCZ(G146g&Di@due6l~3b91M`cvBKm>iU| znWovLG9<=>kAbANgyM@Eds;=6hZPv#GwhlcbpVbBTSYpW3}=PjcCoV4i{Al{Z2gyA z0ylm_6Juu#tgK$k3{6sw<}WPr^dRJBM4smTIxF#9=O5+Y=T6)syb75mG*%RQ--DtC z=wkbihNo|n5x3nc>k*tcH^UQ{j@5qRO0VV+lree3Q2B9r;Mwr^wL8N-V6}nqvmYJ4 z_cO-19FNZGb9~(e?&n}R?qUDiG`KsfXHiOzs38}zS>`tFEZPdJX6WG4!Tu9oXO3Q0 z$(tX13fL@hpiUTB&a%be{-U}hnol?=p5esLY(S$FKJR0aHWuJ@if|T~pJ{7(7_iZ{ z*MOu!GzY|{B5KvJpvW;q$wD1_U?}J3(&ODdwcA|qFs}=~NCn2Shi-cek49erPt29I z+YjJaDV^41dc*sAUYz6MDjH;ygd|AETf6j;jxA}b-HBlEEY~ULT~zwVQwB)dgh`Ie zv7!um5*BH9Q2A{!&=J9SGkZuOehf;c?ZI^=j@QeK7^-YYrVkEEyjC%YF9?f|ynO3y zWjxX)JdwQ=py)MqYnwE<+BW{wyD;)>1i{X`;8W%gz@Tf0UaMhGnKL{O-|QNH2=YKU zLdw8eGK70XWj@$G?QIHDe=(SP@xr`iiZQ*1+WZ4 zDO$Lks!5J_j)bgy-5Nl4jWdeT!ZOa4lO){>OT2rf0mjoGl1j@=mVNHJu#OkYb4H?W z$D>~^eNp89N4t7y2w!rOSP_}>c?|vN`YF#;;VtwdLE`0EQb#y&>;aUqe!eWo;~TTn z78HEmvIX%r2SEgv8|G7Qx@VzsP;>hR9!M8|Ty!v4PE0>@f2jCt53#}0$BZsm$=lw3 zRowe2X^-_$Y{y2@(qKA!{yHw=2W+G{o(*Sz5uc%)Qhe0<>`X=3oow~cJvF)YpHF?J>&N( zqW)_KHY5Z5l7?ns6B>MvU@!e}ncO^_bw2*~g8Yc*Sk4Wr=1>MY<|UL& zT8ds9@AS3g!)n~|(%VuBth_jus13@RUT%EGmyJ6B5j#)#4dG%j7*6xR@Lc=+v82&o z6%Ga_ovpFJ?Bfxygej#DRa~~V=cp>jvsU7ZMNRB2<72X`EEwQ{k;xg~U$FV6tUX3^ zd0SI`5duytG6nZ~S08SaJm9iBu2S8pxXK*?#hZdZDjG zuRGnAc~ISlEx?V&ymM2>32@uZZs;(+q(?o1@bJ-nz}=`{ZoQ7CP55AshB7!= zbxkX-Z5iz)ye5%sMww#~!}7g$fDP?;rJc-IJzd)X8bsXBh$h_3(f%R<_|@U<}V#QacQ z-a>1&DCIYf92~>)t$Q)j9B^hP__cOeS6$Q>Lr4497HF4#K; z7HbIdISH+@5P@|cZ)O)%M&5}n(j={9x>r_>ADj8TrR#fv6=!q$Mh=Hu`<#@*4IlF^ z$@OT+Sl-gGnO396my`E0`Vx`!f2Z7aI%eOT#}}y5NA=zOn83NQqv^zfAuL(!RRA3!F1I(|9*hYyzBne>*3*W5mJ{kWqyl(v}wA7(3e45#vc z^dRY;4d3Qn38O)7w3ch;@m`++zRC@VTJNYRu6^)!ffKBXcR~$dP+Kv!kG5|lGf%>6 z7nknYPaU&KXnbfa5GgwZcx}1HMt?m{KE?1ywx1~-@Xq*(g%+ky!&?Vs29J?Gesl## z`-UZuPDH<}+j@a906gA|-)#hV4SEeqd~^UX$uj@1nF0T5|LwoR1pGURzs`XDyY_#5 z?Poupm"} +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","reqId":"req-1","req":{"method":"POST","url":"/render","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"info","name":"pixelpact","reqId":"req-1","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"actualBytes":9497,"renderDurationMs":"","msg":"Render completed"} +{"level":"info","name":"pixelpact","reqId":"req-1","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","signal":"SIGTERM","msg":"Shutting down"} +{"level":"info","name":"pixelpact","msg":"Shutdown complete"} diff --git a/server/acceptance/cases/default-log-level/golden/transcript.json b/server/acceptance/cases/default-log-level/golden/transcript.json new file mode 100644 index 0000000..7508f33 --- /dev/null +++ b/server/acceptance/cases/default-log-level/golden/transcript.json @@ -0,0 +1,27 @@ +{ + "steps": [ + { + "step": 1, + "request": { + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 01-render.actual.png>" + } + } + } + ], + "container": { + "exitCode": 0 + } +} diff --git a/server/acceptance/cases/form/case.test.js b/server/acceptance/cases/form/case.test.js new file mode 100644 index 0000000..97ee067 --- /dev/null +++ b/server/acceptance/cases/form/case.test.js @@ -0,0 +1,15 @@ +import { pixelpactCase } from "../../lib/pixelpact.js"; + +const { scenario, render, check, fixture } = pixelpactCase(import.meta.dirname); +const viewport = { width: 800, height: 600 }; + +// Blink captures a stylesheet as its own MIME part; this is the shape the real client sends. +scenario( + "checks a document with a CSS sub-resource against its own render", + async () => { + const actualHtml = fixture("input.mhtml"); + + const reference = await render({ actualHtml, viewport }); + await check({ actualHtml, expected: reference.png, viewport }); + }, +); diff --git a/server/acceptance/cases/form/golden/01-render.actual.png b/server/acceptance/cases/form/golden/01-render.actual.png new file mode 100644 index 0000000000000000000000000000000000000000..e472d7fa1d0be6add75783add36ba6bb2b11e971 GIT binary patch literal 13500 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"} +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","reqId":"req-1","req":{"method":"POST","url":"/render","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-1","mhtmlBytes":2151,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-1","indexFile":"/index.html","htmlBytes":1768,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-1","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":13500,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-1","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-1","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"actualBytes":13500,"renderDurationMs":"","msg":"Render completed"} +{"level":"info","name":"pixelpact","reqId":"req-1","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","req":{"method":"POST","url":"/check","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-2","mhtmlBytes":2151,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-2","indexFile":"/index.html","htmlBytes":1768,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-2","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":13500,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-2","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-2","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"expectedBytes":13500,"actualBytes":13500,"numDiffPixels":0,"renderDurationMs":"","compareDurationMs":"","msg":"Check completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","signal":"SIGTERM","msg":"Shutting down"} +{"level":"info","name":"pixelpact","msg":"Shutdown complete"} diff --git a/server/acceptance/cases/form/golden/transcript.json b/server/acceptance/cases/form/golden/transcript.json new file mode 100644 index 0000000..74c063d --- /dev/null +++ b/server/acceptance/cases/form/golden/transcript.json @@ -0,0 +1,51 @@ +{ + "steps": [ + { + "step": 1, + "request": { + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 01-render.actual.png>" + } + } + }, + { + "step": 2, + "request": { + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "", + "expected": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 02-check.actual.png>", + "expected": " 02-check.expected.png>", + "diff": " 02-check.diff.png>", + "numDiffPixels": 0 + } + } + } + ], + "container": { + "exitCode": 0 + } +} diff --git a/server/acceptance/cases/form/input.mhtml b/server/acceptance/cases/form/input.mhtml new file mode 100644 index 0000000..2d6f7c7 --- /dev/null +++ b/server/acceptance/cases/form/input.mhtml @@ -0,0 +1,68 @@ +From: +Snapshot-Content-Location: http://localhost:8000/ +Subject: Document +Date: Thu, 29 Jun 2023 10:36:55 -0000 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4----" + + +------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: quoted-printable +Content-Location: http://localhost:8000/ + + + =20 + + + + Document + + +

Hello World

+ +
+ +

+ + +

+ + +

+ +
+

+ + + +
+ + +------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4---- +Content-Type: text/css +Content-Transfer-Encoding: quoted-printable +Content-Location: http://localhost:8000/example.css + +@charset "utf-8"; + +body { background-color: pink; } + +form { + margin: 20px; + padding: 10px; + border: 2px solid black; + background-color: white; +} + +input, textarea, button { + margin: 10px 0; + padding: 5px; + font-size: 1rem; +} +------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4------ diff --git a/server/acceptance/cases/fullpage/case.test.js b/server/acceptance/cases/fullpage/case.test.js new file mode 100644 index 0000000..ee63c1f --- /dev/null +++ b/server/acceptance/cases/fullpage/case.test.js @@ -0,0 +1,12 @@ +import { pixelpactCase } from "../../lib/pixelpact.js"; + +const { scenario, render, fixture } = pixelpactCase(import.meta.dirname); +const viewport = { width: 800, height: 600 }; + +// Both renders are pinned, so the goldens show the flag actually reaching playwright. +scenario("captures the whole page only when fullpage is set", async () => { + const actualHtml = fixture("input.mhtml"); + + await render({ actualHtml, viewport }); + await render({ actualHtml, viewport, fullpage: true }); +}); diff --git a/server/acceptance/cases/fullpage/golden/01-render.actual.png b/server/acceptance/cases/fullpage/golden/01-render.actual.png new file mode 100644 index 0000000000000000000000000000000000000000..1a11e279fd9099bfe88c7aa16bb6c3649006f8aa GIT binary patch literal 11450 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 |}qrG62Ew#1Si1(3H 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 literal 0 HcmV?d00001 diff --git a/server/acceptance/cases/fullpage/golden/logs.jsonl b/server/acceptance/cases/fullpage/golden/logs.jsonl new file mode 100644 index 0000000..239f00c --- /dev/null +++ b/server/acceptance/cases/fullpage/golden/logs.jsonl @@ -0,0 +1,22 @@ +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","reqId":"req-1","req":{"method":"POST","url":"/render","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-1","mhtmlBytes":1269,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-1","indexFile":"/index.html","htmlBytes":1198,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-1","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":11450,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-1","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-1","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"actualBytes":11450,"renderDurationMs":"","msg":"Render completed"} +{"level":"info","name":"pixelpact","reqId":"req-1","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","req":{"method":"POST","url":"/render","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-2","mhtmlBytes":1269,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-2","indexFile":"/index.html","htmlBytes":1198,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-2","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":25863,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-2","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-2","viewport":{"width":800,"height":600},"fullpage":true,"mhtmlConverter":true,"styled":false,"actualBytes":25863,"renderDurationMs":"","msg":"Render completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","signal":"SIGTERM","msg":"Shutting down"} +{"level":"info","name":"pixelpact","msg":"Shutdown complete"} diff --git a/server/acceptance/cases/fullpage/golden/transcript.json b/server/acceptance/cases/fullpage/golden/transcript.json new file mode 100644 index 0000000..2e9db2a --- /dev/null +++ b/server/acceptance/cases/fullpage/golden/transcript.json @@ -0,0 +1,48 @@ +{ + "steps": [ + { + "step": 1, + "request": { + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 01-render.actual.png>" + } + } + }, + { + "step": 2, + "request": { + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + }, + "fullpage": true + } + }, + "response": { + "status": 200, + "body": { + "actual": " 02-render.actual.png>" + } + } + } + ], + "container": { + "exitCode": 0 + } +} diff --git a/server/acceptance/cases/fullpage/input.mhtml b/server/acceptance/cases/fullpage/input.mhtml new file mode 100644 index 0000000..9ccbf3a --- /dev/null +++ b/server/acceptance/cases/fullpage/input.mhtml @@ -0,0 +1,33 @@ +From: +Snapshot-Content-Location: http://localhost:8000/tall +Subject: Tall +Date: Thu, 29 Jun 2023 10:36:55 -0000 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--fullpageTallerThanTheViewport000000----" + + +------MultipartBoundary--fullpageTallerThanTheViewport000000---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: quoted-printable +Content-Location: http://localhost:8000/tall + + + Tall + + + +

Band one

The viewport ends inside band two.

+

Band two

+

Band three

+

Band four

Only a fullpage capture reaches this.

+ +------MultipartBoundary--fullpageTallerThanTheViewport000000------ diff --git a/server/acceptance/cases/invalid-request/case.test.js b/server/acceptance/cases/invalid-request/case.test.js new file mode 100644 index 0000000..8cfc8bb --- /dev/null +++ b/server/acceptance/cases/invalid-request/case.test.js @@ -0,0 +1,9 @@ +import { pixelpactCase } from "../../lib/pixelpact.js"; + +const { scenario, check, fixture } = pixelpactCase(import.meta.dirname); + +// The 400, the error envelope and the logged error all land in the goldens +// without this case naming any of them. +scenario("rejects a check without a viewport", async () => { + await check({ actualHtml: fixture("../plain/input.mhtml") }); +}); diff --git a/server/acceptance/cases/invalid-request/golden/logs.jsonl b/server/acceptance/cases/invalid-request/golden/logs.jsonl new file mode 100644 index 0000000..54a2ed7 --- /dev/null +++ b/server/acceptance/cases/invalid-request/golden/logs.jsonl @@ -0,0 +1,7 @@ +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","reqId":"req-1","req":{"method":"POST","url":"/check","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"warn","name":"pixelpact","reqId":"req-1","statusCode":400,"code":"FST_ERR_VALIDATION","reason":"body must have required property 'expected'","validation":[{"instancePath":"","schemaPath":"#/required","keyword":"required","params":{"missingProperty":"expected"},"message":"must have required property 'expected'"}],"msg":"Request rejected"} +{"level":"info","name":"pixelpact","reqId":"req-1","res":{"statusCode":400},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","signal":"SIGTERM","msg":"Shutting down"} +{"level":"info","name":"pixelpact","msg":"Shutdown complete"} diff --git a/server/acceptance/cases/invalid-request/golden/transcript.json b/server/acceptance/cases/invalid-request/golden/transcript.json new file mode 100644 index 0000000..b9bc817 --- /dev/null +++ b/server/acceptance/cases/invalid-request/golden/transcript.json @@ -0,0 +1,24 @@ +{ + "steps": [ + { + "step": 1, + "request": { + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "" + } + }, + "response": { + "status": 400, + "body": { + "message": "body must have required property 'expected'", + "statusCode": 400 + } + } + } + ], + "container": { + "exitCode": 0 + } +} diff --git a/server/acceptance/cases/mismatch/case.test.js b/server/acceptance/cases/mismatch/case.test.js new file mode 100644 index 0000000..35acda0 --- /dev/null +++ b/server/acceptance/cases/mismatch/case.test.js @@ -0,0 +1,22 @@ +import { pixelpactCase } from "../../lib/pixelpact.js"; + +const { scenario, render, check, fixture } = pixelpactCase(import.meta.dirname); +const viewport = { width: 800, height: 600 }; + +// Same document as ../form, one heading changed. The reference is rendered here +// rather than read from form/golden, so no golden is ever an input to a case. +scenario( + "reports a diff when the reference came from different HTML", + async () => { + const reference = await render({ + actualHtml: fixture("../form/input.mhtml"), + viewport, + }); + + await check({ + actualHtml: fixture("input.mhtml"), + expected: reference.png, + viewport, + }); + }, +); diff --git a/server/acceptance/cases/mismatch/golden/01-render.actual.png b/server/acceptance/cases/mismatch/golden/01-render.actual.png new file mode 100644 index 0000000000000000000000000000000000000000..e472d7fa1d0be6add75783add36ba6bb2b11e971 GIT binary patch literal 13500 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 AR{#J2 literal 0 HcmV?d00001 diff --git a/server/acceptance/cases/mismatch/golden/02-check.diff.png b/server/acceptance/cases/mismatch/golden/02-check.diff.png new file mode 100644 index 0000000000000000000000000000000000000000..9fbccfb9d257c5f095fad933e59f406fe94beacf GIT binary patch literal 14369 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"} +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","reqId":"req-1","req":{"method":"POST","url":"/render","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-1","mhtmlBytes":2151,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-1","indexFile":"/index.html","htmlBytes":1768,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-1","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":13500,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-1","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-1","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"actualBytes":13500,"renderDurationMs":"","msg":"Render completed"} +{"level":"info","name":"pixelpact","reqId":"req-1","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","req":{"method":"POST","url":"/check","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-2","mhtmlBytes":2154,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-2","indexFile":"/index.html","htmlBytes":1771,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-2","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":14163,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-2","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-2","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"expectedBytes":13500,"actualBytes":14163,"numDiffPixels":911,"renderDurationMs":"","compareDurationMs":"","msg":"Check completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","signal":"SIGTERM","msg":"Shutting down"} +{"level":"info","name":"pixelpact","msg":"Shutdown complete"} diff --git a/server/acceptance/cases/mismatch/golden/transcript.json b/server/acceptance/cases/mismatch/golden/transcript.json new file mode 100644 index 0000000..7279c0f --- /dev/null +++ b/server/acceptance/cases/mismatch/golden/transcript.json @@ -0,0 +1,51 @@ +{ + "steps": [ + { + "step": 1, + "request": { + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 01-render.actual.png>" + } + } + }, + { + "step": 2, + "request": { + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "", + "expected": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 02-check.actual.png>", + "expected": " 02-check.expected.png>", + "diff": " 02-check.diff.png>", + "numDiffPixels": 911 + } + } + } + ], + "container": { + "exitCode": 0 + } +} diff --git a/server/acceptance/cases/mismatch/input.mhtml b/server/acceptance/cases/mismatch/input.mhtml new file mode 100644 index 0000000..89c5176 --- /dev/null +++ b/server/acceptance/cases/mismatch/input.mhtml @@ -0,0 +1,68 @@ +From: +Snapshot-Content-Location: http://localhost:8000/ +Subject: Document +Date: Thu, 29 Jun 2023 10:36:55 -0000 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4----" + + +------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: quoted-printable +Content-Location: http://localhost:8000/ + + + =20 + + + + Document + + +

Hello Mismatch

+ +
+ +

+ + +

+ + +

+ +
+

+ + + +
+ + +------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4---- +Content-Type: text/css +Content-Transfer-Encoding: quoted-printable +Content-Location: http://localhost:8000/example.css + +@charset "utf-8"; + +body { background-color: pink; } + +form { + margin: 20px; + padding: 10px; + border: 2px solid black; + background-color: white; +} + +input, textarea, button { + margin: 10px 0; + padding: 5px; + font-size: 1rem; +} +------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4------ diff --git a/server/acceptance/cases/plain/case.test.js b/server/acceptance/cases/plain/case.test.js new file mode 100644 index 0000000..3a10936 --- /dev/null +++ b/server/acceptance/cases/plain/case.test.js @@ -0,0 +1,11 @@ +import { pixelpactCase } from "../../lib/pixelpact.js"; + +const { scenario, render, check, fixture } = pixelpactCase(import.meta.dirname); +const viewport = { width: 800, height: 600 }; + +scenario("checks a single-part document against its own render", async () => { + const actualHtml = fixture("input.mhtml"); + + const reference = await render({ actualHtml, viewport }); + await check({ actualHtml, expected: reference.png, viewport }); +}); diff --git a/server/acceptance/cases/plain/golden/01-render.actual.png b/server/acceptance/cases/plain/golden/01-render.actual.png new file mode 100644 index 0000000000000000000000000000000000000000..a60d7de5f00ea2c1475c802a8ed06be0883227f7 GIT binary patch literal 9497 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 DxZvP! literal 0 HcmV?d00001 diff --git a/server/acceptance/cases/plain/golden/02-check.expected.png b/server/acceptance/cases/plain/golden/02-check.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..a60d7de5f00ea2c1475c802a8ed06be0883227f7 GIT binary patch literal 9497 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"} +{"level":"info","name":"pixelpact","msg":"Server listening at
"} +{"level":"info","name":"pixelpact","reqId":"req-1","req":{"method":"POST","url":"/render","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-1","mhtmlBytes":950,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-1","indexFile":"/index.html","htmlBytes":921,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-1","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-1","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":9497,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-1","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-1","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"actualBytes":9497,"renderDurationMs":"","msg":"Render completed"} +{"level":"info","name":"pixelpact","reqId":"req-1","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","req":{"method":"POST","url":"/check","host":"","remoteAddress":"
","remotePort":""},"msg":"incoming request"} +{"level":"debug","name":"pixelpact","reqId":"req-2","mhtmlBytes":950,"workspaceDirectory":"","msg":"Rendering page source"} +{"level":"debug","name":"pixelpact","reqId":"req-2","indexFile":"/index.html","htmlBytes":921,"msg":"Converted MHTML to HTML"} +{"level":"debug","name":"pixelpact","reqId":"req-2","browserVersion":"153.0.8010.12","durationMs":"","msg":"Browser launched"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","msg":"Loading page"} +{"level":"debug","name":"pixelpact","reqId":"req-2","url":"file:///index.html","loadDurationMs":"","screenshotDurationMs":"","bytes":9497,"msg":"Screenshot taken"} +{"level":"debug","name":"pixelpact","reqId":"req-2","msg":"Browser closed"} +{"level":"info","name":"pixelpact","reqId":"req-2","viewport":{"width":800,"height":600},"fullpage":false,"mhtmlConverter":true,"styled":false,"expectedBytes":9497,"actualBytes":9497,"numDiffPixels":0,"renderDurationMs":"","compareDurationMs":"","msg":"Check completed"} +{"level":"info","name":"pixelpact","reqId":"req-2","res":{"statusCode":200},"responseTime":"","msg":"request completed"} +{"level":"info","name":"pixelpact","signal":"SIGTERM","msg":"Shutting down"} +{"level":"info","name":"pixelpact","msg":"Shutdown complete"} diff --git a/server/acceptance/cases/plain/golden/transcript.json b/server/acceptance/cases/plain/golden/transcript.json new file mode 100644 index 0000000..04b9328 --- /dev/null +++ b/server/acceptance/cases/plain/golden/transcript.json @@ -0,0 +1,51 @@ +{ + "steps": [ + { + "step": 1, + "request": { + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 01-render.actual.png>" + } + } + }, + { + "step": 2, + "request": { + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "", + "expected": "", + "viewport": { + "width": 800, + "height": 600 + } + } + }, + "response": { + "status": 200, + "body": { + "actual": " 02-check.actual.png>", + "expected": " 02-check.expected.png>", + "diff": " 02-check.diff.png>", + "numDiffPixels": 0 + } + } + } + ], + "container": { + "exitCode": 0 + } +} diff --git a/server/acceptance/cases/plain/input.mhtml b/server/acceptance/cases/plain/input.mhtml new file mode 100644 index 0000000..72c54f3 --- /dev/null +++ b/server/acceptance/cases/plain/input.mhtml @@ -0,0 +1,26 @@ +From: +Snapshot-Content-Location: http://localhost:8000/plain +Subject: Plain +Date: Thu, 29 Jun 2023 10:36:55 -0000 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--plainSingleParteNoSubResources0000000000----" + + +------MultipartBoundary--plainSingleParteNoSubResources0000000000---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: quoted-printable +Content-Location: http://localhost:8000/plain + + + Plain + + + +

Plain document

+

A single MIME part with no sub-resources.

+ +------MultipartBoundary--plainSingleParteNoSubResources0000000000------ diff --git a/server/acceptance/lib/container.js b/server/acceptance/lib/container.js new file mode 100644 index 0000000..4304b48 --- /dev/null +++ b/server/acceptance/lib/container.js @@ -0,0 +1,155 @@ +import { spawn } from "node:child_process"; + +const READY_DEADLINE_MS = 60_000; +const READY_POLL_MS = 250; +/** What `index.js` logs once fastify is bound. */ +const LISTENING_MESSAGE = "Server listening at"; + +/** Runs `docker` with {@link args}. + * @param {string[]} args + * @param {{stream?: boolean}} options `stream` inherits stdio instead of capturing it, + * so a cold `docker build` reports progress as it happens. + * @returns {Promise<{stdout: string, stderr: string, code: number}>} + */ +export function docker(args, { stream = false } = {}) { + return new Promise((resolve, reject) => { + const child = spawn("docker", args, { + stdio: stream + ? ["ignore", "inherit", "inherit"] + : ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => (stdout += chunk)); + child.stderr?.on("data", (chunk) => (stderr += chunk)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr, code }); + } else { + reject( + new Error( + `docker ${args.join(" ")} exited with ${code}${stderr ? `\n${stderr}` : ""}`, + ), + ); + } + }); + }); +} + +/** Starts a container of {@link image} and waits until it is listening. + * + * The image hardcodes port 8888 with no PORT override, so the host side is + * published as an ephemeral port to keep concurrent runs from colliding. + * + * @param {string} image + * @param {Record} env passed through as `-e KEY=VALUE` + * @returns {Promise<{id: string, baseUrl: string}>} + */ +export async function startContainer(image, env) { + const environment = Object.entries(env).flatMap(([key, value]) => [ + "-e", + `${key}=${value}`, + ]); + const { stdout } = await docker([ + "run", + "--detach", + "--publish", + "127.0.0.1:0:8888", + ...environment, + image, + ]); + const id = stdout.trim(); + + try { + const container = { id, baseUrl: await resolveBaseUrl(id) }; + await waitUntilReady(container); + return container; + } catch (error) { + // Never leak a container just because it failed to come up. + await removeContainer({ id }).catch(() => {}); + throw error; + } +} + +async function resolveBaseUrl(id) { + const { stdout } = await docker(["port", id, "8888/tcp"]); + const port = stdout.trim().split("\n")[0]?.split(":").pop(); + if (!port) { + throw new Error(`Failed to resolve the published port of container ${id}`); + } + return `http://127.0.0.1:${port}`; +} + +/** Waits for the server to say it is listening. + * + * Readiness is read from the log rather than probed over HTTP on purpose: a + * probe request would land in the log golden of every single case and shift + * every reqId. It also needs no route, which the server is short of - there is + * no GET endpoint and no healthcheck. + * + * This means a case must run at a level where `info` is emitted. Every case + * does; a quieter one would need a different signal. + */ +async function waitUntilReady(container) { + const deadline = Date.now() + READY_DEADLINE_MS; + while (Date.now() < deadline) { + const { stdout, stderr } = await readContainerLogs(container); + if (stdout.includes(LISTENING_MESSAGE)) { + return; + } + if (!(await isRunning(container))) { + throw new Error( + `Container ${container.id} exited before it started listening.` + + logTail({ stdout, stderr }), + ); + } + await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS)); + } + throw new Error( + `Container ${container.id} did not log "${LISTENING_MESSAGE}" within ` + + `${READY_DEADLINE_MS}ms.` + + logTail(await readContainerLogs(container).catch(() => ({}))), + ); +} + +async function isRunning(container) { + const { stdout } = await docker([ + "inspect", + "--format", + "{{.State.Running}}", + container.id, + ]); + return stdout.trim() === "true"; +} + +function logTail({ stdout = "", stderr = "" }) { + return `\n--- container logs ---\n${stdout}${stderr}`; +} + +/** Stops the container so the log stream covers the whole lifecycle, + * including whatever the SIGTERM handler does. + * @returns {Promise} the container's exit code + */ +export async function stopContainer(container) { + await docker(["stop", "--timeout", "5", container.id]); + const { stdout } = await docker([ + "inspect", + "--format", + "{{.State.ExitCode}}", + container.id, + ]); + return Number(stdout.trim()); +} + +/** Reads everything the container wrote. stdout and stderr are captured + * separately because docker demultiplexes them; stderr is appended last and + * marked by {@link normalizeLogs}, since anything there is a crash, not logging. */ +export async function readContainerLogs(container) { + const { stdout, stderr } = await docker(["logs", container.id]); + return { stdout, stderr }; +} + +export async function removeContainer(container) { + await docker(["rm", "--force", container.id]); +} diff --git a/server/acceptance/lib/image.js b/server/acceptance/lib/image.js new file mode 100644 index 0000000..671fbc1 --- /dev/null +++ b/server/acceptance/lib/image.js @@ -0,0 +1,30 @@ +import path from "node:path"; +import { docker } from "./container.js"; + +const BUILT_TAG = "pixelpact:acceptance"; + +/** vitest `globalSetup`: settles which image the suite drives and whether + * goldens may be written, then hands both to the cases via `inject()`. + * + * CI builds the image once and passes it in via `PIXELPACT_IMAGE`; locally we + * build it ourselves and leave it in place so the next run is warm. + */ +export default async function setup(project) { + const provided = process.env.PIXELPACT_IMAGE; + if (provided) { + // Fail loudly here rather than once per case with a confusing `docker run` error. + await docker(["image", "inspect", provided]); + } else { + const serverDirectory = path.resolve(import.meta.dirname, "../.."); + await docker(["build", "--tag", BUILT_TAG, serverDirectory], { + stream: true, + }); + } + + project.provide("image", provided ?? BUILT_TAG); + // "all" with -u, "new" locally (write what is missing), "none" on CI (missing is a failure). + project.provide( + "updateSnapshot", + project.config.snapshotOptions.updateSnapshot, + ); +} diff --git a/server/acceptance/lib/pixelpact.js b/server/acceptance/lib/pixelpact.js new file mode 100644 index 0000000..5c9dfc5 --- /dev/null +++ b/server/acceptance/lib/pixelpact.js @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, inject, it } from "vitest"; +import { + readContainerLogs, + removeContainer, + startContainer, + stopContainer, +} from "./container.js"; +import { createRecorder, normalizeLogs, pinGoldens } from "./transcript.js"; + +/** Everything a case needs. A case says what to do; the goldens say what happened. + * + * Each test gets its own container, so the whole log stream belongs to that one + * test and cases cannot leak state into each other. + * + * @param {string} dir the case directory, as `import.meta.dirname` + * @param {Record} env container environment; `LOG_LEVEL=debug` + * by default so the goldens capture the full render trace + */ +export function pixelpactCase(dir, env = { LOG_LEVEL: "debug" }) { + let container; + let recorder; + + beforeEach(async () => { + recorder = createRecorder(); + container = await startContainer(inject("image"), env); + }); + + afterEach(async () => { + if (container) { + await removeContainer(container); + container = undefined; + } + }); + + /** Reads a file next to the case. `"../form/input.mhtml"` works as written. */ + const fixture = (relativePath) => + fs.readFileSync(path.resolve(dir, relativePath)); + + /** Raw escape hatch. Never throws on a non-2xx - the status is part of the transcript. */ + async function post(route, body) { + const response = await fetch(`${container.baseUrl}${route}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const raw = await response.text(); + let responseBody; + try { + responseBody = JSON.parse(raw); + } catch { + // A non-JSON body from a JSON API is itself worth pinning. + responseBody = { nonJsonBody: raw }; + } + recorder.record({ + method: "POST", + route, + requestBody: body, + status: response.status, + responseBody, + }); + return { status: response.status, body: responseBody }; + } + + async function render({ actualHtml, ...options }) { + const { status, body } = await post("/render", { + actualHtml: asText(actualHtml), + ...options, + }); + return { status, body, png: asPng(body.actual) }; + } + + async function check({ actualHtml, expected, ...options }) { + const { status, body } = await post("/check", { + actualHtml: asText(actualHtml), + ...(expected === undefined ? {} : { expected: asBase64(expected) }), + ...options, + }); + return { + status, + body, + png: asPng(body.actual), + expected: asPng(body.expected), + diff: asPng(body.diff), + }; + } + + /** `it` plus the pinning, so a case body only has to say what to do. + * + * The pinning runs inside the test rather than in `afterEach` for two + * reasons: `toMatchFileSnapshot` needs a test context, and a body that + * throws should report its own error instead of a pile of golden diffs. + */ + function scenario(name, body) { + it(name, async () => { + await body(); + const exitCode = await stopContainer(container); + const logs = normalizeLogs(await readContainerLogs(container)); + await pinGoldens({ + dir, + steps: recorder.steps, + container: { exitCode }, + logs, + updateSnapshot: inject("updateSnapshot"), + }); + }); + } + + return { scenario, render, check, post, fixture }; +} + +// Buffers in, Buffers out - base64 never appears in a case. +const asText = (value) => + Buffer.isBuffer(value) ? value.toString("utf8") : value; +const asBase64 = (value) => + Buffer.isBuffer(value) ? value.toString("base64") : value; +const asPng = (value) => + typeof value === "string" ? Buffer.from(value, "base64") : undefined; diff --git a/server/acceptance/lib/transcript.js b/server/acceptance/lib/transcript.js new file mode 100644 index 0000000..cfdca0a --- /dev/null +++ b/server/acceptance/lib/transcript.js @@ -0,0 +1,284 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "vitest"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; + +/** Strings at least this long are payloads, not values worth reading inline. */ +const BLOB_MIN_LENGTH = 256; +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const TRANSCRIPT = "transcript.json"; +const LOGS = "logs.jsonl"; + +/** Collects every request a case makes so the whole exchange can be pinned at once. + * Cases assert nothing themselves; this is what makes that possible. */ +export function createRecorder() { + const steps = []; + return { + record(exchange) { + steps.push({ step: steps.length + 1, ...exchange }); + }, + steps, + }; +} + +/** Pins the recorded exchange, the response payloads and the log stream + * against `/golden/`. + * + * Text goldens go through vitest's own `toMatchFileSnapshot`, so `-u` handling + * is public API. Buffers are compared byte-exact and follow the same + * `updateSnapshot` semantics by hand: "all" rewrites, "new" fills in what is + * missing, "none" (CI) treats a missing golden as a failure. + */ +export async function pinGoldens({ + dir, + steps, + container, + logs, + updateSnapshot, +}) { + const goldenDirectory = path.join(dir, "golden"); + const transcript = { steps: [], container }; + const payloads = new Map(); + + for (const { + step, + method, + route, + requestBody, + status, + responseBody, + } of steps) { + const operation = route.replace(/^\//, ""); + const request = elide(requestBody, {}); + // Only responses get their payloads written out - requests are either + // repository fixtures or a previous response, and the sha shows the linkage. + const response = elide(responseBody, { step, operation, payloads }); + transcript.steps.push({ + step, + request: { method, path: route, body: request }, + response: { status, body: response }, + }); + } + + if (updateSnapshot === "all") { + await fs.rm(goldenDirectory, { recursive: true, force: true }); + } + await fs.mkdir(goldenDirectory, { recursive: true }); + + for (const [name, bytes] of payloads) { + await pinBuffer({ dir, goldenDirectory, name, bytes, updateSnapshot }); + } + + await expect(`${JSON.stringify(transcript, null, 2)}\n`).toMatchFileSnapshot( + path.join(goldenDirectory, TRANSCRIPT), + ); + await expect( + logs.map((entry) => `${JSON.stringify(entry)}\n`).join(""), + ).toMatchFileSnapshot(path.join(goldenDirectory, LOGS)); + + await assertNothingObsolete(goldenDirectory, [ + ...payloads.keys(), + TRANSCRIPT, + LOGS, + ]); +} + +/** Replaces payload strings with a descriptor, and records the bytes to pin + * when {@link payloads} is supplied. */ +function elide(body, { step, operation, payloads }) { + const elided = {}; + for (const [key, value] of Object.entries(body ?? {})) { + if (typeof value !== "string" || value.length < BLOB_MIN_LENGTH) { + elided[key] = value; + continue; + } + const { kind, bytes, extension } = classify(value); + let name; + if (payloads) { + name = `${String(step).padStart(2, "0")}-${operation}.${key}.${extension}`; + payloads.set(name, bytes); + } + const digest = crypto + .createHash("sha256") + .update(bytes) + .digest("hex") + .slice(0, 12); + elided[key] = + `<${kind} ${bytes.length} bytes sha256:${digest}${name ? ` -> ${name}` : ""}>`; + } + return elided; +} + +function classify(value) { + const decoded = decodeBase64(value); + if (decoded?.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + return { kind: "png", bytes: decoded, extension: "png" }; + } + const bytes = Buffer.from(value, "utf8"); + const isMhtml = /^(From: |MIME-Version:)/m.test(value); + return { + kind: isMhtml ? "mhtml" : "text", + bytes, + extension: isMhtml ? "mhtml" : "txt", + }; +} + +function decodeBase64(value) { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + return undefined; + } + const decoded = Buffer.from(value, "base64"); + return decoded.length > 0 ? decoded : undefined; +} + +async function pinBuffer({ + dir, + goldenDirectory, + name, + bytes, + updateSnapshot, +}) { + const file = path.join(goldenDirectory, name); + const golden = await fs.readFile(file).catch(() => undefined); + + if (updateSnapshot === "all" || (updateSnapshot === "new" && !golden)) { + await fs.writeFile(file, bytes); + return; + } + if (!golden) { + throw new Error( + `Golden golden/${name} is missing. Regenerate with \`npm run test:acceptance -- -u\`.`, + ); + } + if (Buffer.compare(golden, bytes) !== 0) { + const out = await dumpFailure({ dir, name, actual: bytes, golden }); + throw new Error( + `Golden golden/${name} differs (${golden.length} -> ${bytes.length} bytes). ` + + `Wrote both sides to ${out}.`, + ); + } +} + +/** A case that stops making a request must not leave its golden behind. */ +async function assertNothingObsolete(goldenDirectory, accounted) { + const present = await fs.readdir(goldenDirectory); + const obsolete = present.filter((name) => !accounted.includes(name)); + if (obsolete.length > 0) { + throw new Error( + `Obsolete goldens no longer produced by this case: ${obsolete.join(", ")}. ` + + `Remove them, or regenerate with \`npm run test:acceptance -- -u\`.`, + ); + } +} + +async function dumpFailure({ dir, name, actual, golden }) { + const out = path.join(dir, "..", "..", "out", path.basename(dir)); + await fs.mkdir(out, { recursive: true }); + await fs.writeFile(path.join(out, `actual-${name}`), actual); + await fs.writeFile(path.join(out, `golden-${name}`), golden); + if (name.endsWith(".png")) { + await writePixelDiff(path.join(out, `pixeldiff-${name}`), actual, golden); + } + return path.relative(path.join(dir, "..", ".."), out); +} + +/** A human-readable diff for the failure artifacts only - never an assertion. */ +async function writePixelDiff(file, actual, golden) { + try { + const actualPng = PNG.sync.read(actual); + const goldenPng = PNG.sync.read(golden); + if ( + actualPng.width !== goldenPng.width || + actualPng.height !== goldenPng.height + ) { + return; + } + const diff = new PNG({ width: actualPng.width, height: actualPng.height }); + pixelmatch( + goldenPng.data, + actualPng.data, + diff.data, + actualPng.width, + actualPng.height, + { threshold: 0 }, + ); + await fs.writeFile(file, PNG.sync.write(diff)); + } catch { + // Debugging aid; never the reason a test fails. + } +} + +/** Turns the container's output into stable, comparable entries. + * + * Unparseable stdout is kept as `raw` rather than dropped - a stray + * `console.log` or a half-formatted stack is broken logging and should fail. + * Anything on stderr is a crash, not logging, so it is marked as such. + */ +export function normalizeLogs({ stdout, stderr }) { + return [ + ...lines(stdout).map(normalizeLine), + ...lines(stderr).map((line) => ({ stream: "stderr", raw: scrub(line) })), + ]; +} + +function lines(output) { + return output.split("\n").filter((line) => line.trim().length > 0); +} + +function normalizeLine(line) { + try { + // Wall clock, pid and hostname differ on every run and say nothing. + const { time, pid, hostname, ...rest } = JSON.parse(line); + return scrubDeep(rest); + } catch { + return { raw: scrub(line) }; + } +} + +/** Fields whose value is different on every run. The placeholder keeps the + * field itself pinned, so one appearing or disappearing is still a failure - + * only its unstable value is dropped. Timings, durations and byte counts that + * *are* stable (actualBytes, numDiffPixels, browserVersion) stay verbatim: + * browserVersion in particular is how a playwright bump announces itself. */ +const VOLATILE = { + responseTime: "", + host: "", + remoteAddress: "
", + remotePort: "", +}; + +const isDuration = (key) => /^(duration|.*Duration)Ms$/.test(key); + +function scrub(value) { + return ( + value + // A fresh mkdtemp per request. + .replace(/\/tmp\/pixelpact-[A-Za-z0-9]+/g, "") + // The container's bridge IP and the published ephemeral port. + .replace(/http:\/\/\d+\.\d+\.\d+\.\d+:\d+/g, "
") + ); +} + +function scrubDeep(value) { + if (typeof value === "string") { + return scrub(value); + } + if (Array.isArray(value)) { + return value.map(scrubDeep); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => { + if (isDuration(key)) { + return [key, ""]; + } + return [key, key in VOLATILE ? VOLATILE[key] : scrubDeep(nested)]; + }), + ); + } + return value; +} diff --git a/server/package.json b/server/package.json index 6dd3f6c..f904372 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,8 @@ "scripts": { "start": "LOG_LEVEL=debug node --watch src/index.js | pino-pretty", "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:acceptance": "vitest run --config vitest.acceptance.config.js" }, "dependencies": { "fastify": "5.12.4", diff --git a/server/vitest.acceptance.config.js b/server/vitest.acceptance.config.js new file mode 100644 index 0000000..c2e4d65 --- /dev/null +++ b/server/vitest.acceptance.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["acceptance/cases/**/*.test.js"], + globalSetup: ["acceptance/lib/image.js"], + // Each case launches a container plus a chromium instance (~200MB). + pool: "forks", + fileParallelism: false, + // A cold `docker build` has to fit in globalSetup. + hookTimeout: 600_000, + testTimeout: 120_000, + }, +}); From 10dfdd01ba5e76b52e43b7ca53444c7723137218 Mon Sep 17 00:00:00 2001 From: Raphael Zimmermann Date: Mon, 14 Sep 2026 13:02:18 +0200 Subject: [PATCH 2/4] Gate the published image on the acceptance suite build-docker-image.yml pushed to ghcr.io with no test gate at all. Merge it into test.yml so `needs:` can do the gating - across workflows it cannot. One job builds, runs the acceptance suite, and only then pushes, so the image that reaches the registry is byte-for-byte the one that was just verified. The push steps are guarded by ref rather than by trigger; `on: [push]` already fires for tags, which is what the old `create` trigger covered. For Renovate automerge to actually wait on this, `docker-image` has to be added to the required status checks in branch protection. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015Mx7PVadB4KQULCMucVCFu --- .github/workflows/test.yml | 99 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d266138..38fb734 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,3 +58,102 @@ jobs: - name: Run Tests run: nix develop --command bash -c 'cd server && npm test' + + # Builds the image, proves it works end to end against the golden samples, and + # only then publishes it. Build, test and push share one job so the artifact + # that gets pushed is byte-for-byte the one the acceptance suite just drove. + docker-image: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + # The image under test ships its own browsers; playwright is only a + # transitive install here. + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Nix + uses: nixbuild/nix-quick-install-action@9f63be77f412a248c9d9a65a4c82cf066cdf8f0c # v35 + with: + nix_conf: | + keep-env-derivations = true + keep-outputs = true + + - name: Restore Nix store + uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'flake.nix', 'nix/**.nix') }} + restore-prefixes-first-match: nix-${{ runner.os }}- + # The test job owns this cache; this job only reads it. + save: false + + - name: Build dev shell + run: nix develop --command true + + - name: Restore npm cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }} + restore-keys: npm-${{ runner.os }}- + + # Runs before the build so the labels are baked into the image we test. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build Docker image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: server + push: false + load: true + tags: pixelpact:ci + labels: ${{ steps.meta.outputs.labels }} + + - name: Install dependencies + run: nix develop --command bash -c 'cd server && npm ci --prefer-offline --no-audit --no-fund' + + - name: Run acceptance tests + env: + PIXELPACT_IMAGE: pixelpact:ci + run: nix develop --command bash -c 'cd server && npm run test:acceptance' + + - name: Upload acceptance failure artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-failures + path: server/acceptance/out/ + if-no-files-found: ignore + + - name: Log in to the Container registry + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Pushes the exact image the acceptance suite just verified, rather than + # rebuilding it. + - name: Push Docker image + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: | + printf '%s\n' "$TAGS" | while IFS= read -r tag; do + [ -n "$tag" ] || continue + docker tag pixelpact:ci "$tag" + docker push "$tag" + done From 802ca3e8bbda6888eea628798837dee10b09bee3 Mon Sep 17 00:00:00 2001 From: Raphael Zimmermann Date: Mon, 14 Sep 2026 13:28:54 +0200 Subject: [PATCH 3/4] Rename the workflow to ci.yml and drop the subsumed integration test test.yml no longer only tests - it builds and publishes the image too, so the name was misleading. Job names are unchanged, so required status checks still match. src/integrationtest.test.js is fully covered by the acceptance suite now: its render-then-check round trip is cases/plain and cases/form, and its differing-reference case is cases/mismatch. The acceptance versions assert byte-exact PNGs and an exact numDiffPixels instead of `length > 0` and `> 0`, and they do it against the real image - where MHTML rendering is actually reproducible. testdata/template.mhtml had no other user. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015Mx7PVadB4KQULCMucVCFu --- .github/workflows/{test.yml => ci.yml} | 2 +- server/src/integrationtest.test.js | 79 -------------------------- server/testdata/template.mhtml | 68 ---------------------- 3 files changed, 1 insertion(+), 148 deletions(-) rename .github/workflows/{test.yml => ci.yml} (99%) delete mode 100644 server/src/integrationtest.test.js delete mode 100644 server/testdata/template.mhtml diff --git a/.github/workflows/test.yml b/.github/workflows/ci.yml similarity index 99% rename from .github/workflows/test.yml rename to .github/workflows/ci.yml index 38fb734..3a8cdcf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Run tests +name: CI on: [push] diff --git a/server/src/integrationtest.test.js b/server/src/integrationtest.test.js deleted file mode 100644 index 3439f65..0000000 --- a/server/src/integrationtest.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { startApiServer } from "./api.js"; -import { getLocalAddress } from "./helpers.js"; -import fs from "fs/promises"; - -describe("check integration test", () => { - let instance, baseUrl; - - beforeAll(async () => { - instance = await startApiServer(0); - baseUrl = getLocalAddress(instance); - }); - - afterAll(async () => { - await instance.close(); - }); - - it("succeeds when calling check with the reference image generated using render", async () => { - const viewport = { width: 1920, height: 1024 }; - const htmlContent = await mhtmlOf("

Hello World

"); - const reference = await render(htmlContent, viewport); - - const result = await check(htmlContent, reference, viewport); - - expect(result.expected).toBe(reference); - expect(result.actual).toBe(reference); - expect(result.numDiffPixels).toBe(0); - }); - - it("fails when calling check with a reference obtained from a different HTML content", async () => { - const viewport = { width: 1920, height: 1024 }; - const reference = await render( - await mhtmlOf("

Hello Jack

"), - viewport, - ); - const result = await check( - await mhtmlOf("

Hello Jill

"), - reference, - viewport, - ); - expect(result.expected).toBe(reference); - expect(result.actual).not.toBe(reference); - expect(result.numDiffPixels).toBeGreaterThan(0); - }); - - async function render(actualHtml, viewport) { - const response = await fetch(`${baseUrl}/render`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ actualHtml, viewport }), - }); - - expect(response.status).toBe(200); - - const body = await response.json(); - expect(body.actual.length).toBeGreaterThan(0); - - return body.actual; - } - - async function check(actualHtml, expected, viewport) { - const response = await fetch(`${baseUrl}/check`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ actualHtml, expected, viewport }), - }); - - const body = await response.json(); - expect(body.diff.length).toBeGreaterThan(0); - expect(body.actual.length).toBeGreaterThan(0); - expect(body.expected.length).toBeGreaterThan(0); - - return body; - } -}); - -async function mhtmlOf(html) { - const template = (await fs.readFile("testdata/template.mhtml")).toString(); - return template.replace("Hello World", html); -} diff --git a/server/testdata/template.mhtml b/server/testdata/template.mhtml deleted file mode 100644 index 2d6f7c7..0000000 --- a/server/testdata/template.mhtml +++ /dev/null @@ -1,68 +0,0 @@ -From: -Snapshot-Content-Location: http://localhost:8000/ -Subject: Document -Date: Thu, 29 Jun 2023 10:36:55 -0000 -MIME-Version: 1.0 -Content-Type: multipart/related; - type="text/html"; - boundary="----MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4----" - - -------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4---- -Content-Type: text/html -Content-ID: -Content-Transfer-Encoding: quoted-printable -Content-Location: http://localhost:8000/ - - - =20 - - - - Document - - -

Hello World

- -
- -

- - -

- - -

- -
-

- - - -
- - -------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4---- -Content-Type: text/css -Content-Transfer-Encoding: quoted-printable -Content-Location: http://localhost:8000/example.css - -@charset "utf-8"; - -body { background-color: pink; } - -form { - margin: 20px; - padding: 10px; - border: 2px solid black; - background-color: white; -} - -input, textarea, button { - margin: 10px 0; - padding: 5px; - font-size: 1rem; -} -------MultipartBoundary--mJEnOoe6QIyy5FUC8dMc2jWfHPkkc7zRmcu5QqHSL4------ From 28f1d5f9f0da6a2185b2c643b09f25a2690b50bf Mon Sep 17 00:00:00 2001 From: Raphael Zimmermann Date: Mon, 14 Sep 2026 13:37:21 +0200 Subject: [PATCH 4/4] Call them requests in the transcript, not steps Every entry is exactly one HTTP request, so "steps" was vaguer than the thing it named - and `steps[i].step` stuttered. The request is now flattened into the entry, which avoids the `requests[i].request` stutter in turn, and the explicit number is gone since the array already orders them and the payload descriptors carry the filename. Only the six transcript.json files change; PNG and log goldens are byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015Mx7PVadB4KQULCMucVCFu --- server/acceptance/README.md | 33 ++++++++++++--- .../default-log-level/golden/transcript.json | 19 ++++----- .../cases/form/golden/transcript.json | 38 ++++++++---------- .../cases/fullpage/golden/transcript.json | 40 ++++++++----------- .../invalid-request/golden/transcript.json | 13 +++--- .../cases/mismatch/golden/transcript.json | 38 ++++++++---------- .../cases/plain/golden/transcript.json | 38 ++++++++---------- server/acceptance/lib/pixelpact.js | 2 +- server/acceptance/lib/transcript.js | 39 ++++++++++-------- 9 files changed, 128 insertions(+), 132 deletions(-) diff --git a/server/acceptance/README.md b/server/acceptance/README.md index c2bf969..0620a8d 100644 --- a/server/acceptance/README.md +++ b/server/acceptance/README.md @@ -83,16 +83,37 @@ cases/form/golden/ logs.jsonl the container's whole log stream, normalized ``` -`transcript.json` also records the container's exit code, so a broken shutdown -path shows up. Response payloads over 256 bytes become a descriptor plus their -own file: +`transcript.json` is a list of `requests`, each with its `response`, plus the +container's exit code — so a broken shutdown path shows up too: ```json -"actual": " 01-render.actual.png>" +{ + "requests": [ + { + "method": "POST", + "path": "/render", + "body": { "actualHtml": "", "viewport": {...} }, + "response": { + "status": 200, + "body": { "actual": " 01-render.actual.png>" } + } + } + ], + "container": { "exitCode": 0 } +} ``` -Request payloads get a descriptor but no file — they are either a fixture in -this repository or a previous response, and the sha makes that linkage visible. +Payloads over 256 bytes become that descriptor. A **response** payload also gets +written out as its own byte-exact file. A **request** payload gets the descriptor +only — it is either a fixture in this repository or an earlier response, and the +sha is what makes that linkage visible (above, the next request's `expected` sha +is the same `db92d1ea66e2`). + +The transcript overlaps the logs on purpose: the logs are the server's +self-report, the transcript is what a client observed. When only the logs move, +logging regressed — which is not something a log golden can tell you on its own. +The transcript is also the only record of the **response body's shape**: a +dropped or added response key changes nothing in the logs. ### Why byte-exact pixels work diff --git a/server/acceptance/cases/default-log-level/golden/transcript.json b/server/acceptance/cases/default-log-level/golden/transcript.json index 7508f33..2e2d250 100644 --- a/server/acceptance/cases/default-log-level/golden/transcript.json +++ b/server/acceptance/cases/default-log-level/golden/transcript.json @@ -1,16 +1,13 @@ { - "steps": [ + "requests": [ { - "step": 1, - "request": { - "method": "POST", - "path": "/render", - "body": { - "actualHtml": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { diff --git a/server/acceptance/cases/form/golden/transcript.json b/server/acceptance/cases/form/golden/transcript.json index 74c063d..c73b852 100644 --- a/server/acceptance/cases/form/golden/transcript.json +++ b/server/acceptance/cases/form/golden/transcript.json @@ -1,16 +1,13 @@ { - "steps": [ + "requests": [ { - "step": 1, - "request": { - "method": "POST", - "path": "/render", - "body": { - "actualHtml": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { @@ -21,17 +18,14 @@ } }, { - "step": 2, - "request": { - "method": "POST", - "path": "/check", - "body": { - "actualHtml": "", - "expected": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "", + "expected": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { diff --git a/server/acceptance/cases/fullpage/golden/transcript.json b/server/acceptance/cases/fullpage/golden/transcript.json index 2e9db2a..7279978 100644 --- a/server/acceptance/cases/fullpage/golden/transcript.json +++ b/server/acceptance/cases/fullpage/golden/transcript.json @@ -1,16 +1,13 @@ { - "steps": [ + "requests": [ { - "step": 1, - "request": { - "method": "POST", - "path": "/render", - "body": { - "actualHtml": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { @@ -21,18 +18,15 @@ } }, { - "step": 2, - "request": { - "method": "POST", - "path": "/render", - "body": { - "actualHtml": "", - "viewport": { - "width": 800, - "height": 600 - }, - "fullpage": true - } + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 + }, + "fullpage": true }, "response": { "status": 200, diff --git a/server/acceptance/cases/invalid-request/golden/transcript.json b/server/acceptance/cases/invalid-request/golden/transcript.json index b9bc817..aabfdc0 100644 --- a/server/acceptance/cases/invalid-request/golden/transcript.json +++ b/server/acceptance/cases/invalid-request/golden/transcript.json @@ -1,13 +1,10 @@ { - "steps": [ + "requests": [ { - "step": 1, - "request": { - "method": "POST", - "path": "/check", - "body": { - "actualHtml": "" - } + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "" }, "response": { "status": 400, diff --git a/server/acceptance/cases/mismatch/golden/transcript.json b/server/acceptance/cases/mismatch/golden/transcript.json index 7279c0f..a691c8c 100644 --- a/server/acceptance/cases/mismatch/golden/transcript.json +++ b/server/acceptance/cases/mismatch/golden/transcript.json @@ -1,16 +1,13 @@ { - "steps": [ + "requests": [ { - "step": 1, - "request": { - "method": "POST", - "path": "/render", - "body": { - "actualHtml": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { @@ -21,17 +18,14 @@ } }, { - "step": 2, - "request": { - "method": "POST", - "path": "/check", - "body": { - "actualHtml": "", - "expected": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "", + "expected": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { diff --git a/server/acceptance/cases/plain/golden/transcript.json b/server/acceptance/cases/plain/golden/transcript.json index 04b9328..c2ee808 100644 --- a/server/acceptance/cases/plain/golden/transcript.json +++ b/server/acceptance/cases/plain/golden/transcript.json @@ -1,16 +1,13 @@ { - "steps": [ + "requests": [ { - "step": 1, - "request": { - "method": "POST", - "path": "/render", - "body": { - "actualHtml": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/render", + "body": { + "actualHtml": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { @@ -21,17 +18,14 @@ } }, { - "step": 2, - "request": { - "method": "POST", - "path": "/check", - "body": { - "actualHtml": "", - "expected": "", - "viewport": { - "width": 800, - "height": 600 - } + "method": "POST", + "path": "/check", + "body": { + "actualHtml": "", + "expected": "", + "viewport": { + "width": 800, + "height": 600 } }, "response": { diff --git a/server/acceptance/lib/pixelpact.js b/server/acceptance/lib/pixelpact.js index 5c9dfc5..c01ce84 100644 --- a/server/acceptance/lib/pixelpact.js +++ b/server/acceptance/lib/pixelpact.js @@ -99,7 +99,7 @@ export function pixelpactCase(dir, env = { LOG_LEVEL: "debug" }) { const logs = normalizeLogs(await readContainerLogs(container)); await pinGoldens({ dir, - steps: recorder.steps, + requests: recorder.requests, container: { exitCode }, logs, updateSnapshot: inject("updateSnapshot"), diff --git a/server/acceptance/lib/transcript.js b/server/acceptance/lib/transcript.js index cfdca0a..7a00006 100644 --- a/server/acceptance/lib/transcript.js +++ b/server/acceptance/lib/transcript.js @@ -16,12 +16,14 @@ const LOGS = "logs.jsonl"; /** Collects every request a case makes so the whole exchange can be pinned at once. * Cases assert nothing themselves; this is what makes that possible. */ export function createRecorder() { - const steps = []; + const requests = []; return { record(exchange) { - steps.push({ step: steps.length + 1, ...exchange }); + // The number only ever surfaces in the payload filenames; the transcript + // gets its order from the array. + requests.push({ number: requests.length + 1, ...exchange }); }, - steps, + requests, }; } @@ -35,32 +37,35 @@ export function createRecorder() { */ export async function pinGoldens({ dir, - steps, + requests, container, logs, updateSnapshot, }) { const goldenDirectory = path.join(dir, "golden"); - const transcript = { steps: [], container }; + const transcript = { requests: [], container }; const payloads = new Map(); for (const { - step, + number, method, route, requestBody, status, responseBody, - } of steps) { + } of requests) { const operation = route.replace(/^\//, ""); - const request = elide(requestBody, {}); - // Only responses get their payloads written out - requests are either - // repository fixtures or a previous response, and the sha shows the linkage. - const response = elide(responseBody, { step, operation, payloads }); - transcript.steps.push({ - step, - request: { method, path: route, body: request }, - response: { status, body: response }, + transcript.requests.push({ + method, + path: route, + // No `payloads`, so a request payload gets a descriptor but no file of its + // own: it is either a repository fixture or an earlier response, and the + // sha is what shows that linkage. + body: elide(requestBody, {}), + response: { + status, + body: elide(responseBody, { number, operation, payloads }), + }, }); } @@ -89,7 +94,7 @@ export async function pinGoldens({ /** Replaces payload strings with a descriptor, and records the bytes to pin * when {@link payloads} is supplied. */ -function elide(body, { step, operation, payloads }) { +function elide(body, { number, operation, payloads }) { const elided = {}; for (const [key, value] of Object.entries(body ?? {})) { if (typeof value !== "string" || value.length < BLOB_MIN_LENGTH) { @@ -99,7 +104,7 @@ function elide(body, { step, operation, payloads }) { const { kind, bytes, extension } = classify(value); let name; if (payloads) { - name = `${String(step).padStart(2, "0")}-${operation}.${key}.${extension}`; + name = `${String(number).padStart(2, "0")}-${operation}.${key}.${extension}`; payloads.set(name, bytes); } const digest = crypto