98 lines
7.6 KiB
JavaScript
98 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
const dist = path.join(root, "web", "dist");
|
|
const docs = path.join(root, "docs", "p6");
|
|
const chromePath = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/opt/google/chrome/google-chrome"].find((candidate) => fs.existsSync(candidate));
|
|
const viewport = { width: 1440, height: 900, deviceScaleFactor: 1 };
|
|
const context = { document: "navigation-document-7", module: "Part", command: "part.createRecompute" };
|
|
const contextQuery = new URLSearchParams(context).toString();
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
|
|
if (!chromePath) {
|
|
fs.writeFileSync(path.join(docs, "navigation.json"), `${JSON.stringify({ schema: "freecad-web-p6-navigation/v1", taskId: "FW-P6-FPA-IMPL-NAVIGATION", result: "blocked_chrome_unavailable", policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false } }, null, 2)}\n`);
|
|
process.exitCode = 2;
|
|
} else {
|
|
const server = http.createServer((request, response) => {
|
|
const relative = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname).replace(/^\/+/, "") || "index.html";
|
|
const file = path.resolve(dist, relative);
|
|
if (file !== dist && !file.startsWith(`${dist}${path.sep}`)) { response.writeHead(403); response.end(); return; }
|
|
if (!fs.existsSync(file)) { response.writeHead(404); response.end(); return; }
|
|
const type = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css", ".wasm": "application/wasm", ".data": "application/octet-stream" }[path.extname(file)] ?? "application/octet-stream";
|
|
response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
fs.createReadStream(file).pipe(response);
|
|
});
|
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-p6-navigation-chrome-"));
|
|
const chrome = spawn(chromePath, ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--remote-debugging-port=0", "--remote-allow-origins=*", `--user-data-dir=${profile}`, `--window-size=${viewport.width},${viewport.height}`, `${origin}/index.html?${contextQuery}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
let output = "";
|
|
chrome.stdout.on("data", (chunk) => { output += chunk; });
|
|
chrome.stderr.on("data", (chunk) => { output += chunk; });
|
|
let socket;
|
|
try {
|
|
let target;
|
|
for (let attempt = 0; attempt < 300 && !target; attempt += 1) {
|
|
const match = output.match(/DevTools listening on ws:\/\/([^\s]+)/);
|
|
if (match) {
|
|
const port = match[1].split(":")[1].split("/")[0];
|
|
try { target = (await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json())).find((item) => item.type === "page"); } catch {}
|
|
}
|
|
if (!target) await wait(100);
|
|
}
|
|
if (!target?.webSocketDebuggerUrl) throw new Error("Chrome CDP target unavailable");
|
|
socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); });
|
|
let id = 0;
|
|
const pending = new Map();
|
|
const exceptions = [];
|
|
const requests = [];
|
|
socket.addEventListener("message", (event) => {
|
|
const message = JSON.parse(event.data);
|
|
if (message.id && pending.has(message.id)) { const item = pending.get(message.id); pending.delete(message.id); message.error ? item.reject(new Error(message.error.message)) : item.resolve(message.result); }
|
|
});
|
|
const send = (method, params = {}) => new Promise((resolve, reject) => { const requestId = ++id; pending.set(requestId, { resolve, reject }); socket.send(JSON.stringify({ id: requestId, method, params })); });
|
|
await send("Runtime.enable");
|
|
await send("Page.enable");
|
|
await send("Network.enable");
|
|
socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.method === "Runtime.exceptionThrown") exceptions.push(message.params); if (message.method === "Network.requestWillBeSent") requests.push(message.params.request.url); });
|
|
const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.value;
|
|
const waitForNavigation = async () => { for (let attempt = 0; attempt < 300; attempt += 1) { if (await evaluate("Boolean(document.querySelector('.global-nav'))")) return; await wait(100); } throw new Error("navigation DOM did not mount"); };
|
|
await waitForNavigation();
|
|
const initial = await evaluate("(() => ({ url: location.href, hrefs: [...document.querySelectorAll('.global-nav a')].map((a) => a.href), current: document.querySelector('.global-nav a[aria-current=page]')?.dataset.route ?? '' }))()");
|
|
assert.equal(initial.current, "home");
|
|
assert.ok(initial.hrefs.length >= 32);
|
|
assert.ok(initial.hrefs.every((href) => Object.entries(context).every(([key, value]) => new URL(href).searchParams.get(key) === value)));
|
|
const mainChainHref = initial.hrefs.find((href) => new URL(href).pathname.endsWith("/main-chain.html"));
|
|
assert.ok(mainChainHref);
|
|
await send("Page.navigate", { url: mainChainHref });
|
|
await waitForNavigation();
|
|
const after = await evaluate("(() => ({ url: location.href, current: document.querySelector('.global-nav a[aria-current=page]')?.dataset.route ?? '', documentId: document.querySelector('.global-nav')?.dataset.documentId ?? '' }))()");
|
|
assert.equal(after.current, "main-chain");
|
|
assert.equal(new URL(after.url).searchParams.get("document"), context.document);
|
|
assert.equal(new URL(after.url).searchParams.get("module"), context.module);
|
|
assert.equal(new URL(after.url).searchParams.get("command"), context.command);
|
|
assert.equal(after.documentId, context.document);
|
|
const externalRequests = requests.filter((url) => !url.startsWith(origin));
|
|
assert.equal(externalRequests.length, 0);
|
|
assert.equal(exceptions.length, 0);
|
|
const version = await new Promise((resolve) => { const child = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); let text = ""; child.stdout.on("data", (chunk) => { text += chunk; }); child.on("close", () => resolve(text.trim())); });
|
|
const report = { schema: "freecad-web-p6-navigation/v1", taskId: "FW-P6-FPA-IMPL-NAVIGATION", result: "pass", chrome: { path: chromePath, version }, mode: "headless-new", origin, viewport, context, initial, after, network: { externalRequests, externalRequestCount: externalRequests.length }, exceptions, evidence: { resultHash: hash(JSON.stringify({ initial, after })) }, policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false } };
|
|
fs.writeFileSync(path.join(docs, "navigation.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
fs.writeFileSync(path.join(root, "docs", "freecad-p6-navigation.log"), `result=pass\nchrome=${version}\nexternalRequests=${externalRequests.length}\nexceptions=${exceptions.length}\n`);
|
|
console.log(JSON.stringify({ result: "pass", chrome: version, routes: initial.hrefs.length, externalRequests: externalRequests.length, exceptions: exceptions.length }));
|
|
} finally {
|
|
socket?.close();
|
|
chrome.kill("SIGTERM");
|
|
server.close();
|
|
}
|
|
}
|