mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
feat: add provider-aware browser QA setup
Detect host-native browser tools before offering the isolated local Chromium fallback, add a common readiness fixture, harden managed browser startup, and verify standards installs expose one canonical QA skill.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomBytes } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
|
||||
export function createReadinessServer(options = {}) {
|
||||
const token = options.token ?? randomBytes(24).toString("hex");
|
||||
if (!/^[a-f0-9]{32,128}$/.test(token)) throw new TypeError("Readiness token must be 32-128 lowercase hex characters");
|
||||
let completed = false;
|
||||
let baseUrl = null;
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", baseUrl ?? `http://${HOST}`);
|
||||
const supplied = url.searchParams.get("token");
|
||||
const headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
if (url.pathname === "/" && request.method === "GET") {
|
||||
response.writeHead(200, { ...headers, "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(renderPage(token));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/proof") {
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, { ...headers, Allow: "POST" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (supplied !== token) {
|
||||
response.writeHead(403, headers);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
response.writeHead(200, { ...headers, "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true, status: "READY" }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/status" && request.method === "GET") {
|
||||
if (supplied !== token) {
|
||||
response.writeHead(403, headers);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { ...headers, "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true, completed }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, headers);
|
||||
response.end();
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
token,
|
||||
get completed() { return completed; },
|
||||
async start() {
|
||||
if (baseUrl) return { url: `${baseUrl}/?token=${token}`, baseUrl, token };
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(options.port ?? 0, HOST, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Readiness fixture did not acquire a TCP port");
|
||||
baseUrl = `http://${HOST}:${address.port}`;
|
||||
return { url: `${baseUrl}/?token=${token}`, baseUrl, token };
|
||||
},
|
||||
async stop() {
|
||||
if (!server.listening) return;
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage(token) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GStack browser readiness</title>
|
||||
<style>body{font:16px system-ui;max-width:44rem;margin:4rem auto;padding:0 1rem}button{font:inherit;padding:.7rem 1rem}#gstack-readiness-status{font-weight:700}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>GStack browser readiness</h1>
|
||||
<p>This local page verifies navigation, reading, interaction, console, and network access.</p>
|
||||
<button id="gstack-readiness-action" type="button">Complete readiness check</button>
|
||||
<p id="gstack-readiness-status" role="status">WAITING</p>
|
||||
</main>
|
||||
<script>
|
||||
document.querySelector('#gstack-readiness-action').addEventListener('click', async () => {
|
||||
const response = await fetch('/proof?token=${token}', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
document.querySelector('#gstack-readiness-status').textContent = result.status;
|
||||
console.log('gstack-browser-readiness:ready');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fixture = createReadinessServer();
|
||||
const started = await fixture.start();
|
||||
process.stdout.write(`${JSON.stringify({ ...started, pid: process.pid })}\n`);
|
||||
const stop = async () => {
|
||||
await fixture.stop();
|
||||
process.exitCode = fixture.completed ? 0 : 2;
|
||||
};
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`gstack browser readiness: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+4
-6
@@ -245,13 +245,11 @@ async function inspectManagedChromium(activeRoot, nodeCommand) {
|
||||
const result = await captureCommand(nodeCommand, [
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`const { chromium } = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(chromium.executablePath());`,
|
||||
`const { chromium } = await import(${JSON.stringify(moduleUrl)}); const browser = await chromium.launch({ headless: true }); try { process.stdout.write(browser.version()); } finally { await browser.close(); }`,
|
||||
], { env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browserRoot } });
|
||||
const executable = result.stdout.trim();
|
||||
const stat = await fs.lstat(executable).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink()) return { ok: false, message: "Playwright could not resolve a safe managed Chromium executable" };
|
||||
if (process.platform !== "win32") await fs.access(executable, fsConstants.X_OK);
|
||||
return { ok: true, message: "managed Chromium executable is present", details: { executable } };
|
||||
const version = result.stdout.trim();
|
||||
if (!version) return { ok: false, message: "managed Chromium launched without reporting a browser version" };
|
||||
return { ok: true, message: `managed headless Chromium ${version} launches and exits cleanly`, details: { browserRoot, version } };
|
||||
} catch (error) {
|
||||
return { ok: false, message: `managed Chromium is not runnable: ${error.message}` };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -104,7 +104,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
const root = path.join(temporary, "merged", "gstack");
|
||||
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const claimedFiles = new Set();
|
||||
if (reusable) await seedReusableRuntime(reusable.root, root, claimedFiles);
|
||||
if (reusable) await seedReusableRuntime(reusable, root, claimedFiles);
|
||||
for (const item of plan.downloads) {
|
||||
const archive = path.join(temporary, `${item.component}.tar.gz`);
|
||||
await downloadVerified(fetch_, item.artifact.url, archive, item.artifact.sha256, item.artifact.bytes);
|
||||
@@ -206,6 +206,100 @@ function sameGraph(actual, expected) {
|
||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||
}
|
||||
|
||||
function selectedComponents(capabilities) {
|
||||
const selected = new Set(["core"]);
|
||||
for (const capability of capabilities) {
|
||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||
}
|
||||
const pending = [...selected];
|
||||
while (pending.length) {
|
||||
for (const dependency of COMPONENT_DEPENDENCIES[pending.pop()] ?? []) {
|
||||
if (!selected.has(dependency)) {
|
||||
selected.add(dependency);
|
||||
pending.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...selected].sort();
|
||||
}
|
||||
|
||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
||||
const components = selectedComponents(capabilities);
|
||||
const retained = new Set(reusable?.components ?? []);
|
||||
const downloads = components
|
||||
.filter((component) => !retained.has(component))
|
||||
.map((component) => ({ component, artifact: manifest.targets[target].components[component] }));
|
||||
const downloadBytes = downloads.reduce((total, item) => total + item.artifact.bytes, 0);
|
||||
return {
|
||||
target,
|
||||
version: manifest.version,
|
||||
capabilities,
|
||||
components,
|
||||
reusedComponents: components.filter((component) => retained.has(component)),
|
||||
downloads,
|
||||
downloadBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function printComponentPlan(stdout, plan) {
|
||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
|
||||
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
|
||||
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||
}
|
||||
|
||||
async function inspectReusableRuntime(home, version) {
|
||||
const versions = path.join(home, "versions");
|
||||
const pointer = JSON.parse(await fs.readFile(path.join(versions, "current.json"), "utf8"));
|
||||
if (pointer?.schemaVersion !== 2 || pointer?.status !== "active" ||
|
||||
typeof pointer.current !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(pointer.current)) return null;
|
||||
const root = path.join(versions, pointer.current);
|
||||
const stat = await fs.lstat(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
||||
const bundle = JSON.parse(await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8"));
|
||||
if (bundle?.schemaVersion !== 2 || bundle?.version !== version || !Array.isArray(bundle.runtimeComponents) ||
|
||||
!Array.isArray(bundle.files)) return null;
|
||||
const components = [...new Set(bundle.runtimeComponents)];
|
||||
if (!components.length || components.some((component) => !Object.hasOwn(COMPONENT_DEPENDENCIES, component))) return null;
|
||||
await assertNoLinks(root);
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
for (const entry of bundle.files) {
|
||||
const relative = entry?.path;
|
||||
if (typeof relative !== "string" || !relative || relative.includes("\\") || path.posix.isAbsolute(relative) ||
|
||||
path.posix.normalize(relative) !== relative || relative.split("/").includes("..") || seen.has(relative) ||
|
||||
!Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256)) return null;
|
||||
seen.add(relative);
|
||||
const file = path.join(root, ...relative.split("/"));
|
||||
const fileStat = await fs.lstat(file).catch(() => null);
|
||||
if (!fileStat?.isFile() || fileStat.isSymbolicLink() || fileStat.size !== entry.size ||
|
||||
await sha256File(file) !== entry.sha256) return null;
|
||||
files.push(relative);
|
||||
}
|
||||
return { root, components, files };
|
||||
}
|
||||
|
||||
async function seedReusableRuntime(reusable, destination, claimedFiles) {
|
||||
for (const relative of reusable.files) {
|
||||
if (claimedFiles.has(relative)) throw bootstrapError(`Runtime components overlap at ${relative}`, "BOOTSTRAP_MANIFEST_INVALID");
|
||||
claimedFiles.add(relative);
|
||||
const target = path.join(destination, ...relative.split("/"));
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(path.join(reusable.root, ...relative.split("/")), target, fsConstants.COPYFILE_EXCL);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256File(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(file);
|
||||
stream.on("error", reject);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(fetch_, url) {
|
||||
const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" });
|
||||
assertFinalDownloadUrl(response.url || url);
|
||||
@@ -340,6 +434,31 @@ function safeArtifactRoot(extracted, relative) {
|
||||
return target;
|
||||
}
|
||||
|
||||
async function mergeComponentRoot(source, destination, claimedFiles, component) {
|
||||
async function visit(relative = "") {
|
||||
for (const entry of await fs.readdir(path.join(source, relative), { withFileTypes: true })) {
|
||||
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
const from = path.join(source, ...child.split("/"));
|
||||
const to = path.join(destination, ...child.split("/"));
|
||||
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
||||
throw bootstrapError(`Runtime component ${component} contains a link or special file`, "BOOTSTRAP_ARCHIVE_UNSAFE");
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await fs.mkdir(to, { recursive: true, mode: 0o700 });
|
||||
await visit(child);
|
||||
} else {
|
||||
if (claimedFiles.has(child)) {
|
||||
throw bootstrapError(`Runtime components overlap at ${child}`, "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
claimedFiles.add(child);
|
||||
await fs.mkdir(path.dirname(to), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(from, to, fsConstants.COPYFILE_EXCL);
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit();
|
||||
}
|
||||
|
||||
async function assertNoLinks(root) {
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
|
||||
Reference in New Issue
Block a user