From a885f6b087d9bc6428b36f230f5d2780bdde209b Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 04:18:36 +0000 Subject: [PATCH] fix: remove report fallback from live build metadata --- internal/cloud/server.mjs | 76 +++---- internal/cloud/server.test.mjs | 208 ++++++++++-------- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 60 ++++- web/hwlab-cloud-web/app.mjs | 5 +- web/hwlab-cloud-web/scripts/check.mjs | 2 + .../scripts/live-status-contract.test.mjs | 5 + 6 files changed, 222 insertions(+), 134 deletions(-) diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index b8a7c849..7961eecc 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -429,14 +429,13 @@ async function buildLiveBuildsPayload(options = {}) { contractVersion: "live-builds-v1", observedAt: new Date().toISOString(), source: { - kind: "live-health+artifact-catalog", + kind: "live-health+deploy-artifact-catalog", route: "/v1/live-builds", healthPath: "/health/live", metadataPaths: LIVE_BUILD_METADATA_PATHS, desiredStateSource: metadata.deployManifest.status === "ok" ? LIVE_BUILD_METADATA_PATHS.deployManifest : "unavailable", artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactCatalog : "unavailable", - artifactReportSource: "disabled:repository-report-files-removed", - note: "Each row prefers current live service health build metadata and falls back to repo-owned deploy/artifact metadata only when the live tag/revision matches; observedAt is not used as build time." + note: "Each row prefers current live service health build metadata and falls back only to deploy/artifact catalog metadata when the live tag/revision matches; observedAt is not used as build time." }, latest, counts: { @@ -604,18 +603,23 @@ function liveBuildRecordFromHealth(service, result, metadataRecords = []) { const mismatch = matched.match ?? liveBuildMetadataMatch(liveIdentity, metadataRecord); const unavailableReason = matched.record - ? `构建时间不可用:当前 live tag/revision 已匹配 repo-owned metadata,但该 metadata 没有构建时间(live tag ${liveIdentity.imageTag},live revision ${liveIdentity.revision},metadata tag ${matched.record.image.tag},metadata revision ${matched.record.revision},来源 ${matched.record.build.metadataSource})` + ? "构建时间不可用:当前 live 镜像未提供 buildCreatedAt" : metadataRecord - ? `构建时间不可用:当前 live tag/revision 与 repo-owned artifact metadata 不匹配,未使用旧构建时间(live tag ${liveIdentity.imageTag},live revision ${liveIdentity.revision},metadata tag ${metadataRecord.image.tag},metadata revision ${metadataRecord.revision})` - : "构建时间不可用:health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,且 repo-owned artifact/catalog/deployment env 未提供可匹配的构建时间"; + ? `构建时间不可用:live tag 与 ${controlledMetadataLabel(metadataRecord)} 不匹配` + : "构建时间不可用:当前 live 镜像未提供 buildCreatedAt"; return liveBuildHealthPayloadRecord(service, result, { payload, imageReference, commitId, createdAt: null, - metadataSource: matched.record ? "live-health:matched-metadata-without-build-time" : metadataRecord ? "live-health:metadata-mismatch" : "unavailable", + metadataSource: matched.record ? "live-health:matched-controlled-metadata-without-build-time" : metadataRecord ? "live-health:controlled-metadata-mismatch" : "live-health:build-created-at-missing", unavailableReason, - metadataMismatch: mismatch + metadataMismatch: mismatch, + unavailableDetail: matched.record + ? `live tag ${liveIdentity.imageTag} / revision ${liveIdentity.revision} 已匹配 ${controlledMetadataLabel(matched.record)},但该 metadata 没有 buildCreatedAt(metadata tag ${matched.record.image.tag},metadata revision ${matched.record.revision},来源 ${matched.record.build.metadataSource})` + : metadataRecord + ? `未使用可能过期的构建时间:live tag ${liveIdentity.imageTag},live revision ${liveIdentity.revision},metadata tag ${metadataRecord.image.tag},metadata revision ${metadataRecord.revision},来源 ${metadataRecord.build.metadataSource}` + : "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,deploy/artifact catalog 也没有可匹配的 buildCreatedAt" }); } @@ -626,7 +630,8 @@ function liveBuildHealthPayloadRecord(service, result, { createdAt, metadataSource, unavailableReason, - metadataMismatch = null + metadataMismatch = null, + unavailableDetail = null }) { return { serviceId: service.serviceId, @@ -641,6 +646,7 @@ function liveBuildHealthPayloadRecord(service, result, { source: payload.build?.source ?? payload.commit?.source ?? "health-payload", metadataSource, unavailableReason, + unavailableDetail, metadataMismatch }, image: { @@ -668,7 +674,7 @@ function metadataRecordWithHealthGap(metadataRecord, { service, healthUrl, httpS ...metadataRecord.build, createdAt: null, metadataSource: "live-health-unavailable", - unavailableReason: `构建时间不可用:当前 live health 不可用,无法确认 ${service.serviceId} live tag/revision 是否匹配 repo-owned artifact metadata,未使用可能过期的构建时间(metadata tag ${metadataTag},metadata revision ${metadataRevision});${reason}` + unavailableReason: `构建时间不可用:当前 live health 不可用,无法确认 ${service.serviceId} live tag/revision 是否匹配 deploy/artifact catalog metadata,未使用可能过期的构建时间(metadata tag ${metadataTag},metadata revision ${metadataRevision});${reason}` } }; } @@ -687,7 +693,7 @@ function mergeLiveHealthOntoMetadataRecord(metadataRecord, { match, liveIdentity metadataSource: metadataRecord.build.metadataSource, unavailableReason: null, liveMetadataMatch: match, - liveHealthMissingReason: `health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 repo-owned artifact metadata(live tag ${liveIdentity.imageTag})` + liveHealthMissingReason: `health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 ${controlledMetadataLabel(metadataRecord)}(live tag ${liveIdentity.imageTag})` }, image: { reference: imageReference !== "unknown" ? imageReference : metadataRecord.image.reference, @@ -787,21 +793,14 @@ function liveBuildIdentityEquivalent(left, right) { function liveBuildRecordsFromMetadata(service) { return [ liveBuildRecordFromMetadataSource(service, "artifact"), - liveBuildRecordFromMetadataSource(service, "report"), liveBuildRecordFromMetadataSource(service, "deploy") ].filter(Boolean); } -function liveBuildRecordFromMetadata(service) { - return liveBuildRecordsFromMetadata(service)[0] ?? null; -} - function liveBuildRecordFromMetadataSource(service, sourceKind) { const source = sourceKind === "artifact" ? service.artifact - : sourceKind === "report" - ? service.report - : service.deploy; + : service.deploy; if (!source) return null; const createdAt = normalizeIsoTimestamp( sourceKind === "deploy" ? source.env?.HWLAB_BUILD_CREATED_AT : source.buildCreatedAt @@ -812,15 +811,13 @@ function liveBuildRecordFromMetadataSource(service, sourceKind) { : source.image ?? service.deploy?.image ?? "unknown"; const commitId = sourceKind === "artifact" ? source.commitId ?? imageTagFromReference(imageReference) ?? "unknown" - : sourceKind === "report" - ? source.sourceCommitId ?? source.commitId ?? imageTagFromReference(imageReference) ?? "unknown" - : source.env?.HWLAB_COMMIT_ID ?? imageTagFromReference(imageReference) ?? "unknown"; + : source.env?.HWLAB_COMMIT_ID ?? imageTagFromReference(imageReference) ?? "unknown"; const digest = sourceKind === "deploy" ? source.env?.HWLAB_IMAGE_DIGEST ?? "unknown" : source.digest ?? "unknown"; const buildSource = sourceKind === "deploy" - ? source.env?.HWLAB_BUILD_SOURCE ?? "repo-owned-deployment-env" - : source.buildSource ?? "repo-owned-artifact-metadata"; + ? source.env?.HWLAB_BUILD_SOURCE ?? "deploy-desired-state-env" + : source.buildSource ?? "artifact-catalog-metadata"; const imageTag = sourceKind === "deploy" ? source.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(imageReference) ?? "unknown" : source.imageTag ?? imageTagFromReference(imageReference) ?? "unknown"; @@ -841,7 +838,7 @@ function liveBuildRecordFromMetadataSource(service, sourceKind) { metadataSource: artifactMetadataSource(sourceKind), unavailableReason: createdAt ? null - : `构建时间不可用:repo-owned ${artifactMetadataSource(sourceKind)} 缺少构建时间` + : `构建时间不可用:${controlledMetadataLabel({ build: { metadataSource: artifactMetadataSource(sourceKind) } })} 缺少 buildCreatedAt` }, image: { reference: imageReference, @@ -850,7 +847,7 @@ function liveBuildRecordFromMetadataSource(service, sourceKind) { }, commit: { id: commitId, - source: sourceKind === "artifact" ? "artifact-catalog" : sourceKind === "report" ? "artifact-report" : "deploy-desired-state" + source: sourceKind === "artifact" ? "artifact-catalog" : "deploy-desired-state" }, revision }; @@ -868,10 +865,16 @@ function desiredStateSummary(service) { function artifactMetadataSource(sourceKind) { if (sourceKind === "artifact") return "artifact-catalog:buildCreatedAt"; - if (sourceKind === "report") return "artifact-report:buildCreatedAt"; return "deploy-env:HWLAB_BUILD_CREATED_AT"; } +function controlledMetadataLabel(record) { + const source = String(record?.build?.metadataSource ?? ""); + if (source.includes("artifact-catalog")) return "catalog metadata"; + if (source.includes("deploy-env")) return "deploy metadata"; + return "controlled metadata"; +} + function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) { return { serviceId: service.serviceId, @@ -962,19 +965,17 @@ function normalizeHealthBuildTime(payload) { async function loadLiveBuildMetadata(options = {}) { if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata); const root = options.repoRoot ?? process.env.HWLAB_REPO_ROOT ?? repoRoot; - const [deployManifest, artifactCatalog, artifactReport] = await Promise.all([ + const [deployManifest, artifactCatalog] = await Promise.all([ readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.deployManifest), - readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog), - Promise.resolve({ status: "disabled", payload: null, error: "repository report files removed" }) + readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog) ]); - return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog, artifactReport }); + return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog }); } function normalizeLiveBuildMetadata(metadata) { return { deployManifest: normalizeMetadataFile(metadata.deployManifest), - artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog), - artifactReport: normalizeMetadataFile(metadata.artifactReport) + artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog) }; } @@ -993,7 +994,7 @@ async function readJsonMetadata(root, relativePath) { } function normalizeMetadataFile(file) { - if (file?.status === "ok" || file?.status === "unavailable" || file?.status === "disabled") { + if (file?.status === "ok" || file?.status === "unavailable") { return { status: file.status, path: file.path ?? null, @@ -1018,10 +1019,6 @@ function liveBuildServiceInventory(metadata) { (Array.isArray(metadata.artifactCatalog.payload?.services) ? metadata.artifactCatalog.payload.services : []) .map((service) => [service.serviceId, service]) ); - const reportByServiceId = new Map( - (Array.isArray(metadata.artifactReport.payload?.artifactPublish?.services) ? metadata.artifactReport.payload.artifactPublish.services : []) - .map((service) => [service.serviceId, service]) - ); const serviceIds = uniqueStrings([ ...SERVICE_IDS, ...deployServices.map((service) => service.serviceId), @@ -1041,8 +1038,7 @@ function liveBuildServiceInventory(metadata) { healthPath, activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation, deploy, - artifact: artifactByServiceId.get(serviceId) ?? null, - report: reportByServiceId.get(serviceId) ?? null + artifact: artifactByServiceId.get(serviceId) ?? null }); }), ...LIVE_BUILD_EXTERNAL_COMPONENTS diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 2bffcc88..2b118ba1 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -177,7 +177,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { } }); -test("cloud api aggregates live HWLAB build times from health and repo-owned artifact metadata", async () => { +test("cloud api aggregates live HWLAB build times from health and controlled deploy/catalog metadata", async () => { const healthByPath = new Map([ ["/hwlab-cloud-web/health/live", { serviceId: "hwlab-cloud-web", @@ -324,11 +324,6 @@ test("cloud api aggregates live HWLAB build times from health and repo-owned art buildSource: "artifact-catalog-test" } ] - }, - artifactReport: { - artifactPublish: { - services: [] - } } }; @@ -389,7 +384,7 @@ test("cloud api aggregates live HWLAB build times from health and repo-owned art assert.equal(payload.latest.commit.id, "skillsabcdef123456"); assert.equal(payload.latest.build.metadataSource, "deploy-env:HWLAB_BUILD_CREATED_AT"); assert.equal(payload.latest.build.liveMetadataMatch.metadata.imageTag, "skillsabcd"); - assert.match(payload.latest.build.liveHealthMissingReason, /匹配的 repo-owned artifact metadata/u); + assert.match(payload.latest.build.liveHealthMissingReason, /匹配的 deploy metadata/u); assert.equal(payload.counts.total, 15); assert.equal(payload.counts.external, 2); assert.equal(payload.counts.withBuildTime, 3); @@ -397,10 +392,10 @@ test("cloud api aggregates live HWLAB build times from health and repo-owned art assert.equal(manager.build.createdAt, null); assert.equal(manager.image.tag, "48dfbf9"); assert.equal(manager.revision, "48dfbf9"); - assert.equal(manager.build.metadataSource, "live-health:metadata-mismatch"); - assert.match(manager.build.unavailableReason, /构建时间不可用:当前 live tag\/revision 与 repo-owned artifact metadata 不匹配/u); - assert.match(manager.build.unavailableReason, /live tag 48dfbf9/u); - assert.match(manager.build.unavailableReason, /metadata tag 3df89fe/u); + assert.equal(manager.build.metadataSource, "live-health:controlled-metadata-mismatch"); + assert.equal(manager.build.unavailableReason, "构建时间不可用:live tag 与 catalog metadata 不匹配"); + assert.match(manager.build.unavailableDetail, /live tag 48dfbf9/u); + assert.match(manager.build.unavailableDetail, /metadata tag 3df89fe/u); const extra = payload.services.find((service) => service.serviceId === "hwlab-extra-lab"); assert.equal(extra.build.createdAt, null); assert.match(extra.build.unavailableReason, /当前 live health 不可用/u); @@ -417,96 +412,135 @@ test("cloud api aggregates live HWLAB build times from health and repo-owned art } }); -test("cloud api uses matching report metadata when catalog has the live tag but no build time", async () => { - const liveBuildMetadata = { - deployManifest: { +test("cloud api ignores old repository reports and keeps missing buildCreatedAt unavailable", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "hwlab-live-builds-")); + const deployDir = path.join(tempRoot, "deploy"); + const oldReportDir = path.join(tempRoot, "reports", "dev-gate"); + await mkdir(deployDir, { recursive: true }); + await mkdir(oldReportDir, { recursive: true }); + await writeFile(path.join(deployDir, "deploy.json"), `${JSON.stringify({ + services: [ + { + serviceId: "hwlab-agent-mgr", + image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", + namespace: "hwlab-dev", + profile: "dev", + healthPath: "/health/live", + replicas: 1 + } + ] + }, null, 2)}\n`); + await writeFile(path.join(deployDir, "artifact-catalog.dev.json"), `${JSON.stringify({ + services: [ + { + serviceId: "hwlab-agent-mgr", + commitId: "48dfbf9abcdef", + image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", + imageTag: "48dfbf9", + digest: "sha256:" + "6".repeat(64), + namespace: "hwlab-dev", + profile: "dev", + healthPath: "/health/live", + buildCreatedAt: null, + buildSource: null + } + ] + }, null, 2)}\n`); + await writeFile(path.join(oldReportDir, "dev-artifacts.json"), `${JSON.stringify({ + artifactPublish: { services: [ { serviceId: "hwlab-agent-mgr", - image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", - namespace: "hwlab-dev", - profile: "dev", - healthPath: "/health/live", - replicas: 1 - } - ] - }, - artifactCatalog: { - services: [ - { - serviceId: "hwlab-agent-mgr", - commitId: "48dfbf9", + sourceCommitId: "48dfbf9abcdef", image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", imageTag: "48dfbf9", - digest: "not_published", - namespace: "hwlab-dev", - profile: "dev", - healthPath: "/health/live", - buildCreatedAt: null, - buildSource: null + digest: "sha256:" + "6".repeat(64), + buildCreatedAt: "2099-01-01T00:00:00.000Z", + buildSource: "old-report-must-not-be-read" } ] - }, - artifactReport: { - artifactPublish: { - services: [ - { - serviceId: "hwlab-agent-mgr", - sourceCommitId: "48dfbf9abcdef", - image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", - imageTag: "48dfbf9", - digest: "sha256:" + "6".repeat(64), - namespace: "hwlab-dev", - profile: "dev", - healthPath: "/health/live", - buildCreatedAt: "2026-05-24T06:00:00.000Z", - buildSource: "artifact-report-test" + } + }, null, 2)}\n`); + + const observe = async () => { + const server = createCloudApiServer({ + env: { + PATH: "", + HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr" + }, + repoRoot: tempRoot, + fetchImpl: async (url) => { + if (new URL(url).pathname === "/hwlab-agent-mgr/health/live") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + serviceId: "hwlab-agent-mgr", + status: "ok", + revision: "48dfbf9abcdef", + commit: { id: "48dfbf9abcdef", source: "test" }, + image: { + reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", + tag: "48dfbf9", + digest: "sha256:" + "6".repeat(64) + } + }); + } + }; + } + return { + ok: false, + status: 503, + async text() { + return JSON.stringify({ error: "offline" }); } - ] + }; } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`); + assert.equal(response.status, 200); + return response.json(); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); } }; - const server = createCloudApiServer({ - env: { - PATH: "", - HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr" - }, - liveBuildMetadata, - fetchImpl: async () => ({ - ok: true, - status: 200, - async text() { - return JSON.stringify({ - serviceId: "hwlab-agent-mgr", - status: "ok", - revision: "48dfbf9abcdef", - commit: { id: "48dfbf9abcdef", source: "test" }, - image: { - reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9", - tag: "48dfbf9", - digest: "sha256:" + "6".repeat(64) - } - }); - } - }) - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`); - assert.equal(response.status, 200); - const payload = await response.json(); - const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr"); - assert.equal(manager.build.createdAt, "2026-05-24T06:00:00.000Z"); - assert.equal(manager.build.metadataSource, "artifact-report:buildCreatedAt"); - assert.equal(manager.build.liveMetadataMatch.metadata.imageTag, "48dfbf9"); - assert.equal(payload.latest.serviceId, "hwlab-agent-mgr"); + const withOldReport = await observe(); + await rm(path.join(tempRoot, "reports"), { recursive: true, force: true }); + const withoutOldReport = await observe(); + const summarize = (payload) => { + const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr"); + return { + latest: payload.latest, + source: payload.source, + createdAt: manager.build.createdAt, + metadataSource: manager.build.metadataSource, + unavailableReason: manager.build.unavailableReason, + unavailableDetail: manager.build.unavailableDetail, + liveMetadataMatch: manager.build.liveMetadataMatch, + counts: payload.counts + }; + }; + + assert.deepEqual(summarize(withOldReport), summarize(withoutOldReport)); + const manager = withOldReport.services.find((service) => service.serviceId === "hwlab-agent-mgr"); + assert.equal(withOldReport.latest, null); + assert.equal(manager.build.createdAt, null); + assert.equal(manager.build.metadataSource, "live-health:matched-controlled-metadata-without-build-time"); + assert.equal(manager.build.unavailableReason, "构建时间不可用:当前 live 镜像未提供 buildCreatedAt"); + assert.match(manager.build.unavailableDetail, /catalog metadata/u); + assert.equal(Object.hasOwn(withOldReport.source, "artifactReportSource"), false); + assert.equal(JSON.stringify(withOldReport).includes("2099-01-01T00:00:00.000Z"), false); + assert.equal(JSON.stringify(withOldReport).includes("artifact-report"), false); } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); + await rm(tempRoot, { recursive: true, force: true }); } }); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 1d92d95e..cdeae08a 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1071,7 +1071,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { const server = useLiveUrl ? null - : await startStaticWebServer({ rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot }); + : await startStaticWebServer({ rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot, liveBuildsFixture: true }); const url = useLiveUrl ? args.url : server.url; let browser; try { @@ -3189,7 +3189,7 @@ async function inspectLiveDom(url, options = {}) { /hwlab-agent-worker/u.test(dom.liveBuildDetailWhenOpen.text) && /构建时间不可用/u.test(dom.liveBuildDetailWhenOpen.text) && /外部镜像或非 HWLAB 构建产物/u.test(dom.liveBuildDetailWhenOpen.text) && - /来源 health metadata/u.test(dom.liveBuildDetailWhenOpen.text) && + /来源 live health/u.test(dom.liveBuildDetailWhenOpen.text) && dom.firstViewportForbiddenPresent.length === 0 && dom.labelsPresent && dom.navLabelsPresent && @@ -5385,6 +5385,10 @@ async function startStaticWebServer(options = {}) { if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { return; } + if (options.liveBuildsFixture && request.method === "GET" && url.pathname === "/v1/live-builds") { + jsonResponse(response, 200, liveBuildsFixturePayload()); + return; + } let pathname = decodeURIComponent(url.pathname); if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate" || pathname === "/help") { pathname = "/index.html"; @@ -5774,7 +5778,7 @@ function liveBuildsFixturePayload() { contractVersion: "live-builds-v1", observedAt: "2026-05-23T00:30:00.000Z", source: { - kind: "live-health+artifact-catalog", + kind: "live-health+deploy-artifact-catalog", route: "/v1/live-builds", healthPath: "/health/live" }, @@ -5786,7 +5790,7 @@ function liveBuildsFixturePayload() { build: { createdAt: "2026-05-23T00:20:00.000Z", metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT", - liveHealthMissingReason: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用 repo-owned artifact metadata" + liveHealthMissingReason: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 catalog metadata" }, image: { reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05", @@ -7485,6 +7489,52 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { return element ? element.scrollWidth <= element.clientWidth + 2 : false; })() }; + const inspectLiveBuildLayout = async () => { + const details = document.querySelector("#live-build-summary"); + const label = document.querySelector("#live-build-latest"); + const list = document.querySelector("#live-build-list"); + if (!details || !label || !list) { + return { ok: false, exists: false, summaryOverflowX: null, listOverflowX: null, maxRowOverflowX: null, rows: 0 }; + } + const wasOpen = details.open; + const summaryOverflowX = Math.max(0, label.scrollWidth - label.clientWidth); + details.open = true; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const rows = [...list.querySelectorAll(".live-build-row")]; + const rowOverflows = rows.map((row) => Math.max(0, row.scrollWidth - row.clientWidth)); + const maxRowOverflowX = Math.max(0, ...rowOverflows); + const listOverflowX = Math.max(0, list.scrollWidth - list.clientWidth); + const scrollContained = list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4; + const text = list.textContent?.replace(/\s+/gu, " ").trim() ?? ""; + const expectedRows = rows.length >= 4; + const expectedText = /构建时间不可用/u.test(text) && /外部镜像或非 HWLAB 构建产物/u.test(text); + details.open = wasOpen; + await new Promise((resolve) => requestAnimationFrame(resolve)); + return { + ok: summaryOverflowX <= 1 && listOverflowX <= 1 && maxRowOverflowX <= 1 && scrollContained && expectedRows && expectedText, + exists: true, + openStateRestored: details.open === wasOpen, + summaryOverflowX, + listOverflowX, + maxRowOverflowX, + rowOverflows, + rows: rows.length, + expectedRows, + expectedText, + scrollContained, + textSample: text.slice(0, 240) + }; + }; + const liveBuildLayout = await inspectLiveBuildLayout(); + if (!liveBuildLayout.ok) { + failures.push({ + failureType: "overflow", + selector: "#live-build-summary", + viewport, + summary: "Live build summary/details overflow or cannot be expanded within the viewport.", + liveBuildLayout + }); + } const hardwareTabRows = (() => { const tabs = [...document.querySelectorAll("[data-hardware-tab]")]; return new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size; @@ -7807,6 +7857,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { rightWidthTarget && keyTargetsReachable && resourceExplorerRemovalGuard && + liveBuildLayout.ok && sidePanelOverflowUsable; const passWithWiring = pass && @@ -7833,6 +7884,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { overlapChecks, noHorizontalOverflow, horizontalOverflowFree: Object.values(noHorizontalOverflow).every(Boolean), + liveBuildLayout, hardwareTabRows, rightWidthTarget, noIncoherentOverlap, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index fab0d949..5fe4e7c6 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1380,9 +1380,8 @@ function liveBuildReasonText(service) { function liveBuildSourceLabel(source) { const text = String(source ?? "").trim(); if (text.includes("artifact-catalog")) return "artifact catalog"; - if (text.includes("artifact-report")) return "artifact report"; - if (text.includes("runtime-env") || text.includes("health")) return "health metadata"; - if (text.includes("deploy")) return "desired-state"; + if (text.includes("deploy")) return "deploy metadata"; + if (text.includes("runtime-env") || text.includes("health")) return "live health"; if (text.includes("external")) return "external image"; if (!text || text === "unavailable") return "不可用"; return text; diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 2bc58fa7..4fe18da0 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -938,6 +938,8 @@ assert.match(app, /revision \$\{shortToken\(service\.revision\)\}/); assert.match(app, /liveBuildSourceLabel/); assert.match(app, /liveBuildDesiredStateText/); assert.match(app, /外部镜像或非 HWLAB 构建产物/); +assert.doesNotMatch(app, /artifact report|artifact-report/); +assert.match(app, /live health/); assert.match(styles, /\.live-build-summary-label\s*{[^}]*min-width:\s*0;[^}]*max-width:\s*100%;[^}]*overflow-wrap:\s*anywhere;/s); assert.match(styles, /\.live-build-list\s*{[^}]*max-height:\s*min\(280px,\s*42dvh\);[^}]*min-width:\s*0;[^}]*overflow:\s*auto;/s); assert.match(styles, /\.live-build-row-head\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*0\.8fr\)\s*minmax\(0,\s*1\.2fr\);/s); diff --git a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs index 0ffd7e89..2187da6c 100644 --- a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs +++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs @@ -200,6 +200,7 @@ test("cloud web live build surface renders Beijing summary, details, missing tim const { readFileSync } = await import("node:fs"); const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); const app = readFileSync(new URL("../app.mjs", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); assert.match(html, /id="live-build-summary"/u); assert.match(html, /id="live-build-latest"/u); @@ -209,6 +210,10 @@ test("cloud web live build surface renders Beijing summary, details, missing tim assert.match(app, /liveBuildSourceLabel/u); assert.match(app, /liveBuildDesiredStateText/u); assert.match(app, /外部镜像或非 HWLAB 构建产物/u); + assert.doesNotMatch(app, /artifact report|artifact-report/u); + assert.match(styles, /\.live-build-summary-label\s*\{[^}]*min-width:\s*0;[^}]*max-width:\s*100%;[^}]*overflow-wrap:\s*anywhere;/su); + assert.match(styles, /\.live-build-list\s*\{[^}]*max-height:\s*min\(280px,\s*42dvh\);[^}]*min-width:\s*0;[^}]*overflow:\s*auto;/su); + assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.live-build-row-head\s*\{[\s\S]*?grid-template-columns:\s*1fr;/u); }); test("classifies fully ready workbench runtime as API 正常", () => {