diff --git a/src/__tests__/retired-route-links-guard.test.ts b/src/__tests__/retired-route-links-guard.test.ts index b5d11d6d5..874c20989 100644 --- a/src/__tests__/retired-route-links-guard.test.ts +++ b/src/__tests__/retired-route-links-guard.test.ts @@ -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"))) diff --git a/src/components/day/__tests__/use-day-value-format.test.tsx b/src/components/day/__tests__/use-day-value-format.test.tsx index 7fbca9381..e7f1722b4 100644 --- a/src/components/day/__tests__/use-day-value-format.test.tsx +++ b/src/components/day/__tests__/use-day-value-format.test.tsx @@ -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( - - - , - ) - .replace(/<[^>]+>/g, "") - .replace(//g, ""); + return textOf( + renderToStaticMarkup( + + + , + ), + ); } describe("a day's values in their charts' spelling", () => { diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index e27243b14..710eef051 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -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( {node}, @@ -642,9 +658,7 @@ describe(" 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(" 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. diff --git a/src/lib/pwa/theme-color.ts b/src/lib/pwa/theme-color.ts index 3e8ca8eda..9976c2278 100644 --- a/src/lib/pwa/theme-color.ts +++ b/src/lib/pwa/theme-color.ts @@ -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