Files
voculinux/web/scripts/viewport-qa.mjs
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

176 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Viewport QA for the Vocalinux marketing home page.
* Uses system Google Chrome via playwright-core (no browser download).
*
* Usage:
* node scripts/viewport-qa.mjs [baseUrl] [outDir] [passLabel]
*
* Exit 0 only when every configured viewport has no horizontal overflow
* and the primary hero + install terminal are present and in-bounds.
*/
import { chromium } from "playwright-core";
import fs from "node:fs";
import path from "node:path";
const baseUrl = process.argv[2] || "http://127.0.0.1:3456";
const outDir = process.argv[3] || path.join(process.cwd(), "viewport-qa-out");
const passLabel = process.argv[4] || "pass";
const VIEWPORTS = [
{ name: "desktop", width: 1280, height: 800 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "mobile", width: 390, height: 844 },
];
const CHROME =
process.env.CHROME_PATH ||
["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"].find(
(p) => fs.existsSync(p),
);
if (!CHROME) {
console.error("No Chrome binary found. Set CHROME_PATH.");
process.exit(2);
}
fs.mkdirSync(outDir, { recursive: true });
const results = [];
const browser = await chromium.launch({
executablePath: CHROME,
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
});
try {
for (const vp of VIEWPORTS) {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 1,
});
const page = await context.newPage();
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(String(err)));
await page.goto(baseUrl + "/", { waitUntil: "networkidle", timeout: 60000 });
await page.waitForSelector("h1", { timeout: 15000 });
const metrics = await page.evaluate(() => {
const de = document.documentElement;
const hero = document.querySelector("h1");
const terminal = document.querySelector(".terminal-panel");
const install = document.querySelector("#install");
const nav = document.querySelector("header nav");
const overflowX = de.scrollWidth > de.clientWidth + 1;
const bodyText = document.body.innerText;
const heroRect = hero ? hero.getBoundingClientRect() : null;
const terminalRect = terminal ? terminal.getBoundingClientRect() : null;
const inBounds = (r) =>
!!r && r.width > 0 && r.height > 0 && r.left >= -1 && r.right <= de.clientWidth + 1;
return {
scrollWidth: de.scrollWidth,
clientWidth: de.clientWidth,
overflowX,
hasVocalinux: bodyText.includes("Vocalinux"),
hasInstallCta: !!document.querySelector('a[href="#install"]'),
hasGradientText: !!document.querySelector('[class*="bg-clip-text"]'),
h1: hero ? hero.innerText.trim() : null,
heroInBounds: inBounds(heroRect),
terminalInBounds: terminal ? inBounds(terminalRect) : false,
installPresent: !!install,
navPresent: !!nav,
heroWidth: heroRect ? heroRect.width : null,
terminalWidth: terminalRect ? terminalRect.width : null,
};
});
const shotName =
passLabel === "pass1" || passLabel === "1"
? `${vp.name}.png`
: passLabel === "pass2" || passLabel === "2" || passLabel === "after"
? `${vp.name}-after.png`
: `${vp.name}-${passLabel}.png`;
await page.screenshot({
path: path.join(outDir, shotName),
fullPage: false,
});
// Install section shot for mobile proof
if (vp.name === "mobile") {
await page.locator("#install").scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await page.screenshot({
path: path.join(outDir, shotName.replace(".png", "-install.png")),
fullPage: false,
});
}
// Mobile menu path check (header only)
let mobileMenuOk = true;
if (vp.width < 768) {
const toggle = page.getByRole("button", { name: /toggle menu/i });
if (await toggle.count()) {
await toggle.click();
await page.waitForTimeout(150);
mobileMenuOk = await page
.locator("header")
.getByRole("link", { name: "How it works" })
.isVisible();
await toggle.click();
} else {
mobileMenuOk = false;
}
}
const ok =
!metrics.overflowX &&
metrics.hasVocalinux &&
metrics.hasInstallCta &&
!metrics.hasGradientText &&
metrics.heroInBounds &&
metrics.terminalInBounds &&
metrics.navPresent &&
mobileMenuOk &&
pageErrors.length === 0;
results.push({
viewport: vp,
ok,
metrics,
mobileMenuOk,
pageErrors,
screenshot: shotName,
});
await context.close();
}
} finally {
await browser.close();
}
const summary = {
baseUrl,
chrome: CHROME,
passLabel,
allOk: results.every((r) => r.ok),
results,
};
const jsonName =
passLabel === "pass1" || passLabel === "1"
? "viewport-pass1.json"
: passLabel === "pass2" || passLabel === "2" || passLabel === "after"
? "viewport-pass2.json"
: `viewport-${passLabel}.json`;
fs.writeFileSync(path.join(outDir, jsonName), JSON.stringify(summary, null, 2));
console.log(JSON.stringify(summary, null, 2));
if (!summary.allOk) {
process.exit(1);
}