test(e2e): cover doctor-report generation end to end

The doctor report had unit coverage for the renderer and route coverage for
the envelope, and nothing that pressed the button. Two browser journeys close
that.

`doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one
allergy and one medication through the page's own `fetch`, picks a window in
the export panel and generates. The saved download is asserted as a real
artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a
length no empty document reaches — and then parsed back to text, so the
period line, the blood-pressure section and the drug list are read out of the
document rather than inferred from a 200. The period is checked as a span, so
the assertion survives the account's timezone. Its refusing control is a
window with no readings: still a valid, readable report that names the period
and carries no blood-pressure section, with the reference data that is not
window-scoped as the control against a blank page passing.

`doctor-report-delegate.spec.ts` covers the access boundary the route draws at
MANAGE. A delegate inside a record it may only read is not offered the export
card at all, and the route refuses the request that card would have sent with
`sharing.access.denied`. The positive control is the same delegate, the same
payload and the same switched-record path against a record it manages, so the
one variable between pass and refusal is the access level. Both requests carry
the record-context headers the shipped client sends: without them the fence
refuses with the same status and the same error code, which would make the
refusal pass for the wrong reason.

Seeding is idempotent — a re-posted reading collides on its natural key and
answers 409 — so a second local run and a repeated run both stay green. The
delegate journey takes its own cookie jar because it moves the session's
record selector, and both specs run in one project.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:27:02 +02:00
1 parent 1d372df3a0
commit 7b06a235e1
5 files changed
+729

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
/**
* The doctor report is not something a READ-level delegate can generate.
*
* `POST /api/export/health-record` resolves `requireRecordAuth("manage",
* "record")`: a prepared artefact from a declared selection is the one export an
* invited MANAGER reaches, and READ is below that line. This spec proves both
* halves of that refusal in a browser — the control is not offered, and the
* route refuses the request anyway.
*
* Why both. A missing button proves nothing about the server, and a 403 proves
* nothing about what the person is shown; the failure mode this release family
* keeps rediscovering is a control that renders and then 403s. So the settings
* page is asserted to carry the shared-record refusal panel instead of the
* export card, and the route is asserted separately, from the same session.
*
* The positive control is the same delegate, the same payload and the same
* switched-record plumbing against the record it holds a MANAGE grant on. That
* isolates the one variable the refusal is supposed to be about: if the payload
* were malformed, the session unfenced or the switch broken, the MANAGE arm
* would refuse too and the file would go red instead of passing for the wrong
* reason.
*
* Both requests carry the record-context headers the shipped client sends. A
* raw `fetch` without them is refused by the record-session fence with the SAME
* status and the SAME error code as an insufficient grant — so an unfenced
* request would make the refusal below pass for the wrong reason. The context
* is read from `/api/auth/me`, which is where a browser gets it too.
*
* Its own cookie jar, like every spec that moves a session's record selector:
* the switch is stamped on the session row, so sharing a jar would switch the
* record under whichever spec happened to be mid-navigation.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
E2E_LEVEL_RECORDS,
REPORT_DELEGATE_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
/** A selection the route accepts, so a refusal is about the grant and nothing
* else. One leaf is enough: an empty list is its own 422. */
const SELECTION = { v: 2, leaves: ["WEIGHT"] } as const;
interface ReportAttempt {
status: number;
contentType: string;
errorCode: string | null;
magic: string;
}
async function openSwitcher(page: Page): Promise<void> {
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
await expect(
page.locator('[data-slot="account-switcher-menu"]'),
).toBeVisible();
}
/**
* Run `act` and wait for the document it replaces to be gone — the switch is a
* hard navigation, and a banner assertion alone settles nothing. Same helper
* shape the v1.37/v1.38 sharing journeys use, for the reason written there.
*/
async function withDocumentReplacement(
page: Page,
act: () => Promise<void>,
): Promise<void> {
await page.evaluate(() => {
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
});
await act();
await page.waitForFunction(
() =>
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
undefined,
{ timeout: 30_000 },
);
}
/** Land on the delegate's own record, with the shell live. */
async function openOwnRecord(page: Page): Promise<void> {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: "User menu" }).first(),
).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
async function enterRecord(page: Page, username: string): Promise<void> {
await openSwitcher(page);
const entry = page.locator(
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
);
await expect(entry).toHaveCount(1);
await withDocumentReplacement(page, () => entry.click());
await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({
timeout: 30_000,
});
}
async function leaveRecord(page: Page): Promise<void> {
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
/**
* Open the health-record settings page and wait for the shell to decide.
*
* Inside somebody else's record the shell answers this destination with the
* "not part of shared access" panel rather than the section, so the panel is
* the settled state to gate on — and the thing whose presence says the export
* card was never offered.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible({ timeout: 30_000 });
}
/**
* Ask the route for a PDF from inside the page, carrying the record context the
* shipped client carries.
*/
async function attemptReport(
page: Page,
selection: { v: number; leaves: readonly string[] },
): Promise<ReportAttempt> {
return page.evaluate(async (chosen) => {
const me = (await (await fetch("/api/auth/me")).json()) as {
data: { recordSession: { epoch: number; scope: string | null } | null };
};
const context = me.data.recordSession;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (context) {
headers["x-healthlog-record-epoch"] = String(context.epoch);
headers["x-healthlog-record-scope"] = context.scope ?? "self";
}
const res = await fetch("/api/export/health-record", {
method: "POST",
headers,
body: JSON.stringify({
format: "pdf",
locale: "en",
range: { days: 30 },
includeCharts: false,
selection: chosen,
}),
});
const contentType = res.headers.get("content-type") ?? "";
const buffer = await res.arrayBuffer();
const head = String.fromCharCode(...new Uint8Array(buffer.slice(0, 5)));
let errorCode: string | null = null;
if (contentType.includes("json")) {
try {
const payload = JSON.parse(new TextDecoder().decode(buffer)) as {
meta?: { errorCode?: string };
};
errorCode = payload.meta?.errorCode ?? null;
} catch {
errorCode = null;
}
}
return { status: res.status, contentType, errorCode, magic: head };
}, selection);
}
test.describe.serial("a read-level delegate and the doctor report", () => {
test.use({ storageState: REPORT_DELEGATE_STORAGE_STATE_PATH });
const readRecord = E2E_LEVEL_RECORDS.find(
(record) => record.access === "READ" && record.recordKind === "shared",
);
if (!readRecord) throw new Error("the READ-level record fixture is missing");
const manageRecord = E2E_LEVEL_RECORDS.find(
(record) => record.access === "MANAGE" && record.recordKind === "shared",
);
if (!manageRecord) {
throw new Error("the MANAGE-level record fixture is missing");
}
test.afterEach(async ({ page }) => {
// Hand the session back to its own record even when an assertion above
// failed: this jar is this spec's, but a switched row left behind would
// make its own next run start somewhere it did not choose.
await page.request
.post("/api/account/switch", { data: { accountId: null } })
.catch(() => {});
});
test("cannot generate on a record it may only read, and is not offered the control", async ({
page,
}) => {
test.setTimeout(120_000);
await openOwnRecord(page);
// Positive control: a record the same delegate MANAGES. Everything except
// the access level is identical to the refusal below.
await enterRecord(page, manageRecord.username);
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toHaveAttribute("data-access-level", "manage");
const managed = await attemptReport(page, SELECTION);
expect(managed.status).toBe(200);
expect(managed.contentType).toContain("application/pdf");
expect(managed.magic).toBe("%PDF-");
await leaveRecord(page);
await enterRecord(page, readRecord.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "shared");
// The banner carries the PRESENTATION of the grant, and a READ grant
// presents as "view" — one level below the "view-and-add" a WRITE grant
// shows and two below the "manage" this route requires.
await expect(banner).toHaveAttribute("data-access-level", "view");
// The control is not offered: the health-record page inside a shared record
// is the refusal panel, not the export card.
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toHaveCount(0);
await expect(page.getByTestId("health-record-generate")).toHaveCount(0);
// And the route refuses the request the absent control would have sent.
const shared = await attemptReport(page, SELECTION);
expect(shared.status).toBe(403);
expect(shared.errorCode).toBe("sharing.access.denied");
expect(shared.contentType).toContain("json");
await leaveRecord(page);
});
});
+453
View File
@@ -0,0 +1,453 @@
/**
* The doctor report, generated in a browser and read back as a real PDF.
*
* The surface had unit coverage for the renderer and route coverage for the
* envelope, and nothing that pressed the button. What that leaves untested is
* the part a self-hoster actually meets: a person with a few weeks of readings
* opens Settings → Gesundheitsakte, picks a window, presses Generate, and gets
* a document a practice can file. Every link in that chain — the panel's own
* `fetch`, the selection it sends, the aggregator's window, the renderer, the
* download — is proved here once, end to end.
*
* What the assertions are about:
*
* 1. The response IS a PDF: `application/pdf`, a `%PDF` magic, a byte length
* no empty document could reach, and a `Content-Length` that agrees with
* the bytes.
* 2. The document says what it contains. The bytes are parsed back to text,
* so the period line, the blood-pressure section and the medication the
* account carries are read out of the artefact rather than inferred from a
* 200.
* 3. The window travelled. The period line names two dates, and their span is
* the window the select offered — not the server's default.
*
* And the refusing control, in the same file because it is the same button: a
* window with nothing in it still produces a valid, readable PDF that names the
* period and simply carries no blood-pressure section. An empty report is a
* true statement about an empty window; a truncated or unopenable file is not.
*
* The fixture is seeded through the PAGE's own `fetch` under the session the
* spec is already authenticated as — not through a second Playwright request
* context — so the rows are written by exactly the credential that later reads
* them back. Seeding is idempotent: a re-post of the same reading collides on
* the measurement's natural key and answers 409, which is why `--repeat-each`
* and a second local run both stay green.
*
* The delegate half of the story — a READ-level delegate cannot generate the
* owner's report — lives in `doctor-report-delegate.spec.ts`, which needs its
* own session row because it moves the record selector.
*/
import { readFile } from "node:fs/promises";
import type { Page, Response } from "@playwright/test";
import { PDFParse } from "pdf-parse";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
/** The allergy and the drug the report has to name. Both plain ASCII: the
* PDF's built-in Helvetica is WinAnsi-encoded and the assertions read the
* extracted text back, so a fixture that needs transliteration would be
* testing the sanitiser rather than the report. */
const ALLERGY_SUBSTANCE = "Amoxicillin";
const MEDICATION_NAME = "Ramipril";
const MEDICATION_DOSE = "5 mg";
/** Day offsets for the seeded readings — a few weeks of history, inside the
* 30-day window the spec selects and well inside the 90-day default, so a
* window that failed to travel would still find them. */
const READING_DAY_OFFSETS = [2, 5, 9, 13, 17, 21] as const;
/** The window the spec chooses in the panel. Deliberately NOT the 90-day
* fallback the panel opens with, so the period line proves a choice. */
const RANGE_DAYS = 30;
/** The section heading the PDF prints only when both blood-pressure series
* have readings in the window. Quoted without its em dash: the expectation is
* about the section being there, not about how a parser renders punctuation. */
const BP_SECTION = "ESH classification";
/** Byte floor for "a real document". A cover page with charts is far above
* this; a truncated stream or an error body is far below it. */
const MIN_PDF_BYTES = 10_000;
interface SeedOutcome {
measurements: number[];
allergy: number;
medication: number;
emergency: number;
}
/**
* Write the record this report is about, through the page's own `fetch`.
*
* Each per-day POST carries the day's systolic, diastolic and weight as one
* array body — the route's batch arm — so a repeat run collides as a whole day
* and answers 409 rather than half-inserting. The allergy and the medication
* are read before they are written, because neither has a natural key that
* would make a second create a no-op.
*
* The emergency profile matters more than it looks: the drug list reaches the
* PDF through the emergency sheet, and that sheet only renders once the profile
* holds something. One blood type is the smallest honest way to say so.
*/
async function seedReportFixture(page: Page): Promise<SeedOutcome> {
return page.evaluate(
async (fixture) => {
const send = async (
method: string,
path: string,
body: unknown,
): Promise<number> => {
const res = await fetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.status;
};
const readList = async (path: string): Promise<unknown[]> => {
const res = await fetch(path);
if (!res.ok) return [];
const payload = (await res.json()) as { data: unknown };
return Array.isArray(payload.data) ? payload.data : [];
};
/** 09:00 UTC on the day `offset` days back — a fixed instant, so a
* second run inside the same day reproduces the same natural key. */
const instantFor = (offset: number): string => {
const day = new Date();
day.setUTCDate(day.getUTCDate() - offset);
return `${day.toISOString().slice(0, 10)}T09:00:00.000Z`;
};
const measurements: number[] = [];
for (const [index, offset] of fixture.dayOffsets.entries()) {
const measuredAt = instantFor(offset);
measurements.push(
await send("POST", "/api/measurements", [
{
type: "BLOOD_PRESSURE_SYS",
value: 118 + index,
measuredAt,
},
{
type: "BLOOD_PRESSURE_DIA",
value: 74 + index,
measuredAt,
},
{
type: "WEIGHT",
value: 82.4 - index * 0.2,
measuredAt,
},
]),
);
}
const allergies = (await readList("/api/allergies")) as Array<{
substance?: string;
}>;
const allergy = allergies.some(
(row) => row.substance === fixture.allergySubstance,
)
? 200
: await send("POST", "/api/allergies", {
substance: fixture.allergySubstance,
category: "MEDICATION",
type: "ALLERGY",
severity: "MODERATE",
});
const drugs = (await readList("/api/medications")) as Array<{
name?: string;
}>;
const medication = drugs.some(
(row) => row.name === fixture.medicationName,
)
? 200
: await send("POST", "/api/medications", {
name: fixture.medicationName,
dose: fixture.medicationDose,
// No schedule: an as-needed drug is active indefinitely and needs
// no dose ledger, which keeps the fixture about the drug LIST.
asNeeded: true,
});
const emergency = await send("PATCH", "/api/anamnesis/emergency", {
bloodType: "O_POS",
});
return { measurements, allergy, medication, emergency };
},
{
dayOffsets: [...READING_DAY_OFFSETS],
allergySubstance: ALLERGY_SUBSTANCE,
medicationName: MEDICATION_NAME,
medicationDose: MEDICATION_DOSE,
},
);
}
/**
* Open the panel and wait until the client boundary that owns it is live.
*
* `GesundheitsakteSection` carries the export panel and the sharing card in one
* boundary, and the sharing card's query only runs once that boundary has
* hydrated — so its request is the signal that the Generate button is wired
* rather than merely painted. Same gate `settings-export.spec.ts` uses, for the
* same reason.
*/
async function openReportPanel(page: Page): Promise<void> {
const boundaryHydrated = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
timeout: 30_000,
});
}
/**
* Put the panel into a known state: PDF format, the standard scope.
*
* The account carries a saved selection the moment it has generated once, so
* the panel a second run opens is the "repeat run" state with the picker
* collapsed. Both states are handled by opening the disclosure when it is shut
* and then applying the named template, which is also the only thing in the
* product that fills a selection — so the scope this report is generated under
* is a scope somebody pressed a button for, exactly as the panel intends.
*/
async function chooseStandardPdfScope(page: Page): Promise<void> {
const panel = page.getByTestId("health-record-export-panel");
const formats = panel.locator('[role="radiogroup"] [role="radio"]');
// PDF is the first of the three formats the panel offers.
await formats.first().click();
await expect(formats.first()).toHaveAttribute("aria-checked", "true");
const picker = page.getByTestId("health-record-included-data-panel");
if ((await picker.count()) === 0) {
await page.getByTestId("health-record-included-data-toggle").click();
}
await expect(picker).toBeVisible();
await page.getByTestId("report-apply-standard-export").click();
await expect(page.getByTestId("health-record-generate")).toBeEnabled();
}
interface GeneratedReport {
status: number;
contentType: string;
contentLength: string | undefined;
bytes: Buffer;
text: string;
filename: string;
}
/**
* Press Generate and take the artefact apart.
*
* Both ends of the press are observed: the response carries the bytes the
* assertions are about, and the download event is the state the person sees.
* Waiting on the response ALONE would pass on a build that produced a perfect
* PDF and never handed it to the browser.
*/
async function generateReport(page: Page): Promise<GeneratedReport> {
const responseArrived = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === "/api/export/health-record",
{ timeout: 60_000 },
);
const downloadStarted = page.waitForEvent("download", { timeout: 60_000 });
const generate = page.getByTestId("health-record-generate");
await generate.click();
const response = await responseArrived;
const download = await downloadStarted;
// The bytes come off the DOWNLOAD, not off the response. The panel reads the
// response into a Blob, and a body a page has already consumed is not one
// Playwright can hand back — `response.body()` answers zero bytes here. The
// saved file is also the better subject: it is the artefact the person ends
// up with, so a build that produced a perfect response and saved something
// else would still be caught.
const saved = await download.path();
expect(saved, "the panel saved the generated report").toBeTruthy();
const bytes = await readFile(saved!);
// The control comes back, and nothing on the panel is complaining. The error
// paragraph is the panel's only failure surface, so its absence is the
// statement that the press succeeded rather than failed quietly.
await expect(generate).toBeEnabled({ timeout: 30_000 });
await expect(
page.getByTestId("health-record-export-panel").getByRole("alert"),
).toHaveCount(0);
// Asserted BEFORE the parse: a parser fed an error envelope reports "not a
// PDF", which is a true statement about the bytes and a useless one about
// what went wrong. The status and the content type say that.
expect(response.status(), await failureDetail(response)).toBe(200);
expect(response.headers()["content-type"] ?? "").toContain("application/pdf");
expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
const parser = new PDFParse({ data: new Uint8Array(bytes) });
let text: string;
try {
text = (await parser.getText()).text;
} finally {
await parser.destroy();
}
return {
status: response.status(),
contentType: response.headers()["content-type"] ?? "",
contentLength: response.headers()["content-length"],
bytes,
text,
filename: download.suggestedFilename(),
};
}
/** What the route said, when it did not say "PDF". */
async function failureDetail(response: Response): Promise<string> {
try {
return (await response.text()).slice(0, 400);
} catch {
return "<no readable body>";
}
}
/** Every assertion that is about "this is a PDF", said once. */
function expectRealPdf(report: GeneratedReport): void {
expect(report.status).toBe(200);
expect(report.contentType).toContain("application/pdf");
expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
// The route declares the length it sent; a disagreement means the body was
// truncated on the way out, which a magic-number check alone would miss.
expect(report.contentLength).toBe(String(report.bytes.byteLength));
expect(report.filename).toMatch(/\.pdf$/);
}
/**
* The two dates the period line names, as UTC midnights.
*
* The line is rendered in the account's locale (English → MM/DD/YYYY) and its
* timezone, so the dates are read structurally and compared as a SPAN. A span
* is timezone-proof in a way an absolute date is not: both ends move together.
*/
function periodSpanDays(text: string): number {
const match =
/Reporting period:\s*(\d{2})\/(\d{2})\/(\d{4})\s*\D{1,3}\s*(\d{2})\/(\d{2})\/(\d{4})/.exec(
text,
);
expect(match, "the report prints a reporting period with two dates").not.toBe(
null,
);
const [, sm, sd, sy, em, ed, ey] = match!;
const start = Date.UTC(Number(sy), Number(sm) - 1, Number(sd));
const end = Date.UTC(Number(ey), Number(em) - 1, Number(ed));
return Math.round((end - start) / 86_400_000);
}
test.describe.serial("the doctor report", () => {
// The seeded account owns the readings, the allergy and the drug, and the
// page's own `fetch` writes them under its session.
test.use({ storageState: STORAGE_STATE_PATH });
test("generates a PDF that names the period, the vitals and the medication", async ({
page,
}) => {
// A production build rendering a charted PDF over a seeded window is well
// past the 30 s default, and a timeout here would report a failure that
// says nothing about the report.
test.setTimeout(120_000);
await openReportPanel(page);
const seeded = await seedReportFixture(page);
// 201 on a first run, 409 on a repeat — both mean the readings are there.
for (const status of seeded.measurements) {
expect([201, 409]).toContain(status);
}
expect([200, 201]).toContain(seeded.allergy);
expect([200, 201]).toContain(seeded.medication);
expect(seeded.emergency).toBe(200);
// Reload so the panel is looking at the record that now exists.
await openReportPanel(page);
await chooseStandardPdfScope(page);
// The period: a window the panel offers, and not the one it defaults to.
await page.locator("#hr-range").selectOption({ value: String(RANGE_DAYS) });
await expect(page.locator("#hr-range")).toHaveValue(String(RANGE_DAYS));
const report = await generateReport(page);
expectRealPdf(report);
// The document says what it is about.
expect(report.text).toContain("Reporting period");
const span = periodSpanDays(report.text);
expect(span).toBeGreaterThanOrEqual(RANGE_DAYS - 1);
expect(span).toBeLessThanOrEqual(RANGE_DAYS + 1);
// The blood-pressure section only prints when both series carried readings
// in the window, so its presence is the seeded history arriving.
expect(report.text).toContain(BP_SECTION);
// And the record the clinician reads first: the drug and the allergy.
expect(report.text).toContain(MEDICATION_NAME);
expect(report.text).toContain(ALLERGY_SUBSTANCE);
});
test("a window with no readings is an empty report, not a broken one", async ({
page,
}) => {
test.setTimeout(120_000);
await openReportPanel(page);
await chooseStandardPdfScope(page);
// A custom window a year back: inside the route's span bounds, and holding
// none of this account's readings.
await page.locator("#hr-range").selectOption({ value: "custom" });
const panel = page.getByTestId("health-record-export-panel");
const dateFields = panel.locator(
'[data-slot="date-field"] input[type="text"]',
);
await expect(dateFields).toHaveCount(2);
const dayBack = (offset: number): string => {
const day = new Date();
day.setUTCDate(day.getUTCDate() - offset);
return day.toISOString().slice(0, 10);
};
// The field commits a clean ISO string whatever the locale's field order is.
await dateFields.first().fill(dayBack(400));
await dateFields.first().press("Enter");
await dateFields.nth(1).fill(dayBack(380));
await dateFields.nth(1).press("Enter");
const report = await generateReport(page);
// Still a document: the empty window is answered with a report, not with a
// truncated file and not with an error the panel swallows.
expectRealPdf(report);
expect(report.text).toContain("Reporting period");
// Nothing was measured in it, so the section that needs readings is gone.
expect(report.text).not.toContain(BP_SECTION);
// The positive control for that absence: the reference data that is NOT
// window-scoped is still in the document. Without this, a renderer that
// produced a blank page would pass the line above.
expect(report.text).toContain(MEDICATION_NAME);
expect(report.text).toContain(ALLERGY_SUBSTANCE);
});
});
+12
View File
@@ -250,6 +250,17 @@ export const CROSS_TAB_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateCrossTab.json",
);
/**
* The doctor-report delegate journey's jar. Its own session row, for the reason
* every switching spec has one: it opens somebody else's record to prove the
* report refuses a READ-level grant, and two specs driving one row from two
* workers would test their interference rather than the refusal.
*/
export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateReportDelegate.json",
);
/**
* The banner-geometry spec's jar, and the reason it exists is a release-shaped
* one rather than a load-shaped one.
@@ -760,6 +771,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH);
// v1.37.0 — the guardian's jar, and only then its second factor.
//
+1
View File
@@ -25,5 +25,6 @@ export {
FENCE_STORAGE_STATE_PATH,
FENCE_OFFLINE_STORAGE_STATE_PATH,
CROSS_TAB_STORAGE_STATE_PATH,
REPORT_DELEGATE_STORAGE_STATE_PATH,
GUARDIAN_STORAGE_STATE_PATH,
} from "./global-setup";
+11
View File
@@ -152,6 +152,17 @@ export default defineConfig({
// through stable data-slots, not a mobile layout, so it runs in one
// project.
"vaccinations.spec.ts",
// The doctor-report journey seeds a few weeks of readings into the one
// shared account, presses Generate and reads the PDF back off the
// download. Two projects doing that in parallel would leave a second
// set of rows in the window one of them is asserting an empty report
// for — and mobile emulation emits no `download` event for the
// anchor click the panel makes, so the artefact would be unreachable
// there anyway.
"doctor-report.spec.ts",
// Its delegate half MOVES the session's record selector, so it runs in
// exactly one project for the reason written above the fence specs.
"doctor-report-delegate.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
],