From 2d32d8b078bde3cad700f0f57b92db25bb48b392 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 13:22:39 +0100 Subject: [PATCH 1/8] Add multi-digit support for overlay badges and tests Fix multi-digit notification badge in Windows taskbar overlay The BadgeOverlayRenderer reused the over-baseImage layout for the standalone 16x16 overlay, which pushed multi-digit badges off the left of the canvas (e.g. "10" rendered as just "0"). Keep the pill inside the canvas bounds in the no-baseImage path and reduce its height for multi-digit counts so the rounded-rect path actually produces a pill. Adds a Playwright test that bundles favicon.ts with esbuild, drives the real BadgeOverlayRenderer in Chromium, and writes the resulting PNGs to disk plus a scaled-up screenshot snapshot for regression detection. Include summary for overlay and favicon --- .../e2e/favicon/badge-overlay.spec.ts | 143 +++++++++++++ .../playwright/e2e/favicon/favicon.spec.ts | 188 ++++++++++++++++++ apps/web/src/favicon.ts | 26 ++- 3 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 apps/web/playwright/e2e/favicon/badge-overlay.spec.ts create mode 100644 apps/web/playwright/e2e/favicon/favicon.spec.ts diff --git a/apps/web/playwright/e2e/favicon/badge-overlay.spec.ts b/apps/web/playwright/e2e/favicon/badge-overlay.spec.ts new file mode 100644 index 00000000000..66237dacc4e --- /dev/null +++ b/apps/web/playwright/e2e/favicon/badge-overlay.spec.ts @@ -0,0 +1,143 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* + * Tests for BadgeOverlayRenderer (the 16x16 PNG used as the Windows taskbar overlay). + * + * Approach: + * 1. Open about:blank in real Chromium (canvas needs a real browser). + * 2. Bundle favicon.ts with esbuild and inject it into the page. + * 3. Call renderer.render(count) inside the page via page.evaluate. + * 4. Get the PNG bytes back to Node and write them to disk for inspection. + * 5. Display the PNG scaled-up in an and snapshot it for regression diffs. + */ + +import { test, expect } from "@playwright/test"; +import { build } from "esbuild"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FAVICON_SRC = path.resolve(__dirname, "../../../src/favicon.ts"); +const OUT_DIR = path.resolve(__dirname, "../../test-results/favicon-badges"); + +// Counts that exercise each branch of the multi-digit logic in BadgeOverlayRenderer. +// All cases render as a circle (the overlay is always 16x16), but the font scale +// changes with digit count to keep the text legible. +// - single digit +// - two digits +// - three digits +// - 1000+ falls back to the built-in "Nk+" abbreviation +// - a non-numeric symbol (used for the error overlay) +const SAMPLES: Array<{ name: string; value: number | string; bgColor?: string }> = [ + { name: "1", value: 1 }, + { name: "9", value: 9 }, + { name: "10", value: 10 }, + { name: "12", value: 12 }, + { name: "99", value: 99 }, + { name: "100", value: 100 }, + { name: "999", value: 999 }, + { name: "1000", value: 1000 }, + { name: "error", value: "×", bgColor: "#f00" }, +]; + +let bundledJs: string; + +test.beforeAll(async () => { + // Bundle favicon.ts into an IIFE so we can inject it into about:blank without + // depending on the webapp dev server having the source available. + const result = await build({ + entryPoints: [FAVICON_SRC], + bundle: true, + format: "iife", + globalName: "FaviconModule", + write: false, + target: "es2020", + platform: "browser", + }); + bundledJs = result.outputFiles[0].text; + await fs.mkdir(OUT_DIR, { recursive: true }); +}); + +test.describe("favicon BadgeOverlayRenderer", () => { + // The overlay PNG is consumed by Electron (Chromium) on Windows. Rendering + // it under Firefox/WebKit would just produce slightly different output for + // no benefit, so skip those projects. + test.skip(({ browserName }) => browserName !== "chromium", "Chromium-only canvas test"); + + for (const sample of SAMPLES) { + test(`renders count "${sample.name}"`, { tag: "@screenshot" }, async ({ page }) => { + await page.goto("about:blank"); + await page.addScriptTag({ content: bundledJs }); + + // Run the real BadgeOverlayRenderer inside a real Chromium canvas + // and return the PNG bytes as base64 so we can write them to disk. + const base64 = await page.evaluate( + async ({ value, bgColor }) => { + const renderer = new ( + window as unknown as { + FaviconModule: { + BadgeOverlayRenderer: new () => { + render: (v: number | string, c?: string) => Promise; + }; + }; + } + ).FaviconModule.BadgeOverlayRenderer(); + const buf = await renderer.render(value, bgColor); + if (!buf) return null; + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + }, + { value: sample.value, bgColor: sample.bgColor }, + ); + + expect(base64, "renderer should produce a PNG for non-zero counts").not.toBeNull(); + + const png = Buffer.from(base64!, "base64"); + + // Write the PNG to disk for visual inspection. Output lives under + // playwright/test-results/favicon-badges/ so it survives a normal + // test run and can be opened directly. + await fs.writeFile(path.join(OUT_DIR, `badge-${sample.name}.png`), png); + + // Render the badge into a fixed-size we can screenshot, + // letting us catch visual regressions via Playwright's snapshot + // diffing. The img is scaled up so small pixel differences are + // easier to eyeball when a snapshot fails. + await page.setContent(` + + + + `); + await expect(page.locator("#badge")).toHaveScreenshot(`badge-${sample.name}.png`); + }); + } + + test("renders nothing when count is 0", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "Chromium-only canvas test"); + await page.goto("about:blank"); + await page.addScriptTag({ content: bundledJs }); + + const buf = await page.evaluate(async () => { + const renderer = new ( + window as unknown as { + FaviconModule: { + BadgeOverlayRenderer: new () => { render: (v: number) => Promise }; + }; + } + ).FaviconModule.BadgeOverlayRenderer(); + return await renderer.render(0); + }); + + expect(buf).toBeNull(); + }); +}); diff --git a/apps/web/playwright/e2e/favicon/favicon.spec.ts b/apps/web/playwright/e2e/favicon/favicon.spec.ts new file mode 100644 index 00000000000..536a733dc6a --- /dev/null +++ b/apps/web/playwright/e2e/favicon/favicon.spec.ts @@ -0,0 +1,188 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* + * Tests for Favicon (the browser-tab favicon with a badge drawn on top of the + * Element logo). + * + * Approach: + * 1. Open a stub page with in real Chromium. + * 2. Bundle favicon.ts with esbuild and inject it into the page. + * 3. Set the link's href to the real Element favicon, then call new Favicon().badge(count). + * 4. The class rewrites the link's href asynchronously, so poll until it changes, + * then read the badged PNG out as a data URI and write it to disk. + * 5. Display the PNG scaled-up in an and snapshot it for regression diffs. + */ + +import { test, expect } from "@playwright/test"; +import { build } from "esbuild"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FAVICON_SRC = path.resolve(__dirname, "../../../src/favicon.ts"); +const OUT_DIR = path.resolve(__dirname, "../../test-results/favicons"); +// Use the real Element favicon that ships with the app so the test exercises +// the same image (and image dimensions) the Favicon class sees in production. +const BASE_FAVICON = path.resolve(__dirname, "../../../res/vector-icons/144.png"); + +// Coverage for the over-baseImage path used by the in-browser tab favicon. +// We pick representative counts that hit each branch of circle() (single +// digit, pill at 2 digits, narrower pill at 3+ digits, "Nk+" abbreviation). +const SAMPLES = [ + { name: "1", value: 1 }, + { name: "10", value: 10 }, + { name: "99", value: 99 }, + { name: "100", value: 100 }, + { name: "1000", value: 1000 }, +]; + +let bundledJs: string; +let baseFaviconDataUri: string; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [FAVICON_SRC], + bundle: true, + format: "iife", + globalName: "FaviconModule", + write: false, + target: "es2020", + platform: "browser", + }); + bundledJs = result.outputFiles[0].text; + // Read the real Element favicon and inline it as a data URI so the test + // doesn't depend on a running webserver to serve it. + const baseBytes = await fs.readFile(BASE_FAVICON); + baseFaviconDataUri = `data:image/png;base64,${baseBytes.toString("base64")}`; + await fs.mkdir(OUT_DIR, { recursive: true }); +}); + +test.describe("favicon Favicon (over baseImage)", () => { + test.skip(({ browserName }) => browserName !== "chromium", "Chromium-only canvas test"); + + for (const sample of SAMPLES) { + test(`badges favicon with "${sample.name}"`, { tag: "@screenshot" }, async ({ page }) => { + // Stand up a minimal page with a recognisable base favicon so the + // Favicon class has something to draw on top of. We generate the + // base image in-browser so the test isn't dependent on any + // checked-in image file. + await page.setContent(` + + + + + + + + `); + await page.addScriptTag({ content: bundledJs }); + + // Set the base favicon and trigger badging. The Favicon class updates + // the link's href asynchronously (it waits for the base image to load + // and then defers the write via setTimeout), so we poll for the change + // below rather than reading the href here. + await page.evaluate( + ({ value, baseUri }) => { + const link = document.getElementById("favicon-link") as HTMLLinkElement; + link.setAttribute("href", baseUri); + + // Instantiate the real Favicon class. It reads + // from the document, loads the href into an internal image, + // and rewrites the link's href when badge() runs. + const FaviconCtor = ( + window as unknown as { + FaviconModule: { default: new () => { badge: (n: number | string) => void } }; + } + ).FaviconModule.default; + new FaviconCtor().badge(value); + }, + { value: sample.value, baseUri: baseFaviconDataUri }, + ); + + // Wait for the Favicon class to finish rewriting the link's href. + const linkLocator = page.locator("#favicon-link"); + await expect + .poll(() => linkLocator.getAttribute("href")) + .not.toBe(baseFaviconDataUri); + const dataUri = await linkLocator.getAttribute("href"); + + expect(dataUri).toBeTruthy(); + expect(dataUri!.startsWith("data:image/png;base64,")).toBe(true); + + const base64 = dataUri!.split(",")[1]; + const png = Buffer.from(base64, "base64"); + + // Write the badged favicon PNG to disk for visual inspection. + await fs.writeFile(path.join(OUT_DIR, `favicon-${sample.name}.png`), png); + + // Render it scaled-up so the snapshot is easier to eyeball when + // a diff fails. + await page.setContent(` + + + + `); + await expect(page.locator("#fav")).toHaveScreenshot(`favicon-${sample.name}.png`); + }); + } + + test("badge(0) clears the badge and restores the base image", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "Chromium-only canvas test"); + + await page.setContent(` + + + + + + + + `); + await page.addScriptTag({ content: bundledJs }); + + // Stash a Favicon instance on the window so we can drive it across + // multiple evaluate() calls between Playwright-side waits. + await page.evaluate((baseUri) => { + const link = document.getElementById("favicon-link") as HTMLLinkElement; + link.setAttribute("href", baseUri); + const FaviconCtor = ( + window as unknown as { + FaviconModule: { default: new () => { badge: (n: number) => void } }; + } + ).FaviconModule.default; + (window as unknown as { __fav: { badge: (n: number) => void } }).__fav = new FaviconCtor(); + }, baseFaviconDataUri); + + const linkLocator = page.locator("#favicon-link"); + + // Badge the favicon with 5 and wait for the href to update. + await page.evaluate(() => { + (window as unknown as { __fav: { badge: (n: number) => void } }).__fav.badge(5); + }); + await expect.poll(() => linkLocator.getAttribute("href")).not.toBe(baseFaviconDataUri); + const withBadge = (await linkLocator.getAttribute("href"))!; + + // Clear with badge(0); the href should update again to a different value. + await page.evaluate(() => { + (window as unknown as { __fav: { badge: (n: number) => void } }).__fav.badge(0); + }); + await expect.poll(() => linkLocator.getAttribute("href")).not.toBe(withBadge); + const cleared = (await linkLocator.getAttribute("href"))!; + + // Badging produces a different image; clearing produces a different + // image again. We don't compare cleared to the original because the + // re-encode through canvas may produce different bytes than the + // original PNG. + expect(withBadge).not.toEqual(cleared); + expect(withBadge).toMatch(/^data:image\/png;base64,/); + expect(cleared).toMatch(/^data:image\/png;base64,/); + }); +}); diff --git a/apps/web/src/favicon.ts b/apps/web/src/favicon.ts index 8d014157f6f..2f339cc9bd7 100644 --- a/apps/web/src/favicon.ts +++ b/apps/web/src/favicon.ts @@ -98,21 +98,32 @@ abstract class IconRenderer { const opt = this.options(n, params); let more = false; + // Font-height multiplier applied to opt.h to ensure text fits the badge. + let fontScale: number; if (!this.baseImage) { - // If we omit the background, assume the entire canvas is our target. + // If we omit the background, the entire canvas is our target. + // Keep it a circle inside the square canvas (favicons / overlay + // icons are square) and shrink the text for multi-digit counts + // so it doesn't overflow the fixed-width circle. opt.x = 0; opt.y = 0; opt.w = this.canvas.width; opt.h = this.canvas.height; - } - if (opt.len === 2) { + if (opt.len === 2) fontScale = 0.65; + else if (opt.len >= 3) fontScale = 0.5; + else fontScale = 1; + } else if (opt.len === 2) { opt.x = opt.x - opt.w * 0.4; opt.w = opt.w * 1.4; more = true; + fontScale = 1; } else if (opt.len >= 3) { opt.x = opt.x - opt.w * 0.65; opt.w = opt.w * 1.65; more = true; + fontScale = 0.85; + } else { + fontScale = 1; } this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); @@ -120,9 +131,10 @@ abstract class IconRenderer { this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height); } this.context.beginPath(); - const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px"; + const fontSize = Math.floor(opt.h * fontScale) + "px"; this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`; this.context.textAlign = "center"; + this.context.textBaseline = "middle"; if (more) { this.context.moveTo(opt.x + opt.w / 2, opt.y); @@ -145,11 +157,13 @@ abstract class IconRenderer { this.context.stroke(); this.context.fillStyle = params.textColor; + const textX = Math.floor(opt.x + opt.w / 2); + const textY = Math.floor(opt.y + opt.h / 2); if (typeof opt.n === "number" && opt.n > 999) { const count = (opt.n > 9999 ? 9 : Math.floor(opt.n / 1000)) + "k+"; - this.context.fillText(count, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); + this.context.fillText(count, textX, textY); } else { - this.context.fillText("" + opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); + this.context.fillText("" + opt.n, textX, textY); } this.context.closePath(); From 9e70c0eab58bf701fcc4eb498c95112b5714a77f Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 18:09:20 +0100 Subject: [PATCH 2/8] add esbuild as devDependency --- apps/web/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/package.json b/apps/web/package.json index d725f8aaa97..0a7ea397dc9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -175,6 +175,7 @@ "css-loader": "^7.0.0", "css-minimizer-webpack-plugin": "^8.0.0", "dotenv": "^17.0.0", + "esbuild": "^0.27.0", "eslint": "8.57.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", From c9e3e18d76e39cf926f2ef7aca18fd0fa91a8b34 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 18:14:29 +0100 Subject: [PATCH 3/8] Update pnpm-lock.yaml --- pnpm-lock.yaml | 57 +++++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05325efb5ae..c0478e7d0dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,10 +739,13 @@ importers: version: 7.1.4(webpack@5.107.1) css-minimizer-webpack-plugin: specifier: ^8.0.0 - version: 8.0.0(lightningcss@1.32.0)(webpack@5.107.1) + version: 8.0.0(esbuild@0.27.4)(lightningcss@1.32.0)(webpack@5.107.1) dotenv: specifier: ^17.0.0 version: 17.4.2 + esbuild: + specifier: 0.27.4 + version: 0.27.4 eslint: specifier: 8.57.1 version: 8.57.1 @@ -886,7 +889,7 @@ importers: version: 6.1.1(stylelint@17.12.0(typescript@6.0.3)) terser-webpack-plugin: specifier: ^5.3.9 - version: 5.6.0(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) + version: 5.6.0(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) testcontainers: specifier: ^11.0.0 version: 11.14.0 @@ -901,7 +904,7 @@ importers: version: 4.3.0 webpack: specifier: ^5.89.0 - version: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + version: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-bundle-analyzer: specifier: ^5.0.0 version: 5.3.0 @@ -10109,7 +10112,7 @@ packages: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798: - resolution: {gitHosted: true, integrity: sha512-M8FIPM5lhZcvUKBwovJ2R+gMt5OgQ+VU4XJl0Pft6i5rK12birfBUXDDhSdoMDxajAmZXyeD1VtzbPODWGuBoQ==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798} + resolution: {gitHosted: true, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798} version: 41.6.0 engines: {node: '>=22.0.0'} @@ -17061,7 +17064,7 @@ snapshots: '@principalstudio/html-webpack-inject-preload@1.2.7(html-webpack-plugin@5.6.7(webpack@5.107.1))(webpack@5.107.1)': dependencies: html-webpack-plugin: 5.6.7(webpack@5.107.1) - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': dependencies: @@ -17803,7 +17806,7 @@ snapshots: '@sentry/webpack-plugin@5.3.0(encoding@0.1.13)(webpack@5.107.1)': dependencies: '@sentry/bundler-plugin-core': 5.3.0(encoding@0.1.13) - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) transitivePeerDependencies: - encoding - supports-color @@ -19693,7 +19696,7 @@ snapshots: '@babel/core': 7.29.0 find-up: 5.0.0 optionalDependencies: - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) babel-plugin-const-enum@1.2.0(@babel/core@7.29.0): dependencies: @@ -20404,7 +20407,7 @@ snapshots: schema-utils: 4.3.3 serialize-javascript: 7.0.5 tinyglobby: 0.2.16 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) core-js-compat@3.49.0: dependencies: @@ -20562,9 +20565,9 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.8.1 optionalDependencies: - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) - css-minimizer-webpack-plugin@8.0.0(lightningcss@1.32.0)(webpack@5.107.1): + css-minimizer-webpack-plugin@8.0.0(esbuild@0.27.4)(lightningcss@1.32.0)(webpack@5.107.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 7.1.2(postcss@8.5.15) @@ -20572,8 +20575,9 @@ snapshots: postcss: 8.5.15 schema-utils: 4.3.3 serialize-javascript: 7.0.5 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) optionalDependencies: + esbuild: 0.27.4 lightningcss: 1.32.0 css-prefers-color-scheme@11.0.0(postcss@8.5.15): @@ -22021,7 +22025,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) file-saver@2.0.5: {} @@ -22521,7 +22525,7 @@ snapshots: pretty-error: 4.0.0 tapable: 2.3.3 optionalDependencies: - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) htmlparser2@10.1.0: dependencies: @@ -23985,7 +23989,7 @@ snapshots: dependencies: schema-utils: 4.3.3 tapable: 2.3.3 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) minimalistic-assert@1.0.1: {} @@ -25087,7 +25091,7 @@ snapshots: postcss: 8.5.15 semver: 7.8.1 optionalDependencies: - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) transitivePeerDependencies: - typescript @@ -25597,7 +25601,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) re-resizable@6.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: @@ -26416,7 +26420,7 @@ snapshots: dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) source-map-support@0.5.13: dependencies: @@ -26980,14 +26984,15 @@ snapshots: postcss: 8.5.15 optional: true - terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1): + terser-webpack-plugin@5.6.0(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) optionalDependencies: + esbuild: 0.27.4 lightningcss: 1.32.0 postcss: 8.5.15 @@ -27770,7 +27775,7 @@ snapshots: import-local: 3.2.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-merge: 6.0.1 optionalDependencies: webpack-bundle-analyzer: 5.3.0 @@ -27785,7 +27790,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) transitivePeerDependencies: - tslib @@ -27820,7 +27825,7 @@ snapshots: webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.107.1) ws: 8.21.0 optionalDependencies: - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-cli: 7.0.2(webpack-bundle-analyzer@5.3.0)(webpack-dev-server@5.2.4)(webpack@5.107.1) transitivePeerDependencies: - bufferutil @@ -27838,7 +27843,7 @@ snapshots: webpack-retry-chunk-load-plugin@3.1.1(webpack@5.107.1): dependencies: prettier: 2.8.8 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-sources@3.5.0: {} @@ -27847,7 +27852,7 @@ snapshots: ejs: 3.1.10 fs: 0.0.1-security underscore: 1.13.8 - webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-virtual-modules@0.6.2: {} @@ -27891,7 +27896,7 @@ snapshots: - uglify-js optional: true - webpack@5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2): + webpack@5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -27913,7 +27918,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) + terser-webpack-plugin: 5.6.0(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) watchpack: 2.5.1 webpack-sources: 3.5.0 optionalDependencies: From c05a3c25e2bcb4afdda593dbed354c8f8f9d865a Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 19:44:12 +0100 Subject: [PATCH 4/8] e2e -> unit test --- apps/web/package.json | 3 +- .../e2e/favicon/badge-overlay.spec.ts | 143 ------------ .../playwright/e2e/favicon/favicon.spec.ts | 188 ---------------- ...verlay-renderer-renders-count-1-1-snap.png | Bin 0 -> 408 bytes ...erlay-renderer-renders-count-10-1-snap.png | Bin 0 -> 533 bytes ...rlay-renderer-renders-count-100-1-snap.png | Bin 0 -> 579 bytes ...lay-renderer-renders-count-1000-1-snap.png | Bin 0 -> 520 bytes ...erlay-renderer-renders-count-12-1-snap.png | Bin 0 -> 536 bytes ...verlay-renderer-renders-count-9-1-snap.png | Bin 0 -> 622 bytes ...erlay-renderer-renders-count-99-1-snap.png | Bin 0 -> 635 bytes ...rlay-renderer-renders-count-999-1-snap.png | Bin 0 -> 643 bytes ...ay-renderer-renders-count-error-1-snap.png | Bin 0 -> 389 bytes ...ase-image-badges-favicon-with-1-1-snap.png | Bin 0 -> 8322 bytes ...se-image-badges-favicon-with-10-1-snap.png | Bin 0 -> 8309 bytes ...e-image-badges-favicon-with-100-1-snap.png | Bin 0 -> 8520 bytes ...-image-badges-favicon-with-1000-1-snap.png | Bin 0 -> 6935 bytes ...se-image-badges-favicon-with-99-1-snap.png | Bin 0 -> 10007 bytes .../web/test/unit-tests/badge-overlay-test.ts | 106 +++++++++ .../web/test/unit-tests/favicon-image-test.ts | 150 +++++++++++++ package.json | 1 + pnpm-lock.yaml | 207 ++++++++++++++++-- 21 files changed, 442 insertions(+), 356 deletions(-) delete mode 100644 apps/web/playwright/e2e/favicon/badge-overlay.spec.ts delete mode 100644 apps/web/playwright/e2e/favicon/favicon.spec.ts create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-10-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1000-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-12-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-9-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-99-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-999-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-error-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-1-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-10-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-100-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-1000-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-99-1-snap.png create mode 100644 apps/web/test/unit-tests/badge-overlay-test.ts create mode 100644 apps/web/test/unit-tests/favicon-image-test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0a7ea397dc9..142a54f5591 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -171,11 +171,11 @@ "babel-loader": "^10.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "blob-polyfill": "^9.0.0", + "canvas": "^3.0.0", "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.0.0", "css-minimizer-webpack-plugin": "^8.0.0", "dotenv": "^17.0.0", - "esbuild": "^0.27.0", "eslint": "8.57.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", @@ -197,6 +197,7 @@ "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.2.0", "jest-fixed-jsdom": "^0.0.11", + "jest-image-snapshot": "^6.5.1", "jest-mock": "^30.0.0", "jest-raw-loader": "^1.0.1", "jsqr": "^1.4.0", diff --git a/apps/web/playwright/e2e/favicon/badge-overlay.spec.ts b/apps/web/playwright/e2e/favicon/badge-overlay.spec.ts deleted file mode 100644 index 66237dacc4e..00000000000 --- a/apps/web/playwright/e2e/favicon/badge-overlay.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -/* - * Tests for BadgeOverlayRenderer (the 16x16 PNG used as the Windows taskbar overlay). - * - * Approach: - * 1. Open about:blank in real Chromium (canvas needs a real browser). - * 2. Bundle favicon.ts with esbuild and inject it into the page. - * 3. Call renderer.render(count) inside the page via page.evaluate. - * 4. Get the PNG bytes back to Node and write them to disk for inspection. - * 5. Display the PNG scaled-up in an and snapshot it for regression diffs. - */ - -import { test, expect } from "@playwright/test"; -import { build } from "esbuild"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const FAVICON_SRC = path.resolve(__dirname, "../../../src/favicon.ts"); -const OUT_DIR = path.resolve(__dirname, "../../test-results/favicon-badges"); - -// Counts that exercise each branch of the multi-digit logic in BadgeOverlayRenderer. -// All cases render as a circle (the overlay is always 16x16), but the font scale -// changes with digit count to keep the text legible. -// - single digit -// - two digits -// - three digits -// - 1000+ falls back to the built-in "Nk+" abbreviation -// - a non-numeric symbol (used for the error overlay) -const SAMPLES: Array<{ name: string; value: number | string; bgColor?: string }> = [ - { name: "1", value: 1 }, - { name: "9", value: 9 }, - { name: "10", value: 10 }, - { name: "12", value: 12 }, - { name: "99", value: 99 }, - { name: "100", value: 100 }, - { name: "999", value: 999 }, - { name: "1000", value: 1000 }, - { name: "error", value: "×", bgColor: "#f00" }, -]; - -let bundledJs: string; - -test.beforeAll(async () => { - // Bundle favicon.ts into an IIFE so we can inject it into about:blank without - // depending on the webapp dev server having the source available. - const result = await build({ - entryPoints: [FAVICON_SRC], - bundle: true, - format: "iife", - globalName: "FaviconModule", - write: false, - target: "es2020", - platform: "browser", - }); - bundledJs = result.outputFiles[0].text; - await fs.mkdir(OUT_DIR, { recursive: true }); -}); - -test.describe("favicon BadgeOverlayRenderer", () => { - // The overlay PNG is consumed by Electron (Chromium) on Windows. Rendering - // it under Firefox/WebKit would just produce slightly different output for - // no benefit, so skip those projects. - test.skip(({ browserName }) => browserName !== "chromium", "Chromium-only canvas test"); - - for (const sample of SAMPLES) { - test(`renders count "${sample.name}"`, { tag: "@screenshot" }, async ({ page }) => { - await page.goto("about:blank"); - await page.addScriptTag({ content: bundledJs }); - - // Run the real BadgeOverlayRenderer inside a real Chromium canvas - // and return the PNG bytes as base64 so we can write them to disk. - const base64 = await page.evaluate( - async ({ value, bgColor }) => { - const renderer = new ( - window as unknown as { - FaviconModule: { - BadgeOverlayRenderer: new () => { - render: (v: number | string, c?: string) => Promise; - }; - }; - } - ).FaviconModule.BadgeOverlayRenderer(); - const buf = await renderer.render(value, bgColor); - if (!buf) return null; - const bytes = new Uint8Array(buf); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); - return btoa(binary); - }, - { value: sample.value, bgColor: sample.bgColor }, - ); - - expect(base64, "renderer should produce a PNG for non-zero counts").not.toBeNull(); - - const png = Buffer.from(base64!, "base64"); - - // Write the PNG to disk for visual inspection. Output lives under - // playwright/test-results/favicon-badges/ so it survives a normal - // test run and can be opened directly. - await fs.writeFile(path.join(OUT_DIR, `badge-${sample.name}.png`), png); - - // Render the badge into a fixed-size we can screenshot, - // letting us catch visual regressions via Playwright's snapshot - // diffing. The img is scaled up so small pixel differences are - // easier to eyeball when a snapshot fails. - await page.setContent(` - - - - `); - await expect(page.locator("#badge")).toHaveScreenshot(`badge-${sample.name}.png`); - }); - } - - test("renders nothing when count is 0", async ({ page, browserName }) => { - test.skip(browserName !== "chromium", "Chromium-only canvas test"); - await page.goto("about:blank"); - await page.addScriptTag({ content: bundledJs }); - - const buf = await page.evaluate(async () => { - const renderer = new ( - window as unknown as { - FaviconModule: { - BadgeOverlayRenderer: new () => { render: (v: number) => Promise }; - }; - } - ).FaviconModule.BadgeOverlayRenderer(); - return await renderer.render(0); - }); - - expect(buf).toBeNull(); - }); -}); diff --git a/apps/web/playwright/e2e/favicon/favicon.spec.ts b/apps/web/playwright/e2e/favicon/favicon.spec.ts deleted file mode 100644 index 536a733dc6a..00000000000 --- a/apps/web/playwright/e2e/favicon/favicon.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -/* - * Tests for Favicon (the browser-tab favicon with a badge drawn on top of the - * Element logo). - * - * Approach: - * 1. Open a stub page with in real Chromium. - * 2. Bundle favicon.ts with esbuild and inject it into the page. - * 3. Set the link's href to the real Element favicon, then call new Favicon().badge(count). - * 4. The class rewrites the link's href asynchronously, so poll until it changes, - * then read the badged PNG out as a data URI and write it to disk. - * 5. Display the PNG scaled-up in an and snapshot it for regression diffs. - */ - -import { test, expect } from "@playwright/test"; -import { build } from "esbuild"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const FAVICON_SRC = path.resolve(__dirname, "../../../src/favicon.ts"); -const OUT_DIR = path.resolve(__dirname, "../../test-results/favicons"); -// Use the real Element favicon that ships with the app so the test exercises -// the same image (and image dimensions) the Favicon class sees in production. -const BASE_FAVICON = path.resolve(__dirname, "../../../res/vector-icons/144.png"); - -// Coverage for the over-baseImage path used by the in-browser tab favicon. -// We pick representative counts that hit each branch of circle() (single -// digit, pill at 2 digits, narrower pill at 3+ digits, "Nk+" abbreviation). -const SAMPLES = [ - { name: "1", value: 1 }, - { name: "10", value: 10 }, - { name: "99", value: 99 }, - { name: "100", value: 100 }, - { name: "1000", value: 1000 }, -]; - -let bundledJs: string; -let baseFaviconDataUri: string; - -test.beforeAll(async () => { - const result = await build({ - entryPoints: [FAVICON_SRC], - bundle: true, - format: "iife", - globalName: "FaviconModule", - write: false, - target: "es2020", - platform: "browser", - }); - bundledJs = result.outputFiles[0].text; - // Read the real Element favicon and inline it as a data URI so the test - // doesn't depend on a running webserver to serve it. - const baseBytes = await fs.readFile(BASE_FAVICON); - baseFaviconDataUri = `data:image/png;base64,${baseBytes.toString("base64")}`; - await fs.mkdir(OUT_DIR, { recursive: true }); -}); - -test.describe("favicon Favicon (over baseImage)", () => { - test.skip(({ browserName }) => browserName !== "chromium", "Chromium-only canvas test"); - - for (const sample of SAMPLES) { - test(`badges favicon with "${sample.name}"`, { tag: "@screenshot" }, async ({ page }) => { - // Stand up a minimal page with a recognisable base favicon so the - // Favicon class has something to draw on top of. We generate the - // base image in-browser so the test isn't dependent on any - // checked-in image file. - await page.setContent(` - - - - - - - - `); - await page.addScriptTag({ content: bundledJs }); - - // Set the base favicon and trigger badging. The Favicon class updates - // the link's href asynchronously (it waits for the base image to load - // and then defers the write via setTimeout), so we poll for the change - // below rather than reading the href here. - await page.evaluate( - ({ value, baseUri }) => { - const link = document.getElementById("favicon-link") as HTMLLinkElement; - link.setAttribute("href", baseUri); - - // Instantiate the real Favicon class. It reads - // from the document, loads the href into an internal image, - // and rewrites the link's href when badge() runs. - const FaviconCtor = ( - window as unknown as { - FaviconModule: { default: new () => { badge: (n: number | string) => void } }; - } - ).FaviconModule.default; - new FaviconCtor().badge(value); - }, - { value: sample.value, baseUri: baseFaviconDataUri }, - ); - - // Wait for the Favicon class to finish rewriting the link's href. - const linkLocator = page.locator("#favicon-link"); - await expect - .poll(() => linkLocator.getAttribute("href")) - .not.toBe(baseFaviconDataUri); - const dataUri = await linkLocator.getAttribute("href"); - - expect(dataUri).toBeTruthy(); - expect(dataUri!.startsWith("data:image/png;base64,")).toBe(true); - - const base64 = dataUri!.split(",")[1]; - const png = Buffer.from(base64, "base64"); - - // Write the badged favicon PNG to disk for visual inspection. - await fs.writeFile(path.join(OUT_DIR, `favicon-${sample.name}.png`), png); - - // Render it scaled-up so the snapshot is easier to eyeball when - // a diff fails. - await page.setContent(` - - - - `); - await expect(page.locator("#fav")).toHaveScreenshot(`favicon-${sample.name}.png`); - }); - } - - test("badge(0) clears the badge and restores the base image", async ({ page, browserName }) => { - test.skip(browserName !== "chromium", "Chromium-only canvas test"); - - await page.setContent(` - - - - - - - - `); - await page.addScriptTag({ content: bundledJs }); - - // Stash a Favicon instance on the window so we can drive it across - // multiple evaluate() calls between Playwright-side waits. - await page.evaluate((baseUri) => { - const link = document.getElementById("favicon-link") as HTMLLinkElement; - link.setAttribute("href", baseUri); - const FaviconCtor = ( - window as unknown as { - FaviconModule: { default: new () => { badge: (n: number) => void } }; - } - ).FaviconModule.default; - (window as unknown as { __fav: { badge: (n: number) => void } }).__fav = new FaviconCtor(); - }, baseFaviconDataUri); - - const linkLocator = page.locator("#favicon-link"); - - // Badge the favicon with 5 and wait for the href to update. - await page.evaluate(() => { - (window as unknown as { __fav: { badge: (n: number) => void } }).__fav.badge(5); - }); - await expect.poll(() => linkLocator.getAttribute("href")).not.toBe(baseFaviconDataUri); - const withBadge = (await linkLocator.getAttribute("href"))!; - - // Clear with badge(0); the href should update again to a different value. - await page.evaluate(() => { - (window as unknown as { __fav: { badge: (n: number) => void } }).__fav.badge(0); - }); - await expect.poll(() => linkLocator.getAttribute("href")).not.toBe(withBadge); - const cleared = (await linkLocator.getAttribute("href"))!; - - // Badging produces a different image; clearing produces a different - // image again. We don't compare cleared to the original because the - // re-encode through canvas may produce different bytes than the - // original PNG. - expect(withBadge).not.toEqual(cleared); - expect(withBadge).toMatch(/^data:image\/png;base64,/); - expect(cleared).toMatch(/^data:image\/png;base64,/); - }); -}); diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..6a573b608d85e1cdf70f73efeded6ef8addfb67d GIT binary patch literal 408 zcmV;J0cZY+P)+QB= zYHCO^k^u5PFO%}YVwJgUHia~mE-sqf-MuXVJc+cDg5^=Bdw2+U#jj!|qW)0VJ(!6o z#xN)%+G7~}iugW;!G0{*M*bh-yw6T7|Mu2n)qyW_oCG)~o#3mR1^O2pT+v*j7okD7 zL$hGh{o~|;&jWFpWEf{OA}y>KUg!NjJ9C^=`~Lu_p;sQ0a&58z0000vy@AbH3m2Irngn zAqEFZ2DTNR1BD=b1sp4UdIUeCd^5e&!4h~2yaFU{9KZwiUD&Z<7#alPFTk?|VI1FG zcxl5>n3Q`r6&uS!GMQO=DuraT(MZLDH{qKd@Ep)aI<4KqLv?F4bwC}^*4mnS-L9&t z4$jW>Dwmt)#&xj8z&0QcDixMmEixM$GsPe7?a{ARX%-5k^LgesH^aUJwnX6tfLp8a zr(EV=wHk`e+#L5xCESw}#@E-3E-$%XE=T=W1Xu#lexFgJf%bY~Ru&c}qi?svGd7F7 z0Gc#v|Mqs$Yc3a_ey0Tyo_YCq>Ducr>9X$Q-H5Y1^}JiUFO%< zDYV=8H#hVS4x;HJ^9vpu_yMGnOpIlbT3lq@Y(~SL4^Jfc+xvHQp@TtOjDe3uI2QmH zc7ShpnZ$71f!7c$jSWK=Ruw)lS(qev2HyXL)u%8DI+;ZsY=JFch1qpJ^5Jt4&ZGSQ XuNR~3p`jSY00000NkvXXu0mjf(C+Nf literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..dd34077f3a0df65f13f1cea9094051fe3dbcb700 GIT binary patch literal 579 zcmV-J0=)f+P)>xgjBFZB zClexn0BaM`HkvS4CLsI=5`%6mOcJ#O>Z`4dNMlk!^XjAR>v8BSDUA&Mrkk7Z`|kPf zIY-e48^#qp61WFk_peL9mcW}E@VS?-x`zT78eRZT0Nrow!vmIGn75#DZs3dG0(UME z_Tz&K4=iYCI?ufvKn!r#fad_(hH>DrG7Ms?tEf;2fGd-e#Mah`EG&Rh=tzXCnGDff zj?mZ`3Q`5IC_?7v^t3b%4n#zxXc+Qscv#wwBS*8d(#YrKXl6!EvRV1Fu_3j^MftnE zEjEY^OW5$GXqr+_~NPPAZlq?PgPKt;L2<8i~IA8M7$1V%?zHgf z|9TaOM)7Jj@Vrhm(?l6YZ`>vI6_N@*0)YYM0KjX*E$z?t@_=F#ydH(T25?~>ct3!C zs|L@&Um6PpdMO#p5Z{pQs!8QF4L?If4IJQE2C`w9HVqBYiJ?uu3l!fNOz%ab`zHjE6 zr)YqI0R;;J6F_fp{2e$D`1BG^YWb?V$$_QeJ@5w5jm81Wz-9@SZ1A280`V8%Rfw<= zUrR7$gQxM!tu-MA7`NapfHp7yyjGS)=gtl)8b!robh$1n9uIX;kjjCr6)ZfPnbF;u z4A1B0Kx>FZh$oY%mKIta2Wx1^7bOxz`}?aN5e@TNAbIb){FgSP zj>C)H-CD{d25t?=WlxWYh+J5fTqF`AB2q3CB)_^U1|+|-B6oXx^5^btO?xVZ?&!eFW_dh2LyASb!$T@pR{(;6)36S{wY5pnb)~SeAqM2f$cQ`~ z9f^VJ$|&QHaPhwfSdVw=92^D5K%_}=xdMYa_}iP!mR9hw53(Ad1WQ1=3H?DHmLUj@ z4PFVR1wP;hCbVq@>%U=o7#@R8>X8Fm!#praeVq3z@VO7Nwfw(b!>dQ!DF}T40000< KMNUMnLSTZtSm0Oy literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-12-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-12-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..6153ef91be9d7b5279b9fa4fd25238d7f80cc31b GIT binary patch literal 536 zcmV+z0_XjSP)D%_l=n?s9GTDKtKwcn_?%EVu4s7moqcs7K$z~Tf@m@2%9Fs z;b97<=?{1w0Hob>cZ9<{+1esFI7l`g$38st)1rY)yMX?U4T6yfxkLgfl?Xa+h+1iqNp&(t(ml~M5>~~49+utu?s}%>Wt;zY)l5`wLL_~hC zt|kfO7+$A;wjoR$joDcd5eYm`9Lo|LSzf@Gt-! z$3c8Qe%ERcMHs!kn59xe%@{Ce2t3le8lIj4aARZyWmy1N6BA&>H8KMOI=zww?&y}w z%}vDj$qWv1duIoKZ;$-c)W5Oiayq?e0HRw0*QGH%P3U=8J}YBqJc zC>v_PeXc?E^k9ySfl>snOY!UsfNw)X1oe7CJIDf`^#s{skz0$45Jmi2SU`NA{LDzrRoDdAjtCCOj@fCq1Sbm=?TH z@D#{gZ)z8zg16`JybPUGfsP{>HDC^?@c%ilNAU3;eAW4X0e+M$Z)I+rPXGV_07*qo IM6N<$g5~rO8UO$Q literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-99-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-99-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..3156198650fa941b493ef70ecc47cb38cbd1eb64 GIT binary patch literal 635 zcmV->0)+jEP)(=r;Ef8g_icLUM}~XdoJ&} z1~*{AJp-Nz9s%9eat2rtyuSlq>idnWM+yQayat{F=1t)$Jm8%T(_wIG0#))y;K3h= zH?eBNlQ1|Ym2rz#WM4-IMlg7-+wl8o=yxv)D?6FgNi3$adWem}Q*dvS9)%7sF0jIX^Nv!#X7?k-5RRF>hsQO&|zVDcYRSP2&_^}q`gz=mny z^MCMHj^HI!k0uO`4I`>J@%}>e3|Kmbkv=F@om@u>!X`WghPZytpUUu|7rxf_{|1N9 VA<{3C8T9}F002ovPDHLkV1kB07&ZU^ literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-999-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-999-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..2d2ac080fd2437e5333f1ad699bbfeef9ecaae29 GIT binary patch literal 643 zcmV-}0(||6P)e2a(v9+xjC}y>lC%M;i;>`TwLUMW(K{r z6{YI{ofvSm-_<4d!GUDAwnQ99l1)vL-PjN-9v3^4k>Am%9Q5^xolc7tkBf-NSFcwL zI8+84>MbqTRdF%S{ysIEn|!OSrKqC=b72AB$OzfBHL@!!h;5@>E-GU&_NuD^4l&@< z_fSaebXrdK_T{dPTY* zaU=hbW=Od#^4A@rou3I2q_?nGC_t%{CN&y1HjR5W*Usl*xrFT&YPD#y>%#dAosMm% zsi0v7EEZw40)X`z%4O(w;c_upzi;2d6Vi7Abeu?JzrRNYX(gm%s^B=V+YyH6-D>7R zNQYFxb)%EP^rT+T68K45==CD0}6lL1gDDvRK#xBO$ed zCoy2Voo<-pz_c1!cO*Qnxx2vjL?0#00000NkvXXu0mjf2j#5O literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-1-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-1-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..dabab8adb475bf291b632e7ecad28d1a152fc3fd GIT binary patch literal 8322 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

