import { chromium } from 'playwright'; import fs from 'node:fs/promises'; import path from 'node:path'; import http from 'node:http'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; const root = path.dirname(fileURLToPath(import.meta.url)); await fs.mkdir(path.join(root, 'qa'), { recursive:true }); const catalogResponse = await fetch('https://license.enelix.ch/api/catalog'); assert.equal(catalogResponse.status, 200); const fixture = await catalogResponse.json(); const live = process.argv.includes('--live'); const server = http.createServer(async (req, res) => { const pathname = new URL(req.url, 'http://localhost').pathname; if (pathname === '/api/catalog') { res.writeHead(200, { 'content-type':'application/json' }); return res.end(JSON.stringify(fixture)); } const file = path.join(root, 'preview', pathname); if (!file.startsWith(path.join(root, 'preview') + '/')) { res.writeHead(404); return res.end(); } try { const content = await fs.readFile(file); const type = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json', '.png':'image/png' }[path.extname(file)] || 'application/octet-stream'; res.writeHead(200, { 'content-type':type }); res.end(content); } catch { res.writeHead(404); res.end(); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = live ? 'https://license.enelix.ch' : `http://127.0.0.1:${server.address().port}`; process.env.TMPDIR = path.join(root, 'qa/runtime'); await fs.mkdir(process.env.TMPDIR, { recursive:true }); const standalone = (await import('@sparticuz/chromium')).default; const browser = await chromium.launch({ headless:true, args:standalone.args.filter(arg => !['--single-process', '--disable-web-security', '--allow-running-insecure-content', '--disable-site-isolation-trials'].includes(arg) && !arg.startsWith('--disable-features=')), executablePath:await standalone.executablePath() }); const errors = []; try { for (const [label,width,height] of [['desktop',1440,1000], ['mobile',390,844], ['small-mobile',320,740]]) { const context = await browser.newContext({ viewport:{width,height} }); const page = await context.newPage(); await page.clock.install(); page.on('pageerror', error => errors.push(error.message)); await page.goto(`${origin}/docs/index.html`); assert.match(await page.locator('article h1').textContent(), /Enelix verstehen/); assert.match(await page.locator('article').textContent(), /Energiemanagementsystem/); await page.screenshot({ path:path.join(root, `qa/docs-${label}.png`) }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} overview overflow`); if (width < 720) { assert.equal(await page.locator('#docs-menu').getAttribute('open'), null); await page.locator('#docs-menu > summary').click(); } const consumers = page.locator('[data-folder="verbraucher"]'); const interfaces = page.locator('[data-folder="schnittstellen"]'); const folderToggle = consumers.locator(':scope > summary'); assert.equal(await consumers.getAttribute('open'), null); assert.equal(await consumers.locator('a').count(), 8); assert.equal(await page.locator('nav[aria-label="Dokumentation"] a[href="/docs/ems/Manager.html"]:visible').count(), 1); await folderToggle.focus(); await folderToggle.press('Enter'); assert.equal(await consumers.evaluate(el => el.open), true); await page.screenshot({ path:path.join(root, `qa/navigation-${label}.png`) }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} expanded navigation overflow`); await folderToggle.press('Space'); assert.equal(await consumers.evaluate(el => el.open), false); await page.locator('#doc-search').fill('Waermepumpe'); assert.equal(await consumers.evaluate(el => el.open), true); assert.equal(await consumers.locator('a[href="/docs/ems/Waermepumpe.html"]:visible').count(), 1); assert.equal(await interfaces.isVisible(), false); await page.locator('#doc-search').fill('Schnittstellen'); assert.equal(await consumers.isVisible(), false); assert.equal(await interfaces.evaluate(el => el.open), true); assert.equal(await interfaces.locator('[data-search]:visible').count(), 3); await page.locator('#doc-search').fill(''); assert.equal(await consumers.evaluate(el => el.open), false); assert.equal(await interfaces.evaluate(el => el.open), false); await folderToggle.click(); await page.locator('#doc-search').fill('Shelly'); await page.locator('#doc-search').fill(''); assert.equal(await consumers.evaluate(el => el.open), true, 'Search preserves manually expanded folders'); await page.locator('#doc-search').fill('Shelly'); assert.match(await page.locator('#search-result').textContent(), /passende Themen/); assert.ok(await page.locator('[data-search]:visible').count() >= 1); await page.locator('#doc-search').fill('unfindable-text-xxx'); assert.match(await page.locator('#search-result').textContent(), /Keine/); assert.equal(await page.locator('#docs-menu section:visible').count(), 0); await page.locator('#doc-search').fill(''); await consumers.locator('a[href="/docs/ems/Waermepumpe.html"]').click(); await page.waitForURL('**/docs/ems/Waermepumpe.html'); assert.equal(await consumers.evaluate(el => el.open), true, 'Current page opens its folder'); assert.equal(await consumers.locator('a[aria-current="page"]').getAttribute('href'), '/docs/ems/Waermepumpe.html'); for (const name of ['index','erste-schritte','manager']) { await page.goto(`${origin}/docs/${name}.html`); const images = page.locator('article img'); assert.ok(await images.count() > 0, `${name}: missing screenshot`); for (const img of await images.all()) { await img.scrollIntoViewIfNeeded(); await img.evaluate(el => el.decode()); assert.ok(await img.evaluate(el => el.naturalWidth >= 100 && el.naturalHeight >= 100)); assert.match(await img.getAttribute('src'), /^\/docs\/images\/symcon-[a-z-]+-[a-f0-9]{16}\.png$/); assert.ok(await img.getAttribute('alt')); } assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} ${name} image overflow`); await page.screenshot({ path:path.join(root, `qa/${name}-image-${label}.png`) }); } await page.goto(`${origin}/docs/preise.html`); await page.waitForSelector('.price-table'); assert.equal(await page.locator('.price-table tbody tr').count(), Object.keys(fixture.items).length); assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes((fixture.items.manager_standard.unitAmount / 100).toFixed(2))); await page.locator('[value="gross"]').check(); const expectedGross = ((fixture.items.manager_standard.unitAmount + Math.round(fixture.items.manager_standard.unitAmount * fixture.vatRate / 100)) / 100).toFixed(2); assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes(expectedGross)); await page.screenshot({ path:path.join(root, `qa/prices-${label}.png`) }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} prices overflow`); const changed = structuredClone(fixture); changed.items.manager_standard.unitAmount = 50000; changed.items.new_test = { sku:'NEW', name:'Backend-Neuprodukt', unitAmount:1234, setupAmount:0, termMonths:6 }; await page.route('**/api/catalog', route => route.fulfill({ json:changed })); await page.clock.fastForward(31000); await page.waitForFunction(count => document.querySelectorAll('.price-table tbody tr').length === count, Object.keys(changed.items).length); assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes(((50000 + Math.round(50000 * fixture.vatRate / 100)) / 100).toFixed(2))); await page.unroute('**/api/catalog'); await page.route('**/api/catalog', route => route.fulfill({ status:503, body:'unavailable' })); await page.locator('#refresh-prices').click(); await page.waitForSelector('#price-status.error'); assert.equal(await page.locator('.price-table').count(), 0); await page.unroute('**/api/catalog'); await page.locator('#refresh-prices').click(); await page.waitForSelector('.price-table'); await page.goto(`${origin}/docs/ems/Manager.html`); await page.screenshot({ path:path.join(root, `qa/manager-${label}.png`) }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} reference overflow`); const noJs = await browser.newContext({ javaScriptEnabled:false, viewport:{width,height} }); const staticPage = await noJs.newPage(); await staticPage.goto(`${origin}/docs/installation.html`); assert.match(await staticPage.locator('article').textContent(), /Bibliotheken hinzufügen/); const staticFolder = staticPage.locator('[data-folder="verbraucher"]'); await staticFolder.locator(':scope > summary').click(); assert.equal(await staticFolder.evaluate(el => el.open), true); await staticFolder.locator('a[href="/docs/ems/Batterie.html"]').click(); await staticPage.waitForURL('**/docs/ems/Batterie.html'); assert.equal(await staticFolder.evaluate(el => el.open), true); assert.equal(await staticFolder.locator('a[aria-current="page"]').getAttribute('href'), '/docs/ems/Batterie.html'); await noJs.close(); if (live) { await page.goto(origin + '/'); await page.waitForSelector('#loginForm:visible'); assert.equal(await page.locator('.public-doc-links a').count(), 3); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} login overflow`); await page.screenshot({ path:path.join(root, `qa/portal-${label}.png`) }); } await context.close(); console.log(`${label}: folders, keyboard navigation, search expansion/reset, active branch, screenshots, prices, API recovery, layout and no-JS passed`); } assert.deepEqual(errors, []); } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }