test: capture deployed workbench user journey evidence

Merge PR #168 after host closeout validation. Evidence scope: deployed 16666 browser journey plus same-origin Code Agent completion; sanitized report only; no M3/M4/M5 acceptance claim.
This commit is contained in:
Lyon
2026-05-23 02:41:31 +08:00
committed by GitHub
4 changed files with 918 additions and 11 deletions
@@ -0,0 +1,249 @@
{
"$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json",
"reportVersion": "v1",
"status": "pass",
"issue": "pikasTech/HWLAB#7",
"taskId": "dev-cloud-workbench-live",
"commitId": "unknown",
"acceptanceLevel": "dev_cloud_workbench_live",
"devOnly": true,
"prodDisabled": true,
"reportLifecycle": {
"version": "v1",
"state": "active",
"activeEndpoint": "http://74.48.78.17:16667",
"activeBrowserEndpoint": "http://74.48.78.17:16666",
"deprecatedEndpoint": null,
"summary": "Current deployed browser user journey report for the 16666 Cloud Workbench; it does not substitute for M3/M4/M5 hardware acceptance."
},
"task": "DC-DCSN-P0-2026-003",
"refs": [
"pikasTech/HWLAB#7",
"pikasTech/HWLAB#108",
"pikasTech/HWLAB#99",
"pikasTech/HWLAB#78",
"pikasTech/HWLAB#117",
"pikasTech/HWLAB#118",
"pikasTech/HWLAB#119",
"pikasTech/HWLAB#120",
"pikasTech/HWLAB#131",
"pikasTech/HWLAB#164"
],
"mode": "live",
"url": "http://74.48.78.17:16666/",
"generatedAt": "2026-05-22T18:11:35.955Z",
"evidenceLevel": "DEV-LIVE-BROWSER",
"devLive": true,
"sourceIdentity": {
"status": "observed",
"source": "git-head",
"commitId": "ee9d60f00b1bc47c1d99b7edb8746623333fe544",
"shortCommitId": "ee9d60f00b1b",
"ref": "test/deployed-workbench-journey-evidence",
"worktreeState": "dirty",
"dirty": true,
"reportCommitId": "unknown",
"summary": "Source HEAD was observed, but the worktree was not cleanly attributable when the report was generated; top-level commitId is unknown rather than pretending to identify uncommitted source."
},
"runtimeIdentity": {
"status": "observed",
"source": "health-live",
"endpoint": "http://74.48.78.17:16667/health/live",
"serviceId": "hwlab-cloud-api",
"environment": "dev",
"healthStatus": "degraded",
"commitId": "c7de474",
"commitSource": "runtime-env",
"imageTag": "c7de474",
"observedAt": "2026-05-22T18:11:24.844Z",
"summary": "Live runtime identity was observed through the existing read-only health endpoint and is not inferred from source git HEAD."
},
"sourceContract": {
"status": "pass",
"documents": [
"docs/cloud-web-workbench.md",
"docs/reference/cloud-workbench.md",
"docs/reference/code-agent-chat-readiness.md"
],
"summary": "The deployed browser journey is anchored to the Cloud Workbench and Code Agent readiness contracts."
},
"validationCommands": [
"node --check scripts/dev-cloud-workbench-smoke.mjs",
"node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs",
"node scripts/dev-cloud-workbench-smoke.mjs --static",
"node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json",
"node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json",
"node scripts/validate-dev-gate-report.mjs"
],
"localSmoke": {
"status": "not_run",
"commands": [
"node scripts/dev-cloud-workbench-smoke.mjs --static"
],
"evidence": [
"Static mode verifies the source contract and same-origin Code Agent wiring only."
],
"summary": "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey."
},
"dryRun": {
"status": "not_applicable",
"commands": [
"node scripts/dev-cloud-workbench-smoke.mjs --static"
],
"evidence": [
"No dry-run substitute is accepted for the deployed browser journey."
],
"summary": "The journey evidence is collected from the real DEV browser endpoint, not a dry-run fixture."
},
"devPreconditions": {
"status": "pass",
"requirements": [
"GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.",
"Browser DOM exposes the default workbench and core controls.",
"A controlled same-origin POST /v1/agent/chat returns completed UI-visible evidence or reports a blocker.",
"No hardware write API, service restart, PROD access, or secret read is performed."
],
"commands": [
"node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json"
],
"summary": "Deployed 16666 browser journey and same-origin Code Agent chat completed."
},
"endpoints": {
"frontend": "http://74.48.78.17:16666",
"api": "http://74.48.78.17:16667",
"edge": "http://74.48.78.17:16667"
},
"checks": [
{
"id": "live-http-html",
"status": "pass",
"summary": "Live URL serves the Cloud Workbench HTML.",
"evidence": [
"http://74.48.78.17:16666/",
"HTTP 200",
"text/html; charset=utf-8"
]
},
{
"id": "live-browser-dom",
"status": "pass",
"summary": "Browser DOM confirms the default workbench, core controls, and outer scroll lock.",
"evidence": [
"title=HWLAB 云工作台",
"bodyOverflow=hidden",
"htmlOverflow=hidden"
],
"observations": {
"title": "HWLAB 云工作台",
"bodyOverflow": "hidden",
"htmlOverflow": "hidden",
"workspaceHidden": false,
"gateHidden": true,
"helpHidden": true,
"outerScrollLocked": true,
"labelsPresent": true,
"coreControlsVisible": {
"commandInput": true,
"commandSend": true,
"agentChatStatus": true,
"conversationList": true,
"hardwareList": true,
"wiringTab": true,
"recordsTab": true
}
}
},
{
"id": "live-code-agent-browser-journey",
"status": "pass",
"summary": "Deployed browser journey opened the workbench, sent a Code Agent message, and observed a completed UI response.",
"evidence": [
"POST /v1/agent/chat HTTP 200",
"status=completed",
"provider=openai-responses",
"model=gpt-5.5",
"traceId=trc_cloud-web-workbench-1779473490624-11",
"uiStatus=已回复"
],
"observations": {
"controls": {
"appShell": true,
"workspace": true,
"commandInput": true,
"commandSend": true,
"agentChatStatus": true,
"conversationList": true,
"hardwareList": true,
"wiringTab": true,
"recordsTab": true
},
"request": {
"method": "POST",
"status": 200,
"urlPath": "/v1/agent/chat"
},
"response": {
"status": "completed",
"provider": "openai-responses",
"model": "gpt-5.5",
"backend": "hwlab-cloud-api/openai-responses",
"traceId": "trc_cloud-web-workbench-1779473490624-11",
"hasReply": true,
"error": null
},
"ui": {
"title": "HWLAB 云工作台",
"workspaceHidden": false,
"gateHidden": true,
"helpHidden": true,
"agentChatStatus": "已回复",
"inputCleared": true,
"completedMessageVisible": true,
"failedMessageVisible": false,
"messageCount": 4,
"completedMessageHasNonSensitiveMeta": true
},
"networkEvents": [
{
"method": "POST",
"status": 200,
"urlPath": "/v1/agent/chat",
"body": {
"status": "completed",
"provider": "openai-responses",
"model": "gpt-5.5",
"backend": "hwlab-cloud-api/openai-responses",
"traceId": "trc_cloud-web-workbench-1779473490624-11",
"hasReply": true,
"error": null
}
}
]
}
}
],
"blockers": [],
"safety": {
"prodTouched": false,
"servicesRestarted": false,
"heavyE2E": false,
"hardwareWriteApis": false,
"sourceIsDevLive": false,
"liveMode": "browser-user-journey-with-code-agent-post",
"retainedApiFields": [
"status",
"provider",
"model",
"backend",
"traceId",
"hasReply",
"error.code",
"error.missingEnv"
],
"statement": "Live smoke opens the deployed workbench and sends one controlled Code Agent message; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance."
},
"artifacts": {
"reportPath": "/workspace/hwlab/.worktrees/deployed-workbench-journey-evidence/reports/dev-gate/dev-cloud-workbench-live.json"
}
}
+8
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
@@ -16,6 +18,12 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
: args.mobile
? await runDevCloudWorkbenchMobileSmoke()
: await runDevCloudWorkbenchSmoke(process.argv.slice(2));
if (args.reportPath && report.status !== "usage") {
const reportPath = path.resolve(process.cwd(), args.reportPath);
await mkdir(path.dirname(reportPath), { recursive: true });
report.artifacts = { ...(report.artifacts ?? {}), reportPath };
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
process.exitCode = report.status === "pass" || report.status === "usage" || report.status === "skip" ? 0 : 2;
} catch (error) {
+421 -10
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -179,6 +180,10 @@ export function parseSmokeArgs(argv) {
index += 1;
if (!argv[index]) throw new Error("--url requires a value");
args.url = argv[index];
} else if (arg === "--report") {
index += 1;
if (!argv[index]) throw new Error("--report requires a value");
args.reportPath = argv[index];
} else if (arg === "--help") {
args.help = true;
} else {
@@ -344,6 +349,7 @@ function runStaticSmoke() {
async function runLiveSmoke(args) {
const checks = [];
const blockers = [];
const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api);
const http = await fetchText(args.url);
addCheck(checks, blockers, "live-http-html", http.ok && http.status === 200 && /text\/html/u.test(http.contentType) && liveHtmlLooksLikeWorkbench(http.body), "Live URL serves the Cloud Workbench HTML.", {
blocker: "runtime_blocker",
@@ -366,6 +372,22 @@ async function runLiveSmoke(args) {
});
}
const journey = http.ok ? await inspectLiveUserJourney(args.url) : { status: "skip", summary: "Browser Code Agent journey skipped because HTTP fetch failed.", evidence: [] };
addCheck(checks, blockers, "live-code-agent-browser-journey", journey.status, journey.summary, {
blocker: journey.status === "pass" ? null : "runtime_blocker",
evidence: journey.evidence,
observations: journey.observations
});
if (journey.status === "skip") {
blockers.push({
type: "observability_blocker",
scope: "live-code-agent-browser-journey",
status: "open",
summary: journey.summary
});
}
const status = blockers.length === 0 ? "pass" : "blocked";
return baseReport({
mode: "live",
@@ -373,38 +395,107 @@ async function runLiveSmoke(args) {
status,
checks,
blockers,
evidenceLevel: status === "pass" ? "DEV-LIVE-READONLY" : "BLOCKED",
evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED",
devLive: status === "pass",
runtimeIdentity,
safety: {
prodTouched: false,
servicesRestarted: false,
heavyE2E: false,
hardwareWriteApis: false,
sourceIsDevLive: false,
liveMode: "read-only-http-and-optional-dom",
statement: "Live smoke is read-only and must not be used to infer M3 DEV-LIVE hardware-loop acceptance."
liveMode: "browser-user-journey-with-code-agent-post",
retainedApiFields: ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv"],
statement: "Live smoke opens the deployed workbench and sends one controlled Code Agent message; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance."
}
});
}
function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null }) {
function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null, runtimeIdentity = null }) {
const sourceIdentity = observeSourceIdentity();
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json",
reportVersion: "v1",
status,
issue: "pikasTech/HWLAB#7",
taskId: "dev-cloud-workbench-live",
commitId: sourceIdentity.reportCommitId,
acceptanceLevel: "dev_cloud_workbench_live",
devOnly: true,
prodDisabled: true,
reportLifecycle: {
version: "v1",
state: "active",
activeEndpoint: runtime.endpoints.api,
activeBrowserEndpoint: runtime.endpoints.frontend,
deprecatedEndpoint: null,
summary: "Current deployed browser user journey report for the 16666 Cloud Workbench; it does not substitute for M3/M4/M5 hardware acceptance."
},
task: "DC-DCSN-P0-2026-003",
refs: [
"pikasTech/HWLAB#7",
"pikasTech/HWLAB#108",
"pikasTech/HWLAB#99",
"pikasTech/HWLAB#78",
"pikasTech/HWLAB#117",
"pikasTech/HWLAB#118",
"pikasTech/HWLAB#119",
"pikasTech/HWLAB#120"
"pikasTech/HWLAB#120",
"pikasTech/HWLAB#131",
"pikasTech/HWLAB#164"
],
mode,
url,
generatedAt: new Date().toISOString(),
evidenceLevel,
devLive,
sourceIdentity,
runtimeIdentity: runtimeIdentity ?? notObservedRuntimeIdentity("live runtime identity was not observed in this smoke mode"),
sourceContract: {
status: "pass",
documents: [
"docs/cloud-web-workbench.md",
"docs/reference/cloud-workbench.md",
"docs/reference/code-agent-chat-readiness.md"
],
summary: "The deployed browser journey is anchored to the Cloud Workbench and Code Agent readiness contracts."
},
validationCommands: [
"node --check scripts/dev-cloud-workbench-smoke.mjs",
"node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs",
"node scripts/dev-cloud-workbench-smoke.mjs --static",
"node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json",
"node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json",
"node scripts/validate-dev-gate-report.mjs"
],
localSmoke: {
status: mode === "static" ? status : "not_run",
commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"],
evidence: ["Static mode verifies the source contract and same-origin Code Agent wiring only."],
summary: "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey."
},
dryRun: {
status: "not_applicable",
commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"],
evidence: ["No dry-run substitute is accepted for the deployed browser journey."],
summary: "The journey evidence is collected from the real DEV browser endpoint, not a dry-run fixture."
},
devPreconditions: {
status: status === "pass" ? "pass" : "blocked",
requirements: [
"GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.",
"Browser DOM exposes the default workbench and core controls.",
"A controlled same-origin POST /v1/agent/chat returns completed UI-visible evidence or reports a blocker.",
"No hardware write API, service restart, PROD access, or secret read is performed."
],
commands: [
"node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json"
],
summary: status === "pass"
? "Deployed 16666 browser journey and same-origin Code Agent chat completed."
: "Deployed browser journey is blocked; see checks and blockers."
},
endpoints: {
frontend: runtime.endpoints.frontend,
api: runtime.endpoints.api,
@@ -453,6 +544,65 @@ function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function observeSourceIdentity() {
const head = runGit(["rev-parse", "--verify", "HEAD"]);
const shortHead = head ? runGit(["rev-parse", "--short=12", "HEAD"]) : null;
const ref = runGit(["rev-parse", "--abbrev-ref", "HEAD"]) ?? process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? "unknown";
const statusOutput = runGit(["status", "--porcelain"]);
const envIdentity = head ? null : environmentCommitIdentity();
const commitId = head ?? envIdentity?.commitId ?? "unknown";
const shortCommitId = shortHead ?? envIdentity?.commitId.slice(0, 12) ?? "unknown";
const dirty = statusOutput === null ? null : statusOutput.length > 0;
const worktreeState = dirty === null ? "unknown" : dirty ? "dirty" : "clean";
const source = head ? "git-head" : envIdentity ? "environment" : "unknown";
const reportCommitId = commitId !== "unknown" && worktreeState === "clean" ? shortCommitId : "unknown";
return {
status: commitId === "unknown" ? "unknown" : "observed",
source,
...(envIdentity ? { environmentKey: envIdentity.key } : {}),
commitId,
shortCommitId,
ref,
worktreeState,
dirty,
reportCommitId,
summary: worktreeState !== "clean"
? "Source HEAD was observed, but the worktree was not cleanly attributable when the report was generated; top-level commitId is unknown rather than pretending to identify uncommitted source."
: "Source identity was derived from the current worktree or source environment and is separate from the live runtime identity."
};
}
function environmentCommitIdentity() {
for (const key of [
"GITHUB_SHA",
"CI_COMMIT_SHA",
"BUILDKITE_COMMIT",
"CODEBUILD_RESOLVED_SOURCE_VERSION",
"SOURCE_COMMIT",
"HWLAB_SOURCE_COMMIT_ID"
]) {
const value = process.env[key];
if (typeof value === "string" && /^[a-f0-9]{7,40}$/iu.test(value.trim())) {
return { key, commitId: value.trim().toLowerCase() };
}
}
return null;
}
function runGit(args) {
try {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000
}).trim();
} catch {
return null;
}
}
function assetsExist() {
return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file)));
}
@@ -872,6 +1022,82 @@ function staticSafety() {
};
}
async function observeLiveRuntimeIdentity(apiEndpoint) {
const endpoint = new URL("/health/live", apiEndpoint).toString();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);
try {
const response = await fetch(endpoint, {
signal: controller.signal,
headers: { Accept: "application/json" }
});
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok || !body || typeof body !== "object") {
return notObservedRuntimeIdentity(`health-live returned HTTP ${response.status}`, endpoint);
}
return {
status: "observed",
source: "health-live",
endpoint,
serviceId: sanitizeRuntimeString(body.serviceId),
environment: sanitizeRuntimeString(body.environment),
healthStatus: sanitizeRuntimeString(body.status),
commitId: sanitizeRevision(body.commit?.id ?? body.commitId),
commitSource: sanitizeRuntimeString(body.commit?.source),
imageTag: sanitizeRuntimeString(body.image?.tag),
observedAt: sanitizeTimestamp(body.observedAt),
summary: "Live runtime identity was observed through the existing read-only health endpoint and is not inferred from source git HEAD."
};
} catch (error) {
return notObservedRuntimeIdentity(
error.name === "AbortError" ? "health-live request timed out" : `health-live request failed: ${oneLine(error.message)}`,
endpoint
);
} finally {
clearTimeout(timer);
}
}
function notObservedRuntimeIdentity(reason, endpoint = new URL("/health/live", runtime.endpoints.api).toString()) {
return {
status: "not_observed",
source: "health-live",
endpoint,
serviceId: "not_observed",
environment: "not_observed",
healthStatus: "not_observed",
commitId: "not_observed",
commitSource: "not_observed",
imageTag: "not_observed",
observedAt: new Date().toISOString(),
reason: oneLine(reason),
summary: "Live runtime identity is recorded as not_observed; source identity must not be treated as the deployed runtime revision."
};
}
function sanitizeRevision(value) {
if (typeof value !== "string") return "unknown";
const trimmed = value.trim().toLowerCase();
return /^[a-f0-9]{7,40}$/u.test(trimmed) ? trimmed : "unknown";
}
function sanitizeTimestamp(value) {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : new Date().toISOString();
}
function sanitizeRuntimeString(value) {
return typeof value === "string" && value.trim().length > 0 ? oneLine(value) : "unknown";
}
function oneLine(value) {
return String(value ?? "unknown").replace(/\s+/gu, " ").trim().slice(0, 240) || "unknown";
}
async function fetchText(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);
@@ -932,19 +1158,37 @@ async function inspectLiveDom(url) {
const dom = await page.evaluate(() => {
const workspace = document.querySelector('[data-view="workspace"]');
const gate = document.querySelector('[data-view="gate"]');
const help = document.querySelector('[data-view="help"]');
const diagnostics = document.querySelector('[data-side-panel="diagnostics"]');
const bodyStyle = getComputedStyle(document.body);
const htmlStyle = getComputedStyle(document.documentElement);
const text = document.body.textContent ?? "";
const visible = (selector) => {
const element = document.querySelector(selector);
if (!element) return false;
const style = getComputedStyle(element);
const box = element.getBoundingClientRect();
return style.visibility !== "hidden" && style.display !== "none" && box.width > 0 && box.height > 0;
};
return {
title: document.title,
bodyOverflow: bodyStyle.overflow,
htmlOverflow: htmlStyle.overflow,
workspaceHidden: workspace ? workspace.hidden : null,
gateHidden: gate ? gate.hidden : null,
helpHidden: help ? help.hidden : null,
diagnosticsHidden: diagnostics ? diagnostics.hidden : null,
outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2,
labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label))
labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)),
coreControlsVisible: {
commandInput: visible("#command-input"),
commandSend: visible("#command-send"),
agentChatStatus: visible("#agent-chat-status"),
conversationList: visible("#conversation-list"),
hardwareList: visible("#hardware-list"),
wiringTab: visible("#tab-wiring"),
recordsTab: visible("#tab-records")
}
};
});
const pass =
@@ -953,12 +1197,14 @@ async function inspectLiveDom(url) {
dom.htmlOverflow === "hidden" &&
dom.workspaceHidden === false &&
dom.gateHidden === true &&
(dom.helpHidden === true || dom.helpHidden === null) &&
(dom.diagnosticsHidden === true || dom.diagnosticsHidden === null) &&
dom.outerScrollLocked &&
dom.labelsPresent;
dom.labelsPresent &&
Object.values(dom.coreControlsVisible).every(Boolean);
return {
status: pass ? "pass" : "blocked",
summary: pass ? "Browser DOM confirms the default workbench and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.",
summary: pass ? "Browser DOM confirms the default workbench, core controls, and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.",
evidence: [`title=${dom.title}`, `bodyOverflow=${dom.bodyOverflow}`, `htmlOverflow=${dom.htmlOverflow}`],
observations: dom
};
@@ -973,6 +1219,171 @@ async function inspectLiveDom(url) {
}
}
async function inspectLiveUserJourney(url) {
let chromium;
try {
({ chromium } = await import("playwright"));
} catch (error) {
return {
status: "skip",
summary: `Browser Code Agent journey skipped because Playwright is unavailable: ${error.message}`,
evidence: ["playwright import failed"]
};
}
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
const agentResponses = [];
page.on("response", async (response) => {
if (!response.url().includes("/v1/agent/chat")) return;
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
agentResponses.push({
method: response.request().method(),
status: response.status(),
urlPath: new URL(response.url()).pathname,
body: sanitizeAgentChatBody(body)
});
});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 });
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
const controls = await inspectJourneyControls(page);
if (!Object.values(controls).every(Boolean)) {
return {
status: "blocked",
summary: "Browser could not reliably locate deployed workbench controls for the Code Agent journey.",
evidence: ["selectors=#command-input,#command-send,#agent-chat-status,#conversation-list,#hardware-list"],
observations: { controls }
};
}
await page.locator("#command-input").fill("请用一句话确认 HWLAB 工作台 Code Agent 浏览器链路可用。");
const responsePromise = page.waitForResponse(
(response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST",
{ timeout: 35000 }
);
await page.locator("#command-send").click();
const response = await responsePromise;
let responseBody = null;
try {
responseBody = await response.json();
} catch {
responseBody = null;
}
await page.waitForTimeout(500);
const ui = await inspectJourneyUi(page);
const responseSummary = sanitizeAgentChatBody(responseBody);
const backendCompleted =
response.ok() &&
responseSummary?.status === "completed" &&
Boolean(responseSummary.provider) &&
Boolean(responseSummary.model) &&
Boolean(responseSummary.traceId);
const uiCompleted = ui.agentChatStatus === "已回复" || ui.completedMessageVisible;
const pass = backendCompleted && uiCompleted && !ui.failedMessageVisible;
return {
status: pass ? "pass" : "blocked",
summary: pass
? "Deployed browser journey opened the workbench, sent a Code Agent message, and observed a completed UI response."
: "Deployed browser journey did not observe a completed non-error Code Agent response.",
evidence: [
`POST /v1/agent/chat HTTP ${response.status()}`,
`status=${responseSummary?.status ?? "unknown"}`,
`provider=${responseSummary?.provider ?? "unknown"}`,
`model=${responseSummary?.model ?? "unknown"}`,
`traceId=${responseSummary?.traceId ?? "unknown"}`,
`uiStatus=${ui.agentChatStatus ?? "unknown"}`
],
observations: {
controls,
request: {
method: response.request().method(),
status: response.status(),
urlPath: new URL(response.url()).pathname
},
response: responseSummary,
ui,
networkEvents: agentResponses
}
};
} catch (error) {
return {
status: "blocked",
summary: `Browser Code Agent journey failed: ${error.message}`,
evidence: ["browser navigation, selector, or POST failed"]
};
} finally {
if (browser) await browser.close();
}
}
async function inspectJourneyControls(page) {
return page.evaluate(() => {
const visible = (selector) => {
const element = document.querySelector(selector);
if (!element) return false;
const style = getComputedStyle(element);
const box = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && box.width > 0 && box.height > 0;
};
return {
appShell: visible("[data-app-shell]"),
workspace: document.querySelector('[data-view="workspace"]')?.hidden === false,
commandInput: visible("#command-input"),
commandSend: visible("#command-send"),
agentChatStatus: visible("#agent-chat-status"),
conversationList: visible("#conversation-list"),
hardwareList: visible("#hardware-list"),
wiringTab: visible("#tab-wiring"),
recordsTab: visible("#tab-records")
};
});
}
async function inspectJourneyUi(page) {
return page.evaluate(() => ({
title: document.title,
workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null,
gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null,
helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null,
agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null,
inputCleared: document.querySelector("#command-input")?.value === "",
completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")),
failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")),
messageCount: document.querySelectorAll(".message-card").length,
completedMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-completed .message-meta")]
.some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? ""))
)
}));
}
function sanitizeAgentChatBody(body) {
if (!body || typeof body !== "object") return null;
return {
status: typeof body.status === "string" ? body.status : null,
provider: typeof body.provider === "string" ? body.provider : null,
model: typeof body.model === "string" ? body.model : null,
backend: typeof body.backend === "string" ? body.backend : null,
traceId: typeof body.traceId === "string" ? body.traceId : null,
hasReply: Boolean(body.reply?.content),
error: body.error && typeof body.error === "object"
? {
code: typeof body.error.code === "string" ? body.error.code : null,
missingEnv: Array.isArray(body.error.missingEnv) ? body.error.missingEnv.filter((item) => typeof item === "string") : []
}
: null
};
}
export async function runDevCloudWorkbenchMobileSmoke() {
let chromium;
try {
@@ -1414,11 +1825,11 @@ function escapeRegExp(value) {
export function printSmokeHelp() {
return {
status: "usage",
command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --live --url http://74.48.78.17:16666/",
command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]",
notes: [
"Static mode reads repository files and emits SOURCE-level evidence only.",
"--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.",
"Live mode is read-only and reports blocked/skip when optional browser DOM evidence is unavailable."
"Live mode opens the deployed workbench in a browser, verifies core controls, sends one Code Agent message, and retains only non-sensitive response fields."
]
};
}
+240 -1
View File
@@ -29,7 +29,8 @@ const issueFamily = Object.freeze({
DEV_M5_GATE_REPORT: "pikasTech/HWLAB#39",
DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40",
DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41",
DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58"
DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58",
DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7"
});
const allowedIssues = new Set([
contractIssue,
@@ -118,6 +119,18 @@ const requiredDevM2Docs = [
"docs/dev-acceptance-matrix.md",
"fixtures/dev-deploy-smoke/dev-deploy-smoke.json"
];
const requiredDevCloudWorkbenchValidationCommands = [
"node --check scripts/dev-cloud-workbench-smoke.mjs",
"node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs",
"node scripts/dev-cloud-workbench-smoke.mjs --static",
"node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json",
"node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json"
];
const requiredDevCloudWorkbenchDocs = [
"docs/cloud-web-workbench.md",
"docs/reference/cloud-workbench.md",
"docs/reference/code-agent-chat-readiness.md"
];
const reportFamilyTemplates = new Map([
[
"dev-gate-report-contract",
@@ -459,6 +472,13 @@ async function validateReport(relativePath) {
await validateDevM2Report(report, label);
return;
}
if (
report.issue === issueFamily.DEV_CLOUD_WORKBENCH_LIVE ||
report.taskId === "dev-cloud-workbench-live"
) {
await validateDevCloudWorkbenchLiveReport(report, label);
return;
}
const template = reportFamilyTemplates.get(report.taskId);
assert.ok(
@@ -1277,6 +1297,225 @@ async function validateDevM2Report(report, label) {
);
}
async function validateDevCloudWorkbenchLiveReport(report, label) {
for (const field of [
"$schema",
"$id",
"reportVersion",
"issue",
"taskId",
"commitId",
"acceptanceLevel",
"devOnly",
"prodDisabled",
"status",
"generatedAt",
"mode",
"url",
"evidenceLevel",
"devLive",
"sourceIdentity",
"runtimeIdentity",
"sourceContract",
"validationCommands",
"localSmoke",
"dryRun",
"devPreconditions",
"checks",
"blockers",
"safety"
]) {
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
}
assertString(report.$schema, `${label}.$schema`);
assertString(report.$id, `${label}.$id`);
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
assert.equal(report.issue, issueFamily.DEV_CLOUD_WORKBENCH_LIVE, `${label}.issue`);
assert.equal(report.taskId, "dev-cloud-workbench-live", `${label}.taskId`);
assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`);
assert.equal(report.acceptanceLevel, "dev_cloud_workbench_live", `${label}.acceptanceLevel`);
assert.equal(report.devOnly, true, `${label}.devOnly`);
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
assertStatus(report.status, `${label}.status`);
assert.equal(report.status, report.blockers.length === 0 ? "pass" : "blocked", `${label}.status must match blockers`);
assertTimestamp(report.generatedAt, `${label}.generatedAt`);
assert.equal(report.mode, "live", `${label}.mode`);
assert.equal(report.url, "http://74.48.78.17:16666/", `${label}.url`);
assert.equal(report.evidenceLevel, report.status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED", `${label}.evidenceLevel`);
assert.equal(report.devLive, report.status === "pass", `${label}.devLive`);
assertDevCloudWorkbenchIdentity(report, label);
assertObject(report.sourceContract, `${label}.sourceContract`);
assertStatus(report.sourceContract.status, `${label}.sourceContract.status`);
await assertDocumentSet(
report.sourceContract.documents,
`${label}.sourceContract.documents`,
requiredDevCloudWorkbenchDocs
);
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
assertStringArray(report.validationCommands, `${label}.validationCommands`, {
minLength: requiredDevCloudWorkbenchValidationCommands.length
});
assertUnique(report.validationCommands, `${label}.validationCommands`);
for (const requiredCommand of requiredDevCloudWorkbenchValidationCommands) {
assert.ok(
report.validationCommands.includes(requiredCommand),
`${label}.validationCommands missing ${requiredCommand}`
);
}
for (const section of ["localSmoke", "dryRun", "devPreconditions"]) {
assertObject(report[section], `${label}.${section}`);
assertStatus(report[section].status, `${label}.${section}.status`);
assertStringArray(report[section].commands, `${label}.${section}.commands`, { minLength: 1 });
assertStringArray(report[section].evidence ?? [], `${label}.${section}.evidence`, { minLength: section === "devPreconditions" ? 0 : 1 });
assertString(report[section].summary, `${label}.${section}.summary`);
}
assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, {
minLength: 4
});
assertArray(report.checks, `${label}.checks`);
assert.ok(report.checks.length >= 3, `${label}.checks must include HTTP, DOM, and Code Agent journey checks`);
assertUnique(report.checks.map((check) => check.id), `${label}.checks ids`);
const checks = new Map(report.checks.map((check, index) => {
const checkLabel = `${label}.checks[${index}]`;
assertObject(check, checkLabel);
for (const field of ["id", "status", "summary"]) {
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
}
assertString(check.id, `${checkLabel}.id`);
assertStatus(check.status, `${checkLabel}.status`);
assertString(check.summary, `${checkLabel}.summary`);
if (Object.hasOwn(check, "evidence")) {
assertStringArray(check.evidence, `${checkLabel}.evidence`, { minLength: 1 });
}
if (Object.hasOwn(check, "observations")) {
assertObject(check.observations, `${checkLabel}.observations`);
}
return [check.id, check];
}));
for (const requiredCheck of [
"live-http-html",
"live-browser-dom",
"live-code-agent-browser-journey"
]) {
assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`);
}
const httpCheck = checks.get("live-http-html");
assert.equal(httpCheck.status, "pass", `${label}.live-http-html.status`);
assert.ok(httpCheck.evidence.includes("HTTP 200"), `${label}.live-http-html.evidence missing HTTP 200`);
assert.ok(httpCheck.evidence.includes("text/html; charset=utf-8"), `${label}.live-http-html.evidence missing content-type`);
const dom = checks.get("live-browser-dom").observations;
assertObject(dom, `${label}.live-browser-dom.observations`);
assert.equal(dom.title, "HWLAB 云工作台", `${label}.live-browser-dom.title`);
assert.equal(dom.workspaceHidden, false, `${label}.live-browser-dom.workspaceHidden`);
assert.equal(dom.gateHidden, true, `${label}.live-browser-dom.gateHidden`);
assert.equal(dom.helpHidden, true, `${label}.live-browser-dom.helpHidden`);
assertObject(dom.coreControlsVisible, `${label}.live-browser-dom.coreControlsVisible`);
for (const control of ["commandInput", "commandSend", "agentChatStatus", "conversationList", "hardwareList"]) {
assert.equal(dom.coreControlsVisible[control], true, `${label}.live-browser-dom.coreControlsVisible.${control}`);
}
const journey = checks.get("live-code-agent-browser-journey");
assert.equal(journey.status, report.status, `${label}.live-code-agent-browser-journey.status`);
assertObject(journey.observations, `${label}.live-code-agent-browser-journey.observations`);
assertObject(journey.observations.request, `${label}.live-code-agent-browser-journey.request`);
assert.equal(journey.observations.request.method, "POST", `${label}.live-code-agent-browser-journey.request.method`);
assert.equal(journey.observations.request.urlPath, "/v1/agent/chat", `${label}.live-code-agent-browser-journey.request.urlPath`);
assertObject(journey.observations.response, `${label}.live-code-agent-browser-journey.response`);
assert.equal(journey.observations.response.status, "completed", `${label}.live-code-agent-browser-journey.response.status`);
for (const field of ["provider", "model", "backend", "traceId"]) {
assertString(journey.observations.response[field], `${label}.live-code-agent-browser-journey.response.${field}`);
}
assert.equal(journey.observations.response.hasReply, true, `${label}.live-code-agent-browser-journey.response.hasReply`);
assert.equal(journey.observations.response.error, null, `${label}.live-code-agent-browser-journey.response.error`);
for (const forbiddenField of ["conversationId", "sessionId", "messageId", "reply"]) {
assert.equal(
Object.hasOwn(journey.observations.response, forbiddenField),
false,
`${label}.live-code-agent-browser-journey.response must not retain ${forbiddenField}`
);
}
assertObject(journey.observations.ui, `${label}.live-code-agent-browser-journey.ui`);
assert.equal(journey.observations.ui.agentChatStatus, "已回复", `${label}.live-code-agent-browser-journey.ui.agentChatStatus`);
assert.equal(journey.observations.ui.completedMessageVisible, true, `${label}.live-code-agent-browser-journey.ui.completedMessageVisible`);
assert.equal(journey.observations.ui.failedMessageVisible, false, `${label}.live-code-agent-browser-journey.ui.failedMessageVisible`);
assertBlockers(report.blockers, `${label}.blockers`);
assertObject(report.safety, `${label}.safety`);
for (const field of ["prodTouched", "servicesRestarted", "heavyE2E", "hardwareWriteApis", "sourceIsDevLive"]) {
assert.equal(report.safety[field], false, `${label}.safety.${field}`);
}
assert.equal(report.safety.liveMode, "browser-user-journey-with-code-agent-post", `${label}.safety.liveMode`);
assert.deepEqual(
report.safety.retainedApiFields,
["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv"],
`${label}.safety.retainedApiFields`
);
assertString(report.safety.statement, `${label}.safety.statement`);
}
function assertDevCloudWorkbenchIdentity(report, label) {
assertObject(report.sourceIdentity, `${label}.sourceIdentity`);
for (const field of ["status", "source", "commitId", "shortCommitId", "ref", "worktreeState", "reportCommitId", "summary"]) {
assert.ok(Object.hasOwn(report.sourceIdentity, field), `${label}.sourceIdentity missing ${field}`);
assertString(report.sourceIdentity[field], `${label}.sourceIdentity.${field}`);
}
assert.ok(["observed", "unknown"].includes(report.sourceIdentity.status), `${label}.sourceIdentity.status`);
assert.ok(["git-head", "environment", "unknown"].includes(report.sourceIdentity.source), `${label}.sourceIdentity.source`);
assert.match(report.sourceIdentity.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.sourceIdentity.commitId`);
assert.match(report.sourceIdentity.shortCommitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.sourceIdentity.shortCommitId`);
assert.ok(["clean", "dirty", "unknown"].includes(report.sourceIdentity.worktreeState), `${label}.sourceIdentity.worktreeState`);
if (report.sourceIdentity.source === "environment") {
assertString(report.sourceIdentity.environmentKey, `${label}.sourceIdentity.environmentKey`);
}
if (report.sourceIdentity.worktreeState === "clean" && report.sourceIdentity.commitId !== "unknown") {
assert.match(report.sourceIdentity.reportCommitId, /^[a-f0-9]{7,40}$/, `${label}.sourceIdentity.reportCommitId`);
assert.equal(report.commitId, report.sourceIdentity.reportCommitId, `${label}.commitId must match clean source reportCommitId`);
} else {
assert.equal(report.sourceIdentity.reportCommitId, "unknown", `${label}.sourceIdentity.reportCommitId`);
assert.equal(report.commitId, "unknown", `${label}.commitId must be unknown when source worktree identity is dirty or unknown`);
}
if (report.sourceIdentity.dirty !== null) {
assertBoolean(report.sourceIdentity.dirty, `${label}.sourceIdentity.dirty`);
assert.equal(report.sourceIdentity.dirty, report.sourceIdentity.worktreeState === "dirty", `${label}.sourceIdentity.dirty`);
}
assertObject(report.runtimeIdentity, `${label}.runtimeIdentity`);
for (const field of ["status", "source", "endpoint", "serviceId", "environment", "healthStatus", "commitId", "commitSource", "imageTag", "observedAt", "summary"]) {
assert.ok(Object.hasOwn(report.runtimeIdentity, field), `${label}.runtimeIdentity missing ${field}`);
assertString(report.runtimeIdentity[field], `${label}.runtimeIdentity.${field}`);
}
assert.ok(["observed", "not_observed"].includes(report.runtimeIdentity.status), `${label}.runtimeIdentity.status`);
assert.equal(report.runtimeIdentity.source, "health-live", `${label}.runtimeIdentity.source`);
assert.equal(report.runtimeIdentity.endpoint, "http://74.48.78.17:16667/health/live", `${label}.runtimeIdentity.endpoint`);
assertTimestamp(report.runtimeIdentity.observedAt, `${label}.runtimeIdentity.observedAt`);
assert.match(report.runtimeIdentity.commitId, /^([a-f0-9]{7,40}|unknown|not_observed)$/, `${label}.runtimeIdentity.commitId`);
if (report.runtimeIdentity.status === "not_observed") {
assert.equal(report.runtimeIdentity.commitId, "not_observed", `${label}.runtimeIdentity.commitId`);
assertString(report.runtimeIdentity.reason, `${label}.runtimeIdentity.reason`);
} else {
assert.ok(
["hwlab-cloud-api", "unknown"].includes(report.runtimeIdentity.serviceId),
`${label}.runtimeIdentity.serviceId`
);
assert.ok(["dev", "unknown"].includes(report.runtimeIdentity.environment), `${label}.runtimeIdentity.environment`);
}
assert.ok(
report.sourceIdentity.commitId === "unknown" ||
report.runtimeIdentity.commitId === "unknown" ||
report.runtimeIdentity.commitId === "not_observed" ||
report.runtimeIdentity.commitSource !== "source-git-head",
`${label}.runtimeIdentity must not present source git HEAD as the deployed live revision`
);
}
async function validateDevM3Report(report, label) {
for (const field of [
"$schema",