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:
CRTkafa 2026-10-15 01:25:17 +08:00
parent 4c7ff89950
commit cd1e13c1e2
20 changed files with 116 additions and 133 deletions

View 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);
});

File diff suppressed because it is too large Load diff

View 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([]);
});
});

View 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);
}
});

View 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);
});

View 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));
});

View 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));
});

View 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 }); }
});
});

View 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')); }
});
});

View 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);
}
});