|
| 1 | +#!/usr/bin/env node |
| 2 | +import fs from 'node:fs'; |
| 3 | +import http from 'node:http'; |
| 4 | +import path from 'node:path'; |
| 5 | +import { chromium } from 'playwright-core'; |
| 6 | +import { DIST_DIR } from './orbit-content-utils.mjs'; |
| 7 | + |
| 8 | +const errors = []; |
| 9 | +let assertions = 0; |
| 10 | + |
| 11 | +function check(condition, message) { |
| 12 | + assertions += 1; |
| 13 | + if (!condition) errors.push(message); |
| 14 | +} |
| 15 | + |
| 16 | +function chromeExecutable() { |
| 17 | + const candidates = [ |
| 18 | + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, |
| 19 | + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', |
| 20 | + '/usr/bin/google-chrome', |
| 21 | + '/usr/bin/google-chrome-stable', |
| 22 | + '/usr/bin/chromium', |
| 23 | + '/usr/bin/chromium-browser', |
| 24 | + ].filter(Boolean); |
| 25 | + return candidates.find((candidate) => fs.existsSync(candidate)); |
| 26 | +} |
| 27 | + |
| 28 | +function staticFileFor(requestUrl) { |
| 29 | + const pathname = decodeURIComponent(new URL(requestUrl, 'http://localhost').pathname); |
| 30 | + const clean = pathname.replace(/^\/+/, ''); |
| 31 | + const candidates = !clean |
| 32 | + ? [path.join(DIST_DIR, 'index.html')] |
| 33 | + : path.extname(clean) |
| 34 | + ? [path.join(DIST_DIR, clean)] |
| 35 | + : [path.join(DIST_DIR, clean, 'index.html'), path.join(DIST_DIR, `${clean}.html`)]; |
| 36 | + |
| 37 | + return candidates.find((candidate) => { |
| 38 | + const relative = path.relative(DIST_DIR, candidate); |
| 39 | + return !relative.startsWith('..') && fs.existsSync(candidate) && fs.statSync(candidate).isFile(); |
| 40 | + }); |
| 41 | +} |
| 42 | + |
| 43 | +const mimeTypes = { |
| 44 | + '.css': 'text/css; charset=utf-8', |
| 45 | + '.html': 'text/html; charset=utf-8', |
| 46 | + '.ico': 'image/x-icon', |
| 47 | + '.js': 'text/javascript; charset=utf-8', |
| 48 | + '.json': 'application/json; charset=utf-8', |
| 49 | + '.png': 'image/png', |
| 50 | + '.svg': 'image/svg+xml', |
| 51 | + '.webp': 'image/webp', |
| 52 | + '.xml': 'application/xml; charset=utf-8', |
| 53 | +}; |
| 54 | + |
| 55 | +check(fs.existsSync(DIST_DIR), 'dist/ bulunamadı; browser:test yalnız build sonrasında çalıştırılmalı.'); |
| 56 | +const executablePath = chromeExecutable(); |
| 57 | +check(Boolean(executablePath), 'Desteklenen Chrome/Chromium executable bulunamadı.'); |
| 58 | + |
| 59 | +if (errors.length === 0) { |
| 60 | + const server = http.createServer((request, response) => { |
| 61 | + const file = staticFileFor(request.url ?? '/'); |
| 62 | + if (!file) { |
| 63 | + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); |
| 64 | + response.end('Not found'); |
| 65 | + return; |
| 66 | + } |
| 67 | + response.writeHead(200, { |
| 68 | + 'cache-control': 'no-store', |
| 69 | + 'content-type': mimeTypes[path.extname(file)] ?? 'application/octet-stream', |
| 70 | + }); |
| 71 | + fs.createReadStream(file).pipe(response); |
| 72 | + }); |
| 73 | + |
| 74 | + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); |
| 75 | + const address = server.address(); |
| 76 | + const baseUrl = `http://127.0.0.1:${address.port}`; |
| 77 | + const browser = await chromium.launch({ executablePath, headless: true }); |
| 78 | + const viewports = [ |
| 79 | + { width: 320, height: 700 }, |
| 80 | + { width: 360, height: 800 }, |
| 81 | + { width: 390, height: 844 }, |
| 82 | + { width: 768, height: 1024 }, |
| 83 | + { width: 1440, height: 900 }, |
| 84 | + ]; |
| 85 | + |
| 86 | + try { |
| 87 | + for (const viewport of viewports) { |
| 88 | + const label = `${viewport.width}x${viewport.height}`; |
| 89 | + const context = await browser.newContext({ viewport, colorScheme: 'light' }); |
| 90 | + const page = await context.newPage(); |
| 91 | + const pageErrors = []; |
| 92 | + page.on('pageerror', (error) => pageErrors.push(error.message)); |
| 93 | + await page.goto(baseUrl, { waitUntil: 'networkidle' }); |
| 94 | + |
| 95 | + const layout = await page.evaluate(() => { |
| 96 | + const rect = (selector) => { |
| 97 | + const element = document.querySelector(selector); |
| 98 | + if (!element) return null; |
| 99 | + const box = element.getBoundingClientRect(); |
| 100 | + return { x: box.x, y: box.y, width: box.width, height: box.height, right: box.right, bottom: box.bottom }; |
| 101 | + }; |
| 102 | + const navigation = document.querySelector('.primary-nav'); |
| 103 | + const navLinks = [...navigation.querySelectorAll('a')]; |
| 104 | + return { |
| 105 | + innerWidth, |
| 106 | + scrollWidth: document.documentElement.scrollWidth, |
| 107 | + bodyScrollWidth: document.body.scrollWidth, |
| 108 | + hero: rect('.orbit-welcome'), |
| 109 | + feedHeading: rect('.feed-heading'), |
| 110 | + filter: rect('.feed-filter'), |
| 111 | + firstPost: rect('[data-feed-post]'), |
| 112 | + nav: rect('.primary-nav'), |
| 113 | + navPosition: getComputedStyle(navigation).position, |
| 114 | + navLinks: navLinks.map((link) => ({ |
| 115 | + rect: (() => { |
| 116 | + const box = link.getBoundingClientRect(); |
| 117 | + return { x: box.x, width: box.width, right: box.right }; |
| 118 | + })(), |
| 119 | + flex: getComputedStyle(link).flex, |
| 120 | + minWidth: getComputedStyle(link).minWidth, |
| 121 | + })), |
| 122 | + }; |
| 123 | + }); |
| 124 | + |
| 125 | + check(layout.scrollWidth <= layout.innerWidth, `${label}: document yatay taşıyor (${layout.scrollWidth}/${layout.innerWidth}).`); |
| 126 | + check(layout.bodyScrollWidth <= layout.innerWidth, `${label}: body yatay taşıyor (${layout.bodyScrollWidth}/${layout.innerWidth}).`); |
| 127 | + for (const [name, box] of Object.entries({ hero: layout.hero, filter: layout.filter, firstPost: layout.firstPost })) { |
| 128 | + check(box && box.x >= -0.5 && box.right <= layout.innerWidth + 0.5, `${label}: ${name} viewport dışına taşıyor.`); |
| 129 | + } |
| 130 | + check(layout.hero.bottom <= layout.feedHeading.y + 0.5, `${label}: hero ile akış başlığı çakışıyor.`); |
| 131 | + check(layout.feedHeading.bottom <= layout.filter.y + 0.5, `${label}: akış başlığı ile filtre çakışıyor.`); |
| 132 | + check(layout.filter.bottom <= layout.firstPost.y + 0.5, `${label}: filtre ile ilk gönderi çakışıyor.`); |
| 133 | + check(pageErrors.length === 0, `${label}: sayfa hatası: ${pageErrors.join(' | ')}`); |
| 134 | + |
| 135 | + if (viewport.width <= 780) { |
| 136 | + check(layout.navPosition === 'fixed', `${label}: mobil alt navigasyon fixed değil.`); |
| 137 | + check(layout.nav.x >= 0 && layout.nav.right <= layout.innerWidth && layout.nav.bottom <= viewport.height, `${label}: mobil alt navigasyon kırpılıyor.`); |
| 138 | + check(layout.navLinks.length === 4, `${label}: mobil navigasyonda dört öğe yok.`); |
| 139 | + check(layout.navLinks.every((link) => link.flex.startsWith('1 1 0') && link.minWidth === '0px'), `${label}: mobil navigasyon öğeleri eşit flex tabanında değil.`); |
| 140 | + const navWidths = layout.navLinks.map((link) => link.rect.width); |
| 141 | + check(Math.max(...navWidths) - Math.min(...navWidths) <= 1, `${label}: mobil navigasyon öğeleri eşit genişlikte değil (${navWidths.join(', ')}).`); |
| 142 | + check(layout.navLinks.every((link, index) => index === 0 || layout.navLinks[index - 1].rect.right <= link.rect.x + 0.5), `${label}: mobil navigasyon öğeleri birbiriyle çakışıyor.`); |
| 143 | + check(layout.firstPost.y < viewport.height, `${label}: ilk gönderi ilk viewport'ta görünmüyor.`); |
| 144 | + |
| 145 | + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); |
| 146 | + const footerClearance = await page.evaluate(() => { |
| 147 | + const nav = document.querySelector('.primary-nav').getBoundingClientRect(); |
| 148 | + const footerLinks = [...document.querySelectorAll('.site-footer a')]; |
| 149 | + const lastLinkBottom = Math.max(...footerLinks.map((link) => link.getBoundingClientRect().bottom)); |
| 150 | + return { navTop: nav.top, lastLinkBottom }; |
| 151 | + }); |
| 152 | + check(footerClearance.lastLinkBottom <= footerClearance.navTop - 2, `${label}: alt navigasyon footer bağlantılarını kapatıyor.`); |
| 153 | + } else { |
| 154 | + check(layout.navPosition !== 'fixed', `${label}: masaüstü navigasyonu yanlışlıkla fixed alt bara dönüştü.`); |
| 155 | + } |
| 156 | + |
| 157 | + check(await page.locator('html').getAttribute('data-theme') === 'light', `${label}: başlangıç teması light değil.`); |
| 158 | + await page.locator('[data-theme-toggle]').click(); |
| 159 | + check(await page.locator('html').getAttribute('data-theme') === 'dark', `${label}: tema dark durumuna geçmedi.`); |
| 160 | + check(await page.evaluate(() => localStorage.getItem('orbit-theme')) === 'dark', `${label}: dark tema localStorage'a yazılmadı.`); |
| 161 | + await page.reload({ waitUntil: 'networkidle' }); |
| 162 | + check(await page.locator('html').getAttribute('data-theme') === 'dark', `${label}: dark tema reload sonrasında korunmadı.`); |
| 163 | + await page.locator('[data-theme-toggle]').click(); |
| 164 | + check(await page.locator('html').getAttribute('data-theme') === 'light', `${label}: tema light durumuna geri dönmedi.`); |
| 165 | + check(await page.evaluate(() => localStorage.getItem('orbit-theme')) === 'light', `${label}: light tema localStorage'a yazılmadı.`); |
| 166 | + await context.close(); |
| 167 | + } |
| 168 | + } finally { |
| 169 | + await browser.close(); |
| 170 | + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +if (errors.length) { |
| 175 | + process.stderr.write(`${errors.map((error) => `- ${error}`).join('\n')}\n`); |
| 176 | + process.stderr.write(`Orbit browser regression tests failed (${errors.length}/${assertions}).\n`); |
| 177 | + process.exit(1); |
| 178 | +} |
| 179 | + |
| 180 | +process.stdout.write(`Orbit browser regression tests passed (${assertions} assertions).\n`); |
0 commit comments