feat(harness): agent-drivable dev harness and CI that gates
A coding agent could develop this repo's Go packages and could not develop the application: every path to running YellowJacket ended in a blocking GTK window, so 265 bound methods, 46 events, 33 component directories and 13 stores had exactly one form of verification available — `tsc --noEmit`. The unlock is that `wails dev`'s dev server on :34115 serves the real frontend with the real generated bindings against the same Go backend a desktop window attaches to, so a plain Chromium under Xvfb gets a fully functional app. Four test tiers now exist, cheapest first: - `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app, no backend, no display. Works because `frontend/wailsjs/` is a pure passthrough to `window.go`/`window.runtime`, so faking just those two globals runs the real bindings and the real store code. - `make test` — services in-process, asserting on the payload the frontend would receive, via a new `events.Emit` wrapper. - `make dev-headless` + `playwright-cli` — the real app, driven interactively, with an event bridge on `window.__yjEvents` and a dev-only control surface at `/__test/`. - `make e2e` — 19 of those flows frozen as Playwright specs. `events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call sites: wails' `getEvents` `log.Fatalf`s on any context without its runtime, so those paths could not run under test and a background worker could take the app down. Four packages had each hand-rolled the same guard; nine more guarded on `ctx != nil`, which does not help. `TestNoDirectRuntimeEmits` fails the build on a new one. Fixtures are generated, not committed (`make testdata`), and seeds are built by *running the app* — never by hand-writing config and DB rows, which would be a second description of a valid YJ_HOME. `.gitea/workflows/ci.yml` is the first workflow here that tests anything; the other three only package, so `gitea_ci` reported only packaging jobs and misled anyone asking whether a push was healthy. Both jobs were prototyped to green in a bare ubuntu:24.04 container before the YAML was written, which immediately caught `make lint` linting three configurations that nothing builds: all three passes omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch still ships and Ubuntu 24.04 dropped. Operational instructions live in `.pi/skills/yellowjacket-dev/`, measured discoveries in `.planning/NOTES.md`, and architecture in `CLAUDE.md` — split by tense, not by topic, because a topical split gives every new fact two plausible homes. `make skill-check` fails a commit if the skill cites a make target that does not exist.
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 558 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* The app chrome: sidebar navigation, the library filter dropdown and
|
||||
* the tri-state library status indicator. All three are small, all
|
||||
* three are on screen constantly, and the sidebar's testids and
|
||||
* aria-current are what the e2e tier navigates by.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import '@components/sidebar/app-sidebar';
|
||||
import '@components/library-filter/library-filter';
|
||||
import '@components/library-status-indicator/library-status-indicator';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
texts,
|
||||
update,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
|
||||
describe('<app-sidebar>', () => {
|
||||
it('renders a testid per destination, which is how e2e navigates', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
expect(
|
||||
shadowAll(el, 'li').map((li) => li.getAttribute('data-testid')),
|
||||
).toEqual([
|
||||
'nav-home',
|
||||
'nav-playlists',
|
||||
'nav-artists',
|
||||
'nav-genres',
|
||||
'nav-albums',
|
||||
'nav-tracks',
|
||||
'nav-explore',
|
||||
'nav-downloads',
|
||||
'nav-autotag',
|
||||
'nav-jobs',
|
||||
'nav-settings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks exactly one item as the current page', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
const current = shadowAll(el, 'li').filter(
|
||||
(li) => li.getAttribute('aria-current') === 'page',
|
||||
);
|
||||
|
||||
expect(current).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('announces a navigation as a composed event, so the shell hears it through the shadow root', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
const seen: string[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => {
|
||||
seen.push((e as CustomEvent<{ view: string }>).detail.view);
|
||||
}, { once: true });
|
||||
|
||||
shadow<HTMLElement>(el, '[data-testid="nav-artists"]')?.click();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(seen).toEqual(['artists']);
|
||||
});
|
||||
|
||||
it('moves aria-current to the clicked destination', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(
|
||||
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
|
||||
).toBe('page');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
await visual(el, 'app-sidebar');
|
||||
expect(shadowAll(el, 'li').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<library-filter>', () => {
|
||||
beforeEach(() => {
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', [
|
||||
{ id: 7, name: 'Music' },
|
||||
{ id: 8, name: 'Field Recordings' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers every library plus the merged view', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, 'option')).toEqual([
|
||||
'All Libraries',
|
||||
'Music',
|
||||
'Field Recordings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries an accessible name — it is a bare select otherwise', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
expect(shadow(el, 'select')?.getAttribute('aria-label')).toBe(
|
||||
'Library filter',
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a library by id, and the merged view by empty string', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const select = shadow<HTMLSelectElement>(el, 'select');
|
||||
|
||||
if (select) select.value = '8';
|
||||
|
||||
select?.dispatchEvent(new Event('change'));
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]);
|
||||
});
|
||||
|
||||
it('picks up a library added while it was on screen', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', [
|
||||
{ id: 7, name: 'Music' },
|
||||
{ id: 8, name: 'Field Recordings' },
|
||||
{ id: 9, name: 'Podcasts' },
|
||||
]);
|
||||
emit(Events.LibraryAdded, { id: 9 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, 'option')).toContain('Podcasts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<library-status-indicator>', () => {
|
||||
it('defaults to "not in library"', async () => {
|
||||
const el = await fixture('library-status-indicator');
|
||||
|
||||
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('plus');
|
||||
});
|
||||
|
||||
it('uses a distinct glyph per state', async () => {
|
||||
const glyphs: (string | null | undefined)[] = [];
|
||||
|
||||
for (const status of ['in-library', 'queued', 'not-in-library']) {
|
||||
const el = await fixture('library-status-indicator', { status });
|
||||
|
||||
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
|
||||
}
|
||||
|
||||
expect(glyphs).toEqual(['check', 'hourglass-half', 'plus']);
|
||||
});
|
||||
|
||||
it('phrases its label around the entity it describes', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
status: 'in-library',
|
||||
entityType: 'album',
|
||||
label: 'Abbey Road',
|
||||
});
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Album "Abbey Road" is in your library',
|
||||
);
|
||||
});
|
||||
|
||||
it('phrases an unowned entity as an invitation', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
entityType: 'artist',
|
||||
label: 'Eno',
|
||||
});
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Add artist "Eno" to library',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the quoted name when it has none', async () => {
|
||||
const el = await fixture('library-status-indicator', { status: 'queued' });
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Track is queued for download',
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors the label into the tooltip', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
status: 'in-library',
|
||||
});
|
||||
|
||||
const button = shadow(el, 'button');
|
||||
|
||||
expect(button?.getAttribute('title')).toBe(
|
||||
button?.getAttribute('aria-label'),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows the click, so it does not navigate the card it sits on', async () => {
|
||||
const el = await fixture('library-status-indicator');
|
||||
let bubbled = 0;
|
||||
|
||||
el.addEventListener('click', () => {
|
||||
bubbled += 1;
|
||||
});
|
||||
|
||||
shadow<HTMLElement>(el, 'button')?.click();
|
||||
|
||||
expect([bubbled, calls()]).toEqual([0, []]);
|
||||
});
|
||||
|
||||
it('swallows Enter and Space for the same reason', async () => {
|
||||
const el = await fixture('library-status-indicator');
|
||||
let bubbled = 0;
|
||||
|
||||
el.addEventListener('keydown', () => {
|
||||
bubbled += 1;
|
||||
});
|
||||
|
||||
for (const key of ['Enter', ' ', 'Tab']) {
|
||||
shadow(el, 'button')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// Tab still gets through: it is navigation, not activation.
|
||||
expect(bubbled).toBe(1);
|
||||
});
|
||||
|
||||
it('honours a non-default size', async () => {
|
||||
const el = await fixture('library-status-indicator', { size: 32 });
|
||||
|
||||
expect(el.style.getPropertyValue('--indicator-size')).toBe('32px');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
status: 'in-library',
|
||||
});
|
||||
|
||||
await update(el, { size: 40 });
|
||||
await visual(el, 'library-status-indicator-in-library');
|
||||
expect(shadow(el, 'button')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<library-filter> resilience', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the merged view even when the library list cannot be loaded', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', () => {
|
||||
throw new Error('database locked');
|
||||
});
|
||||
// The store caches the library list; drop it so the failing stub is
|
||||
// actually reached.
|
||||
emit(Events.LibraryRemoved, { id: 7 });
|
||||
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, 'option')).toEqual(['All Libraries']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* `<now-playing>` and `<queue-panel>` are the two components driven
|
||||
* entirely by store state rather than by their own properties: feeding
|
||||
* them backend events is how they are made to render anything at all.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/now-playing/now-playing';
|
||||
import '@components/queue-panel/queue-panel';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, stub, flush, lastArgs } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
text,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
import type { QueueTrack } from '@store/queue-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'ashes.mp3',
|
||||
filePath: '/music/ashes.mp3',
|
||||
trackLength: 215,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Ashes to Ashes',
|
||||
artist: 'David Bowie',
|
||||
album: 'Scary Monsters',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
function queueTrack(n: number, title: string): QueueTrack {
|
||||
return {
|
||||
id: n,
|
||||
audioFileId: n,
|
||||
filePath: `/music/${n}.mp3`,
|
||||
position: n,
|
||||
title,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
coverArtPath: '',
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
}
|
||||
|
||||
function setQueue(tracks: QueueTrack[], currentIndex = 0): void {
|
||||
emit(Events.QueueChanged, {
|
||||
tracks,
|
||||
currentIndex,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
});
|
||||
}
|
||||
|
||||
describe('<now-playing>', () => {
|
||||
beforeEach(() => {
|
||||
emit(Events.TrackChanged, null);
|
||||
});
|
||||
|
||||
it('shows only a placeholder when nothing is loaded', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
expect([
|
||||
shadow(el, '.cover-placeholder'),
|
||||
shadow(el, '[data-testid="now-playing-title"]'),
|
||||
]).toEqual([expect.anything(), null]);
|
||||
});
|
||||
|
||||
it('renders the title and artist of the loaded track', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([
|
||||
text(el, '[data-testid="now-playing-title"]'),
|
||||
text(el, '[data-testid="now-playing-artist"]'),
|
||||
]).toEqual(['Ashes to Ashes', 'David Bowie']);
|
||||
});
|
||||
|
||||
it('names an artistless track rather than leaving the line blank', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, artist: '', trackChangeId: 2 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="now-playing-artist"]')).toBe(
|
||||
'Unknown Artist',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the small cover variant and falls back on error', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, {
|
||||
...TRACK,
|
||||
coverArt: '/covers/big.jpg',
|
||||
coverArtSmall: '/covers/missing.jpg',
|
||||
trackChangeId: 3,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
||||
const initial = img?.getAttribute('src');
|
||||
|
||||
img?.dispatchEvent(new Event('error'));
|
||||
|
||||
expect([initial, img?.src.endsWith('/covers/big.jpg')]).toEqual([
|
||||
'/covers/missing.jpg',
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers a favourite toggle that names the target playlist', async () => {
|
||||
stub('playlist.Service.GetDefaultPlaylistTrackPaths', []);
|
||||
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' });
|
||||
emit(Events.PlaylistRenamed, 1);
|
||||
await flush();
|
||||
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 4 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, '.fav-btn')?.getAttribute('title')).toBe('Add to Loved');
|
||||
});
|
||||
|
||||
it('toggles the favourite through the backend', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
shadow<HTMLElement>(el, '.fav-btn')?.click();
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('playlist.Service.ToggleDefaultPlaylistTrack')).toEqual([
|
||||
'/music/ashes.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
await visual(el, 'now-playing');
|
||||
expect(text(el, '[data-testid="now-playing-title"]')).toBe(
|
||||
'Ashes to Ashes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<queue-panel>', () => {
|
||||
beforeEach(() => {
|
||||
setQueue([]);
|
||||
});
|
||||
|
||||
it('says so when the queue is empty', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
expect(text(el, '.empty-state p')).toBe('Queue is empty');
|
||||
});
|
||||
|
||||
it('renders a row per queued track, tagged with its file path', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
const rows = shadowAll(el, '[data-testid="queue-row"]');
|
||||
|
||||
expect(rows.map((r) => r.getAttribute('data-file-path'))).toEqual([
|
||||
'/music/1.mp3',
|
||||
'/music/2.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the playing row as active', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
const active = shadowAll(el, '[data-testid="queue-row"].active');
|
||||
|
||||
expect(active.map((r) => r.getAttribute('data-index'))).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('disables the clear button on an empty queue', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
const button = shadow<HTMLButtonElement>(el, '.header-action-button');
|
||||
|
||||
expect(button?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('clears through the backend, not locally', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
setQueue([queueTrack(1, 'First')]);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
shadow<HTMLButtonElement>(el, '.header-action-button')?.click();
|
||||
await flush();
|
||||
|
||||
expect(calls().map((c) => c.path)).toContain('queue.Queue.Clear');
|
||||
});
|
||||
|
||||
// No screenshot for the queue panel: its list is a
|
||||
// @lit-labs/virtualizer, which keeps re-measuring, so
|
||||
// toMatchScreenshot never gets two identical frames and fails with
|
||||
// "could not capture a stable screenshot" rather than a real diff.
|
||||
it('keeps rendering rows after the virtualizer settles', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
expect(shadowAll(el, '[data-testid="queue-row"]').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Every custom element in the tree, mounted against an empty backend.
|
||||
*
|
||||
* This is deliberately shallow: it asserts each component renders
|
||||
* *something* and logs no error, which is the state an agent's change
|
||||
* most often breaks and which nothing else here would notice. Depth
|
||||
* belongs in the per-component specs and in e2e; breadth belongs here,
|
||||
* because 46 elements is more than anyone will write specs for.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// One import per component module, so a component that fails to even
|
||||
// load is a failure here rather than a silent absence.
|
||||
import '@components/artist-details/artist-details';
|
||||
import '@components/artists-view/artists-view';
|
||||
import '@components/audio-player/audio-player';
|
||||
import '@components/audio-player/controls/player-controls';
|
||||
import '@components/audio-player/seekbar/seek-bar';
|
||||
import '@components/audio-player/volume-control/volume-control';
|
||||
import '@components/autotag-view/autotag-view';
|
||||
import '@components/combobox/combobox';
|
||||
import '@components/config-page/config-page';
|
||||
import '@components/config-page/config-field';
|
||||
import '@components/config-page/config-section';
|
||||
import '@components/config-page/download-clients';
|
||||
import '@components/config-page/shortcut-capture';
|
||||
import '@components/cover-grid/cover-grid';
|
||||
import '@components/cover-grid/album-dropdown';
|
||||
import '@components/download-picker/download-picker';
|
||||
import '@components/download-picker/candidate-row';
|
||||
import '@components/downloads-view/downloads-view';
|
||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog';
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import '@components/explore-artist-details/explore-artist-details';
|
||||
import '@components/explore-view/explore-view';
|
||||
import '@components/first-run-wizard/first-run-wizard';
|
||||
import '@components/genre-details/genre-details';
|
||||
import '@components/genres-view/genres-view';
|
||||
import '@components/jobs/job-details-drawer';
|
||||
import '@components/jobs/job-indicator';
|
||||
import '@components/jobs/job-log-view';
|
||||
import '@components/jobs/job-row';
|
||||
import '@components/jobs/jobs-view';
|
||||
import '@components/library-filter/library-filter';
|
||||
import '@components/library-status-indicator/library-status-indicator';
|
||||
import '@components/now-playing/now-playing';
|
||||
import '@components/phantom-resolver/phantom-resolver';
|
||||
import '@components/playlist-details/playlist-details';
|
||||
import '@components/playlist-picker/playlist-picker';
|
||||
import '@components/playlist-view/playlist-view';
|
||||
import '@components/queue-panel/queue-panel';
|
||||
import '@components/search-bar/search-bar';
|
||||
import '@components/sidebar/app-sidebar';
|
||||
import '@components/smart-playlist-details/smart-playlist-details';
|
||||
import '@components/smart-playlist-editor/smart-playlist-editor';
|
||||
import '@components/top-results-row/top-results-row';
|
||||
import '@components/track-details/track-details';
|
||||
import '@components/track-info/track-info';
|
||||
import '@components/track-list/track-list';
|
||||
|
||||
import { flush, stub } from '@test/support/harness';
|
||||
import { fixture } from '@test/support/render';
|
||||
|
||||
/** Every element the app registers, in registration order. */
|
||||
const TAGS = [
|
||||
'album-dropdown',
|
||||
'app-sidebar',
|
||||
'artist-details',
|
||||
'artists-view',
|
||||
'audio-player',
|
||||
'autotag-view',
|
||||
'candidate-row',
|
||||
'config-field',
|
||||
'config-page',
|
||||
'config-section',
|
||||
'cover-grid',
|
||||
'download-clients',
|
||||
'download-picker',
|
||||
'downloads-view',
|
||||
'duplicate-tracks-dialog',
|
||||
'explore-album-details',
|
||||
'explore-artist-details',
|
||||
'explore-view',
|
||||
'first-run-wizard',
|
||||
'genre-details',
|
||||
'genres-view',
|
||||
'job-details-drawer',
|
||||
'job-indicator',
|
||||
'job-log-view',
|
||||
'job-row',
|
||||
'jobs-view',
|
||||
'library-filter',
|
||||
'library-status-indicator',
|
||||
'now-playing',
|
||||
'phantom-resolver',
|
||||
'player-controls',
|
||||
'playlist-details',
|
||||
'playlist-picker',
|
||||
'playlist-view',
|
||||
'queue-panel',
|
||||
'search-bar',
|
||||
'seek-bar',
|
||||
'shortcut-capture',
|
||||
'smart-playlist-details',
|
||||
'smart-playlist-editor',
|
||||
'top-results-row',
|
||||
'track-details',
|
||||
'track-info',
|
||||
'track-list',
|
||||
'volume-control',
|
||||
'yj-combobox',
|
||||
];
|
||||
|
||||
/**
|
||||
* An empty-but-valid backend. Unstubbed bindings resolve undefined,
|
||||
* which is not what Go sends — an empty list is.
|
||||
*/
|
||||
function stubEmptyBackend(): void {
|
||||
const emptyLists = [
|
||||
'library.Library.GetAllTracks',
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||
'playlist.Service.GetAllPlaylists',
|
||||
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||
'playlist.Service.GetDefaultPlaylistTrackPaths',
|
||||
'jobs.Service.GetJobs',
|
||||
'download.Service.ListProviders',
|
||||
'download.Service.ListDownloads',
|
||||
'download.Service.ListRequests',
|
||||
'download.Service.ProviderKinds',
|
||||
];
|
||||
|
||||
for (const path of emptyLists) stub(path, []);
|
||||
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('config.Config.GetDownloadPreferences', {});
|
||||
stub('config.Config.GetThemeAccentColor', '#ffd43b');
|
||||
stub('config.Config.GetThemeBackgroundShade', 'dark');
|
||||
}
|
||||
|
||||
describe('every component mounts on an empty library', () => {
|
||||
let errors: unknown[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
stubEmptyBackend();
|
||||
errors = [];
|
||||
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers every element it defines', () => {
|
||||
const missing = TAGS.filter((tag) => !customElements.get(tag));
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
for (const tag of TAGS) {
|
||||
it(`<${tag}> renders without logging an error`, async () => {
|
||||
const el = await fixture(tag);
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect({ tag, root: el.shadowRoot !== null, errors }).toEqual({
|
||||
tag,
|
||||
root: true,
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* `<track-info>` is the shared row renderer: every list that shows a
|
||||
* track goes through it, so its fallbacks (missing title, missing
|
||||
* cover, missing duration) are visible in half the app.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/track-info/track-info';
|
||||
import { fixture, shadow, text, update, visual } from '@test/support/render';
|
||||
|
||||
describe('<track-info>', () => {
|
||||
it('renders title, artist and album', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'Ashes to Ashes',
|
||||
artist: 'David Bowie',
|
||||
album: 'Scary Monsters',
|
||||
});
|
||||
|
||||
expect([text(el, '.title'), text(el, '.secondary')]).toEqual([
|
||||
'Ashes to Ashes',
|
||||
'David Bowie — Scary Monsters',
|
||||
]);
|
||||
});
|
||||
|
||||
it('joins artist and album with an em dash, and omits the separator when one is missing', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
artist: 'Only Artist',
|
||||
});
|
||||
|
||||
expect(text(el, '.secondary')).toBe('Only Artist');
|
||||
});
|
||||
|
||||
it('omits the secondary line entirely when there is nothing to put in it', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'X' });
|
||||
|
||||
expect(shadow(el, '.secondary')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the filename, without its extension, when untitled', async () => {
|
||||
// WAV tracks currently scan in untitled, so this path is live.
|
||||
const el = await fixture('track-info', {
|
||||
filePath: '/music/field/01 - Dawn Chorus.wav',
|
||||
});
|
||||
|
||||
expect(text(el, '.title')).toBe('01 - Dawn Chorus');
|
||||
});
|
||||
|
||||
it('handles a Windows path in the same fallback', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
filePath: 'C:\\Music\\Album\\Track.mp3',
|
||||
});
|
||||
|
||||
expect(text(el, '.title')).toBe('Track');
|
||||
});
|
||||
|
||||
it('prefers a real title over the filename', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'Real Title',
|
||||
filePath: '/music/whatever.mp3',
|
||||
});
|
||||
|
||||
expect(text(el, '.title')).toBe('Real Title');
|
||||
});
|
||||
|
||||
it('renders no title element at all when it has neither title nor path', async () => {
|
||||
const el = await fixture('track-info', { artist: 'Someone' });
|
||||
|
||||
expect(shadow(el, '.title')).toBeNull();
|
||||
});
|
||||
|
||||
it('formats a duration given in milliseconds', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
duration: '215000',
|
||||
});
|
||||
|
||||
expect(text(el, '.duration')).toBe('03:35');
|
||||
});
|
||||
|
||||
it('shows placeholder dashes for an unparseable duration', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
duration: 'unknown',
|
||||
});
|
||||
|
||||
expect(text(el, '.duration')).toBe('--:--');
|
||||
});
|
||||
|
||||
it('omits the duration column when there is no duration', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'X' });
|
||||
|
||||
expect(shadow(el, '.duration')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no cover slot at all unless a cover was supplied', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'X' });
|
||||
|
||||
expect(shadow(el, '.cover-art')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the small cover variant, which is what a row needs', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
coverArt: '/covers/big.jpg',
|
||||
coverArtSmall: '/covers/small.jpg',
|
||||
});
|
||||
|
||||
expect(shadow<HTMLImageElement>(el, '.cover-art img')?.getAttribute('src')).toBe(
|
||||
'/covers/small.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the full-size cover when the thumbnail fails to load', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
coverArt: '/covers/big.jpg',
|
||||
coverArtSmall: '/covers/missing.jpg',
|
||||
});
|
||||
|
||||
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
||||
|
||||
img?.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(img?.src).toContain('/covers/big.jpg');
|
||||
});
|
||||
|
||||
it('degrades to the music-note placeholder when both covers fail', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
coverArtSmall: '/covers/missing.jpg',
|
||||
});
|
||||
|
||||
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
||||
|
||||
img?.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(shadow(el, '.cover-placeholder wa-icon')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('re-renders when a property changes', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'Before' });
|
||||
|
||||
await update(el, { trackTitle: 'After' });
|
||||
|
||||
expect(text(el, '.title')).toBe('After');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'Ashes to Ashes',
|
||||
artist: 'David Bowie',
|
||||
album: 'Scary Monsters',
|
||||
duration: '215000',
|
||||
});
|
||||
|
||||
await visual(el, 'track-info');
|
||||
expect(el.shadowRoot).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* The transport bar: five buttons and a seek bar, all of them driven by
|
||||
* backend push events rather than by their own clicks. These are the
|
||||
* controls the e2e tier drives by accessible name, so the names are as
|
||||
* much of a contract as the behaviour.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import '@components/audio-player/controls/player-controls';
|
||||
import '@components/audio-player/seekbar/seek-bar';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, lastArgs, flush } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
text,
|
||||
click,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'long.mp3',
|
||||
filePath: '/music/long.mp3',
|
||||
trackLength: 90,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Long Player',
|
||||
artist: 'Test Artist',
|
||||
album: 'Fixtures',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
/** Reset the backend-owned state both components read from. */
|
||||
function idle(): void {
|
||||
emit(Events.TrackChanged, null);
|
||||
emit(Events.PlaybackStateChanged, { state: 'stopped' });
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'off' });
|
||||
}
|
||||
|
||||
function labelOf(host: Element, index: number): string | null {
|
||||
return shadowAll(host, 'button')[index]?.getAttribute('aria-label') ?? null;
|
||||
}
|
||||
|
||||
describe('<player-controls>', () => {
|
||||
beforeEach(() => {
|
||||
idle();
|
||||
});
|
||||
|
||||
it('names every button, so both a screen reader and a selector can find it', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
expect(shadowAll(el, 'button').map((b) => b.getAttribute('aria-label'))).toEqual(
|
||||
['Shuffle', 'Previous track', 'Play', 'Next track', 'Repeat: off'],
|
||||
);
|
||||
});
|
||||
|
||||
it('becomes a pause button while playing', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([labelOf(el, 2), shadow(el, 'wa-icon[name="pause"]')]).not.toContain(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the queue to play, not the player — the queue owns what plays next', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
await click(el, 'button[aria-label="Play"]');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual(['queue.Queue.Play']);
|
||||
});
|
||||
|
||||
it('pauses through the player once playing', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await click(el, 'button[aria-label="Pause"]');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual(['player.Player.Pause']);
|
||||
});
|
||||
|
||||
it('wires skip forward and back to the queue', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
await click(el, 'button[aria-label="Next track"]');
|
||||
await click(el, 'button[aria-label="Previous track"]');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'queue.Queue.Next',
|
||||
'queue.Queue.Previous',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports shuffle state through aria-pressed, not just colour', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
const before = shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute(
|
||||
'aria-pressed',
|
||||
);
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'off' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([
|
||||
before,
|
||||
shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute('aria-pressed'),
|
||||
]).toEqual(['false', 'true']);
|
||||
});
|
||||
|
||||
it('spells the repeat mode into the label, since one icon covers three states', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'one' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(labelOf(el, 4)).toBe('Repeat: one');
|
||||
});
|
||||
|
||||
it('marks repeat-one so its badge renders', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'one' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'button.repeat-one')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not toggle its own state — the backend confirms it', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
await click(el, 'button[aria-label="Shuffle"]');
|
||||
|
||||
expect([
|
||||
calls().map((c) => c.path),
|
||||
shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute('aria-pressed'),
|
||||
]).toEqual([['queue.Queue.ToggleShuffle'], 'false']);
|
||||
});
|
||||
|
||||
it('stops listening to the queue once removed', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
el.remove();
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' });
|
||||
await flush();
|
||||
|
||||
// A leaked subscription would keep rendering a detached element.
|
||||
expect(el.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
await visual(el, 'player-controls');
|
||||
expect(shadowAll(el, 'button')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<seek-bar>', () => {
|
||||
beforeEach(() => {
|
||||
idle();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows placeholder clocks with nothing loaded', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
expect([
|
||||
text(el, '[data-testid="elapsed-time"]'),
|
||||
text(el, '[data-testid="remaining-time"]'),
|
||||
]).toEqual(['--:--', '--:--']);
|
||||
});
|
||||
|
||||
it('shows elapsed and remaining once a track is loaded', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([
|
||||
text(el, '[data-testid="elapsed-time"]'),
|
||||
text(el, '[data-testid="remaining-time"]'),
|
||||
]).toEqual(['00:00', '01:30']);
|
||||
});
|
||||
|
||||
it('resumes mid-track from the position the backend reported', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 30, trackChangeId: 2 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30');
|
||||
});
|
||||
|
||||
it('rewinds when the same file plays again, which only the change id reveals', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 45, trackChangeId: 3 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 0, trackChangeId: 4 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:00');
|
||||
});
|
||||
|
||||
it('ticks the clock forward while playing', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await el.updateComplete;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:03');
|
||||
});
|
||||
|
||||
it('stops ticking when paused', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await el.updateComplete;
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'paused' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await el.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:02');
|
||||
});
|
||||
|
||||
it('seeks to the position the slider was dropped at', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
|
||||
|
||||
if (slider) slider.value = 42;
|
||||
|
||||
slider?.dispatchEvent(new Event('change'));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(lastArgs('player.Player.Seek')).toEqual([42]);
|
||||
});
|
||||
|
||||
it('bounds the slider by the track length', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'wa-slider')?.getAttribute('max')).toBe('90');
|
||||
});
|
||||
|
||||
it('carries an accessible name, since it is otherwise an unlabelled slider', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
expect(shadow(el, 'wa-slider')?.getAttribute('aria-label')).toBe('Seek');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 30, trackChangeId: 9 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
await visual(el, 'seek-bar');
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user