Render Harness Tests (frontend)¶
A separate test track from the Python suite: JS/Playwright tests that render the real card headless and gate it for crashes, visual regressions, colorblind distinguishability, and theme-intake safety. Architecture lives in frontend/render-harness; this is how to run it.
The harness is where the rendered card is tested β the Python suite stops
at the backend contract; these pick up at the shadow-DOM boundary. (A small
set of pure-JS tooling units β the gallery-submission bot and the gallery-HTML
builder β are tested separately with node --test; see CI.)
TL;DR¶
- Run the gates:
npm run test:harness(builds the bundle, runs all specs). - Visual baselines are Linux, generated and checked in one pinned Playwright
image. On any other OS the render differs, so the visual specs skip unless
CIorVISUAL=1β everything else runs anywhere. - First time:
npm installthennpx playwright install chromium. - Regenerate a baseline after an intended visual change β run the visual gate
in the pinned Docker image with
--update-snapshots(below). Never re-bake baselines on the host.
The gates¶
| Spec | Asserts | Runs |
|---|---|---|
smoke.spec.mjs |
every tab renders from the stub without throwing (the pure-renderer contract) | everywhere |
gallery-completeness.spec.mjs |
every semantic-color token has a gallery entry (or a reasoned allowlist) | everywhere |
visual.spec.mjs |
each tab + gallery matches its committed baseline | CI / VISUAL=1 only |
cvd.spec.mjs |
the cvd-safe theme separates all 30 group pairs on the real card (ΞE2000 β₯ 15) + the 5-override cascade resolves |
everywhere |
shape-marks.spec.mjs |
the six badge marks are distinguishable in flat grayscale at dot size | everywhere |
i18n-escaping.spec.mjs |
every POPULATED view x all 18 bundled locales: no HTML entity reaches the screen as literal text (a catalog string escaped twice). The populated set includes setup-system (added 2026-08-14), a fixture carrying one row per branch of _renderSystemSubtab β resolved-uncontested, contested-and-won with a rejected alternative, config-entry sweep, user override, and unresolved β so the localized, RTL and escaping gates cannot report coverage of halves that never rendered |
everywhere |
intake.spec.mjs |
the ingest gate skips malformed / unknown-namespace exports and clamps every value | everywhere |
device-theme.spec.mjs |
per-device theme resolution: the real VacuumCardState.effectiveActiveThemeId() fallback chain keeps a device pin through a pre-load, resolves it once the library loads, and clears it only when genuinely stale |
everywhere (also re-run in card-visual CI) |
tab-gating.spec.mjs |
capability tab gating: renderHeader hides the Base Station nav tab when supportsBaseStation() is false (the S6 no-dock case), default-shown otherwise (Eufy-safe) |
everywhere |
i18n-locale.spec.mjs |
the renderers resolve the user's language: a tab rendered under a registered foreign catalog switches its strings (the rest of the harness only ever renders English); no-language still renders English | everywhere |
i18n-layout.spec.mjs |
a translated locale must not break the layout β property-based, not pixel-pinned: under a pseudo-lengthened catalog assert nothing escapes its box, at desktop @500px and mobile @390px. Plus two data-driven passes added 2026-08-07: a real-German sweep @390px (seeded via flattenLocale(...).flat, with the seed key-count asserted and one test pinning Maintenance OverviewβWartungsΓΌbersicht, so the describe cannot silently degrade into a second English run), and a maintenance-CARDS pass in en/de/nl/ru driven by the gallery fixture β because renderTab's generic stub renders an EMPTY maintenance view, so the card grid had never been measured in any locale. The cards pass also asserts the title box, not just overflow: a starved flex item plus overflow-wrap:anywhere degrades into a vertical one-character-per-line column that overflows nothing, so probeLayout is blind to it by construction. Form controls are excluded from probeLayout (2026-08-14): a closed <select> reports its widest <option> as scrollWidth, which the UA paints in a popup layer and never inline, so the Setup β System row picker registered 14px of "overflow" while sitting 220px inside its cell. probeBleed already skipped them for the same reason. This cannot mask a real blowout β a control genuinely too wide makes its ANCESTORS overflow, and those are still measured, with shellOverflow as the aggregate backstop |
everywhere |
incomplete-run-banner.spec.mjs |
the incomplete-run banner under a WIDE FONT and a LONG LOCALE, at 360px and 390px. Added 2026-08-24 from a phone report that arrived as two symptoms of one defect: in EN + OpenDyslexic the title starved into a word-per-line column beside the button; in FR + OpenDyslexic the same banner pushed the page wider than the viewport. .evcc-incomplete-run-actions is flex-shrink:0 while the body was flex:1; min-width:0, so the actions took what they needed and the body absorbed the whole shortfall β starving if they fit, overflowing if they did not. Asserts BOTH symptoms, because fixing one is how this survives: line count for the starve (probeLayout is blind to it by construction), probeLayout for the overflow. Introduced two dimensions this suite did not have. (1) 360x772 β the real reporting device; 22 of the 24 mobile viewports in harness/tests were 390, an iPhone width. (2) OS FONT SCALE β the card sizes in rem and Android's slider reaches 1.3x on its largest non-accessibility setting. That second one is the whole story: at 1.0x the reported English frame does NOT reproduce at ANY width, and at 1.3x it lands on exactly the seven lines that were photographed. The gate pairs OpenDyslexic with 1.3x deliberately β the two settings are the same accessibility affordance, so that combination is a real user's default rather than a corner case |
everywhere |
dashboard-sequence-override.spec.mjs |
the STANDALONE dashboard card's sequence-override row: does it render, is it STYLED AT ALL, and does its verification box carry its own status token. Added 2026-08-24 after a pre-release audit found the .soro-* rules had lived in src/styles/rooms.js since the row shipped and could never apply β vacuum-agent-dashboard attaches its own shadow root and injects only CARD_CSS, so the panel's stylesheet cannot reach it. The row rendered as bare divs on that surface for its whole life, and TWO commits "fixed" its colours by editing the panel stylesheet, one of them saying so in its message. Nothing went red because sequence-override.spec.mjs mounts the PANEL, and CARD_STATES carries neither clean-order entity, so even existing card mounts rendered the row as an empty string. The crude assertion is the load-bearing one β an unstyled div computes rgba(0, 0, 0, 0). Mounting this element for the first time also exposed two defects no review had: 21 calls to a non-existent this.escapeHtml (which threw and took the WHOLE CARD down for any V1 owner), and _shouldRender tracking neither clean-order entity, so the card never repainted after Apply |
everywhere |
sequence-override.spec.mjs |
the Override Order row in all five states: it RENDERS at all, fits, and resolves the right semantic colour. Added 2026-08-24 after two defects reached a user's phone that no gate could reach, because the row never rendered in the harness β it needs a switch.<vac>_clean_order_override in hass.states or findOverrideSwitch returns null and the renderer returns "", and no fixture supplied one. So i18n-layout swept Rooms at 390/500px under pseudo-long and passed over an empty string. Two lessons are pinned in the spec itself: (1) the colour must be asserted as a RESOLVED rgb value against the document's own --evcc-sem-*, because the is-<kind> class was correct all along β no stylesheet consumed it, so asserting the class passes while a confirmed match paints in the theme accent; (2) the fit claim lives in the pseudo-long block, not the English one β with flex-wrap ablated, English at 390px overflows by ZERO (the buttons fit), so there the assertion is a declaration with nothing behind it, while pseudo-long measures +90..100px |
everywhere |
i18n-rtl.spec.mjs |
an RTL locale must flip cleanly: each tab rendered under the real Arabic and Hebrew catalogs with the host stamped dir="rtl" (via applyDir, as src/main.js does) β same property-based probe as the pseudo-long gate (nothing escapes its box, no horizontal scroll), plus an assertion the host actually carries dir="rtl" so the gate can't pass by rendering LTR |
everywhere |
npm run test:harness runs all of them (visual auto-skips off-CI). The i18n
strings + intake security gate are covered separately β see i18n system
(check:i18n + the real-Chromium scripts/sanitize-locale.test.mjs).
Running¶
# one-time setup
npm install
npx playwright install chromium
# all gates (visual skips locally)
npm run test:harness
# capture PNGs for eyeballing
npm run harness:shoot # every tab, default bundle -> harness/out/<bundle>/
npm run harness:gallery # all-states galleries -> harness/out/gallery/
npm run harness:preview # theme exports in gallery/themes/ -> harness/out/preview/
npm run harness:cards # the three standalone cards -> docs/screenshots/card-*.png
npm run harness:readme # the README panel shots -> docs/screenshots/*.png
# the CVD separation matrix for any bundle
node harness/cvd/report.mjs # default palette (fails β shows the problem)
node harness/cvd/report.mjs cvd-safe # the shipped colorblind palette (passes)
harness/out/ and harness/dist/ are build artifacts (gitignored). Baselines
under harness/tests/__screenshots__/ are committed.
Hero shots β the standalone cards¶
Renders the three standalone Lovelace cards and writes the committed hero shots.
Deterministic: the clock is frozen and animations zeroed by default (unlike the tab
shooters, where --freeze is opt-in), so re-running with nothing changed reproduces
the same bytes.
| Output | Shows |
|---|---|
docs/screenshots/card-room.png |
eufy-room-card β one room's cleaning-mode / suction / water / path / passes / edge-mopping chips with the saved values selected, plus Start |
docs/screenshots/card-dashboard.png |
vacuum-agent-dashboard β the vacuum header, all six rooms with two selected and one expanded to its settings, the saved-profile + app-scene launchers, Dock / Start |
docs/screenshots/card-profile.png |
vacuum-agent-profile-card β one saved routine's "Runs in this order" step manifest across two room groups, a charge-to-80% stop and a wait, plus Run |
harness/out/cards/ |
the same three frames plus _contact-sheet.png, for reviewing them together |
Flags: --bundle <name> themes the cards from harness/bundles/ exactly as it themes
the panel (they read the same --evcc-* tokens); --scale sets the device pixel ratio
(default 2); --out <dir> redirects the hero shots.
This is a different mount path from everything above. The tab shooters drive the
panel's pure renderers with a stub state accessor. The standalone cards can't be
driven that way β they are plain custom elements with setConfig + a hass setter,
and every value they show is read back out of hass. So window.__evcc.mountCard()
mounts the real element in the real document, against the stub hass in
harness/fixtures/cards.js, exactly as Lovelace does. What that fixture must supply
is the whole contract in src/cards/_shared.js:
- room switches β a
switch.*entity is only a room if its attributes carry a matchingvacuum_entity_idand a non-nullroom_id. Miss either and the card renders zero rooms. - the adapter option lists β
clean_mode_options/fan_speed_options/water_level_options/clean_intensity_options, carried on each switch. These are the chips. Omit them and the card still mounts, still looks structurally plausible, and every chip row is empty. - the two response-capable reads β
get_dashboard_snapshotandget_saved_run_profiles, answered by the fixture's canned payloads through acallService(..., returnResponse=true)stub.
Because a card that fails to mount looks like a small empty box β and passes any check that only asserts a file exists β every shot is gated on what the live shadow tree actually contains (chip count, active-chip count, room rows, manifest steps, rendered height) before anything is written. A card that misses its floor is reported and skipped, its previous PNG left untouched, and the run exits non-zero.
Two things the fixture does deliberately, worth knowing before editing it:
clean_modeappears in both spellings. The room editor persists the display labelVacuum and mop; the adapter option value, the profile catalog and the framework use the tokenvacuum_mop. Kitchen holds the label and Office holds the token, so the shot exercisescanonicalCleanMode()instead of the trivially-equal path β a token-only fixture would light the chip either way and prove nothing.- The dashboard card's map section is off, via the card's own
show_map: falseconfig toggle (a real setting in its visual editor), not by understating the device's capability. The map body is a separately-served bundle (/eufy_vacuum/frontend/eufy-vacuum-map.js) that the harness has no route for.
Panel shots β the README screenshots¶
Renders the fifteen committed panel screenshots the README embeds β one full-tab
capture per view, at 920 CSS px and deviceScaleFactor 2, so each PNG lands ~1840px
wide. Deterministic on the same terms as the card shooter: the clock is frozen,
animations are zeroed, and no fixture derives from wall-clock or randomness, so
re-running with nothing changed reproduces the same bytes.
| Output | Shows |
|---|---|
rooms-cards.png / rooms-map.png |
the Rooms tab in both view modes β six room cards with learned ETAs and confidence, and the same six as selectable polygons on a floor plan |
maintenance.png Β· base-station.png Β· metrics.png Β· metrics-battery.png Β· learning-review.png Β· external-jobs.png Β· external-wizard-step[12].png Β· room-rules.png Β· setup.png |
one tab (or sub-tab, or modal step) each |
themes-presets.png / themes-palette.png / themes-tokens.png |
one view in three sub-states β the preset grid, the palette editor, the token editor |
harness/out/readme/ |
the same frames plus _contact-sheet.png, for reviewing them together |
Flags: --bundle <name> themes the shots from harness/bundles/; --width / --scale
set the CSS width and device pixel ratio; --only <id,id> shoots a subset; --dry-run
writes only the review copies, leaving docs/screenshots/ alone.
Every shot is gated on its content, because the failure mode here is a screenshot
that looks fine. renderTab with the default stub state renders the empty state:
renderRoomsView returns .evcc-empty the moment getRoomsForActiveMap() is falsy,
and the metrics, maintenance and review tabs each have their own "no data yet" branch.
Every one of those produces a PNG with the right chrome, the right active tab, the
right theme and nothing in it β and passes any check that asserts the file exists. So
each entry in harness/fixtures/readme-shots.js declares a floor, measured against the
live shadow tree before anything is written:
selectorsβ minimum element counts for the things that are the view (six.evcc-room-card, four.evcc-base-station-action-card, three.evcc-metrics-table).text/notTextβ substrings that must, and must not, appear.notTextexists because not every empty branch renders.evcc-empty: the battery tab prints "β¦ β no single-bucket jobs yet" as ordinary table rows, so a fixture that named an attribute wrong produced a table that looked populated and said nothing.minHeight, zero.evcc-emptynodes, the claimed tab actually marked active, and noundefined/NaN/[object Object]in the rendered text.- overflow, both axes.
<ha-card>isoverflow: hidden, so content the shell cannot fit is cropped out of the capture with no other symptom. Shots that declare aheightare bounded panels whose view owns a scroll container, so for those only the horizontal axis is checked.
A shot that misses its floor is reported and skipped, its previous PNG left untouched, and the run exits non-zero.
Three things about the fixtures, worth knowing before editing them:
- Room names are synthetic, and that is the point. These are published on a public
repo, so every shot draws from one neutral list β Kitchen / Living Room / Bedroom /
Office / Bathroom / Hallway β and the gate asserts those names are present, so a
fixture that drifted to some other source of room names fails rather than ships. Same
for entity ids, map names and file paths. The single real-home shot in
docs/screenshotsisfloor-texture-map.png, kept deliberately because it shows no names. - The map backdrop is drawn, not photographed. A real install serves the plan from
Home Assistant; headless there is no such route.
readme-shots.jsemits an inline SVG data URI whose rooms are the same percent-coordinate polygons the segment overlay uses, square because.evcc-map-containerisaspect-ratio: 1and.evcc-map-imageisobject-fit: containβ any other ratio letterboxes and the polygons drift off the walls. - Seven shots reuse the all-states gallery fixtures rather than carrying a second
copy that could drift from them. Where such a shot needs one accessor changed, it
declares an
overlayinstead of editing the gallery entry, whose render is a committed visual baseline.
Two of the fifteen are driven by a real VacuumCardState, via the real layer in
makeStubState (resolution order: overrides β header essentials β real state β
recording null-object). The Themes tab reads _ensureThemeState() and
resolvedTheme(), which are an implementation β a mutable sub-tab/draft/facet object,
and several hundred resolved token values seeded from the room-fill palette and the
floor-texture registry. A hand-written stand-in for either would be a transcript that
drifts from the thing it transcribes, so the fixture builds the real object and seeds it
with the shipped library from gallery/themes/.
Visual baselines β the Docker workflow¶
Visual regression only works if baselines are generated in the same
environment that gates them. That environment is the pinned image
mcr.microsoft.com/playwright:v1.60.0-noble β the same one CI uses. Run it via
PowerShell (the Bash tool mangles --workdir):
# regenerate baselines after an INTENDED visual change
docker run --rm `
-v "${PWD}:/work" -v evcc_harness_nm:/work/node_modules -w /work `
mcr.microsoft.com/playwright:v1.60.0-noble `
bash -lc "npm ci && node harness/build.mjs && VISUAL=1 npx playwright test -c harness/playwright.config.mjs visual --update-snapshots"
Drop --update-snapshots to verify against the committed baselines instead
(this is exactly what CI does). The -v evcc_harness_nm:/work/node_modules
anonymous-ish named volume keeps the container's Linux dependency binaries (esbuild,
Chromium) from clobbering the host's, and is reused across runs so only the first
pays npm ci.
To see what an edit changed: run without --update-snapshots first β the
failing screenshots are the blast radius β then re-run with it to accept.
CI¶
| Workflow | Trigger | Does |
|---|---|---|
.github/workflows/card-visual.yml |
PR (any branch) or push to master touching src/**, harness/**, package.json, package-lock.json, or the workflow file |
runs visual + device-theme in the pinned image; uploads the diff report on failure |
.github/workflows/node-tests.yml |
PR or push to master touching scripts/**, src/theme-tags/**, harness/lib/**, the animal-svg frontend, gallery animals, or package.json |
node --test scripts/*.test.mjs harness/lib/*.test.mjs in the pinned Playwright image β covers the Chromium-driven sanitiser gates (animal SVG and the locale intake sanitize-locale.test.mjs), the submission/PR-gate cores, and the gallery-HTML builder |
.github/workflows/theme-intake.yml |
workflow_dispatch, or push/PR to gallery/themes/*.json, docs/**, mkdocs.yml, or harness/** (push also on the workflow file) |
four jobs (gallery / docs / publish / deploy): renders each theme export through the ingest gate and builds the MkDocs docs site (mkdocs build --strict); uploads PNG + docs artifacts (PR/dispatch) and on push to master publishes both to the one GitHub Pages site β gallery at /, docs at /docs β one-time: enable Pages β GitHub Actions source |
.github/workflows/theme-submission.yml |
a theme-submission-labelled issue is opened |
validates a pasted export, renders its preview, and opens a reviewable PR with the preview inline (frontend/render-harness Β§8) β not a gate, no spec; one-time: a theme-submission label + "Allow Actions to create PRs" |
Both run in the pinned image, so local-Docker and CI agree byte-for-byte. They
require package-lock.json to be committed (for npm ci).
tests.yml is the separate Python (pytest) gate. The Node test suites have their
own workflow, node-tests.yml (above): it runs every scripts/*.test.mjs
plus harness/lib/*.test.mjs in the pinned Playwright image β the
security-critical intake sanitiser gates (animal SVG via DOMPurify, and the
locale intake sanitize-locale.test.mjs) drive real Chromium, alongside the
gallery-submission bot core (scripts/process-submission.mjs) and the
gallery-HTML builder.
Calibration knobs¶
Three values are spec, not defaults β tune them deliberately:
| Knob | Where | Current | Notes |
|---|---|---|---|
| diff threshold + budget | harness/playwright.config.mjs |
threshold 0.1, maxDiffPixels 60 |
absolute pixel budget, not a ratio β a ratio hides small colored-region changes in a tall image |
| CVD pass criterion | harness/cvd/report.mjs (FLOOR) |
ΞE2000 β₯ 15, 10 pairs Γ 3 sims | fix the palette, not the floor |
| fixture content | harness/fixtures/gallery.js |
all colored branches per tab | a new state-token must get a gallery row or the completeness gate fails |
| card content floors | harness/shoot-cards.mjs (GATES) |
per-card chip / room / step / height minima | raise them when a card gains a section; a floor low enough to cover all three cards catches none of them |
| panel-shot content floors | harness/fixtures/readme-shots.js (gate) |
per-shot selector counts / required + forbidden text / height | each shot's floor lives next to its fixture; raise it when the tab gains a section, and check it still fails on an emptied fixture |
Gotchas¶
- Node can't import
src/*.jsdirectly. Those are ESM-syntax.jsfiles in a package with no"type": "module", so Node reads them as CommonJS. They're only ever consumed by esbuild. Test code reaches src through the bundle (window.__evcc.*), never by importing it. Theharness/cvd/*.mjsandharness/bundles/*.mjsfiles are.mjs, so those import fine Node-side. - Animations are frozen in the harness (
freezeflag + Playwrightanimations: 'disabled') so the pulse/progress animations don't make diffs flaky. - The visual gate is structural. It catches layout / z-index / missing-element regressions. Subtle small-region color changes are the CVD gate's job β a whole-image pixel budget intentionally won't flag them.
- Everything renders from
src/, never from the shipped bundle.harness/build.mjsbundles the mount entry β panel and standalone cards β out ofsrc/. Pointing any of it atcustom_components/eufy_vacuum/frontend/*.jswould render whatever was last deployed (that bundle only changes onnpm run build:deploy) while every check stayed green. - Don't sweep the harness into an
eufy_vacuumrelease.harness/,.github/workflows/*,gallery/, and thepackage.jsondevDeps are tooling; onlysrc/,custom_components/, andtests/ship.
Escaping state is invisible in the source, and that is the real hazard¶
translate() returns text that is ALREADY HTML-escaped (Trust Model B). Backend
strings and user-controlled names are RAW. Both flow through variables with
names like attentionSummary, and nothing at the sink can tell them apart:
// SAFE β the trust decision is made where the value ORIGINATES
const summary = haveCount
? this.t("maintenance.attention_summary", { count }) // already escaped
: this.escapeHtml(upkeep.attention_summary ?? ""); // untrusted -> escape here
// UNSAFE β one escape at the sink cannot serve both branches
const summary = haveCount ? this.t(...) : upkeep.attention_summary;
html += `<div>${this.escapeHtml(summary)}</div>`; // double-escapes branch 1
Escaping at the sink double-escapes the translated branch (the user reads
d'entretien) and removing it un-escapes the untrusted branch (an XSS
regression). Both were live in this file simultaneously, and the second was
introduced WHILE fixing the first β the entity gate went green on a change that
had quietly removed the protection.
So: decide trust at the branch, never at the sink. Where a variable can hold either kind, say so in its name.
Neither direction is fully gated. i18n-escaping.spec.mjs catches
double-escaping; nothing yet asserts that a backend-sourced string reaching an
innerHTML sink was escaped. That gap is known, not covered.