316 lines
9.4 KiB
JavaScript
316 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
const puppeteer = require("puppeteer");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const DEFAULT_VIEWPORTS = {
|
|
mobile: { width: 375, height: 812 },
|
|
tablet: { width: 768, height: 1024 },
|
|
desktop: { width: 1440, height: 900 },
|
|
};
|
|
|
|
async function run() {
|
|
const manifestPath = path.resolve(process.cwd(), "test-manifest.json");
|
|
if (!fs.existsSync(manifestPath)) {
|
|
console.error("ERROR: test-manifest.json not found in", process.cwd());
|
|
process.exit(1);
|
|
}
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
const baseUrl = manifest.baseUrl || "http://localhost:3000";
|
|
const viewports = manifest.viewports
|
|
? Object.fromEntries(
|
|
Object.entries(manifest.viewports).map(function (entry) {
|
|
return [entry[0], entry[1]];
|
|
})
|
|
)
|
|
: DEFAULT_VIEWPORTS;
|
|
|
|
const resultsDir = path.resolve(process.cwd(), "test-results");
|
|
if (!fs.existsSync(resultsDir)) {
|
|
fs.mkdirSync(resultsDir, { recursive: true });
|
|
}
|
|
|
|
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || "chromium";
|
|
var browser;
|
|
try {
|
|
browser = await puppeteer.launch({
|
|
headless: "new",
|
|
executablePath: executablePath,
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
|
|
});
|
|
} catch (err) {
|
|
console.error("ERROR: Failed to launch browser:", err.message);
|
|
process.exit(1);
|
|
}
|
|
|
|
var results = { pages: [], passed: 0, failed: 0, errors: [] };
|
|
|
|
for (var pi = 0; pi < (manifest.pages || []).length; pi++) {
|
|
var pageDef = manifest.pages[pi];
|
|
var pageResult = {
|
|
id: pageDef.id,
|
|
url: baseUrl + (pageDef.path || "/"),
|
|
viewports: {},
|
|
passed: true,
|
|
};
|
|
|
|
var vpEntries = Object.entries(viewports);
|
|
for (var vi = 0; vi < vpEntries.length; vi++) {
|
|
var vpName = vpEntries[vi][0];
|
|
var vpSize = vpEntries[vi][1];
|
|
var vpResult = {
|
|
viewport: vpName,
|
|
screenshots: [],
|
|
actions: [],
|
|
consoleErrors: [],
|
|
passed: true,
|
|
};
|
|
|
|
var browserPage = null;
|
|
try {
|
|
browserPage = await browser.newPage();
|
|
await browserPage.setViewport(vpSize);
|
|
|
|
var consoleErrors = [];
|
|
browserPage.on("console", function (msg) {
|
|
if (msg.type() === "error") {
|
|
consoleErrors.push(msg.text());
|
|
}
|
|
});
|
|
browserPage.on("pageerror", function (err) {
|
|
consoleErrors.push(err.message);
|
|
});
|
|
|
|
await browserPage.goto(pageResult.url, {
|
|
waitUntil: "networkidle2",
|
|
timeout: 30000,
|
|
});
|
|
|
|
if (pageDef.waitFor) {
|
|
await browserPage.waitForSelector(pageDef.waitFor, { timeout: 10000 });
|
|
}
|
|
|
|
var initialScreenshot = path.join(
|
|
resultsDir,
|
|
pageDef.id + "-" + vpName + "-initial.png"
|
|
);
|
|
await browserPage.screenshot({
|
|
path: initialScreenshot,
|
|
fullPage: true,
|
|
});
|
|
vpResult.screenshots.push(initialScreenshot);
|
|
|
|
var actions = pageDef.actions || [];
|
|
for (var ai = 0; ai < actions.length; ai++) {
|
|
var action = actions[ai];
|
|
var actionResult = { id: action.id, type: action.type, passed: true, error: null };
|
|
try {
|
|
await executeAction(browserPage, action);
|
|
|
|
var actionScreenshot = path.join(
|
|
resultsDir,
|
|
pageDef.id + "-" + vpName + "-" + action.id + ".png"
|
|
);
|
|
await browserPage.screenshot({
|
|
path: actionScreenshot,
|
|
fullPage: true,
|
|
});
|
|
vpResult.screenshots.push(actionScreenshot);
|
|
|
|
if (action.expectAfter) {
|
|
var valid = await validateExpectations(
|
|
browserPage,
|
|
action.expectAfter
|
|
);
|
|
if (!valid) {
|
|
actionResult.passed = false;
|
|
actionResult.error = "Expectation failed for " + action.id;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
actionResult.passed = false;
|
|
actionResult.error = err.message;
|
|
}
|
|
|
|
if (!actionResult.passed) {
|
|
vpResult.passed = false;
|
|
}
|
|
vpResult.actions.push(actionResult);
|
|
}
|
|
|
|
vpResult.consoleErrors = consoleErrors;
|
|
if (consoleErrors.length > 0) {
|
|
vpResult.passed = false;
|
|
}
|
|
} catch (err) {
|
|
vpResult.passed = false;
|
|
vpResult.error = err.message;
|
|
results.errors.push({
|
|
page: pageDef.id,
|
|
viewport: vpName,
|
|
error: err.message,
|
|
});
|
|
} finally {
|
|
if (browserPage) {
|
|
await browserPage.close().catch(function () {});
|
|
}
|
|
}
|
|
|
|
if (!vpResult.passed) {
|
|
pageResult.passed = false;
|
|
}
|
|
pageResult.viewports[vpName] = vpResult;
|
|
}
|
|
|
|
if (pageResult.passed) {
|
|
results.passed++;
|
|
} else {
|
|
results.failed++;
|
|
}
|
|
results.pages.push(pageResult);
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
var summaryPath = path.join(resultsDir, "summary.json");
|
|
fs.writeFileSync(summaryPath, JSON.stringify(results, null, 2));
|
|
|
|
console.log("");
|
|
console.log("=== Visual Test Results ===");
|
|
console.log("Pages tested: " + results.pages.length);
|
|
console.log("Passed: " + results.passed);
|
|
console.log("Failed: " + results.failed);
|
|
|
|
for (var ri = 0; ri < results.pages.length; ri++) {
|
|
var p = results.pages[ri];
|
|
var icon = p.passed ? "PASS" : "FAIL";
|
|
console.log(" [" + icon + "] " + p.id + " (" + p.url + ")");
|
|
var vpKeys = Object.keys(p.viewports);
|
|
for (var vk = 0; vk < vpKeys.length; vk++) {
|
|
var vr = p.viewports[vpKeys[vk]];
|
|
if (!vr.passed) {
|
|
console.log(" " + vpKeys[vk] + ": FAIL" + (vr.error ? " - " + vr.error : ""));
|
|
for (var ak = 0; ak < (vr.actions || []).length; ak++) {
|
|
var a = vr.actions[ak];
|
|
if (!a.passed) {
|
|
console.log(" action " + a.id + ": " + a.error);
|
|
}
|
|
}
|
|
if (vr.consoleErrors && vr.consoleErrors.length > 0) {
|
|
console.log(" console errors: " + vr.consoleErrors.length);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("");
|
|
console.log("Summary written to: " + summaryPath);
|
|
process.exit(results.failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
async function executeAction(page, action) {
|
|
switch (action.type) {
|
|
case "click":
|
|
await page.waitForSelector(action.selector, { timeout: 5000 });
|
|
await page.click(action.selector);
|
|
break;
|
|
case "fill-form":
|
|
var fields = action.fields || [];
|
|
for (var fi = 0; fi < fields.length; fi++) {
|
|
var field = fields[fi];
|
|
await page.waitForSelector(field.selector, { timeout: 5000 });
|
|
await page.click(field.selector, { clickCount: 3 });
|
|
await page.type(field.selector, field.value);
|
|
}
|
|
break;
|
|
case "select":
|
|
await page.waitForSelector(action.selector, { timeout: 5000 });
|
|
await page.select(action.selector, action.value);
|
|
break;
|
|
case "scroll":
|
|
if (action.selector) {
|
|
await page.waitForSelector(action.selector, { timeout: 5000 });
|
|
await page.$eval(action.selector, function (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
});
|
|
} else {
|
|
await page.evaluate(
|
|
function (x, y) { window.scrollTo(x, y); },
|
|
action.x || 0,
|
|
action.y || 0
|
|
);
|
|
}
|
|
await new Promise(function (r) { setTimeout(r, 500); });
|
|
break;
|
|
case "hover":
|
|
await page.waitForSelector(action.selector, { timeout: 5000 });
|
|
await page.hover(action.selector);
|
|
await new Promise(function (r) { setTimeout(r, 300); });
|
|
break;
|
|
case "wait":
|
|
if (action.selector) {
|
|
await page.waitForSelector(action.selector, {
|
|
timeout: action.timeout || 10000,
|
|
});
|
|
} else {
|
|
await new Promise(function (r) { setTimeout(r, action.duration || 1000); });
|
|
}
|
|
break;
|
|
default:
|
|
throw new Error("Unknown action type: " + action.type);
|
|
}
|
|
}
|
|
|
|
async function validateExpectations(page, expectations) {
|
|
var items = Array.isArray(expectations) ? expectations : [expectations];
|
|
for (var ei = 0; ei < items.length; ei++) {
|
|
var expect = items[ei];
|
|
if (expect.url) {
|
|
var currentUrl = page.url();
|
|
var pattern = new RegExp(expect.url);
|
|
if (!pattern.test(currentUrl)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (expect.visible) {
|
|
var el = await page.$(expect.visible);
|
|
if (!el) return false;
|
|
var isVisible = await page.evaluate(function (s) {
|
|
var e = document.querySelector(s);
|
|
if (!e) return false;
|
|
var r = e.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
}, expect.visible);
|
|
if (!isVisible) return false;
|
|
}
|
|
if (expect.hidden) {
|
|
var elH = await page.$(expect.hidden);
|
|
if (elH) {
|
|
var isVis = await page.evaluate(function (s) {
|
|
var e = document.querySelector(s);
|
|
if (!e) return false;
|
|
var r = e.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
}, expect.hidden);
|
|
if (isVis) return false;
|
|
}
|
|
}
|
|
if (expect.text) {
|
|
var textContent = await page.$eval(
|
|
expect.text.selector,
|
|
function (el) { return el.textContent; }
|
|
);
|
|
if (!textContent || !textContent.includes(expect.text.contains)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
run().catch(function (err) {
|
|
console.error("Fatal error:", err);
|
|
process.exit(1);
|
|
});
|