browser tests run only by hand: the suites that launch Chrome / Chromium move to tests/browser/*.browser.ts (bun test, ci, ci:quick and the Forgejo workflows never pick them up; bun run test:browser runs them); the desktop boot check leaves bun run ci (it stays in desktop-release.ts mac at release time); CI=full is gone: Forgejo's ci runs the Rust steps when the push changed a Rust input (STOREBOUND_CI_BASE=HEAD~1), no Playwright install; CONTRIBUTING
This commit is contained in:
parent
4c7ff89950
commit
cd1e13c1e2
20 changed files with 116 additions and 133 deletions
216
tests/browser/local-fonts.browser.ts
Normal file
216
tests/browser/local-fonts.browser.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// A game's own brand font as a local file in shots.fonts: validation, cmap coverage with listed + Noto fallbacks,
|
||||
// @font-face served in place, `shots check` (local + listed fallback pass, license warning) and the content hash that
|
||||
// invalidates the capture cache and stale renders. Fonts are tiny hand-made sfnt / WOFF2 files (cmap + name only).
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { brotliCompressSync } from 'node:zlib';
|
||||
import { fontCodepoints, fontFamilyName } from '../../cli/shots/cmap';
|
||||
import { type FaceJob, fontIssues, fontsCss, fontUsage, type FontManifest, GlyphError, planFonts } from '../../cli/shots/glyphs';
|
||||
import { langTexts } from '../../cli/shots/fonts';
|
||||
import { faceFilePath, fontsRoute, loadLocalFont, localByFamily, localFontIssues, resolveShotFonts, staleLocalFonts } from '../../cli/shots/local-fonts';
|
||||
import { planWeb } from '../../cli/shots/capture-web';
|
||||
import { validateConfig } from '../../cli/lib/config';
|
||||
|
||||
// ---- minimal font writer: an sfnt (or WOFF2) with a format 12 cmap and a name table ----
|
||||
function cmapBytes(cps: number[]): Uint8Array {
|
||||
const sorted = [...new Set(cps)].sort((a, b) => a - b);
|
||||
const len = 16 + sorted.length * 12;
|
||||
const b = new DataView(new ArrayBuffer(12 + len));
|
||||
b.setUint16(2, 1); b.setUint16(4, 3); b.setUint16(6, 10); b.setUint32(8, 12);
|
||||
b.setUint16(12, 12); b.setUint32(16, len); b.setUint32(24, sorted.length);
|
||||
sorted.forEach((c, i) => { b.setUint32(28 + i * 12, c); b.setUint32(32 + i * 12, c); b.setUint32(36 + i * 12, i + 1); });
|
||||
return new Uint8Array(b.buffer);
|
||||
}
|
||||
function nameBytes(family: string): Uint8Array {
|
||||
const str = new Uint8Array(family.length * 2);
|
||||
[...family].forEach((c, i) => { str[i * 2] = 0; str[i * 2 + 1] = c.charCodeAt(0); });
|
||||
const b = new DataView(new ArrayBuffer(6 + 12 + str.length));
|
||||
b.setUint16(2, 1); b.setUint16(4, 18);
|
||||
b.setUint16(6, 3); b.setUint16(8, 1); b.setUint16(10, 0x409); b.setUint16(12, 1); b.setUint16(14, str.length); b.setUint16(16, 0);
|
||||
new Uint8Array(b.buffer).set(str, 18);
|
||||
return new Uint8Array(b.buffer);
|
||||
}
|
||||
function sfnt(cps: number[], family: string, sig = 0x00010000): Uint8Array {
|
||||
const tables: [string, Uint8Array][] = [['cmap', cmapBytes(cps)], ['name', nameBytes(family)]];
|
||||
let off = 12 + tables.length * 16;
|
||||
const out = new Uint8Array(off + tables.reduce((n, [, t]) => n + t.length, 0));
|
||||
const d = new DataView(out.buffer);
|
||||
d.setUint32(0, sig); d.setUint16(4, tables.length);
|
||||
tables.forEach(([tag, t], i) => {
|
||||
out.set([...tag].map(c => c.charCodeAt(0)), 12 + i * 16);
|
||||
d.setUint32(12 + i * 16 + 8, off); d.setUint32(12 + i * 16 + 12, t.length);
|
||||
out.set(t, off); off += t.length;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function woff2(cps: number[], family: string): Uint8Array {
|
||||
const cmap = cmapBytes(cps), name = nameBytes(family);
|
||||
const stream = new Uint8Array(brotliCompressSync(Buffer.concat([cmap, name])));
|
||||
const b128 = (n: number): number[] => { const b = [n & 0x7f]; n = Math.floor(n / 128); while (n) { b.unshift(0x80 | (n & 0x7f)); n = Math.floor(n / 128); } return b; };
|
||||
const dir = [0, ...b128(cmap.length), 5, ...b128(name.length)]; // known-table indices: 0 = cmap, 5 = name
|
||||
const out = new Uint8Array(48 + dir.length + stream.length);
|
||||
const d = new DataView(out.buffer);
|
||||
out.set([...'wOF2'].map(c => c.charCodeAt(0)), 0); d.setUint32(4, 0x00010000); d.setUint32(8, out.length);
|
||||
d.setUint16(12, 2); d.setUint32(20, stream.length);
|
||||
out.set(dir, 48); out.set(stream, 48 + dir.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
const range = (a: number, b: number): number[] => Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||||
const cpsOf = (s: string): number[] => [...s].map(c => c.codePointAt(0)!);
|
||||
// "ER Display": ASCII, Latin-1 and Latin Extended-A without ą ę Ł ł; no Vietnamese (ơ ư, U+1EA0..)
|
||||
const ER = [...range(0x20, 0x7e), ...range(0xa0, 0xff), ...range(0x100, 0x17f).filter(c => ![0x105, 0x119, 0x141, 0x142].includes(c))];
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'sb-localfonts-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const fontsDir = join(root, '.storebound', 'fonts');
|
||||
mkdirSync(join(root, 'assets', 'fonts'), { recursive: true });
|
||||
mkdirSync(fontsDir, { recursive: true });
|
||||
writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2(ER, 'ER Display'));
|
||||
writeFileSync(join(root, 'assets/fonts/er-pl.ttf'), sfnt([0x105, 0x119, 0x141, 0x142], 'ER Polish'));
|
||||
const resolve = (p: string): string => join(root, p);
|
||||
|
||||
describe('local font files: config and validation', () => {
|
||||
test('a path reads the family from the name table (WOFF2 and TTF); the object form sets family, weight, license', () => {
|
||||
expect(fontFamilyName(woff2(ER, 'ER Display'))).toBe('ER Display');
|
||||
const a = loadLocalFont('assets/fonts/er-display.woff2', 'shots.fonts.title', resolve, root);
|
||||
expect(a.family).toBe('ER Display');
|
||||
expect(a.local).toMatchObject({ path: 'assets/fonts/er-display.woff2', format: 'woff2' });
|
||||
expect(a.local!.hash).toMatch(/^[0-9a-f]{16}$/);
|
||||
const b = loadLocalFont({ family: 'ER Pl', file: 'assets/fonts/er-pl.ttf', weight: 800, license: 'OFL-1.1' }, 'k', resolve, root);
|
||||
expect(b).toMatchObject({ family: 'ER Pl', local: { format: 'truetype', weight: '800', license: 'OFL-1.1' } });
|
||||
expect(fontCodepoints(readFileSync(join(root, 'assets/fonts/er-display.woff2'))).size).toBe(ER.length);
|
||||
});
|
||||
|
||||
test('missing file, wrong extension, not a font, mismatched format', () => {
|
||||
writeFileSync(join(root, 'assets/fonts/bad.ttf'), new Uint8Array(64));
|
||||
writeFileSync(join(root, 'assets/fonts/fake.ttf'), woff2(ER, 'X'));
|
||||
expect(() => loadLocalFont('assets/fonts/nope.ttf', 'shots.fonts.title', resolve, root)).toThrow('shots.fonts.title: assets/fonts/nope.ttf not found');
|
||||
expect(() => loadLocalFont({ file: 'assets/fonts/er.svg' }, 'k', resolve, root)).toThrow('not a font file (TTF, OTF, WOFF or WOFF2)');
|
||||
expect(() => loadLocalFont('assets/fonts/bad.ttf', 'k', resolve, root)).toThrow('unknown signature');
|
||||
expect(() => loadLocalFont('assets/fonts/fake.ttf', 'k', resolve, root)).toThrow('is WOFF2 inside, but named .ttf');
|
||||
});
|
||||
|
||||
test('validateConfig: font entry shapes', () => {
|
||||
const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en' }] };
|
||||
const shots = (fonts: unknown) => ({ ...base, shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: 'fire', frames: [{ src: 'a' }] }], fonts } });
|
||||
expect(validateConfig(shots({ title: 'assets/er.woff2', body: 'Montserrat', fallback: [{ family: 'ER Pl', file: 'a/er-pl.ttf', license: 'OFL-1.1' }, 'Noto Sans Display'] }))).toEqual([]);
|
||||
const errs = validateConfig(shots({ title: 'assets/er.svg', body: { family: 'B' }, fallback: [{ file: 'x.otf', style: 'oblique', weight: 'bold' }] }));
|
||||
expect(errs).toEqual([
|
||||
'shots.fonts.title: assets/er.svg: a font file must be TTF, OTF, WOFF or WOFF2',
|
||||
'shots.fonts.body: a Google Fonts family, a font file path or { family, file, weight?, style?, license? }',
|
||||
'shots.fonts.fallback[0].style: normal | italic',
|
||||
'shots.fonts.fallback[0].weight: e.g. 400, 800 or "100 900"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Copy: ER Display covers tr / de / fr titles; Polish needs ą ę Ł ł, Vietnamese needs ơ ư ạ ...
|
||||
const COPY = {
|
||||
tr: { s1: { title: ["Taş Devri'nden", 'İleri!'] as [string, string], sub: 'Kasabaları yağmala' } },
|
||||
de: { s1: { title: ['Größer', 'schöner'] as [string, string] } },
|
||||
pl: { s1: { title: ['Gęsi łąka', 'Łódź'] as [string, string], sub: 'Szybka gra' } },
|
||||
vi: { s1: { title: ['Chiến đấu', 'Thế giới'] as [string, string], sub: 'Trận nhanh' } },
|
||||
};
|
||||
const NOTO: Record<string, (cp: number) => boolean> = {
|
||||
Montserrat: () => true, // body: covers everything here
|
||||
'Noto Sans Display': cp => cp < 0x250 || (cp >= 0x1ea0 && cp <= 0x1ef9),
|
||||
};
|
||||
/** Getter like `shots fonts`: local files read in place, Google families faked. */
|
||||
function getter(asked: FaceJob[]): (job: FaceJob) => Promise<Set<number>> {
|
||||
return async job => {
|
||||
asked.push(job);
|
||||
if (job.local) return fontCodepoints(readFileSync(join(root, job.local.path)));
|
||||
const f = NOTO[job.family] ?? (() => false);
|
||||
return new Set(cpsOf(job.chars).filter(f));
|
||||
};
|
||||
}
|
||||
async function plan(fallback: (string | { family?: string; file: string; license?: string })[] = [], asked: FaceJob[] = []): Promise<FontManifest> {
|
||||
const sf = resolveShotFonts({ fonts: { title: 'assets/fonts/er-display.woff2', body: 'Montserrat', fallback } }, 'game', resolve, root);
|
||||
return planFonts({ langs: langTexts(COPY, [], 'STAR DASH'), title: sf.title.family, body: sf.body.family, titleAxis: null, bodyAxis: 'wght@100..900',
|
||||
fallbackTitleAxis: 'wght@800', chosen: sf.fallback.map(f => f.family), local: localByFamily([sf.title, sf.body, ...sf.fallback]) }, getter(asked));
|
||||
}
|
||||
const fb = (m: FontManifest, lang: string) => m.langs[lang]!.fallbacks.filter(f => f.role === 'title').map(f => `${f.family}:${[...f.chars].join(' ')}`);
|
||||
|
||||
describe('coverage of a local title font', () => {
|
||||
test('pl / vi titles: the characters ER Display lacks go to Noto Sans Display (automatic); tr / de need nothing', async () => {
|
||||
const asked: FaceJob[] = [];
|
||||
const m = await plan([], asked);
|
||||
expect(fb(m, 'pl')).toEqual(['Noto Sans Display:ę ł ą Ł']);
|
||||
expect(fb(m, 'vi')).toEqual(['Noto Sans Display:ế ấ ớ']);
|
||||
for (const l of ['tr', 'de']) expect(m.langs[l]!.fallbacks).toEqual([]);
|
||||
expect(m.faces['ER Display']).toMatchObject({ family: 'ER Display', kind: 'title', chosen: true, file: 'local/er-display.woff2', local: { path: 'assets/fonts/er-display.woff2', format: 'woff2' } });
|
||||
expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(false);
|
||||
expect(asked.find(j => j.family === 'ER Display')!.local).toBeDefined(); // read in place, not downloaded
|
||||
});
|
||||
|
||||
test('listed fallbacks first (a local file), then Noto; chosen, so check passes', async () => {
|
||||
const m = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'OFL-1.1' }, 'Noto Sans Display']);
|
||||
expect(fb(m, 'pl')).toEqual(['ER Polish:ę ł ą Ł']);
|
||||
expect(fb(m, 'vi')).toEqual(['Noto Sans Display:ế ấ ớ']);
|
||||
expect(m.langs.pl!.title).toEqual(['ER Display', 'Store fb er-polish title']);
|
||||
expect(m.faces['Store fb er-polish title']).toMatchObject({ kind: 'fallback', chosen: true, file: 'local/er-polish.ttf', local: { license: 'OFL-1.1' } });
|
||||
expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(true);
|
||||
});
|
||||
|
||||
test('nothing covers it: GlyphError names the local font and the fallbacks tried', async () => {
|
||||
const err = await planFonts({ langs: langTexts({ pl: { s1: { title: ['ą', 'x'] } } }, [], ''), title: 'ER Display', body: 'Montserrat', titleAxis: null, bodyAxis: null,
|
||||
fallbackTitleAxis: null, local: localByFamily([loadLocalFont('assets/fonts/er-display.woff2', 'k', resolve, root)]) },
|
||||
async job => job.local ? fontCodepoints(readFileSync(join(root, job.local.path))) : new Set()).catch(e => e);
|
||||
expect(err).toBeInstanceOf(GlyphError);
|
||||
expect(err.message).toContain('pl (title): "ą" U+0105; tried ER Display, Noto Sans Display, Noto Sans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render and check with a local font', () => {
|
||||
test('fonts.css: @font-face for the local file, served in place at /fonts/local/... with its hash', async () => {
|
||||
const m = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'OFL-1.1' }]);
|
||||
const css = fontsCss(m);
|
||||
const h = m.faces['ER Display']!.local!.hash;
|
||||
expect(css).toContain(`@font-face { font-family: 'ER Display'; font-weight: 100 900; font-style: normal; font-synthesis: none; src: url(local/er-display.woff2?v=${h}) format('woff2'); }`);
|
||||
expect(css).toContain("src: url(local/er-polish.ttf?v=");
|
||||
expect(css).toContain("format('truetype')");
|
||||
expect(css).toContain('/* ER Polish: local file assets/fonts/er-pl.ttf (license: OFL-1.1) */');
|
||||
expect(fontsRoute(`local/er-display.woff2?v=${h}`, fontsDir, root, m)).toBe(join(root, 'assets/fonts/er-display.woff2'));
|
||||
expect(fontsRoute('local/other.ttf', fontsDir, root, m)).toBeNull();
|
||||
expect(fontsRoute('local/../../x.ttf', fontsDir, root, m)).toBeNull();
|
||||
expect(fontsRoute('fb-noto-sans-display-title.woff2', fontsDir, root, m)).toBe(join(fontsDir, 'fb-noto-sans-display-title.woff2'));
|
||||
expect(faceFilePath(fontsDir, root, m.faces['ER Display']!)).toBe(join(root, 'assets/fonts/er-display.woff2'));
|
||||
// nothing is copied into .storebound/fonts
|
||||
expect(Object.values(m.faces).filter(f => f.local).every(f => f.file.startsWith('local/'))).toBe(true);
|
||||
});
|
||||
|
||||
test('check: the local font and the listed fallback are not fallbacks the config did not choose; an automatic Noto is', async () => {
|
||||
const m = await plan([{ file: 'assets/fonts/er-pl.ttf' }]);
|
||||
const ok = fontUsage([
|
||||
{ el: 'h1 span:1', text: 'Gęsi łąka', faces: { 'ER Display': 'Gsika', 'Store fb er-polish title': 'ęłą' } },
|
||||
{ el: 'p.sub', text: 'Szybka gra', faces: { Montserrat: 'Szybkagr' } },
|
||||
], m.faces);
|
||||
expect(ok.used).toEqual({ 'ER Display': 5, 'ER Polish': 3, Montserrat: 8 });
|
||||
expect(fontIssues(ok)).toEqual([]);
|
||||
const bad = fontUsage([{ el: 'h1 span:1', text: 'Chiến', faces: { 'ER Display': 'Chin', 'Store fb noto-sans-display title': 'ế' } }], m.faces);
|
||||
expect(fontIssues(bad).map(i => i.code)).toEqual(['font-fallback']);
|
||||
// license note: warn for the local file without one
|
||||
expect(localFontIssues(m).map(i => i.msg.split(' has')[0])).toEqual(['local font ER Display (assets/fonts/er-display.woff2)', 'local font ER Polish (assets/fonts/er-pl.ttf)']);
|
||||
const licensed = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'proprietary' }]);
|
||||
expect(localFontIssues(licensed).length).toBe(1);
|
||||
});
|
||||
|
||||
test('a changed font file changes the content hash: capture cache misses, render refuses the stale fonts.json', async () => {
|
||||
const m1 = await plan();
|
||||
const cap = { source: 'web' as const, baseUrl: 'http://x', shots: { a: { path: '/a' } } };
|
||||
const dims = { phone: { w: 10, h: 20, dpr: 1, bar: 0 }, tablet: { w: 10, h: 20, dpr: 1, bar: 0 } };
|
||||
const job = (m: FontManifest) => planWeb(cap, { langs: ['tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b', fonts: fontsCss(m) })[0]!.hash;
|
||||
expect(staleLocalFonts(m1, root)).toEqual([]);
|
||||
writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2([...ER, 0x105], 'ER Display'));
|
||||
try {
|
||||
expect(staleLocalFonts(m1, root)).toEqual(['ER Display: assets/fonts/er-display.woff2 changed']);
|
||||
const m2 = await plan();
|
||||
expect(m2.faces['ER Display']!.local!.hash).not.toBe(m1.faces['ER Display']!.local!.hash);
|
||||
expect(job(m2)).not.toBe(job(m1));
|
||||
expect(fb(m2, 'pl')).toEqual(['Noto Sans Display:ę ł Ł']); // ą now in the font
|
||||
} finally { writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2(ER, 'ER Display')); }
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue