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
185
tests/browser/capture-web.browser.ts
Normal file
185
tests/browser/capture-web.browser.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// `shots capture` (web) against a local fixture web app: parallel contexts give the same files as serial, the
|
||||
// per-language session boots the app once per language, the input-hash cache skips unchanged frames and re-runs changed
|
||||
// ones, and every frame has a timing breakdown. Pure helpers are tested without a browser. Skips the browser part when
|
||||
// no Chromium can launch. The browser runs run in a `storebound` child process (its own Chromium), like a real run.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ShipConfig, ShotsCapture } from '../../cli/lib/config';
|
||||
import { type CaptureReport, defaultConcurrency, planWeb, pool, progressLine, selectJobs, timingSummary } from '../../cli/shots/capture-web';
|
||||
import { BASE } from '../helpers/store-mock';
|
||||
|
||||
const CLI = join(import.meta.dir, '..', '..', 'cli', 'index.ts');
|
||||
|
||||
describe('capture helpers', () => {
|
||||
test('default concurrency: render min(4, cores/2), capture (GPU-heavy) min(3, cores/4), at least 1', () => {
|
||||
expect(defaultConcurrency('render', 10)).toBe(4);
|
||||
expect(defaultConcurrency('capture', 10)).toBe(2);
|
||||
expect(defaultConcurrency('capture', 2)).toBe(1);
|
||||
expect(defaultConcurrency('render', 1)).toBe(1);
|
||||
});
|
||||
test('pool keeps the input order and never runs more than n at once', async () => {
|
||||
let now = 0, peak = 0;
|
||||
const out = await pool([5, 1, 3, 2, 4], 2, async x => { now++; peak = Math.max(peak, now); await Bun.sleep(x * 3); now--; return x * 10; });
|
||||
expect(out).toEqual([50, 10, 30, 20, 40]);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
test('progress line with an ETA', () => {
|
||||
expect(progressLine(5, 20, 0, 10_000)).toBe(' 5/20 ETA 30.0s');
|
||||
expect(progressLine(20, 20, 0, 10_000)).toBe('20/20');
|
||||
});
|
||||
test('cache selection: changed inputs and missing files run; --only-missing; --force', () => {
|
||||
const cap: ShotsCapture = { source: 'web', baseUrl: 'http://x', shots: { a: { path: '/a' }, b: { path: '/b' } } };
|
||||
const dims = { phone: { w: 10, h: 20, dpr: 1, bar: 0 }, tablet: { w: 10, h: 20, dpr: 1, bar: 0 } };
|
||||
const jobs = planWeb(cap, { langs: ['en', 'tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b1' });
|
||||
expect(jobs.map(j => j.frame)).toEqual(['en/phone/a.png', 'en/phone/b.png', 'tr/phone/a.png', 'tr/phone/b.png']);
|
||||
const cache = Object.fromEntries(jobs.map(j => [j.frame, j.hash]));
|
||||
const all = () => true;
|
||||
expect(selectJobs(jobs, cache, 'changed', all).run).toEqual([]);
|
||||
const jobs2 = planWeb({ ...cap, shots: { ...cap.shots, b: { path: '/b', delayMs: 5 } } }, { langs: ['en', 'tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b1' });
|
||||
expect(selectJobs(jobs2, cache, 'changed', all).run.map(j => j.frame)).toEqual(['en/phone/b.png', 'tr/phone/b.png']);
|
||||
const jobs3 = planWeb(cap, { langs: ['en', 'tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b2' });
|
||||
expect(selectJobs(jobs3, cache, 'changed', all).run.length).toBe(4);
|
||||
expect(selectJobs(jobs3, cache, 'only-missing', (f: string) => !f.endsWith('tr/phone/a.png')).run.map(j => j.frame)).toEqual(['tr/phone/a.png']);
|
||||
expect(selectJobs(jobs, cache, 'force', all).run.length).toBe(4);
|
||||
});
|
||||
test('timing summary: slowest frames with their slowest step, time per step', () => {
|
||||
const t = (goto: number, waitFor: number) => ({ goto, waitFor, eval: 0, delay: 100, screenshot: 50, total: goto + waitFor + 150 });
|
||||
const s = timingSummary([{ frame: 'en/phone/a.png', timing: t(1000, 200) }, { frame: 'en/phone/b.png', timing: t(100, 5000) }], 1);
|
||||
expect(s.slowest).toEqual([{ frame: 'en/phone/b.png', total: 5250, step: 'waitFor' }]);
|
||||
expect(s.steps).toEqual({ goto: 1100, waitFor: 5200, eval: 0, delay: 200, screenshot: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- a local fixture web app: /<key>/scene/<name>?lang=xx, a SPA (popstate) that counts its boots per key: each
|
||||
// browser test has its own key, so they run at the same time (test.concurrent) ----
|
||||
const boots = new Map<string, number>();
|
||||
const booted = (key: string) => boots.get(key) ?? 0;
|
||||
const server = Bun.serve({
|
||||
port: 0, hostname: '127.0.0.1',
|
||||
fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/app.js') {
|
||||
const key = u.searchParams.get('k') ?? '';
|
||||
boots.set(key, booted(key) + 1);
|
||||
return new Response(`
|
||||
const COLORS = { en: '#1e66f5', de: '#40a02b', tr: '#d20f39' };
|
||||
function show() {
|
||||
const name = location.pathname.split('/').pop() || 'home', lang = new URLSearchParams(location.search).get('lang');
|
||||
document.body.style.cssText = 'margin:0;background:' + (COLORS[lang] || '#000') + ';font:24px sans-serif;color:#fff';
|
||||
document.body.innerHTML = '<div style="padding:20px">' + lang + ' ' + name + '</div><div style="margin:20px;width:' + (40 * name.length) + 'px;height:60px;background:#fff"></div>';
|
||||
window.scene = null;
|
||||
window.shows = (window.shows || 0) + 1;
|
||||
setTimeout(() => { window.scene = name; }, 30);
|
||||
}
|
||||
addEventListener('popstate', show);
|
||||
show();`, { headers: { 'content-type': 'text/javascript' } });
|
||||
}
|
||||
const key = encodeURIComponent(u.pathname.split('/')[1] ?? '');
|
||||
return new Response(`<!doctype html><html><head><meta charset="utf-8"><script src="/app.js?k=${key}" defer></script></head><body></body></html>`, { headers: { 'content-type': 'text/html' } });
|
||||
},
|
||||
});
|
||||
afterAll(() => server.stop(true));
|
||||
|
||||
// launch only: exiting closes the browser's pipe and it quits on its own (a graceful close of Google Chrome, the
|
||||
// fallback without playwright's Chromium, can take 30 s)
|
||||
const canLaunch = spawnSync('bun', ['-e', `const { launch } = await import(${JSON.stringify(join(import.meta.dir, '..', '..', 'cli', 'shots', 'browser.ts'))}); await launch(${JSON.stringify(join(import.meta.dir, '..', '..'))}, { headless: true }); process.exit(0);`], { timeout: 60_000 }).status === 0;
|
||||
if (!canLaunch) console.warn('capture-web browser tests skipped: no Chromium could launch (bunx playwright install chromium)');
|
||||
|
||||
/** A temp project with this config; cli() runs `storebound shots capture <args>` in it. */
|
||||
function project(cfg: ShipConfig, files: Record<string, string>): { root: string; setConfig: (c: ShipConfig) => void; cli: (args: string[]) => Promise<{ out: string; err: string; code: number }> } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sb-cap-'));
|
||||
for (const [p, t] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), t); }
|
||||
const setConfig = (c: ShipConfig) => writeFileSync(join(root, 'storebound.config.json'), JSON.stringify(c));
|
||||
setConfig(cfg);
|
||||
// async: the fixture server lives in this process and must keep answering while the child captures
|
||||
return { root, setConfig, cli: async args => {
|
||||
const r = Bun.spawn(['bun', CLI, 'shots', 'capture', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } });
|
||||
const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited]);
|
||||
// failed frames are warnings on stderr (exit 1): show them, or a CI failure only says which file is missing
|
||||
if (code !== 0) console.error(`storebound shots capture ${args.join(' ')} (exit ${code}):\n${err}`);
|
||||
return { out, err, code };
|
||||
} };
|
||||
}
|
||||
|
||||
const shot = (name: string, extra: object = {}) => ({ path: `/scene/${name}`, waitFor: `window.scene === '${name}'`, delayMs: 20, ...extra });
|
||||
/** `key`: the fixture app's boot counter this config's runs count on (one per browser test). */
|
||||
function config(capture: Partial<ShotsCapture> = {}, key = 'app'): ShipConfig {
|
||||
return { ...BASE, shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', out: 'store/renders', scenes: [{ id: 'hero', palette: 'fire', frames: [{ src: 'hero.png' }] }], phone: { w: 180, h: 240, dpr: 1 },
|
||||
capture: { source: 'web', baseUrl: `http://127.0.0.1:${server.port}/${key}`, query: 'lang={lang}', build: 'src', shots: { hero: shot('hero'), battle: shot('battle'), king: shot('king') }, ...capture } } };
|
||||
}
|
||||
const files = { 'store/shots/copy.json': '{}', 'src/main.js': 'v1' };
|
||||
const frames = (root: string) => ['en', 'de', 'tr'].flatMap(l => ['hero', 'battle', 'king'].map(n => join(root, 'store/shots/frames', l, 'phone', `${n}.png`)));
|
||||
|
||||
describe.skipIf(!canLaunch)('shots capture (web) with a fixture app', () => {
|
||||
test.concurrent('concurrency 3 gives the same files as serial', async () => {
|
||||
const p = project(config(), files);
|
||||
try {
|
||||
await p.cli(['--device', 'phone', '--concurrency', '1', '--force']);
|
||||
const serial = frames(p.root).map(f => readFileSync(f));
|
||||
const r = await p.cli(['--device', 'phone', '--concurrency', '3', '--force']);
|
||||
expect(r.out).toContain('capturing 9 frames, 3 at a time');
|
||||
const parallel = frames(p.root).map(f => readFileSync(f));
|
||||
expect(parallel.length).toBe(9);
|
||||
for (const [i, b] of parallel.entries()) expect(Buffer.compare(b, serial[i]!)).toBe(0);
|
||||
} finally { rmSync(p.root, { recursive: true, force: true }); }
|
||||
}, 120_000);
|
||||
|
||||
test.concurrent('per-language session boots the app once per language; per-shot once per frame', async () => {
|
||||
const p = project(config({ session: 'per-language' }, 'lang'), files);
|
||||
try {
|
||||
const r = await p.cli(['--device', 'phone', '--concurrency', '2', '--force']);
|
||||
expect(booted('lang')).toBe(3);
|
||||
expect(r.out).toContain('(reused page)');
|
||||
const report = JSON.parse(readFileSync(join(p.root, '.storebound/capture-report.json'), 'utf8')) as CaptureReport;
|
||||
expect(report.frames.filter(f => f.reused).length).toBe(6);
|
||||
expect(frames(p.root).every(f => existsSync(f))).toBe(true);
|
||||
// a shot whose precondition fails in the reused page (it needs a freshly booted page) falls back to a fresh page
|
||||
const bad = project(config({ session: 'per-language', reuseTimeoutMs: 300, shots: { hero: shot('hero'), fresh: { path: '/scene/fresh', waitFor: "window.scene === 'fresh' && window.shows === 1", delayMs: 20 } } }, 'fresh'), files);
|
||||
try {
|
||||
const r2 = await bad.cli(['--device', 'phone', '--lang', 'en', '--force']);
|
||||
expect(r2.err).toContain('en/phone/fresh: reused page failed');
|
||||
expect(r2.out).toContain('capture done: 2 frames');
|
||||
expect(booted('fresh')).toBe(2);
|
||||
} finally { rmSync(bad.root, { recursive: true, force: true }); }
|
||||
const q = project(config({}, 'shot'), files);
|
||||
await q.cli(['--device', 'phone', '--lang', 'en', '--force']);
|
||||
rmSync(q.root, { recursive: true, force: true });
|
||||
expect(booted('shot')).toBe(3);
|
||||
} finally { rmSync(p.root, { recursive: true, force: true }); }
|
||||
}, 180_000);
|
||||
|
||||
test.concurrent('cache skips unchanged frames, re-runs changed ones; --only-missing; timing breakdown', async () => {
|
||||
const p = project(config(), files);
|
||||
try {
|
||||
let r = await p.cli(['--device', 'phone']);
|
||||
expect(r.out).toContain('capturing 9 frames');
|
||||
expect(r.out).toMatch(/tr\/phone\/king\.png goto [\d.]+s wait [\d.]+s eval [\d.]+s delay [\d.]+s shot [\d.]+s = [\d.]+s/);
|
||||
expect(r.out).toContain('time per step: goto');
|
||||
expect(r.out).toContain('slowest: ');
|
||||
const report = JSON.parse(readFileSync(join(p.root, '.storebound/capture-report.json'), 'utf8')) as CaptureReport;
|
||||
expect(Object.keys(report.frames[0]!.timing).sort()).toEqual(['delay', 'eval', 'goto', 'screenshot', 'total', 'waitFor']);
|
||||
expect(report.summary.slowest.length).toBe(5);
|
||||
|
||||
r = await p.cli(['--device', 'phone']);
|
||||
expect(r.out).toContain('9 of 9 frames unchanged, skipped');
|
||||
expect(r.out).toContain('capturing 0 frames');
|
||||
|
||||
// a changed shot entry re-runs only that shot (3 languages)
|
||||
p.setConfig(config({ shots: { hero: shot('hero'), battle: shot('battle', { delayMs: 40 }), king: shot('king') } }));
|
||||
r = await p.cli(['--device', 'phone']);
|
||||
expect(r.out).toContain('6 of 9 frames unchanged');
|
||||
expect(r.out).toContain('capturing 3 frames');
|
||||
|
||||
// a build change re-runs everything; --only-missing only the missing file
|
||||
await Bun.write(join(p.root, 'src/main.js'), 'v2 changed');
|
||||
rmSync(frames(p.root)[0]!);
|
||||
r = await p.cli(['--device', 'phone', '--only-missing']);
|
||||
expect(r.out).toContain('capturing 1 frames');
|
||||
r = await p.cli(['--device', 'phone']);
|
||||
expect(r.out).toContain('capturing 8 frames'); // the one just captured already has the new inputs
|
||||
} finally { rmSync(p.root, { recursive: true, force: true }); }
|
||||
}, 180_000);
|
||||
});
|
||||
1082
tests/browser/console-fill.browser.ts
Normal file
1082
tests/browser/console-fill.browser.ts
Normal file
File diff suppressed because it is too large
Load diff
507
tests/browser/console-flow.browser.ts
Normal file
507
tests/browser/console-flow.browser.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
// storebound console fill walks a whole form: it clicks every navigation control (Next, App Privacy's Get Started,
|
||||
// Edit next to Data Types, each data type) and App Privacy's in-dialog draft Save itself, stops only for an uncertain
|
||||
// answer (until the owner set it), an agreement or the final button (Publish, Submit, Save, Create), which stays the
|
||||
// owner's. Runs fillSession against MODELED pages (tests/fixtures/console) on 127.0.0.1. No network.
|
||||
// Guardrail: no log line ever asks the owner to click a navigation control.
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { engineWaits, fillSession } from '../../cli/console/fill';
|
||||
import type { ConsoleBrowser } from '../../cli/console/session';
|
||||
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../../cli/lib/app-privacy';
|
||||
import type { ShipConfig } from '../../cli/lib/config';
|
||||
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, setUpAllowed, setUpTexts, TYPE_ALIASES } from '../../cli/lib/console-forms';
|
||||
import { APPLE_PRIVACY_TYPES } from '../../cli/lib/app-privacy';
|
||||
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, scaled, TEST_TICK_MS, TEST_WAITS, testScope } from '../helpers/browser';
|
||||
|
||||
const FIX = join(import.meta.dir, '..', 'fixtures', 'console');
|
||||
const base0: ShipConfig = {
|
||||
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
|
||||
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
|
||||
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
|
||||
play: { packageName: 'com.example.game' },
|
||||
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] } } },
|
||||
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
|
||||
} as ShipConfig;
|
||||
/** Every App Privacy answer certain: Device ID and Crash Data collected, nothing else. */
|
||||
function privacy(over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}): ShipConfig {
|
||||
const types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
||||
types.DEVICE_ID = { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true };
|
||||
types.CRASH_DATA = { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false };
|
||||
return { ...base0, dataSafety: undefined, appPrivacy: { types: { ...types, ...over } } } as unknown as ShipConfig;
|
||||
}
|
||||
|
||||
const shared = await launchTestBrowser('playwright');
|
||||
const { browser, skipWhy } = shared;
|
||||
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||
const scope = testScope(() => browser);
|
||||
// a whole App Privacy walk (6 types x 5 dialog steps) on a loaded CI machine: generous, still bounded
|
||||
const TEST_MS = scaled(90_000);
|
||||
const WALK_MS = scaled(75_000);
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let base = '';
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
||||
const name = new URL(req.url).pathname.slice(1);
|
||||
if (!/^[a-z_-]+\.(html|js)$/.test(name)) return new Response('not found', { status: 404 });
|
||||
return new Response(Bun.file(join(FIX, name)));
|
||||
} });
|
||||
base = `http://127.0.0.1:${server.port}/`;
|
||||
});
|
||||
afterEach(() => scope.settle(), AFTER_EACH_MS);
|
||||
let restoreWaits = () => {};
|
||||
beforeAll(() => { restoreWaits = engineWaits(TEST_WAITS); });
|
||||
afterAll(async () => { restoreWaits(); await shared.close(); server?.stop(true); }, AFTER_ALL_MS);
|
||||
|
||||
async function local(): Promise<ConsoleBrowser> {
|
||||
const ctx = scope.track(await browser.newContext());
|
||||
const page = await ctx.newPage();
|
||||
let closed = false;
|
||||
const done = new Promise<void>(r => ctx.on('close', () => { closed = true; r(); }));
|
||||
return { mode: 'profile', context: ctx, page, pages: () => ctx.pages(), closed: done, isClosed: () => closed, close: () => ctx.close(), cleanup: () => {} };
|
||||
}
|
||||
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: TEST_TICK_MS, navTimeoutMs: scaled(5000), signInWaitMs: 30_000, env: {} });
|
||||
const clicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__clicks);
|
||||
const navClicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__navClicks());
|
||||
/** Guardrail: a line that tells the owner to click a navigation control themselves. */
|
||||
const NAV_YOURSELF = /\b(Next|Continue|Get Started|Edit|Back|Set Up|Start|Manage|New App)\b[^.]*\byourself\b|\byourself\b[^.]*\b(Next|Continue|Get Started|Edit next|Set Up)\b/;
|
||||
const navAsked = (lines: string[]) => lines.filter(l => NAV_YOURSELF.test(l));
|
||||
const PUBLISH = 'review the summary, then press Publish yourself';
|
||||
|
||||
describe('App Privacy data types as the real Data Collection dialog names them (label + help text, no separator)', () => {
|
||||
// the labels in the order of a real dialog (October 2026); each name is the label run together with its help text
|
||||
const REAL = ['Name', 'Email Address', 'Phone Number', 'Physical Address', 'Other User Contact Info', 'Health', 'Fitness', 'Payment Info', 'Credit Info',
|
||||
'Other Financial Info', 'Precise Location', 'Coarse Location', 'Sensitive Info', 'Contacts', 'Emails or Text Messages', 'Photos or Videos', 'Audio Data',
|
||||
'Gameplay Content', 'Customer Support', 'Other User Content', 'Browsing History', 'Search History', 'User ID', 'Device ID', 'Purchases',
|
||||
'Product Interaction', 'Advertising Data', 'Other Usage Data', 'Crash Data', 'Performance Data', 'Other Diagnostic Data', 'Environment Scanning', 'Hands',
|
||||
'Head', 'Other Data'];
|
||||
const names = REAL.map(l => `${l}Such as a placeholder help text`);
|
||||
test('every Apple data type matches exactly one checkbox; "Name" never matches "Phone Number...", "Head" never "Health..."', () => {
|
||||
for (const [, label] of Object.values(APPLE_PRIVACY_TYPES)) {
|
||||
const cands = labelNames(label, TYPE_ALIASES[label]);
|
||||
// what find() does: the first candidate name that matches anything decides, and it must match one checkbox
|
||||
const hits = cands.map(c => names.filter(n => (typeof c === 'string' ? n === c : c.test(n)))).find(h => h.length) ?? [];
|
||||
expect({ label, hits: hits.length }).toEqual({ label, hits: 1 });
|
||||
expect(hits[0]!.startsWith(TYPE_ALIASES[label]?.[0] ?? label)).toBe(true);
|
||||
}
|
||||
expect(labelNames('Contact').some(c => typeof c !== 'string' && c.test('ContactsSuch as a list'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
|
||||
const at = (w: Partial<FlowWhere> = {}): FlowWhere => ({ privacyPage: true, inDialog: false, inDataTypes: false, dataTypes: ['Device ID'], ...w });
|
||||
test('App Privacy: openers, Next and the in-dialog Save are clicked; Publish never', () => {
|
||||
expect(flowAllowed('apple-app-privacy', 'Get Started', 'button', at())).toBe(true);
|
||||
expect(flowAllowed('apple-app-privacy', 'Başlayın', 'button', at())).toBe(true);
|
||||
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at({ inDataTypes: true }))).toBe(true);
|
||||
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at())).toBe(false); // the Privacy Policy URL's Edit
|
||||
expect(flowAllowed('apple-app-privacy', 'Set Up Device ID', 'button', at({ inDataTypes: true }))).toBe(true);
|
||||
expect(flowAllowed('apple-app-privacy', 'Device ID', 'link', at({ inDataTypes: true }))).toBe(true);
|
||||
expect(flowAllowed('apple-app-privacy', 'Next', 'button', at({ inDialog: true }))).toBe(true);
|
||||
expect(flowClass('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe('draft-save');
|
||||
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe(true);
|
||||
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at())).toBe(false); // not in a questionnaire dialog
|
||||
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true, privacyPage: false }))).toBe(false);
|
||||
for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false);
|
||||
expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false);
|
||||
});
|
||||
test('the Set Up text gate: exactly "Set Up <a type storebound answers>" in the Data Types section, outside dialogs', () => {
|
||||
const w = at({ inDataTypes: true, dataTypes: ['Coarse Location', 'Purchase History', 'Purchases'] });
|
||||
for (const t of ['Set Up Coarse Location', 'set up coarse location', 'Set Up Purchase History', 'Set Up Purchases']) expect(setUpAllowed(t, w)).toBe(true);
|
||||
for (const t of ['?', 'More information', 'Set Up Device ID', 'Set Up Coarse Location now', 'Publish', 'Coarse Location', 'Edit']) expect(setUpAllowed(t, w)).toBe(false);
|
||||
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDataTypes: false })).toBe(false);
|
||||
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDialog: true })).toBe(false);
|
||||
expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false);
|
||||
expect(setUpTexts('Purchase History')).toContain('Set Up Purchases');
|
||||
});
|
||||
test('other forms: Next is navigation; the content rating\'s Save draft and the Play declarations\' Save are clicked; Submit, Create, Done, Send changes for review stay the owner\'s', () => {
|
||||
const w = at({ privacyPage: false });
|
||||
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
|
||||
expect(flowAllowed(f, 'Next', 'button', w)).toBe(true);
|
||||
expect(flowAllowed(f, 'İleri', 'button', w)).toBe(true);
|
||||
expect(flowAllowed(f, 'Get Started', 'button', w)).toBe(false);
|
||||
expect(flowAllowed(f, 'Save draft', 'button', w)).toBe(f === 'play-content-rating');
|
||||
expect(flowAllowed(f, 'Taslağı kaydet', 'button', w)).toBe(f === 'play-content-rating');
|
||||
// an App content declaration's Save goes to Google only with Send changes for review (verified 2026-10-13)
|
||||
expect(flowAllowed(f, 'Save', 'button', w)).toBe(f === 'play-content-rating' || f === 'play-target-audience');
|
||||
for (const n of ['Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish', 'Send changes for review', 'Submit 3 changes for review', 'Restart review']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
|
||||
}
|
||||
expect(flowClass('play-content-rating', 'Save draft', 'button', w)).toBe('draft-save');
|
||||
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('declaration-save');
|
||||
expect(flowClass('play-content-rating', 'Save', 'button', w)).toBe('declaration-save');
|
||||
expect(flowClass('apple-app-record', 'Save', 'button', w)).toBe('final-submit');
|
||||
expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit');
|
||||
expect(flowAllowed('play-target-audience', 'Save', 'button', at({ privacyPage: false, inDialog: true }))).toBe(false);
|
||||
});
|
||||
test('every form has its buttons classified; only navigation, the agreed draft saves (App Privacy, content rating) and the Play declaration saves are automatic', () => {
|
||||
for (const [f, rules] of Object.entries(FORM_BUTTONS)) {
|
||||
expect(rules.length).toBeGreaterThan(0);
|
||||
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')
|
||||
|| (['play-content-rating', 'play-target-audience'].includes(f) && r.class === 'declaration-save')).toBe(true);
|
||||
expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true);
|
||||
}
|
||||
for (const f of ['play-content-rating', 'play-target-audience'] as const) expect(FORM_BUTTONS[f].some(r => r.button === 'Send changes for review' && r.class === 'final-submit' && !r.auto)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-app-privacy-flow.html)', () => {
|
||||
scope.concurrent('first time: Get Started > yes > Next > data types > Save > each type (purposes > Next > linked > Next > tracking > Save); Publish never clicked', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
const c = await clicks(b);
|
||||
expect(c).not.toContain('Publish');
|
||||
expect(c).not.toContain('See Details');
|
||||
expect(c).not.toContain('Edit'); // neither the Privacy Policy Edit nor (first time) the Data Types one
|
||||
expect(c.filter(x => x === 'Get Started')).toHaveLength(1);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3); // the type list + Device ID + Crash Data, drafts
|
||||
expect(await navClicks(b)).toContain('Crash Data'); // the link of the second type
|
||||
expect(c).toContain('Set Up Device ID');
|
||||
expect(log.lines).toContain('clicked "Save" (draft-save)');
|
||||
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
expect((await run).fallback).toBe(false);
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } });
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
|
||||
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(4); // the list + three types
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('an uncertain answer stops the walk on its page until the owner sets it, then it goes on', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const config = privacy({ DEVICE_ID: { collected: true, purposes: ['ANALYTICS'], tracking: false } }); // linked: not in the config
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
||||
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Device ID: linked to the user"')));
|
||||
await Bun.sleep(600); // several rounds: Next must not be clicked past the open question
|
||||
const linked = b.page.getByRole('radio', { name: /^No, Device ID .* not linked/ });
|
||||
expect(await linked.isVisible()).toBe(true);
|
||||
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
|
||||
await linked.check(); // the owner answers
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(log.lines).toContain('"Device ID: linked to the user" answered: storebound goes on');
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('no data collected: "No" and the dialog\'s Save, then the summary', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const types = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
||||
const config = { ...base0, dataSafety: undefined, appPrivacy: { types } } as unknown as ShipConfig;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(1);
|
||||
expect(await clicks(b)).not.toContain('Next');
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
});
|
||||
|
||||
/** Six collected types as on the real page (Purchase History shows as "Purchases"), every answer certain. */
|
||||
const SIX: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {
|
||||
COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'], linked: false, tracking: false },
|
||||
EMAILS_OR_TEXT_MESSAGES: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
|
||||
DEVICE_ID: { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true },
|
||||
PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
|
||||
PRODUCT_INTERACTION: { collected: true, purposes: ['ANALYTICS', 'PRODUCT_PERSONALIZATION'], linked: false, tracking: false },
|
||||
CRASH_DATA: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false },
|
||||
};
|
||||
const six = (over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}) => privacy({ CRASH_DATA: SIX.CRASH_DATA!, ...SIX, ...over });
|
||||
const SIX_NAMES = ['Coarse Location', 'Emails or Text Messages', 'Device ID', 'Purchase History', 'Product Interaction', 'Crash Data'];
|
||||
const SETUP = 'apple-app-privacy-setup.html';
|
||||
const answersOf = (b: ConsoleBrowser) => b.page.evaluate(() => (window as any).__answers);
|
||||
|
||||
describe.skipIf(!browser)('App Privacy: each data type set up from the real page ("Set Up <type>" as plain text; fixture apple-app-privacy-setup.html)', () => {
|
||||
scope.concurrent('every collected type is set up through its dialog (purposes > Next > linked > Next > two info steps > Next > Next > tracking > Save; purpose checkboxes without an accessible name); "?", Publish and the Edits never clicked', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(SIX_NAMES.map(n => `Set Up ${n}`));
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||
const got = await answersOf(b);
|
||||
expect(got['Device ID']).toEqual({ purposes: ['Third-Party Advertising', 'Analytics'], linked: 'no', tracking: 'yes' });
|
||||
expect(got['Purchase History']).toEqual({ purposes: ['App Functionality'], linked: 'yes', tracking: 'no' });
|
||||
expect(got['Product Interaction']).toEqual({ purposes: ['Analytics', 'Product Personalization'], linked: 'no', tracking: 'no' });
|
||||
expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort());
|
||||
const c = await clicks(b);
|
||||
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back', 'Definitions and Examples']) expect(c).not.toContain(never);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(24); // purposes, linked and the TWO info steps of each of 6 types
|
||||
expect(log.lines.filter(m => m === 'an information-only step of the dialog: storebound pressed Next')).toHaveLength(12);
|
||||
expect(c.filter(x => x === 'Save')).toHaveLength(6);
|
||||
expect(log.lines).toContain('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
|
||||
expect(log.lines).toContain('clicked "Set Up Purchase History" (navigation)');
|
||||
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
expect((await run).fallback).toBe(false);
|
||||
}, TEST_MS);
|
||||
|
||||
// --debug-dump writes a page dump (summary, HTML, screenshot) at every step: the first and the last type are walked
|
||||
// (the four between were set up before with the config's purposes and are left as they are), which covers every
|
||||
// kind of dump without six types' worth of screenshots on a loaded machine
|
||||
scope.concurrent('every step dumped with --debug-dump: the start, each Set Up, each dialog step and each filled step', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const before = ['Emails or Text Messages:App Functionality', 'Device ID:Third-Party Advertising|Analytics', 'Purchase History:App Functionality', 'Product Interaction:Analytics|Product Personalization'];
|
||||
const url = `${base}${SETUP}?${before.map(p => `preset=${encodeURIComponent(p)}`).join('&')}`;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log), debugDir: dir, debugEveryStep: true }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(['Set Up Coarse Location', 'Set Up Crash Data']);
|
||||
expect(log.lines).toContain('Device ID was set up before with the purposes of the config: left as it is');
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
const names = readdirSync(dir);
|
||||
for (const want of [/-after-set-up-coarse-location$/, /-coarse-location-after-next$/, /-filled-coarse-location-purposes$/, /-filled-coarse-location-linked-to-/, /-filled-crash-data-tracking$/])
|
||||
expect(names.some(n => want.test(n))).toBe(true);
|
||||
const step = names.find(n => /-coarse-location-after-next$/.test(n))!;
|
||||
expect(JSON.parse(readFileSync(join(dir, step, 'page.json'), 'utf8')).dialogs).toContain('Coarse Location');
|
||||
await b.context.close();
|
||||
expect((await run).fallback).toBe(false);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const config = six({ PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], tracking: false } }); // linked: the owner's
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + SETUP, ...fast(log) }));
|
||||
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Purchase History: linked to the user": Purchase History: linked to the user\'s identity?')));
|
||||
await Bun.sleep(600); // several rounds: Next is not clicked past the open question
|
||||
const yes = b.page.getByRole('radio', { name: /^Yes, purchase history .* linked/ });
|
||||
expect(await yes.isVisible()).toBe(true);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||
await yes.check(); // the owner answers
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(log.lines).toContain('"Purchase History: linked to the user" answered: storebound goes on');
|
||||
expect((await answersOf(b))['Purchase History'].linked).toBe('yes');
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
expect(await clicks(b)).not.toContain('?');
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const started = Date.now();
|
||||
const run = scope.run(fillSession(b, {
|
||||
form: 'apple-app-privacy', config: six(), startUrl: `${base}${SETUP}?unknown=Device%20ID`, ...fast(log),
|
||||
stepTimeoutMs: 1500, idleLimitMs: 60_000, heartbeatMs: 700, debugDir: dir,
|
||||
}));
|
||||
await log.wait('the pause', l => l.some(m => m.startsWith('paused on "Device ID"')));
|
||||
expect(Date.now() - started).toBeLessThan(30_000);
|
||||
const said = log.lines.join('\n');
|
||||
expect(said).toMatch(/the "Device ID" dialog step after "Next" is not a page storebound knows .*Buttons in the dialog: "Back", "Cancel", "Next"/);
|
||||
expect(said).toContain(`Debug saved to ${dir}`);
|
||||
// the Device ID pause's dump (on a loaded machine a slow earlier step can get a dump of its own first)
|
||||
const sub = readdirSync(dir).find(n => /unknown/.test(n) && /device-id/.test(n))!;
|
||||
const pj = JSON.parse(readFileSync(join(dir, sub, 'page.json'), 'utf8'));
|
||||
expect(pj.dialogs).toContain('Device ID');
|
||||
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false });
|
||||
await Bun.sleep(500);
|
||||
const c = await clicks(b);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(2 * 4 + 1); // two types done (purposes, linked, the two info steps), Device ID's first step only
|
||||
expect(c).not.toContain('Publish');
|
||||
expect(c).not.toContain('?');
|
||||
await b.context.close();
|
||||
expect((await run).fallback).toBe(false);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(log.lines).toContain('Crash Data was set up before with the purposes of the config: left as it is');
|
||||
expect(log.lines).toContain('Device ID was set up before with other purposes than the config: storebound opens it with its Edit');
|
||||
const got = await answersOf(b);
|
||||
expect(got['Device ID'].purposes).toEqual(['Third-Party Advertising', 'Analytics']);
|
||||
expect(got['Crash Data']).toBeUndefined();
|
||||
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1); // Device ID's own, never the section's
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
});
|
||||
|
||||
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
|
||||
scope.concurrent('content rating: waits for the owner\'s IARC Terms tick, Save draft and Next by itself on each page, a stop at the uncertain questions until answered, then the Summary\'s Save (a declaration save)', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
|
||||
// page 1: Next does nothing until the IARC Terms box is ticked (verified 2026-10-13): storebound waits for the owner
|
||||
await log.wait('the agreement stop', l => l.some(m => m.startsWith('agreement needed on "Email address and category"') && m.includes('IARC Terms of Use')));
|
||||
await Bun.sleep(300);
|
||||
expect(await clicks(b)).toEqual([]);
|
||||
expect(await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).isChecked()).toBe(false); // never ticked by storebound
|
||||
await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).check(); // the owner
|
||||
await log.wait('the questionnaire stop', l => l.some(m => m.startsWith('answer needed on "Questionnaire"')));
|
||||
expect(log.lines).toContain('clicked "Save draft" (draft-save)');
|
||||
expect(log.lines).toContain('clicked "Next" (navigation)');
|
||||
expect(await clicks(b)).toEqual(['Save draft', 'Next']);
|
||||
await Bun.sleep(500);
|
||||
expect(await clicks(b)).toEqual(['Save draft', 'Next']); // nothing saved or moved past the open questions
|
||||
// the owner answers every outlined question
|
||||
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
|
||||
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
|
||||
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save') && m.includes('Send changes for review in Publishing overview yourself')));
|
||||
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next', 'Save']);
|
||||
expect(log.lines).toContain('clicked "Save" (declaration-save)');
|
||||
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next and the declaration\'s Save; Send changes for review stays the owner\'s', async () => {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log) }));
|
||||
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
|
||||
expect(await clicks(b)).toEqual(['Next']);
|
||||
await b.page.getByRole('radiogroup', { name: /appeal to children/ }).getByRole('radio', { name: 'No' }).check();
|
||||
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save')));
|
||||
expect(await clicks(b)).toEqual(['Next', 'Next', 'Save']);
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
await run;
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('New App (App Store) and Create app (Play): filled, the final button is the owner\'s, no navigation asked of the owner', async () => {
|
||||
for (const [form, file, final] of [['apple-app-record', 'apple-app-record.html', 'Create'], ['play-app-record', 'play-create-app.html', 'Create app']] as const) {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form, config: base0, startUrl: base + file, ...fast(log) }));
|
||||
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
|
||||
// Play: only the read-only package name check (verified 2026-10-12); never the final button
|
||||
expect(await clicks(b)).toEqual(form === 'play-app-record' ? ['Check availability'] : []);
|
||||
// App Store Connect's New App (React, verified 2026-10-13): a programmatic value set left its state empty; the
|
||||
// engine types like a person (the fixture's state only follows key presses)
|
||||
if (form === 'apple-app-record') {
|
||||
const state = await b.page.evaluate(() => (window as any).__state);
|
||||
const shown = await b.page.evaluate(() => [...document.querySelectorAll('input[type=text]')].map(i => (i as HTMLInputElement).value));
|
||||
expect(state.Name).toBe('Example Game');
|
||||
expect([state.Name, state.SKU]).toEqual(shown);
|
||||
}
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
await run;
|
||||
}
|
||||
}, TEST_MS);
|
||||
});
|
||||
|
||||
describe.skipIf(!browser)('watchdog: an unknown page after a click never means a silent wait', () => {
|
||||
scope.concurrent('Next leads to a page storebound does not know: heartbeat, a sanitized debug dump, the manual steps, a bounded stop', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const started = Date.now();
|
||||
const out = await scope.run(fillSession(b, {
|
||||
form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-unknown.html', ...fast(log),
|
||||
stepTimeoutMs: 1500, idleLimitMs: 5000, heartbeatMs: 700, debugDir: dir,
|
||||
}));
|
||||
expect(Date.now() - started).toBeLessThan(30_000);
|
||||
expect(out.timedOut).toBe(true);
|
||||
expect(out.debugDir).toBe(dir);
|
||||
expect(b.isClosed()).toBe(true);
|
||||
const said = log.lines.join('\n');
|
||||
expect(said).toContain('clicked "Next" (navigation)');
|
||||
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
|
||||
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/);
|
||||
expect(said).toContain(`Debug saved to ${dir}`);
|
||||
expect(said).toContain('this part is left to the calling agent with its browser tool (`storebound console plan apple-app-privacy`');
|
||||
expect(said).toContain('it stops here');
|
||||
// the dump of the unknown page (on a loaded machine a slow earlier step can get a dump of its own first)
|
||||
const sub = readdirSync(dir).find(n => /unknown-the-page-after-next/.test(n));
|
||||
const files = readdirSync(join(dir, sub!)).sort();
|
||||
expect(files).toEqual(['page.html', 'page.json', 'screen.jpg']);
|
||||
const pj = JSON.parse(readFileSync(join(dir, sub!, 'page.json'), 'utf8'));
|
||||
expect(pj.dialogs).toContain('Data Collection');
|
||||
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Contact details', checked: false });
|
||||
expect(pj.url).toBe(`${base}apple-app-privacy-unknown.html`);
|
||||
const all = readFileSync(join(dir, sub!, 'page.html'), 'utf8') + JSON.stringify(pj);
|
||||
for (const leak of ['sb-token-should-not-leak', 'sb-csrf-should-not-leak', 'typed-by-owner-should-not-leak', 'owner@example.com', '010-9999', 'Example Owner']) expect(all).not.toContain(leak);
|
||||
expect(all).toContain('[email]');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, TEST_MS);
|
||||
|
||||
scope.concurrent('--debug-dump: a dump at the start, after each click and after each filled page', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log), debugDir: dir, debugEveryStep: true }));
|
||||
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
|
||||
await Bun.sleep(500);
|
||||
const names = readdirSync(dir).sort();
|
||||
expect(names[0]).toBe('01-start');
|
||||
expect(names.some(n => /-filled-target-age$/.test(n))).toBe(true);
|
||||
expect(names.some(n => /-after-next$/.test(n))).toBe(true);
|
||||
await b.context.close();
|
||||
await run;
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, TEST_MS);
|
||||
});
|
||||
|
||||
describe('every wait of the console engine is bounded (code scan)', () => {
|
||||
const SRC = join(import.meta.dir, '..', '..', 'cli', 'console');
|
||||
const files = readdirSync(SRC).filter(f => f.endsWith('.ts'));
|
||||
/** The text from `open` to its matching `close` (brackets balanced). */
|
||||
const span = (s: string, at: number, open: string, close: string): string => {
|
||||
let d = 0;
|
||||
for (let i = s.indexOf(open, at); i >= 0 && i < s.length; i++) { if (s[i] === open) d++; else if (s[i] === close && --d === 0) return s.slice(at, i + 1); }
|
||||
return s.slice(at);
|
||||
};
|
||||
test('a while loop always checks a deadline (Date.now / end) or the watchdog', () => {
|
||||
const bad: string[] = [];
|
||||
for (const f of files) {
|
||||
const s = readFileSync(join(SRC, f), 'utf8');
|
||||
for (const m of s.matchAll(/\bwhile \(/g)) {
|
||||
const cond = span(s, m.index!, '(', ')'), body = span(s, m.index! + cond.length, '{', '}');
|
||||
if (!/Date\.now\(\)|\bend\b|\bwatch\(/.test(cond + body)) bad.push(`${f}: ${cond.slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
test('no Playwright wait or click without a timeout, and the window\'s close is awaited only after a bounded wait or a close()', () => {
|
||||
const bad: string[] = [];
|
||||
for (const f of files) {
|
||||
const lines = readFileSync(join(SRC, f), 'utf8').split('\n');
|
||||
lines.forEach((l, i) => {
|
||||
if (/\.waitFor(Selector|URL|Event|LoadState|Function)?\(/.test(l) && !/timeout/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
||||
if (/\.(click|setChecked|check|uncheck)\(/.test(l) && !/timeout|ACTION_MS/.test(l) && !/^\s*(\/\/|\*)/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
||||
if (/await [\w.]+\.closed\s*;/.test(l) && !lines.slice(Math.max(0, i - 3), i).some(p => /close\(\)/.test(p))) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
||||
});
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
60
tests/browser/console-persist.browser.ts
Normal file
60
tests/browser/console-persist.browser.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// storebound console fill, mode (c): a session cookie (no Expires / Max-Age) survives closing and relaunching
|
||||
// storebound's own persistent profile (--restore-last-session plus a graceful close). Launched with the browser kind the
|
||||
// product uses for that profile (cli/shots/browser.ts, kind "visible"): real Google Chrome, else Playwright's full
|
||||
// Chromium, headless here so no window appears. Never the headless shell: it does not restore the last session, so the
|
||||
// cookie is always lost there. Skips (with the reason) only when neither is installed. Slow (two real Chrome launches
|
||||
// and graceful closes per store), so tools/ci-plan.ts lists it as HEAVY (the dedicated browser shard).
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { type ConsoleBrowser, openConsoleBrowser } from '../../cli/console/session';
|
||||
import { installedBrowsers } from '../../cli/shots/browser';
|
||||
import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS, persistentBrowser } from '../helpers/browser';
|
||||
|
||||
const real = await persistentBrowser();
|
||||
if (!real.label) console.warn(`console-persist tests skipped: ${real.skipWhy}`);
|
||||
const TEST_MS = 2 * LAUNCH_TIMEOUT_MS + 30_000; // two launches (each capped) plus the test itself
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let base = '';
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path === '/session-set') return new Response('<p>set</p>', { headers: { 'content-type': 'text/html', 'set-cookie': 'sbsession=example; Path=/; HttpOnly' } });
|
||||
if (path === '/session-echo') return new Response(`<p id="c">${/(?:^|; )sbsession=example(?:;|$)/.test(req.headers.get('cookie') ?? '') ? 'kept' : 'lost'}</p>`, { headers: { 'content-type': 'text/html' } });
|
||||
return new Response('not found', { status: 404 });
|
||||
} });
|
||||
base = `http://127.0.0.1:${server.port}/`;
|
||||
});
|
||||
afterAll(() => { server?.stop(true); });
|
||||
|
||||
describe.skipIf(!real.label)(`storebound's own profile (${real.label ?? 'no real Chrome'})`, () => {
|
||||
for (const store of ['apple', 'play'] as const) {
|
||||
test.concurrent(`${store}: a session cookie survives closing and relaunching storebound's own profile`, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), `storebound-profile-${store}-`));
|
||||
const root = join(import.meta.dir, '..', '..');
|
||||
const { chromium } = await import('playwright');
|
||||
// the resolver's own choice, without a STOREBOUND_CHROME from the environment
|
||||
const installed = installedBrowsers(chromium, { ...process.env, STOREBOUND_CHROME: undefined });
|
||||
const opts = { store, profileDir: dir, fresh: true, headless: true, say: () => {}, launchTimeoutMs: LAUNCH_TIMEOUT_MS, launchDeps: { installed } };
|
||||
let b: ConsoleBrowser | null = null;
|
||||
try {
|
||||
b = await openConsoleBrowser(root, opts);
|
||||
await b.page.goto(base + 'session-set');
|
||||
await b.close(); // graceful: Chrome writes its cookie store
|
||||
await b.closed;
|
||||
b = await openConsoleBrowser(root, opts);
|
||||
expect(b.pages()).toHaveLength(1); // the restored tab was closed again
|
||||
await b.page.goto(base + 'session-echo');
|
||||
expect(await b.page.textContent('#c')).toBe('kept');
|
||||
await b.close();
|
||||
await b.closed;
|
||||
} finally {
|
||||
// a failed assertion must not leave this Chrome running into the next test
|
||||
if (b && !b.isClosed()) await capped(b.context.close(), CLOSE_TIMEOUT_MS);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, TEST_MS);
|
||||
}
|
||||
});
|
||||
282
tests/browser/console-session.browser.ts
Normal file
282
tests/browser/console-session.browser.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// storebound console fill: which browser it uses. Pure parts (data dir, profile pick, file selection, cookie host
|
||||
// filter, DevToolsActivePort, the opt-in flag, the browser plan, Google's rejected page, the launch switches) and the
|
||||
// session copy on throwaway folders (cleanup on success and on error). Safety: the default run never copies Google
|
||||
// (or Apple) cookies; the opt-in copy of Google cookies warns about device-bound sessions. Integration (real Google
|
||||
// Chrome, throwaway profiles, a local fixture site): the window storebound starts has navigator.webdriver false and no
|
||||
// automation switch on its real command line (chrome://version); the opt-in copy carries a cookie of the source
|
||||
// profile and drops another host's. It never touches the real Chrome profile or a store site; it skips when Google
|
||||
// Chrome is not installed or not on macOS.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { APPLE_COPY_NOTE, AUTOMATION_SWITCHES, browserPlan, chromeDataDir, consoleChromeArgs, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, GOOGLE_COPY_WARNING, GOOGLE_REJECTED, googleRejected, NEVER_COPY, openConsoleBrowser, parseUseChromeSession, pickProfile, PROVIDER_HOSTS, sessionFiles } from '../../cli/console/session';
|
||||
import { sandboxArgs } from '../../cli/shots/browser';
|
||||
import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS } from '../helpers/browser';
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'sb-session-test-'));
|
||||
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
const tmp = (name: string) => { const d = join(scratch, name); mkdirSync(d, { recursive: true }); return d; };
|
||||
|
||||
async function cookieDb(path: string, hosts: string[]): Promise<void> {
|
||||
const { Database } = await import('bun:sqlite');
|
||||
const db = new Database(path);
|
||||
db.exec('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB)');
|
||||
const ins = db.prepare('INSERT INTO cookies VALUES (?, ?, ?, ?)');
|
||||
for (const h of hosts) ins.run(h, 'c', '', new Uint8Array([1]));
|
||||
db.close();
|
||||
}
|
||||
|
||||
/** A fake Chrome user data dir with the session files and the ones that must never be copied. */
|
||||
async function fakeChrome(name: string, profile = 'Default'): Promise<string> {
|
||||
const d = tmp(name);
|
||||
writeFileSync(join(d, 'Local State'), JSON.stringify({ profile: { last_used: profile } }));
|
||||
mkdirSync(join(d, profile), { recursive: true });
|
||||
await cookieDb(join(d, profile, 'Cookies'), ['.apple.com', 'appstoreconnect.apple.com', '.google.com', 'play.google.com', 'accounts.google.com', '.youtube.com', 'mail.google.com', '.example.com', 'news.example.org']);
|
||||
writeFileSync(join(d, profile, 'Cookies-journal'), '');
|
||||
for (const f of ['Login Data', 'History', 'Web Data', 'Bookmarks', 'Preferences']) writeFileSync(join(d, profile, f), 'x');
|
||||
mkdirSync(join(d, profile, 'Extensions'), { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
describe('pure parts', () => {
|
||||
test('Chrome user data dir per OS', () => {
|
||||
expect(chromeDataDir('darwin', '/Users/you')).toBe('/Users/you/Library/Application Support/Google/Chrome');
|
||||
expect(chromeDataDir('linux', '/home/you', {})).toBe('/home/you/.config/google-chrome');
|
||||
expect(chromeDataDir('win32', 'C:\\Users\\you', { LOCALAPPDATA: 'L' })).toContain('User Data');
|
||||
});
|
||||
test('profile: --chrome-profile, else last used, else Default; no path tricks', () => {
|
||||
expect(pickProfile(null)).toBe('Default');
|
||||
expect(pickProfile('{"profile":{"last_used":"Profile 2"}}')).toBe('Profile 2');
|
||||
expect(pickProfile('{"profile":{"last_used":"../x"}}')).toBe('Default');
|
||||
expect(pickProfile('not json', 'Profile 1')).toBe('Profile 1');
|
||||
expect(() => pickProfile(null, '../Default')).toThrow('--chrome-profile');
|
||||
});
|
||||
test('files: Local State and the cookie store only, never passwords, history or extensions', () => {
|
||||
const all = new Set(['Local State', 'Default/Cookies', 'Default/Cookies-journal', 'Default/Login Data', 'Default/History', 'Default/Network/Cookies']);
|
||||
expect(sessionFiles('Default', r => all.has(r))).toEqual([
|
||||
['Local State', 'Local State'], ['Default/Cookies', 'Default/Cookies'], ['Default/Cookies-journal', 'Default/Cookies-journal'], ['Default/Network/Cookies', 'Default/Network/Cookies'],
|
||||
]);
|
||||
expect(sessionFiles('Profile 1', r => r === 'Profile 1/Cookies')).toEqual([['Profile 1/Cookies', 'Default/Cookies']]);
|
||||
for (const f of ['Default/Login Data', 'Default/History', 'Default/Extensions', 'Default/Web Data']) expect(NEVER_COPY.test(f)).toBe(true);
|
||||
expect(NEVER_COPY.test('Default/Cookies')).toBe(false);
|
||||
});
|
||||
test('cookie hosts: only domains the consoles get', () => {
|
||||
for (const h of ['.apple.com', 'appstoreconnect.apple.com', 'idmsa.apple.com', '.google.com', 'play.google.com', 'accounts.google.com']) expect(cookieApplies(h)).toBe(true);
|
||||
for (const h of ['.youtube.com', 'mail.google.com', 'developer.apple.com', '.example.com', 'apple.com.example.org', '', '.com']) expect(cookieApplies(h)).toBe(false);
|
||||
expect(CONSOLE_HOSTS).toContain('play.google.com');
|
||||
});
|
||||
test('DevToolsActivePort -> a local endpoint', () => {
|
||||
const d = tmp('devtools');
|
||||
expect(devToolsEndpoint(d)).toBeNull();
|
||||
writeFileSync(join(d, 'DevToolsActivePort'), '9333\n/devtools/browser/abc\n');
|
||||
expect(devToolsEndpoint(d)).toBe('http://127.0.0.1:9333');
|
||||
writeFileSync(join(d, 'DevToolsActivePort'), 'nope');
|
||||
expect(devToolsEndpoint(d)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google session safety (device-bound cookies)', () => {
|
||||
const none = { storeboundChrome: null, chrome: null, chromium: null };
|
||||
const copies = () => readdirSync(tmpdir()).filter(f => f.startsWith('storebound-chrome-')).length;
|
||||
test('--use-chrome-session: google | apple only; absent = no copy', () => {
|
||||
expect(parseUseChromeSession(undefined)).toEqual([]);
|
||||
expect(parseUseChromeSession('google')).toEqual(['google']);
|
||||
expect(parseUseChromeSession('Apple, google,apple')).toEqual(['apple', 'google']);
|
||||
expect(() => parseUseChromeSession(true)).toThrow('--use-chrome-session');
|
||||
expect(() => parseUseChromeSession('all')).toThrow('--use-chrome-session');
|
||||
});
|
||||
test('plan: no copy by default for either store; an opt-in copies only the form\'s own provider', () => {
|
||||
for (const store of ['play', 'apple'] as const) expect(browserPlan({ store, platform: 'darwin' }, true).copy).toBeNull();
|
||||
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBe('google');
|
||||
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBeNull();
|
||||
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBeNull();
|
||||
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBe('apple');
|
||||
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'], fresh: true }, true).copy).toBeNull();
|
||||
expect(browserPlan({ store: 'play', platform: 'linux', useChromeSession: ['google'] }, true).copy).toBeNull();
|
||||
// an Apple copy never carries a Google cookie and the other way round
|
||||
expect(PROVIDER_HOSTS.apple.some(h => h.includes('google'))).toBe(false);
|
||||
expect(PROVIDER_HOSTS.google.some(h => h.includes('apple'))).toBe(false);
|
||||
});
|
||||
test('the default run makes no copy of the owner\'s Chrome cookies (play and apple)', async () => {
|
||||
const src = await fakeChrome('chrome-default');
|
||||
for (const store of ['play', 'apple'] as const) {
|
||||
const said: string[] = [];
|
||||
const before = copies();
|
||||
const e = await openConsoleBrowser(scratch, { store, profileDir: tmp(`own-${store}`), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(x => x as Error);
|
||||
expect((e as Error).message).toContain('no usable browser'); // it went straight to storebound's own profile
|
||||
expect(said.join('\n')).not.toMatch(/copied|Chrome sign-in|WARNING/);
|
||||
expect(copies()).toBe(before);
|
||||
}
|
||||
});
|
||||
test('the opt-in Google copy warns about device-bound sessions first; the Apple one says its note', async () => {
|
||||
const src = await fakeChrome('chrome-optin');
|
||||
const said: string[] = [];
|
||||
await openConsoleBrowser(scratch, { store: 'play', useChromeSession: ['google'], profileDir: tmp('own-optin'), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
|
||||
expect(said[0]).toBe(GOOGLE_COPY_WARNING);
|
||||
expect(GOOGLE_COPY_WARNING).toContain('sign you out of your real Chrome');
|
||||
expect(said.join('\n')).toContain('could not use your Chrome sign-in'); // no Google Chrome here: the copy is removed again
|
||||
const saidA: string[] = [];
|
||||
await openConsoleBrowser(scratch, { store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-optin-a'), say: m => saidA.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
|
||||
expect(saidA[0]).toBe(APPLE_COPY_NOTE);
|
||||
});
|
||||
test('Google\'s rejected page is recognised (and only that page)', () => {
|
||||
for (const u of ['https://accounts.google.com/v3/signin/rejected?continue=x', 'https://accounts.google.com/signin/rejected', 'https://accounts.google.com/signin/v2/rejected']) expect(googleRejected(u)).toBe(true);
|
||||
for (const u of ['https://accounts.google.com/v3/signin/accountchooser', 'https://accounts.google.com/v3/signin/identifier', 'https://play.google.com/console/signin/rejected', 'https://example.com/v3/signin/rejected', 'not a url']) expect(googleRejected(u)).toBe(false);
|
||||
expect(GOOGLE_REJECTED).toBe('Google refused sign-in in this window (/signin/rejected). storebound uses a normal Chrome window; try again, or sign in at accounts.google.com first in this window.');
|
||||
});
|
||||
test('launch switches: no automation switch, webdriver off, session restore, a DevTools port the OS picks', () => {
|
||||
const a = consoleChromeArgs('/p');
|
||||
for (const s of AUTOMATION_SWITCHES) expect(a.some(x => x.startsWith(s))).toBe(false);
|
||||
expect(a).toContain('--disable-blink-features=AutomationControlled');
|
||||
expect(a).toContain('--restore-last-session');
|
||||
expect(a).toContain('--remote-debugging-port=0');
|
||||
expect(a).toContain('--user-data-dir=/p');
|
||||
expect(a.some(x => /user-agent/i.test(x))).toBe(false);
|
||||
expect(consoleChromeArgs('/p', { headless: true })).toContain('--headless=new');
|
||||
expect(consoleChromeArgs('/p', { sandbox: [] })).not.toContain('--no-sandbox');
|
||||
expect(consoleChromeArgs('/p', { sandbox: ['--no-sandbox'] })).toContain('--no-sandbox');
|
||||
});
|
||||
test('--no-sandbox only for root on Linux or inside a container; never on macOS, Windows or for a normal user', () => {
|
||||
const no = () => false, yes = () => true;
|
||||
expect(sandboxArgs('linux', 0, no)).toEqual(['--no-sandbox']);
|
||||
expect(sandboxArgs('linux', 1000, yes)).toEqual(['--no-sandbox']);
|
||||
expect(sandboxArgs('linux', 1000, no)).toEqual([]);
|
||||
expect(sandboxArgs('darwin', 0, yes)).toEqual([]);
|
||||
expect(sandboxArgs('darwin', 501, no)).toEqual([]);
|
||||
expect(sandboxArgs('win32', undefined, yes)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session copy', () => {
|
||||
test('copies only the session files, filters the cookies, cleans up', async () => {
|
||||
const src = await fakeChrome('chrome-a');
|
||||
const root = tmp('copies-a');
|
||||
const c = await copySession(src, { tmpRoot: root });
|
||||
expect(c.profile).toBe('Default');
|
||||
expect(c.files).toEqual(['Local State', 'Default/Cookies', 'Default/Cookies-journal']);
|
||||
for (const f of readdirSync(join(c.dir, 'Default'))) expect(['Cookies', 'Cookies-journal']).toContain(f);
|
||||
expect(readdirSync(c.dir).sort()).toEqual(['Default', 'Local State']);
|
||||
for (const f of ['Login Data', 'History', 'Web Data', 'Bookmarks', 'Preferences', 'Extensions']) expect(existsSync(join(c.dir, 'Default', f))).toBe(false);
|
||||
expect(c.filtered).toBe(true);
|
||||
expect(c.kept).toBe(5);
|
||||
const { Database } = await import('bun:sqlite');
|
||||
const db = new Database(join(c.dir, 'Default', 'Cookies'), { readonly: true });
|
||||
expect((db.query('SELECT host_key AS h FROM cookies ORDER BY h').all() as { h: string }[]).map(r => r.h)).toEqual(['.apple.com', '.google.com', 'accounts.google.com', 'appstoreconnect.apple.com', 'play.google.com']);
|
||||
db.close();
|
||||
c.cleanup(); c.cleanup();
|
||||
expect(existsSync(c.dir)).toBe(false);
|
||||
expect(readdirSync(root)).toEqual([]);
|
||||
// the source is untouched
|
||||
expect(existsSync(join(src, 'Default', 'Login Data'))).toBe(true);
|
||||
});
|
||||
test('a named profile lands in Default of the copy', async () => {
|
||||
const src = await fakeChrome('chrome-b', 'Profile 3');
|
||||
const c = await copySession(src, { tmpRoot: tmp('copies-b'), profile: 'Profile 3' });
|
||||
expect(c.files).toContain('Profile 3/Cookies');
|
||||
expect(existsSync(join(c.dir, 'Default', 'Cookies'))).toBe(true);
|
||||
c.cleanup();
|
||||
});
|
||||
test('an error removes the temporary profile before it is thrown', async () => {
|
||||
const src = tmp('chrome-broken');
|
||||
mkdirSync(join(src, 'Default', 'Cookies'), { recursive: true }); // a folder where the file should be: the copy fails
|
||||
const root = tmp('copies-c');
|
||||
await expect(copySession(src, { tmpRoot: root })).rejects.toThrow();
|
||||
expect(readdirSync(root)).toEqual([]);
|
||||
await expect(copySession(tmp('chrome-empty'), { tmpRoot: root })).rejects.toThrow('no Chrome cookie store');
|
||||
expect(readdirSync(root)).toEqual([]);
|
||||
});
|
||||
test('an unreadable cookie file is copied whole and the log says so (filtered: false)', async () => {
|
||||
const src = tmp('chrome-notdb');
|
||||
writeFileSync(join(src, 'Local State'), '{}');
|
||||
mkdirSync(join(src, 'Default'));
|
||||
writeFileSync(join(src, 'Default', 'Cookies'), 'not a database');
|
||||
const c = await copySession(src, { tmpRoot: tmp('copies-d') });
|
||||
expect(c.filtered).toBe(false);
|
||||
c.cleanup();
|
||||
expect(await filterCookies(join(scratch, 'none.db')).catch(() => 'err')).toBe('err');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- integration: real Google Chrome, throwaway profiles, a local fixture site ----
|
||||
|
||||
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const hasChrome = process.platform === 'darwin' && existsSync(CHROME);
|
||||
if (!hasChrome) console.warn('console-session integration test skipped: Google Chrome is not installed (or not macOS)');
|
||||
|
||||
describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, local site)', () => {
|
||||
test.concurrent('a cookie set in the source profile is there in the copy; another host\'s is filtered out; the copy is deleted on close', async () => {
|
||||
let chromium: any;
|
||||
try { chromium = (await import('playwright')).chromium; } catch { console.warn('skipped: playwright not installed'); return; }
|
||||
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/set') return new Response('set', { headers: { 'set-cookie': 'sb_test=1; Max-Age=3600; Path=/' } });
|
||||
return new Response(`cookie: ${/sb_test=1/.test(req.headers.get('cookie') ?? '') ? 'yes' : 'no'}`);
|
||||
} });
|
||||
const site = `http://127.0.0.1:${server.port}`, other = `http://localhost:${server.port}`;
|
||||
const src = tmp('chrome-real');
|
||||
// two real Chrome launches: SLOW; launch timeouts raised like the shared browser's (tests/helpers/browser.ts), and
|
||||
// whatever is still open is closed in `finally`, so a failed assertion leaves no Chrome running
|
||||
let seed: any = null, b: any = null;
|
||||
try {
|
||||
const opts = { channel: 'chrome', headless: true, timeout: LAUNCH_TIMEOUT_MS, ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'] };
|
||||
seed = await chromium.launchPersistentContext(src, opts);
|
||||
const p0 = seed.pages()[0] ?? await seed.newPage();
|
||||
await p0.goto(`${site}/set`); await p0.goto(`${other}/set`);
|
||||
await seed.close();
|
||||
seed = null;
|
||||
const said: string[] = [];
|
||||
b = await openConsoleBrowser(join(import.meta.dir, '..', '..'), {
|
||||
store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-profile'), say: m => said.push(m), chromeDir: src, hosts: ['127.0.0.1'], headless: true, platform: 'darwin', launchTimeoutMs: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
expect(b.mode).toBe('copy');
|
||||
expect(said[0]).toBe(APPLE_COPY_NOTE);
|
||||
expect(said.join('\n')).toContain('your Chrome sign-in');
|
||||
expect(said.join('\n')).not.toContain('sb_test');
|
||||
expect(b.tempDir && existsSync(b.tempDir)).toBe(true);
|
||||
await b.page.goto(`${site}/check`);
|
||||
expect(await b.page.textContent('body')).toBe('cookie: yes');
|
||||
await b.page.goto(`${other}/check`);
|
||||
expect(await b.page.textContent('body')).toBe('cookie: no');
|
||||
await b.close();
|
||||
await b.closed;
|
||||
expect(existsSync(b.tempDir!)).toBe(false);
|
||||
} finally {
|
||||
if (seed) await capped(seed.close(), CLOSE_TIMEOUT_MS);
|
||||
if (b && !b.isClosed()) { await capped(b.close(), CLOSE_TIMEOUT_MS); b.cleanup(); }
|
||||
server.stop(true);
|
||||
}
|
||||
}, 2 * LAUNCH_TIMEOUT_MS); // bounded by the two launches; ~3 s on an idle machine
|
||||
});
|
||||
|
||||
describe.skipIf(!hasChrome)('the console window has no automation markers (real Chrome, throwaway profile, local page)', () => {
|
||||
test.concurrent('navigator.webdriver is false and the real command line has no --enable-automation', async () => {
|
||||
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response(
|
||||
'<p id="r"></p><script>document.getElementById("r").textContent = JSON.stringify({ webdriver: navigator.webdriver, ua: navigator.userAgent })</script>',
|
||||
{ headers: { 'content-type': 'text/html' } }) });
|
||||
let b: any = null;
|
||||
try {
|
||||
b = await openConsoleBrowser(join(import.meta.dir, '..', '..'), {
|
||||
store: 'play', profileDir: tmp('own-webdriver'), say: () => {}, headless: true, platform: 'darwin', chromeDir: tmp('no-chrome-here'), launchTimeoutMs: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
expect(b.mode).toBe('profile');
|
||||
for (const s of AUTOMATION_SWITCHES) expect(b.args.some((x: string) => x.startsWith(s))).toBe(false);
|
||||
await b.page.goto(`http://127.0.0.1:${server.port}/`);
|
||||
const r = JSON.parse(await b.page.textContent('#r'));
|
||||
expect(r.webdriver).toBe(false);
|
||||
expect(r.ua).toContain('Chrome/');
|
||||
await b.page.goto('chrome://version');
|
||||
const cmd: string = await b.page.textContent('#command_line');
|
||||
expect(cmd).toContain('--user-data-dir=');
|
||||
expect(cmd).not.toContain('--enable-automation');
|
||||
expect(cmd).not.toContain('--remote-debugging-pipe');
|
||||
expect(cmd).toContain('--disable-blink-features=AutomationControlled');
|
||||
await b.close();
|
||||
await b.closed;
|
||||
expect(b.isClosed()).toBe(true);
|
||||
} finally {
|
||||
if (b && !b.isClosed()) await capped(b.close(), CLOSE_TIMEOUT_MS);
|
||||
server.stop(true);
|
||||
}
|
||||
}, 2 * LAUNCH_TIMEOUT_MS);
|
||||
});
|
||||
108
tests/browser/feature-fit.browser.ts
Normal file
108
tests/browser/feature-fit.browser.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Regression (2026-10-13): the Play feature graphic cut the phone off at the bottom. The game template's phone was
|
||||
// 232 px wide at top 36 rotated 7 deg, so its outline spanned about y 24..552 on a 500 px canvas. featureLayout()
|
||||
// now sizes the phone so its rotated box fits (fit 'contain'), the templates record the measured device boxes and
|
||||
// `shots check` fails a feature graphic whose device leaves the canvas unless fit is 'bleed'.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { boxOutside, DEFAULT_PHONE, FEATURE_CANVAS, featureLayout, rotatedBox } from '../../cli/shots/lib';
|
||||
import { deviceIssues } from '../../cli/shots/report';
|
||||
import { validateConfig } from '../../cli/lib/config';
|
||||
import { AFTER_ALL_MS, launchTestBrowser, scaled } from '../helpers/browser';
|
||||
|
||||
const close = (a: number, b: number) => expect(Math.abs(a - b)).toBeLessThan(0.01);
|
||||
|
||||
describe('rotated device box', () => {
|
||||
test('0 and 90 degrees', () => {
|
||||
expect(rotatedBox(100, 200, 0)).toEqual({ w: 100, h: 200 });
|
||||
const r = rotatedBox(100, 200, 90); close(r.w, 200); close(r.h, 100);
|
||||
});
|
||||
for (const [w, h] of [[232, 504], [200, 434], [120, 260], [300, 300]] as const) test(`7 deg, ${w} x ${h}`, () => {
|
||||
const r = rotatedBox(w, h, 7), s = Math.sin(7 * Math.PI / 180), c = Math.cos(7 * Math.PI / 180);
|
||||
close(r.w, w * c + h * s); close(r.h, w * s + h * c);
|
||||
expect(rotatedBox(w, h, -7)).toEqual(r);
|
||||
});
|
||||
test('the old layout bleeds ~50 px; contain fits with the margin', () => {
|
||||
const old = featureLayout('game', DEFAULT_PHONE, { fit: 'bleed' });
|
||||
expect(old.width).toBe(232);
|
||||
expect(boxOutside(old.box, 1024, 500)[0]).toMatch(/^bottom (4\d|5\d) px$/);
|
||||
for (const style of ['game', 'app'] as const) for (const dev of [DEFAULT_PHONE, { w: 390, h: 790, bar: 54 }, { w: 412, h: 860, bar: 24 }, { w: 600, h: 700, bar: 20 }])
|
||||
for (const scale of [0.5, 1, 1.5]) {
|
||||
const L = featureLayout(style, dev, { scale });
|
||||
const m = FEATURE_CANVAS.margin;
|
||||
expect(L.box.y).toBeGreaterThanOrEqual(m - 0.01);
|
||||
expect(L.box.y + L.box.h).toBeLessThanOrEqual(500 - m + 0.01);
|
||||
expect(L.box.x).toBeGreaterThanOrEqual(m - 0.01);
|
||||
expect(L.box.x + L.box.w).toBeLessThanOrEqual(1024 - m + 0.01);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shots check: device-crop', () => {
|
||||
const box = (y: number, h: number) => ({ x: 700, y, w: 280, h });
|
||||
test('a bleeding feature graphic is an error, unless fit is bleed', () => {
|
||||
const e = deviceIssues({ overflow: [], devices: [box(24, 528)] }, 'feature', 1024, 500);
|
||||
expect(e).toHaveLength(1);
|
||||
expect(e[0]!.level).toBe('error');
|
||||
expect(e[0]!.msg).toContain('bottom 52 px');
|
||||
expect(deviceIssues({ overflow: [], devices: [box(24, 528)], bleed: true }, 'feature', 1024, 500)).toEqual([]);
|
||||
expect(deviceIssues({ overflow: [], devices: [box(24, 452)] }, 'feature', 1024, 500)).toEqual([]);
|
||||
expect(deviceIssues({ overflow: [] }, 'feature', 1024, 500)).toEqual([]);
|
||||
});
|
||||
test('screenshots: bottom bleed is the design, a side crop warns', () => {
|
||||
expect(deviceIssues({ overflow: [], devices: [{ x: 100, y: 900, w: 800, h: 1400 }] }, 'play', 1080, 1920)).toEqual([]);
|
||||
const w = deviceIssues({ overflow: [], devices: [{ x: -60, y: 900, w: 800, h: 900 }] }, 'play', 1080, 1920);
|
||||
expect(w.map(i => [i.level, i.code])).toEqual([['warn', 'device-crop']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shots.feature config', () => {
|
||||
test('fit and scale are validated', () => {
|
||||
const errs = (feature: unknown) => validateConfig({ app: { name: 'X', profile: 'game' }, languages: [{ id: 'en' }],
|
||||
shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', frames: [{ src: 'a.png' }] }], feature } } as never).filter(e => e.startsWith('shots.feature'));
|
||||
expect(errs({ frame: 'a.png' })).toEqual([]);
|
||||
expect(errs({ frame: 'a.png', fit: 'bleed', scale: 1.2 })).toEqual([]);
|
||||
expect(errs({ frame: 'a.png', fit: 'cover' })).toEqual(["shots.feature.fit: 'contain' | 'bleed'"]);
|
||||
expect(errs({ frame: 'a.png', scale: 3 })).toEqual(['shots.feature.scale: a number from 0.5 to 1.5']);
|
||||
});
|
||||
});
|
||||
|
||||
// one browser for the file through the shared helper (bounded, load-aware launch and kill); HEAVY in tools/ci-plan.ts
|
||||
const shared = await launchTestBrowser('playwright-core');
|
||||
const { browser } = shared;
|
||||
if (!browser) console.warn(`feature graphic render test skipped: no Chromium installed (${shared.skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||
afterAll(() => shared.close(), AFTER_ALL_MS);
|
||||
|
||||
// 1x1 PNG
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
||||
|
||||
describe.skipIf(!browser)('feature graphic templates at 1024 x 500 (DOM layout box)', () => {
|
||||
for (const style of ['game', 'app'] as const) for (const fit of ['contain', 'bleed'] as const) test(`${style}, fit ${fit}`, async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1024, height: 500 } });
|
||||
try {
|
||||
const html = readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', style === 'app' ? 'feature-app.html' : 'feature.html'), 'utf8');
|
||||
const job = { style, phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png',
|
||||
device: featureLayout(style, DEFAULT_PHONE, { fit }),
|
||||
palette: { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' } };
|
||||
await page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise<void> }) => {
|
||||
const u = new URL(r.request().url());
|
||||
if (u.pathname === '/feature.html') return r.fulfill({ contentType: 'text/html', body: html });
|
||||
if (u.pathname === '/job.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(job) });
|
||||
if (u.pathname.startsWith('/frames/')) return r.fulfill({ contentType: 'image/png', body: PNG });
|
||||
return r.fulfill({ contentType: 'text/css', body: '' });
|
||||
});
|
||||
await page.goto('http://t.test/feature.html?lang=en');
|
||||
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
|
||||
const devices = JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[];
|
||||
expect(devices).toHaveLength(1);
|
||||
const d = devices[0]!;
|
||||
// the measured layout box matches the computed geometry
|
||||
expect(Math.abs(d.y - job.device.box.y)).toBeLessThan(1.5); expect(Math.abs(d.h - job.device.box.h)).toBeLessThan(1.5);
|
||||
const bleed = (await page.evaluate('document.body.dataset.bleed')) === '1';
|
||||
const issues = deviceIssues({ overflow: [], devices, bleed }, 'feature', 1024, 500);
|
||||
expect(issues).toEqual([]);
|
||||
if (fit === 'contain') expect(boxOutside(d, 1024, 500)).toEqual([]);
|
||||
else expect(bleed).toBe(true);
|
||||
} finally { await page.close(); }
|
||||
}, scaled(30_000));
|
||||
});
|
||||
47
tests/browser/glyph-ligatures.browser.ts
Normal file
47
tests/browser/glyph-ligatures.browser.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Regression: ligatures must not read as system-font glyphs. STAR DASH's "Outfits, ..." caption failed `shots check`
|
||||
// with `i drawn with a system font` although Montserrat drew every glyph: the per-character probe spans sat side by
|
||||
// side, Chrome shaped "f" + "i" into the fi ligature across them and the lone "i" span reported no glyph of ours.
|
||||
// The fixture is a real Google Fonts css2 `text=` subset of Montserrat (as `shots fonts` downloads it; OFL, see
|
||||
// fixtures/fonts/Montserrat-OFL.txt); it keeps the GSUB ligatures (ff, fi, ffi). Skips when no Chromium can launch.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { measureFonts } from '../../cli/shots/glyphs';
|
||||
import { AFTER_ALL_MS, launchTestBrowser, scaled } from '../helpers/browser';
|
||||
|
||||
// one browser for the file, launched and killed by the shared helper (bounded, load-aware timeouts); tools/ci-plan.ts
|
||||
// lists this file as HEAVY (the browser shard)
|
||||
const shared = await launchTestBrowser('playwright-core');
|
||||
const { browser, skipWhy } = shared;
|
||||
if (!browser) console.warn(`glyph ligature test skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||
|
||||
afterAll(() => shared.close(), AFTER_ALL_MS);
|
||||
|
||||
const font = readFileSync(join(import.meta.dir, '..', 'fixtures', 'fonts', 'montserrat-text-subset.woff2')).toString('base64');
|
||||
const TEXT = 'Outfits, fit, office, affine';
|
||||
|
||||
describe.skipIf(!browser)('shots font usage: ligatures', () => {
|
||||
for (const weight of [400, 600, 800]) test(`"${TEXT}" in a Montserrat text= subset, weight ${weight}: zero system-font glyphs`, async () => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.setContent(`<style>@font-face { font-family: 'Montserrat'; font-weight: 100 900; font-synthesis: none; src: url(data:font/woff2;base64,${font}) format('woff2'); }
|
||||
p { font-family: 'Montserrat'; font-weight: ${weight}; font-size: 48px; } small { font-family: 'Montserrat'; font-weight: ${weight}; }</style>
|
||||
<p class="sub">${TEXT}</p><div><small>mounts and vehicles</small></div>`);
|
||||
await page.evaluate('document.fonts.ready.then(() => 0)');
|
||||
// the subset really ligates: the line has fewer glyphs than characters, all from our font
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
|
||||
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
|
||||
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'p' });
|
||||
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId }) as { fonts: { isCustomFont: boolean; glyphCount: number }[] };
|
||||
await cdp.detach();
|
||||
expect(fonts.every(f => f.isCustomFont)).toBe(true);
|
||||
expect(fonts.reduce((n, f) => n + f.glyphCount, 0)).toBeLessThan(TEXT.length);
|
||||
|
||||
const measured = await measureFonts(page, ['Montserrat']);
|
||||
expect(measured.map(m => m.el)).toEqual(['p.sub', 'div small']);
|
||||
for (const m of measured) expect(Object.keys(m.faces)).toEqual(['Montserrat']);
|
||||
expect(await page.evaluate("document.getElementById('sb-glyph-probes')")).toBeNull();
|
||||
} finally { await page.close(); }
|
||||
}, scaled(30_000));
|
||||
});
|
||||
220
tests/browser/glyphs.browser.ts
Normal file
220
tests/browser/glyphs.browser.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Glyph coverage of the screenshot fonts: the pure-TS cmap reader (TTF / WOFF / WOFF2), the per-language plan with
|
||||
// automatic Noto fallbacks, and `shots check` errors for renders that needed a fallback or a system font.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { brotliCompressSync, deflateSync } from 'node:zlib';
|
||||
import { cmapTable, fontCodepoints, missingChars } from '../../cli/shots/cmap';
|
||||
import { attribute, type FaceJob, type MeasuredText, fontIssues, fontsCss, fontUsage, GlyphError, planFonts, roleTexts, uncoveredChars } from '../../cli/shots/glyphs';
|
||||
import { langTexts } from '../../cli/shots/fonts';
|
||||
import { allIssues, scanRenders } from '../../cli/shots/report';
|
||||
import { shotFonts } from '../../cli/shots/lib';
|
||||
|
||||
// ---- tiny font builders (only a cmap table: enough for the reader) ----
|
||||
function cmapBytes(cps: number[]): Uint8Array {
|
||||
const sorted = [...new Set(cps)].sort((a, b) => a - b);
|
||||
const bmp = sorted.filter(c => c < 0xffff);
|
||||
// format 4: one segment per code point (+ the 0xFFFF terminator), glyph id = index + 1 via idDelta
|
||||
const segs = [...bmp.map((c, i) => ({ s: c, e: c, d: (i + 1 - c) & 0xffff })), { s: 0xffff, e: 0xffff, d: 1 }];
|
||||
const f4len = 16 + segs.length * 8;
|
||||
const f12len = 16 + sorted.length * 12;
|
||||
const b = new DataView(new ArrayBuffer(4 + 16 + f4len + f12len));
|
||||
b.setUint16(0, 0); b.setUint16(2, 2);
|
||||
b.setUint16(4, 3); b.setUint16(6, 1); b.setUint32(8, 20);
|
||||
b.setUint16(12, 3); b.setUint16(14, 10); b.setUint32(16, 20 + f4len);
|
||||
let o = 20;
|
||||
b.setUint16(o, 4); b.setUint16(o + 2, f4len); b.setUint16(o + 6, segs.length * 2);
|
||||
segs.forEach((s, i) => {
|
||||
b.setUint16(o + 14 + i * 2, s.e);
|
||||
b.setUint16(o + 16 + segs.length * 2 + i * 2, s.s);
|
||||
b.setUint16(o + 16 + segs.length * 4 + i * 2, s.d);
|
||||
b.setUint16(o + 16 + segs.length * 6 + i * 2, 0);
|
||||
});
|
||||
o += f4len;
|
||||
b.setUint16(o, 12); b.setUint32(o + 4, f12len); b.setUint32(o + 12, sorted.length);
|
||||
sorted.forEach((c, i) => { b.setUint32(o + 16 + i * 12, c); b.setUint32(o + 20 + i * 12, c); b.setUint32(o + 24 + i * 12, i + 1); });
|
||||
return new Uint8Array(b.buffer);
|
||||
}
|
||||
|
||||
function ttf(cps: number[]): Uint8Array {
|
||||
const cmap = cmapBytes(cps);
|
||||
const out = new Uint8Array(12 + 16 + cmap.length);
|
||||
const d = new DataView(out.buffer);
|
||||
d.setUint32(0, 0x00010000); d.setUint16(4, 1);
|
||||
out.set([...'cmap'].map(c => c.charCodeAt(0)), 12);
|
||||
d.setUint32(20, 28); d.setUint32(24, cmap.length);
|
||||
out.set(cmap, 28);
|
||||
return out;
|
||||
}
|
||||
|
||||
function woff(cps: number[]): Uint8Array {
|
||||
const cmap = cmapBytes(cps), z = new Uint8Array(deflateSync(cmap));
|
||||
const out = new Uint8Array(44 + 20 + z.length);
|
||||
const d = new DataView(out.buffer);
|
||||
out.set([...'wOFF'].map(c => c.charCodeAt(0)), 0); d.setUint32(4, 0x00010000); d.setUint32(8, out.length); d.setUint16(12, 1);
|
||||
out.set([...'cmap'].map(c => c.charCodeAt(0)), 44);
|
||||
d.setUint32(48, 64); d.setUint32(52, z.length); d.setUint32(56, cmap.length);
|
||||
out.set(z, 64);
|
||||
return out;
|
||||
}
|
||||
|
||||
function woff2(cps: number[]): Uint8Array {
|
||||
// two tables: an untransformed 'head' stand-in (8 bytes) and cmap, both inside one brotli stream
|
||||
const cmap = cmapBytes(cps), head = new Uint8Array(8);
|
||||
const stream = new Uint8Array(brotliCompressSync(Buffer.concat([head, cmap])));
|
||||
const base128 = (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 = [1, ...base128(head.length), 0, ...base128(cmap.length)]; // flags: index 1 = head, 0 = cmap (transform version 0)
|
||||
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 ASCII = range(0x20, 0x7e);
|
||||
const cpsOf = (s: string): number[] => [...s].map(c => c.codePointAt(0)!);
|
||||
|
||||
describe('cmap reader', () => {
|
||||
const want = [...ASCII, 0x15f, 0x11f, 0x1f600];
|
||||
test('TTF, WOFF and WOFF2 give the same code points (format 4 + 12)', () => {
|
||||
for (const f of [ttf(want), woff(want), woff2(want)]) {
|
||||
const got = fontCodepoints(f);
|
||||
expect([...got].sort((a, b) => a - b)).toEqual([...want].sort((a, b) => a - b));
|
||||
}
|
||||
expect(cmapTable(woff2(want)).length).toBe(cmapBytes(want).length);
|
||||
});
|
||||
test('missingChars skips whitespace and joiners, reports each character once', () => {
|
||||
const cps = fontCodepoints(ttf(ASCII));
|
||||
expect(missingChars("Taş Devri'nden şş", cps)).toEqual(['ş']);
|
||||
});
|
||||
test('not a font: a clear error', () => {
|
||||
expect(() => fontCodepoints(new Uint8Array(64))).toThrow('not a font file');
|
||||
});
|
||||
});
|
||||
|
||||
// Copy in six languages whose glyphs an ASCII-only title / body font does not have.
|
||||
const COPY = {
|
||||
tr: { s1: { eyebrow: 'Bilgi', title: ["Taş Devri'nden", '2100\'e'] as [string, string], sub: 'Kasabaları yağmala' } },
|
||||
pl: { s1: { title: ['Zażółć', 'gęślą jaźń'] as [string, string], sub: 'Łódź' } },
|
||||
vi: { s1: { title: ['Chiến đấu', 'Thế giới'] as [string, string], sub: 'Trận chiến nhanh' } },
|
||||
ru: { s1: { title: ['Битвы', 'за 2 минуты'] as [string, string], sub: 'Грабь деревни' } },
|
||||
ar: { s1: { title: ['معارك', 'سريعة'] as [string, string], sub: '2 دقيقة' } },
|
||||
ja: { s1: { title: ['2分', 'バトル'] as [string, string], sub: '村を襲え' } },
|
||||
};
|
||||
|
||||
// Fake Google Fonts: what each family covers.
|
||||
const COVER: Record<string, (cp: number) => boolean> = {
|
||||
'Lilita One': cp => cp < 0x7f || cp === 0x131 || cp === 0x141 || cp === 0x142,
|
||||
Montserrat: cp => cp < 0x7f,
|
||||
'Noto Sans': cp => cp < 0x250 || (cp >= 0x400 && cp <= 0x4ff) || (cp >= 0x1ea0 && cp <= 0x1ef9),
|
||||
'Noto Sans Display': cp => cp < 0x250 || (cp >= 0x1ea0 && cp <= 0x1ef9),
|
||||
'Noto Sans Arabic': cp => cp >= 0x600 && cp <= 0x6ff,
|
||||
'Noto Sans JP': cp => cp < 0x7f || (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x4e00 && cp <= 0x9fff),
|
||||
};
|
||||
function fakeGet(asked: FaceJob[]): (job: FaceJob) => Promise<Set<number>> {
|
||||
return async job => { asked.push(job); const f = COVER[job.family] ?? (() => false); return new Set(cpsOf(job.chars).filter(f)); };
|
||||
}
|
||||
const input = (chosen?: string[]) => ({ langs: langTexts(COPY, ['en'], 'STAR DASH'), title: 'Lilita One', body: 'Montserrat', titleAxis: null,
|
||||
bodyAxis: 'wght@100..900', fallbackTitleAxis: 'wght@800', chosen, base: String.fromCharCode(...ASCII) });
|
||||
|
||||
describe('glyph coverage plan (shots fonts)', () => {
|
||||
test('tr / pl / vi / ru / ar / ja against fonts that lack the glyphs: flagged and an automatic Noto fallback added', async () => {
|
||||
const asked: FaceJob[] = [];
|
||||
const m = await planFonts(input(), fakeGet(asked));
|
||||
// whole-line script faces stay as before
|
||||
expect(m.langs.ru!.title[0]).toBe('Store cyrgreek');
|
||||
expect(m.langs.ar!.title[0]).toBe('Store arabic');
|
||||
expect(m.langs.ja!.title[0]).toBe('Store jp');
|
||||
// Latin Extended + Vietnamese titles: Noto Sans Display after Lilita One; body text: Noto Sans after Montserrat
|
||||
const fb = (lang: string, role: 'title' | 'body') => m.langs[lang]!.fallbacks.filter(f => f.role === role).map(f => `${f.family}:${f.chars}`);
|
||||
expect(fb('tr', 'title')).toEqual(['Noto Sans Display:ş']);
|
||||
expect(fb('tr', 'body')).toEqual(['Noto Sans:İığ']); // "BİLGİ" (Turkish upper case) + the subline
|
||||
expect(fb('pl', 'title')).toEqual(['Noto Sans Display:żóćęśąźń']); // ł: the title font has it
|
||||
expect(fb('pl', 'body')).toEqual(['Noto Sans:Łóź']);
|
||||
expect(fb('vi', 'title')).toEqual(['Noto Sans Display:ếđấớ']);
|
||||
expect(fb('vi', 'body')).toEqual(['Noto Sans:ậế']);
|
||||
expect(m.langs.tr!.title).toEqual(['Lilita One', 'Store fb noto-sans-display title']);
|
||||
expect(m.faces['Store fb noto-sans-display title']).toMatchObject({ family: 'Noto Sans Display', kind: 'fallback', chosen: false });
|
||||
expect(asked.find(j => j.family === 'Noto Sans Display')!.axis).toBe('wght@800');
|
||||
// ru / ar / ja need no fallback: their Noto script face covers the line, ASCII digits come from it or the font
|
||||
for (const l of ['ru', 'ar', 'ja', 'en']) expect(m.langs[l]!.fallbacks).toEqual([]);
|
||||
expect(fontsCss(m)).toContain("font-family: 'Store fb noto-sans-display title'; font-weight: 100 900; font-synthesis: none; src: url(fb-noto-sans-display-title.woff2)");
|
||||
});
|
||||
|
||||
test('shots.fonts.fallback marks the family as chosen', async () => {
|
||||
const m = await planFonts(input(['Noto Sans Display', 'Noto Sans']), fakeGet([]));
|
||||
expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(true);
|
||||
expect(m.faces['Store fb noto-sans body']!.chosen).toBe(true);
|
||||
});
|
||||
|
||||
test('a character no fallback covers fails with the language and the characters', async () => {
|
||||
const copy = { ...COPY, tr: { s1: { title: ['Taş ', 'x'] as [string, string] } } };
|
||||
const err = await planFonts({ ...input(), langs: langTexts(copy, [], '') }, fakeGet([])).catch(e => e);
|
||||
expect(err).toBeInstanceOf(GlyphError);
|
||||
expect(err.message).toContain('tr (title): "" U+E000; tried Lilita One, Noto Sans Display, Noto Sans');
|
||||
expect((err as GlyphError).gaps.map(g => g.lang)).toEqual(['tr']);
|
||||
});
|
||||
|
||||
test('role texts: Turkish upper-case eyebrow, badges split between title and body', () => {
|
||||
const t = roleTexts('tr', { a: { eyebrow: 'bilgi', title: ['A', 'B'], badges: [['9x', 'şarj']] } }, 'Game');
|
||||
expect(t.title).toContain('9x');
|
||||
expect(t.body).toContain('BİLGİ');
|
||||
expect(t.body).toContain('şarj');
|
||||
expect(uncoveredChars(t.body, [new Set(ASCII)])).toEqual(['İ', 'ş']);
|
||||
});
|
||||
|
||||
test('defaults cover Latin Extended and Vietnamese (game: Paytone One + Montserrat)', () => {
|
||||
expect(shotFonts(undefined, 'game')).toEqual({ title: 'Paytone One', body: 'Montserrat', titleVariable: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('render report fonts -> shots check', () => {
|
||||
const faces = {
|
||||
'Lilita One': { family: 'Lilita One', file: 'title.woff2', kind: 'title' as const, chosen: true },
|
||||
Montserrat: { family: 'Montserrat', file: 'body.woff2', kind: 'body' as const, chosen: true },
|
||||
'Store fb noto-sans-display title': { family: 'Noto Sans Display', file: 'fb.woff2', kind: 'fallback' as const, role: 'title' as const, chosen: false },
|
||||
};
|
||||
const measured: MeasuredText[] = [
|
||||
{ el: 'h1 span:1', text: "Taş Devri'nden", faces: { 'Lilita One': "TaDevri'nden", 'Store fb noto-sans-display title': 'ş' } },
|
||||
{ el: 'p.sub', text: 'Kasabaları yağmala', faces: { Montserrat: 'Kasbalrıyğm' } },
|
||||
{ el: 'div.eyebrow', text: 'X', faces: { '(system)': '✪' } },
|
||||
];
|
||||
|
||||
test('attribute: first face of the stack whose probe drew with a web font, else (system)', () => {
|
||||
const els = [{ el: 'h1 span:1', text: 'Taş ✪', stack: ['Lilita One', 'Store fb noto-sans-display title'], chars: ['T', 'a', 'ş', '✪'],
|
||||
probe: { 'Lilita One|T': 0, 'Store fb noto-sans-display title|T': 1, 'Lilita One|a': 2, 'Store fb noto-sans-display title|a': 3,
|
||||
'Lilita One|ş': 4, 'Store fb noto-sans-display title|ş': 5, 'Lilita One|✪': 6, 'Store fb noto-sans-display title|✪': 7 } }];
|
||||
expect(attribute(els, [true, true, true, true, false, true, false, false])).toEqual([
|
||||
{ el: 'h1 span:1', text: 'Taş ✪', faces: { 'Lilita One': 'Ta', 'Store fb noto-sans-display title': 'ş', '(system)': '✪' } }]);
|
||||
});
|
||||
|
||||
test('fallback the config did not choose and system-font glyphs are errors', () => {
|
||||
const u = fontUsage(measured, faces);
|
||||
expect(u.used).toEqual({ 'Lilita One': 12, 'Noto Sans Display': 1, Montserrat: 11, '(system)': 1 });
|
||||
const issues = fontIssues(u);
|
||||
expect(issues.map(i => i.code)).toEqual(['font-fallback', 'glyph-missing']);
|
||||
expect(issues[0]!.msg).toContain('ş drawn with Noto Sans Display');
|
||||
// a chosen fallback is fine
|
||||
const ok = fontUsage(measured.slice(0, 2), { ...faces, 'Store fb noto-sans-display title': { ...faces['Store fb noto-sans-display title'], chosen: true } });
|
||||
expect(fontIssues(ok)).toEqual([]);
|
||||
});
|
||||
|
||||
test('scanRenders: ERROR per image, warn for renders without font usage', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-glyphs-'));
|
||||
try {
|
||||
mkdirSync(join(dir, 'tr', 'ios69'), { recursive: true });
|
||||
const png = (w: number, h: number) => { const b = new Uint8Array(33); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); const d = new DataView(b.buffer); d.setUint32(16, w); d.setUint32(20, h); b[24] = 8; b[25] = 2; return b; };
|
||||
for (const n of ['01', '02', '03']) writeFileSync(join(dir, 'tr', 'ios69', `${n}.png`), png(1320, 2868));
|
||||
writeFileSync(join(dir, 'report.json'), JSON.stringify({
|
||||
'tr/ios69/01.png': { overflow: [], fonts: fontUsage(measured.slice(0, 2), faces) },
|
||||
'tr/ios69/02.png': { overflow: [], fonts: { used: { 'Paytone One': 20 }, fallback: [], missing: [] } },
|
||||
'tr/ios69/03.png': { overflow: [] },
|
||||
}));
|
||||
const got = allIssues(scanRenders(dir, { langs: ['tr'], sizes: ['ios69'], probe: () => null })).map(x => `${x.where} ${x.issue.level} ${x.issue.code}`);
|
||||
expect(got).toEqual(['tr/ios69/01.png error font-fallback', 'tr/ios69/03.png warn fonts']);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
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')); }
|
||||
});
|
||||
});
|
||||
50
tests/browser/site-overflow.browser.ts
Normal file
50
tests/browser/site-overflow.browser.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// The site (site/index.html) never scrolls sideways: at every viewport width from 320 to 1440 px (10 px steps), in the
|
||||
// light and the dark theme, document.documentElement.scrollWidth stays within innerWidth. On a failure it names the
|
||||
// elements whose right edge passes the viewport outside any scroll container (pseudo-elements show as their owner).
|
||||
// Uses the bundled playwright-core with its Chromium (or Google Chrome); skips when neither is installed (a launch that
|
||||
// times out on a busy machine fails instead). One launch, the context closed right after each test, capped closes
|
||||
// (tests/helpers/browser.ts). SLOW on purpose: 113 widths x 2 themes, each a resize and a layout read.
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
||||
import { join } from 'node:path';
|
||||
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, testScope } from '../helpers/browser';
|
||||
|
||||
const page = 'file://' + join(import.meta.dir, '..', '..', 'site', 'index.html');
|
||||
|
||||
const shared = await launchTestBrowser('playwright-core');
|
||||
const { browser, skipWhy } = shared;
|
||||
if (!browser) console.warn(`site overflow test skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||
const scope = testScope(() => browser);
|
||||
|
||||
// bounded: one context per test, each close capped (bun's 5 s default hook timeout was too tight for Chrome's slow shutdown)
|
||||
afterEach(() => scope.settle(), AFTER_EACH_MS);
|
||||
afterAll(() => shared.close(), AFTER_ALL_MS);
|
||||
|
||||
describe.skipIf(!browser)('site: no horizontal overflow', () => {
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`320-1440 px, ${scheme}`, async () => {
|
||||
const p = await scope.newPage({ colorScheme: scheme, viewport: { width: 1440, height: 900 } });
|
||||
await p.goto(page, { waitUntil: 'load' });
|
||||
const bad: string[] = [];
|
||||
for (let w = 320; w <= 1440; w += 10) {
|
||||
await p.setViewportSize({ width: w, height: 900 });
|
||||
const r = await p.evaluate(() => {
|
||||
const iw = innerWidth, sw = document.documentElement.scrollWidth;
|
||||
if (sw <= iw) return null;
|
||||
const els: string[] = [];
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
const rc = el.getBoundingClientRect();
|
||||
if (rc.width === 0 || rc.right <= iw + 0.5) continue;
|
||||
let clipped = false;
|
||||
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
||||
if (getComputedStyle(a).overflowX !== 'visible') { clipped = true; break; }
|
||||
}
|
||||
if (!clipped) els.push(`${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${[...el.classList].map(c => '.' + c).join('')} right=${Math.round(rc.right)}`);
|
||||
}
|
||||
return `${iw}px: page ${sw - iw}px too wide${els.length ? ' (' + els.slice(0, 5).join(', ') + ')' : ' (a pseudo-element or transform)'}`;
|
||||
});
|
||||
if (r) bad.push(r);
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
}, 120_000);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue