shots: Mac App Store screenshots (mac size, APP_DESKTOP to the macOS version), a landscape layout (Mac window or bare; Mac, Play Chromebook, Play tablets), safe margins in every template checked by shots check from the render's layout boxes, a contact sheet timeout that scales with images, sizes and load; pitfall 80

This commit is contained in:
CRTkafa 2026-10-20 03:29:19 +07:00
parent 30488c512d
commit e59539fec8
26 changed files with 532 additions and 67 deletions

View file

@ -0,0 +1,74 @@
// The screenshot templates in a real page: the landscape layout (Mac window or bare frame, copy column) renders with
// its device inside the canvas, and every template keeps its text and logo inside the safe margins (7 % of the width
// left / right, 5 % of the height at the top), measured from the page's own layout (body.dataset.boxes). A page with
// the margins taken away fails the same rule. Gated like feature-fit: skipped without Chromium.
import { afterAll, describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { boxOutside, DEFAULT_PHONE, DEFAULT_TABLET, marginViolations, renderDims, type TextBox } from '../../cli/shots/lib';
import { AFTER_ALL_MS, launchTestBrowser, scaled } from '../helpers/browser';
const shared = await launchTestBrowser('playwright-core');
const { browser } = shared;
if (!browser) console.warn(`landscape / safe-margin render test skipped: no Chromium installed (${shared.skipWhy}). Run \`bunx playwright install chromium\`.`);
afterAll(() => shared.close(), AFTER_ALL_MS);
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
const dir = join(import.meta.dir, '..', '..', 'cli', 'shots');
async function render(style: 'game' | 'app', w: number, h: number, o: { landscape?: boolean; frame?: string; hero?: boolean; css?: string; title?: [string, string] } = {}) {
const page = await browser.newPage({ viewport: { width: w, height: h } });
try {
let html = readFileSync(join(dir, style === 'app' ? 'template-app.html' : 'template.html'), 'utf8');
if (o.css) html = html.replace('</style>', `${o.css}</style>`);
const job = { style, phone: DEFAULT_PHONE, tablet: DEFAULT_TABLET, landscape: { w: 1440, h: 900, bar: 0 }, side: 'left', logo: !!o.hero, appName: 'Demo',
fonts: { title: 'X', body: 'Y' }, number: 1, scene: { id: 'a', hero: !!o.hero, frames: [{ src: 'a.png' }] },
palette: { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' },
copy: { eyebrow: 'Daily puzzles', title: o.title ?? ['Match three', 'Win big'], sub: 'Hundreds of levels, no wifi needed.' } };
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.endsWith('.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/') || u.pathname === '/logo') return r.fulfill({ contentType: 'image/png', body: PNG });
return r.fulfill({ contentType: 'text/css', body: '' });
});
const dev = o.landscape ? `&device=landscape&frame=${o.frame ?? 'window'}` : '';
await page.goto(`http://t.test/t.html?s=a&w=${w}&h=${h}&lang=en${dev}`);
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: scaled(15000) });
const read = async (k: string) => JSON.parse((await page.evaluate(`document.body.dataset.${k}`)) ?? '[]');
return { boxes: await read('boxes') as TextBox[], devices: await read('devices') as { x: number; y: number; w: number; h: number }[],
frameUrl: await page.evaluate("document.querySelector('.lwin img, .tel img.app')?.getAttribute('src') ?? ''") as string,
titlebar: await page.evaluate("!!document.querySelector('.lwin:not(.bare) .tb')") as boolean };
} finally { await page.close(); }
}
describe.skipIf(!browser)('landscape layout', () => {
for (const style of ['game', 'app'] as const) for (const [size, frame] of [['mac', 'window'], ['chromebook', 'none'], ['tab10', 'none']] as const) test(`${style} ${size} (${frame})`, async () => {
const d = renderDims(size, { landscape: { playTablets: true } });
const r = await render(style, d.w, d.h, { landscape: true, frame });
expect(r.frameUrl).toContain('/landscape/');
expect(r.titlebar).toBe(frame === 'window');
expect(r.devices).toHaveLength(1);
expect(boxOutside(r.devices[0]!, d.w, d.h)).toEqual([]);
// copy on the left, device on the right
expect(Math.max(...r.boxes.map(b => b.x + b.w))).toBeLessThan(r.devices[0]!.x);
expect(r.boxes.length).toBeGreaterThanOrEqual(3);
expect(marginViolations(r.boxes, d.w, d.h)).toEqual([]);
}, scaled(30_000));
});
describe.skipIf(!browser)('safe margins on the portrait templates', () => {
for (const style of ['game', 'app'] as const) for (const [w, h] of [[1320, 2868], [1080, 1920], [2064, 2752]] as const) test(`${style} ${w}x${h}, hero with logo and a scene`, async () => {
for (const hero of [true, false]) {
const r = await render(style, w, h, { hero, title: ['Wonderful long words', 'Everywhere'] });
expect(r.boxes.length).toBeGreaterThan(0);
if (hero) expect(r.boxes.some(b => b.el === 'logo')).toBe(true);
expect(marginViolations(r.boxes, w, h)).toEqual([]);
}
}, scaled(30_000));
test('a fixture with the margins taken away fails', async () => {
const r = await render('game', 1080, 1920, { hero: true, css: '.top { padding: 0 !important; top: 0 !important } .logo { width: 100% !important }' });
const v = marginViolations(r.boxes, 1080, 1920);
expect(v.some(m => m.startsWith('logo is too close to the edge (left 0 px, right 0 px, top 0 px'))).toBe(true);
}, scaled(30_000));
});

