fix(v02): bound cloud web layout smoke (#846)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 18:50:35 +08:00
committed by GitHub
parent dc74c7c99a
commit d23cc410be
5 changed files with 212 additions and 24 deletions
+167 -23
View File
@@ -25,6 +25,9 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../
const domOnlyReportPath = tempReportPath("dev-cloud-workbench-dom-only.json");
const liveReportPath = tempReportPath("dev-cloud-workbench-live.json");
const webRoot = path.join(repoRoot, "web/hwlab-cloud-web");
const defaultLayoutProgressIntervalMs = 10000;
const defaultLayoutViewportTimeoutMs = 15000;
const defaultLayoutCleanupTimeoutMs = 3000;
const runtime = Object.freeze({
endpoints: Object.freeze({
frontend: "http://74.48.78.17:16666",
@@ -358,6 +361,17 @@ export function parseSmokeArgs(argv) {
index += 1;
if (!argv[index]) throw new Error("--report requires a value");
args.reportPath = argv[index];
} else if (arg === "--viewport-timeout-ms") {
index += 1;
args.viewportTimeoutMs = parsePositiveIntegerArg("--viewport-timeout-ms", argv[index]);
} else if (arg === "--progress-interval-ms") {
index += 1;
args.progressIntervalMs = parsePositiveIntegerArg("--progress-interval-ms", argv[index], { allowZero: true });
} else if (arg === "--cleanup-timeout-ms") {
index += 1;
args.cleanupTimeoutMs = parsePositiveIntegerArg("--cleanup-timeout-ms", argv[index]);
} else if (arg === "--no-progress") {
args.progressEnabled = false;
} else if (arg === "--help") {
args.help = true;
} else {
@@ -370,6 +384,14 @@ export function parseSmokeArgs(argv) {
return args;
}
function parsePositiveIntegerArg(flag, value, options = {}) {
if (!value) throw new Error(`${flag} requires a value`);
const parsed = Number(value);
const valid = Number.isInteger(parsed) && (options.allowZero ? parsed >= 0 : parsed > 0);
if (!valid) throw new Error(`${flag} must be a ${options.allowZero ? "non-negative" : "positive"} integer`);
return parsed;
}
function runStaticSmoke() {
const checks = [];
const blockers = [];
@@ -707,6 +729,9 @@ async function runLiveDomOnlySmoke(args) {
export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
const useLiveUrl = args.live === true || args.urlExplicit === true;
const layoutSourceMode = useLiveUrl ? "dev-live" : "local-build";
const progress = createLayoutSmokeProgress(args);
const viewportTimeoutMs = args.viewportTimeoutMs ?? defaultLayoutViewportTimeoutMs;
const cleanupTimeoutMs = args.cleanupTimeoutMs ?? defaultLayoutCleanupTimeoutMs;
const artifactRoot = path.resolve(
repoRoot,
args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp())
@@ -714,8 +739,10 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
const layoutViewports = workbenchLayoutViewports();
let chromium;
try {
progress.log("import-playwright", { sourceMode: layoutSourceMode });
({ chromium } = await importPlaywright());
} catch (error) {
progress.stop();
const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`;
return sanitizeLayoutReport({
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
@@ -774,30 +801,59 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
}
if (!useLiveUrl && args.build === true) {
execFileSync(resolveBunCommand(), ["run", "web/hwlab-cloud-web/scripts/build.ts"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30000
});
}
await fs.promises.mkdir(artifactRoot, { recursive: true });
const server = useLiveUrl
? null
: await startStaticWebServer({
rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot,
authFixture: true,
liveBuildsFixture: true
progress.log("build-start", { command: "bun run web/hwlab-cloud-web/scripts/build.ts" });
try {
execFileSync(resolveBunCommand(), ["run", "web/hwlab-cloud-web/scripts/build.ts"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30000
});
const url = useLiveUrl ? args.url : server.url;
} catch (error) {
progress.stop();
throw error;
}
progress.log("build-done");
}
let browser;
let server = null;
let url = useLiveUrl ? args.url : null;
let cleanupStarted = false;
const cleanupResources = async (reason) => {
if (cleanupStarted) return;
cleanupStarted = true;
progress.log("cleanup-start", { reason, browser: Boolean(browser), server: Boolean(server), timeoutMs: cleanupTimeoutMs });
if (browser) await closeWithTimeout(() => browser.close(), cleanupTimeoutMs, "browser.close", progress);
if (server) await closeWithTimeout(() => server.close(), cleanupTimeoutMs, "static-server.close", progress);
progress.log("cleanup-done", { reason });
};
const signalHandlers = registerLayoutSmokeSignalCleanup(cleanupResources, progress);
try {
progress.log("artifact-dir", { artifactRoot });
await fs.promises.mkdir(artifactRoot, { recursive: true });
progress.log(useLiveUrl ? "use-live-url" : "start-static-server", { url: useLiveUrl ? args.url : null });
server = useLiveUrl
? null
: await startStaticWebServer({
rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot,
authFixture: true,
liveBuildsFixture: true
});
url = useLiveUrl ? args.url : server.url;
progress.log(useLiveUrl ? "live-url-ready" : "static-server-ready", { url });
progress.log("launch-browser");
browser = await launchChromium(chromium);
progress.log("browser-launched", browser.hwlabLaunchPlan ?? {});
const checks = [];
const screenshots = [];
for (const viewport of layoutViewports) {
const check = await inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot });
progress.log("viewport-start", { viewport: viewport.id, timeoutMs: viewportTimeoutMs });
const check = await withLayoutTimeout(
inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot }),
viewportTimeoutMs,
`layout viewport ${viewport.id}`
);
progress.log("viewport-done", { viewport: viewport.id, status: check.status });
checks.push(check);
screenshots.push(...(check.artifacts?.screenshots ?? []));
}
@@ -970,8 +1026,9 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
safety: layoutSafety()
});
} finally {
if (browser) await browser.close();
if (server) await server.close();
removeLayoutSmokeSignalCleanup(signalHandlers);
await cleanupResources("finally");
progress.stop();
}
}
@@ -983,6 +1040,83 @@ function workbenchLayoutViewports() {
];
}
function createLayoutSmokeProgress(args = {}) {
const enabled = args.progressEnabled !== false;
const intervalMs = args.progressIntervalMs ?? defaultLayoutProgressIntervalMs;
let lastStage = "start";
let interval = null;
const write = (stage, details = {}) => {
if (!enabled) return;
const payload = {
event: "layout-smoke-progress",
stage,
generatedAt: new Date().toISOString(),
...details
};
process.stderr.write(`[dev-cloud-workbench-layout-smoke] ${JSON.stringify(payload)}\n`);
};
if (enabled && intervalMs > 0) {
interval = setInterval(() => write("heartbeat", { lastStage }), intervalMs);
interval.unref?.();
}
return {
log(stage, details = {}) {
lastStage = stage;
write(stage, details);
},
stop() {
if (interval) clearInterval(interval);
interval = null;
}
};
}
function registerLayoutSmokeSignalCleanup(cleanupResources, progress) {
const handlers = [];
for (const signal of ["SIGINT", "SIGTERM"]) {
const handler = () => {
progress.log("signal", { signal });
cleanupResources(signal)
.catch((error) => progress.log("signal-cleanup-failed", {
signal,
summary: error instanceof Error ? error.message : String(error)
}))
.finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
};
process.once(signal, handler);
handlers.push({ signal, handler });
}
return handlers;
}
function removeLayoutSmokeSignalCleanup(handlers) {
for (const { signal, handler } of handlers) {
process.off(signal, handler);
}
}
function withLayoutTimeout(promise, timeoutMs, label) {
let timeout = null;
const timer = new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
});
return Promise.race([promise, timer]).finally(() => {
if (timeout) clearTimeout(timeout);
});
}
async function closeWithTimeout(closeFn, timeoutMs, label, progress) {
try {
await withLayoutTimeout(Promise.resolve().then(closeFn), timeoutMs, label);
progress.log("cleanup-resource-done", { resource: label });
} catch (error) {
progress.log("cleanup-resource-timeout", {
resource: label,
summary: error instanceof Error ? error.message : String(error)
});
}
}
function layoutSafety() {
return {
...staticSafety(),
@@ -3441,9 +3575,11 @@ async function inspectAuthFixtureViewport(browser, url, viewport) {
}
async function loginWithDefaultCredentials(page) {
await page.waitForLoadState("networkidle", { timeout: 12000 }).catch(() => null);
await page.waitForFunction(() => document.body.dataset.authState !== "checking", null, { timeout: 12000 }).catch(() => null);
const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
await page.waitForFunction(() => {
const authState = document.body.dataset.authState ?? "";
return authState !== "checking" || Boolean(document.querySelector("#login-shell, #command-input"));
}, null, { timeout: 12000 }).catch(() => null);
const loginVisible = await page.evaluate(() => document.querySelector("#login-shell")?.hidden === false).catch(() => false);
if (!loginVisible) return;
await page.locator("#login-submit").waitFor({ state: "visible", timeout: 12000 });
await page.waitForFunction(() => document.querySelector("#login-submit")?.disabled === false, null, { timeout: 12000 }).catch(() => null);
@@ -5195,6 +5331,7 @@ async function startStaticWebServer(options = {}) {
const rootDir = path.resolve(options.rootDir ?? webRoot);
const authFixtureSessions = new Set();
const agentTraceStore = new Map();
const sockets = new Set();
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) {
@@ -5229,6 +5366,10 @@ async function startStaticWebServer(options = {}) {
response.end(JSON.stringify({ ok: false, error: "not_found" }));
}
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
@@ -5236,7 +5377,10 @@ async function startStaticWebServer(options = {}) {
const address = server.address();
return {
url: `http://127.0.0.1:${address.port}/`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
close: () => new Promise((resolve, reject) => {
for (const socket of sockets) socket.destroy();
server.close((error) => error ? reject(error) : resolve());
})
};
}