H>ZMcIOAE>Yhhcx7>YMMVe!nGKwQXF(8;5=@)_x z27q$(k(io~4s;+!q|K?_`!jcFX+qC$jnr}X#^t>9UaR&+(Lm?nT zmwjX*x+59Cx`OemE4by6mw4sWF+6+riL4vmH|e^SR0tT1QbH6h{cT<>s;yPJ)-ThO zXobQQIpuSC}nw%qLT$)hye zl7&i$?j6wsH99tz^g6Ot@z|R;v-1Z(V9V&y)X$%PSUf4<#8n&k$4~#j++Y8lKHF;& zt3)c45Cyg+3pMG`1IW$v70D7sf$8VEe#f==e*OIUY#B3#@Vt2(&iCS2|`74Z|$L&_t$p@}iE-`NhK+alpjc*|m!u7hJ%OnKO~QcW1Uy zU5uQDD8Ib_IsP!`2?}fCkEU#LLTLg)k%p}b(K0&~RWwEM{iNiW@4Rk(^%dJDOrZXS z7c$zOuFj+tpYi0?4>0_zZSg;(YoJu2riAJxGJLcoCLNCL)|=ykz4yHqeeXTCeeZi% z8xA@-a+nNITpi+1H$KYv)fI7-7eb(f)=LwL@hZcsFGoo(C?39VyX1u%dF0l|nY5z)JA0KQQOIKrB^Z^-<+}p98D35+90Wk@lTX-j;f1Ki#*DQg z8xW?6F=!B4em+`W9>VKIMWe{NI^>=`*cBD1SnAL1c`TcI?))29-FG!>j_luhK}RA` zqYMxwnOq)Q(&3n3F+*|+u(xbs`hotxISw1!?( zg#U~)@K2qJ@1&D31`I$Ltw#tb+s6L-YoaSw(71Rp4T~0~tOxf+Egqcn1eZT}4d3)_ z<1G3mTouR zNbZFfBE0c$HweSP95MuR$PfaTU51LqsDJiZ_S}Cz(UmKcZdd-EdhY+_|8nW0KO^F8 zVSlnoGx-_c3DAV#gl>Ja$?pFBDJc3h$t7E<)sW0b61N#L@}T#2V8 zgvUeR(n~3M?>&0_`OoM*l0WV=Xwy#q^G|JGKd%9`WM~zpP~_?!j{2m6Jr6vP5u5(f zQj7ruGGZ%UJVzf*$;y=kueyqqyHGTZyeqDtZ>ziCtiG$CQ-6{dD=y7l$6ltt+((`o!WlkHV{hj z%ln_hci@doLI7&3rVwk{CpMM_jxN%9<(|bn^7ya{q6SumCP^{Gi+J_=T0`f(oocq@W8L_1WgA~u3 z*XBwllL)_`p3gpumY?5#;S{3f<V6YjLlnzAzNO$TTCFpfBag0eF5uDHTgow+k+U<@3Hy)nKMvn_GK zg7;ZC^;l5U3n8lv!_=x}MAax=3*e+jK)rX=aN^4K2|X+Pe)_Iilj0U-j=U?ba6OT) z`sq&zJ^E;?@7tRUT{eIBOQWZ@bM^d-mtpH5K&RR?X(#-FF+N zR;^V{m>pJvZRz|5PhPqftt*D33gNn!2tE2}o8tLS*+(9s?oWSmRe#=(ew0*iEtF#N z(zPh4teS9ZSVIV<;47h}&zU%(B~NA-z&m!VD~F-nv&VJsu=}>#Pz??37u<{9rw_gp z5|Ov18*duWXyh(xnO6YJB_c_6zNY zcg&ciJ=2rk{j@ji6;P<&w+X@5>DUt+;U-Y~5W;)>@d%G6?((r!tM;9?b&FG3iS^}| z@jsyJcu!0&aaU1QXaF>y3_kPvsE~Y|jynx7!BEo<_01 zYV&5wgD{UgGAVmNk4?~LUzej0%TzRGr*a-NtfFi7K9G3OcW$LR*gQY)#cL)-%Zobhe`R?CGk!424iua?aL71mw1z2O^;4J4&f7 zy`dH%UKM6)!axgmyU6%(2%!}exGFEJlh5lY!(rDaRg(>`RnYsOu13H!sT#)oS#)J) zDslUZk@fW{v8!CxqbDDqoqLdgLiOQyu$=XzU^4BEb9UD{kjBDHuu;swV{kinQ5>g{lU;ieydMP}BZ2TnMEJbvw4>I_Q39QMW|f zmet5wcNs{u!uYPaYO{_&z0!+61}=b;s;^Ia2-Bd_l`FEo^yVkA?B|6q zzGdgvM@;^bS_J%Xt(5dL3 zT-ThfSf76Cs(i8na@&K&T(++TzUTuD3GJqZr_7$OEbEbYR%Po4_esP=))RG^6w8z3 zNm0+}_Af2opV->9N!bHdLlT!BhxLY^tEjc2JqP2uWG;8jVxr5JyV_;deH5L z8(i7wcGFF+wlQQ~o$H?QpgQ1{=6|OwlS17)4Yv`AJx&Ww#-%5Wq$WQz7mRc!R4hjL zsi#~$Q*it3_)b1KuCl&KlgM|Ol(YVsXHbzy`vqHPPY&$M#h1EN9A#y)uDbqO5s5u@ zpolC(XW^9OZj8xRLXSP>s=V-c=(T7Of$x96&2oWDFQwOl1+F(?<4qyl27m7Z`_jUu zHYM52vRFu~|FMc%Pai0v7tT07rKYll*vB6edFw4#&uD=F#q;LTclBxt=FB1IoOAF` znL^Gv=a7HfZS?*4V~YR&ceFsjRlSjA%aXnhQ^KG3|9|RZPD{%o;qtN{1tYl%pXXw= z_jvP{1AFn&sDX@Kn|PZ2Y-RVHIrMqw9oNr!jy@XC(MPAO9W{5{k!%$o4BhMhX(m1C z=}XHZq3Zg}RW$ZEUUT2-?6J#}OD~(PM3ye4;pLY*v<;1~y+-33ZzNTRf`b&8zWWVk zmc{DcuK#zU_FN1dG>sMGhVuSccbk5P3cIepI_dC}JS+m?9{!11q*)?lcDuT!6wDfvL?nnwv zx#b}t?F&#;u03WD3#N9>6_M1+F1xa@(4RGxy024DT$z>QFdK%1$!QRFxjdqqi{`YZ)WtC zw(zuDKl3Ty^svW#=#HOX(j|v7Xu4Eo+r)`PKm0H)^+!MYh;1jIOx+Vtq@uc6Bcavk z`;1@nx2yD;aH#aA>sA~+@Zu+DOzfID@PO#5Rct-}cy`U2h4tBIDXG)?{Bx?VzM8Ga z9hbDfXD<&7hBj|<>Ci5q=S#mDc;B7#%1>JPMgNX$M~5Q>{!>mN=e+aqPn+f{33zPX zIvN)*rs2gGX%lg#{;2OS@tQwKRUWCU7UaVU~0VLflkr`HKRbQfV+WP=wxj> z#8JQlgWzTXt~{Nc@|RQr(^S*0?-3o{mM*{!0ap#$FUH3Woni<(kU_9mgQJ0Y9o?2g z839kn;HX5!v$u}U%|J0!8r=f0Hy=IJGxDs zi0uMimGDXqytID|bE?T&0UbyM9Iap~FdjGt7!GuW)F6(6^#WE4SSjF*0Zso-*`7vQ z95Mnrkj>za!6*SEWYfJ?B??Mt>Zy7tAxtIcCWSBzsd7-NGl|UsLV)-m=>z}(03Jz1 zK~y6kq(BPTrJz#476sb`Yy-aV!P<28FzxYwE5Rp4MRz|000000NkvXXu0mjf(ezwL literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-100-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-100-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..24794f715f0592a15168da4df14a92a139c6a868 GIT binary patch literal 8520 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 literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/badge-overlay-test.ts b/apps/web/test/unit-tests/badge-overlay-test.ts new file mode 100644 index 00000000000..451d65d13bb --- /dev/null +++ b/apps/web/test/unit-tests/badge-overlay-test.ts @@ -0,0 +1,106 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* + * Unit-test alternative to playwright/e2e/favicon/badge-overlay.spec.ts. + * + * Approach: + * 1. Run under the existing Jest + jsdom setup, transpiled by the same babel + * pipeline as production (avoids the esbuild-vs-babel divergence the + * Playwright test has). + * 2. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by + * node-canvas so real pixels come out of toBlob(). + * 3. Drive the real BadgeOverlayRenderer and compare the PNG bytes against a + * stored baseline via jest-image-snapshot. + */ + +import { createCanvas, type Canvas } from "canvas"; +import { toMatchImageSnapshot } from "jest-image-snapshot"; + +import { BadgeOverlayRenderer } from "../../src/favicon"; + +expect.extend({ toMatchImageSnapshot }); + +// jest-canvas-mock (configured globally via setupFiles) replaces +// HTMLCanvasElement with stubs that record calls but don't rasterise. For +// pixel-accurate output we re-patch the prototype to delegate to node-canvas. +beforeAll(() => { + const backing = new WeakMap(); + const get = (el: HTMLCanvasElement): Canvas => { + let c = backing.get(el); + if (!c) { + c = createCanvas(1, 1); + backing.set(el, c); + } + return c; + }; + + // Mirror width/height onto the node-canvas. Setting either on a real + // HTMLCanvasElement resizes and clears it; node-canvas behaves the same + // when you assign to its width/height. + Object.defineProperty(HTMLCanvasElement.prototype, "width", { + configurable: true, + get(): number { + return get(this).width; + }, + set(v: number): void { + get(this).width = v; + }, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "height", { + configurable: true, + get(): number { + return get(this).height; + }, + set(v: number): void { + get(this).height = v; + }, + }); + + HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any { + return get(this).getContext(type as "2d"); + }; + HTMLCanvasElement.prototype.toBlob = function ( + this: HTMLCanvasElement, + cb: BlobCallback, + type = "image/png", + ): void { + const buf = get(this).toBuffer("image/png"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cb(new Blob([buf as any], { type })); + }; +}); + +const SAMPLES: Array<{ name: string; value: number | string; bgColor?: string }> = [ + { name: "1", value: 1 }, + { name: "9", value: 9 }, + { name: "10", value: 10 }, + { name: "12", value: 12 }, + { name: "99", value: 99 }, + { name: "100", value: 100 }, + { name: "999", value: 999 }, + { name: "1000", value: 1000 }, + { name: "error", value: "×", bgColor: "#f00" }, +]; + +describe("BadgeOverlayRenderer", () => { + it.each(SAMPLES)("renders count $name", async ({ value, bgColor }) => { + const renderer = new BadgeOverlayRenderer(); + const buf = await renderer.render(value, bgColor); + expect(buf).not.toBeNull(); + expect(Buffer.from(buf!)).toMatchImageSnapshot(); + }); + + it("returns null when count is 0", async () => { + const renderer = new BadgeOverlayRenderer(); + expect(await renderer.render(0)).toBeNull(); + }); +}); diff --git a/apps/web/test/unit-tests/favicon-image-test.ts b/apps/web/test/unit-tests/favicon-image-test.ts new file mode 100644 index 00000000000..9383c866e6e --- /dev/null +++ b/apps/web/test/unit-tests/favicon-image-test.ts @@ -0,0 +1,150 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* + * Unit-test alternative to playwright/e2e/favicon/favicon.spec.ts. + * + * Approach: + * 1. Run under the existing Jest + jsdom setup (same babel pipeline as prod). + * 2. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by + * node-canvas so real pixels come out. + * 3. Pre-load the real Element favicon (apps/web/res/vector-icons/144.png) + * as a node-canvas Image, then have document.createElement("img") return + * it so Favicon's internal baseImage is already populated. + * 4. Drive the real Favicon class, run timers, then read the badged PNG out + * of the 's href and compare via jest-image-snapshot. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createCanvas, loadImage, type Canvas, type Image } from "canvas"; +import { toMatchImageSnapshot } from "jest-image-snapshot"; + +import Favicon from "../../src/favicon"; + +expect.extend({ toMatchImageSnapshot }); +jest.useFakeTimers(); + +const BASE_FAVICON = path.resolve(__dirname, "../../res/vector-icons/144.png"); +let baseImage: Image; + +beforeAll(async () => { + baseImage = await loadImage(await fs.readFile(BASE_FAVICON)); + + // Replace jest-canvas-mock's HTMLCanvasElement stubs with shims backed by + // node-canvas so toDataURL produces real PNG bytes. + const backing = new WeakMap(); + const get = (el: HTMLCanvasElement): Canvas => { + let c = backing.get(el); + if (!c) { + c = createCanvas(1, 1); + backing.set(el, c); + } + return c; + }; + + Object.defineProperty(HTMLCanvasElement.prototype, "width", { + configurable: true, + get(): number { + return get(this).width; + }, + set(v: number): void { + get(this).width = v; + }, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "height", { + configurable: true, + get(): number { + return get(this).height; + }, + set(v: number): void { + get(this).height = v; + }, + }); + + HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any { + return get(this).getContext(type as "2d"); + }; + HTMLCanvasElement.prototype.toDataURL = function (this: HTMLCanvasElement, type = "image/png"): string { + return "data:" + type + ";base64," + get(this).toBuffer("image/png").toString("base64"); + }; +}); + +beforeEach(() => { + // Reset the document between tests so each starts from a clean slate. + document.getElementsByTagName("head")[0]?.remove(); + const head = document.createElement("head"); + document.documentElement.prepend(head); + + // Add a for Favicon to discover. + const link = document.createElement("link"); + link.rel = "icon"; + link.href = "favicon.png"; + document.head.appendChild(link); + + // Intercept Favicon's internal document.createElement("img") and return + // the pre-loaded node-canvas Image so its bytes are ready for drawImage. + // We add the jsdom-flavoured shims (setAttribute, onload trigger) that + // Favicon relies on. + const originalCreateElement = document.createElement.bind(document); + jest.spyOn(document, "createElement").mockImplementation((tag: string) => { + if (tag !== "img") return originalCreateElement(tag); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const img = baseImage as any; + img.setAttribute = (name: string, _value: string): void => { + if (name === "src" && typeof img.onload === "function") { + // src is already loaded; fire the next-tick onload Favicon expects. + queueMicrotask(() => img.onload?.()); + } + }; + return img; + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const SAMPLES = [ + { name: "1", value: 1 }, + { name: "10", value: 10 }, + { name: "99", value: 99 }, + { name: "100", value: 100 }, + { name: "1000", value: 1000 }, +]; + +const linkHref = (): string => document.querySelector("link[rel='icon']")!.getAttribute("href")!; + +describe("Favicon (over base image)", () => { + it.each(SAMPLES)("badges favicon with $name", async ({ value }) => { + const fav = new Favicon(); + fav.badge(value); + await jest.runAllTimersAsync(); + + const href = linkHref(); + expect(href).toMatch(/^data:image\/png;base64,/); + const png = Buffer.from(href.split(",")[1], "base64"); + expect(png).toMatchImageSnapshot(); + }); + + it("badge(0) clears the badge and rewrites the link", async () => { + const fav = new Favicon(); + fav.badge(5); + await jest.runAllTimersAsync(); + const withBadge = linkHref(); + + fav.badge(0); + await jest.runAllTimersAsync(); + const cleared = linkHref(); + + expect(withBadge).not.toEqual(cleared); + }); +}); diff --git a/package.json b/package.json index e55184abf81..20646b8e794 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "canvas", "matrix-js-sdk" ], "ignoredBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0478e7d0dc..f277ffbea6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,7 +327,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) vitest-sonar-reporter: specifier: 'catalog:' version: 3.0.0(vitest@4.1.7) @@ -731,6 +731,9 @@ importers: blob-polyfill: specifier: ^9.0.0 version: 9.0.20240710 + canvas: + specifier: ^3.0.0 + version: 3.2.3 copy-webpack-plugin: specifier: ^14.0.0 version: 14.0.0(webpack@5.107.1) @@ -805,10 +808,13 @@ importers: version: 2.5.2 jest-environment-jsdom: specifier: ^30.2.0 - version: 30.4.1 + version: 30.4.1(canvas@3.2.3) jest-fixed-jsdom: specifier: ^0.0.11 - version: 0.0.11(patch_hash=b4bc876eca343b57d231c1cf9817ddb13ff04503ece7794d1690f342f6289ad3)(jest-environment-jsdom@30.4.1) + version: 0.0.11(patch_hash=b4bc876eca343b57d231c1cf9817ddb13ff04503ece7794d1690f342f6289ad3)(jest-environment-jsdom@30.4.1(canvas@3.2.3)) + jest-image-snapshot: + specifier: ^6.5.1 + version: 6.5.2(jest@30.4.2(@types/node@18.19.130)(babel-plugin-macros@3.1.0)) jest-mock: specifier: ^30.0.0 version: 30.4.1 @@ -977,7 +983,7 @@ importers: version: 8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) vitest-sonar-reporter: specifier: 'catalog:' version: 3.0.0(vitest@4.1.7) @@ -1240,7 +1246,7 @@ importers: version: 0.28.0(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) vitest: specifier: 'catalog:' - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) vitest-sonar-reporter: specifier: 'catalog:' version: 3.0.0(vitest@4.1.7) @@ -1252,7 +1258,7 @@ importers: version: 4.1.7(@vitest/browser@4.1.7)(vitest@4.1.7) vitest: specifier: 'catalog:' - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) vitest-sonar-reporter: specifier: 'catalog:' version: 3.0.0(vitest@4.1.7) @@ -7006,6 +7012,10 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + canvas@3.2.3: + resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} + engines: {node: ^18.12.0 || >= 20.9.0} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -7743,6 +7753,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -8452,6 +8466,10 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -8762,6 +8780,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stdin@5.0.1: + resolution: {integrity: sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA==} + engines: {node: '>=0.12.0'} + get-stream@5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -8777,6 +8799,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-markdown-css@5.9.0: resolution: {integrity: sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==} engines: {node: '>=10'} @@ -8846,6 +8871,9 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -9575,6 +9603,15 @@ packages: resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-image-snapshot@6.5.2: + resolution: {integrity: sha512-frenWThr5ddnnokcX5N4gwi41hA5TiUOdhv/JoGcJrOaktHjrk4/7XbiHKW52lgKX+vei6QkRlgM7fkYQ15nPg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: '>=20 <31' + peerDependenciesMeta: + jest: + optional: true + jest-leak-detector@30.4.1: resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -10412,6 +10449,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -10445,6 +10485,9 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -10847,6 +10890,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + pixelmatch@7.2.0: resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} hasBin: true @@ -10898,10 +10945,18 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -11368,6 +11423,12 @@ packages: preact@10.28.3: resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -11537,6 +11598,10 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + re-resizable@6.11.2: resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} peerDependencies: @@ -12124,6 +12189,12 @@ packages: resolution: {integrity: sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==} engines: {node: ^20.17.0 || >=22.9.0} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -12402,6 +12473,10 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -12800,6 +12875,9 @@ packages: resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} engines: {node: ^20.17.0 || >=22.9.0} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} @@ -15434,7 +15512,7 @@ snapshots: '@fetch-mock/vitest@0.2.18(vitest@4.1.7)': dependencies: fetch-mock: 12.6.0 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) '@figspec/components@2.1.0': {} @@ -15628,7 +15706,7 @@ snapshots: '@jest/diff-sequences@30.4.0': {} - '@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))': + '@jest/environment-jsdom-abstract@30.4.1(canvas@3.2.3)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))': dependencies: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 @@ -15637,7 +15715,9 @@ snapshots: '@types/node': 18.19.130 jest-mock: 30.4.1 jest-util: 30.4.1 - jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f) + jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3) + optionalDependencies: + canvas: 3.2.3 '@jest/environment@30.4.1': dependencies: @@ -17962,7 +18042,7 @@ snapshots: '@vitest/browser': 4.1.7(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.7) '@vitest/browser-playwright': 4.1.7(playwright@1.60.0)(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.7) '@vitest/runner': 4.1.7 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - react - react-dom @@ -18947,7 +19027,7 @@ snapshots: '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) playwright: 1.60.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - bufferutil - msw @@ -18963,7 +19043,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -18983,7 +19063,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: '@vitest/browser': 4.1.7(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.7) @@ -20154,6 +20234,11 @@ snapshots: caniuse-lite@1.0.30001793: {} + canvas@3.2.3: + dependencies: + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + ccount@2.0.1: {} chai@5.3.3: @@ -20927,6 +21012,8 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -21851,6 +21938,8 @@ snapshots: exit-x@0.2.2: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} expect@30.4.1: @@ -22231,6 +22320,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stdin@5.0.1: {} + get-stream@5.2.0: dependencies: pump: 3.0.4 @@ -22247,6 +22338,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + github-markdown-css@5.9.0: {} gl-matrix@3.4.4: {} @@ -22333,6 +22426,8 @@ snapshots: globrex@0.1.2: {} + glur@1.1.2: {} + gopd@1.2.0: {} got@11.8.6: @@ -23100,11 +23195,13 @@ snapshots: jest-util: 30.4.1 pretty-format: 30.4.1 - jest-environment-jsdom@30.4.1: + jest-environment-jsdom@30.4.1(canvas@3.2.3): dependencies: '@jest/environment': 30.4.1 - '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)) - jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f) + '@jest/environment-jsdom-abstract': 30.4.1(canvas@3.2.3)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3)) + jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3) + optionalDependencies: + canvas: 3.2.3 transitivePeerDependencies: - bufferutil - supports-color @@ -23120,9 +23217,9 @@ snapshots: jest-util: 30.4.1 jest-validate: 30.4.1 - jest-fixed-jsdom@0.0.11(patch_hash=b4bc876eca343b57d231c1cf9817ddb13ff04503ece7794d1690f342f6289ad3)(jest-environment-jsdom@30.4.1): + jest-fixed-jsdom@0.0.11(patch_hash=b4bc876eca343b57d231c1cf9817ddb13ff04503ece7794d1690f342f6289ad3)(jest-environment-jsdom@30.4.1(canvas@3.2.3)): dependencies: - jest-environment-jsdom: 30.4.1 + jest-environment-jsdom: 30.4.1(canvas@3.2.3) jest-haste-map@30.4.1: dependencies: @@ -23139,6 +23236,18 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-image-snapshot@6.5.2(jest@30.4.2(@types/node@18.19.130)(babel-plugin-macros@3.1.0)): + dependencies: + chalk: 4.1.2 + get-stdin: 5.0.1 + glur: 1.1.2 + lodash: 4.18.1 + pixelmatch: 5.3.0 + pngjs: 3.4.0 + ssim.js: 3.5.0 + optionalDependencies: + jest: 30.4.2(@types/node@18.19.130)(babel-plugin-macros@3.1.0) + jest-leak-detector@30.4.1: dependencies: '@jest/get-type': 30.1.0 @@ -23361,7 +23470,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f): + jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 @@ -23383,6 +23492,8 @@ snapshots: whatwg-url: 14.2.0 ws: 8.21.0 xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 3.2.3 transitivePeerDependencies: - bufferutil - supports-color @@ -24128,6 +24239,8 @@ snapshots: nanoid@3.3.12: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -24152,6 +24265,8 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@7.1.1: {} + node-api-version@0.2.1: dependencies: semver: 7.8.1 @@ -24850,6 +24965,10 @@ snapshots: pirates@4.0.7: {} + pixelmatch@5.3.0: + dependencies: + pngjs: 6.0.0 + pixelmatch@7.2.0: dependencies: pngjs: 7.0.0 @@ -24911,8 +25030,12 @@ snapshots: minimist: 1.2.8 pngjs: 7.0.0 + pngjs@3.4.0: {} + pngjs@5.0.0: {} + pngjs@6.0.0: {} + pngjs@7.0.0: {} points-on-curve@0.2.0: {} @@ -25428,6 +25551,21 @@ snapshots: preact@10.28.3: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 4.31.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@2.8.8: {} @@ -25603,6 +25741,13 @@ snapshots: schema-utils: 3.3.0 webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + re-resizable@6.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -26349,6 +26494,14 @@ snapshots: transitivePeerDependencies: - supports-color + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-update-notifier@2.0.0: dependencies: semver: 7.8.1 @@ -26545,7 +26698,7 @@ snapshots: pathe: 2.0.3 storybook: 10.4.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) type-plus: 8.0.0-beta.8(typescript@6.0.3) - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) vitest-plugin-vis: 5.1.1(@vitest/browser-playwright@4.1.7)(@vitest/browser@4.1.7)(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vitest@4.1.7) transitivePeerDependencies: - '@vitest/browser-playwright' @@ -26718,6 +26871,8 @@ snapshots: strip-indent@4.1.1: {} + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -27187,6 +27342,10 @@ snapshots: transitivePeerDependencies: - supports-color + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tunnel@0.0.6: {} tweetnacl@0.14.5: {} @@ -27639,7 +27798,7 @@ snapshots: rimraf: 6.1.3 ssim.js: 3.5.0 type-plus: 8.0.0-beta.8(typescript@6.0.3) - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: '@vitest/browser-playwright': 4.1.7(playwright@1.60.0)(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.7) transitivePeerDependencies: @@ -27648,9 +27807,9 @@ snapshots: vitest-sonar-reporter@3.0.0(vitest@4.1.7): dependencies: - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.7 '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4)) @@ -27677,7 +27836,7 @@ snapshots: '@types/node': 18.19.130 '@vitest/browser-playwright': 4.1.7(playwright@1.60.0)(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.7) '@vitest/coverage-v8': 4.1.7(@vitest/browser@4.1.7)(vitest@4.1.7) - jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f) + jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f)(canvas@3.2.3) transitivePeerDependencies: - msw From 4b4351d3b361930475f265346114853cd04c081b Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 19:44:50 +0100 Subject: [PATCH 5/8] Fix regression on vertical alignment of the badge text --- apps/web/src/favicon.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/src/favicon.ts b/apps/web/src/favicon.ts index 2f339cc9bd7..46df2996b86 100644 --- a/apps/web/src/favicon.ts +++ b/apps/web/src/favicon.ts @@ -134,7 +134,6 @@ abstract class IconRenderer { const fontSize = Math.floor(opt.h * fontScale) + "px"; this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`; this.context.textAlign = "center"; - this.context.textBaseline = "middle"; if (more) { this.context.moveTo(opt.x + opt.w / 2, opt.y); @@ -157,14 +156,18 @@ abstract class IconRenderer { this.context.stroke(); this.context.fillStyle = params.textColor; + const text = typeof opt.n === "number" && opt.n > 999 + ? (opt.n > 9999 ? 9 : Math.floor(opt.n / 1000)) + "k+" + : "" + opt.n; + // Centre the glyph vertically on the badge using its measured bounding + // box. `actualBoundingBoxAscent` is the distance from the alphabetic + // baseline up to the top of the glyph, so placing the baseline at + // `centre + ascent/2` puts the glyph's visual centre at the badge centre, + // regardless of font size or font metrics. + const metrics = this.context.measureText(text); const textX = Math.floor(opt.x + opt.w / 2); - const textY = Math.floor(opt.y + opt.h / 2); - if (typeof opt.n === "number" && opt.n > 999) { - const count = (opt.n > 9999 ? 9 : Math.floor(opt.n / 1000)) + "k+"; - this.context.fillText(count, textX, textY); - } else { - this.context.fillText("" + opt.n, textX, textY); - } + const textY = Math.floor(opt.y + opt.h / 2 + metrics.actualBoundingBoxAscent / 2); + this.context.fillText(text, textX, textY); this.context.closePath(); } From 3a813c0684f87cefe792214e4510c417886fbd4d Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 19:46:01 +0100 Subject: [PATCH 6/8] Update pnpm-lock.yaml --- pnpm-lock.yaml | 55 +++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f277ffbea6c..5906cb93d3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,13 +742,10 @@ importers: version: 7.1.4(webpack@5.107.1) css-minimizer-webpack-plugin: specifier: ^8.0.0 - version: 8.0.0(esbuild@0.27.4)(lightningcss@1.32.0)(webpack@5.107.1) + version: 8.0.0(lightningcss@1.32.0)(webpack@5.107.1) dotenv: specifier: ^17.0.0 version: 17.4.2 - esbuild: - specifier: 0.27.4 - version: 0.27.4 eslint: specifier: 8.57.1 version: 8.57.1 @@ -895,7 +892,7 @@ importers: version: 6.1.1(stylelint@17.12.0(typescript@6.0.3)) terser-webpack-plugin: specifier: ^5.3.9 - version: 5.6.0(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) + version: 5.6.0(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) testcontainers: specifier: ^11.0.0 version: 11.14.0 @@ -910,7 +907,7 @@ importers: version: 4.3.0 webpack: specifier: ^5.89.0 - version: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + version: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-bundle-analyzer: specifier: ^5.0.0 version: 5.3.0 @@ -17144,7 +17141,7 @@ snapshots: '@principalstudio/html-webpack-inject-preload@1.2.7(html-webpack-plugin@5.6.7(webpack@5.107.1))(webpack@5.107.1)': dependencies: html-webpack-plugin: 5.6.7(webpack@5.107.1) - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': dependencies: @@ -17886,7 +17883,7 @@ snapshots: '@sentry/webpack-plugin@5.3.0(encoding@0.1.13)(webpack@5.107.1)': dependencies: '@sentry/bundler-plugin-core': 5.3.0(encoding@0.1.13) - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) transitivePeerDependencies: - encoding - supports-color @@ -19776,7 +19773,7 @@ snapshots: '@babel/core': 7.29.0 find-up: 5.0.0 optionalDependencies: - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) babel-plugin-const-enum@1.2.0(@babel/core@7.29.0): dependencies: @@ -20492,7 +20489,7 @@ snapshots: schema-utils: 4.3.3 serialize-javascript: 7.0.5 tinyglobby: 0.2.16 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) core-js-compat@3.49.0: dependencies: @@ -20650,9 +20647,9 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.8.1 optionalDependencies: - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) - css-minimizer-webpack-plugin@8.0.0(esbuild@0.27.4)(lightningcss@1.32.0)(webpack@5.107.1): + css-minimizer-webpack-plugin@8.0.0(lightningcss@1.32.0)(webpack@5.107.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 7.1.2(postcss@8.5.15) @@ -20660,9 +20657,8 @@ snapshots: postcss: 8.5.15 schema-utils: 4.3.3 serialize-javascript: 7.0.5 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) optionalDependencies: - esbuild: 0.27.4 lightningcss: 1.32.0 css-prefers-color-scheme@11.0.0(postcss@8.5.15): @@ -22114,7 +22110,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) file-saver@2.0.5: {} @@ -22620,7 +22616,7 @@ snapshots: pretty-error: 4.0.0 tapable: 2.3.3 optionalDependencies: - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) htmlparser2@10.1.0: dependencies: @@ -24100,7 +24096,7 @@ snapshots: dependencies: schema-utils: 4.3.3 tapable: 2.3.3 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) minimalistic-assert@1.0.1: {} @@ -25214,7 +25210,7 @@ snapshots: postcss: 8.5.15 semver: 7.8.1 optionalDependencies: - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) transitivePeerDependencies: - typescript @@ -25739,7 +25735,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) rc@1.2.8: dependencies: @@ -26573,7 +26569,7 @@ snapshots: dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) source-map-support@0.5.13: dependencies: @@ -27139,15 +27135,14 @@ snapshots: postcss: 8.5.15 optional: true - terser-webpack-plugin@5.6.0(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1): + terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) optionalDependencies: - esbuild: 0.27.4 lightningcss: 1.32.0 postcss: 8.5.15 @@ -27934,7 +27929,7 @@ snapshots: import-local: 3.2.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-merge: 6.0.1 optionalDependencies: webpack-bundle-analyzer: 5.3.0 @@ -27949,7 +27944,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) transitivePeerDependencies: - tslib @@ -27984,7 +27979,7 @@ snapshots: webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.107.1) ws: 8.21.0 optionalDependencies: - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-cli: 7.0.2(webpack-bundle-analyzer@5.3.0)(webpack-dev-server@5.2.4)(webpack@5.107.1) transitivePeerDependencies: - bufferutil @@ -28002,7 +27997,7 @@ snapshots: webpack-retry-chunk-load-plugin@3.1.1(webpack@5.107.1): dependencies: prettier: 2.8.8 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-sources@3.5.0: {} @@ -28011,7 +28006,7 @@ snapshots: ejs: 3.1.10 fs: 0.0.1-security underscore: 1.13.8 - webpack: 5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) + webpack: 5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2) webpack-virtual-modules@0.6.2: {} @@ -28055,7 +28050,7 @@ snapshots: - uglify-js optional: true - webpack@5.107.1(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2): + webpack@5.107.1(lightningcss@1.32.0)(postcss@8.5.15)(webpack-cli@7.0.2): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -28077,7 +28072,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.0(esbuild@0.27.4)(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) + terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(postcss@8.5.15)(webpack@5.107.1) watchpack: 2.5.1 webpack-sources: 3.5.0 optionalDependencies: From a9bd41ca0a15b0e59665489f443b989e0e2fe99c Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 27 May 2026 20:16:01 +0100 Subject: [PATCH 7/8] Fix prettier formatting in favicon.ts and image snapshot tests --- apps/web/src/favicon.ts | 7 ++++--- apps/web/test/unit-tests/badge-overlay-test.ts | 9 +++------ apps/web/test/unit-tests/favicon-image-test.ts | 10 +++++----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/web/src/favicon.ts b/apps/web/src/favicon.ts index 46df2996b86..8d60d082f8f 100644 --- a/apps/web/src/favicon.ts +++ b/apps/web/src/favicon.ts @@ -156,9 +156,10 @@ abstract class IconRenderer { this.context.stroke(); this.context.fillStyle = params.textColor; - const text = typeof opt.n === "number" && opt.n > 999 - ? (opt.n > 9999 ? 9 : Math.floor(opt.n / 1000)) + "k+" - : "" + opt.n; + const text = + typeof opt.n === "number" && opt.n > 999 + ? (opt.n > 9999 ? 9 : Math.floor(opt.n / 1000)) + "k+" + : "" + opt.n; // Centre the glyph vertically on the badge using its measured bounding // box. `actualBoundingBoxAscent` is the distance from the alphabetic // baseline up to the top of the glyph, so placing the baseline at diff --git a/apps/web/test/unit-tests/badge-overlay-test.ts b/apps/web/test/unit-tests/badge-overlay-test.ts index 451d65d13bb..158e800caf4 100644 --- a/apps/web/test/unit-tests/badge-overlay-test.ts +++ b/apps/web/test/unit-tests/badge-overlay-test.ts @@ -6,15 +6,12 @@ Please see LICENSE files in the repository root for full details. */ /* - * Unit-test alternative to playwright/e2e/favicon/badge-overlay.spec.ts. + * Tests for BadgeOverlayRenderer (the 16x16 PNG used as the Windows taskbar overlay). * * Approach: - * 1. Run under the existing Jest + jsdom setup, transpiled by the same babel - * pipeline as production (avoids the esbuild-vs-babel divergence the - * Playwright test has). - * 2. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by + * 1. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by * node-canvas so real pixels come out of toBlob(). - * 3. Drive the real BadgeOverlayRenderer and compare the PNG bytes against a + * 2. Drive the real BadgeOverlayRenderer and compare the PNG bytes against a * stored baseline via jest-image-snapshot. */ diff --git a/apps/web/test/unit-tests/favicon-image-test.ts b/apps/web/test/unit-tests/favicon-image-test.ts index 9383c866e6e..331160ec18c 100644 --- a/apps/web/test/unit-tests/favicon-image-test.ts +++ b/apps/web/test/unit-tests/favicon-image-test.ts @@ -6,16 +6,16 @@ Please see LICENSE files in the repository root for full details. */ /* - * Unit-test alternative to playwright/e2e/favicon/favicon.spec.ts. + * Tests for the Favicon class (browser-tab favicon with a badge drawn over + * the Element logo). * * Approach: - * 1. Run under the existing Jest + jsdom setup (same babel pipeline as prod). - * 2. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by + * 1. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by * node-canvas so real pixels come out. - * 3. Pre-load the real Element favicon (apps/web/res/vector-icons/144.png) + * 2. Pre-load the real Element favicon (apps/web/res/vector-icons/144.png) * as a node-canvas Image, then have document.createElement("img") return * it so Favicon's internal baseImage is already populated. - * 4. Drive the real Favicon class, run timers, then read the badged PNG out + * 3. Drive the real Favicon class, run timers, then read the badged PNG out * of the 's href and compare via jest-image-snapshot. */ From bd499a957576b9706d7473c2ea3e8a792db19bf3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 28 May 2026 12:54:11 +0100 Subject: [PATCH 8/8] Clamp Windows overlay badge at "99+" Clamp Windows overlay badge at "99+" The Windows taskbar overlay is only 16x16 px and the platform's own notification badges clamp at "99+", so do the same here rather than trying to fit three-digit counts or "1k+" labels into the tiny canvas. Browser-tab favicons are unaffected and still render larger counts. --- apps/web/package.json | 1 + apps/web/src/favicon.ts | 9 +++++- ...rlay-renderer-renders-count-100-1-snap.png | Bin 579 -> 0 bytes ...renders-count-100-clamped-to-99-1-snap.png | Bin 0 -> 606 bytes ...lay-renderer-renders-count-1000-1-snap.png | Bin 520 -> 0 bytes ...erlay-renderer-renders-count-12-1-snap.png | Bin 536 -> 0 bytes ...rlay-renderer-renders-count-999-1-snap.png | Bin 643 -> 0 bytes ...enders-count-9999-clamped-to-99-1-snap.png | Bin 0 -> 606 bytes .../web/test/unit-tests/badge-overlay-test.ts | 9 +++--- pnpm-lock.yaml | 27 +++++++++++++++--- 10 files changed, 37 insertions(+), 9 deletions(-) delete mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-clamped-to-99-1-snap.png delete mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1000-1-snap.png delete mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-12-1-snap.png delete mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-999-1-snap.png create mode 100644 apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-9999-clamped-to-99-1-snap.png diff --git a/apps/web/package.json b/apps/web/package.json index 142a54f5591..7bec55c542f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -148,6 +148,7 @@ "@types/file-saver": "^2.0.3", "@types/glob-to-regexp": "^0.4.1", "@types/jest": "30.0.0", + "@types/jest-image-snapshot": "^6.4.1", "@types/jitsi-meet": "^2.0.2", "@types/jsrsasign": "^10.5.4", "@types/lodash": "^4.14.168", diff --git a/apps/web/src/favicon.ts b/apps/web/src/favicon.ts index 8d60d082f8f..652f17e41d5 100644 --- a/apps/web/src/favicon.ts +++ b/apps/web/src/favicon.ts @@ -194,7 +194,14 @@ export class BadgeOverlayRenderer extends IconRenderer { return null; } - this.circle(contents, { ...(bgColor ? { bgColor } : undefined) }); + // Windows native notification badges clamp at "99+" + // (https://learn.microsoft.com/en-us/windows/apps/develop/notifications/badges), + // and the overlay canvas is only 16x16, so we follow the same convention here. + // This only affects the Windows taskbar overlay; browser-tab favicons + // (Favicon below) still render larger counts as e.g. "1k+". + const clamped = typeof contents === "number" && contents > 99 ? "99+" : contents; + + this.circle(clamped, { ...(bgColor ? { bgColor } : undefined) }); return new Promise((resolve, reject) => { this.canvas.toBlob( (blob) => { diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-1-snap.png deleted file mode 100644 index dd34077f3a0df65f13f1cea9094051fe3dbcb700..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 579 zcmV-J0=)f+P)>xgjBFZB zClexn0BaM`HkvS4CLsI=5`%6mOcJ#O>Z`4dNMlk!^XjAR>v8BSDUA&Mrkk7Z`|kPf zIY-e48^#qp61WFk_peL9mcW}E@VS?-x`zT78eRZT0Nrow!vmIGn75#DZs3dG0(UME z_Tz&K4=iYCI?ufvKn!r#fad_(hH>DrG7Ms?tEf;2fGd-e#Mah`EG&Rh=tzXCnGDff zj?mZ`3Q`5IC_?7v^t3b%4n#zxXc+Qscv#wwBS*8d(#YrKXl6!EvRV1Fu_3j^MftnE zEjEY^OW5$GXqr+_~NPPAZlq?PgPKt;L2<8i~IA8M7$1V%?zHgf z|9TaOM)7Jj@Vrhm(?l6YZ`>vI6_N@*0)YYM0KjX*E$z?t@_=F#ydH(T25?~>ct3!C zs|L@&Um6P2xC*lrTUCE!vhX%`m}C55I*Abll5Y#U>hR!v`C-t%}5cCB$ZYFyMX=OhrB2^A^YecRcVEK+5{2!J;lrDtu8u9+E7 z|G!9sM|lV;4W_Qn&ns7NFUH0OJ&6SFK!9t@%N(z)&@nMVM>JX|dA+#%`pz}l6ih0C zdoI5pV{?;})m2ES^4UAi?OvOW;QEk zE+>WVZqY%CLqk$cr9_AG^-0s~C48*9y3jlx>{7}3qBdMUA9k^bSk`6be5QSYkOm)t zwifyTz&?YU%IWP`i)1u-6@ZKaFkl*Z-vU2*2v5Knnjn}4L0GY=4za*O9#w=p8X#fBK07*qoM6N<$g5S;%;{X5v literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1000-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1000-1-snap.png deleted file mode 100644 index ad8d2ad7f926b9f1c5a3d1c72931c49f3d7ceb58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 520 zcmV+j0{8uiP)pdMO#p5Z{pQs!8QF4L?If4IJQE2C`w9HVqBYiJ?uu3l!fNOz%ab`zHjE6 zr)YqI0R;;J6F_fp{2e$D`1BG^YWb?V$$_QeJ@5w5jm81Wz-9@SZ1A280`V8%Rfw<= zUrR7$gQxM!tu-MA7`NapfHp7yyjGS)=gtl)8b!robh$1n9uIX;kjjCr6)ZfPnbF;u z4A1B0Kx>FZh$oY%mKIta2Wx1^7bOxz`}?aN5e@TNAbIb){FgSP zj>C)H-CD{d25t?=WlxWYh+J5fTqF`AB2q3CB)_^U1|+|-B6oXx^5^btO?xVZ?&!eFW_dh2LyASb!$T@pR{(;6)36S{wY5pnb)~SeAqM2f$cQ`~ z9f^VJ$|&QHaPhwfSdVw=92^D5K%_}=xdMYa_}iP!mR9hw53(Ad1WQ1=3H?DHmLUj@ z4PFVR1wP;hCbVq@>%U=o7#@R8>X8Fm!#praeVq3z@VO7Nwfw(b!>dQ!DF}T40000< KMNUMnLSTZtSm0Oy diff --git a/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-12-1-snap.png b/apps/web/test/unit-tests/__image_snapshots__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-12-1-snap.png deleted file mode 100644 index 6153ef91be9d7b5279b9fa4fd25238d7f80cc31b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 536 zcmV+z0_XjSP)D%_l=n?s9GTDKtKwcn_?%EVu4s7moqcs7K$z~Tf@m@2%9Fs z;b97<=?{1w0Hob>cZ9<{+1esFI7l`g$38st)1rY)yMX?U4T6yfxkLgfl?Xe2a(v9+xjC}y>lC%M;i;>`TwLUMW(K{r z6{YI{ofvSm-_<4d!GUDAwnQ99l1)vL-PjN-9v3^4k>Am%9Q5^xolc7tkBf-NSFcwL zI8+84>MbqTRdF%S{ysIEn|!OSrKqC=b72AB$OzfBHL@!!h;5@>E-GU&_NuD^4l&@< z_fSaebXrdK_T2xC*lrTUCE!vhX%`m}C55I*Abll5Y#U>hR!v`C-t%}5cCB$ZYFyMX=OhrB2^A^YecRcVEK+5{2!J;lrDtu8u9+E7 z|G!9sM|lV;4W_Qn&ns7NFUH0OJ&6SFK!9t@%N(z)&@nMVM>JX|dA+#%`pz}l6ih0C zdoI5pV{?;})m2ES^4UAi?OvOW;QEk zE+>WVZqY%CLqk$cr9_AG^-0s~C48*9y3jlx>{7}3qBdMUA9k^bSk`6be5QSYkOm)t zwifyTz&?YU%IWP`i)1u-6@ZKaFkl*Z-vU2*2v5Knnjn}4L0GY=4za*O9#w=p8X#fBK07*qoM6N<$g5S;%;{X5v literal 0 HcmV?d00001 diff --git a/apps/web/test/unit-tests/badge-overlay-test.ts b/apps/web/test/unit-tests/badge-overlay-test.ts index 158e800caf4..df87f11c54c 100644 --- a/apps/web/test/unit-tests/badge-overlay-test.ts +++ b/apps/web/test/unit-tests/badge-overlay-test.ts @@ -76,15 +76,16 @@ beforeAll(() => { }; }); +// The overlay only ships on Windows and matches the platform's own +// notification badges, which clamp at "99+". Anything above 99 should +// render identically, so we just snapshot the boundary cases. const SAMPLES: Array<{ name: string; value: number | string; bgColor?: string }> = [ { name: "1", value: 1 }, { name: "9", value: 9 }, { name: "10", value: 10 }, - { name: "12", value: 12 }, { name: "99", value: 99 }, - { name: "100", value: 100 }, - { name: "999", value: 999 }, - { name: "1000", value: 1000 }, + { name: "100 (clamped to 99+)", value: 100 }, + { name: "9999 (clamped to 99+)", value: 9999 }, { name: "error", value: "×", bgColor: "#f00" }, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5906cb93d3e..6ee18365d7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,7 +468,7 @@ importers: version: 1.0.3 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e1a7f6532b82638dcd3ceabe67e5f136e2d54e0d matrix-widget-api: specifier: ^1.17.0 version: 1.17.0 @@ -662,6 +662,9 @@ importers: '@types/jest': specifier: 30.0.0 version: 30.0.0 + '@types/jest-image-snapshot': + specifier: ^6.4.1 + version: 6.4.1 '@types/jitsi-meet': specifier: ^2.0.2 version: 2.0.5 @@ -5642,6 +5645,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jest-image-snapshot@6.4.1': + resolution: {integrity: sha512-pj3Sdc7Cx5mMLUttPprazSDQCur2cr512Dm38e9aAHI55LDxEhqdyqzK9myC4EmEy7sPAF2nGJ8zifX4qso7sQ==} + '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} @@ -5735,6 +5741,9 @@ packages: '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + '@types/pixelmatch@5.2.6': + resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -10145,8 +10154,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798: - resolution: {gitHosted: true, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798} + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e1a7f6532b82638dcd3ceabe67e5f136e2d54e0d: + resolution: {gitHosted: true, integrity: sha512-gEbgqEiUhz1iryI39+yuAmygIvM9yP2Cbz8c77AQvIqB2dXiA95qK+DhxAt9rkubw40f2fG3Dy55WUWG88skaQ==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e1a7f6532b82638dcd3ceabe67e5f136e2d54e0d} version: 41.6.0 engines: {node: '>=22.0.0'} @@ -18564,6 +18573,12 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jest-image-snapshot@6.4.1': + dependencies: + '@types/jest': 30.0.0 + '@types/pixelmatch': 5.2.6 + ssim.js: 3.5.0 + '@types/jest@30.0.0': dependencies: expect: 30.4.1 @@ -18668,6 +18683,10 @@ snapshots: '@types/picomatch@4.0.2': {} + '@types/pixelmatch@5.2.6': + dependencies: + '@types/node': 18.19.130 + '@types/plist@3.0.5': dependencies: '@types/node': 18.19.130 @@ -23913,7 +23932,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ec54b54a8964e87832421a8e1faf60ede9364798: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e1a7f6532b82638dcd3ceabe67e5f136e2d54e0d: dependencies: '@babel/runtime': 7.29.2 '@matrix-org/matrix-sdk-crypto-wasm': 18.2.0