import { kami, shu, sumi } from '@tsuki/tokens'; import { describe, expect, it } from 'vitest'; import { buildManifest, buildUpdates, buildUserChrome, GECKO_ID, resolve } from './build'; import { type ColorMap, markPairs, modes, textPairs } from './mapping'; const palette = new Set([ ...Object.values(kami), ...Object.values(sumi), ...Object.values(shu), ]); function luminance(hex: string): number { const [r, g, b] = [1, 3, 5].map((i) => { const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }) as [number, number, number]; return 0.2126 * r + 0.7152 * g + 0.0722 * b; } function contrast(a: string, b: string): number { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (hi + 0.05) / (lo + 0.05); } const manifest = buildManifest('1.2.3'); describe('manifest', () => { it('targets Nova and self-distribution', () => { expect(manifest.manifest_version).toBe(3); expect(manifest.browser_specific_settings.gecko).toMatchObject({ id: GECKO_ID, strict_min_version: '157.0', }); expect(manifest.browser_specific_settings.gecko.update_url).toMatch(/^https:\/\//); }); it('is adaptive: light in `theme`, dark in `dark_theme`', () => { expect(manifest.theme.properties.color_scheme).toBe('light'); expect(manifest.dark_theme.properties.color_scheme).toBe('dark'); }); it('ships no images — tsuki has no gradients or textures', () => { expect(manifest.theme).not.toHaveProperty('images'); expect(manifest.dark_theme).not.toHaveProperty('images'); expect(JSON.stringify(manifest)).not.toMatch(/gradient/i); }); it('omits keys that do nothing in Nova', () => { for (const block of [manifest.theme, manifest.dark_theme]) { for (const dead of ['ntp_text', 'sidebar_highlight', 'sidebar_highlight_text']) { expect(block.colors).not.toHaveProperty(dead); } } }); it('light and dark define the same keys', () => { expect(Object.keys(modes.dark).sort()).toEqual(Object.keys(modes.light).sort()); }); it('every color is a tsuki token (or transparent)', () => { for (const block of [manifest.theme, manifest.dark_theme]) { for (const [key, value] of Object.entries(block.colors)) { expect(value === 'transparent' || palette.has(value), `${key}: ${value}`).toBe(true); } } }); }); describe('resolve', () => { it('maps token names to @tsuki/tokens hex', () => { expect(resolve('shu-500')).toBe(shu['500']); expect(resolve('kami-0')).toBe(kami['0']); }); it('throws on unknown tokens', () => { expect(() => resolve('shu-300')).toThrow(/Unknown tsuki token/); }); }); describe.each(Object.entries(modes))('%s contrast', (_mode, map: ColorMap) => { it.each(textPairs)('%s on %s ≥ 4.5:1', (fg, bg) => { expect(contrast(resolve(map[fg]), resolve(map[bg]))).toBeGreaterThanOrEqual(4.5); }); it.each(markPairs)('%s on %s ≥ 3:1', (fg, bg) => { expect(contrast(resolve(map[fg]), resolve(map[bg]))).toBeGreaterThanOrEqual(3); }); }); describe('updates.json', () => { it('points the gecko id at the release asset for this version', () => { const [update] = buildUpdates('1.2.3').addons[GECKO_ID]!.updates; expect(update!.version).toBe('1.2.3'); expect(update!.update_link).toMatch(/\/releases\/download\/v1\.2\.3\/tsuki-1\.2\.3\.xpi$/); }); }); describe('userChrome.css', () => { const css = buildUserChrome('1.2.3'); it('marks every declaration !important so it beats Nova author sheets', () => { const decls = css .replace(/@font-face\s*{[^}]*}/g, '') .match(/^\s+--?[a-z-]+:[^;]+;/gm); expect(decls?.length).toBeGreaterThan(10); for (const d of decls ?? []) expect(d, d.trim()).toMatch(/!important;$/); }); it('keeps corners within tsuki limits (never 0, never 12+)', () => { for (const [, px] of css.matchAll(/radius[a-z-]*:\s*(\d+)px/g)) { expect(Number(px)).toBeGreaterThan(0); expect(Number(px)).toBeLessThan(12); } }); it('caps font weight at 500', () => { for (const [, w] of css.matchAll(/font-weight[a-z-]*:\s*(\d+)/g)) { expect(Number(w)).toBeLessThanOrEqual(500); } }); it('has no gradients', () => { expect(css).not.toMatch(/gradient/i); }); });