Files
yellowjacket/e2e/specs/layout-overflow.spec.ts
logan ddd04623f7
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m3s
test(e2e): fuse the scroll guard and the write it guards
`album-dropdown`'s "can be scrolled" failed twice over two sessions with
`Expected 80, Received 10`, both times on a branch that could not have
caused it. #133 strengthened the guard from "scrollable at all" to "has
the range this assertion needs", which was necessary and cannot be
sufficient: the guard and the write are separate round trips, so the
page re-lays-out between them.

Measured every frame across the resize, three runs: the range goes 0 →
**88** at 1ms → 330 settled by 8-14ms. 88 satisfies a guard asking for
80 while the grid is still a pass from done, so the guard is capable of
passing on a layout that is about to move. Under full-suite load the
transient is worse — the observed failures read 10 — which is why this
shows up on the second run of a suite and not in ten consecutive runs
of the file alone (0/10 before the change and after it; isolation is
not where this lives).

So the probe sets `scrollTop` and returns what it reads back, in one
page-side call, and the poll retries that. The assertion is now about
what the grid did rather than about what it was ready to do, and there
is no window between deciding and doing for anything to happen in.

#133's own last line asked for the other viewport-shrinking specs to be
swept for the same shape. One had it: `layout-overflow`'s sidebar probe
already fused its scroll and its measurement into one evaluate but ran
it once, so it read whatever the sidebar happened to be doing after the
resize. It is polled now — safe to repeat, because scrolling to the
bottom twice is scrolling to the bottom.

Closes #151
2026-08-19 23:37:41 -04:00

