// 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', rotate: -90 })).toEqual([]); expect(errs({ frame: 'a.png', rotate: 'left' })).toEqual(['shots.feature.rotate: a number of degrees from -360 to 360']); 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 }) => { 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)); }); describe.skipIf(!browser)('shots.feature.rotate', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const route = (page: any, html: string, name: string, job: unknown) => page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise }) => { const u = new URL(r.request().url()); if (u.pathname === `/${name}`) 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: '' }); }); const pal = { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' }; test('game template: a -90 phone lies on its side and still fits', async () => { const page = await browser.newPage({ viewport: { width: 1024, height: 500 } }); try { const device = featureLayout('game', DEFAULT_PHONE, { rotate: -90 }); expect(device.angle).toBe(-83); await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature.html'), 'utf8'), 'feature.html', { style: 'game', phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png', device, palette: pal }); await page.goto('http://t.test/feature.html?lang=en'); await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 }); const d = (JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[])[0]!; expect(d.w).toBeGreaterThan(d.h); // landscape outline expect(boxOutside(d, 1024, 500)).toEqual([]); } finally { await page.close(); } }, scaled(30_000)); test('bleed template: the frame image is turned and still covers the canvas', async () => { const page = await browser.newPage({ viewport: { width: 1024, height: 500 } }); try { await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature-bleed.html'), 'utf8'), 'feature-bleed.html', { fonts: { title: 'X' }, frame: 'a.png', appName: 'Demo', rotate: -90 }); await page.goto('http://t.test/feature-bleed.html?lang=en'); await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 }); const r = await page.evaluate("(() => { const b = document.querySelector('img.frame').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; })()") as { x: number; y: number; w: number; h: number }; expect(Math.round(r.w)).toBe(1024); expect(Math.round(r.h)).toBe(500); expect(Math.round(r.x)).toBe(0); expect(Math.round(r.y)).toBe(0); expect(await page.evaluate("document.querySelector('img.frame').style.transform")).toBe('rotate(-90deg)'); } finally { await page.close(); } }, scaled(30_000)); });