mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
axe 4.14 checks WCAG 2.5.3 (label in name), and the app frame failed it on every page: the account link, the user menu and the day strip carried an aria-label that left out their visible text. The same class sat in the coverage meter, the score ring, medication links, instrument cards, filter pills with a value set, cycle calendar days (named by an ISO key), admin user and login rows, a few sort and action buttons, and the French, Spanish, Italian and Polish weekday chips, whose abbreviations were not part of the day names. Controls with visible text are now named by it, with any extra purpose added as screen-reader text; the select pill and calendar days spell out a name that contains what they show. The day and conversation strips keep their action as the tooltip.
469 lines
19 KiB
TypeScript
469 lines
19 KiB
TypeScript
import type { Page, TestInfo } from "@playwright/test";
|
|
|
|
import { mockDay } from "./setup/day-mock";
|
|
import { expect, test } from "./setup/test";
|
|
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
|
|
|
/**
|
|
* v1.42 — the day view and its doors.
|
|
*
|
|
* The record is the phone-sweep account `globalSetup` seeds: a week of blood
|
|
* pressure, two lab readings, mood and an episode. The day itself
|
|
* (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in
|
|
* the contract's shape, so the journeys pin the client: which door opens which
|
|
* day, what the history does, and where no door is.
|
|
*
|
|
* - a chart point opens the day beside the chart (desktop);
|
|
* - on a touch screen the first tap shows the value and the tooltip offers
|
|
* the day, the second opens it as a bottom sheet;
|
|
* - a date in a list opens the day while the row keeps its own target;
|
|
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
|
|
* the future is dropped;
|
|
* - stepping to the neighbouring days never adds history entries;
|
|
* - docked, the day folds to its strip, which runs the full height of the
|
|
* window right of the top bar, is the panel's only open and close
|
|
* control and brings the day back on any page; the search magnifier at
|
|
* the end of the top bar is never covered by it; a sheet closes for good
|
|
* from its close button;
|
|
* - the dashboard's today area offers no door; its charts below do.
|
|
*
|
|
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
|
* the header's `day-date-button` and its `day-date-picker` (days keyed by
|
|
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
|
|
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
|
* `day-next` / `day-close`, and the docked day's strip `day-strip`
|
|
* (+ `data-day`, `data-state`) with its `day-strip-toggle`.
|
|
*/
|
|
|
|
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
|
|
|
async function shot(page: Page, testInfo: TestInfo, name: string) {
|
|
const path = testInfo.outputPath(`${name}.png`);
|
|
await page.screenshot({ path });
|
|
await testInfo.attach(name, { path, contentType: "image/png" });
|
|
const dir = process.env.DAY_SHOTS_DIR;
|
|
if (dir) {
|
|
await page.screenshot({
|
|
path: `${dir}/${name}-${testInfo.project.name}.png`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function isoDaysAgo(days: number): string {
|
|
const d = new Date(Date.now() - days * 86_400_000);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
test.describe("the day view", () => {
|
|
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(async ({ page, context }, testInfo) => {
|
|
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
|
await context.addCookies([
|
|
{ name: "healthlog-locale", value: "en", url: baseURL },
|
|
]);
|
|
await mockDay(page);
|
|
});
|
|
|
|
test("a chart point opens its day beside the chart; Back closes it", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== "chromium-desktop", "desktop click");
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto("/insights/blood-pressure");
|
|
const plot = page.locator(
|
|
'[data-slot="chart-plot"][data-day-links="true"]',
|
|
);
|
|
await expect(plot).toBeVisible();
|
|
// The plot's right half holds the most recent points.
|
|
const box = (await plot.boundingBox())!;
|
|
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
|
|
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
|
|
|
|
await expect(panel(page)).toBeVisible();
|
|
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
|
// A landmark beside the page, named by its date.
|
|
await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE");
|
|
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
|
|
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
|
// The chart beside it stays usable: the panel is not modal.
|
|
await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0);
|
|
|
|
await page.goBack();
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await expect(page).not.toHaveURL(/[?&]day=/);
|
|
});
|
|
|
|
test("a date in a list opens the day; the row keeps its own target", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/labs");
|
|
await page
|
|
.locator('[data-slot="lab-list-analyte"]')
|
|
.filter({ hasText: MOBILE_ROUTES_ANALYTE })
|
|
.first()
|
|
.click();
|
|
// The marker's readings, each dated.
|
|
await page.locator('a[href$="/values"]').first().click();
|
|
const link = page.locator('[data-slot="day-link"]:visible').first();
|
|
await expect(link).toBeVisible();
|
|
const day = await link.getAttribute("data-day");
|
|
const before = page.url();
|
|
await link.click();
|
|
await expect(panel(page)).toBeVisible();
|
|
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
|
"data-day",
|
|
day!,
|
|
);
|
|
// Same page underneath: the click opened the day, not the reading.
|
|
expect(new URL(page.url()).pathname).toBe(new URL(before).pathname);
|
|
// The reading the person came from sits on top, with the one before it.
|
|
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
|
});
|
|
|
|
test("?day= opens the layer on any page; Escape closes it", async ({
|
|
page,
|
|
}) => {
|
|
const day = isoDaysAgo(3);
|
|
await page.goto(`/mood?day=${day}`);
|
|
await expect(panel(page)).toBeVisible();
|
|
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
|
"data-day",
|
|
day,
|
|
);
|
|
await page.keyboard.press("Escape");
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
});
|
|
|
|
test("the day's scores sit below its values, each opening its own page", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
|
await expect(panel(page)).toBeVisible();
|
|
const scores = page.locator('[data-slot="day-scores"]');
|
|
await expect(scores).toBeVisible();
|
|
const tiles = scores.locator('[data-slot="day-score"]');
|
|
await expect(tiles).toHaveCount(2);
|
|
await expect(
|
|
scores.locator('[data-slot="day-score"][data-score="healthScore"]'),
|
|
).toHaveAttribute("href", "/insights/health-score");
|
|
await expect(
|
|
scores.locator('[data-slot="day-score"][data-score="strain"]'),
|
|
).toHaveAttribute("href", "/insights/scores/strain");
|
|
// A score the day does not hold has no tile.
|
|
await expect(
|
|
scores.locator('[data-slot="day-score"][data-score="readiness"]'),
|
|
).toHaveCount(0);
|
|
// Below the values, above what happened.
|
|
const order = await page
|
|
.locator(
|
|
'[data-slot="day-values"], [data-slot="day-scores"], [data-slot="day-events"]',
|
|
)
|
|
.evaluateAll((nodes) =>
|
|
nodes.map((node) => node.getAttribute("data-slot")),
|
|
);
|
|
expect(order).toEqual(["day-values", "day-scores", "day-events"]);
|
|
});
|
|
|
|
test("the docked day folds beside its strip and comes back on another page", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "chromium-desktop",
|
|
"docked from 1280 px",
|
|
);
|
|
const day = isoDaysAgo(3);
|
|
await page.goto(`/mood?day=${day}`);
|
|
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
|
|
|
// Docked, the header has no fold button: the strip is the toggle.
|
|
await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0);
|
|
const rail = page.locator('[data-slot="day-strip"]');
|
|
const expand = page.locator('[data-slot="day-strip-toggle"]');
|
|
await expect(expand).toHaveAttribute("aria-expanded", "true");
|
|
await expand.click();
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
// The strip stays, holding the day, and focus waits on it.
|
|
await expect(rail).toHaveAttribute("data-day", day);
|
|
await expect(rail).toHaveAttribute("data-state", "closed");
|
|
await expect(expand).toBeFocused();
|
|
await expect(expand).toHaveAttribute("aria-expanded", "false");
|
|
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
|
|
// Named by its visible date (WCAG 2.5.3); the action is the tooltip.
|
|
expect(await expand.getAttribute("aria-label")).toBeNull();
|
|
expect(await expand.getAttribute("title")).toMatch(/^Show /);
|
|
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
|
|
|
|
// Another page: the edge is still there and brings the same day back,
|
|
// from the keyboard too.
|
|
await page.goto("/labs");
|
|
await expect(rail).toHaveAttribute("data-day", day);
|
|
await expand.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
|
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
|
"data-day",
|
|
day,
|
|
);
|
|
await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`));
|
|
await expect(panel(page).locator("h2").first()).toBeFocused();
|
|
// Open, the strip stays where it was and says so.
|
|
await expect(rail).toHaveAttribute("data-state", "open");
|
|
await expect(expand).toHaveAttribute("aria-expanded", "true");
|
|
});
|
|
|
|
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const desktop = testInfo.project.name === "chromium-desktop";
|
|
// The days of the month that hold anything carry a dot.
|
|
await mockDay(page, { index: "full" });
|
|
const day = isoDaysAgo(5);
|
|
await page.goto(`/mood?day=${day}`);
|
|
await expect(panel(page)).toHaveAttribute(
|
|
"data-shell",
|
|
desktop ? "docked" : "bottom",
|
|
);
|
|
const date = panel(page).locator('[data-slot="day-date-button"]');
|
|
const picker = page.locator('[data-slot="day-date-picker"]');
|
|
// The date stays one line in the header, the arrows beside it.
|
|
await expect(date).toHaveAttribute("aria-expanded", "false");
|
|
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
|
|
await shot(page, testInfo, "day-date-closed");
|
|
|
|
await date.click();
|
|
await expect(picker).toBeVisible();
|
|
await expect(date).toHaveAttribute("aria-expanded", "true");
|
|
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
|
|
"data-selected-single",
|
|
"true",
|
|
);
|
|
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
|
|
// Nothing past today can be picked.
|
|
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
|
|
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
|
|
await shot(page, testInfo, "day-date-open");
|
|
|
|
// Another day of the same month: the 1st, or the 2nd when the open day
|
|
// is the 1st itself.
|
|
const first = `${day.slice(0, 8)}01`;
|
|
const target = first === day ? isoDaysAgo(4) : first;
|
|
const start = await page.evaluate(() => window.history.length);
|
|
await picker.locator(`[data-date-key="${target}"]`).click();
|
|
await expect(picker).toHaveCount(0);
|
|
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
|
"data-day",
|
|
target,
|
|
);
|
|
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
|
|
// Like a step: no new history entry, and focus back on the date.
|
|
expect(await page.evaluate(() => window.history.length)).toBe(start);
|
|
await expect(date).toBeFocused();
|
|
|
|
// The keyboard: Enter opens, Escape closes the calendar only.
|
|
await page.keyboard.press("Enter");
|
|
await expect(picker).toBeVisible();
|
|
// The calendar has the keyboard before Escape is pressed.
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
document.activeElement?.closest(
|
|
'[role="dialog"]:not([data-slot="day-panel"])',
|
|
) != null,
|
|
),
|
|
)
|
|
.toBe(true);
|
|
// On a phone the calendar is a sheet over the day's own sheet. Focus
|
|
// arrives before Radix has handed it the Escape key: in between, the day
|
|
// underneath still holds it. The handover shows as the day's layer
|
|
// turning inert (`pointer-events: none`) once the calendar is the top
|
|
// layer; wait for that, not for a fixed time. The docked column is no
|
|
// dialog and has no such handover.
|
|
if (!desktop)
|
|
await expect
|
|
.poll(() =>
|
|
panel(page).evaluate(
|
|
(el) =>
|
|
new Promise<string>((resolve) =>
|
|
requestAnimationFrame(() =>
|
|
setTimeout(() => resolve(getComputedStyle(el).pointerEvents)),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toBe("none");
|
|
await page.keyboard.press("Escape");
|
|
await expect(picker).toHaveCount(0);
|
|
await expect(panel(page)).toBeVisible();
|
|
await expect(date).toBeFocused();
|
|
|
|
// Today.
|
|
await page.keyboard.press("Space");
|
|
await expect(picker).toBeVisible();
|
|
await picker.locator('[data-slot="day-date-today"]').click();
|
|
await expect(picker).toHaveCount(0);
|
|
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
|
|
});
|
|
|
|
test("a sheet closes for good and leaves no strip behind", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const desktop = testInfo.project.name === "chromium-desktop";
|
|
// Below 1280 px the day is a sheet from the right; on a phone, from the
|
|
// bottom. Neither collapses.
|
|
if (desktop) await page.setViewportSize({ width: 1024, height: 800 });
|
|
const day = isoDaysAgo(3);
|
|
await page.goto(`/mood?day=${day}`);
|
|
await expect(panel(page)).toHaveAttribute(
|
|
"data-shell",
|
|
desktop ? "sheet" : "bottom",
|
|
);
|
|
await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute(
|
|
"aria-label",
|
|
"Close day",
|
|
);
|
|
await page.locator('[data-slot="day-close"]').click();
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
|
|
});
|
|
|
|
test("the strip runs the full height; the search magnifier stays clear of it", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "chromium-desktop",
|
|
"docked from 1280 px",
|
|
);
|
|
const day = isoDaysAgo(3);
|
|
const strip = page.locator('[data-slot="day-strip"]');
|
|
const search = page.locator('[data-slot="command-palette-trigger"]');
|
|
const topBar = page.locator('[data-slot="top-bar"]');
|
|
for (const width of [1280, 1440, 1920]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
for (const url of ["/mood", `/mood?day=${day}`]) {
|
|
await page.goto(url);
|
|
await expect(strip).toBeVisible();
|
|
if (url.includes("day=")) {
|
|
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
|
// The open panel runs the full height beside its strip.
|
|
await expect
|
|
.poll(async () => (await panel(page).boundingBox())?.width)
|
|
.toBeGreaterThan(400);
|
|
const p = (await panel(page).boundingBox())!;
|
|
expect(p.y).toBe(0);
|
|
expect(Math.round(p.y + p.height)).toBe(900);
|
|
}
|
|
const s = (await strip.boundingBox())!;
|
|
// Top edge at the very top of the window, down to the bottom.
|
|
expect(s.y).toBe(0);
|
|
expect(Math.round(s.height)).toBe(900);
|
|
expect(Math.round(s.x + s.width)).toBe(width);
|
|
// The top bar ends left of the strips, so the magnifier sits fully
|
|
// inside it, in front of them.
|
|
const bar = (await topBar.boundingBox())!;
|
|
expect(bar.x + bar.width).toBeLessThanOrEqual(s.x + 0.5);
|
|
await expect(search).toBeVisible();
|
|
const m = (await search.boundingBox())!;
|
|
expect(m.x).toBeGreaterThanOrEqual(0);
|
|
expect(m.y).toBeGreaterThanOrEqual(0);
|
|
expect(m.x + m.width).toBeLessThanOrEqual(s.x);
|
|
expect(m.y + m.height).toBeLessThanOrEqual(bar.y + bar.height);
|
|
// Nothing paints over its centre: the hit test lands on it.
|
|
const hit = await page.evaluate(
|
|
([x, y]) =>
|
|
document
|
|
.elementFromPoint(x, y)
|
|
?.closest('[data-slot="command-palette-trigger"]') != null,
|
|
[m.x + m.width / 2, m.y + m.height / 2],
|
|
);
|
|
expect(hit).toBe(true);
|
|
// No horizontal scroll anywhere on the page.
|
|
const overflow = await page.evaluate(
|
|
() =>
|
|
document.documentElement.scrollWidth -
|
|
document.documentElement.clientWidth,
|
|
);
|
|
expect(overflow).toBeLessThanOrEqual(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("a future or malformed ?day= is dropped without a word", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/mood?day=2999-01-01");
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await page.goto("/mood?day=2026-02-30");
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
await expect(panel(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("stepping between days never adds a history entry", async ({ page }) => {
|
|
await page.goto("/mood");
|
|
await page.locator('[data-slot="day-link"]:visible').first().click();
|
|
await expect(panel(page)).toBeVisible();
|
|
const start = await page.evaluate(() => window.history.length);
|
|
const first = await page
|
|
.locator('[data-slot="day-view"]')
|
|
.getAttribute("data-day");
|
|
for (let i = 0; i < 3; i += 1) {
|
|
await page.locator('[data-slot="day-prev"]').click();
|
|
}
|
|
await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute(
|
|
"data-day",
|
|
first!,
|
|
);
|
|
expect(await page.evaluate(() => window.history.length)).toBe(start);
|
|
// One Back leaves the layer, however far it stepped.
|
|
await page.goBack();
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
});
|
|
|
|
test("on a phone the first tap shows the value, the second opens the day", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
|
|
await page.goto("/insights/blood-pressure");
|
|
const plot = page.locator(
|
|
'[data-slot="chart-plot"][data-day-links="true"]',
|
|
);
|
|
await plot.scrollIntoViewIfNeeded();
|
|
await expect(plot).toBeVisible();
|
|
const box = (await plot.boundingBox())!;
|
|
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
|
|
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
|
|
await expect(open).toBeVisible();
|
|
// A tap alone never opens a sheet.
|
|
await expect(panel(page)).toHaveCount(0);
|
|
await open.tap();
|
|
await expect(panel(page)).toBeVisible();
|
|
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
|
// The tapped value heads the day.
|
|
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
|
});
|
|
|
|
test("the dashboard's today area offers no day door", async ({ page }) => {
|
|
await page.goto("/");
|
|
await expect(
|
|
page.locator('[data-slot="main-content-wrapper"]'),
|
|
).toBeVisible();
|
|
// The hero is today: nothing in it opens another day. (The charts
|
|
// further down do; `day-doors.spec.ts` walks one.)
|
|
await expect(
|
|
page.locator('[data-slot^="today-hero"]').first(),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator(
|
|
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
|
|
),
|
|
).toHaveCount(0);
|
|
});
|
|
});
|