E2E: one shared theme/capture helper — dark screenshots render light across suites #380

Closed
opened 2026-09-29 01:20:36 +00:00 by kayg · 8 comments
Owner

Bug (Claude, reviews 2026-09-28/29): e2e screenshots labelled dark often render the light theme

Seen in #326 (mobile-focus), #357 (dup-task), #291 (motion-spring) and #317 (toast-polish): files named *-dark, *-tokyo-night or *-midnight show the light theme. Some also miss the element under test (the toast capture had no toast). Each job patched it locally; one owner fixes the shared primitive.

Fix

  • One shared e2e helper (apps/web/e2e/lib/theme.mjs or the existing shared setup module; search first) that sets the colour scheme and theme before navigation (stored appearance setting through the API, plus emulateMedia({ colorScheme })), waits for hydration, and asserts document.documentElement carries the expected theme and scheme before any capture. It throws on mismatch.
  • A shared capture(page, name, { expect: selector }) that asserts the element under test is visible before taking the screenshot.
  • Migrate every e2e suite that captures screenshots to these helpers (grep page.screenshot). No suite keeps its own theme code.
  • Regression: a tiny e2e that captures light and dark and checks a known token's computed colour differs.
## Bug (Claude, reviews 2026-09-28/29): e2e screenshots labelled dark often render the light theme Seen in #326 (mobile-focus), #357 (dup-task), #291 (motion-spring) and #317 (toast-polish): files named `*-dark`, `*-tokyo-night` or `*-midnight` show the light theme. Some also miss the element under test (the toast capture had no toast). Each job patched it locally; one owner fixes the shared primitive. ## Fix - One shared e2e helper (`apps/web/e2e/lib/theme.mjs` or the existing shared setup module; search first) that sets the colour scheme and theme **before** navigation (stored appearance setting through the API, plus `emulateMedia({ colorScheme })`), waits for hydration, and **asserts** `document.documentElement` carries the expected theme and scheme before any capture. It throws on mismatch. - A shared `capture(page, name, { expect: selector })` that asserts the element under test is visible before taking the screenshot. - Migrate every e2e suite that captures screenshots to these helpers (grep `page.screenshot`). No suite keeps its own theme code. - Regression: a tiny e2e that captures light and dark and checks a known token's computed colour differs.
Author
Owner

Starting #380 on branch job/e2e-theme from base 00015e652a. The issue has no comments. I am locating every browser screenshot and existing theme setup before implementing the shared helper.

Starting #380 on branch job/e2e-theme from base 00015e652a153485d9e7adda1c5ee61bc17300db. The issue has no comments. I am locating every browser screenshot and existing theme setup before implementing the shared helper.
Author
Owner

Progress: the suite review found independent screenshot calls and theme setters across the E2E modules, so captures did not share one required theme check. I added a harness-level setter/assertion/capture path and migrated the screenshot calls; the required light/dark viewport regression is queued behind the production server build. Branch: job/e2e-theme; base SHA: 00015e652a.

Progress: the suite review found independent screenshot calls and theme setters across the E2E modules, so captures did not share one required theme check. I added a harness-level setter/assertion/capture path and migrated the screenshot calls; the required light/dark viewport regression is queued behind the production server build. Branch: job/e2e-theme; base SHA: 00015e652a153485d9e7adda1c5ee61bc17300db.
Author
Owner

Merged-server adversarial evidence: the 32-worker GET /api/v1/files/recent?limit=500 storm produced repeated 30-second NO RESPONSE timeouts. The Photos upload burst and cross-plugin Calendar writes also produced timeouts, and an oversized Appearance request returned 502 “local adversarial server is unavailable” instead of 413. A concurrent /readyz check returned 200, and the attack probe reported “server alive at end: True”. This is an endpoint availability finding under sustained load; this E2E-only branch does not change server behavior.

Merged-server adversarial evidence: the 32-worker GET /api/v1/files/recent?limit=500 storm produced repeated 30-second NO RESPONSE timeouts. The Photos upload burst and cross-plugin Calendar writes also produced timeouts, and an oversized Appearance request returned 502 “local adversarial server is unavailable” instead of 413. A concurrent /readyz check returned 200, and the attack probe reported “server alive at end: True”. This is an endpoint availability finding under sustained load; this E2E-only branch does not change server behavior.
Author
Owner

Merged-server adversarial evidence: the DAV probe PUT a VALARM into an existing Calendar object and received 201, which the probe flags as accepted hostile input (tests/adversarial/attack.py, “DAV alarm”). The same round reported no crash in its final server-alive check. This needs backend triage; no server changes were made in this job.

Merged-server adversarial evidence: the DAV probe PUT a VALARM into an existing Calendar object and received 201, which the probe flags as accepted hostile input (tests/adversarial/attack.py, “DAV alarm”). The same round reported no crash in its final server-alive check. This needs backend triage; no server changes were made in this job.
Author
Owner

