Files
SRCmail/lib/__tests__/favicon-badge.test.ts
honzupandLinus Rath 334fdbfb86 feat: show unread count badge on favicon
Closes #560.

Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.

The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.

Notes on the approach:

- The badge link is an *additional* icon link that we append and mark as
  ours; we never remove or mutate a link we did not create. Next's metadata
  icons are rendered by React, which keeps a fiber pointing at that DOM node,
  so removing it would leave React holding a detached node and throw
  "Cannot read properties of null (reading 'removeChild')" on the next
  commit that deletes the fiber. Appending instead means the last-declared
  icon wins, and non-SVG fallback links survive with their type/sizes intact.
  (The usual recipe for this feature — assign canvas.toDataURL() to the
  existing link's href — does both of the things that break here.)

- Every change of state is an *insertion* of a fresh link of ours, never a
  mutation or a removal, because that is the only signal a browser reliably
  re-reads the favicon on. Firefox ignores an in-place href change, and it
  equally ignores a removal — so clearing the badge by deleting our link left
  a stale count painted on the tab until a hard reload. Clearing it instead
  inserts a new link of ours carrying the original base href.

- Holding last place has to be defended: on a client-side navigation React
  re-hoists its metadata icon link into <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> moves our own
  link back to the end whenever a foreign icon link appears — moving only our
  node, never anyone else's. It no-ops once ours is last again, so a move
  cannot feed itself.

- The badge is a full-width band across the foot of the icon, drawn to the
  metrics measured from Gmail's own 16px favicon: band height 0.625 of the
  icon, digit cap height 0.44, flush to the edges, corners rounded by about a
  pixel. Full width is what keeps a three-glyph label legible — rounded ends
  waste exactly the horizontal space it needs. Neutral white with black digits
  rather than the conventional red: faviconUrl is admin-overridable and
  Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.

- The base SVG may be admin-uploaded, and the branding route deliberately
  serves it under a sandboxing CSP because SVG can carry script. Re-emitting
  it as a same-origin data: URL would un-fence that, so script, foreignObject
  and every on* handler are stripped before serialising.

- Mounted in the root layout, not on the mail route: the badge belongs to the
  tab, so mounting it on the page would clear it on every hop to settings,
  calendar or contacts.
2026-07-16 18:00:41 +02:00

343 lines
16 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
function decode(dataUrl: string): string {
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
}
/** The badge band: the last <rect> the renderer appends, identified by its fill. */
function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
const match =
/<rect[^>]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
svg,
);
expect(match).not.toBeNull();
const [, x, y, w, h, rx] = match!.map(Number);
return { x, y, w, h, rx };
}
function fontSize(svg: string): number {
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
}
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
const [minX, minY, width, height] = /viewBox="([^"]+)"/
.exec(svg)![1]
.trim()
.split(/[\s,]+/)
.map(Number);
return { minX, minY, width, height };
}
describe('formatBadgeCount', () => {
it('returns an empty string for zero and below', () => {
expect(formatBadgeCount(0)).toBe('');
expect(formatBadgeCount(-3)).toBe('');
});
it('returns the count verbatim from 1 to 99', () => {
expect(formatBadgeCount(1)).toBe('1');
expect(formatBadgeCount(9)).toBe('9');
expect(formatBadgeCount(47)).toBe('47');
expect(formatBadgeCount(99)).toBe('99');
});
it('caps at 99+ above 99', () => {
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
// typical inbox needs three glyphs almost always, and three glyphs do not
// fit at the full font size — so "99+" rendered permanently smaller than a
// real two-digit count would have.
expect(formatBadgeCount(100)).toBe('99+');
expect(formatBadgeCount(133)).toBe('99+');
expect(formatBadgeCount(1000)).toBe('99+');
});
it('returns an empty string for non-finite input', () => {
expect(formatBadgeCount(Number.NaN)).toBe('');
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
});
});
describe('renderBadgedFavicon', () => {
it('returns null when the count is zero', () => {
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
});
it('returns null when the source is not SVG', () => {
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
});
it('returns null when the root element is not <svg>', () => {
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
});
it('returns null when the root has no viewBox', () => {
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
});
it('returns a percent-encoded svg data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3);
expect(url).not.toBeNull();
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
});
it('draws a badge band and the count text', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('<rect');
expect(svg).toContain('>3<');
});
it('renders 99+ for large counts', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(svg).toContain('>99+<');
});
it('preserves the base artwork and its clipPath id', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('id="_clip1"');
expect(svg).toContain('#123456');
});
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('width="16"');
expect(svg).toContain('height="16"');
expect(svg).toContain('viewBox="0 0 1000 1000"');
expect(svg).not.toContain('1000pt');
});
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
});
it('shrinks the font as the label grows so three glyphs still fit', () => {
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(three)).toBeLessThan(fontSize(one));
});
it('returns null rather than throwing when the source contains a lone surrogate', () => {
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
expect(renderBadgedFavicon(bad, 3)).toBeNull();
});
it('sizes the band to the label, and only "99+" fills the full icon width', () => {
// The box is only as wide as its digits need — "5" must not squat on as much
// white as "99+". It is never wider than the icon, and three glyphs, whose
// font is budgeted against the full span, grow to exactly fill it.
const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
expect(w(7)).toBeLessThan(w(47));
expect(w(47)).toBeLessThan(w(250));
expect(w(250)).toBeCloseTo(1000, 5);
});
it('matches the geometry measured from Gmail\'s 16px favicon', () => {
// Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
// 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
// edge, corners rounded by 0.1h, width fitted to the label, anchored right.
// Gmail's own single-digit badge sits hard right in a box about a third of
// the icon wide, so the box grows leftwards from the corner.
// w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
const expected: Record<string, { x: number; w: number; font: number }> = {
'5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
'15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
'250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
};
for (const [count, want] of Object.entries(expected)) {
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
const { x, y, w, h, rx } = band(svg);
expect(x).toBeCloseTo(want.x, 5);
expect(w).toBeCloseTo(want.w, 5);
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
expect(y).toBeCloseTo(375, 5);
expect(h).toBeCloseTo(625, 5);
expect(rx).toBeCloseTo(62.5, 5);
}
});
it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
// Corner-anchored, not centred: the box grows leftwards from the bottom-right
// corner, so its right edge sits on minX + span whatever the label. Centring
// was rejected — at a single digit it lands under the middle of the mark.
const cases: [string, number, number][] = [
// [base svg, minX, span]
[BASE_SVG, 0, 1000],
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
];
for (const [svgSource, minX, span] of cases) {
for (const count of [7, 47, 250]) {
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
expect(x + w).toBeCloseTo(minX + span, 5);
expect(x).toBeGreaterThanOrEqual(minX);
}
}
});
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
// The font is budgeted against the full icon span, not against the fitted
// box, so one or two glyphs always land at FONT_MAX; only three force a
// shrink — and their box then grows to fill the icon.
const FONT_MAX = 0.61 * 1000;
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(three)).toBeLessThan(FONT_MAX);
});
it('rounds the band corners slightly — neither an oval nor a hard square', () => {
// rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
// and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
// a visible ~1px round at 16px. Guard against a silent revert to either.
for (const count of [7, 47, 250]) {
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
expect(rx).toBeCloseTo(0.1 * h, 5);
expect(rx).toBeGreaterThan(0);
expect(rx).toBeLessThan(h / 2);
}
});
it('draws the digits at font-weight 500, in both the attribute and the style', () => {
// 700 read visibly heavier than Gmail's equivalent badge.
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
});
it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
// The band is flush to the bottom and, at three glyphs, to the left and
// right edges too — but it must never overflow any of them.
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x + w).toBeLessThanOrEqual(1000);
expect(y + h).toBeLessThanOrEqual(1000);
}
});
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
// negative number: dropping `minX +` from the anchoring passed it. Anchor
// against a viewBox whose origin is negative, where the band's own x is
// legitimately negative, so the offset is genuinely pinned.
it('anchors the band to the viewBox origin, including a negative origin', () => {
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(-40);
expect(y).toBeGreaterThanOrEqual(-40);
expect(x + w).toBeLessThanOrEqual(200);
expect(y + h).toBeLessThanOrEqual(200);
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
// band's bottom edge and its right edge both sit well past the midpoint.
expect(x + w).toBeGreaterThan(80);
expect(y + h).toBeGreaterThan(80);
}
});
it('fits the label inside the band, with padding, for every label length', () => {
// The core band invariant: textW + 2 * pad <= w, where the glyph advance and
// padding are the renderer's own published constants. PAD_FACTOR is a
// fraction of the icon span, not of the fitted box, so the padding is the
// same at every label length.
const GLYPH_ADV = 0.6;
const PAD_FACTOR = 0.04;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { w } = band(svg);
const label = count > 99 ? '99+' : String(count);
const textW = label.length * GLYPH_ADV * fontSize(svg);
const pad = PAD_FACTOR * 1000;
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
}
});
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3)!;
// encodeURI leaves "#" bare, which the browser reads as a fragment
// delimiter: everything after the first colour would be silently dropped.
expect(url).toContain('%23');
expect(url).not.toContain('#');
});
it('returns an empty label, and no badge, for a fractional count below one', () => {
expect(formatBadgeCount(0.5)).toBe('');
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
});
it('returns null when the root svg has no SVG namespace', () => {
// Non-null but unrenderable: a data URL built from this would show nothing.
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
});
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
// Presentation attributes lose to any CSS rule in the document. A branded
// base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
// and the digits red — exactly what the white band exists to prevent.
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
expect(svg).toMatch(/<rect[^>]*style="[^"]*fill:\s*#ffffff/);
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#000000/);
});
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
// The base may be an admin-uploaded file, which upstream serves under a
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
// same-origin data: URL would un-fence it, so sanitise before serialising.
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
const url = renderBadgedFavicon(HOSTILE, 3)!;
expect(url).not.toBeNull();
const svg = decode(url);
expect(svg).not.toContain('<script');
expect(svg).not.toContain('foreignObject');
expect(svg.toLowerCase()).not.toContain('onload');
expect(svg.toLowerCase()).not.toContain('onclick');
expect(svg.toLowerCase()).not.toContain('onmouseover');
expect(svg).not.toContain('alert');
// The legitimate artwork survives.
expect(svg).toContain('#123456');
});
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
// the rendered box instead.
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
const vb = viewBoxOf(svg);
expect(vb.width).toBe(100);
expect(vb.height).toBe(100);
expect(vb.minX).toBe(0);
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
const { x, y, w, h } = band(svg);
// Sized against the square side (100), not the 20-unit short axis.
expect(h).toBeCloseTo(0.625 * 100, 5);
// Two glyphs at FONT_MAX (61) plus padding: 2 * 0.6 * 61 + 2 * 4 = 81.2,
// anchored to the right of the squared span.
expect(w).toBeCloseTo(81.2, 5);
expect(x + w).toBeCloseTo(100, 5);
// Still in bounds of the normalised viewBox.
expect(x).toBeGreaterThanOrEqual(vb.minX);
expect(y).toBeGreaterThanOrEqual(vb.minY);
expect(x + w).toBeLessThanOrEqual(vb.minX + vb.width);
expect(y + h).toBeLessThanOrEqual(vb.minY + vb.height);
});
it('leaves a square viewBox untouched', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('viewBox="0 0 1000 1000"');
});
});