test: capture deployed workbench user journey evidence
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"$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": "ab252f5",
|
||||
"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-22T17:52:04.341Z",
|
||||
"evidenceLevel": "DEV-LIVE-BROWSER",
|
||||
"devLive": true,
|
||||
"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-1779472320347-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-1779472320347-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-1779472320347-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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -164,6 +164,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 {
|
||||
@@ -346,6 +350,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",
|
||||
@@ -353,7 +373,7 @@ async function runLiveSmoke(args) {
|
||||
status,
|
||||
checks,
|
||||
blockers,
|
||||
evidenceLevel: status === "pass" ? "DEV-LIVE-READONLY" : "BLOCKED",
|
||||
evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED",
|
||||
devLive: status === "pass",
|
||||
safety: {
|
||||
prodTouched: false,
|
||||
@@ -361,30 +381,95 @@ async function runLiveSmoke(args) {
|
||||
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 }) {
|
||||
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: "ab252f5",
|
||||
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,
|
||||
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,
|
||||
@@ -848,7 +933,19 @@ async function fetchText(url) {
|
||||
}
|
||||
|
||||
function liveHtmlLooksLikeWorkbench(html) {
|
||||
return /HWLAB 云工作台/u.test(html) && labelsPresent(html, workbenchMarkers) && !/<body[^>]*>\s*<(?:main|section)[^>]*(?:gate|diagnostics|status|help)/iu.test(html);
|
||||
const workspaceTag = firstTagForDataView(html, "workspace");
|
||||
const gateTag = firstTagForDataView(html, "gate");
|
||||
const helpTag = firstTagForDataView(html, "help");
|
||||
return (
|
||||
/HWLAB 云工作台/u.test(html) &&
|
||||
labelsPresent(html, workbenchMarkers) &&
|
||||
workspaceTag !== null &&
|
||||
!/\bhidden\b/u.test(workspaceTag) &&
|
||||
gateTag !== null &&
|
||||
/\bhidden\b/u.test(gateTag) &&
|
||||
helpTag !== null &&
|
||||
/\bhidden\b/u.test(helpTag)
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectLiveDom(url) {
|
||||
@@ -871,19 +968,35 @@ async function inspectLiveDom(url) {
|
||||
const dom = await page.evaluate(() => {
|
||||
const workspace = document.querySelector('[data-view="workspace"]');
|
||||
const gate = document.querySelector('[data-view="gate"]');
|
||||
const diagnostics = document.querySelector('[data-side-panel="diagnostics"]');
|
||||
const help = document.querySelector('[data-view="help"]');
|
||||
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,
|
||||
diagnosticsHidden: diagnostics ? diagnostics.hidden : null,
|
||||
helpHidden: help ? help.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 =
|
||||
@@ -892,12 +1005,13 @@ async function inspectLiveDom(url) {
|
||||
dom.htmlOverflow === "hidden" &&
|
||||
dom.workspaceHidden === false &&
|
||||
dom.gateHidden === true &&
|
||||
dom.diagnosticsHidden === true &&
|
||||
dom.helpHidden === true &&
|
||||
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
|
||||
};
|
||||
@@ -912,6 +1026,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 {
|
||||
@@ -1204,11 +1483,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."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -115,6 +116,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",
|
||||
@@ -456,6 +469,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(
|
||||
@@ -1274,6 +1294,167 @@ 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",
|
||||
"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`);
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
async function validateDevM3Report(report, label) {
|
||||
for (const field of [
|
||||
"$schema",
|
||||
|
||||
Reference in New Issue
Block a user