Both specs stage a job through `/__test/emit` and neither cleared it. Nothing resets those stores, so the spec that staged it is the one that should put it back, and `JobsChanged` with `[]` is the whole cleanup -- `JobStore` replaces its list from every snapshot, so `testctl` needs no special case. **The leak as reported did not reproduce, and that is worth recording rather than quietly fixing.** Measured with a temporary probe: a positive control confirmed a staged job really does move the shell at a phone width (`job-band` renders a row, `.main-panel`'s top goes 0 to 55), and the very next page had no job at all. The reason is that every test gets a fresh page and `JobStore.init()` refetches `GetJobs()` from a backend registry `/__test/emit` never writes to -- it calls `events.Deliver`, which touches frontends and no state. So the state cannot cross a spec boundary as described, and the 55px offset the draft assertion saw in that suite run has another cause that is not in evidence. The cleanup stays, because it costs a line and the leak would need only one spec that keeps a page alive, and the comments say what was measured rather than asserting the mechanism. The durable half is the rule, now in the harness reference: measure against the element next to you, not an absolute coordinate. An absolute number in a shell measurement is also a claim about everything above it -- `contentTop === 0` asserts "and no background job is running", which that spec could not arrange. Closes #168
222 lines
7.7 KiB
TypeScript
222 lines
7.7 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
|
|
|
/**
|
|
* #62. On a phone, background work is shown in the notification band
|
|
* and the header indicator stands down.
|
|
*
|
|
* The report was that the indicator's popover "is obscured by other UI,
|
|
* so it cannot be read while jobs run". Worth saying plainly: **that
|
|
* symptom did not reproduce in this tier.** Measured at the device's
|
|
* own 424x439 viewport, the popover was neither clipped nor covered —
|
|
* `elementFromPoint` at its centre returned the indicator at every
|
|
* width tried. So this is not a fix for a stacking bug, and a spec
|
|
* asserting one would be a spec asserting something that was never
|
|
* true here.
|
|
*
|
|
* What is true regardless, and is what these assert:
|
|
*
|
|
* - a popover is a **disclosure**, and it is anchored to a bar 3.25em
|
|
* tall on a screen 439px tall. Background work is the one thing a
|
|
* phone should not make you open something to see.
|
|
* - #57 deletes that bar and is *blocked on this issue*, because the
|
|
* indicator needs somewhere else to live first. Somewhere else is
|
|
* the band, and the test that matters for #57 is that the bar no
|
|
* longer holds the indicator at all.
|
|
*
|
|
* This is the media-query tier by necessity: a query inside a shadow
|
|
* root is answered by the viewport, and `notification-host` decides
|
|
* whether the panel *exists* from `matchMedia`. The component tier
|
|
* cannot set either.
|
|
*/
|
|
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
const JOBS = [
|
|
{
|
|
id: 'phone:scan',
|
|
kind: 'library-scan',
|
|
state: 'running',
|
|
title: 'Scanning Music',
|
|
current: 40,
|
|
total: 100,
|
|
caps: { pausable: true, cancellable: true },
|
|
},
|
|
{
|
|
id: 'phone:idx',
|
|
kind: 'index-build',
|
|
state: 'running',
|
|
title: 'Building the search index',
|
|
current: 2,
|
|
total: 9,
|
|
caps: { pausable: true, cancellable: true },
|
|
},
|
|
];
|
|
|
|
/** The panel the band renders. Playwright's CSS engine pierces open
|
|
* shadow roots, which is what keeps this one line. */
|
|
const bandPanel = (page: Page) => page.locator('job-band').locator('job-panel');
|
|
|
|
const PHONE = { width: 424, height: 439 };
|
|
const DESKTOP = { width: 1100, height: 800 };
|
|
|
|
test.describe('background jobs on a phone', () => {
|
|
/**
|
|
* **State a spec stages is the spec's to clear.** `/__test/emit` writes
|
|
* to a store nothing resets, so the event that staged a job is the
|
|
* event that clears it — `JobStore` replaces its whole list from every
|
|
* snapshot, so `testctl` needs no special case.
|
|
*
|
|
* **Measured on #168: this does not currently outlive the page.** Every
|
|
* test gets a fresh page, and `JobStore.init()` refetches `GetJobs()`
|
|
* from a backend registry that `/__test/emit` never writes to, so the
|
|
* staged job is gone before the next spec starts. Ownership is stated
|
|
* rather than a live leak repaired — the leak needs a page that
|
|
* survives its own spec, and there is none today.
|
|
*/
|
|
test.afterEach(async ({ testctl }) => {
|
|
await testctl.emit('JobsChanged', []);
|
|
});
|
|
|
|
test('are shown in the band, without opening anything', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize(PHONE);
|
|
await testctl.emit('JobsChanged', JOBS);
|
|
|
|
await expect(bandPanel(app)).toBeVisible();
|
|
|
|
// Both jobs, drawn by real `job-row`s -- asking the rows what they
|
|
// hold rather than reading the panel's text, which would pass
|
|
// whether or not a row rendered. Playwright's CSS engine pierces
|
|
// open shadow roots, which is what makes this one line;
|
|
// `querySelectorAll` does not, and stops at `job-panel`.
|
|
await expect(bandPanel(app).locator('job-row')).toHaveCount(2);
|
|
|
|
await expect(
|
|
bandPanel(app).locator('job-row').first(),
|
|
).toContainText('Scanning Music');
|
|
});
|
|
|
|
/**
|
|
* The #57 assertion. Not "the indicator is invisible" — that could be
|
|
* true because the bar overflowed — but that the shell's own rule
|
|
* puts it away at this width.
|
|
*/
|
|
test('leave the top bar, which is what #57 is waiting for', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize(PHONE);
|
|
await testctl.emit('JobsChanged', JOBS);
|
|
await expect(bandPanel(app)).toBeVisible();
|
|
|
|
await expect(app.locator('job-indicator')).toBeHidden();
|
|
});
|
|
|
|
/**
|
|
* The property the first attempt at this got wrong, so it is the one
|
|
* worth pinning: the band is **in the layout**, not over it.
|
|
*
|
|
* A fixed band reads fine in a screenshot and is unusable -- at
|
|
* 424x439 a compact panel is ~200px of a 439px screen and it covers
|
|
* what is under it. Four specs failed on that version, two
|
|
* phone-shell journeys and the header's action menu, because the
|
|
* panel was intercepting the taps. So: nothing of the app is
|
|
* underneath it, and the main panel starts below it.
|
|
*/
|
|
test('push the content down rather than covering it', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize(PHONE);
|
|
|
|
const before = await app
|
|
.getByTestId('main-content')
|
|
.evaluate((el) => el.getBoundingClientRect().top);
|
|
|
|
await testctl.emit('JobsChanged', JOBS);
|
|
await expect(bandPanel(app)).toBeVisible();
|
|
|
|
const after = await app.evaluate(() => {
|
|
const band = document.querySelector('job-band') as HTMLElement;
|
|
const main = document.querySelector(
|
|
'[data-testid="main-content"]',
|
|
) as HTMLElement;
|
|
const b = band.getBoundingClientRect();
|
|
const m = main.getBoundingClientRect();
|
|
|
|
// What the browser reports at the band's own centre. If this is
|
|
// anything but the band, the band is sitting on top of it.
|
|
const hit = document.elementFromPoint(
|
|
Math.round(b.x + b.width / 2),
|
|
Math.round(b.y + b.height / 2),
|
|
);
|
|
|
|
return {
|
|
mainTop: m.top,
|
|
bandBottom: b.bottom,
|
|
withinViewport: b.bottom <= window.innerHeight + 0.5,
|
|
hit: hit?.tagName.toLowerCase() ?? null,
|
|
};
|
|
});
|
|
|
|
expect({
|
|
pushed: after.mainTop > before,
|
|
mainClearsBand: after.mainTop >= after.bandBottom - 0.5,
|
|
withinViewport: after.withinViewport,
|
|
hit: after.hit,
|
|
}).toEqual({
|
|
pushed: true,
|
|
mainClearsBand: true,
|
|
withinViewport: true,
|
|
hit: 'job-band',
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A running job repaints several times a second. The stack it sits
|
|
* beside is `role="status" aria-live="polite"`, and a progress bar
|
|
* inside a live region is a screen reader reading a number out over
|
|
* and over — so the two are siblings in the band rather than one
|
|
* list, and this is what says so.
|
|
*/
|
|
test('are not inside the live region they sit beside', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize(PHONE);
|
|
await testctl.emit('JobsChanged', JOBS);
|
|
await expect(bandPanel(app)).toBeVisible();
|
|
|
|
const insideLiveRegion = await app.evaluate(() => {
|
|
const band = document.querySelector('job-band');
|
|
|
|
// Neither the band itself nor anything it is nested in may be a
|
|
// live region -- `closest` answers both at once.
|
|
return !!band?.closest('[aria-live]') || band?.hasAttribute('aria-live');
|
|
});
|
|
|
|
expect(insideLiveRegion).toBe(false);
|
|
});
|
|
|
|
/**
|
|
* `bottom-nav` rendering its duplicate `<app-sidebar>` unconditionally
|
|
* broke 30 specs with "resolved to 2 elements" on a viewport where it
|
|
* was not even visible. Settings already holds four `job-panel`s, so
|
|
* a fifth that answers for *every* kind is the same trap — which is
|
|
* why the band decides from `matchMedia` whether the element exists
|
|
* rather than hiding it with CSS.
|
|
*/
|
|
test('do not leave a second panel behind on a desktop', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize(DESKTOP);
|
|
await testctl.emit('JobsChanged', JOBS);
|
|
|
|
await expect(app.locator('job-indicator')).toBeVisible();
|
|
await expect(bandPanel(app)).toHaveCount(0);
|
|
});
|
|
});
|