Files
yellowjacket/e2e/specs/reduced-motion.spec.ts
T
yonluandClaude Opus 5 deb3f3da7e feat(wails): move the e2e harness and headless launch onto v3
make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.

The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.

The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.

__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.

measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.

Four bugs surfaced, and the migration is how.

The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.

Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".

requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.

SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.

Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that dcc40b1 deleted on main — that spec has been failing since,
and what replaced it is covered in frontend/test/components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 20:58:20 -04:00

111 lines
3.9 KiB
TypeScript

import {
test,
expect,
callBinding,
waitForEvent,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* `a11y.15` — WCAG 2.2.2. The bottom bar's title and artist scroll
* continuously while a track plays, re-armed in a loop by
* `transitionend`, with no pause mechanism and no reduced-motion guard.
*
* The component test for this fakes `window.matchMedia`, which is a
* stub of the thing being tested. This spec sets the real context
* option, so the real media query answers.
*
* Both directions are here on purpose. A guard that suppressed
* everything would pass the reduce case for free, and so would a bar
* whose text simply does not overflow at this viewport — which is what
* the component test failed on first.
*/
/** The fixture track whose title is long enough to overflow the bar. */
const LONG_TITLE = 'An Exhaustively Overlong Track Title';
/**
* Read the title line's classes from inside `now-playing`'s shadow root.
*
* `will-scroll` is the class that carries both the transition and the
* `padding-right` the scroll distance is measured against, so its
* absence is the whole fix: suppressing only the animation leaves the
* text translated off its own box with nothing to bring it back.
*/
async function titleClasses(app: Page): Promise<string> {
return app.evaluate(() => {
const np = document.querySelector('now-playing');
const title = np?.shadowRoot?.querySelector('.track-title');
return title?.className ?? '';
});
}
async function playTheLongOne(app: Page): Promise<void> {
await app.getByTestId('nav-tracks').click();
// The scroll mode defaults to `hover`, and a fresh context has no
// persisted setting — so without this the positive case never
// scrolls and reports the same thing a broken build would. Set it
// rather than hovering, because `always` is also the mode the
// finding is about: continuous motion for as long as the track
// plays, with nothing the user has to do to provoke it.
await app.evaluate(() => {
localStorage.setItem('yj-now-playing-scroll-mode', 'always');
window.dispatchEvent(new CustomEvent('yj-scroll-mode-changed'));
});
const paths: string[] = await app.evaluate(async (needle) => {
const tracks = await window.__yjEvents.call(
'library.Library.GetAllTracks',
[],
10_000,
);
return (tracks as { TrackName: string; FilePath: string }[])
.filter((t) => t.TrackName.startsWith(needle))
.map((t) => t.FilePath);
}, LONG_TITLE);
expect(paths.length).toBeGreaterThan(0);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await waitForEvent(app, 'TrackChanged');
// The scroll cycle is armed 1500 ms after the geometry is measured,
// and the geometry is measured after the render that puts the title
// on screen. Reading before that reports "not scrolling" on a build
// that scrolls — the same shape as every probe read too early in
// plan 007.
await expect
.poll(() => titleClasses(app), { timeout: 10_000 })
.toContain('track-title');
}
test.describe('the marquee under prefers-reduced-motion', () => {
test.use({ contextOptions: { reducedMotion: 'reduce' } });
test('does not scroll the now-playing text at all', async ({ app }) => {
await playTheLongOne(app);
// Give the cycle longer than the 1500 ms arming delay to prove it
// never arms, rather than catching it before it would have.
await app.waitForTimeout(2500);
expect(await titleClasses(app)).not.toContain('will-scroll');
});
});
test.describe('the marquee without a motion preference', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } });
test('still scrolls an overflowing title', async ({ app }) => {
await playTheLongOne(app);
await expect
.poll(() => titleClasses(app), { timeout: 10_000 })
.toContain('will-scroll');
});
});