mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the de-flaked end-to-end specs
This commit is contained in:
commit
7036b2d225
6 files changed
+105
-26
No files matched your search
+29
-11
@@ -75,6 +75,20 @@ const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({
|
||||
notes: null,
|
||||
}));
|
||||
|
||||
// Ten daily mood scores ending yesterday, anchored for the same reason the
|
||||
// measurement rows above are: the mood chart windows by calendar day and
|
||||
// withholds its line below three DISTINCT days, so a fixed date left the mood
|
||||
// sub-page painting its sparse placeholder. The scan's painted gate was then
|
||||
// satisfied by a different chart on the page, and the mood line — the reason
|
||||
// the case exists — was never waited for and might never have been scanned.
|
||||
const fixedMoodEntries = Array.from({ length: 10 }, (_, index) => ({
|
||||
date: new Date(Date.now() - (index + 1) * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
score: 3 + (index % 3) * 0.5,
|
||||
samples: 1,
|
||||
}));
|
||||
|
||||
const workoutListEntry = {
|
||||
id: A11Y_WORKOUT_ID,
|
||||
sportType: "running",
|
||||
@@ -330,8 +344,8 @@ async function installA11yMocks(page: Page) {
|
||||
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
fulfilJson(route, {
|
||||
entries: [{ date: "2026-07-20", score: 4 }],
|
||||
summary: { count: 1 },
|
||||
entries: fixedMoodEntries,
|
||||
summary: { count: fixedMoodEntries.length },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -342,11 +356,8 @@ async function installA11yMocks(page: Page) {
|
||||
// present-but-empty; the correlations block carries every metric key.
|
||||
await page.route("**/api/mood/insights", (route) =>
|
||||
fulfilJson(route, {
|
||||
summary: { totalEntries: 1, inTargetPct: null },
|
||||
heatmap: {
|
||||
windowDays: 30,
|
||||
cells: [{ date: "2026-07-20", score: 4, samples: 1 }],
|
||||
},
|
||||
summary: { totalEntries: fixedMoodEntries.length, inTargetPct: null },
|
||||
heatmap: { windowDays: 30, cells: fixedMoodEntries },
|
||||
distribution: [],
|
||||
weekday: [],
|
||||
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
|
||||
@@ -712,18 +723,25 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
// `.recharts-wrapper` is a class the charting library writes, not a
|
||||
// contract this repository keeps, and it is shared by every chart on the
|
||||
// page — so the gate could be satisfied by a chart other than the one the
|
||||
// route exists to render, and it moves whenever the library's markup does.
|
||||
// `chart-plot` is the measurement chart's own slot and the data branch is
|
||||
// the only branch that renders it: an empty window paints `ChartEmptyState`
|
||||
// instead and this scan still waits, which is what it is for.
|
||||
name: "/insights/weight metric subpage",
|
||||
path: "/insights/weight",
|
||||
painted: (page) => page.locator(".recharts-wrapper").first(),
|
||||
painted: (page) => page.locator('[data-slot="chart-plot"]').first(),
|
||||
},
|
||||
{
|
||||
// Mood is event-driven, so its sub-page renders the heatmap and the mood
|
||||
// line chart from `/api/mood/insights` rather than a MeasurementType series.
|
||||
// Gate on the line chart's recharts wrapper — the same painted signal the
|
||||
// weight sub-page uses — so the scan waits for the heavy content to land.
|
||||
// The mood chart carries the same `chart-plot` slot on the same terms, so
|
||||
// the scan waits for the heavy content to land here too.
|
||||
name: "/insights/mood metric subpage",
|
||||
path: "/insights/mood",
|
||||
painted: (page) => page.locator(".recharts-wrapper").first(),
|
||||
painted: (page) => page.locator('[data-slot="chart-plot"]').first(),
|
||||
},
|
||||
{
|
||||
// The two states this page's OWN render produces: the list, or the
|
||||
|
||||
+22
-4
@@ -345,6 +345,16 @@ test.describe("document vault", () => {
|
||||
await detail.locator('[data-slot="document-chat-open"]').click();
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
// The drawer being on screen is not the same event as the vault having
|
||||
// recorded the hand-off. The page books `?doc=` as the way back only once
|
||||
// the detail sheet is actually closed with the drawer owning the same
|
||||
// document; maximize before that and the close path takes the other branch
|
||||
// — `history.back()` — which races the push to `/coach` and lands back on
|
||||
// `/documents` with nothing raised. The sheet's own close control leaves
|
||||
// the tree with the sheet, so its absence is that hand-off, observed.
|
||||
await expect(
|
||||
page.locator('[data-slot="document-detail-close"]'),
|
||||
).toHaveCount(0);
|
||||
await drawer.locator('[data-slot="coach-drawer-maximize"]').click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/coach");
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID);
|
||||
@@ -392,11 +402,19 @@ test.describe("document vault", () => {
|
||||
await sheet.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
// Undo toast → restore → the card returns.
|
||||
await expect(
|
||||
page.getByText("Document deleted", { exact: true }),
|
||||
).toBeVisible();
|
||||
//
|
||||
// Addressed to the toast's own attributes, not to its copy. The copy is
|
||||
// i18n-driven and the literal here has already been wrong once — a trailing
|
||||
// full stop moved and the assertion spent its whole timeout looking for a
|
||||
// sentence that no longer existed, on every attempt, on both projects. The
|
||||
// success type is what the delete claims and the action button is what the
|
||||
// undo needs; both are structural.
|
||||
const undo = page.locator(
|
||||
'[data-sonner-toast][data-type="success"] [data-button]',
|
||||
);
|
||||
await expect(undo).toBeVisible();
|
||||
await expect(openButton(page, `${title}.pdf`)).not.toBeVisible();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await undo.click();
|
||||
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -411,12 +411,28 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
expect(stored!.epoch).toMatch(/^\d+$/);
|
||||
expect(stored!.scope).not.toBe("self");
|
||||
|
||||
// The EXTERNAL switch: driven from a request context, so the app never
|
||||
// runs its cache wipe and the entry above survives.
|
||||
const left = await context.request.post("/api/account/switch", {
|
||||
data: { accountId: null },
|
||||
// The EXTERNAL switch: nothing in the app initiates it, so
|
||||
// `clearOfflineCachesForRecordSwitch` never runs and the entry above
|
||||
// survives — the positive control below is what proves that.
|
||||
//
|
||||
// Sent with the page's own `fetch` rather than through
|
||||
// `context.request`. Both are outside the app: the switch UI is what runs
|
||||
// the wipe, and a bare `fetch` is not it (the service worker returns
|
||||
// without touching any non-GET, so the request reaches the network
|
||||
// unaltered). What differs is the socket. Every Playwright API context in
|
||||
// the runner shares one process-wide keep-alive agent, so this POST could
|
||||
// be handed a connection the server had already closed on its five-second
|
||||
// idle timeout, and a POST is never replayed on a fresh one — twice this
|
||||
// line ended the spec with `read ECONNRESET`, on all three attempts.
|
||||
const left = await page.evaluate(async () => {
|
||||
const res = await fetch("/api/account/switch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ accountId: null }),
|
||||
});
|
||||
return res.status;
|
||||
});
|
||||
expect(left.status()).toBe(200);
|
||||
expect(left).toBe(200);
|
||||
const me = await context.request.get("/api/auth/me");
|
||||
const now = (await me.json()).data.recordSession as {
|
||||
epoch: number;
|
||||
|
||||
@@ -282,16 +282,34 @@ test.describe("vaccinations", () => {
|
||||
|
||||
test("the list and the capture form carry no serious accessibility violations", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Seed one dose so the list scans a populated surface rather than the empty
|
||||
// state, then scan both the list and the open capture form.
|
||||
const seeded = await request.post("/api/vaccinations", {
|
||||
data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" },
|
||||
});
|
||||
expect(seeded.status()).toBe(201);
|
||||
|
||||
//
|
||||
// Written from the page, not from a Playwright API context. Every API
|
||||
// context in the runner shares ONE process-wide keep-alive agent
|
||||
// (`httpHappyEyeballsAgent`), so a request can be handed a pooled socket the
|
||||
// server has already closed on its five-second idle timeout — and a POST,
|
||||
// unlike a GET, is never replayed on a fresh one. This seed died that way
|
||||
// twice, on this line, on all three attempts each time: `socket hang up`
|
||||
// and `read ECONNRESET`. The browser opens its own connection and the row
|
||||
// is still written by the real route, so nothing about the fixture changes
|
||||
// except which socket carries it.
|
||||
await page.goto("/vaccinations");
|
||||
const seededStatus = await page.evaluate(async () => {
|
||||
const res = await fetch("/api/vaccinations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
occurredAt: "2019-05-01T00:00:00.000Z",
|
||||
antigenSlug: "polio",
|
||||
}),
|
||||
});
|
||||
return res.status;
|
||||
});
|
||||
expect(seededStatus).toBe(201);
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -1945,6 +1945,11 @@ export function HealthChart({
|
||||
) : null}
|
||||
|
||||
<div
|
||||
// Stable hook for the browser suite. `.recharts-wrapper` is a
|
||||
// library-internal class and it is what the a11y scan used to
|
||||
// gate on; this slot is rendered by the data branch only, so it
|
||||
// says the same thing without depending on Recharts' markup.
|
||||
data-slot="chart-plot"
|
||||
className="relative z-10 h-full touch-pan-y"
|
||||
role="img"
|
||||
aria-label={chartAriaLabel}
|
||||
|
||||
@@ -767,6 +767,10 @@ export function MoodChart({
|
||||
? "h-[var(--chart-height,140px)]"
|
||||
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
|
||||
} touch-pan-y`}
|
||||
// Same stable hook as the measurement chart: rendered by the data
|
||||
// branch only, so the browser suite can gate on it instead of on
|
||||
// Recharts' own class names.
|
||||
data-slot="chart-plot"
|
||||
role="img"
|
||||
aria-label={moodAriaLabel}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user