mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge the end-to-end and code-scanning fixes into release/v1.42.0
This commit is contained in:
commit
bb5e439553
8 files changed
+110
-24
No files matched your search
@@ -162,8 +162,17 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
|
||||
// swept by the same `e2e-just-in-` purge on the next run.
|
||||
const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000);
|
||||
const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000);
|
||||
//
|
||||
// Tagged `foreground`, the trigger an open app sends. Since v1.42 a batch
|
||||
// without it only marks the dashboard snapshot stale, and `cachedSwr`
|
||||
// serves the stale cell once while it rebuilds: a snapshot an earlier
|
||||
// spec left for this account (no sleep at all, so no sleep expected)
|
||||
// would then paint the BEFORE state as `final`, depending on which spec
|
||||
// happened to run first in the shard. `foreground` evicts, so the first
|
||||
// read below is built from the rows this spec just wrote.
|
||||
const priorBatch = await page.request.post("/api/measurements/batch", {
|
||||
data: {
|
||||
syncTrigger: "foreground",
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
|
||||
@@ -229,8 +238,11 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
|
||||
const wokeAt = new Date(now.getTime() - 60 * 60_000);
|
||||
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
|
||||
|
||||
// `foreground` for the same reason as the prior night above: the page
|
||||
// is open while it lands.
|
||||
const batch = await page.request.post("/api/measurements/batch", {
|
||||
data: {
|
||||
syncTrigger: "foreground",
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* the same ones, for the same reasons written there.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
@@ -111,15 +112,38 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
|
||||
if (!managed) throw new Error("managed record fixture is missing");
|
||||
|
||||
test("offers the mood add control and lands the entry", async ({ page }) => {
|
||||
// The profile starts every run without mood entries. The form stamps an
|
||||
// entry to the minute and `(user, date, moodLoggedAt)` is unique, so a
|
||||
// second run inside the same minute (a retry, a repeat) met a 409 for the
|
||||
// entry the first one wrote. An empty record also fixes which control the
|
||||
// page offers: the empty state's add, not the header's.
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to reset the profile's mood");
|
||||
const pool = new pg.Pool({ connectionString: dbUrl });
|
||||
try {
|
||||
await pool.query(
|
||||
`DELETE FROM mood_entries
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[managed.username],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
await openRecord(page, managed.username);
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
|
||||
await page.goto("/mood");
|
||||
const add = page.locator('[data-slot="mood-add-entry"]');
|
||||
await expect(add).toBeVisible({ timeout: 30_000 });
|
||||
await add.click();
|
||||
// Since v1.42 an empty list carries the one add control in its empty
|
||||
// state and the header drops its own; the header's returns once there is
|
||||
// an entry to list. Both are asserted on their data-slots.
|
||||
const addFirst = page.locator('[data-slot="mood-add-first"]');
|
||||
const addHeader = page.locator('[data-slot="mood-add-entry"]');
|
||||
await expect(addFirst).toBeVisible({ timeout: 30_000 });
|
||||
await expect(addHeader).toHaveCount(0);
|
||||
await addFirst.click();
|
||||
|
||||
const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]');
|
||||
await expect(face).toBeVisible();
|
||||
@@ -139,6 +163,8 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
|
||||
await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(addHeader).toBeVisible();
|
||||
await expect(addFirst).toHaveCount(0);
|
||||
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ function walk(dir: string): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every regex metacharacter escaped, the backslash included. */
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
||||
}
|
||||
|
||||
describe("no link targets a retired route", () => {
|
||||
const files = walk(join(ROOT, "src"));
|
||||
|
||||
@@ -33,7 +38,7 @@ describe("no link targets a retired route", () => {
|
||||
|
||||
it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => {
|
||||
const pattern = new RegExp(
|
||||
`(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${route.replace(/\//g, "\\/")}["'\`?#]`,
|
||||
`(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${escapeRegExp(route)}["'\`?#]`,
|
||||
);
|
||||
const offenders = files
|
||||
.filter((file) => pattern.test(readFileSync(file, "utf8")))
|
||||
|
||||
@@ -42,22 +42,38 @@ function Probe({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of rendered markup: every character outside a `<…>` tag. A walk
|
||||
* rather than a tag-stripping regex, which can leave a tag behind when one
|
||||
* is split across another.
|
||||
*/
|
||||
function textOf(html: string): string {
|
||||
let out = "";
|
||||
let inTag = false;
|
||||
for (const ch of html) {
|
||||
if (ch === "<") inTag = true;
|
||||
else if (ch === ">") inTag = false;
|
||||
else if (!inTag) out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function read(
|
||||
type: string,
|
||||
value: number,
|
||||
unit: string,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider
|
||||
initialLocale={locale}
|
||||
initialMessages={locale === "de" ? deMessages : enMessages}
|
||||
>
|
||||
<Probe type={type} value={value} unit={unit} />
|
||||
</I18nProvider>,
|
||||
)
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/<!-- -->/g, "");
|
||||
return textOf(
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider
|
||||
initialLocale={locale}
|
||||
initialMessages={locale === "de" ? deMessages : enMessages}
|
||||
>
|
||||
<Probe type={type} value={value} unit={unit} />
|
||||
</I18nProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("a day's values in their charts' spelling", () => {
|
||||
|
||||
@@ -48,6 +48,22 @@ const coverage = {
|
||||
missing: ["ACTIVITY", "SLEEP"],
|
||||
};
|
||||
|
||||
/**
|
||||
* The text of rendered markup: every character outside a `<…>` tag. A walk
|
||||
* rather than a tag-stripping regex, which can leave a tag behind when one
|
||||
* is split across another.
|
||||
*/
|
||||
function textOf(html: string): string {
|
||||
let out = "";
|
||||
let inTag = false;
|
||||
for (const ch of html) {
|
||||
if (ch === "<") inTag = true;
|
||||
else if (ch === ">") inTag = false;
|
||||
else if (!inTag) out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
@@ -642,9 +658,7 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
// The onset is an instant; it reads as the day it falls on in the
|
||||
// profile timezone, in the user's date format, never as raw ISO.
|
||||
// v1.42 — the date is a link to its day; the sentence reads the same.
|
||||
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
|
||||
/Rest Mode active since \d{2}\/\d{2}\/2026\./,
|
||||
);
|
||||
expect(textOf(rest)).toMatch(/Rest Mode active since \d{2}\/\d{2}\/2026\./);
|
||||
expect(rest).not.toContain("T23:30:00");
|
||||
expect(rest).toContain('data-slot="health-score-tension"');
|
||||
expect(rest).toContain('data-slot="health-score-return-to-band"');
|
||||
@@ -672,7 +686,7 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
/>,
|
||||
);
|
||||
const rest = atRest(html);
|
||||
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
|
||||
expect(textOf(rest)).toMatch(
|
||||
/Rest Mode active since (07\/20\/2026|20\/07\/2026)/,
|
||||
);
|
||||
// The date opens the day it names, in the profile timezone.
|
||||
|
||||
@@ -742,7 +742,11 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
|
||||
{t("mood.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst && canManageMind ? (
|
||||
<Button size="sm" onClick={onAddFirst}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAddFirst}
|
||||
data-slot="mood-add-first"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.emptyAddFirst")}
|
||||
</Button>
|
||||
|
||||
@@ -249,7 +249,10 @@ export function CoachNudgeCard({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
{/* Stacked on a phone: at 16 px touch type the longest option
|
||||
("At most every two weeks") plus the label no longer fits one
|
||||
row inside a 390 px card. */}
|
||||
<div className="flex min-h-11 flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<label
|
||||
htmlFor="coach-nudge-frequency"
|
||||
className="text-sm font-medium"
|
||||
@@ -258,7 +261,7 @@ export function CoachNudgeCard({
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="coach-nudge-frequency"
|
||||
className="w-auto"
|
||||
className="sm:w-auto"
|
||||
value={resolved.nudgeFrequency}
|
||||
disabled={!isAuthenticated || saving}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -35,7 +35,13 @@ export function applyThemeColor(
|
||||
* tags to that theme, which until now waited for hydration: a phone in light
|
||||
* mode running the app in dark mode showed a light status bar until React
|
||||
* caught up. The tags may stream in after the script, so it repeats the
|
||||
* rewrite once the document is parsed. Built from constants only — the
|
||||
* layout's one `dangerouslySetInnerHTML` stays file-literal content.
|
||||
* rewrite once the document is parsed.
|
||||
*
|
||||
* One string literal, with the two colours written into it rather than
|
||||
* interpolated from `THEME_COLOR`: the layout's one `dangerouslySetInnerHTML`
|
||||
* then carries file-literal content and nothing is built from a value at
|
||||
* runtime. `theme-color.test.ts` runs the script and compares what it paints
|
||||
* against `THEME_COLOR`, so the copies cannot drift apart unnoticed.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?${JSON.stringify(THEME_COLOR.light)}:${JSON.stringify(THEME_COLOR.dark)};function m(){var a=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i<a.length;i++)a[i].setAttribute("content",k)}m();document.addEventListener("DOMContentLoaded",m)})()`;
|
||||
export const THEME_BOOT_SCRIPT =
|
||||
'(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?"#f3f2f5":"#282a36";function m(){var a=document.querySelectorAll(\'meta[name="theme-color"]\');for(var i=0;i<a.length;i++)a[i].setAttribute("content",k)}m();document.addEventListener("DOMContentLoaded",m)})()';
|
||||
Reference in new issue
Block a user