866 lines
36 KiB
JavaScript
866 lines
36 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
classifyCodeAgentBrowserJourney,
|
||
classifyLiveDeploymentIdentity,
|
||
classifyLiveWebAssetIdentity,
|
||
parseSmokeArgs,
|
||
runDevCloudWorkbenchLayoutSmoke,
|
||
runDevCloudWorkbenchQuickPromptsFixtureSmoke,
|
||
runDevCloudWorkbenchSlowBlockerFixtureSmoke,
|
||
runDevCloudWorkbenchStaticSmoke,
|
||
runDevCloudWorkbenchTimeoutFixtureSmoke,
|
||
sanitizeAgentChatBody
|
||
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
|
||
import {
|
||
classifyCodeAgentBrowserFailure,
|
||
classifyCodeAgentChatReadiness
|
||
} from "./src/code-agent-response-contract.mjs";
|
||
import {
|
||
decideSmokeReportWrite,
|
||
smokeCliExitCode
|
||
} from "./dev-cloud-workbench-smoke.mjs";
|
||
import {
|
||
compactLayoutSmokeCliOutput,
|
||
parseLayoutSmokeArgs
|
||
} from "./dev-cloud-workbench-layout-smoke.mjs";
|
||
|
||
const sourceIdentity = Object.freeze({
|
||
status: "observed",
|
||
commitId: "ff4b3f928f76a0b46cd7998a2a865f3e1caf02c8",
|
||
shortCommitId: "ff4b3f928f76",
|
||
reportCommitId: "ff4b3f928f76",
|
||
worktreeState: "clean",
|
||
dirty: false
|
||
});
|
||
|
||
const expectedRuntimeIdentity = Object.freeze({
|
||
status: "observed",
|
||
serviceId: "hwlab-cloud-api",
|
||
source: "deploy/artifact-catalog.dev.json+deploy/deploy.json",
|
||
commitId: "7de6edd",
|
||
imageTag: "7de6edd",
|
||
image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:7de6edd"
|
||
});
|
||
const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||
const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8"));
|
||
const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.mjs", import.meta.url), "utf8");
|
||
|
||
test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => {
|
||
const defaultArgs = parseSmokeArgs([]);
|
||
assert.equal(defaultArgs.mode, "source");
|
||
assert.equal(defaultArgs.url, "http://74.48.78.17:16666/");
|
||
assert.equal(defaultArgs.confirmDevLive, false);
|
||
|
||
const sourceArgs = parseSmokeArgs(["--source"]);
|
||
assert.equal(sourceArgs.mode, "source");
|
||
|
||
const staticAliasArgs = parseSmokeArgs(["--static"]);
|
||
assert.equal(staticAliasArgs.mode, "source");
|
||
|
||
const liveArgs = parseSmokeArgs(["--live", "--confirm-dev-live"]);
|
||
assert.equal(liveArgs.mode, "live");
|
||
assert.equal(liveArgs.confirmDevLive, true);
|
||
assert.equal(liveArgs.url, "http://74.48.78.17:16666/");
|
||
|
||
assert.throws(
|
||
() => parseSmokeArgs(["--live"]),
|
||
/requires explicit --live --confirm-dev-live/u
|
||
);
|
||
});
|
||
|
||
test("source/default workbench report cannot claim DEV-LIVE and documents the confirmed live command", () => {
|
||
const report = runDevCloudWorkbenchStaticSmoke();
|
||
assert.equal(report.mode, "source");
|
||
assert.equal(report.evidenceLevel, "SOURCE");
|
||
assert.equal(report.devLive, false);
|
||
assert.equal(report.safety.devLive, false);
|
||
assert.equal(report.validationCommands.includes("node scripts/dev-cloud-workbench-smoke.mjs --source"), true);
|
||
assert.equal(
|
||
report.validationCommands.includes("node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-live.json"),
|
||
true
|
||
);
|
||
assert.equal(
|
||
report.validationCommands.includes("node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-live.json"),
|
||
false
|
||
);
|
||
assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass");
|
||
});
|
||
|
||
test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => {
|
||
const timeout = classifyCodeAgentBrowserFailure(new Error("request timed out after 180000ms"), { traceId: "trc_timeout" });
|
||
assert.equal(timeout.category, "timeout");
|
||
assert.match(timeout.chineseSummary, /超时/u);
|
||
assert.match(timeout.chineseSummary, /trc_timeout/u);
|
||
|
||
const provider = classifyCodeAgentBrowserFailure(new Error("OpenAI provider returned HTTP 502"), { traceId: "trc_provider" });
|
||
assert.equal(provider.category, "provider");
|
||
assert.match(provider.chineseSummary, /Provider 故障/u);
|
||
assert.match(provider.chineseSummary, /trc_provider/u);
|
||
|
||
const runnerBusy = classifyCodeAgentBrowserFailure(new Error("session_busy: runner already busy"), { traceId: "trc_busy" });
|
||
assert.equal(runnerBusy.category, "runner_busy");
|
||
assert.equal(runnerBusy.blocker, "runner-busy");
|
||
assert.match(runnerBusy.chineseSummary, /Runner 忙碌/u);
|
||
assert.match(runnerBusy.chineseSummary, /trc_busy/u);
|
||
|
||
const sessionBlocked = classifyCodeAgentBrowserFailure(new Error("session_expired: session blocked"), { traceId: "trc_session" });
|
||
assert.equal(sessionBlocked.category, "session_blocked");
|
||
assert.equal(sessionBlocked.blocker, "session-blocked");
|
||
assert.match(sessionBlocked.chineseSummary, /Session 受阻/u);
|
||
assert.match(sessionBlocked.chineseSummary, /trc_session/u);
|
||
|
||
const apiError = classifyCodeAgentBrowserFailure(new Error("API HTTP 500 request_failed"), { traceId: "trc_api" });
|
||
assert.equal(apiError.category, "api_error");
|
||
assert.equal(apiError.blocker, "api-error");
|
||
assert.match(apiError.chineseSummary, /API 错误/u);
|
||
assert.match(apiError.chineseSummary, /trc_api/u);
|
||
|
||
const browser = classifyCodeAgentBrowserFailure(new Error("locator #command-input not found"), { traceId: "trc_browser" });
|
||
assert.equal(browser.category, "browser");
|
||
assert.match(browser.chineseSummary, /浏览器故障/u);
|
||
assert.match(browser.chineseSummary, /trc_browser/u);
|
||
});
|
||
|
||
test("workbench smoke CLI treats skipped browser evidence as blocked and refuses to write reports", () => {
|
||
const report = {
|
||
status: "skip",
|
||
mode: "static-mobile-browser",
|
||
summary: "Mobile browser smoke skipped because Playwright is unavailable."
|
||
};
|
||
const decision = decideSmokeReportWrite(
|
||
{ reportPath: "/tmp/hwlab-dev-gate/dev-cloud-workbench-mobile.json" },
|
||
report,
|
||
{ repoRoot: "/repo", cwd: "/repo" }
|
||
);
|
||
|
||
assert.equal(decision.status, "blocked");
|
||
assert.equal(decision.write, false);
|
||
assert.match(decision.summary, /skipped and does not contain required browser evidence/u);
|
||
assert.equal(smokeCliExitCode(report, decision), 2);
|
||
});
|
||
|
||
test("workbench smoke CLI refuses to overwrite live report with non-live fixture evidence", () => {
|
||
const report = {
|
||
status: "pass",
|
||
mode: "local-agent-fixture-browser"
|
||
};
|
||
const decision = decideSmokeReportWrite(
|
||
{ reportPath: "/tmp/hwlab-dev-gate/dev-cloud-workbench-live.json" },
|
||
report,
|
||
{ repoRoot: "/repo", cwd: "/repo" }
|
||
);
|
||
|
||
assert.equal(decision.status, "blocked");
|
||
assert.equal(decision.write, false);
|
||
assert.match(decision.summary, /Refusing to overwrite \/tmp\/hwlab-dev-gate\/dev-cloud-workbench-live\.json/u);
|
||
assert.equal(smokeCliExitCode(report, decision), 2);
|
||
});
|
||
|
||
test("workbench smoke CLI allows live evidence to write the live report", () => {
|
||
const report = {
|
||
status: "pass",
|
||
mode: "live"
|
||
};
|
||
const decision = decideSmokeReportWrite(
|
||
{ reportPath: "/tmp/hwlab-dev-gate/dev-cloud-workbench-live.json" },
|
||
report,
|
||
{ repoRoot: "/repo", cwd: "/repo" }
|
||
);
|
||
|
||
assert.equal(decision.status, "pass");
|
||
assert.equal(decision.write, true);
|
||
assert.equal(decision.reportPath, "/tmp/hwlab-dev-gate/dev-cloud-workbench-live.json");
|
||
assert.equal(smokeCliExitCode(report, decision), 0);
|
||
});
|
||
|
||
test("smoke args include a DOM-only live read-only mode", () => {
|
||
const args = parseSmokeArgs(["--dom-only", "--url", "http://74.48.78.17:16666/"]);
|
||
assert.equal(args.confirmDevLive, false);
|
||
assert.equal(args.mode, "dom-only");
|
||
assert.equal(args.url, "http://74.48.78.17:16666/");
|
||
assert.equal(args.urlExplicit, true);
|
||
});
|
||
|
||
test("smoke args include reusable layout-only browser mode", () => {
|
||
const local = parseSmokeArgs(["--layout"]);
|
||
assert.equal(local.mode, "layout");
|
||
assert.equal(local.urlExplicit, undefined);
|
||
|
||
const build = parseSmokeArgs(["--layout", "--build"]);
|
||
assert.equal(build.mode, "layout");
|
||
assert.equal(build.build, true);
|
||
|
||
const live = parseSmokeArgs(["--layout", "--url", "http://74.48.78.17:16666/"]);
|
||
assert.equal(live.mode, "layout");
|
||
assert.equal(live.url, "http://74.48.78.17:16666/");
|
||
assert.equal(live.urlExplicit, true);
|
||
});
|
||
|
||
test("dedicated layout smoke CLI supports static build and DEV live shorthand", () => {
|
||
const local = parseLayoutSmokeArgs(["--static"]);
|
||
assert.equal(local.mode, "layout");
|
||
assert.equal(local.urlExplicit, undefined);
|
||
|
||
const build = parseLayoutSmokeArgs(["--build", "--report", "/tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json"]);
|
||
assert.equal(build.mode, "layout");
|
||
assert.equal(build.build, true);
|
||
assert.equal(build.reportPath, "/tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json");
|
||
|
||
const liveDefault = parseLayoutSmokeArgs(["--live"]);
|
||
assert.equal(liveDefault.mode, "layout");
|
||
assert.equal(liveDefault.url, "http://74.48.78.17:16666/");
|
||
assert.equal(liveDefault.urlExplicit, true);
|
||
|
||
const liveExplicit = parseLayoutSmokeArgs(["--live", "--url", "http://74.48.78.17:16666/"]);
|
||
assert.equal(liveExplicit.mode, "layout");
|
||
assert.equal(liveExplicit.url, "http://74.48.78.17:16666/");
|
||
assert.equal(liveExplicit.urlExplicit, true);
|
||
});
|
||
|
||
test("source/default smoke covers #352 resource explorer removal contract", () => {
|
||
const report = runDevCloudWorkbenchStaticSmoke();
|
||
const check = report.checks.find((item) => item.id === "feedback-352-resource-explorer-removed");
|
||
assert.equal(check?.status, "pass");
|
||
assert.equal(check.evidence.includes("aside#resource-explorer absent"), true);
|
||
assert.equal(check.evidence.includes("#explorer-resize absent"), true);
|
||
});
|
||
|
||
test("source/default smoke covers #276 two-column wiring long-table contract", () => {
|
||
const report = runDevCloudWorkbenchStaticSmoke();
|
||
const check = report.checks.find((item) => item.id === "feedback-276-wiring-long-table");
|
||
assert.equal(check?.status, "pass");
|
||
assert.deepEqual(
|
||
check.evidence.filter((item) => ["res_boxsimu_1", "res_boxsimu_2", "DO1 对 DI1"].includes(item)),
|
||
["res_boxsimu_1", "res_boxsimu_2", "DO1 对 DI1"]
|
||
);
|
||
assert.equal(check.evidence.includes("legacy 8-column table forbidden"), true);
|
||
assert.equal(check.evidence.includes("trace/evidence header forbidden"), true);
|
||
assert.equal(check.evidence.includes("状态/证据/trace kept in summary/detail"), true);
|
||
});
|
||
|
||
test("source/default smoke covers #288 gate single-table contract", () => {
|
||
const report = runDevCloudWorkbenchStaticSmoke();
|
||
const check = report.checks.find((item) => item.id === "feedback-288-gate-single-table");
|
||
assert.equal(check?.status, "pass");
|
||
assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false);
|
||
});
|
||
|
||
test("source/default smoke covers Code Agent quick prompt fill-only contract", () => {
|
||
const report = runDevCloudWorkbenchStaticSmoke();
|
||
const check = report.checks.find((item) => item.id === "code-agent-quick-prompts-contract");
|
||
assert.equal(check?.status, "pass");
|
||
assert.equal(check.evidence.includes("HWLAB API:DO1=true 后读 DI1"), true);
|
||
assert.equal(check.evidence.includes("HWLAB API:DO1=false 复核 DI1"), true);
|
||
assert.equal(check.evidence.some((item) => /我点击发送后才确认执行这次写入请求/u.test(item)), true);
|
||
});
|
||
|
||
test("live workbench identity passes only when runtime commit or image tag matches current source", () => {
|
||
assert.equal(
|
||
classifyLiveDeploymentIdentity(sourceIdentity, {
|
||
status: "observed",
|
||
commitId: "7de6edd2c41f",
|
||
imageTag: "c7de474"
|
||
}, expectedRuntimeIdentity).status,
|
||
"pass"
|
||
);
|
||
|
||
assert.equal(
|
||
classifyLiveDeploymentIdentity(sourceIdentity, {
|
||
status: "observed",
|
||
commitId: "unknown",
|
||
imageTag: "7de6edd"
|
||
}, expectedRuntimeIdentity).status,
|
||
"pass"
|
||
);
|
||
|
||
const stale = classifyLiveDeploymentIdentity(sourceIdentity, {
|
||
status: "observed",
|
||
commitId: "c7de474",
|
||
imageTag: "c7de474"
|
||
}, expectedRuntimeIdentity);
|
||
assert.equal(stale.status, "blocked");
|
||
assert.match(stale.summary, /Deployment drift/u);
|
||
assert.match(stale.reason, /does not match current source desired runtime identity/u);
|
||
});
|
||
|
||
test("live workbench identity blocks when source identity is dirty or runtime identity is absent", () => {
|
||
const dirty = classifyLiveDeploymentIdentity(
|
||
{ ...sourceIdentity, worktreeState: "dirty", dirty: true, reportCommitId: "unknown" },
|
||
{ status: "observed", commitId: "7de6edd", imageTag: "7de6edd" },
|
||
expectedRuntimeIdentity
|
||
);
|
||
assert.equal(dirty.status, "blocked");
|
||
assert.match(dirty.reason, /not cleanly attributable/u);
|
||
|
||
const missingRuntime = classifyLiveDeploymentIdentity(sourceIdentity, {
|
||
status: "not_observed",
|
||
commitId: "not_observed",
|
||
imageTag: "not_observed"
|
||
}, expectedRuntimeIdentity);
|
||
assert.equal(missingRuntime.status, "blocked");
|
||
assert.match(missingRuntime.reason, /not observed/u);
|
||
});
|
||
|
||
test("live workbench identity uses deploy desired runtime identity instead of git HEAD", () => {
|
||
const deployed = classifyLiveDeploymentIdentity(sourceIdentity, {
|
||
status: "observed",
|
||
commitId: "7de6edd",
|
||
imageTag: "7de6edd"
|
||
}, expectedRuntimeIdentity);
|
||
assert.equal(deployed.status, "pass");
|
||
assert.equal(deployed.expectedCommit, "7de6edd");
|
||
assert.equal(deployed.expectedReportCommitId, "ff4b3f928f76");
|
||
assert.equal(deployed.expectedSource, "deploy/artifact-catalog.dev.json+deploy/deploy.json");
|
||
});
|
||
|
||
test("live web asset identity blocks stale deployed primary assets", () => {
|
||
const pass = classifyLiveWebAssetIdentity([
|
||
{ path: "index.html", status: "match" },
|
||
{ path: "styles.css", status: "match" },
|
||
{ path: "app.mjs", status: "match" }
|
||
]);
|
||
assert.equal(pass.status, "pass");
|
||
|
||
const stale = classifyLiveWebAssetIdentity([
|
||
{ path: "index.html", status: "match" },
|
||
{ path: "styles.css", status: "mismatch" },
|
||
{ path: "app.mjs", status: "mismatch" }
|
||
]);
|
||
assert.equal(stale.status, "blocked");
|
||
assert.deepEqual(stale.mismatches, ["styles.css", "app.mjs"]);
|
||
assert.match(stale.summary, /Deployment drift/u);
|
||
});
|
||
|
||
test("Code Agent browser classifier blocks provider 502 response shapes", () => {
|
||
const summary = sanitizeAgentChatBody({
|
||
status: "completed",
|
||
provider: "openai-responses",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/openai-responses",
|
||
traceId: "trc_provider_502",
|
||
reply: {
|
||
content: "must not count"
|
||
},
|
||
error: {
|
||
code: "provider_unavailable",
|
||
message: "OpenAI Responses returned HTTP 502: request rejected",
|
||
providerStatus: 502,
|
||
missingEnv: []
|
||
}
|
||
}, { httpStatus: 502 });
|
||
|
||
assert.equal(summary.status, "failed");
|
||
assert.equal(summary.hasReply, false);
|
||
assert.equal(summary.error.code, "provider_unavailable");
|
||
assert.equal(summary.error.providerStatus, 502);
|
||
|
||
const classification = classifyCodeAgentBrowserJourney({
|
||
responseSummary: summary,
|
||
httpOk: false,
|
||
httpStatus: 502,
|
||
ui: {
|
||
agentChatStatus: "DEV-LIVE 回复",
|
||
completedMessageVisible: true,
|
||
failedMessageVisible: false
|
||
}
|
||
});
|
||
assert.equal(classification.status, "blocked");
|
||
assert.equal(classification.blocker, "provider-upstream");
|
||
});
|
||
|
||
test("Code Agent browser classifier treats sanitized 服务受阻 UI as blocked provider evidence", () => {
|
||
const summary = sanitizeAgentChatBody({
|
||
status: "failed",
|
||
provider: "openai-responses",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/openai-responses",
|
||
traceId: "trc_provider_502",
|
||
error: {
|
||
code: "provider_unavailable",
|
||
message: "OpenAI Responses returned HTTP 502: request rejected",
|
||
providerStatus: 502,
|
||
missingEnv: []
|
||
}
|
||
}, { httpStatus: 200 });
|
||
|
||
const classification = classifyCodeAgentBrowserJourney({
|
||
responseSummary: summary,
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "服务受阻",
|
||
completedMessageVisible: false,
|
||
failedMessageVisible: true
|
||
}
|
||
});
|
||
|
||
assert.equal(classification.status, "blocked");
|
||
assert.equal(classification.blocker, "provider-upstream");
|
||
});
|
||
|
||
test("Code Agent browser classifier distinguishes runner busy, session blocked, and API error payloads", () => {
|
||
const base = {
|
||
status: "failed",
|
||
provider: "codex-readonly-runner",
|
||
model: "read-only-tools",
|
||
backend: "hwlab-cloud-api/codex-readonly-runner",
|
||
traceId: "trc_runtime_block"
|
||
};
|
||
|
||
const runnerBusy = classifyCodeAgentBrowserJourney({
|
||
responseSummary: sanitizeAgentChatBody({
|
||
...base,
|
||
error: {
|
||
code: "session_busy",
|
||
message: "Code Agent runner session is already busy"
|
||
}
|
||
}, { httpStatus: 200 }),
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "服务受阻",
|
||
completedMessageVisible: false,
|
||
failedMessageVisible: true
|
||
}
|
||
});
|
||
assert.equal(runnerBusy.blocker, "runner-busy");
|
||
assert.equal(runnerBusy.category, "runner_busy");
|
||
|
||
const sessionBlocked = classifyCodeAgentBrowserJourney({
|
||
responseSummary: sanitizeAgentChatBody({
|
||
...base,
|
||
error: {
|
||
code: "session_expired",
|
||
message: "Code Agent runner session expired"
|
||
}
|
||
}, { httpStatus: 200 }),
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "服务受阻",
|
||
completedMessageVisible: false,
|
||
failedMessageVisible: true
|
||
}
|
||
});
|
||
assert.equal(sessionBlocked.blocker, "session-blocked");
|
||
assert.equal(sessionBlocked.category, "session_blocked");
|
||
|
||
const apiError = classifyCodeAgentBrowserJourney({
|
||
responseSummary: sanitizeAgentChatBody({
|
||
status: "failed",
|
||
traceId: "trc_proxy_timeout",
|
||
error: {
|
||
code: "cloud_api_proxy_failed",
|
||
message: "cloud web proxy failed"
|
||
}
|
||
}, { httpStatus: 502 }),
|
||
httpOk: false,
|
||
httpStatus: 502,
|
||
ui: {
|
||
agentChatStatus: "发送失败",
|
||
completedMessageVisible: false,
|
||
failedMessageVisible: true
|
||
}
|
||
});
|
||
assert.equal(apiError.blocker, "api-error");
|
||
assert.equal(apiError.category, "api_error");
|
||
});
|
||
|
||
test("Code Agent browser classifier consumes structured blocker taxonomy", () => {
|
||
const base = {
|
||
status: "failed",
|
||
provider: "hwlab-skill-cli",
|
||
model: "controlled-m3-io",
|
||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||
traceId: "trc_structured_blocker"
|
||
};
|
||
for (const [code, expected] of [
|
||
["skill_cli_api_base_missing", ["needs-config", "needs_config"]],
|
||
["hwlab_api_unavailable", ["retryable", "retryable"]],
|
||
["m3_readiness_blocked", ["capability-unavailable", "capability_unavailable"]],
|
||
["text_chat_only_fallback", ["text-chat-only-fallback", "fallback"]]
|
||
]) {
|
||
const [blocker, category] = expected;
|
||
const classification = classifyCodeAgentBrowserJourney({
|
||
responseSummary: sanitizeAgentChatBody({
|
||
...base,
|
||
error: {
|
||
code,
|
||
layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness",
|
||
category,
|
||
blocker: {
|
||
code,
|
||
layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness",
|
||
category,
|
||
retryable: category === "retryable",
|
||
userMessage: "结构化中文提示"
|
||
},
|
||
retryable: category === "retryable",
|
||
userMessage: "结构化中文提示",
|
||
route: "/v1/m3/io",
|
||
toolName: "hwlab-agent-runtime.m3-io"
|
||
}
|
||
}, { httpStatus: 200 }),
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "服务受阻",
|
||
completedMessageVisible: false,
|
||
failedMessageVisible: true
|
||
}
|
||
});
|
||
assert.equal(classification.blocker, blocker);
|
||
assert.equal(classification.category, category);
|
||
}
|
||
});
|
||
|
||
test("Code Agent browser classifier consumes completed blocked M3 payload blocker", () => {
|
||
const classification = classifyCodeAgentBrowserJourney({
|
||
responseSummary: sanitizeAgentChatBody({
|
||
status: "completed",
|
||
provider: "hwlab-skill-cli",
|
||
model: "controlled-m3-io",
|
||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||
traceId: "trc_completed_m3_blocked",
|
||
capabilityLevel: "hwlab-api-control-blocked",
|
||
reply: {
|
||
content: "M3 IO Skill CLI result blocked."
|
||
},
|
||
blocker: {
|
||
code: "m3_readiness_blocked",
|
||
layer: "m3-readiness",
|
||
category: "capability_unavailable",
|
||
retryable: false,
|
||
userMessage: "M3 控制链路尚未就绪。"
|
||
}
|
||
}, { httpStatus: 200 }),
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "能力未开放",
|
||
completedMessageVisible: false,
|
||
failedMessageVisible: true
|
||
}
|
||
});
|
||
assert.equal(classification.status, "blocked");
|
||
assert.equal(classification.blocker, "capability-unavailable");
|
||
assert.equal(classification.category, "capability_unavailable");
|
||
});
|
||
|
||
test("Code Agent readiness classifier blocks completed payloads over any non-2xx HTTP status", () => {
|
||
const completedPayload = {
|
||
conversationId: "cnv_completed_non_2xx",
|
||
sessionId: "cnv_completed_non_2xx",
|
||
messageId: "msg_completed_non_2xx",
|
||
status: "completed",
|
||
provider: "openai-responses",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/openai-responses",
|
||
traceId: "trc_completed_non_2xx",
|
||
reply: {
|
||
content: "must only count over HTTP 2xx"
|
||
}
|
||
};
|
||
|
||
const accepted = classifyCodeAgentChatReadiness(completedPayload, { realDevLive: true, httpStatus: 200 });
|
||
assert.equal(accepted.status, "blocked");
|
||
assert.equal(accepted.devLiveReplyPass, false);
|
||
assert.equal(accepted.blocker, "untrusted-completion");
|
||
|
||
for (const httpStatus of [400, 401, 403, 429, 500, 502, 503, 504]) {
|
||
const blocked = classifyCodeAgentChatReadiness(completedPayload, { realDevLive: true, httpStatus });
|
||
assert.equal(blocked.status, "blocked", `HTTP ${httpStatus} must block`);
|
||
assert.equal(blocked.blocker, "provider-upstream", `HTTP ${httpStatus} blocker`);
|
||
assert.equal(blocked.devLiveReplyPass, false, `HTTP ${httpStatus} must not pass DEV-LIVE`);
|
||
assert.equal(blocked.providerStatus, httpStatus, `HTTP ${httpStatus} providerStatus`);
|
||
assert.match(blocked.reason, /HTTP non-2xx|upstream response/u, `HTTP ${httpStatus} reason`);
|
||
}
|
||
});
|
||
|
||
test("Code Agent browser classifier accepts only completed HTTP success plus completed UI and real provider evidence", () => {
|
||
const summary = sanitizeAgentChatBody({
|
||
status: "completed",
|
||
provider: "codex-stdio",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/codex-mcp-stdio",
|
||
traceId: "trc_completed",
|
||
reply: {
|
||
content: "ok"
|
||
},
|
||
longLivedSessionGate: {
|
||
status: "pass",
|
||
pass: true
|
||
}
|
||
}, { httpStatus: 200 });
|
||
|
||
const pass = classifyCodeAgentBrowserJourney({
|
||
responseSummary: summary,
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "DEV-LIVE 回复",
|
||
completedMessageVisible: true,
|
||
failedMessageVisible: false
|
||
}
|
||
});
|
||
assert.equal(pass.status, "pass");
|
||
|
||
const blocked = classifyCodeAgentBrowserJourney({
|
||
responseSummary: summary,
|
||
httpOk: false,
|
||
httpStatus: 500,
|
||
ui: {
|
||
agentChatStatus: "DEV-LIVE 回复",
|
||
completedMessageVisible: true,
|
||
failedMessageVisible: false
|
||
}
|
||
});
|
||
assert.equal(blocked.status, "blocked");
|
||
assert.equal(blocked.blocker, "provider-upstream");
|
||
});
|
||
|
||
test("Code Agent browser classifier blocks completed payloads without backend evidence", () => {
|
||
const summary = sanitizeAgentChatBody({
|
||
status: "completed",
|
||
provider: "openai-responses",
|
||
model: "gpt-5.5",
|
||
traceId: "trc_missing_backend",
|
||
reply: {
|
||
content: "must not count"
|
||
}
|
||
}, { httpStatus: 200 });
|
||
|
||
const classification = classifyCodeAgentBrowserJourney({
|
||
responseSummary: summary,
|
||
httpOk: true,
|
||
httpStatus: 200,
|
||
ui: {
|
||
agentChatStatus: "DEV-LIVE 回复",
|
||
completedMessageVisible: true,
|
||
failedMessageVisible: false
|
||
}
|
||
});
|
||
|
||
assert.equal(classification.status, "blocked");
|
||
assert.equal(classification.blocker, "untrusted-completion");
|
||
});
|
||
|
||
test("local Code Agent timeout fixture keeps failed UI state, trace evidence, and retry input", async () => {
|
||
const report = await runDevCloudWorkbenchTimeoutFixtureSmoke({
|
||
responseDelayMs: 120,
|
||
timeoutConfigMs: 50
|
||
});
|
||
|
||
if (report.status === "skip") {
|
||
assert.match(report.summary, /Playwright is unavailable/u);
|
||
return;
|
||
}
|
||
|
||
assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2));
|
||
assert.equal(report.evidenceLevel, "SOURCE");
|
||
assert.equal(report.devLive, false);
|
||
const timeoutCheck = report.checks.find((check) => check.id === "local-agent-timeout-fixture-failed-state");
|
||
assert.equal(timeoutCheck?.status, "pass");
|
||
assert.equal(timeoutCheck.observations.ui.agentChatStatus, "等待超时");
|
||
assert.equal(timeoutCheck.observations.ui.failedMessageVisible, true);
|
||
assert.equal(timeoutCheck.observations.ui.failedMessageHasTrace, true);
|
||
assert.equal(timeoutCheck.observations.ui.failedMessageHasChineseTimeout, true);
|
||
assert.equal(timeoutCheck.observations.ui.retryInputPreserved, true);
|
||
assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false);
|
||
});
|
||
|
||
test("local slow structured blocker fixture preserves trace, pending state, and retry input", async () => {
|
||
const report = await runDevCloudWorkbenchSlowBlockerFixtureSmoke({
|
||
responseDelayMs: 5200
|
||
});
|
||
|
||
if (report.status === "skip") {
|
||
assert.match(report.summary, /Playwright is unavailable/u);
|
||
return;
|
||
}
|
||
|
||
assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2));
|
||
assert.equal(report.evidenceLevel, "SOURCE");
|
||
assert.equal(report.devLive, false);
|
||
const blockerCheck = report.checks.find((check) => check.id === "local-agent-fixture-slow-structured-blocker");
|
||
assert.equal(blockerCheck?.status, "pass");
|
||
assert.equal(blockerCheck.observations.ui.agentChatStatus, "Runner 受阻");
|
||
assert.equal(blockerCheck.observations.ui.failedMessageVisible, true);
|
||
assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true);
|
||
assert.equal(blockerCheck.observations.ui.retryInputPreserved, true);
|
||
assert.equal(blockerCheck.observations.ui.retryInputValue, "列出你能使用的所有skill");
|
||
assert.equal(blockerCheck.observations.prompt.legacyWindow.permanentFailureAround4500ms, false);
|
||
assert.equal(blockerCheck.observations.prompt.legacyWindow.pendingChineseVisible, true);
|
||
assert.equal(blockerCheck.observations.prompt.response.error.code, "skills_unavailable");
|
||
assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked");
|
||
});
|
||
|
||
test("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => {
|
||
const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke();
|
||
if (report.status === "skip") {
|
||
assert.match(report.summary, /Playwright is unavailable/u);
|
||
return;
|
||
}
|
||
|
||
assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2));
|
||
assert.equal(report.evidenceLevel, "SOURCE");
|
||
assert.equal(report.devLive, false);
|
||
assert.equal(report.safety.codeAgentPostSentByQuickPrompt, false);
|
||
assert.equal(report.safety.hardwareWriteApis, false);
|
||
|
||
const fill = report.checks.find((check) => check.id === "quick-prompts-fill-input");
|
||
const noAutosend = report.checks.find((check) => check.id === "quick-prompts-write-no-autosend");
|
||
const copy = report.checks.find((check) => check.id === "quick-prompts-copy-boundary");
|
||
const layout = report.checks.find((check) => check.id === "quick-prompts-mobile-layout");
|
||
assert.equal(fill?.status, "pass");
|
||
assert.equal(noAutosend?.status, "pass");
|
||
assert.equal(copy?.status, "pass");
|
||
assert.equal(layout?.status, "pass");
|
||
assert.equal(noAutosend.observations.every((item) => item.agentPostCountDelta === 0), true);
|
||
assert.equal(noAutosend.observations.every((item) => item.explicitSendRequired === true), true);
|
||
assert.equal(copy.observations.every((item) => item.copyBoundaryOk === true), true);
|
||
assert.equal(layout.observations.some((item) => item.viewport.width === 390 && item.layoutOk), true);
|
||
});
|
||
|
||
test("layout smoke verifies desktop and mobile default workbench geometry without resource explorer", async () => {
|
||
const report = await runDevCloudWorkbenchLayoutSmoke();
|
||
if (report.status === "skip") {
|
||
assert.match(report.summary, /Playwright is unavailable/u);
|
||
return;
|
||
}
|
||
|
||
assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2));
|
||
assert.equal(report.issue, "pikasTech/HWLAB#273");
|
||
assert.equal(report.taskId, "dev-cloud-workbench-layout");
|
||
assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout");
|
||
assert.equal(report.sourceMode, "source-static");
|
||
assert.equal(report.evidenceLevel, "SOURCE");
|
||
assert.equal(report.devLive, false);
|
||
assert.equal(report.devPreconditions.status, "not_applicable");
|
||
assert.equal(report.localSmoke.commands.includes("npm run web:check"), true);
|
||
for (const command of [
|
||
"node --check scripts/dev-cloud-workbench-layout-smoke.mjs",
|
||
"npm run web:layout",
|
||
"npm run web:layout:build",
|
||
"npm run web:layout:live"
|
||
]) {
|
||
assert.equal(report.validationCommands.includes(command), true, `missing ${command}`);
|
||
}
|
||
for (const id of [
|
||
"layout-desktop-default",
|
||
"layout-narrow-desktop-default",
|
||
"layout-mobile-default",
|
||
"layout-feedback-352-resource-explorer-removed",
|
||
"layout-left-sidebar-collapse",
|
||
"layout-gate-desktop",
|
||
"layout-gate-narrow-desktop",
|
||
"layout-gate-mobile"
|
||
]) {
|
||
assert.equal(report.checks.find((check) => check.id === id)?.status, "pass", id);
|
||
}
|
||
|
||
assert.deepEqual(
|
||
report.viewports.map((viewport) => `${viewport.width}x${viewport.height}`),
|
||
["1366x768", "1024x768", "390x844"]
|
||
);
|
||
assert.equal(Array.isArray(report.failures), true);
|
||
assert.equal(Array.isArray(report.skipped), true);
|
||
assert.equal(report.artifacts.reportPath, null);
|
||
assert.match(report.reportLifecycle.summary, /cannot be used as DEV-LIVE evidence/u);
|
||
assert.match(report.devPreconditions.summary, /not required for SOURCE\/static gates/u);
|
||
assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true);
|
||
assert.match(report.safety.statement, /does not send Code Agent chat, call M3 IO/u);
|
||
assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass");
|
||
assert.equal(report.checks.find((check) => check.id === "layout-feedback-352-resource-explorer-removed")?.status, "pass");
|
||
assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip");
|
||
|
||
const desktopDefault = report.checks.find((check) => check.id === "layout-desktop-default")?.observations;
|
||
const resourceExplorerRemoval = report.checks.find((check) => check.id === "layout-feedback-352-resource-explorer-removed")?.observations;
|
||
assert.equal(resourceExplorerRemoval.forbiddenSelectors.includes("#explorer-resize"), true);
|
||
assert.equal(resourceExplorerRemoval.forbiddenCopy.includes("常用能力"), true);
|
||
assert.equal(resourceExplorerRemoval.coverage.desktop.covered, true);
|
||
assert.equal(resourceExplorerRemoval.coverage.mobile.removedVisibleCopyAbsent, true);
|
||
assert.equal(desktopDefault.removedSelectorsAbsent, true);
|
||
assert.equal(desktopDefault.removedVisibleCopyAbsent, true);
|
||
assert.equal(desktopDefault.resourceExplorerRemovalGuard, true);
|
||
assert.equal(desktopDefault.keyTargetsReachable, true);
|
||
assert.equal(desktopDefault.wiring.longTableOk, true);
|
||
assert.equal(desktopDefault.wiring.noHorizontalScroll, true);
|
||
assert.equal(desktopDefault.wiring.headers.length, 2);
|
||
assert.deepEqual(desktopDefault.wiring.devicePair, ["res_boxsimu_1", "res_boxsimu_2"]);
|
||
assert.equal(desktopDefault.wiring.ioPairs.some((row) => row.join("|") === "DO1|DI1"), true);
|
||
assert.deepEqual(desktopDefault.wiring.legacyHeaderHits, []);
|
||
assert.deepEqual(desktopDefault.wiring.metadataColumnHits, []);
|
||
assert.equal(desktopDefault.wiring.metadataInSummary, true);
|
||
assert.equal(desktopDefault.wiring.metadataInDetails, true);
|
||
assert.equal(desktopDefault.wiring.horizontalScroll.panelScrollWidth <= desktopDefault.wiring.horizontalScroll.panelClientWidth + 2, true);
|
||
assert.equal(desktopDefault.noHorizontalOverflow.right, true);
|
||
assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false);
|
||
assert.equal(Object.hasOwn(desktopDefault.semanticOverlapChecks[0].boxes["#m3-control-form"], "text"), false);
|
||
assert.equal(desktopDefault.failures.length, 0);
|
||
assert.equal(desktopDefault.semanticOverlapChecks.every((check) => !check.overlaps), true);
|
||
assert.equal(desktopDefault.overflowChecks.every((check) => check.ok), true);
|
||
|
||
const mobileDefault = report.checks.find((check) => check.id === "layout-mobile-default")?.observations;
|
||
assert.equal(mobileDefault.removedSelectorsAbsent, true);
|
||
assert.equal(mobileDefault.removedVisibleCopyAbsent, true);
|
||
assert.equal(mobileDefault.resourceExplorerRemovalGuard, true);
|
||
assert.equal(mobileDefault.keyTargetsReachable, true);
|
||
assert.equal(mobileDefault.wiring.longTableOk, true);
|
||
assert.equal(mobileDefault.wiring.noHorizontalScroll, true);
|
||
assert.deepEqual(mobileDefault.wiring.devicePair, ["res_boxsimu_1", "res_boxsimu_2"]);
|
||
assert.equal(mobileDefault.wiring.ioPairs.some((row) => row.join("|") === "DO1|DI1"), true);
|
||
assert.deepEqual(mobileDefault.wiring.legacyHeaderHits, []);
|
||
assert.deepEqual(mobileDefault.wiring.metadataColumnHits, []);
|
||
assert.equal(mobileDefault.wiring.metadataInSummary, true);
|
||
assert.equal(mobileDefault.wiring.metadataInDetails, true);
|
||
assert.equal(mobileDefault.noHorizontalOverflow.right, true);
|
||
|
||
const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations;
|
||
assert.equal(leftCollapse.status, "pass");
|
||
assert.equal(leftCollapse.coverage.desktop.widthReclaimed, true);
|
||
assert.equal(leftCollapse.coverage["narrow-desktop"].widthReclaimed, true);
|
||
assert.equal(leftCollapse.coverage.mobile.widthReclaimed, true);
|
||
assert.equal(leftCollapse.coverage.desktop.collapsed.keyTargetsReachable, true);
|
||
assert.equal(leftCollapse.coverage.mobile.collapsed.keyTargetsReachable, true);
|
||
assert.equal(leftCollapse.coverage.desktop.restoredCollapsed.collapsed, true);
|
||
assert.equal(leftCollapse.coverage.mobile.expanded.routeButtonsVisible, true);
|
||
|
||
const compact = compactLayoutSmokeCliOutput(report);
|
||
assert.equal(compact.status, "pass");
|
||
assert.equal(compact.artifacts.screenshotCount, 21);
|
||
assert.equal(compact.failures.length, 0);
|
||
assert.equal(compact.blockers.length, 0);
|
||
assert.equal(JSON.stringify(compact).includes('"checks"'), false);
|
||
assert.equal(JSON.stringify(compact).includes('"boxes"'), false);
|
||
assert.equal(JSON.stringify(compact).includes('"text"'), false);
|
||
});
|
||
|
||
test("repo-owned web checks expose source build and DEV live layout smoke gates", () => {
|
||
assert.equal(rootPackage.scripts["web:check"], "node web/hwlab-cloud-web/scripts/check.mjs");
|
||
assert.equal(
|
||
rootPackage.scripts["web:layout"],
|
||
"node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json"
|
||
);
|
||
assert.equal(
|
||
rootPackage.scripts["web:layout:build"],
|
||
"node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json"
|
||
);
|
||
assert.equal(
|
||
rootPackage.scripts["web:layout:live"],
|
||
"node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-live.json"
|
||
);
|
||
assert.match(rootPackage.scripts.check, /node --check scripts\/dev-cloud-workbench-layout-smoke\.mjs/u);
|
||
assert.match(rootPackage.scripts.check, /node web\/hwlab-cloud-web\/scripts\/check\.mjs/u);
|
||
assert.equal(cloudWebPackage.scripts.layout.includes("--static"), true);
|
||
assert.equal(cloudWebPackage.scripts["layout:build"].includes("--build"), true);
|
||
assert.equal(cloudWebPackage.scripts["layout:live"].includes("--live --url http://74.48.78.17:16666/"), true);
|
||
assert.match(cloudWebCheckSource, /runDevCloudWorkbenchLayoutSmoke/u);
|
||
assert.match(cloudWebCheckSource, /tmp\/dev-cloud-workbench-layout-web-check\.json/u);
|
||
assert.match(cloudWebCheckSource, /layout-feedback-352-resource-explorer-removed/u);
|
||
assert.match(cloudWebCheckSource, /compactLayoutSmokeFailures/u);
|
||
});
|