298 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '../support/fixtures.js';
/**
* H-7 and H-11, which are one finding seen from two distances: the app
* did not fit in its own window.
*
* `computeDefaultWidths` shared out the track list's whole
* `clientWidth` and never subtracted the 24 px favourite column or the
* 2×8 px row padding that `colBoundaryPositions` already knew about, so
* every row was 40 px wider than the box holding it (`scrollWidth 1280`
* against `clientWidth 1240`) and Duration was clipped at every size.
*
* And the enforced minimum window was 512×384, which the layout had
* never supported: at 700×480 the eleven sidebar items needed 406 px of
* a 352 px pane, `overflow: hidden` cut the last two off, nothing
* scrolled, and **Settings and Jobs could not be reached at all**.
*
* The three viewports are the two common ones and the new enforced
* minimum (`backend/config/window.go`). The minimum is the one that
* matters: it is the only size the app is *promising* to work at.
*/
/** Keep in step with `MinWidth`/`MinHeight` in backend/config/window.go. */
const MIN_VIEWPORT = { width: 800, height: 600 };
const VIEWPORTS = [
{ name: '1440×900', width: 1440, height: 900 },
{ name: '1024×768', width: 1024, height: 768 },
// Not the minimum, and that is the point (#24). The sidebar collapses
// to icons *below* 900, so the main panel is 843px at 899 and 700px
// at 900 — the narrowest content area any desktop width produces is
// here, not at the enforced floor. A list that stopped at the minimum
// was missing its own worst case.
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
// Below the enforced minimum on purpose, and for the reason 700×480
// is below it further down: a scaled display or a large system font
// lands the layout here without the window ever being dragged there,
// and 600 is the last width before the phone layout takes over. The
// *narrowest header* is a different question from the narrowest
// content area and has a different answer — this one (#143), where
// the bar was 611px inside 600 sitting still.
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
];
/**
* Rows report their own overflow, which is the symptom a user sees: a
* column rendered past the edge of the row it belongs to.
*/
const rowOverflow = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const list = document.querySelector('track-list');
const root = list?.shadowRoot;
if (!root) return { rows: 0, overflowing: [] as string[] };
const boxes = [
...root.querySelectorAll<HTMLElement>('.header-row, .track-row'),
];
return {
rows: boxes.length,
overflowing: boxes
.filter((b) => b.scrollWidth > b.clientWidth)
.map((b) => `${b.className}: ${b.scrollWidth} > ${b.clientWidth}`),
};
});
test.describe('the app fits in its own window', () => {
for (const vp of VIEWPORTS) {
test(`no track row overflows at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
// The columns are recomputed by a ResizeObserver, so poll rather
// than read once: a single read races the resize and passes on
// the widths from the previous viewport.
await expect.poll(async () => (await rowOverflow(app)).rows).toBeGreaterThan(1);
await expect
.poll(async () => (await rowOverflow(app)).overflowing)
.toEqual([]);
});
}
test('every destination stays reachable at the minimum size', async ({
app,
}) => {
await app.setViewportSize(MIN_VIEWPORT);
// Below the breakpoint the sidebar collapses to icons; the labels
// go, the destinations do not.
const sidebar = app.locator('app-sidebar');
await expect
.poll(() =>
sidebar.evaluate((el) => el.classList.contains('collapsed')),
)
.toBe(true);
// Settings is the one that was unreachable: it is last in the nav,
// and the pane used to clip rather than scroll. (Jobs was the other
// half of this until #27 folded it into Settings.)
for (const view of ['explore', 'settings'] as const) {
const item = app.getByTestId(`nav-${view}`);
await item.scrollIntoViewIfNeeded();
await item.click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
view,
);
}
// Leave the app where the next spec expects to find it.
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test('the sidebar scrolls rather than hiding what does not fit', async ({
app,
}) => {
await app.setViewportSize({ width: 700, height: 480 });
// Smaller than the enforced minimum on purpose: the window cannot
// be dragged here, but a scaled display or a large system font can
// still land the layout in it, and clipping the nav with no scroll
// is the failure that made Settings unreachable.
//
// The scroll and the measurement share one `evaluate` — #151's
// rule, which this already had — and the whole probe is polled,
// which it did not: a viewport change settles asynchronously, so a
// single attempt reads whatever the sidebar happened to be doing.
// The probe is safe to repeat because scrolling to the bottom twice
// is scrolling to the bottom.
await expect
.poll(() =>
app.locator('app-sidebar').evaluate((el) => {
const settings = el.shadowRoot?.querySelector<HTMLElement>(
'[data-testid="nav-settings"]',
);
if (!settings) return null;
el.scrollTop = el.scrollHeight;
const item = settings.getBoundingClientRect();
const pane = el.getBoundingClientRect();
return (
item.bottom <= Math.ceil(pane.bottom) &&
item.top >= Math.floor(pane.top)
);
}),
)
.toBe(true);
});
});
test.describe('the title block fits its bar', () => {
test('the hgroup stays inside the 4em top bar', async ({ app }) => {
// Stated rather than inherited from whatever ran last. Since #143
// the wordmark is visually hidden at widths where the bar cannot
// afford it, so a test about its *vertical* fit has to say which
// width it is asking about.
await app.setViewportSize({ width: 1440, height: 900 });
// The state a11y.29 landed in. The pair is flex-centred and a UA
// gives an `h1` a 0.67em top margin, so the block measured 67px
// inside 64 — pre-existing, and invisible until dropping the h3's
// bottom margin shortened the block and shifted it down into the
// clip. The descenders of "meant to bee." were cut.
const fits = await app.locator('hgroup').evaluate((el) => {
const bar = el.closest('.top-bar')!.getBoundingClientRect();
const group = el.getBoundingClientRect();
return {
top: group.top >= Math.floor(bar.top),
bottom: group.bottom <= Math.ceil(bar.bottom),
height: Math.round(group.height),
};
});
expect(fits.top).toBe(true);
expect(fits.bottom).toBe(true);
});
});
/**
* `a11y.21` (WCAG 1.4.10 Reflow), measured rather than taken as filed.
*
* The finding's mechanism is vertical — "at high zoom the 4em bars grow
* while the viewport does not, and anything that no longer fits is
* clipped with no scrollbar" — and that is not what happens. The middle
* row is `1fr` and absorbs the bars exactly. What is real is the axis
* the finding does not mention.
*/
test.describe('the shell reflows rather than hiding what does not fit', () => {
test('larger text shrinks the panel instead of clipping the shell', async ({
app,
}) => {
await app.setViewportSize({ width: 800, height: 600 });
await app.evaluate(() => {
document.documentElement.style.fontSize = '32px';
});
const shell = await app.evaluate(() => {
const foot = document.querySelector('.bottom-bar')!.getBoundingClientRect();
const main = document.querySelector('#main-content')!.getBoundingClientRect();
return {
footBottom: Math.round(foot.bottom),
viewport: document.documentElement.clientHeight,
mainHeight: Math.round(main.height),
};
});
// 200% text takes the bars from 64px to 128px each and the panel
// from 472px to 344px, and the footer still lands exactly on the
// bottom of the viewport. Nothing is clipped vertically.
expect(shell.footBottom).toBe(shell.viewport);
expect(shell.mainHeight).toBeGreaterThan(300);
await app.evaluate(() => {
document.documentElement.style.fontSize = '';
});
});
test('nothing needs scrolling to at 320px, because it all fits', async ({ app }) => {
// 320 CSS px is 400% page zoom of a 1280px viewport, which is the
// size 1.4.10 names.
//
// **This assertion is the inverse of the one it replaces, and that
// is the fix landing rather than the test being weakened.** The
// shell used to be 784px wide here, so 464px of the app — the job
// indicator and the queue button among it — sat behind
// `overflow: hidden` with no way to reach it; making the axis
// scrollable was the remedy available at the time. 016 B2's phone
// layout reflows instead: below 600px the sidebar becomes a bottom
// tab bar, the header's controls shrink, and the shell measures
// exactly 320px in a 320px viewport. Reflow is what 1.4.10 asks
// for; being able to scroll to the overflow was the concession.
await app.setViewportSize({ width: 320, height: 256 });
const fit = await app.evaluate(() => {
const se = document.scrollingElement!;
return {
scrollWidth: se.scrollWidth,
clientWidth: document.documentElement.clientWidth,
scrollHeight: se.scrollHeight,
clientHeight: document.documentElement.clientHeight,
};
});
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth);
// And the vertical axis stays fixed, which is what keeps the
// transport where a player's transport belongs.
expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight);
await app.setViewportSize({ width: 1440, height: 900 });
});
for (const vp of VIEWPORTS) {
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled, for the reason the track-row test above is: since #143
// the top bar's fit is *measured* — a ResizeObserver decides what
// it can afford at this width — so a single read taken straight
// after the resize races the observer and reports the frame
// before it. Read once, this passed alone and failed in the full
// suite, which is the shape of a timing assumption rather than of
// a defect.
//
// The other half of the assertion: at every size this app
// promises, the fix costs nothing. A scrollbar that is always
// there is a worse answer than the clipping it replaced.
await expect
.poll(() =>
app.evaluate(() => {
const de = document.documentElement;
return de.scrollWidth - de.clientWidth;
}),
)
.toBe(0);
});
}
});