import { chromium } from 'playwright'; import AxeBuilder from '@axe-core/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 } 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-size'; await mkdir(report, { recursive: true }); let browser, page; const checks = [], errors = []; 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(); const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items; const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800); assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title; data.document.blocks.unshift({ id: 'qa-image-size-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px' } }, children: [{ id: 'qa-image-size', type: 'image', props: original }] }); 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 page.goto(`${origin}/admin/pages/${fixture.homeId}`); const select = async () => { await page.locator('#structure [data-block-id="qa-image-size"]').click(); await page.locator('[data-panel="content"]').click(); }; await select(); const exact = () => page.getByLabel('Bildbreite (%)', { exact: true }); const slider = () => page.getByRole('slider', { name: 'Bildbreite Schieberegler' }); const width = async percent => page.waitForFunction(percent => { const doc = document.querySelector('#preview')?.contentDocument; const figure = doc?.getElementById('qa-image-size'); if (!figure) return false; const parent = figure.parentElement; const style = doc.defaultView.getComputedStyle(parent); const available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); return Math.abs(figure.getBoundingClientRect().width / available * 100 - percent) < .1; }, percent); const saved = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); }; const changeDevice = async device => page.locator(`[data-viewport="${device}"]`).click(); assert.equal(await exact().inputValue(), ''); await exact().fill('50'); await width(50); assert.equal(await exact().evaluate(input => input === document.activeElement), true); await exact().blur(); await slider().focus(); await slider().press('ArrowRight'); await width(50.1); await page.locator('#undo').click(); await width(50); await page.locator('#redo').click(); await width(50.1); const box = await slider().boundingBox(); await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); await page.mouse.up(); const dragged = Number(await exact().inputValue()); assert.ok(dragged > 50.1); await width(dragged); await page.locator('#undo').click(); await width(50.1); await page.locator('#redo').click(); await width(dragged); await exact().fill('0'); assert.equal(await exact().getAttribute('aria-invalid'), 'true'); await width(dragged); await exact().fill('50'); await width(50); await exact().blur(); checks.push('number, keyboard and drag update preview; one undo per gesture; invalid values do not change the document'); await changeDevice('tablet'); assert.equal(await exact().inputValue(), '50'); assert.match(await page.locator('#image-width-state').innerText(), /Von Desktop übernommen/); await exact().fill('75'); await width(75); await exact().blur(); await changeDevice('mobile'); assert.equal(await exact().inputValue(), '75'); await exact().fill('30'); await width(30); await exact().blur(); await page.locator('#image-width-reset').click(); await width(75); assert.match(await page.locator('#image-width-state').innerText(), /Von Tablet übernommen/); await page.locator('#undo').click(); await width(30); await changeDevice('desktop'); assert.equal(await exact().inputValue(), '50'); await width(50); await saved(); await page.reload(); await select(); assert.equal(await exact().inputValue(), '50'); await width(50); const stored = await (await context.request.get(api)).json(); const image = stored.document.blocks[0].children[0]; assert.deepEqual(image.style, { desktop: { width: '50%' }, tablet: { width: '75%' }, mobile: { width: '30%' } }); assert.deepEqual(image.props, original, 'original dimensions, media variants, caption and alt text are unchanged'); checks.push('device inheritance, reset, undo, save/reload and unchanged image metadata'); await page.locator('[data-panel="design"]').click(); const size = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Größe$/ }) }); if (!(await size.getAttribute('open'))) await size.locator('summary').click(); assert.equal(await page.locator('#style-width').inputValue(), '50'); await page.locator('#style-width').fill('60'); await page.locator('#style-width').blur(); await page.locator('[data-panel="content"]').click(); assert.equal(await exact().inputValue(), '60'); await width(60); await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true }); for (const viewport of [1600, 390]) { await page.setViewportSize({ width: viewport, height: 1050 }); await exact().scrollIntoViewIfNeeded(); const results = await new AxeBuilder({ page }).include('.image-width-control').analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); assert.equal(await page.locator('.image-width-control').evaluate(el => el.scrollWidth <= el.clientWidth), true); await page.locator('.image-width-control').screenshot({ path: `${report}/control-${viewport}.png` }); } await page.setViewportSize({ width: 1600, height: 1050 }); await saved(); 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 [viewport, percent] of [[1600, 60], [900, 75], [390, 30]]) { await publicPage.setViewportSize({ width: viewport, height: 900 }); await publicPage.goto(origin); const geometry = await publicPage.locator('#qa-image-size').evaluate(async figure => { const img = figure.querySelector('img'); await img.decode(); const parent = figure.parentElement, style = getComputedStyle(parent); return { figure: figure.getBoundingClientRect().width, available: parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height }; }); assert.ok(Math.abs(geometry.figure / geometry.available * 100 - percent) < .1); assert.ok(Math.abs(geometry.width / geometry.height - original.width / original.height) < .02, 'image aspect ratio is preserved'); await publicPage.locator('#qa-image-size-section').screenshot({ path: `${report}/published-${viewport}.png` }); } await plain.close(); checks.push('shared Design width; desktop/mobile accessible controls; published responsive percentages and aspect ratio without JavaScript'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Image size 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(); }