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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user