fix(v02): bound cloud web layout smoke (#846)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -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());
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user