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
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
|
|
|
/**
|
|
* The top bar fits the window it is in (#143).
|
|
*
|
|
* **This is measured per child, not on the shell**, which is #69's
|
|
* lesson repeated one component over: `layout-overflow.spec.ts` asserts
|
|
* the *document* needs no sideways scrolling, and clipping inside a
|
|
* component is invisible to it — which is exactly why that spec was
|
|
* green throughout this defect. What a user sees is a control rendered
|
|
* past the edge of the bar it belongs to, so that is what is asserted.
|
|
*
|
|
* **And it is measured with a job running**, which is the half the
|
|
* original report missed. `job-indicator` is `hidden` while idle and up
|
|
* to 235px wide when it is not, so the bar was 611px inside 600 sitting
|
|
* still and 862px during a scan — 171 to 262px of overflow, arriving
|
|
* exactly when a user has reason to look at that bar. Nothing else in
|
|
* this suite has ever measured a layout with work in flight;
|
|
* `/__test/emit` stages it without staging the scan.
|
|
*/
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
/**
|
|
* The widths this asks about.
|
|
*
|
|
* 600 is the bottom of the Compact band (#24) and where the defect
|
|
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
|
|
* find, at the width that just gained the sidebar's labels); 800 is the
|
|
* enforced minimum.
|
|
*
|
|
* **390 is kept, and what it asks changed with #57.** There is no bar
|
|
* to fit below 600px any more — it is out of the grid and visually
|
|
* hidden — so "nothing hangs out of it" is a claim about an element
|
|
* with no row, and would pass on a build that had merely broken the
|
|
* bar. Dropping the width would be dropping the one place this file
|
|
* can still say something true about a phone, so it asserts the
|
|
* *stronger* property instead, below: the bar is out of the layout
|
|
* altogether, which is the thing #57 wanted and the thing that makes
|
|
* fitting moot.
|
|
*/
|
|
const WIDTHS = [600, 800, 899, 900, 1440];
|
|
|
|
/** Where #57 leaves the bar, and where the desktop still has one. */
|
|
const PHONE_WIDTH = 390;
|
|
|
|
/**
|
|
* A scan whose title is as long as a real one gets. The label is capped
|
|
* at 12rem by the component, so this is the widest the indicator can
|
|
* be — measuring with "Scanning" instead reports a bar that fits and a
|
|
* defect that is 100px smaller than it is.
|
|
*/
|
|
const LONG_JOB = {
|
|
id: 'top-bar-fit',
|
|
kind: 'library-scan',
|
|
state: 'running',
|
|
title: 'Scanning Music from the external drive',
|
|
current: 40,
|
|
total: 100,
|
|
};
|
|
|
|
/**
|
|
* Every child's right edge against the bar's own content box.
|
|
*
|
|
* The content box, not `clientWidth`: the bar has a 2em right gutter,
|
|
* and a control sitting in the padding is already the failure — it is
|
|
* simply one that `scrollWidth` under-reports, because `scrollWidth`
|
|
* counts the left padding and not the right.
|
|
*/
|
|
const overflowingChildren = (page: Page) =>
|
|
page.evaluate(() => {
|
|
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
|
|
const style = getComputedStyle(bar);
|
|
const box = bar.getBoundingClientRect();
|
|
const left = box.left + parseFloat(style.paddingLeft);
|
|
const right = box.right - parseFloat(style.paddingRight);
|
|
|
|
return [...bar.children]
|
|
.filter((child) => {
|
|
const cs = getComputedStyle(child);
|
|
|
|
// Out of flow is out of the question: a collapsed wordmark is
|
|
// `position: absolute` and 1px wide precisely so it costs the
|
|
// row nothing.
|
|
if (cs.display === 'none' || cs.position === 'absolute') return false;
|
|
|
|
const r = child.getBoundingClientRect();
|
|
|
|
return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5);
|
|
})
|
|
.map((child) => {
|
|
const r = child.getBoundingClientRect();
|
|
|
|
return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`;
|
|
});
|
|
});
|
|
|
|
/** What the fit pass gave up, read back off the DOM it changed. */
|
|
const collapsed = (page: Page) =>
|
|
page.evaluate(() => ({
|
|
wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'),
|
|
jobLabel: !!document.querySelector('job-indicator[compact]'),
|
|
}));
|
|
|
|
test.describe('the top bar fits the window', () => {
|
|
/**
|
|
* **State a spec stages is the spec's to clear** (#168). `/__test/emit`
|
|
* writes to a store nothing resets, and this file stages the widest job
|
|
* in the app, so it puts it back — with the same event, since the store
|
|
* replaces its whole list from every snapshot.
|
|
*
|
|
* **Measured: it does not currently outlive the page.** Every test gets
|
|
* a fresh page and `JobStore.init()` refetches `GetJobs()` from a
|
|
* backend registry `/__test/emit` never writes to, so nothing is being
|
|
* repaired here; the rule is stated because it costs one line and the
|
|
* leak would need only one spec that keeps a page alive.
|
|
*/
|
|
test.afterEach(async ({ testctl }) => {
|
|
await testctl.emit('JobsChanged', []);
|
|
});
|
|
|
|
/**
|
|
* The phone's answer, which is not "it fits" (#57).
|
|
*
|
|
* The bar has no grid row below 600px, so measuring its children
|
|
* against its content box is measuring a 1px box that is already
|
|
* invisible — a fit pass would collapse the wordmark every time and
|
|
* report success about nothing, which is why `measureTopBarFit`
|
|
* declines to run at all when the bar is out of flow. What is worth
|
|
* asserting here is that the fit pass has not quietly started
|
|
* *undoing* that: a rule that put the bar back in the layout would
|
|
* pass every assertion in this file and cost a 439px screen 12% of
|
|
* its height.
|
|
*/
|
|
test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize({ width: PHONE_WIDTH, height: 600 });
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
|
|
// Not merely hidden: `display: none` on the header would satisfy
|
|
// "invisible" and leave the 3.25em row exactly where it was. So
|
|
// the assertion is that the content starts where the row above it
|
|
// ends -- and with a job staged, the row above it is the jobs
|
|
// band, which is the whole reason this row could go.
|
|
await expect
|
|
.poll(() =>
|
|
app.evaluate(() => {
|
|
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
|
|
const main = document.querySelector<HTMLElement>('.main-panel')!;
|
|
const band = document.querySelector<HTMLElement>('job-band')!;
|
|
|
|
return {
|
|
position: getComputedStyle(bar).position,
|
|
gap:
|
|
Math.round(main.getBoundingClientRect().top) -
|
|
Math.round(band.getBoundingClientRect().bottom),
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({ position: 'absolute', gap: 0 });
|
|
|
|
// And the work is still visible, in the band that replaced the
|
|
// indicator (#62) — which is what made this row removable at all.
|
|
await expect(app.locator('job-indicator')).toBeHidden();
|
|
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
|
|
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
for (const width of WIDTHS) {
|
|
test(`no control sits outside the bar at ${width}px, idle`, async ({
|
|
app,
|
|
}) => {
|
|
await app.setViewportSize({ width, height: 600 });
|
|
|
|
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
|
});
|
|
|
|
test(`no control sits outside the bar at ${width}px, with a job running`, async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize({ width, height: 600 });
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
|
|
// The indicator has to actually be up, or this test passes by
|
|
// measuring the idle case under another name.
|
|
await expect(app.locator('job-indicator')).toBeVisible();
|
|
|
|
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The other half of "measured, never breakpointed": a rule that
|
|
* collapses defensively at every narrow width fits just as well and
|
|
* is a worse app. 1440 is roomy at any job title; 899 was measured to
|
|
* fit with the longest one, because `nav-history` is not there yet.
|
|
*/
|
|
test('nothing is given up where there is room for it', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await expect(app.locator('job-indicator')).toBeVisible();
|
|
|
|
await expect.poll(() => collapsed(app)).toEqual({
|
|
wordmark: false,
|
|
jobLabel: false,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* And it gives them back. The pass starts from all-visible every
|
|
* time, so this is the property that a rule which only ever *added*
|
|
* to the collapsed set would fail — the wordmark would be gone for
|
|
* the rest of the session after one narrow moment.
|
|
*/
|
|
test('the wordmark comes back when the window does', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await app.setViewportSize({ width: 600, height: 600 });
|
|
|
|
await expect.poll(() => collapsed(app)).toEqual({
|
|
wordmark: true,
|
|
jobLabel: true,
|
|
});
|
|
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
await expect.poll(() => collapsed(app)).toEqual({
|
|
wordmark: false,
|
|
jobLabel: false,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The wordmark yields its width and not its existence: `display:
|
|
* none` would take the document from one top-level heading to none.
|
|
*/
|
|
test('the collapsed wordmark is still the document heading', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await app.setViewportSize({ width: 600, height: 600 });
|
|
|
|
await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true });
|
|
|
|
await expect(
|
|
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
|
|
).toHaveCount(1);
|
|
});
|
|
|
|
/**
|
|
* And the indicator keeps saying what it is doing after its visible
|
|
* label goes — the `sr-only` live region is what announces the state,
|
|
* which is the same argument the phone's own rule was written on.
|
|
*/
|
|
test('the job indicator still announces its state without its label', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await app.setViewportSize({ width: 600, height: 600 });
|
|
|
|
await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true });
|
|
|
|
const spoken = await app
|
|
.locator('job-indicator')
|
|
.evaluate(
|
|
(el) =>
|
|
el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '',
|
|
);
|
|
|
|
expect(spoken).toContain('Scanning Music from the external drive');
|
|
|
|
// Leave the app as the next spec expects to find it.
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
});
|