Files
HealthLog/e2e/tablet-shell.spec.ts
Marc-André Bombeck 9ff35efb47 test(e2e): measure the Coach button's clearance once the page has settled
Scrolling to the end can start a section's load, and its skeleton sat under
the button for a moment on the Insights page in landscape. The check now
scrolls to the end and measures again until the last line is in place;
content that stays under the button still fails it.
2026-10-25 08:45:25 +01:00

741 lines
28 KiB
TypeScript

import type { Locator, Page } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The app shell on a tablet, under a finger, at every iPad size in both
* orientations: the rail reaches every destination, `<main>` scrolls to its
* last line, and closing a sheet or a dialog hands the scroll back.
*
* Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job)
* and `chromium-tablet` (an Android tablet, in the Chromium shards). Both
* report `pointer: coarse`, so the rail keeps its 44 px rows and its list
* overflows in landscape, which is the case that broke.
*
* What a browser under test cannot show is the iPadOS toolbar moving the
* visible height away from `100dvh`; `shell-viewport.test.ts` holds the
* decisions that take that disagreement out, and this file measures that the
* shell they produce fills the window exactly and leaves the document
* nothing to scroll or bounce.
*
* Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`,
* `sidebar-footer`, `main-content-wrapper`, `bottom-nav`,
* `bottom-nav-more` (test id), `command-palette-trigger`,
* `command-palette-input`, `day-panel`.
*/
/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */
const VIEWPORTS = [
{ name: "iPad mini portrait", width: 744, height: 1133 },
{ name: "iPad mini landscape", width: 1133, height: 744 },
{ name: "iPad mini landscape, Safari bars", width: 1133, height: 670 },
{ name: "iPad landscape, Safari bars", width: 1000, height: 690 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Pro 11 landscape", width: 1194, height: 834 },
{ name: "iPad Pro 13 portrait", width: 1024, height: 1366 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
{ name: "Split View two thirds", width: 694, height: 1024 },
{ name: "Split View half, landscape", width: 590, height: 820 },
{ name: "Slide Over", width: 375, height: 1024 },
{ name: "Android tablet landscape", width: 1138, height: 712 },
] as const;
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
async function openDashboard(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('#main-content [data-slot="main-content-wrapper"]'),
).toBeVisible();
// The module-gated destinations join the rail once `/api/auth/me` lands.
await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1);
}
/** The shell fills the window exactly and the document has nothing to scroll. */
async function expectShellPinned(page: Page) {
const m = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".shell-safe-area")!;
const rect = shell.getBoundingClientRect();
const root = document.scrollingElement!;
const html = getComputedStyle(document.documentElement);
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
innerHeight,
innerWidth,
docScroll: root.scrollHeight - root.clientHeight,
overflowY: html.overflowY,
overscroll: html.overscrollBehaviorY,
};
});
expect(m.top).toBe(0);
expect(m.left).toBe(0);
expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5);
expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5);
expect(m.docScroll).toBeLessThanOrEqual(0);
expect(m.overflowY).toBe("hidden");
// No rubber band on the document: on iOS it held the next swipe.
expect(m.overscroll).toBe("none");
}
function railNav(page: Page): Locator {
return page.locator('aside [data-slot="sidebar-nav-region"] > nav');
}
/** Scrolls `<main>` to its end, waiting out content that grows on the way. */
async function scrollMainToEnd(page: Page) {
let last = -1;
for (let i = 0; i < 8; i += 1) {
const height = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
main.scrollTop = main.scrollHeight;
return main.scrollHeight;
});
if (height === last) return;
last = height;
await page.waitForTimeout(250);
}
}
/** Where the page's lowest content ends, and what covers the window's bottom edge. */
async function measureMainEnd(page: Page) {
return page.evaluate(() => {
const main = document.getElementById("main-content")!;
const wrapper = main.querySelector<HTMLElement>(
'[data-slot="main-content-wrapper"]',
)!;
let contentBottom = 0;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.visibility === "hidden") continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
let p = el.parentElement;
let nested = false;
while (p && p !== main) {
const ps = getComputedStyle(p);
if (
/(auto|scroll|hidden)/.test(ps.overflowY) &&
p.scrollHeight > p.clientHeight + 1
) {
nested = true;
break;
}
p = p.parentElement;
}
if (nested) continue;
contentBottom = Math.max(contentBottom, rect.bottom);
}
const visible = (el: Element | null): el is HTMLElement =>
!!el &&
getComputedStyle(el).display !== "none" &&
el.getBoundingClientRect().height > 0;
const bar = document.querySelector('[data-slot="bottom-nav"]');
const mainRect = main.getBoundingClientRect();
return {
atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1,
scrolls: main.scrollHeight > main.clientHeight,
contentBottom,
mainBottom: mainRect.bottom,
barTop: visible(bar) ? bar.getBoundingClientRect().top : null,
};
});
}
for (const vp of VIEWPORTS) {
test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
});
test("every rail destination scrolls into view and takes a tap", async ({
page,
}) => {
await openDashboard(page);
await expectShellPinned(page);
const aside = page.locator("aside");
const railShown = vp.width >= 768;
if (!railShown) {
// Split View and Slide Over get the phone shell: the bar, no rail.
await expect(aside).toBeHidden();
await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible();
return;
}
await expect(aside).toBeVisible();
// Nothing in the rail is clipped by the rail: the logo band, the list
// region, the pinned footer and the account row all sit inside it, in
// that order, and the list region alone gives up height.
const parts = await page.evaluate(() => {
const asideEl = document.querySelector("aside")!;
const box = asideEl.getBoundingClientRect();
const pick = (sel: string) =>
asideEl.querySelector(sel)!.getBoundingClientRect();
const column = asideEl.querySelector('[data-slot="sidebar-column"]')!;
const user = column.lastElementChild!.getBoundingClientRect();
return {
clipped: asideEl.scrollHeight - asideEl.clientHeight,
asideTop: box.top,
asideBottom: box.bottom,
header: pick('[data-slot="sidebar-header"]'),
region: pick('[data-slot="sidebar-nav-region"]'),
footer: pick('[data-slot="sidebar-footer"]'),
user,
overscroll: getComputedStyle(
asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!,
).overscrollBehaviorY,
};
});
expect(parts.clipped).toBeLessThanOrEqual(0);
expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5);
expect(parts.region.top).toBeGreaterThanOrEqual(
parts.header.bottom - 0.5,
);
expect(parts.footer.top).toBeGreaterThanOrEqual(
parts.region.bottom - 0.5,
);
expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5);
expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5);
expect(parts.overscroll).toBe("contain");
await expect(
aside.locator('[data-slot="sidebar-collapse-toggle"]'),
).toBeInViewport({ ratio: 1 });
await expect(
aside.locator('[data-slot="nav-settings-link"]'),
).toBeInViewport({ ratio: 1 });
// Every destination: scrolled into the list's view, whole, on top
// (not under the cue, the footer or anything else), and actionable.
const nav = railNav(page);
const links = nav.locator("a[href]");
const count = await links.count();
expect(count).toBeGreaterThanOrEqual(10);
for (let i = 0; i < count; i += 1) {
const link = links.nth(i);
await link.scrollIntoViewIfNeeded();
await expect
.poll(() =>
link.evaluate((el) => {
const navEl = el.closest("nav")!;
const r = el.getBoundingClientRect();
const n = navEl.getBoundingClientRect();
const hit = document.elementFromPoint(
r.left + r.width / 2,
r.top + r.height / 2,
);
return (
r.top >= n.top - 0.5 &&
r.bottom <= n.bottom + 0.5 &&
!!hit &&
el.contains(hit)
);
}),
)
.toBe(true);
await link.click({ trial: true });
}
// A list taller than its region shows the touch cue at the edge that
// has more, and a tap on it is a way there.
const overflows = await nav.evaluate(
(el) => el.scrollHeight > el.clientHeight + 1,
);
const down = page.locator(
'[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]',
);
const up = page.locator(
'[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]',
);
await nav.evaluate((el) => (el.scrollTop = 0));
if (overflows) {
await expect(down).toBeVisible();
await expect(up).toHaveCount(0);
await down.tap();
await expect
.poll(() => nav.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await expect(up).toBeVisible();
await nav.evaluate((el) => (el.scrollTop = el.scrollHeight));
await expect(down).toHaveCount(0);
} else {
await expect(down).toHaveCount(0);
await expect(up).toHaveCount(0);
}
// And the last destination is a real tap away.
const lastLink = links.nth(count - 1);
const href = await lastLink.getAttribute("href");
await lastLink.scrollIntoViewIfNeeded();
await lastLink.tap();
await expect(page).toHaveURL(new RegExp(`${href}$`));
});
// The Coach button's clearance is held by `shell-viewport.test.ts`: the
// button renders only with an AI provider, which the suite's accounts
// do not have, so a check here would never run.
test("main scrolls to its last line, clear of the bottom bar", async ({
page,
}) => {
await openDashboard(page);
await scrollMainToEnd(page);
const end = await measureMainEnd(page);
expect(end.scrolls).toBe(true);
expect(end.atEnd).toBe(true);
expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5);
if (end.barTop !== null) {
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
}
await expectShellPinned(page);
});
});
}
/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */
async function expectPageScrollsAgain(page: Page, browserName: string) {
await expect
.poll(() =>
page.evaluate(() => ({
locked: document.body.hasAttribute("data-scroll-locked"),
pointer: getComputedStyle(document.body).pointerEvents,
})),
)
.toEqual({ locked: false, pointer: "auto" });
const before = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
main.scrollTop = 0;
const r = main.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const hit = document.elementFromPoint(x, y);
const wheel = new WheelEvent("wheel", {
deltaY: 120,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
});
hit?.dispatchEvent(wheel);
return {
onMain: !!hit && main.contains(hit),
blocked: wheel.defaultPrevented,
x,
y,
};
});
// Nothing invisible lies over `<main>`, and no scroll lock swallows the
// wheel (react-remove-scroll cancels it outside its own layer).
expect(before.onMain).toBe(true);
expect(before.blocked).toBe(false);
// A real wheel where the engine has one. Mobile WebKit under Playwright
// has none (and no native touch scroll), so there the two checks above
// are the proof: nothing covers `<main>` and nothing cancels its events.
if (browserName === "webkit") return;
await page.mouse.move(before.x, before.y);
await page.mouse.wheel(0, 600);
await expect
.poll(() =>
page.evaluate(() => document.getElementById("main-content")!.scrollTop),
)
.toBeGreaterThan(0);
}
const MODAL_VIEWPORTS = [
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "Split View two thirds", width: 694, height: 1024 },
] as const;
for (const vp of MODAL_VIEWPORTS) {
test.describe(`${vp.name}: scroll after a modal`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
});
test("the page scrolls after the command palette dialog closes", async ({
page,
browserName,
}) => {
await openDashboard(page);
await page.locator('[data-slot="command-palette-trigger"]:visible').tap();
const input = page.locator('[data-slot="command-palette-input"]');
await expect(input).toBeVisible();
// The scroll lock makes the body `position: relative`; the shell
// behind the dialog keeps the window's box all the same.
await expectShellPinned(page);
await page.keyboard.press("Escape");
await expect(input).toBeHidden();
await expectPageScrollsAgain(page, browserName);
});
test("the page scrolls after a sheet closes", async ({
page,
browserName,
}) => {
await openDashboard(page);
if (vp.width < 768) {
// The phone shell's More sheet: the draggable bottom sheet.
await page.locator('[data-testid="bottom-nav-more"]').tap();
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
await expect(sheet).toBeVisible();
await expectShellPinned(page);
await sheet.locator('[data-slot="sheet-close-button"]').tap();
await expect(sheet).toBeHidden();
} else {
// The day, a sheet from the right below 1280 px.
await mockDay(page);
const day = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" });
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible();
await expectShellPinned(page);
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
}
await expectPageScrollsAgain(page, browserName);
});
});
}
test("a vertical swipe that starts on a tab strip is the page's", async ({
page,
}) => {
await page.setViewportSize({ width: 1180, height: 820 });
await page.goto("/checkups", { waitUntil: "domcontentloaded" });
const strip = page.locator('#main-content [data-slot="tabs-list"]').first();
await expect(strip).toBeVisible();
// `pan-x` here refused the vertical pan, so the page did not move.
const touchAction = await strip.evaluate(
(el) => getComputedStyle(el).touchAction,
);
expect(touchAction).toBe("auto");
});
/**
* An iPad without a home button reports a bottom inset of about 20 px, which
* no browser under test does. The shell reads the inset through
* `--safe-area-bottom` (globals.css), so setting the property is the same
* inset to every element that owns the bottom edge.
*/
const HOME_INDICATOR = 20;
async function setBottomInset(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-bottom: ${px}px !important; }`,
});
}
/** What paints the window's last row of pixels, sampled across its width. */
async function bottomRowOwners(page: Page) {
return page.evaluate(() => {
const y = innerHeight - 1;
const owners: string[] = [];
// 8 px in lands on the rail at its narrowest (64 px); the rest spread
// across the content column.
for (const x of [
8,
...Array.from({ length: 11 }, (_, i) => (innerWidth * (i + 1)) / 12),
]) {
const hit = document.elementFromPoint(x, y);
const owner = !hit
? "nothing"
: hit === document.documentElement || hit === document.body
? "page"
: hit.closest("aside")
? "rail"
: hit.closest("#main-content")
? "main"
: hit.closest('[data-slot="bottom-nav"]')
? "bar"
: hit.closest('[data-slot="coach-fab"]')
? "fab"
: `other:${hit.tagName}`;
owners.push(owner);
}
return owners;
});
}
const INSET_VIEWPORTS = [
{ name: "iPad mini portrait", width: 744, height: 1133 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
{ name: "Split View two thirds", width: 694, height: 1024 },
] as const;
for (const vp of INSET_VIEWPORTS) {
test(`${vp.name}: the shell paints to the edge under a home indicator`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
await openDashboard(page);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
// The last row of pixels belongs to the rail and `<main>` (or the bar),
// never to the page behind the shell: that was the band.
const owners = await bottomRowOwners(page);
expect(owners.filter((o) => o === "page" || o === "nothing")).toEqual([]);
const railShown = vp.width >= 768;
const edge = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const column = aside.querySelector('[data-slot="sidebar-column"]');
const bar = document.querySelector<HTMLElement>(
'[data-slot="bottom-nav"]',
)!;
return {
innerHeight,
asideBottom: aside.getBoundingClientRect().bottom,
lastRailRowBottom:
column?.lastElementChild?.getBoundingClientRect().bottom ?? 0,
barBottom: bar.getBoundingClientRect().bottom,
barPad: parseFloat(getComputedStyle(bar).paddingBottom),
// What a WKWebView browser paints where it keeps the page out: the
// root's own background.
root: getComputedStyle(document.documentElement).backgroundColor,
body: getComputedStyle(document.body).backgroundColor,
};
});
if (railShown) {
expect(owners).toContain("rail");
expect(owners).toContain("main");
// The rail's background runs to the edge; its account row stops
// above the inset.
expect(edge.asideBottom).toBe(edge.innerHeight);
expect(edge.lastRailRowBottom).toBeLessThanOrEqual(
edge.innerHeight - HOME_INDICATOR,
);
expect(edge.root).toBe(edge.body);
} else {
expect(owners).toContain("bar");
expect(edge.barBottom).toBe(edge.innerHeight);
expect(edge.barPad).toBe(HOME_INDICATOR);
expect(edge.root).toBe(edge.body);
}
// At the end of the page, the last line stands clear of the inset, the
// bar and the Coach button.
await scrollMainToEnd(page);
const end = await measureMainEnd(page);
expect(end.atEnd).toBe(true);
expect(end.contentBottom).toBeLessThanOrEqual(
edge.innerHeight - HOME_INDICATOR,
);
if (end.barTop !== null) {
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
}
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
const fabTop = (await fab.boundingBox())!.y;
expect(end.contentBottom).toBeLessThanOrEqual(fabTop);
});
}
/**
* The Coach button over the last card of a nested layout (Insights, Settings,
* a values list): during the scroll it may sit over content, at the end it
* must not. Rendered by serving an `ai` block with the Coach available,
* since the suite has no provider.
*/
const NESTED_PAGES = [
"/insights/blood-pressure",
"/insights/values/BLOOD_PRESSURE_SYS",
"/insights",
"/settings/account",
"/settings/modules",
] as const;
for (const vp of [
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
] as const) {
for (const inset of [0, HOME_INDICATOR]) {
test(`${vp.name}, inset ${inset}: the Coach button clears the last line of nested layouts`, async ({
page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
for (const path of NESTED_PAGES) {
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(
page.locator('#main-content [data-slot="main-content-wrapper"]'),
).toBeVisible();
await page.waitForLoadState("networkidle");
if (inset) await setBottomInset(page, inset);
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
// A section near the end can still load once the scroll reaches it
// and show its skeleton for a moment; what has to hold is the page
// as it ends up, so scroll to the end and measure again until the
// last line is in place. Content that stays under the button fails.
const coveredAtEnd = async () => {
await scrollMainToEnd(page);
return page.evaluate(() => {
const f = document
.querySelector('[data-slot="coach-fab"]')!
.getBoundingClientRect();
const main = document.getElementById("main-content")!;
const under: string[] = [];
for (const el of main.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.visibility === "hidden")
continue;
const paints =
(el.childElementCount === 0 && !!el.textContent?.trim()) ||
cs.borderBottomWidth !== "0px" ||
cs.backgroundColor !== "rgba(0, 0, 0, 0)";
if (!paints) continue;
const b = el.getBoundingClientRect();
if (!b.width || !b.height) continue;
if (
b.right > f.left &&
b.left < f.right &&
b.bottom > f.top &&
b.top < f.bottom
) {
under.push(el.getAttribute("data-slot") ?? el.tagName);
}
}
return under;
});
};
await expect
.poll(coveredAtEnd, {
message: `${path}: content under the Coach button`,
timeout: 15_000,
})
.toEqual([]);
}
});
}
}
/**
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
* left edge in portrait, which no browser under test reports. The shell and
* the rail read the side insets through `--safe-area-left` / `-right`
* (globals.css), so setting them is the same inset to both.
*/
const SIDE_INSET = 10;
async function setSideInsets(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
});
}
const SIDE_INSET_VIEWPORTS = [
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
] as const;
for (const vp of SIDE_INSET_VIEWPORTS) {
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await openDashboard(page);
await setSideInsets(page, SIDE_INSET);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
await expect(page.locator("aside")).toBeVisible();
const m = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const box = (el: Element | null) => {
const r = el!.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
// What paints a pixel: the rail, or the page behind the shell (the
// root, the body, or one of the shell's own unpainted boxes around the
// rail and `<main>`), or some other chrome that meets the edge.
const owner = (x: number, y: number) => {
const hit = document.elementFromPoint(x, y);
if (!hit) return "nothing";
if (hit.closest("aside")) return "rail";
const unpainted =
hit === document.documentElement ||
hit === document.body ||
(hit.id !== "main-content" &&
hit.contains(document.getElementById("main-content")));
return unpainted ? `page:${hit.tagName}` : "chrome";
};
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
const account = aside.querySelector(
'[data-slot="sidebar-column"]',
)!.lastElementChild!;
const avatar = account.querySelector('[data-slot="avatar"]')!;
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
const links = [...aside.querySelectorAll("nav a[href]")].map(
(a) => a.getBoundingClientRect().left,
);
const headerBox = box(header);
const accountBox = box(account);
return {
innerWidth,
innerHeight,
aside: box(aside),
header: headerBox,
account: accountBox,
avatar: box(avatar),
logoLeft: box(header.querySelector("svg")).left,
footerLeft: box(footer).left,
firstLinkLeft: Math.min(...links),
// Down the window's first column of pixels, and its last.
leftEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
(accountBox.top + accountBox.bottom) / 2,
innerHeight - 2,
].map((y) => owner(1, y)),
rightEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
].map((y) => owner(innerWidth - 1, y)),
};
});
// No strip: the rail paints the inset from the logo band to the bottom
// edge, and the top bar and `<main>` (or a docked panel) meet the right
// edge.
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
expect(m.aside.left).toBe(0);
// The band lines run to the edge: the logo band, the account band and
// the footer group (whose scroll edge draws a line) start at x = 0.
expect(m.header.left).toBe(0);
expect(m.account.left).toBe(0);
expect(m.footerLeft).toBe(0);
// And everything in the rail stays clear of the inset.
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
// The account band mirrors the logo band: the same height, the avatar
// centred in it, and the home indicator's inset below it.
expect(m.account.bottom - m.account.top).toBe(
m.header.bottom - m.header.top,
);
const above = m.avatar.top - m.account.top;
const below = m.account.bottom - m.avatar.bottom;
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
expect(m.aside.bottom).toBe(m.innerHeight);
});
}