Files
HealthLog/e2e/admin-api-tokens-mobile.spec.ts
T
Marc-André BombeckandClaude Opus 4.7 dd8212e202 fix(admin): api-tokens table truncate-with-tooltip (no scrollbar 4th attempt)
Marc has now reported a residual scrollbar on `/admin/api-tokens`
four times running. Earlier fixes addressed the obvious overflow
culprits (column-hide in v1.4.15, mobile card-list in v1.4.16,
admin-shell mobile-strip `no-scrollbar` in v1.4.18), but the
section itself still leaked a few quirks:

- The mobile token-name span carried `truncate` and `break-all` on
  the same element. `truncate` sets `white-space: nowrap`, which
  beats `word-break: break-all` per CSS spec — `break-all` was dead
  code AND it was misleading. Remove it.
- The desktop table had no per-column upper bound. A long token
  name could expand the row past the card on 768-1024 px viewports.
  Lock the layout with `table-fixed` + `<colgroup>` widths.
- Permission badges had no `max-width` cap. A single long
  permission string (`measurements:write:audit:legacy:extended`)
  could push the cell past the card. Cap each badge with
  `max-w-full truncate` and a native `title` attribute.

Wrap every potentially-long string (token name, username, permission
badge) in a small `<TruncatedCell>` helper that pairs the visual
truncation with a radix tooltip. The tooltip is the discoverable
hover/long-press path so the full value is still readable; the
native `title` attribute is a graceful fallback for screen readers
and right-click → "view full name" on desktop.

Adds an e2e regression that walks every element inside the mobile
card-list and asserts none of them carry `scrollWidth > clientWidth`,
exercised against a worst-case payload (long token name + long
username + long permission string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00

208 lines
7.5 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
* hide breakpoints + tighter card padding, but Marc still saw a
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
* mobile card-list at <md, mirroring `<UserManagementSection>`.
*
* Mobile-only spec — the desktop project skips it.
*/
test.describe("/admin/api-tokens mobile no-overflow", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("Pixel-5 viewport: no horizontal page scroll on /admin/api-tokens", async ({
page,
}) => {
// Mock the admin tokens endpoint with a moderately wide payload —
// a token with multiple permissions, a long name, and timestamps.
// This is the realistic worst case Marc hit on prod.
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [
{
id: "tok1",
name: "iOS app device-AABBCCDDEEFF",
permissions: ["measurements:write", "medications:read", "*"],
lastUsedAt: "2026-05-08T12:00:00Z",
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
},
{
id: "tok2",
name: "Native client xyz",
permissions: ["*"],
lastUsedAt: null,
expiresAt: "2026-08-01T00:00:00Z",
createdAt: "2026-04-15T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
},
],
error: null,
}),
}),
);
await page.goto("/admin/api-tokens", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// The mobile card-list must be visible and the desktop table must
// be display:none at this viewport.
await expect(page.getByTestId("admin-tokens-mobile-list")).toBeVisible({
timeout: 10_000,
});
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
});
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
// visible "scrollbar" Marc reported for the third time as the
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
// permanently exceeds clientWidth (~360 CSS px). Adding the
// `no-scrollbar` utility class (`globals.css`) suppresses the
// painted bar while preserving swipe + keyboard-arrow scrolling.
// This regression guard fails if a future refactor drops the class.
test("Pixel-5 viewport: admin section strip suppresses its painted scrollbar", async ({
page,
}) => {
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [], error: null }),
}),
);
await page.goto("/admin/api-tokens", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Find the mobile strip (`<nav aria-label>` matches both English
// "Admin sections" and German "Admin-Bereiche").
const strip = page.locator("nav.no-scrollbar.overflow-x-auto").first();
await expect(strip).toBeAttached();
// The strip itself must declare `no-scrollbar` so the painted
// horizontal scrollbar — which production showed at Pixel-5 — is
// suppressed.
const cls = await strip.getAttribute("class");
expect(cls ?? "").toContain("no-scrollbar");
expect(cls ?? "").toContain("overflow-x-auto");
// And the strip must still carry useful overflow internally
// (clientWidth < scrollWidth), proving scroll behaviour isn't
// disabled — only the bar is invisible.
const stripDims = await strip.evaluate((el) => ({
scrollWidth: (el as HTMLElement).scrollWidth,
clientWidth: (el as HTMLElement).clientWidth,
}));
expect(stripDims.scrollWidth).toBeGreaterThan(stripDims.clientWidth);
});
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
// `no-scrollbar` fix from v1.4.18, Marc still reported a painted
// scrollbar. The card body itself must not contribute any
// horizontal-overflow culprit: every element inside the api-tokens
// section must satisfy `scrollWidth <= clientWidth + 1`. Pads with
// a long token name + long username + a long permission to exercise
// the worst case.
test("Pixel-5 viewport: nothing inside the api-tokens card overflows horizontally", async ({
page,
}) => {
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [
{
id: "tok1",
name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF-marathon",
permissions: [
"measurements:write:audit:legacy:extended",
"medications:read",
"*",
],
lastUsedAt: "2026-05-08T12:00:00Z",
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: {
id: "u1",
username: "marc-the-very-long-username-that-overflows",
},
},
],
error: null,
}),
}),
);
await page.goto("/admin/api-tokens", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("admin-tokens-mobile-list")).toBeVisible({
timeout: 10_000,
});
// Walk every element inside the mobile card-list and assert no
// child has a wider scrollWidth than its container. This is the
// Pixel-5 worst-case Marc kept hitting.
const culprits = await page
.getByTestId("admin-tokens-mobile-list")
.evaluate((root) => {
const out: Array<{
tag: string;
klass: string;
scrollWidth: number;
clientWidth: number;
}> = [];
for (const el of root.querySelectorAll("*")) {
if (el.scrollWidth > el.clientWidth + 1) {
out.push({
tag: el.tagName,
klass:
typeof el.className === "string"
? el.className.slice(0, 80)
: "",
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
});
}
}
return out;
});
expect(
culprits,
`overflow culprits: ${JSON.stringify(culprits, null, 2)}`,
).toEqual([]);
// Document-level horizontal overflow — the page must not paint
// any horizontal scroll, period.
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(dims.scrollWidth).toBeLessThanOrEqual(dims.innerWidth + 1);
});
});