deploy_skill: soporte para Coolify v4.1.2 (API /applications 404) + flujo UI Playwright

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]>
This commit is contained in:
urieljareth
2026-07-08 00:42:12 -06:00
co-authored by Claude Opus 4.8
parent cd998ce6b0
commit e7d1c33cd5
22 changed files with 2935 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
#!/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);