import { chromium } from 'playwright'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { mkdir, writeFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import net from 'node:net'; import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js'; const php = process.env.PHP_BINARY || 'php'; const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory }; execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env }); const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); const origin = `http://127.0.0.1:${port}`; const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } }); const report = 'reports/image-alignment'; await mkdir(report, { recursive: true }); let browser, page; const checks = [], errors = []; // Geometry of the visible image as well as its figure: centering an empty, full-width // figure alone must not pass while the logo inside it stays at the left edge. const geometry = figure => { const img = figure.querySelector('img'); const parent = figure.parentElement, style = getComputedStyle(parent); const p = parent.getBoundingClientRect(), f = figure.getBoundingClientRect(), i = img.getBoundingClientRect(); const left = p.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft); const width = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); const top = p.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop); const height = parent.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); return { left: f.left - left, right: left + width - f.right, width: f.width, available: width, center: i.left + i.width / 2 - left - width / 2, innerCenter: i.left + i.width / 2 - f.left - f.width / 2, verticalCenter: f.top + f.height / 2 - top - height / 2, imageWidth: i.width, imageHeight: i.height, align: getComputedStyle(figure).alignSelf }; }; const closeTo = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 2, `${message}: ${actual} vs ${expected}`); try { for (let attempt = 0; attempt < 60; attempt++) { try { if ((await fetch(origin)).ok) break; } catch {} if (attempt === 59) throw new Error('Pilot test server did not start'); await new Promise(resolve => setTimeout(resolve, 200)); } browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } }); page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message)); await page.goto(origin + '/admin/login'); await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin'); const api = `${origin}/admin/api/pages/${fixture.homeId}`; const data = await (await context.request.get(api)).json(); upgradeDocument(data.document, 20); const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items; const chosen = media.find(item => /teamviewer/i.test(item.title)); assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title; const small = { ...original, width: 180, height: Math.round(180 * original.height / original.width) }; const image = (id, style, props = original) => ({ id, type: 'image', props: structuredClone(props), style }); const centered = { desktop: { width: '40%', alignSelf: 'center' } }; const container = (id, style, child) => ({ id, type: 'column', style, children: [child] }); const section = { id: 'qa-alignment-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px', padding: { left: '20px', right: '20px' } } }, children: [ container('qa-flow', { desktop: { padding: { left: '18px', right: '18px' } } }, image('qa-center', { desktop: { width: '50%' } })), image('qa-natural', { desktop: { alignSelf: 'center' } }, small), image('qa-linked', { desktop: { width: '60%', alignSelf: 'center', margin: { left: '11px', right: '7px' } }, tablet: { alignSelf: 'flex-end' }, mobile: { alignSelf: 'auto' } }, { ...small, click: 'link', href: '/kontakt', newTab: true }), container('qa-grid', { desktop: { display: 'grid', gridColumns: '1', height: '160px' } }, image('qa-grid-image', centered)), container('qa-flex-column', { desktop: { display: 'flex', flexDirection: 'column', height: '160px' } }, image('qa-column-image', centered)), container('qa-flex-row', { desktop: { display: 'flex', height: '160px' } }, image('qa-row-image', centered)), container('qa-responsive-parent', { desktop: { display: 'flex', height: '160px' }, tablet: { flexDirection: 'column' }, mobile: { display: 'block' } }, image('qa-responsive-image', centered)), image('qa-zoom', { desktop: { maxWidth: '300px', alignSelf: 'center' } }, { ...small, click: 'vergroessern' }), ] }; data.document.blocks.unshift(section); const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } }); assert.equal(created.status(), 200, await created.text()); await page.goto(`${origin}/admin/pages/${fixture.homeId}`); const select = async () => { await page.locator('#structure [data-block-id="qa-center"]').click(); await page.locator('[data-panel="design"]').click(); const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Layout$/ }) }); if (await group.getAttribute('open') === null) await group.locator('summary').click(); }; const preview = () => page.frameLocator('#preview'); const aligned = async value => { await page.waitForFunction(value => { const doc = document.querySelector('#preview')?.contentDocument, figure = doc?.getElementById('qa-center'); return figure && doc.defaultView.getComputedStyle(figure).alignSelf === value; }, value); return preview().locator('#qa-center').evaluate(geometry); }; await select(); closeTo((await aligned('auto')).left, 0, 'Initial left edge'); await page.locator('#style-alignSelf').selectOption('center'); closeTo((await aligned('center')).center, 0, 'Center in normal column'); await page.locator('#undo').click(); closeTo((await aligned('auto')).left, 0, 'Undo centering'); await page.locator('#redo').click(); closeTo((await aligned('center')).center, 0, 'Redo centering'); await page.locator('#style-alignSelf').focus(); await page.locator('#style-alignSelf').press('ArrowDown'); closeTo((await aligned('flex-end')).right, 0, 'Keyboard end alignment'); await page.locator('#style-alignSelf').selectOption('flex-start'); closeTo((await aligned('flex-start')).left, 0, 'Start alignment'); await page.locator('#style-alignSelf').selectOption('center'); await aligned('center'); checks.push('actual Design control: center, start, end, keyboard, undo and redo in a normal padded column'); const checkLayout = async (root, device) => { for (const id of ['qa-center', 'qa-natural', 'qa-grid-image', 'qa-column-image', 'qa-zoom']) { closeTo((await root.locator(`#${id}`).evaluate(geometry)).center, 0, `${device} ${id} centered`); } const normal = await root.locator('#qa-center').evaluate(geometry); closeTo(normal.width / normal.available * 100, 50, 'Percentage width preserved'); const natural = await root.locator('#qa-natural').evaluate(geometry); closeTo(natural.imageWidth, 180, 'Natural logo is not stretched'); const row = await root.locator('#qa-row-image').evaluate(geometry); closeTo(row.left, 0, 'Flex row keeps its main-axis position'); closeTo(row.verticalCenter, 0, 'Flex row retains vertical centering'); const changing = await root.locator('#qa-responsive-image').evaluate(geometry); closeTo(device === 'desktop' ? changing.left : changing.center, 0, 'Responsive parent layout'); const linked = await root.locator('#qa-linked').evaluate(geometry); if (device === 'desktop') closeTo(linked.center, 0, 'Linked image centered'); if (device === 'tablet') { closeTo(linked.right, 0, 'Tablet figure at end'); const img = await root.locator('#qa-linked img').boundingBox(); const fig = await root.locator('#qa-linked').boundingBox(); closeTo(img.x + img.width, fig.x + fig.width, 'Small linked image at end'); } if (device === 'mobile') { closeTo(linked.left, 11, 'Auto restores authored margin'); closeTo(linked.innerCenter, -(linked.width - linked.imageWidth) / 2, 'Auto clears inherited internal alignment'); } assert.equal(await root.locator('#qa-linked a').getAttribute('target'), '_blank'); closeTo(normal.imageWidth / normal.imageHeight, original.width / original.height, 'Aspect ratio preserved'); }; for (const device of ['desktop', 'tablet', 'mobile']) { await page.locator(`[data-viewport="${device}"]`).click(); await aligned('center'); await checkLayout(preview(), device); } await page.locator('[data-viewport="desktop"]').click(); await aligned('center'); await preview().locator('#qa-flow').screenshot({ path: `${report}/centered-column.png` }); await page.locator('#style-alignSelf').scrollIntoViewIfNeeded(); await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true }); checks.push('preview: natural, percentage and maximum widths; links/zoom; grid and flex axes; responsive parent and auto reset'); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); await page.reload(); await select(); closeTo((await aligned('center')).center, 0, 'Reload preserves alignment'); const stored = await (await context.request.get(api)).json(); const block = stored.document.blocks[0].children[0].children[0]; assert.deepEqual(block.props, original); assert.deepEqual(block.style, { desktop: { width: '50%', alignSelf: 'center' } }); await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage(); for (const [width, device] of [[1600, 'desktop'], [900, 'tablet'], [390, 'mobile']]) { await publicPage.setViewportSize({ width, height: 1000 }); await publicPage.goto(origin); await checkLayout(publicPage, device); await publicPage.locator('#qa-alignment-section').screenshot({ path: `${report}/published-${device}.png` }); } await plain.close(); checks.push('save/reload, unchanged media metadata, published desktop/tablet/mobile with JavaScript disabled'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Image alignment checks passed:', checks.length, 'groups.'); } catch (error) { if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {}); throw error; } finally { await browser?.close(); server.kill(); }