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
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import { test, expect, callBinding } from '../support/fixtures.js';
|
|
|
|
/**
|
|
* The badge says an album is requested, and the button beside it agrees.
|
|
*
|
|
* `library-status-indicator` has had three states since it was written
|
|
* and produced two: every one of the eight call sites was a two-way
|
|
* ternary, so an album already on the request list showed a plus and
|
|
* said "is not in your library" — on the same page, forty pixels from a
|
|
* filled button reading "Wanted".
|
|
*
|
|
* This spec exists at this tier rather than only in the component one
|
|
* because of what it drags in with it: reaching the requested state is
|
|
* also the only way to render the requested *icon*, and the requested
|
|
* icon was `bookmark-check`, a Font Awesome **Pro** name that has never
|
|
* been bundled. `offline-icons.spec.ts` asserts `__yjIconMisses` is
|
|
* empty and passed anyway, because no spec had ever put the app in this
|
|
* state. A name computed from state is only checkable from the state.
|
|
*
|
|
* It gives back what it spends: the request is removed in `afterAll`,
|
|
* and the staged catalog rows are `INSERT OR IGNORE`d so a second run
|
|
* against the same backend is a no-op.
|
|
*/
|
|
|
|
/** A release group that exists whether or not this environment has a
|
|
* catalog — CI's `YJ_CORE_INDEX_URL` is deliberately dead. */
|
|
const MBID = 'e2e-rg-badge-0001';
|
|
const TITLE = 'Requested Album';
|
|
const ARTIST = 'Badge Artist';
|
|
|
|
let requestId = 0;
|
|
|
|
test.describe('the requested badge', () => {
|
|
test.beforeAll(async ({ browser, baseURL }) => {
|
|
const page = await browser.newPage();
|
|
|
|
await page.goto(baseURL!);
|
|
|
|
const res = await page.evaluate(
|
|
async (row) => {
|
|
const r = await fetch('/__test/sql', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sql: `INSERT OR IGNORE INTO explore_index
|
|
(entity_type, mbid, title, artist_name, artist_mbid,
|
|
popularity, listener_count, primary_type)
|
|
VALUES ('release_group', ?, ?, ?, 'e2e-ar-badge', 10, 10, 'Album')`,
|
|
args: [row.mbid, row.title, row.artist],
|
|
}),
|
|
});
|
|
|
|
return { status: r.status, body: await r.text() };
|
|
},
|
|
{ mbid: MBID, title: TITLE, artist: ARTIST },
|
|
);
|
|
|
|
// A setup step whose failure is not checked is not setup.
|
|
expect(res.status, `staging failed: ${res.body}`).toBe(200);
|
|
|
|
// A previous run that died between adding and removing would leave
|
|
// this album requested, and the first assertion here is that it is
|
|
// not — so start from a known state rather than from the last run's
|
|
// luck. The 90 specs share one backend in file order.
|
|
await clearRequest(page);
|
|
|
|
await page.close();
|
|
});
|
|
|
|
test.afterAll(async ({ browser, baseURL }) => {
|
|
const page = await browser.newPage();
|
|
|
|
await page.goto(baseURL!);
|
|
await clearRequest(page);
|
|
await page.close();
|
|
requestId = 0;
|
|
});
|
|
|
|
test('a requested album is queued, not absent', async ({ app }) => {
|
|
const libraryId = 1;
|
|
|
|
// Before: the badge for an unrequested album.
|
|
await app.getByTestId('nav-explore').click();
|
|
await search(app, TITLE);
|
|
|
|
expect(await badgeStatus(app, TITLE)).toBe('not-in-library');
|
|
|
|
requestId = await addRequest(app, libraryId);
|
|
|
|
expect(requestId).toBeGreaterThan(0);
|
|
|
|
// The badge is told by the store, which is told by the backend —
|
|
// no navigation, no reload.
|
|
await expect
|
|
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
|
|
.toBe('queued');
|
|
|
|
// And it says so where it counts. The name is the whole point —
|
|
// the plus used to be accompanied by "is not in your library" —
|
|
// and since phase 3 it is a control, so the name is the action it
|
|
// performs rather than the state it is in.
|
|
expect(await badgeLabel(app, TITLE)).toBe(
|
|
`Cancel the request for album "${TITLE}"`,
|
|
);
|
|
});
|
|
|
|
test('clicking the badge wants the album and does not open it', async ({
|
|
app,
|
|
}) => {
|
|
// Only this tier can say this. The badge sits inside a card whose
|
|
// own click navigates, so the assertion is that a real gesture on
|
|
// the badge files a request *and* leaves the page where it was —
|
|
// and a synthetic MouseEvent is not evidence of either.
|
|
await clearRequest(app);
|
|
await app.getByTestId('nav-explore').click();
|
|
await search(app, TITLE);
|
|
|
|
// A locator rather than measured coordinates, and the difference
|
|
// is not style. The first version read a bounding box the moment
|
|
// the search settled and clicked it — but cover art is still
|
|
// arriving then, and a card that grows moves the badge, so the
|
|
// click landed on the card and opened the album. A locator
|
|
// re-resolves and waits for the element to stop moving.
|
|
const button = app
|
|
.locator(`library-status-indicator[label="${TITLE}"] button`)
|
|
.first();
|
|
|
|
// A badge that is not a button makes every assertion below vacuous.
|
|
await expect(button, 'the badge is not a button').toBeVisible();
|
|
|
|
await button.click();
|
|
|
|
await expect
|
|
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
|
|
.toBe('queued');
|
|
|
|
expect(
|
|
await app.evaluate(() => !!document.querySelector('explore-album-details')),
|
|
'the click reached the card underneath',
|
|
).toBe(false);
|
|
|
|
requestId = 1; // so afterAll cleans up regardless of order
|
|
});
|
|
|
|
test('the requested state renders a real icon', async ({ app }) => {
|
|
// `bookmark-check` is Pro, so the button rendered the fallback
|
|
// glyph for as long as anything had been requested. The sweep in
|
|
// offline-icons.spec.ts could not see it: it never reached here.
|
|
// Runs after the test above in file order, which is where the
|
|
// request comes from — but a spec that only passes as part of a
|
|
// sequence is a spec that lies when it is run alone.
|
|
if (!requestId) requestId = await addRequest(app, 1);
|
|
|
|
await app.getByTestId('nav-explore').click();
|
|
await search(app, TITLE);
|
|
await openFirstAlbum(app);
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
app.evaluate(() => {
|
|
const ds = document.querySelector('explore-album-details')
|
|
?.shadowRoot;
|
|
const btn = [...(ds?.querySelectorAll('wa-button') ?? [])].find(
|
|
(b) => /Wanted/.test(b.textContent ?? ''),
|
|
);
|
|
|
|
return btn?.querySelector('wa-icon')?.getAttribute('name') ?? '';
|
|
}),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.not.toBe('');
|
|
|
|
const misses = await app.evaluate(
|
|
() =>
|
|
(window as unknown as { __yjIconMisses?: string[] }).__yjIconMisses ??
|
|
[],
|
|
);
|
|
|
|
expect(misses, 'an icon name that is not bundled').toEqual([]);
|
|
});
|
|
});
|
|
|
|
function addRequest(
|
|
app: import('@playwright/test').Page,
|
|
libraryId: number,
|
|
): Promise<number> {
|
|
return callBinding<number>(app, 'download.Service.AddRequest', [
|
|
{
|
|
mbid: MBID,
|
|
entity: 'release-group',
|
|
libraryId,
|
|
artist: ARTIST,
|
|
title: TITLE,
|
|
scope: 'future',
|
|
secondary: false,
|
|
},
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Drop any request for this album, over the runtime endpoint.
|
|
*
|
|
* Deliberately not `callBinding`: that goes through
|
|
* `window.__yjEvents`, which only exists on a page the `app` fixture
|
|
* created — a bare `browser.newPage()` has no init script, so the
|
|
* bridge is undefined and the cleanup throws where nobody is looking.
|
|
* The first version of this did exactly that and left the request
|
|
* behind, which failed the *next* run of this same spec.
|
|
*
|
|
* v2's answer was `window.go`, which every page had. v3 has no such
|
|
* global, and the version of this that kept reading it did not throw —
|
|
* it returned early on `if (!svc)`, which is the same silent cleanup
|
|
* with a different cause. A POST to `/wails/runtime` needs neither:
|
|
* it is the same request the bundle makes, and any page can make it.
|
|
*/
|
|
async function clearRequest(page: import('@playwright/test').Page) {
|
|
await page.evaluate(async (mbid) => {
|
|
const call = async (method: string, args: unknown[]) => {
|
|
const res = await fetch('/wails/runtime', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
object: 0,
|
|
method: 0,
|
|
args: {
|
|
'call-id': `clear-${method}`,
|
|
methodName: `yellowjacket/backend/${method}`,
|
|
args,
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) throw new Error(`${method}: ${await res.text()}`);
|
|
|
|
return res.json();
|
|
};
|
|
|
|
const rows: { id: number; mbid: string }[] =
|
|
(await call('download.Service.ListRequests', [])) ?? [];
|
|
|
|
for (const row of rows) {
|
|
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
|
|
await call('download.Service.RemoveRequest', [row.id]);
|
|
}
|
|
}
|
|
}, MBID);
|
|
}
|
|
|
|
/** Type into Explore's own search box and wait for it to settle. */
|
|
async function search(app: import('@playwright/test').Page, term: string) {
|
|
// A view is a chunk, and `document.createElement` on a tag that has
|
|
// not loaded yet yields an inert element rather than throwing — so
|
|
// the box being missing reads exactly like a selector bug.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
app.evaluate(
|
|
() =>
|
|
!!document
|
|
.querySelector('explore-view')
|
|
?.shadowRoot?.querySelector('input'),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(true);
|
|
|
|
await app.evaluate((t) => {
|
|
const sr = document.querySelector('explore-view')?.shadowRoot;
|
|
const input = sr?.querySelector('input');
|
|
|
|
if (!input) throw new Error('explore search box not found');
|
|
|
|
input.value = t;
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}, term);
|
|
|
|
// 60 s, and it is not paranoia. A freshly launched app spends its
|
|
// first ~40 s merging the core catalog artifact, and until that lands
|
|
// Explore's search returns nothing at all — including for rows staged
|
|
// directly into `explore_index` a moment ago. A shorter budget fails
|
|
// here for a reason that has nothing to do with what is being tested,
|
|
// which is how this spec first "failed" on a build that was fine.
|
|
await expect
|
|
.poll(() => cardTitles(app), { timeout: 60_000 })
|
|
.toContain(term);
|
|
}
|
|
|
|
function cardTitles(app: import('@playwright/test').Page): Promise<string[]> {
|
|
return app.evaluate(() =>
|
|
[
|
|
...(document
|
|
.querySelector('explore-view')
|
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
|
].map((b) => b.getAttribute('label') ?? ''),
|
|
);
|
|
}
|
|
|
|
function badgeStatus(
|
|
app: import('@playwright/test').Page,
|
|
label: string,
|
|
): Promise<string> {
|
|
return app.evaluate((wanted) => {
|
|
const badge = [
|
|
...(document
|
|
.querySelector('explore-view')
|
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
|
].find((b) => b.getAttribute('label') === wanted);
|
|
|
|
return badge?.getAttribute('status') ?? '(no badge)';
|
|
}, label);
|
|
}
|
|
|
|
function badgeLabel(
|
|
app: import('@playwright/test').Page,
|
|
label: string,
|
|
): Promise<string> {
|
|
return app.evaluate((wanted) => {
|
|
const badge = [
|
|
...(document
|
|
.querySelector('explore-view')
|
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
|
].find((b) => b.getAttribute('label') === wanted);
|
|
|
|
return (
|
|
badge?.shadowRoot?.querySelector('.badge')?.getAttribute('aria-label') ??
|
|
'(no badge)'
|
|
);
|
|
}, label);
|
|
}
|
|
|
|
async function openFirstAlbum(app: import('@playwright/test').Page) {
|
|
await app.evaluate((wanted) => {
|
|
const badge = [
|
|
...(document
|
|
.querySelector('explore-view')
|
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
|
].find((b) => b.getAttribute('label') === wanted);
|
|
|
|
(badge?.closest('.album-card') as HTMLElement | null)?.click();
|
|
}, TITLE);
|
|
|
|
await expect
|
|
.poll(
|
|
() => app.evaluate(() => !!document.querySelector('explore-album-details')),
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(true);
|
|
}
|