Probe contract findings from the same round: search_chaos expected 200 for the staged admin rebuild but got 202 with a job_id; xuser_matrix cannot classify DELETE /api/v1/admin/jobs/{id} (admin_stop_job); authz_matrix has no policy for GET /api/v1/mail/accounts. The quota probe reports tests/adversarial/attack2.py expecting 5,000,000,000 bytes while the merged DESIGN and server return 1,000,000,000 bytes. I preserved those existing expectations. The nested Calendar-tag and Mail API probes first lacked CALTERNAL_SERVER_BIN, then both passed when run with the correct merged server binary. SLOW-only latency findings are load and do not block.

Probe contract findings from the same round: search_chaos expected 200 for the staged admin rebuild but got 202 with a job_id; xuser_matrix cannot classify DELETE /api/v1/admin/jobs/{id} (admin_stop_job); authz_matrix has no policy for GET /api/v1/mail/accounts. The quota probe reports tests/adversarial/attack2.py expecting 5,000,000,000 bytes while the merged DESIGN and server return 1,000,000,000 bytes. I preserved those existing expectations. The nested Calendar-tag and Mail API probes first lacked CALTERNAL_SERVER_BIN, then both passed when run with the correct merged server binary. SLOW-only latency findings are load and do not block.
Author
Owner

Final report

Implemented and pushed the shared e2e theme/capture helper on job/e2e-theme. It sets the browser color scheme and persisted/server appearance before navigation, asserts the selected theme after rendering, and captures only after a visible target is present. The regression captures light and dark at 390, 820, and 1440 px. All 39 e2e modules now use the shared capture path.

Files: apps/web/e2e/harness.mjs, new apps/web/e2e/theme-capture.mjs, apps/web/package.json, and the 39 migrated modules under apps/web/e2e/.

Commits:

  • 98630056 — test(web): add shared theme capture regression
  • 93293261 — test(web): migrate e2e suites to shared captures
  • a104a49a6ce2bb1480a06e3a53535018e2edccf3 — merged dev once, no conflicts

Branch push is complete. Working tree is clean.

Gates and verification

cargo fmt --check:

(no output)
EXIT_CODE=0

cargo clippy --all-targets -- -D warnings was stopped at the shared four-hour job limit while still compiling:

Checking chrono-tz v0.4.45
Checking h2 v0.4.19
EXIT_CODE=130

Not run after the time limit: cargo test, bun run check, and bun run test.

Other completed checks:

bun run build
✓ built in 27.81s
> Using @sveltejs/adapter-static
Wrote site to "build"
✔ done
checked 48 e2e modules; syntax failures: 0
PASS shared theme and capture regression (390, 820, 1440; light and dark)
EXIT_CODE=0

git diff --check: no output, exit 0.

The screenshots remain in ignored artifacts/theme-capture/: light-390.png, light-820.png, light-1440.png, dark-390.png, dark-820.png, and dark-1440.png. Cargo output and web build output were cleaned.

Adversarial round and known gaps

The one post-merge adversarial round ran against the merged server. The supplemental Calendar tag and Mail API probes passed:

calendar Event tag probe: Unicode/bidi, 65536-byte category, 10 malformed inputs, and 24 parallel tag/range/search reads passed
Mail API probe: anonymous access, Unicode and public-endpoint policy, malformed/oversized JSON, hostile IDs/cursors, and 24 parallel reads passed

Non-SLOW findings recorded during the round:

  • DAV accepted the tested VALARM payload with HTTP 201.
  • The admin staged rebuild probe expected HTTP 200 and received HTTP 202 with a job id.
  • Authorization/resource matrix entries were missing for GET /api/v1/mail/accounts and DELETE /api/v1/admin/jobs/{id}.
  • The quota probe still expects 5,000,000,000 bytes; the merged server reports 1,000,000,000. The existing expectation was left unchanged.
  • Oversized Appearance input returned HTTP 502 instead of the probe's expected 413.
  • The /files/recent storm, Photos fixture upload burst, and Calendar/Log/Reminder and bookmark projection probes saw repeated no-response timeouts. The server remained alive and /readyz returned HTTP 200 during the storm. Search storm had zero failures (p50 611.6 ms, p95 741.7 ms).
  • The adversarial wrapper initially lacked CALTERNAL_SERVER_BIN for nested Calendar tag and Mail probes; both were rerun directly with the server binary and passed.

Decisions not specified by the design docs

  • The regression uses the Calendar /today screen with Paper and Tokyo Night as the two families, and checks the --paper role token changes between light and dark.
  • It applies local storage and the server Appearance setting before navigation, then checks persisted and rendered theme state before capture.
  • Captures require a visible body target; per-suite captures can pass a more specific target selector.

This job changed e2e infrastructure only. The merged dev server change was included for the required post-merge run; the quota and API findings above are from that merged state.

