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/link-media'; 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-link-section', type: 'section', children: [ { id: 'qa-link-image', type: 'image', props: { ...original, href: '/impressum', click: 'link' } }, { id: 'qa-empty-image', type: 'image', props: { ...original, click: 'keine' } }, { id: 'qa-link-button', type: 'button', props: { text: 'QA Neuer Tab', href: '/impressum' } }, { id: 'qa-link-text', type: 'text', props: { text: '
Textlink prüfen
' } }, { id: 'qa-link-gallery', type: 'gallery', children: [{ id: 'qa-gallery-image', type: 'image', props: original }] }, ] }); data.document.blocks.unshift({ id: 'qa-background', type: 'section', style: { desktop: { backgroundImage: chosen.id, minHeight: '200px' } }, children: [] }); 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 frame = page.frameLocator('#preview'); const select = async (id, panel = 'content') => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`[data-panel="${panel}"]`).click(); }; const thumbnail = () => page.locator('#properties > .selected-media-preview img'); const background = async hasImage => page.waitForFunction(hasImage => { const doc = document.querySelector('#preview')?.contentDocument, block = doc?.getElementById('qa-background'); return block && doc.defaultView.getComputedStyle(block).backgroundImage.includes('url(') === hasImage; }, hasImage); const save = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); }; await select('qa-empty-image'); const clickAction = () => page.getByLabel('Beim Klick', { exact: true }); await page.locator('#property-href').waitFor({ state: 'hidden' }); const nativeSelect = await clickAction().elementHandle(); await clickAction().click(); await page.keyboard.press('End'); await page.keyboard.press('Enter'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); assert.equal(await nativeSelect.evaluate(el => el.isConnected), true, 'Keep the native dropdown mounted while changing the action'); await nativeSelect.dispose(); await page.getByLabel('Linkziel', { exact: true }).fill('/kontakt'); await clickAction().focus(); await page.keyboard.press('Home'); await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.keyboard.press('End'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt'); await clickAction().selectOption('keine'); await clickAction().evaluate(el => { el.value = 'link'; el.dispatchEvent(new Event('change', { bubbles: true })); }); await page.getByLabel('Linkziel', { exact: true }).waitFor(); assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt'); await page.locator('#undo').click(); assert.equal(await clickAction().inputValue(), 'keine'); await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#redo').click(); assert.equal(await clickAction().inputValue(), 'link'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); await clickAction().selectOption('vergroessern'); await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#property-newTab').waitFor({ state: 'hidden' }); await frame.locator('#qa-empty-image a.image-zoom').waitFor(); await clickAction().selectOption('link'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); await page.locator('.image-link-options').screenshot({ path: `${report}/image-link-fields.png` }); checks.push('fresh image URL appears after native dropdown and keyboard selection; URL retained; change-only event supported'); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' }); assert.ok(await thumbnail().evaluate(img => img.naturalWidth > 0)); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check(); await frame.locator('#qa-link-image a[target="_blank"][rel="noopener"]').waitFor(); await page.getByLabel('Beim Klick', { exact: true }).selectOption('keine'); await frame.locator('#qa-link-image a').waitFor({ state: 'detached' }); await page.getByLabel('Beim Klick', { exact: true }).focus(); await page.getByLabel('Beim Klick', { exact: true }).selectOption('link'); assert.equal(await page.getByLabel('Beim Klick', { exact: true }).evaluate(el => el === document.activeElement), true); await page.getByLabel('Linkziel', { exact: true }).waitFor(); await page.getByLabel('Bildadresse', { exact: true }).fill('/nicht-vorhanden.png'); await page.getByLabel('Bildadresse', { exact: true }).blur(); await page.locator('#properties > .selected-media-preview').getByText(/nicht verfügbar/).waitFor(); await page.locator('#undo').click(); await thumbnail().waitFor({ state: 'visible' }); await page.getByRole('button', { name: 'Bild entfernen', exact: true }).click(); await frame.locator('#qa-link-image').waitFor({ state: 'detached' }); assert.equal((await context.request.get(origin + '/admin/api/media/' + chosen.id)).status(), 200); await page.locator('#undo').click(); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' }); await page.locator('.right-panel').screenshot({ path: `${report}/image-inspector.png` }); await select('qa-link-gallery'); await page.locator('.selected-media-list img').waitFor({ state: 'visible' }); await page.locator('.selected-media-list').getByRole('button', { name: /entfernen$/ }).click(); await frame.locator('#qa-gallery-image').waitFor({ state: 'detached' }); await page.locator('#undo').click(); await frame.locator('#qa-gallery-image').waitFor(); checks.push('image and gallery thumbnails, missing-image feedback, remove/undo without deleting media; image link/no-action toggle'); await select('qa-background', 'design'); const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Hintergrund$/ }) }); if (!(await group.getAttribute('open'))) await group.locator('summary').click(); await group.locator('.selected-media-preview img').waitFor({ state: 'visible' }); await page.locator('[data-viewport="tablet"]').click(); await group.locator('.selected-media-preview img').waitFor({ state: 'visible' }); await background(true); await group.getByRole('button', { name: 'Bild entfernen', exact: true }).click(); await background(false); assert.equal(await page.locator('#style-backgroundImage').evaluate(el => el === document.activeElement), true); await page.locator('[data-viewport="mobile"]').click(); await background(false); await page.locator('[data-viewport="desktop"]').click(); await background(true); await page.locator('[data-viewport="tablet"]').click(); await page.getByRole('button', { name: 'Hintergrundbild zurücksetzen', exact: true }).click(); await background(true); await page.locator('#undo').click(); await background(false); await page.locator('[data-viewport="desktop"]').click(); checks.push('inherited background thumbnail, remove on tablet/mobile, preserve desktop, reset and undo'); await select('qa-link-button'); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check(); await frame.locator('#qa-link-button[target="_blank"]').waitFor(); await select('qa-link-text'); const surface = page.locator('.rt-surface'); await surface.evaluate(el => { const range = document.createRange(); range.selectNodeContents(el.querySelector('p')); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.focus(); }); await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click(); await page.getByLabel('Linkziel', { exact: true }).fill('/impressum'); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check(); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click(); await frame.locator('#qa-link-text a[target="_blank"][rel="noopener"]').waitFor(); const editExisting = async () => { await surface.locator('a').evaluate(el => { const range = document.createRange(); range.selectNodeContents(el); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.closest('[contenteditable]').focus(); }); await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click(); assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/impressum'); }; await editExisting(); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true); await page.getByLabel('In neuem Tab öffnen', { exact: true }).uncheck(); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click(); await frame.locator('#qa-link-text a:not([target])').waitFor(); await editExisting(); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check(); await page.locator('.rt-link').screenshot({ path: `${report}/text-link.png` }); const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click(); await save(); await page.reload(); await select('qa-link-image'); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true); await thumbnail().waitFor({ state: 'visible' }); await select('qa-empty-image'); assert.equal(await clickAction().inputValue(), 'link'); assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt'); await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); checks.push('button and rich-text target creation/edit/uncheck, safe attributes, save/reload and publication'); await page.goto(origin + '/admin/menus'); await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click(); await page.locator('#link-label').fill('QA Menülink'); await page.locator('#link-url').fill('/impressum'); await page.locator('#link-new-tab').check(); await page.locator('#menu-add-url').click(); const entry = () => page.locator('#menu-items > li').last(); await entry().locator('summary').click(); assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true); await entry().getByLabel('In neuem Tab öffnen', { exact: true }).uncheck(); await page.locator('#menu-undo').click(); await entry().locator('summary').click(); assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true); await page.getByRole('button', { name: 'Menü speichern', exact: true }).click(); await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor(); await page.reload(); await entry().locator('summary').click(); assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true); await entry().screenshot({ path: `${report}/menu-link.png` }); checks.push('menu creation, checkbox undo, save and reload'); const publicPage = await context.newPage(); await publicPage.goto(origin); for (const selector of ['#qa-link-image a', '#qa-link-button', '#qa-link-text a']) { assert.equal(await publicPage.locator(selector).getAttribute('target'), '_blank'); assert.equal(await publicPage.locator(selector).getAttribute('rel'), 'noopener'); } assert.equal(await publicPage.getByRole('link', { name: 'QA Menülink', exact: true }).getAttribute('target'), '_blank'); assert.equal(await publicPage.locator('#qa-empty-image a').getAttribute('href'), '/kontakt'); const popupPromise = publicPage.waitForEvent('popup'); await publicPage.locator('#qa-link-button').click(); const popup = await popupPromise; await popup.waitForLoadState(); assert.equal(new URL(popup.url()).pathname, '/impressum'); assert.equal(await popup.evaluate(() => window.opener), null); await popup.close(); assert.equal(new URL(publicPage.url()).pathname, '/'); const publicAudit = await new AxeBuilder({ page: publicPage }).analyze(); assert.deepEqual(publicAudit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); await publicPage.close(); const noScriptContext = await browser.newContext({ javaScriptEnabled: false }); const noScript = await noScriptContext.newPage(); for (const [width, hasImage] of [[1600, true], [900, false], [390, false]]) { await noScript.setViewportSize({ width, height: 1000 }); await noScript.goto(origin); assert.equal(await noScript.locator('#qa-background').evaluate(el => getComputedStyle(el).backgroundImage.includes('url(')), hasImage); assert.equal(await noScript.locator('#qa-link-image a').getAttribute('target'), '_blank'); } await noScriptContext.close(); await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await select('qa-link-image'); for (const viewport of [1600, 390]) { await page.setViewportSize({ width: viewport, height: 1050 }); await thumbnail().scrollIntoViewIfNeeded(); const result = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); assert.equal(await page.locator('.selected-media-preview').evaluate(el => el.scrollWidth <= el.clientWidth), true); await page.locator('#properties').screenshot({ path: `${report}/image-properties-${viewport}.png` }); } checks.push('public links really open a separate tab without opener; public page and desktop/mobile image controls pass axe; background removal persists without JavaScript at 1600/900/390 px'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Link and media 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(); }