Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:44:00 +02:00
commit 492c2a4131
41 files changed
+1268 -151

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The installed-app behaviours a phone depends on, measured in the Pixel 5
* project (a touch screen: `pointer: coarse`).
*
* 1. The "new version" hint stands above the bottom bar instead of on it,
* and its Reload action reloads the page.
* 2. With the on-screen keyboard open, a bottom sheet stands on the
* keyboard (its Save button stays visible) and the bottom bar steps
* aside. Playwright has no on-screen keyboard, so the visual viewport is
* replaced by a stand-in whose height the test shrinks the way a
* keyboard does; what is asserted is the page's answer to that signal.
* 3. No form field a phone can focus is set below 16 px, in portrait or
* held sideways, because iOS Safari zooms the page into such a field.
* 4. Held sideways, the sign-in card is not centred out of reach.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
const VISUAL_VIEWPORT_STAND_IN = () => {
const stand = new EventTarget() as EventTarget & {
height: number;
width: number;
offsetTop: number;
offsetLeft: number;
pageTop: number;
pageLeft: number;
scale: number;
};
stand.height = window.innerHeight;
stand.width = window.innerWidth;
stand.offsetTop = 0;
stand.offsetLeft = 0;
stand.pageTop = 0;
stand.pageLeft = 0;
stand.scale = 1;
Object.defineProperty(window, "visualViewport", {
configurable: true,
get: () => stand,
});
(window as unknown as { __viewport: typeof stand }).__viewport = stand;
};
async function setKeyboard(page: Page, covered: number) {
await page.evaluate((px) => {
const stand = (
window as unknown as {
__viewport: EventTarget & { height: number };
}
).__viewport;
stand.height = window.innerHeight - px;
stand.dispatchEvent(new Event("resize"));
}, covered);
}
async function openCaptureForm(page: Page) {
await page.goto("/");
await page.getByTestId("bottom-nav-capture").click();
const picker = page.locator('[data-slot="responsive-sheet-content"]');
await expect(picker).toBeVisible();
await picker
.locator('[data-slot="responsive-sheet-body"] button')
.first()
.click();
const form = page
.locator('[data-slot="responsive-sheet-content"]')
.filter({ has: page.locator('[data-slot="responsive-sheet-footer"]') });
await expect(form).toBeVisible();
return form;
}
/** Every visible field a phone can focus, with its computed font size. */
async function fieldFontSizes(page: Page) {
return page.evaluate(() =>
Array.from(
document.querySelectorAll<HTMLElement>(
"input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),textarea,select",
),
)
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 2 && r.height > 2;
})
.map((el) => ({
field:
el.getAttribute("name") ||
el.id ||
el.getAttribute("aria-label") ||
el.tagName,
px: parseFloat(getComputedStyle(el).fontSize),
})),
);
}
test.describe("installed app on a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the new-version hint stands above the bottom bar and reloads", async ({
page,
}) => {
await page.route("**/api/version", (route) =>
route.fulfill({
json: {
data: { version: "999.0.0", buildSha: "e2e" },
error: null,
},
}),
);
await page.goto("/");
const toast = page.locator("[data-sonner-toast]").filter({
has: page.getByRole("button", { name: "Reload" }),
});
// The poller's first check runs five seconds after mount.
await expect(toast).toBeVisible({ timeout: 15_000 });
const nav = page.locator('[data-slot="bottom-nav"]');
const navBox = (await nav.boundingBox())!;
// Polled: the toast slides in from below, so an early sample is
// mid-animation.
await expect
.poll(async () => {
const b = (await toast.boundingBox())!;
return b.y + b.height;
})
.toBeLessThanOrEqual(navBox.y);
// The bottom bar's controls are still reachable with the hint up.
await expect(page.getByTestId("bottom-nav-more")).toBeVisible();
const reloaded = page.waitForEvent("load");
await toast.getByRole("button", { name: "Reload" }).click();
await reloaded;
});
test("a bottom sheet stands on the on-screen keyboard and the bottom bar steps aside", async ({
page,
}) => {
await page.addInitScript(VISUAL_VIEWPORT_STAND_IN);
const form = await openCaptureForm(page);
const save = form
.locator('[data-slot="responsive-sheet-footer"] button')
.last();
const viewportHeight = page.viewportSize()!.height;
const bottomEdge = async () => {
const b = (await form.boundingBox())!;
return Math.round(b.y + b.height);
};
// Closed keyboard: the sheet sits on the bottom edge (polled: it slides
// in from below).
await expect.poll(bottomEdge).toBe(viewportHeight);
const keyboard = 300;
await setKeyboard(page, keyboard);
await expect(page.locator("html")).toHaveAttribute("data-keyboard", "open");
await expect.poll(bottomEdge).toBe(viewportHeight - keyboard);
const box = (await form.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
const saveBox = (await save.boundingBox())!;
expect(saveBox.y + saveBox.height).toBeLessThanOrEqual(
viewportHeight - keyboard,
);
await expect(page.locator('[data-slot="bottom-nav"]')).toBeHidden();
// A toolbar-sized change is not a keyboard.
await setKeyboard(page, 60);
await expect(page.locator("html")).not.toHaveAttribute("data-keyboard");
await expect.poll(bottomEdge).toBe(viewportHeight);
});
for (const viewport of [
{ width: 390, height: 844 },
{ width: 844, height: 390 },
]) {
test(`no focusable field is below 16 px at ${viewport.width}x${viewport.height} (iOS focus zoom)`, async ({
page,
}) => {
await page.setViewportSize(viewport);
const offenders: string[] = [];
for (const path of [
"/settings",
"/documents",
"/settings/notifications",
]) {
await page.goto(path);
await page.waitForLoadState("networkidle");
const fields = await fieldFontSizes(page);
expect(fields.length, `${path} renders no field`).toBeGreaterThan(0);
for (const f of fields)
if (f.px < 16) offenders.push(`${path} ${f.field} ${f.px}px`);
}
if (viewport.width < 768) {
const form = await openCaptureForm(page);
await expect(form.locator("input").first()).toBeVisible();
for (const f of await fieldFontSizes(page))
if (f.px < 16) offenders.push(`capture ${f.field} ${f.px}px`);
}
expect(offenders).toEqual([]);
});
}
});
test.describe("signed out, held sideways", () => {
test.use({ viewport: { width: 844, height: 390 } });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the sign-in card stays reachable from its top edge", async ({
page,
}) => {
await page.goto("/auth/login");
const card = page.locator("main#main-content > *").first();
await expect(card).toBeVisible();
// Open the password form, the tallest state of the card.
await page
.getByRole("button", { name: /password|passwort/i })
.first()
.click();
await expect(page.locator("input[type=password]").first()).toBeVisible();
const box = (await card.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
// And the document scrolls to reach whatever lies below the fold.
const scrollable = await page.evaluate(
() =>
document.documentElement.scrollHeight >= window.innerHeight &&
getComputedStyle(document.documentElement).overflowY !== "hidden",
);
expect(scrollable).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* What the installed app shows without a connection, with the service worker
* actually running.
*
* Runs in the `chromium-service-worker` project, the one that lets the worker
* register (every other project blocks it; see `playwright.config.ts`), and
* like the other spec there it uses no route mocks: the claim is about what
* the worker itself serves.
*
* 1. A page that is open when the connection drops stays the app and says
* so (the offline strip).
* 2. Opening a page without a connection gets the worker's offline page:
* the word "Offline" and a working retry control, never the browser's
* error. (Signed-in pages are never cached, by design: the proxy pins
* `private, no-store` on them because some carry the record in the
* HTML. So this is what an offline launch of the installed app shows.)
* 3. When the connection returns, that page reloads into the app by
* itself.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
page,
context,
}) => {
await page.goto("/medications");
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
await expect(page.locator("#main-content")).toBeVisible();
await page.waitForLoadState("networkidle");
try {
await context.setOffline(true);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
const response = await page.goto("/timeline");
expect(response?.status()).toBe(503);
await expect(page.getByText("Offline", { exact: true })).toBeVisible();
const retry = page.getByRole("link", { name: "Reload" });
await expect(retry).toBeVisible();
const box = (await retry.boundingBox())!;
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
// Back online: the offline page reloads itself into the app.
const reloaded = page.waitForEvent("load");
await context.setOffline(false);
await reloaded;
await expect(page.locator("#main-content")).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/timeline");
} finally {
await context.setOffline(false);
}
});
});