## Final report Implemented and pushed the shared e2e theme/capture helper on `job/e2e-theme`. It sets the browser color scheme and persisted/server appearance before navigation, asserts the selected theme after rendering, and captures only after a visible target is present. The regression captures light and dark at 390, 820, and 1440 px. All 39 e2e modules now use the shared capture path. Files: `apps/web/e2e/harness.mjs`, new `apps/web/e2e/theme-capture.mjs`, `apps/web/package.json`, and the 39 migrated modules under `apps/web/e2e/`. Commits: - `98630056` — `test(web): add shared theme capture regression` - `93293261` — `test(web): migrate e2e suites to shared captures` - `a104a49a6ce2bb1480a06e3a53535018e2edccf3` — merged `dev` once, no conflicts Branch push is complete. Working tree is clean. ### Gates and verification `cargo fmt --check`: ``` (no output) EXIT_CODE=0 ``` `cargo clippy --all-targets -- -D warnings` was stopped at the shared four-hour job limit while still compiling: ``` Checking chrono-tz v0.4.45 Checking h2 v0.4.19 EXIT_CODE=130 ``` Not run after the time limit: `cargo test`, `bun run check`, and `bun run test`. Other completed checks: ``` bun run build ✓ built in 27.81s > Using @sveltejs/adapter-static Wrote site to "build" ✔ done ``` ``` checked 48 e2e modules; syntax failures: 0 ``` ``` PASS shared theme and capture regression (390, 820, 1440; light and dark) EXIT_CODE=0 ``` `git diff --check`: no output, exit 0. The screenshots remain in ignored `artifacts/theme-capture/`: `light-390.png`, `light-820.png`, `light-1440.png`, `dark-390.png`, `dark-820.png`, and `dark-1440.png`. Cargo output and web build output were cleaned. ### Adversarial round and known gaps The one post-merge adversarial round ran against the merged server. The supplemental Calendar tag and Mail API probes passed: ``` calendar Event tag probe: Unicode/bidi, 65536-byte category, 10 malformed inputs, and 24 parallel tag/range/search reads passed Mail API probe: anonymous access, Unicode and public-endpoint policy, malformed/oversized JSON, hostile IDs/cursors, and 24 parallel reads passed ``` Non-SLOW findings recorded during the round: - DAV accepted the tested VALARM payload with HTTP 201. - The admin staged rebuild probe expected HTTP 200 and received HTTP 202 with a job id. - Authorization/resource matrix entries were missing for `GET /api/v1/mail/accounts` and `DELETE /api/v1/admin/jobs/{id}`. - The quota probe still expects 5,000,000,000 bytes; the merged server reports 1,000,000,000. The existing expectation was left unchanged. - Oversized Appearance input returned HTTP 502 instead of the probe's expected 413. - The /files/recent storm, Photos fixture upload burst, and Calendar/Log/Reminder and bookmark projection probes saw repeated no-response timeouts. The server remained alive and `/readyz` returned HTTP 200 during the storm. Search storm had zero failures (p50 611.6 ms, p95 741.7 ms). - The adversarial wrapper initially lacked `CALTERNAL_SERVER_BIN` for nested Calendar tag and Mail probes; both were rerun directly with the server binary and passed. ### Decisions not specified by the design docs - The regression uses the Calendar `/today` screen with Paper and Tokyo Night as the two families, and checks the `--paper` role token changes between light and dark. - It applies local storage and the server Appearance setting before navigation, then checks persisted and rendered theme state before capture. - Captures require a visible body target; per-suite captures can pass a more specific target selector. This job changed e2e infrastructure only. The merged `dev` server change was included for the required post-merge run; the quota and API findings above are from that merged state.
Author
Owner

Production theme captures

Theme was asserted before each capture. All six screenshots are attached to this issue.

Viewport Light Dark
390 px light-390.png dark-390.png
820 px light-820.png dark-820.png
1440 px light-1440.png dark-1440.png
## Production theme captures Theme was asserted before each capture. All six screenshots are attached to this issue. | Viewport | Light | Dark | | --- | --- | --- | | 390 px | [light-390.png](https://git.kayg.org/attachments/d80b29ba-3ba1-4deb-bbcd-fedd21a80a12) | [dark-390.png](https://git.kayg.org/attachments/0b682f2a-d856-4378-8840-f1273853bf5a) | | 820 px | [light-820.png](https://git.kayg.org/attachments/a24f160f-d29b-4c53-954d-42d235f5c638) | [dark-820.png](https://git.kayg.org/attachments/275d73e0-4496-4799-8543-96b1607fb711) | | 1440 px | [light-1440.png](https://git.kayg.org/attachments/b260916c-8d00-4768-9012-9345be14fa64) | [dark-1440.png](https://git.kayg.org/attachments/170da844-706e-43d9-8b91-4a426f057c08) |
kayg closed this issue 2026-09-29 08:29:22 +00:00
Author
Owner

Follow-up: on dev before #398, apps/web/e2e/files.mjs called captureScreenshot(), which is not defined or imported anywhere, so the Files capture run failed. Merging #398 took select-align's working version of the file. It sets the theme through the Settings UI plus assertRenderedTheme instead of the shared setTheme helper. Move it onto the shared helper the next time a job touches e2e.

Follow-up: on dev before #398, apps/web/e2e/files.mjs called captureScreenshot(), which is not defined or imported anywhere, so the Files capture run failed. Merging #398 took select-align's working version of the file. It sets the theme through the Settings UI plus assertRenderedTheme instead of the shared setTheme helper. Move it onto the shared helper the next time a job touches e2e.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#380
No description provided.