6b417cc620
Co-authored-by: Codex Agent <codex@hwlab.local>
316 lines
13 KiB
JavaScript
316 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { importPlaywright, launchChromium } from "./src/browser-launcher.mjs";
|
|
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
|
|
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const repoRoot = path.resolve(path.dirname(scriptPath), "..");
|
|
const stateRoot = path.join(repoRoot, ".state", "web-live-dom-probe");
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
try {
|
|
const args = parseProbeArgs(process.argv.slice(2));
|
|
const result = args.command === "start"
|
|
? await startProbe(args)
|
|
: args.command === "status"
|
|
? await probeStatus(args)
|
|
: args.command === "help"
|
|
? helpPayload()
|
|
: await runProbe(args);
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
process.exitCode = ["pass", "running", "usage"].includes(result.status) ? 0 : 2;
|
|
} catch (error) {
|
|
process.stderr.write(`[web-live-dom-probe] ${error instanceof Error ? error.stack : String(error)}\n`);
|
|
process.stdout.write(`${JSON.stringify({ ok: false, command: "web-live-dom-probe", status: "blocked", error: error instanceof Error ? error.message : String(error) }, null, 2)}\n`);
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
export function parseProbeArgs(argv) {
|
|
const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "run";
|
|
const startIndex = command === "run" ? (argv[0] === "run" ? 1 : 0) : 1;
|
|
const args = {
|
|
command,
|
|
url: process.env.HWLAB_WEB_PROBE_URL ?? process.env.HWLAB_WEB_URL ?? "http://74.48.78.17:19666/",
|
|
user: process.env.HWLAB_WEB_USER ?? "admin",
|
|
pass: process.env.HWLAB_WEB_PASS ?? "hwlab2026",
|
|
viewport: process.env.HWLAB_WEB_PROBE_VIEWPORT ?? "1440x900",
|
|
message: process.env.HWLAB_WEB_PROBE_MESSAGE ?? "",
|
|
freshSession: false,
|
|
cancelRunning: true,
|
|
waitAfterSubmitMs: 1500,
|
|
timeoutMs: 30000,
|
|
reportPath: tempReportPath("web-live-dom-probe.json"),
|
|
screenshotPath: tempReportPath("web-live-dom-probe.png"),
|
|
jobId: ""
|
|
};
|
|
if (command === "help" || argv.includes("--help") || argv.includes("-h")) return { ...args, command: "help" };
|
|
for (let index = startIndex; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const readValue = (name) => {
|
|
const inline = arg.match(new RegExp(`^${name}=(.+)$`, "u"));
|
|
if (inline) return inline[1];
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
index += 1;
|
|
return value;
|
|
};
|
|
if (arg === "--url" || arg.startsWith("--url=")) args.url = readValue("--url");
|
|
else if (arg === "--user" || arg.startsWith("--user=")) args.user = readValue("--user");
|
|
else if (arg === "--pass" || arg.startsWith("--pass=")) args.pass = readValue("--pass");
|
|
else if (arg === "--viewport" || arg.startsWith("--viewport=")) args.viewport = readValue("--viewport");
|
|
else if (arg === "--message" || arg.startsWith("--message=")) args.message = readValue("--message");
|
|
else if (arg === "--report" || arg.startsWith("--report=")) args.reportPath = readValue("--report");
|
|
else if (arg === "--screenshot" || arg.startsWith("--screenshot=")) args.screenshotPath = readValue("--screenshot");
|
|
else if (arg === "--job-id" || arg.startsWith("--job-id=")) args.jobId = readValue("--job-id");
|
|
else if (arg === "--wait-after-submit-ms" || arg.startsWith("--wait-after-submit-ms=")) args.waitAfterSubmitMs = positiveInteger(readValue("--wait-after-submit-ms"), "--wait-after-submit-ms");
|
|
else if (arg === "--timeout-ms" || arg.startsWith("--timeout-ms=")) args.timeoutMs = positiveInteger(readValue("--timeout-ms"), "--timeout-ms");
|
|
else if (arg === "--fresh-session") args.freshSession = true;
|
|
else if (arg === "--no-cancel-running") args.cancelRunning = false;
|
|
else if (command === "status" && !args.jobId) args.jobId = arg;
|
|
else throw new Error(`unknown web-live-dom-probe argument: ${arg}`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
export async function runProbe(args) {
|
|
const startedAt = new Date().toISOString();
|
|
const reportPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.reportPath), "--report");
|
|
const screenshotPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.screenshotPath), "--screenshot");
|
|
const { width, height } = parseViewport(args.viewport);
|
|
const actions = [];
|
|
let browser;
|
|
let result;
|
|
try {
|
|
const { chromium } = await importPlaywright();
|
|
browser = await launchChromium(chromium);
|
|
const context = await browser.newContext({ viewport: { width, height } });
|
|
const page = await context.newPage();
|
|
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
|
|
await maybeLogin(page, args, actions);
|
|
await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
if (args.freshSession) await maybeClick(page, "#session-create", actions, "fresh-session");
|
|
if (args.message) await submitPrompt(page, args, actions);
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
const dom = await collectDomEvidence(page);
|
|
result = {
|
|
ok: true,
|
|
status: dom.requiredSelectors.workspace && dom.requiredSelectors.commandInput && dom.requiredSelectors.conversationList ? "pass" : "blocked",
|
|
command: "web-live-dom-probe run",
|
|
generatedAt: new Date().toISOString(),
|
|
startedAt,
|
|
url: args.url,
|
|
finalUrl: page.url(),
|
|
viewport: { width, height },
|
|
browser: browser.hwlabLaunchPlan ?? null,
|
|
actions,
|
|
dom,
|
|
artifacts: { reportPath, screenshotPath },
|
|
safety: {
|
|
repoOwnedLauncher: true,
|
|
directChromiumLaunch: false,
|
|
freshSessionRequested: args.freshSession,
|
|
promptSubmitted: Boolean(args.message),
|
|
cancelRunningRequested: args.cancelRunning
|
|
}
|
|
};
|
|
} catch (error) {
|
|
result = {
|
|
ok: false,
|
|
status: "blocked",
|
|
command: "web-live-dom-probe run",
|
|
generatedAt: new Date().toISOString(),
|
|
startedAt,
|
|
url: args.url,
|
|
browser: browser?.hwlabLaunchPlan ?? error?.hwlabBrowserLaunch ?? null,
|
|
actions,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
artifacts: { reportPath, screenshotPath }
|
|
};
|
|
} finally {
|
|
if (browser) await browser.close().catch(() => {});
|
|
}
|
|
await mkdir(path.dirname(reportPath), { recursive: true });
|
|
await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
|
return result;
|
|
}
|
|
|
|
async function startProbe(args) {
|
|
await mkdir(stateRoot, { recursive: true });
|
|
const jobId = args.jobId || `web-live-dom-probe-${Date.now()}-${process.pid}`;
|
|
const reportPath = path.join(stateRoot, `${jobId}.result.json`);
|
|
const screenshotPath = path.join(stateRoot, `${jobId}.png`);
|
|
const stdoutPath = path.join(stateRoot, `${jobId}.stdout.log`);
|
|
const stderrPath = path.join(stateRoot, `${jobId}.stderr.log`);
|
|
const metaPath = path.join(stateRoot, `${jobId}.json`);
|
|
const childArgs = [
|
|
scriptPath,
|
|
"run",
|
|
"--url", args.url,
|
|
"--user", args.user,
|
|
"--pass", args.pass,
|
|
"--viewport", args.viewport,
|
|
"--report", reportPath,
|
|
"--screenshot", screenshotPath,
|
|
"--timeout-ms", String(args.timeoutMs),
|
|
"--wait-after-submit-ms", String(args.waitAfterSubmitMs)
|
|
];
|
|
if (args.freshSession) childArgs.push("--fresh-session");
|
|
if (!args.cancelRunning) childArgs.push("--no-cancel-running");
|
|
if (args.message) childArgs.push("--message", args.message);
|
|
const stdout = fs.openSync(stdoutPath, "a");
|
|
const stderr = fs.openSync(stderrPath, "a");
|
|
const child = spawn(process.execPath, childArgs, {
|
|
cwd: repoRoot,
|
|
detached: true,
|
|
stdio: ["ignore", stdout, stderr]
|
|
});
|
|
child.unref();
|
|
const meta = {
|
|
ok: true,
|
|
status: "running",
|
|
command: "web-live-dom-probe start",
|
|
jobId,
|
|
pid: child.pid,
|
|
startedAt: new Date().toISOString(),
|
|
url: args.url,
|
|
reportPath,
|
|
screenshotPath,
|
|
stdoutPath,
|
|
stderrPath,
|
|
statusCommand: `node scripts/web-live-dom-probe.mjs status ${jobId}`
|
|
};
|
|
await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
return meta;
|
|
}
|
|
|
|
async function probeStatus(args) {
|
|
if (!args.jobId) throw new Error("status requires a job id");
|
|
const metaPath = path.join(stateRoot, `${args.jobId}.json`);
|
|
const meta = JSON.parse(await readFile(metaPath, "utf8"));
|
|
const report = fs.existsSync(meta.reportPath) ? JSON.parse(await readFile(meta.reportPath, "utf8")) : null;
|
|
const running = !report && typeof meta.pid === "number" && processAlive(meta.pid);
|
|
return {
|
|
ok: report ? report.ok === true : running,
|
|
status: report ? report.status : running ? "running" : "blocked",
|
|
command: "web-live-dom-probe status",
|
|
jobId: args.jobId,
|
|
pid: meta.pid,
|
|
startedAt: meta.startedAt,
|
|
reportPath: meta.reportPath,
|
|
screenshotPath: meta.screenshotPath,
|
|
stdoutPath: meta.stdoutPath,
|
|
stderrPath: meta.stderrPath,
|
|
running,
|
|
reportSummary: report ? { status: report.status, url: report.url, finalUrl: report.finalUrl, browser: report.browser, dom: report.dom } : null,
|
|
stdoutTail: await readTail(meta.stdoutPath),
|
|
stderrTail: await readTail(meta.stderrPath)
|
|
};
|
|
}
|
|
|
|
async function maybeLogin(page, args, actions) {
|
|
const username = page.locator("#login-username");
|
|
if (!(await username.isVisible({ timeout: 2500 }).catch(() => false))) return;
|
|
await username.fill(args.user);
|
|
await page.locator("#login-password").fill(args.pass);
|
|
await page.locator("#login-submit").click();
|
|
actions.push({ action: "login", user: args.user });
|
|
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
}
|
|
|
|
async function maybeClick(page, selector, actions, action) {
|
|
const locator = page.locator(selector).first();
|
|
if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await locator.click();
|
|
actions.push({ action, selector });
|
|
await page.waitForTimeout(1000);
|
|
return true;
|
|
}
|
|
actions.push({ action, selector, skipped: "not-visible" });
|
|
return false;
|
|
}
|
|
|
|
async function submitPrompt(page, args, actions) {
|
|
await page.locator("#command-input").fill(args.message);
|
|
await page.locator("#command-send").click();
|
|
actions.push({ action: "submit-prompt", chars: args.message.length });
|
|
await page.waitForTimeout(args.waitAfterSubmitMs);
|
|
if (args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message");
|
|
}
|
|
|
|
async function collectDomEvidence(page) {
|
|
return page.evaluate(() => {
|
|
const workspace = document.querySelector("#workspace");
|
|
const list = document.querySelector("#conversation-list");
|
|
const cards = [...document.querySelectorAll(".message-card")];
|
|
const scroll = workspace ? {
|
|
scrollTop: Math.round(workspace.scrollTop),
|
|
scrollHeight: Math.round(workspace.scrollHeight),
|
|
clientHeight: Math.round(workspace.clientHeight),
|
|
overflowY: getComputedStyle(workspace).overflowY,
|
|
followState: workspace.getAttribute("data-scroll-follow")
|
|
} : null;
|
|
return {
|
|
title: document.title,
|
|
requiredSelectors: {
|
|
workspace: Boolean(workspace),
|
|
commandInput: Boolean(document.querySelector("#command-input")),
|
|
commandSend: Boolean(document.querySelector("#command-send")),
|
|
conversationList: Boolean(list),
|
|
sessionCreate: Boolean(document.querySelector("#session-create"))
|
|
},
|
|
messageCount: cards.length,
|
|
messageRoles: cards.map((card) => card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item.startsWith("message-")) ?? "unknown"),
|
|
scroll,
|
|
textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800)
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseViewport(value) {
|
|
const [width, height] = String(value).split("x").map(Number);
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw new Error(`invalid --viewport: ${value}`);
|
|
return { width, height };
|
|
}
|
|
|
|
function positiveInteger(value, label) {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function processAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readTail(file, maxChars = 4000) {
|
|
if (!file || !fs.existsSync(file)) return "";
|
|
const text = await readFile(file, "utf8");
|
|
return text.length > maxChars ? text.slice(-maxChars) : text;
|
|
}
|
|
|
|
function helpPayload() {
|
|
return {
|
|
ok: true,
|
|
status: "usage",
|
|
command: "web-live-dom-probe",
|
|
usage: [
|
|
"node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/ --fresh-session --report /tmp/hwlab-dev-gate/web-live-dom-probe.json",
|
|
"node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session",
|
|
"node scripts/web-live-dom-probe.mjs status <jobId>"
|
|
],
|
|
output: "JSON with browser launch plan, DOM selectors, report/screenshot paths, and short-poll status metadata."
|
|
};
|
|
}
|