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
@@ -51,6 +51,12 @@
| `POST /v1/internal/device-pod/gateway-dispatch` | 仅接受 `hwlab-device-pod` 内部服务凭据,用于把 executor job dispatch 到 gateway poll/result;普通用户和 Code Agent 不可调用。 |
| `POST /v1/agent/chat``POST /v1/agent/chat/steer``GET /v1/agent/chat/result/{traceId}``GET /v1/agent/chat/trace/{traceId}``POST /v1/agent/chat/cancel` | Code Agent 短连接提交、运行中 steer、轮询、trace 和取消;普通 chat 必须绑定显式 usable session。 |
### `/v1/live-builds` 语义
`/v1/live-builds` 返回的是运行面服务清单,不是 Cloud Web 专属 health。`latest` 字段按所有 HWLAB runtime service 的 build time 排序,可能指向 `hwlab-cloud-api``hwlab-cloud-web``hwlab-agent-skills` 或其他本轮最近滚动的服务;当 API 比 Web 更新时,`latest.serviceId=hwlab-cloud-api` 是正常状态,不能据此判断 Cloud Web 构建时间缺失或 Web rollout 失败。
需要验证 Cloud Web 时必须读取 `services[]``serviceId=hwlab-cloud-web` 的行,并区分 `build.createdAt``image.tag/digest``commit.id``revision``build.liveMetadataMatch``/health/live` 只能证明当前服务 health/revisionCloud Web 顶部 build chip 和详情弹窗展示的构建时间、env image 和实际 runtime commit 以 `/v1/live-builds` 的对应 service row 为准。
登录鉴权 API 的最终规格见 [spec-v02-auth.md](spec-v02-auth.md);用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md)。
## 测试规格
@@ -106,6 +106,8 @@ Browser/layout/live smoke 属于显式专项诊断,不进入默认 Cloud Web c
Live smoke 登录前必须等待前端 auth bootstrap 结束(`body[data-auth-state]` 不再是 `checking`,且 login submit 已可见/可用)再填表;登录后必须断言 URL query 不含 `username``password`,防止原生 form submit 泄漏凭据并伪装成 layout 超时。
Workbench build summary 的顶部 chip 可以展示 `/v1/live-builds.latest`,但 `latest` 是跨所有 HWLAB runtime service 的最新构建,不是 Cloud Web 专属字段。Cloud Web 布局或 build-time issue 的验收必须打开详情或读取 `services[]``serviceId=hwlab-cloud-web` 的行,分别核对 build time、env image、actual commit、revision 和 source metadata;不得因为 `latest.serviceId` 指向 `hwlab-cloud-api` 就判定 Web 构建时间缺失。
Cloud Web check 必须先对实际 bundle 输入集合运行 TypeScript 语义检查。语法检查和 Bun build 只能证明源码可解析或可打包,不能稳定发现未绑定标识符;`isRequestTraceEvent is not defined` 这类错误必须由 semantic check 在发布前拦截。实现上可以生成与 dist build 相同顺序的临时 app entry,再执行 `tsc --noEmit` 或等价 TS checker;只跑 `node --check``bun build` 或源码字符串断言不满足本规格。
Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-web/**/*.test.ts`,不允许只维护硬编码文件清单。`app-trace` 的 trace row/render helper 必须有纯逻辑单测,直接构造 request、setup、commandExecution、assistant markdown 和 completion events,证明 trace 展示路径不会因为漏定义 helper、事件分类漂移或 markdown body 渲染变更而在浏览器运行期崩溃。
+2 -1
View File
@@ -38,7 +38,7 @@ export function parseLayoutSmokeArgs(argv) {
continue;
}
normalized.push(arg);
if (["--url", "--report"].includes(arg)) {
if (["--url", "--report", "--viewport-timeout-ms", "--progress-interval-ms", "--cleanup-timeout-ms"].includes(arg)) {
index += 1;
if (!argv[index]) throw new Error(`${arg} requires a value`);
normalized.push(argv[index]);
@@ -110,6 +110,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
"默认和 --static 都使用 Vite local-build;源码静态服务路径已废弃。",
"--build 会先运行 cd web/hwlab-cloud-web && bun run build,再检查本地 dist 构建产物。",
"--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 Device Pod 或硬件写操作,不等同于硬件验收。",
"长连接透传场景默认向 stderr 输出 JSON progress/heartbeat;可用 --no-progress 关闭,或用 --progress-interval-ms、--viewport-timeout-ms、--cleanup-timeout-ms 调整边界。",
"结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。"
]
}
@@ -23,6 +23,9 @@ import {
decideSmokeReportWrite,
smokeCliExitCode
} from "./dev-cloud-workbench-smoke.mjs";
import {
parseLayoutSmokeArgs
} from "./dev-cloud-workbench-layout-smoke.mjs";
import { checkProfiles } from "./src/check-plan.mjs";
const sourceIdentity = Object.freeze({
@@ -194,6 +197,38 @@ test("smoke args include a DOM-only live read-only mode", () => {
assert.equal(args.urlExplicit, true);
});
test("layout smoke CLI exposes bounded progress and timeout controls", () => {
const args = parseLayoutSmokeArgs([
"--live",
"http://74.48.78.17:19666/",
"--viewport-timeout-ms",
"1234",
"--progress-interval-ms",
"0",
"--cleanup-timeout-ms",
"567",
"--no-progress"
]);
assert.equal(args.mode, "layout");
assert.equal(args.url, "http://74.48.78.17:19666/");
assert.equal(args.urlExplicit, true);
assert.equal(args.viewportTimeoutMs, 1234);
assert.equal(args.progressIntervalMs, 0);
assert.equal(args.cleanupTimeoutMs, 567);
assert.equal(args.progressEnabled, false);
});
test("layout smoke CLI rejects invalid bounded timeout arguments", () => {
assert.throws(
() => parseLayoutSmokeArgs(["--viewport-timeout-ms", "0"]),
/--viewport-timeout-ms must be a positive integer/u
);
assert.throws(
() => parseLayoutSmokeArgs(["--progress-interval-ms", "-1"]),
/--progress-interval-ms must be a non-negative integer/u
);
});
test("source/default smoke covers React shell-only and module boundary contracts", () => {
const report = runDevCloudWorkbenchStaticSmoke();
assert.equal(report.checks.find((item) => item.id === "react-shell-only-index")?.status, "pass");
+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());
})
};
}