Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -171,6 +172,7 @@
"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",
Expand All @@ -196,6 +198,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",
Expand Down
47 changes: 36 additions & 11 deletions apps/web/src/favicon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,29 +98,40 @@ 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);
if (this.baseImage) {
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";

Expand All @@ -145,12 +156,19 @@ abstract class IconRenderer {
this.context.stroke();
this.context.fillStyle = params.textColor;

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));
} else {
this.context.fillText("" + opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
}
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 + metrics.actualBoundingBoxAscent / 2);
this.context.fillText(text, textX, textY);

this.context.closePath();
}
Expand All @@ -176,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) => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
104 changes: 104 additions & 0 deletions apps/web/test/unit-tests/badge-overlay-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
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. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by
* node-canvas so real pixels come out of toBlob().
* 2. 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<HTMLCanvasElement, Canvas>();
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 }));
};
});

// 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: "99", value: 99 },
{ name: "100 (clamped to 99+)", value: 100 },
{ name: "9999 (clamped to 99+)", value: 9999 },
{ 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();

Check failure on line 97 in apps/web/test/unit-tests/badge-overlay-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

BadgeOverlayRenderer › renders count 100 (clamped to 99+)

Expected image to match or be a close match to snapshot but was 25.78125% different from snapshot (66 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-100-clamped-to-99-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/badge-overlay-test.ts:97:35)

Check failure on line 97 in apps/web/test/unit-tests/badge-overlay-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

BadgeOverlayRenderer › renders count 99

Expected image to match or be a close match to snapshot but was 30.859375% different from snapshot (79 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-99-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/badge-overlay-test.ts:97:35)

Check failure on line 97 in apps/web/test/unit-tests/badge-overlay-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

BadgeOverlayRenderer › renders count 10

Expected image to match or be a close match to snapshot but was 21.484375% different from snapshot (55 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-10-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/badge-overlay-test.ts:97:35)

Check failure on line 97 in apps/web/test/unit-tests/badge-overlay-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

BadgeOverlayRenderer › renders count 9

Expected image to match or be a close match to snapshot but was 25% different from snapshot (64 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-9-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/badge-overlay-test.ts:97:35)

Check failure on line 97 in apps/web/test/unit-tests/badge-overlay-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

BadgeOverlayRenderer › renders count 1

Expected image to match or be a close match to snapshot but was 12.109375% different from snapshot (31 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/badge-overlay-test-ts-badge-overlay-renderer-renders-count-1-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/badge-overlay-test.ts:97:35)
});

it("returns null when count is 0", async () => {
const renderer = new BadgeOverlayRenderer();
expect(await renderer.render(0)).toBeNull();
});
});
150 changes: 150 additions & 0 deletions apps/web/test/unit-tests/favicon-image-test.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

/*
* Tests for the Favicon class (browser-tab favicon with a badge drawn over
* the Element logo).
*
* Approach:
* 1. Swap jest-canvas-mock's HTMLCanvasElement stubs for shims backed by
* node-canvas so real pixels come out.
* 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.
* 3. Drive the real Favicon class, run timers, then read the badged PNG out
* of the <link rel=icon>'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<HTMLCanvasElement, Canvas>();
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 <head> 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 <link rel=icon> 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();

Check failure on line 135 in apps/web/test/unit-tests/favicon-image-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

Favicon (over base image) › badges favicon with 1000

Expected image to match or be a close match to snapshot but was 11.564429012345679% different from snapshot (2398 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-1000-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/favicon-image-test.ts:135:21)

Check failure on line 135 in apps/web/test/unit-tests/favicon-image-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

Favicon (over base image) › badges favicon with 100

Expected image to match or be a close match to snapshot but was 13.797260802469136% different from snapshot (2861 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-100-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/favicon-image-test.ts:135:21)

Check failure on line 135 in apps/web/test/unit-tests/favicon-image-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

Favicon (over base image) › badges favicon with 99

Expected image to match or be a close match to snapshot but was 7.952353395061729% different from snapshot (1649 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-99-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/favicon-image-test.ts:135:21)

Check failure on line 135 in apps/web/test/unit-tests/favicon-image-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

Favicon (over base image) › badges favicon with 10

Expected image to match or be a close match to snapshot but was 9.900655864197532% different from snapshot (2053 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-10-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/favicon-image-test.ts:135:21)

Check failure on line 135 in apps/web/test/unit-tests/favicon-image-test.ts

View workflow job for this annotation

GitHub Actions / Jest (Element Web) (1)

Favicon (over base image) › badges favicon with 1

Expected image to match or be a close match to snapshot but was 2.8018904320987654% different from snapshot (581 differing pixels). See diff for details: /home/runner/work/element-web/element-web/apps/web/test/unit-tests/__image_snapshots__/__diff_output__/favicon-image-test-ts-favicon-over-base-image-badges-favicon-with-1-1-snap-diff.png at toMatchImageSnapshot (test/unit-tests/favicon-image-test.ts:135:21)
});

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);
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"canvas",
"matrix-js-sdk"
],
"ignoredBuiltDependencies": [
Expand Down
Loading
Loading