View file

@ -170,6 +170,22 @@ describe('App Store Connect: a second run writes nothing', () => {
expect(count('appScreenshots')).toBe(12);
});
test('apple screenshots --platform macos: the Mac set (APP_DESKTOP) on the macOS version only', async () => {
const files: Record<string, Uint8Array> = {};
for (const l of ['en', 'de']) for (const n of [1, 2]) files[`store/renders/${l}/mac/0${n}.png`] = png(n + (l === 'de' ? 40 : 30));
const p = project({ ...CONFIG, apple: { ...CONFIG.apple!, mac: true } }, files);
const ios = s.asc.put({ type: 'appStoreVersions', id: 'ver-ios', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
const mac = s.asc.put({ type: 'appStoreVersions', id: 'ver-mac', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, rels: { app: APP } });
for (const v of [ios, mac]) for (const loc of ['en-US', 'de-DE']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${v.id}-${loc}`, attributes: { locale: loc }, rels: { appStoreVersion: v.id } });
const r = await twice(() => appleScreenshots(p.ctx(['--platform', 'macos'])));
expect(r.first.filter(x => x === 'POST /v1/appScreenshotSets')).toHaveLength(2);
expect(r.second).toEqual([]);
const sets = s.asc.all('appScreenshotSets');
expect(sets.map(x => x.attributes.screenshotDisplayType)).toEqual(['APP_DESKTOP', 'APP_DESKTOP']);
expect(sets.every(x => JSON.stringify(x).includes('loc-ver-mac-'))).toBe(true);
expect(count('appScreenshots')).toBe(4);
});
test('apple cpp create, events create, testflight groups, review-details, bundle, offer-codes, ppo create', async () => {
const p = project({ ...CONFIG, shots: { variants: { b: {} } } as never }, { '.storebound/credentials.json': JSON.stringify({ reviewContact: { firstName: 'Ex', lastName: 'Ample', phone: '+1 555 0100', email: 'review@example.com' } }) });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });

105
tests/mac-landscape.test.ts Normal file
View file

@ -0,0 +1,105 @@
// Mac App Store screenshots (APP_DESKTOP, 16:10), the landscape layout's sizes, the safe-margin rule and the contact
// sheet timeout. The page-level render of the landscape layout and the margins: tests/browser/landscape.browser.ts.
import { describe, expect, test } from 'bun:test';
import { checkImage, IMAGE_SPECS, imageInfo, macNote } from '../cli/shots/spec';
import { defaultSizes, marginViolations, renderDims, SAFE_MARGIN_MIN, sheetLayout, sheetTimeoutMs, SIZES } from '../cli/shots/lib';
import { marginIssues, requiredSets } from '../cli/shots/report';
import { displaySets, MAC_DISPLAY } from '../cli/apple/screenshots';
import { chromebookSkip } from '../cli/play/images';
import { APPLE_SHOT_DIR } from '../cli/lib/art-pull';
import { validateConfig } from '../cli/lib/config';
import { loadFactor } from '../cli/shots/render';
import { png } from './fixtures';
describe('Mac sizes', () => {
test('the four 16:10 sizes Apple lists, landscape only, APP_DESKTOP', () => {
expect(IMAGE_SPECS.mac!.sizes).toEqual([[2880, 1800], [2560, 1600], [1440, 900], [1280, 800]]);
expect(IMAGE_SPECS.mac!.type).toBe('APP_DESKTOP');
expect(SIZES.mac).toMatchObject({ w: 2880, h: 1800, device: 'landscape' });
for (const [w, h] of IMAGE_SPECS.mac!.sizes) expect(checkImage(imageInfo(png(w, h)), 'mac')).toEqual([]);
expect(checkImage(imageInfo(png(1800, 2880)), 'mac').map(i => i.code)).toEqual(['size']);
expect(checkImage(imageInfo(png(1920, 1080)), 'mac').map(i => i.code)).toEqual(['size']);
expect(checkImage(imageInfo(png(2880, 1800, { colorType: 6 })), 'mac').map(i => i.code)).toEqual(['alpha']);
});
test('opt-in with apple.mac; required then', () => {
expect(defaultSizes({ apple: { bundleId: 'x' } } as never)).not.toContain('mac');
expect(defaultSizes({ apple: { bundleId: 'x', mac: true } } as never)).toContain('mac');
expect(requiredSets({ apple: { mac: true } }).mac).toBe(true);
expect(requiredSets({ apple: {} }).mac).toBeUndefined();
});
test('uploads: the Mac set only to a macOS version', () => {
expect(MAC_DISPLAY).toEqual({ mac: 'APP_DESKTOP' });
expect(displaySets({ mac: true }, 'MAC_OS')).toEqual({ mac: 'APP_DESKTOP' });
expect(displaySets({ mac: true }, 'IOS')).not.toHaveProperty('mac');
expect(displaySets({}, 'MAC_OS')).toEqual({});
expect(APPLE_SHOT_DIR.APP_DESKTOP).toBe('mac');
});
test('validate note: a macOS version without Mac screenshots', () => {
expect(macNote({ macVersion: true, mac: false })).toContain('apple.mac: true');
expect(macNote({ macVersion: true, mac: true })).toBeNull();
expect(macNote({ macVersion: false, mac: false })).toBeNull();
});
});
describe('landscape layout sizes', () => {
test('mac: window frame by default; chromebook and Play tablets: bare', () => {
expect(renderDims('mac', undefined)).toEqual({ w: 2880, h: 1800, landscape: true, frame: 'window' });
expect(renderDims('chromebook', undefined)).toEqual({ w: 1920, h: 1080, landscape: true, frame: 'none' });
expect(renderDims('tab10', undefined)).toMatchObject({ w: 1440, h: 2560, landscape: false });
expect(renderDims('tab10', { landscape: { playTablets: true } })).toMatchObject({ w: 2560, h: 1440, landscape: true, frame: 'none' });
expect(renderDims('mac', { landscape: { frame: 'none' } }).frame).toBe('none');
for (const k of ['tab7', 'tab10']) { const d = renderDims(k, { landscape: { playTablets: true } }); expect(checkImage(imageInfo(png(d.w, d.h)), k)).toEqual([]); }
});
test('chromebook: opt-in with play.chromebook, a check, and a console note instead of an upload', () => {
expect(defaultSizes({ play: { packageName: 'x' } } as never)).not.toContain('chromebook');
expect(defaultSizes({ play: { packageName: 'x', chromebook: true } } as never)).toContain('chromebook');
expect(checkImage(imageInfo(png(1920, 1080)), 'chromebook')).toEqual([]);
expect(chromebookSkip(true)).toContain('Main store listing');
expect(chromebookSkip(false)).toBeNull();
});
test('config: frame and side are validated', () => {
const errs = (landscape: 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' }] }], landscape } } as never).filter(e => e.startsWith('shots.landscape'));
expect(errs({ frame: 'window', side: 'right', playTablets: true })).toEqual([]);
expect(errs({ frame: 'macbook' })).toEqual(["shots.landscape.frame: 'window' | 'none'"]);
});
});
describe('safe margins', () => {
const W = 1320, H = 2868;
test('boxes inside the margins pass; text at the edge fails', () => {
expect(marginViolations([{ el: 'title', x: W * .07, y: H * .05, w: W * .86, h: 200 }], W, H)).toEqual([]);
const v = marginViolations([{ el: 'title', x: 12, y: 300, w: 1290, h: 200 }, { el: 'logo', x: 200, y: 20, w: 900, h: 300 }], W, H);
expect(v).toHaveLength(2);
expect(v[0]).toContain('title is too close to the edge (left 12 px, right 18 px');
expect(v[1]).toContain('logo is too close to the edge (top 20 px');
expect(Math.round(W * SAFE_MARGIN_MIN.x)).toBe(79);
});
test('shots check: an error per box, none without layout data', () => {
const e = marginIssues({ overflow: [], boxes: [{ el: 'subtitle', x: 0, y: 900, w: 500, h: 60 }] }, W, H);
expect(e.map(i => [i.level, i.code])).toEqual([['error', 'margin']]);
expect(marginIssues({ overflow: [] }, W, H)).toEqual([]);
expect(marginIssues(undefined, W, H)).toEqual([]);
});
});
describe('contact sheet timeout', () => {
test('scales with images, sizes and load; never a flat 30 s', () => {
const nine = sheetTimeoutMs(9 * 5, 9);
expect(nine).toBeGreaterThan(30_000 * 3);
expect(sheetTimeoutMs(90, 9)).toBeGreaterThan(nine);
expect(sheetTimeoutMs(45, 9, 3)).toBe(nine * 3);
expect(sheetTimeoutMs(45, 9, 50)).toBe(nine * 5);
expect(sheetTimeoutMs(1, 1, 0.2)).toBe(sheetTimeoutMs(1, 1));
});
test('load factor: forced by env, clamped 1..5', () => {
expect(loadFactor({ STOREBOUND_LOAD_FACTOR: '3' })).toBe(3);
expect(loadFactor({}, [40, 20, 0], 10)).toBe(4);
expect(loadFactor({}, [0, 0, 0], 10)).toBe(1);
expect(loadFactor({}, [500, 0, 0], 10)).toBe(5);
});
test('landscape tiles take half height on the sheet', () => {
const l = sheetLayout([{ size: 'mac', files: ['a'], dims: { w: 2880, h: 1800 } }]);
expect(l.tiles[0]).toMatchObject({ h: 450, w: 720 });
});
});