Files
yellowjacket/e2e/specs/offline-icons.spec.ts
T
logan 1ac919d228 test(e2e): freeze the four reproductions this work started from
Each was written first and watched fail first:

- `view-lifecycle` — pressing `s` on Settings must not skip an album
  out of the Autotag queue, and on Autotag the same key must not also
  toggle shuffle.
- `player-truth` — the elapsed clock tracks the backend through steady
  playback and four keyboard seeks (the measurement that failed by
  30 s), a finished queue keeps the track on the bar at 0:00, and
  auto-advance skips a missing file and reaches the next track.
- `offline-icons` — blocks every non-local request and asserts on the
  <svg> *inside* each icon's shadow root, since asserting the element
  exists would have passed before the fix too. Verified red: 24 empty
  icons.
- `failure-voice` — induces a real binding failure through /__test/sql
  and asserts a sentence appears. Its first version renamed the decoy
  library to its own name, which the backend accepts: it failed at the
  right assertion while never inducing the failure, so it now picks the
  row by the seeded library's name from /__test/health.
- `play-count` — a finished track does not refetch the library.

The queue spec now opens the panel before asserting on its rows and
waits for it to close again: the panel's width is animated and the
transport slides with it, so a click issued during the close lands on
whichever button moved under the pointer.
2026-08-12 01:20:03 -04:00

140 lines
4.4 KiB
TypeScript

import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { test, expect } from '../support/fixtures.js';
/**
* The app must work with no network.
*
* Every `<wa-icon>` used to resolve to ka-f.fontawesome.com at runtime
* (audit H-4 / perf.M9), so a desktop music player offline, on a
* captive portal or behind a firewall rendered no icons at all — while
* playing files sitting on the local disk. `src/icons/` bundles them
* and overrides Web Awesome's `default` library.
*
* The reproduction is the point. Asserting that a `<wa-icon>` *exists*
* would have passed before the fix too: the element is always in the
* DOM and only its contents came from the network. So this asserts on
* the `<svg>` inside each icon's shadow root, with everything that is
* not the app's own origin blocked — which is what a closed network
* actually looks like to a local server.
*/
const here = dirname(fileURLToPath(import.meta.url));
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
/** Icons on the first screen. Fewer than this means nothing rendered. */
const EXPECTED_MIN_ICONS = 5;
const VIEWS = [
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
'explore', 'downloads', 'jobs', 'settings',
];
type IconState = { name: string; hasSvg: boolean };
const collectIcons = (): IconState[] => {
const icons: IconState[] = [];
const walk = (root: Document | ShadowRoot): void => {
for (const el of Array.from(root.querySelectorAll('*'))) {
if (el.tagName === 'WA-ICON') {
icons.push({
name: el.getAttribute('name') ?? '(unnamed)',
hasSvg: !!el.shadowRoot?.querySelector('svg'),
});
}
if (el.shadowRoot) walk(el.shadowRoot);
}
};
walk(document);
return icons;
};
test.describe('offline', () => {
test('icons render with every external request blocked', async ({
page,
baseURL,
}) => {
const blocked: string[] = [];
// Deliberately not `context.setOffline(true)`: the app *is* a local
// server, so taking the whole stack down would break the bindings
// rather than the icons. A closed network is precisely "the app's
// own origin still answers, nothing else does".
await page.route('**/*', (route) => {
const url = route.request().url();
if (url.startsWith(baseURL!) || url.startsWith('data:')) {
return route.continue();
}
blocked.push(url);
return route.abort();
});
await page.addInitScript({ path: INIT_SCRIPT });
await page.goto(baseURL!);
await page.evaluate(() => window.__yjEvents.ready(20_000));
await page.waitForTimeout(2000);
const drawn = await page.evaluate(collectIcons);
expect(
drawn.length,
'no <wa-icon> on screen at all — the app did not render',
).toBeGreaterThanOrEqual(EXPECTED_MIN_ICONS);
expect(
drawn.filter((i) => !i.hasSvg).map((i) => i.name),
'icons that did not draw with the network closed',
).toEqual([]);
// The complementary half: nothing should have *wanted* the network.
// An icon drawing from a warm module cache would satisfy the
// assertion above on a machine that happens to be online.
expect(
blocked.filter((u) => u.includes('fontawesome')),
'still reaching for the icon CDN',
).toEqual([]);
});
test('every icon the app asks for is bundled', async ({ app }) => {
for (const view of VIEWS) {
await app.evaluate((v) => document.dispatchEvent(
new CustomEvent('navigate', { detail: { view: v } }),
), view);
await app.waitForTimeout(600);
}
const drawn = await app.evaluate(collectIcons);
expect(drawn.length).toBeGreaterThan(EXPECTED_MIN_ICONS);
// Covers the icons that only exist on views past the first screen,
// which the offline test above never reaches.
expect(
drawn.filter((i) => !i.hasSvg).map((i) => i.name),
'icons that did not draw on some view',
).toEqual([]);
// The resolver records what it could not find rather than failing
// silently, because twenty call sites compute their icon name from
// state and no static check can enumerate them.
const misses = await app.evaluate(
() => (window as unknown as { __yjIconMisses?: string[] })
.__yjIconMisses ?? [],
);
expect(
misses,
'add these to frontend/src/icons/names.txt, then run: ' +
'node frontend/scripts/fetch-icons.mjs',
).toEqual([]);
});
});