Aprendido desplegando cotizador (Next.js+FastAPI+Postgres, Docker Compose, repo privado) en coolify.urieljareth.org (v4.1.2): - Documenta que /applications/* devuelve 404 en esta instancia y que configurar apps git build-from-source solo es posible por la UI web. - Nuevos scripts scripts/coolify-ui/: coolify-login.mjs (guarda storageState) y Configure-CoolifyComposeApp.mjs (build pack -> Docker Compose, Reload Compose File, env vars por Developer view, dominios por servicio). - references/coolify-4.1.2-notes.md: mapa de API funcional/404, gotchas (BOM UTF-8 rompe el parser YAML de Coolify; Reload Compose File obligatorio; pin de dominios por servicio o 503; no encolar deploys concurrentes; PowerShell [IO.File]::ReadAllText para payloads de llaves). - env.local.template.ps1: COOLIFY_EMAIL/COOLIFY_PASSWORD para la UI. - SKILL.md/TOOLS.md: seccion "instance reality" y flujo UI paso a paso. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
129 lines
4.0 KiB
JavaScript
129 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// verify-online.mjs — Browser-level operational verification for a deployed service.
|
|
// Used by Test-ServiceOnline.ps1. Exits 0 on success, non-zero on failure.
|
|
//
|
|
// Usage:
|
|
// node verify-online.mjs <url> [--screenshot <path>] [--timeout <ms>] [--expect-title <regex>]
|
|
//
|
|
// Checks performed:
|
|
// 1. Page navigates to status < 400 (HTTP layer via Playwright)
|
|
// 2. No uncaught `pageerror` events (JS crashes)
|
|
// 3. No failed main-frame network requests (DNS, 5xx on document, etc.)
|
|
// 4. Optional: document.title matches a regex
|
|
// 5. Saves a screenshot (PNG) when --screenshot is provided
|
|
//
|
|
// Output: a single JSON line on stdout with the result; human logs go to stderr.
|
|
|
|
import { chromium } from "playwright";
|
|
|
|
const args = process.argv.slice(2);
|
|
if (args.length === 0) {
|
|
console.error("Usage: verify-online.mjs <url> [--screenshot <path>] [--timeout <ms>] [--expect-title <regex>] [--no-headless]");
|
|
process.exit(2);
|
|
}
|
|
|
|
const url = args[0];
|
|
const opts = {};
|
|
for (let i = 1; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a === "--screenshot") opts.screenshot = args[++i];
|
|
else if (a === "--timeout") opts.timeout = parseInt(args[++i], 10);
|
|
else if (a === "--expect-title") opts.expectTitle = args[++i];
|
|
else if (a === "--no-headless") opts.headless = false;
|
|
}
|
|
|
|
const timeout = opts.timeout || 30000;
|
|
const headless = opts.headless !== false;
|
|
|
|
const result = {
|
|
url,
|
|
ok: false,
|
|
httpStatus: null,
|
|
title: null,
|
|
pageErrors: [],
|
|
requestFailures: [],
|
|
consoleErrors: [],
|
|
screenshot: null,
|
|
elapsedMs: null,
|
|
};
|
|
|
|
const startedAt = Date.now();
|
|
let browser;
|
|
try {
|
|
browser = await chromium.launch({ headless });
|
|
const context = await browser.newContext({
|
|
ignoreHTTPSErrors: true,
|
|
viewport: { width: 1280, height: 720 },
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
page.on("pageerror", (err) => {
|
|
result.pageErrors.push(err.message);
|
|
});
|
|
page.on("requestfailed", (req) => {
|
|
const u = req.url();
|
|
// Ignore third-party analytics/CDN noise; surface only main-frame document/sub-resource
|
|
// failures relevant to the app itself.
|
|
const failure = req.failure();
|
|
result.requestFailures.push({
|
|
url: u,
|
|
method: req.method(),
|
|
errorText: failure ? failure.errorText : "unknown",
|
|
resourceType: req.resourceType(),
|
|
});
|
|
});
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") {
|
|
const txt = msg.text();
|
|
// Suppress noisy third-party errors that don't affect operability.
|
|
if (!/favicon|googletagmanager|google-analytics|doubleclick|sentry/i.test(txt)) {
|
|
result.consoleErrors.push(txt);
|
|
}
|
|
}
|
|
});
|
|
|
|
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
if (!response) {
|
|
throw new Error("Navigation returned no response (blocked or timeout).");
|
|
}
|
|
result.httpStatus = response.status();
|
|
result.title = await page.title();
|
|
|
|
if (opts.expectTitle) {
|
|
const re = new RegExp(opts.expectTitle);
|
|
if (!re.test(result.title || "")) {
|
|
throw new Error(`Title "${result.title}" does not match /${opts.expectTitle}/`);
|
|
}
|
|
}
|
|
|
|
if (result.httpStatus >= 400) {
|
|
throw new Error(`HTTP ${result.httpStatus} from ${url}`);
|
|
}
|
|
if (result.pageErrors.length > 0) {
|
|
throw new Error(`Page threw ${result.pageErrors.length} uncaught error(s): ${result.pageErrors.slice(0, 3).join(" | ")}`);
|
|
}
|
|
|
|
// Give SPA a moment to settle, then capture screenshot.
|
|
await page.waitForTimeout(1500);
|
|
if (opts.screenshot) {
|
|
try {
|
|
await page.screenshot({ path: opts.screenshot, fullPage: false });
|
|
result.screenshot = opts.screenshot;
|
|
} catch (e) {
|
|
// Non-fatal: screenshot is best-effort.
|
|
console.error(`[warn] screenshot failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
result.ok = true;
|
|
} catch (err) {
|
|
result.error = err.message;
|
|
result.ok = false;
|
|
} finally {
|
|
if (browser) await browser.close().catch(() => {});
|
|
result.elapsedMs = Date.now() - startedAt;
|
|
console.log(JSON.stringify(result));
|
|
}
|
|
|
|
process.exit(result.ok ? 0 : 1);
|