Merge remote-tracking branch 'origin/main' into fix/durable-runtime-auth-blocker-003
This commit is contained in:
@@ -25,6 +25,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
|
||||
- DEV 运行态、端口、k3s 和 DB DNS 边界:[docs/reference/dev-runtime-boundary.md](docs/reference/dev-runtime-boundary.md)
|
||||
- 部署正规化、镜像发布、回滚和 Cloud Web 路径:[docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)
|
||||
- Code Agent 对话就绪与真实回复判定:[docs/reference/code-agent-chat-readiness.md](docs/reference/code-agent-chat-readiness.md)
|
||||
- MVP E2E 验收测试与带编号测试报告 issue 规则:[docs/reference/MVP-e2e-acceptance.md](docs/reference/MVP-e2e-acceptance.md)
|
||||
- 指挥官协作、PR 和 runner 交接:[docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md)
|
||||
- M3 闭环发布运行手册:[docs/reference/m3-loop-rollout-runbook.md](docs/reference/m3-loop-rollout-runbook.md)
|
||||
- runner issue 可见性与 prompt 交接:[docs/reference/runner-issue-visibility-handoff.md](docs/reference/runner-issue-visibility-handoff.md)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# HWLAB MVP E2E 验收测试与报告 issue 规则
|
||||
|
||||
本文定义 HWLAB MVP 的用户可读、Agent 可执行验收测试规格。它按 `cli-spec` 的 `TEST.md` 风格组织用例,但报告产物不写入 `docs/` 或 `reports/`;每轮验收必须创建一个带编号的 GitHub 测试报告 issue。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 当前 DEV 浏览器入口固定为 `http://74.48.78.17:16666/`。
|
||||
- 当前 DEV API、edge 和 live health 入口固定为 `http://74.48.78.17:16667/` 与 `http://74.48.78.17:16667/health/live`。
|
||||
- 当前 MVP 上位约束是 `DC-DCSN-P0-2026-003` / [pikasTech/HWLAB#78](https://github.com/pikasTech/HWLAB/issues/78):M3 虚拟硬件可信闭环必须证明 `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`。
|
||||
- 当前默认前端方向以 [pikasTech/HWLAB#99](https://github.com/pikasTech/HWLAB/issues/99) 为准:`/` 必须是类 VS Code 的 Cloud Workbench,不是 Gate、status 或 diagnostics 首页。
|
||||
|
||||
## 报告 issue 规则
|
||||
|
||||
测试报告产物必须落到 GitHub issue,不进入长期文档目录,也不提交一次性报告文件。
|
||||
|
||||
- 报告 issue 标题格式:`[MVP-E2E-RPT-<NNN>] HWLAB MVP E2E 验收测试报告:<简短范围>`。
|
||||
- `<NNN>` 从 `001` 开始递增;创建前先搜索 open 和 closed issue 中已有的 `MVP-E2E-RPT-` 编号,选择下一个编号。
|
||||
- 报告 issue 必须包含:测试日期、测试人或 runner、源文档路径、本次目标、DEV URL、observed commit/revision/tag/digest、每个 `T{x}` 用例的状态、blocker class、关键截图或截图附件、operation/trace/audit/evidence id、最终结论和后续 issue。
|
||||
- 如果 Playwright 截图无法直接上传到 issue,报告 issue 必须记录截图文件路径、文件名、SHA 或可审查的 artifact URL;不能只写“已截图”。
|
||||
- 任一用例为 `failed` 或 `blocked` 时,总结论只能是 failed/blocked,不得写 pass。
|
||||
|
||||
推荐报告 issue body 结构:
|
||||
|
||||
```markdown
|
||||
## 测试范围
|
||||
|
||||
- 源文档:docs/reference/MVP-e2e-acceptance.md
|
||||
- 报告编号:MVP-E2E-RPT-<NNN>
|
||||
- DEV Cloud Web:
|
||||
- DEV API/edge:
|
||||
- observed frontend revision:
|
||||
- observed API revision:
|
||||
- runner / tester:
|
||||
|
||||
## 结果总览
|
||||
|
||||
| 用例 | 状态 | 证据等级 | blocker class | 截图/证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T1 | pass/failed/blocked | DEV-LIVE/SOURCE/DRY-RUN/BLOCKED | | |
|
||||
|
||||
## 详细记录
|
||||
|
||||
## T1
|
||||
|
||||
- 操作:
|
||||
- 观察:
|
||||
- 截图:
|
||||
- 关键 ID:
|
||||
- 结论:
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 未触碰 PROD:
|
||||
- 未读取 secret 明文:
|
||||
- 未重启 UniDesk / Code Queue / backend-core:
|
||||
- 未把 UniDesk runtime 当 HWLAB runtime:
|
||||
```
|
||||
|
||||
## 证据等级
|
||||
|
||||
- `DEV-LIVE`:真实 DEV `16666/16667` 上的浏览器、API、runtime 或短生命周期授权操作证据。
|
||||
- `SOURCE`:源代码、静态检查、合同校验或未部署构建证据。
|
||||
- `LOCAL`:本地浏览器、fixture 或本地服务证据。
|
||||
- `DRY-RUN`:显式 dry-run 输出,不触碰 DEV runtime。
|
||||
- `BLOCKED`:因为环境、权限、运行态、agent、DB、evidence 或安全边界无法继续。
|
||||
|
||||
禁止把 `SOURCE`、`LOCAL`、`DRY-RUN`、fixture、edge-only health、前端静态状态或 UniDesk runtime 代理写成 M3 或 MVP `DEV-LIVE` 通过。
|
||||
|
||||
## 验收用例
|
||||
|
||||
## T1 DEV 入口与运行边界
|
||||
|
||||
阅读 `AGENTS.md`,然后用浏览器或 Playwright 手动测试以下内容:打开 `http://74.48.78.17:16666/`,确认默认页是 HWLAB Cloud Workbench,不是 Gate、status、help 或 diagnostics;访问 `http://74.48.78.17:16667/health` 和 `http://74.48.78.17:16667/health/live`,确认返回 HWLAB DEV API/edge identity。报告必须写明 public `:6666/:6667` 未作为当前验收入口,UniDesk backend、provider-gateway 和 microservice proxy 未作为 HWLAB runtime 替代。
|
||||
|
||||
通过标准:
|
||||
|
||||
- `16666` 根页可由浏览器打开,页面标题、主导航或首屏内容明确指向 HWLAB 云工作台。
|
||||
- `16667/health` 或 `16667/health/live` 返回 JSON,包含 HWLAB service identity 和 dev environment。
|
||||
- 报告 issue 附桌面首屏截图和 API/health 摘要。
|
||||
|
||||
## T2 Cloud Workbench 首屏、布局与中文 UX
|
||||
|
||||
阅读 `AGENTS.md`,然后用浏览器或 Playwright 手动测试以下内容:分别在桌面视口和 `390x844` 移动视口截图,确认左侧资源/功能导航、中间 Agent 对话/trace/输入区、右侧硬件状态和控制/接线/可信记录区域可见;外层页面不滚动,滚动只发生在内部面板;用户可见文案中文优先;使用说明是内部入口且由 Markdown 渲染,不是默认首页。
|
||||
|
||||
通过标准:
|
||||
|
||||
- 默认页显示工作台,不显示 Gate/M0-M5/验收报告作为首屏主体。
|
||||
- 桌面和移动视口下 `html/body` 或等价应用根节点没有页面级滚动路径。
|
||||
- 关键中文标签可见,必要机器标识可以保留原文。
|
||||
- 报告 issue 附桌面、移动和使用说明内部页截图。
|
||||
|
||||
## T3 M3 虚拟硬件可信闭环
|
||||
|
||||
阅读 `AGENTS.md`,然后用浏览器或 Playwright 手动测试以下内容:在明确授权的 DEV live 窗口内,通过工作台或等价受控入口执行 `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` 的 true/false 循环。报告必须记录两个 distinct box-simu、两个 distinct gateway-simu、一个 patch-panel、patch-panel-owned wiring、operationId、traceId、auditId 和 evidenceId。
|
||||
|
||||
通过标准:
|
||||
|
||||
- 写 `DO1=true` 后,`DI1=true` 经 `hwlab-patch-panel` 同步可见;写 `DO1=false` 后,`DI1=false` 同步可见。
|
||||
- 证据链明确归因到 `hwlab-patch-panel`,没有 box loopback、前端直改状态或 UniDesk runtime 替代。
|
||||
- operation、trace、audit 和 evidence id 能互相指向同一轮操作。
|
||||
- 未获授权、目标缺失、持久化 blocked 或任一关键 ID 缺失时,必须标为 `BLOCKED`,不得 claim M3 PASS。
|
||||
|
||||
## T4 Code Agent 真实 runner 能力
|
||||
|
||||
阅读 `AGENTS.md`,然后用浏览器或 Playwright 手动测试以下内容:在 `16666` 工作台 Agent 区输入 `pwd` 和“列出你可用的 skills”,确认返回来自真实 Codex runner、workspace、tool calls 和 skills 注入,而不是 `openai-responses` text-chat-only。随后让 Agent 通过 `hwlab-cloud-api` 执行同一 M3 硬件任务,确认 agent trace 引用对应 hardware operation 和 audit。
|
||||
|
||||
通过标准:
|
||||
|
||||
- `pwd` 返回真实 runner 工作目录或结构化 runner blocker。
|
||||
- skills 查询返回真实 skill 列表、skill 发现输出或结构化 skills blocker。
|
||||
- runner trace 中能看到工具调用、workspace 证据和 hardware operation/audit 关联。
|
||||
- 如果只能文本聊天、provider unavailable、无工具调用或无 workspace 证据,必须标为 `BLOCKED/agent-runtime` 或 `BLOCKED/credential`。
|
||||
|
||||
## T5 Audit、Evidence 与持久化
|
||||
|
||||
阅读 `AGENTS.md`,然后用浏览器或 Playwright 手动测试以下内容:确认直接 M3 操作和 Agent M3 操作都能在工作台、API 或 CLI 输出中看到 hardware audit、agent trace 和 evidence record。最小 evidence record 至少包含 `sessionId`、worker image 或 digest、`skillsCommitId`、`skillsTreeSha` 或文件清单 hash、`workspaceMode`、`agentTraceId`、`operationId`、`auditId`、`result` 和 `createdAt`。
|
||||
|
||||
通过标准:
|
||||
|
||||
- hardware audit 由 HWLAB cloud/gateway/patch-panel 硬件链路生成,agent 自述不能替代 audit。
|
||||
- evidence 中不包含 secret/token 明文。
|
||||
- durable DB 或 evidence adapter 仍 blocked 时,报告必须标注准确 blocker,不得写 trusted green。
|
||||
|
||||
## T6 报告 issue 创建与编号
|
||||
|
||||
阅读 `AGENTS.md`,然后用浏览器或 Playwright 手动测试以下内容:按本文“报告 issue 规则”创建一个带递增编号的测试报告 issue,标题使用 `[MVP-E2E-RPT-<NNN>]` 前缀。报告 issue 必须挂载本次所有截图、关键 JSON 摘要、测试结论和后续 issue 链接;不得把测试报告正文写入 `docs/reference/`,不得提交一次性报告文件来替代 issue。
|
||||
|
||||
通过标准:
|
||||
|
||||
- 报告 issue 存在,编号未与历史报告冲突。
|
||||
- 每个 `T{x}` 都有状态、证据等级和简短证据。
|
||||
- 最终结论与各用例状态一致。
|
||||
- 安全边界明确写出:未触碰 PROD、未读取 secret 明文、未重启 UniDesk / Code Queue / backend-core、未用 UniDesk runtime 替代 HWLAB runtime。
|
||||
|
||||
## 停止条件
|
||||
|
||||
出现以下情况时停止后续 live 或 mutating 测试,并在报告 issue 中标为 `BLOCKED` 或 `failed`:
|
||||
|
||||
- 入口不是 `16666/16667`,或命中非 HWLAB DEV runtime。
|
||||
- 需要读取或打印 Secret/token 明文。
|
||||
- 需要触碰 PROD。
|
||||
- 需要重启 UniDesk、Code Queue、backend-core 或无关基础设施。
|
||||
- M3 目标不能证明两个 distinct box-simu、两个 distinct gateway-simu 和 patch-panel-owned wiring。
|
||||
- agent 只能文本聊天,不能证明真实 runner/workspace/tool/skills。
|
||||
- 任一结果要求把 SOURCE、LOCAL、DRY-RUN、fixture 或 edge-only 证据升级为 DEV-LIVE。
|
||||
|
||||
## 稳定来源
|
||||
|
||||
- [architecture.md](architecture.md):MVP 边界、M3 trusted loop 和证据等级。
|
||||
- [dev-runtime-boundary.md](dev-runtime-boundary.md):`16666/16667`、D601 k3s 和运行态边界。
|
||||
- [cloud-workbench.md](cloud-workbench.md):Cloud Workbench 默认首页和 UX 约束。
|
||||
- [code-agent-chat-readiness.md](code-agent-chat-readiness.md):Code Agent 真实回复和 provider/runner blocker 判定。
|
||||
- [m3-loop-rollout-runbook.md](m3-loop-rollout-runbook.md):M3 live 操作授权、证据和失败分类。
|
||||
@@ -23,6 +23,7 @@
|
||||
| 部署正规化、artifact 发布、回滚和 Cloud Web rollout | [deployment-publish.md](deployment-publish.md) |
|
||||
| Cloud Workbench 默认界面和 UX 边界 | [cloud-workbench.md](cloud-workbench.md) |
|
||||
| Code Agent chat 同源通道 readiness 与真实回复判定 | [code-agent-chat-readiness.md](code-agent-chat-readiness.md) |
|
||||
| MVP E2E 验收测试与带编号测试报告 issue 规则 | [MVP-e2e-acceptance.md](MVP-e2e-acceptance.md) |
|
||||
| 指挥官/runner 协作、PR 和 prompt handoff | [commander-collaboration.md](commander-collaboration.md) |
|
||||
| M3 闭环 rollout runbook | [m3-loop-rollout-runbook.md](m3-loop-rollout-runbook.md) |
|
||||
| runner GitHub 可见性与 prompt handoff | [runner-issue-visibility-handoff.md](runner-issue-visibility-handoff.md) |
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
classifyLiveDeploymentIdentity,
|
||||
classifyLiveWebAssetIdentity,
|
||||
parseSmokeArgs,
|
||||
runDevCloudWorkbenchLayoutSmoke,
|
||||
runDevCloudWorkbenchStaticSmoke,
|
||||
runDevCloudWorkbenchTimeoutFixtureSmoke,
|
||||
sanitizeAgentChatBody
|
||||
@@ -148,11 +149,27 @@ test("workbench smoke CLI allows live evidence to write the live report", () =>
|
||||
});
|
||||
|
||||
test("smoke args include a DOM-only live read-only mode", () => {
|
||||
assert.deepEqual(parseSmokeArgs(["--dom-only", "--url", "http://74.48.78.17:16666/"]), {
|
||||
confirmDevLive: false,
|
||||
mode: "dom-only",
|
||||
url: "http://74.48.78.17:16666/"
|
||||
});
|
||||
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 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("source/default smoke covers #278 sidebar collapse static contract", () => {
|
||||
const report = runDevCloudWorkbenchStaticSmoke();
|
||||
assert.equal(report.checks.find((check) => check.id === "feedback-278-sidebar-collapse-contract")?.status, "pass");
|
||||
});
|
||||
|
||||
test("live workbench identity passes only when runtime commit or image tag matches current source", () => {
|
||||
@@ -415,3 +432,36 @@ test("local Code Agent timeout fixture keeps failed UI state, trace evidence, an
|
||||
assert.equal(timeoutCheck.observations.ui.retryInputPreserved, true);
|
||||
assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false);
|
||||
});
|
||||
|
||||
test("layout smoke verifies desktop and mobile sidebar collapse geometry", 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.evidenceLevel, "SOURCE");
|
||||
assert.equal(report.devLive, false);
|
||||
for (const id of [
|
||||
"layout-desktop-expanded",
|
||||
"layout-desktop-collapsed",
|
||||
"layout-desktop-restored",
|
||||
"layout-mobile-collapsed",
|
||||
"layout-mobile-drawer"
|
||||
]) {
|
||||
assert.equal(report.checks.find((check) => check.id === id)?.status, "pass", id);
|
||||
}
|
||||
|
||||
const desktopCollapsed = report.checks.find((check) => check.id === "layout-desktop-collapsed")?.observations;
|
||||
assert.equal(desktopCollapsed.explorerCollapsed, true);
|
||||
assert.equal(desktopCollapsed.toggle.ariaLabel, "展开左侧资源树");
|
||||
assert.equal(desktopCollapsed.toggle.text, "展开资源树");
|
||||
assert.equal(desktopCollapsed.collapsedWidthGain, true);
|
||||
assert.equal(desktopCollapsed.keyTargetsReachable, true);
|
||||
|
||||
const mobileCollapsed = report.checks.find((check) => check.id === "layout-mobile-collapsed")?.observations;
|
||||
assert.equal(mobileCollapsed.explorerCollapsed, true);
|
||||
assert.equal(mobileCollapsed.defaultCollapsedMobile, true);
|
||||
assert.equal(mobileCollapsed.keyTargetsReachable, true);
|
||||
});
|
||||
|
||||
@@ -222,6 +222,7 @@ export async function runDevCloudWorkbenchSmoke(argv = []) {
|
||||
if (args.mode === "live") return runLiveSmoke(args);
|
||||
if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke();
|
||||
if (args.mode === "dom-only") return runLiveDomOnlySmoke(args);
|
||||
if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args);
|
||||
if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke();
|
||||
if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke();
|
||||
return runStaticSmoke();
|
||||
@@ -273,6 +274,8 @@ export function parseSmokeArgs(argv) {
|
||||
args.confirmDevLive = true;
|
||||
} else if (arg === "--dom-only") {
|
||||
args.mode = "dom-only";
|
||||
} else if (arg === "--layout") {
|
||||
args.mode = "layout";
|
||||
} else if (arg === "--mobile") {
|
||||
args.mode = "mobile";
|
||||
args.mobile = true;
|
||||
@@ -284,6 +287,7 @@ export function parseSmokeArgs(argv) {
|
||||
index += 1;
|
||||
if (!argv[index]) throw new Error("--url requires a value");
|
||||
args.url = argv[index];
|
||||
args.urlExplicit = true;
|
||||
} else if (arg === "--report") {
|
||||
index += 1;
|
||||
if (!argv[index]) throw new Error("--report requires a value");
|
||||
@@ -388,6 +392,17 @@ function runStaticSmoke() {
|
||||
]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "feedback-278-sidebar-collapse-contract", hasSidebarCollapseContract(files), "Left resource tree exposes explicit Chinese collapse/expand semantics and releases width for the Agent workspace plus M3 panel.", {
|
||||
blocker: "runtime_blocker",
|
||||
evidence: [
|
||||
"收起左侧资源树 / 展开左侧资源树 aria-label",
|
||||
"收起资源树 / 展开资源树 visible button text",
|
||||
"explorer-collapsed sets explorer column to 0",
|
||||
"collapsed desktop center column minmax(560px, 1fr)",
|
||||
"right side keeps a future width boundary through --right-width"
|
||||
]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", {
|
||||
blocker: "safety_blocker",
|
||||
evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
|
||||
@@ -796,6 +811,152 @@ async function runLiveDomOnlySmoke(args) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
|
||||
const useLiveUrl = args.urlExplicit === true;
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await importPlaywright());
|
||||
} catch (error) {
|
||||
const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`;
|
||||
return {
|
||||
status: "skip",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "layout-browser",
|
||||
url: useLiveUrl ? args.url : null,
|
||||
evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE",
|
||||
devLive: false,
|
||||
summary,
|
||||
checks: [
|
||||
{
|
||||
id: "layout-browser-dependency",
|
||||
status: "skip",
|
||||
summary: playwrightDependencySummary,
|
||||
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
|
||||
}
|
||||
],
|
||||
blockers: [
|
||||
{
|
||||
type: "environment_blocker",
|
||||
scope: "layout-browser-dependency",
|
||||
status: "open",
|
||||
summary
|
||||
}
|
||||
],
|
||||
safety: staticSafety(),
|
||||
dependency: {
|
||||
package: "playwright",
|
||||
declaredIn: "package.json",
|
||||
installCommand: playwrightDependencyCommand
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const server = useLiveUrl ? null : await startStaticWebServer();
|
||||
const url = useLiveUrl ? args.url : server.url;
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const desktop = await inspectWorkbenchLayoutViewport(browser, url, {
|
||||
width: 1366,
|
||||
height: 768,
|
||||
isMobile: false
|
||||
});
|
||||
const narrow = await inspectWorkbenchLayoutViewport(browser, url, {
|
||||
width: 390,
|
||||
height: 844,
|
||||
isMobile: true
|
||||
});
|
||||
|
||||
const checks = [
|
||||
{
|
||||
id: "layout-desktop-expanded",
|
||||
status: desktop.expanded.pass ? "pass" : "blocked",
|
||||
summary: "Desktop expanded layout keeps navigation/resources visible, key controls reachable, and no outer scroll.",
|
||||
observations: desktop.expanded
|
||||
},
|
||||
{
|
||||
id: "layout-desktop-collapsed",
|
||||
status: desktop.collapsed.pass ? "pass" : "blocked",
|
||||
summary: "Desktop collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.",
|
||||
observations: desktop.collapsed
|
||||
},
|
||||
{
|
||||
id: "layout-desktop-restored",
|
||||
status: desktop.restored.pass ? "pass" : "blocked",
|
||||
summary: "Desktop expand action restores the left navigation/resource tree without layout overlap.",
|
||||
observations: desktop.restored
|
||||
},
|
||||
{
|
||||
id: "layout-mobile-collapsed",
|
||||
status: narrow.collapsed.pass ? "pass" : "blocked",
|
||||
summary: "Mobile default layout keeps the resource tree collapsed and preserves Agent input plus M3 tabs/controls.",
|
||||
observations: narrow.collapsed
|
||||
},
|
||||
{
|
||||
id: "layout-mobile-drawer",
|
||||
status: narrow.drawer.pass ? "pass" : "blocked",
|
||||
summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.",
|
||||
observations: narrow.drawer
|
||||
}
|
||||
];
|
||||
const blockers = checks
|
||||
.filter((check) => check.status !== "pass")
|
||||
.map((check) => ({
|
||||
type: "runtime_blocker",
|
||||
scope: check.id,
|
||||
status: "open",
|
||||
summary: check.summary
|
||||
}));
|
||||
return {
|
||||
status: blockers.length === 0 ? "pass" : "blocked",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "layout-browser",
|
||||
url,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE",
|
||||
devLive: false,
|
||||
summary: useLiveUrl
|
||||
? "Live browser layout smoke verifies sidebar collapse/expand usability only; it is not M3 hardware acceptance."
|
||||
: "Static local browser layout smoke verifies sidebar collapse/expand usability only; deployment still requires DEV live verification.",
|
||||
refs: ["pikasTech/HWLAB#278", "pikasTech/HWLAB#273", "pikasTech/HWLAB#99"],
|
||||
checks,
|
||||
blockers,
|
||||
safety: {
|
||||
...staticSafety(),
|
||||
sourceIsDevLive: false,
|
||||
layoutOnly: true,
|
||||
codeAgentPostSent: false,
|
||||
hardwareWriteApis: false,
|
||||
statement: "Layout smoke opens the workbench and clicks sidebar controls only. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance."
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "blocked",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "layout-browser",
|
||||
url,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE",
|
||||
devLive: false,
|
||||
summary: `Workbench layout smoke failed: ${error.message}`,
|
||||
checks: [],
|
||||
blockers: [
|
||||
{
|
||||
type: "runtime_blocker",
|
||||
scope: "layout-browser-smoke",
|
||||
status: "open",
|
||||
summary: error.message
|
||||
}
|
||||
],
|
||||
safety: staticSafety()
|
||||
};
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function addDomOnlyCodeAgentCheck(checks) {
|
||||
checks.push({
|
||||
id: "live-code-agent-browser-journey",
|
||||
@@ -852,9 +1013,7 @@ function baseReport({
|
||||
const sourceOnlyMode = mode === "source";
|
||||
return {
|
||||
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
|
||||
$id: mode === "dom-only"
|
||||
? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-dom-only.json"
|
||||
: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json",
|
||||
$id: reportModeId(mode),
|
||||
reportVersion: "v1",
|
||||
status,
|
||||
issue: "pikasTech/HWLAB#7",
|
||||
@@ -915,9 +1074,11 @@ function baseReport({
|
||||
"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 --source",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --layout",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --layout --url http://74.48.78.17:16666/",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json",
|
||||
mode === "dom-only"
|
||||
@@ -929,12 +1090,13 @@ function baseReport({
|
||||
status: mode === "source" ? status : "not_run",
|
||||
commands: [
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --source",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --layout",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
|
||||
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture"
|
||||
],
|
||||
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
|
||||
summary: "Source, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey."
|
||||
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
|
||||
summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey."
|
||||
},
|
||||
dryRun: {
|
||||
status: "not_applicable",
|
||||
@@ -977,6 +1139,14 @@ function baseReport({
|
||||
};
|
||||
}
|
||||
|
||||
function reportModeId(mode) {
|
||||
return mode === "layout"
|
||||
? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-layout.json"
|
||||
: mode === "dom-only"
|
||||
? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-dom-only.json"
|
||||
: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json";
|
||||
}
|
||||
|
||||
function addCheck(checks, blockers, id, result, summary, options = {}) {
|
||||
const status = result === true ? "pass" : result === false ? "blocked" : result;
|
||||
const check = {
|
||||
@@ -1340,7 +1510,9 @@ function textHasForbiddenTerm(text, term) {
|
||||
}
|
||||
|
||||
function hasCompletePrimaryNavigation(html) {
|
||||
const navLabelsPresent = ["工作台", "内部复核", "使用说明", "资源树"].every((label) => html.includes(`>${label}<`));
|
||||
const navLabelsPresent =
|
||||
["工作台", "内部复核", "使用说明"].every((label) => html.includes(`>${label}<`)) &&
|
||||
/>(?:资源树|收起资源树|展开资源树)<\/button>/u.test(html);
|
||||
const legacyLabelsAbsent = incompletePrimaryNavLabels.every((label) => !html.includes(`>${label}<`));
|
||||
const noLeftSideRecordsShortcut = !/data-side-tab-jump=["']records["']/u.test(html);
|
||||
return navLabelsPresent && legacyLabelsAbsent && noLeftSideRecordsShortcut;
|
||||
@@ -1474,12 +1646,15 @@ function hasMobileWorkbenchLayoutContract({ html, app, styles }) {
|
||||
/id=["']resource-explorer["']/u.test(html) &&
|
||||
/aria-controls=["']resource-explorer["']/u.test(html) &&
|
||||
/aria-expanded=["']true["']/u.test(html) &&
|
||||
/aria-pressed=["']false["']/u.test(html) &&
|
||||
/function\s+syncMobileExplorer\s*\(/u.test(app) &&
|
||||
/window\.matchMedia\(["']\(max-width:\s*860px\)["']\)/u.test(app) &&
|
||||
/setExplorerCollapsed\(true\)/u.test(app) &&
|
||||
/setExplorerCollapsed\(false\)/u.test(app) &&
|
||||
/function\s+collapseExplorerAfterMobileAction\s*\(/u.test(app) &&
|
||||
/aria-expanded/u.test(functionBody(app, "setExplorerCollapsed")) &&
|
||||
/aria-label/u.test(functionBody(app, "setExplorerCollapsed")) &&
|
||||
/展开资源树/u.test(functionBody(app, "setExplorerCollapsed")) &&
|
||||
/\.explorer-collapsed\s+\.explorer\s*\{[^\}]*pointer-events:\s*none;/su.test(styles) &&
|
||||
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.activity-rail\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;/u.test(styles) &&
|
||||
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;[\s\S]*?z-index:\s*4;/u.test(styles) &&
|
||||
@@ -1489,9 +1664,28 @@ function hasMobileWorkbenchLayoutContract({ html, app, styles }) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasSidebarCollapseContract({ html, app, styles }) {
|
||||
const setCollapsedBody = functionBody(app, "setExplorerCollapsed");
|
||||
return (
|
||||
/id=["']explorer-toggle["']/u.test(html) &&
|
||||
/aria-label=["']收起左侧资源树["']/u.test(html) &&
|
||||
/title=["']收起左侧资源树,给工作区更多宽度["']/u.test(html) &&
|
||||
/>收起资源树<\/button>/u.test(html) &&
|
||||
/setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/u.test(setCollapsedBody) &&
|
||||
/textContent = collapsed \? "展开资源树" : "收起资源树"/u.test(setCollapsedBody) &&
|
||||
/title = collapsed \? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"/u.test(setCollapsedBody) &&
|
||||
/--explorer-width:\s*292px/u.test(styles) &&
|
||||
/--right-width:\s*clamp\(366px,\s*28vw,\s*440px\)/u.test(styles) &&
|
||||
/--right-width-expanded:\s*clamp\(366px,\s*26vw,\s*410px\)/u.test(styles) &&
|
||||
/\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(560px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) &&
|
||||
/\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles)
|
||||
);
|
||||
}
|
||||
|
||||
function hasStableRouteControls({ html, styles }) {
|
||||
return (
|
||||
["工作台", "内部复核", "使用说明", "资源树", "控制", "接线", "可信记录"].every((label) => html.includes(`>${label}<`)) &&
|
||||
["工作台", "内部复核", "使用说明", "控制", "接线", "可信记录"].every((label) => html.includes(`>${label}<`)) &&
|
||||
/>(?:资源树|收起资源树|展开资源树)<\/button>/u.test(html) &&
|
||||
/\.rail-button\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*36px;[^}]*text-align:\s*center;[^}]*word-break:\s*keep-all;/su.test(styles) &&
|
||||
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.rail-button\s*\{[\s\S]*?min-height:\s*42px;/u.test(styles) &&
|
||||
/\.side-tab\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*34px;[^}]*overflow-wrap:\s*anywhere;[^}]*word-break:\s*keep-all;/su.test(styles) &&
|
||||
@@ -3523,6 +3717,302 @@ async function inspectMobileWorkbench(page, { explorerOpen }) {
|
||||
}, { explorerOpen });
|
||||
}
|
||||
|
||||
async function inspectWorkbenchLayoutViewport(browser, url, viewport) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: viewport.isMobile === true
|
||||
});
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await page.locator("#explorer-toggle").waitFor({ state: "visible", timeout: 12000 });
|
||||
await page.waitForTimeout(150);
|
||||
if (viewport.isMobile) {
|
||||
const collapsed = await inspectLayoutState(page, {
|
||||
mode: "mobile-collapsed",
|
||||
viewport,
|
||||
compareTo: null
|
||||
});
|
||||
await page.locator("#explorer-toggle").click();
|
||||
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
|
||||
const drawer = await inspectLayoutState(page, {
|
||||
mode: "mobile-drawer",
|
||||
viewport,
|
||||
compareTo: collapsed
|
||||
});
|
||||
await page.locator("#explorer-toggle").click();
|
||||
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
|
||||
const recollapsed = await inspectLayoutState(page, {
|
||||
mode: "mobile-recollapsed",
|
||||
viewport,
|
||||
compareTo: collapsed
|
||||
});
|
||||
drawer.recollapsed = {
|
||||
explorerCollapsed: recollapsed.explorerCollapsed,
|
||||
toggleText: recollapsed.toggle.text,
|
||||
toggleAriaLabel: recollapsed.toggle.ariaLabel,
|
||||
keyTargetsReachable: recollapsed.keyTargetsReachable
|
||||
};
|
||||
drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable;
|
||||
return { collapsed, drawer };
|
||||
}
|
||||
|
||||
const expanded = await inspectLayoutState(page, {
|
||||
mode: "desktop-expanded",
|
||||
viewport,
|
||||
compareTo: null
|
||||
});
|
||||
await page.locator("#explorer-toggle").click();
|
||||
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
|
||||
const collapsed = await inspectLayoutState(page, {
|
||||
mode: "desktop-collapsed",
|
||||
viewport,
|
||||
compareTo: expanded
|
||||
});
|
||||
await page.locator("#explorer-toggle").click();
|
||||
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
|
||||
const restored = await inspectLayoutState(page, {
|
||||
mode: "desktop-restored",
|
||||
viewport,
|
||||
compareTo: expanded
|
||||
});
|
||||
return { expanded, collapsed, restored };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
return page.evaluate(async ({ mode, viewport, compareTo }) => {
|
||||
const selectorLabels = [
|
||||
["#explorer-toggle", "资源树折叠按钮"],
|
||||
["#command-input", "Code Agent 输入"],
|
||||
["#command-send", "发送"],
|
||||
["#tab-control", "M3 控制标签"],
|
||||
["#tab-wiring", "M3 接线标签"],
|
||||
["#tab-records", "可信记录标签"],
|
||||
["#m3-write-do", "写入 DO1"],
|
||||
["#m3-read-di", "读取 DI1"]
|
||||
];
|
||||
const drawerSelectorLabels = [
|
||||
[".quick-actions button", "资源树快捷操作"],
|
||||
["#resource-tree summary", "资源树分组"],
|
||||
["#resource-tree .tree-row", "资源树资源行"]
|
||||
];
|
||||
const boxForElement = (element) => {
|
||||
if (!element) return null;
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
right: box.right,
|
||||
bottom: box.bottom,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
overflow: style.overflow,
|
||||
overflowY: style.overflowY,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? ""
|
||||
};
|
||||
};
|
||||
const boxFor = (selector) => boxForElement(document.querySelector(selector));
|
||||
const boxesOverlap = (a, b, gap = 0) => {
|
||||
if (!a || !b || a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false;
|
||||
return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top);
|
||||
};
|
||||
const ownsHit = (element, hit) => hit === element || element.contains(hit);
|
||||
const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
|
||||
const inspectHitTarget = (selector, label) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return { selector, label, ok: false, missing: true };
|
||||
element.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
const box = element.getBoundingClientRect();
|
||||
const cx = box.left + box.width / 2;
|
||||
const cy = box.top + box.height / 2;
|
||||
const hit = document.elementFromPoint(cx, cy);
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
selector,
|
||||
label,
|
||||
ok:
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
cx >= 0 &&
|
||||
cx <= window.innerWidth &&
|
||||
cy >= 0 &&
|
||||
cy <= window.innerHeight &&
|
||||
ownsHit(element, hit),
|
||||
center: { x: cx, y: cy },
|
||||
box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height },
|
||||
hit: hitLabel(hit)
|
||||
};
|
||||
};
|
||||
const inspectFirstHit = (selector, label) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return { selector, label, ok: false, missing: true };
|
||||
return inspectHitTarget(selector, label);
|
||||
};
|
||||
const scrollableProbe = async (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return { selector, exists: false, scrolls: false };
|
||||
element.scrollTop = 0;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const before = element.scrollTop;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
selector,
|
||||
exists: true,
|
||||
before,
|
||||
after: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
overflowY: getComputedStyle(element).overflowY,
|
||||
scrolls: element.scrollHeight > element.clientHeight ? element.scrollTop > before : true
|
||||
};
|
||||
};
|
||||
|
||||
window.scrollTo(0, 240);
|
||||
document.documentElement.scrollTop = 240;
|
||||
document.body.scrollTop = 240;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
const shell = document.querySelector("[data-app-shell]");
|
||||
const toggleElement = document.querySelector("#explorer-toggle");
|
||||
const boxes = {
|
||||
shell: boxFor("[data-app-shell]"),
|
||||
rail: boxFor(".activity-rail"),
|
||||
explorer: boxFor("#resource-explorer"),
|
||||
center: boxFor(".center-workspace"),
|
||||
right: boxFor(".right-sidebar"),
|
||||
commandBar: boxFor("#command-form"),
|
||||
controlPanel: boxFor("#panel-control"),
|
||||
m3Form: boxFor("#m3-control-form"),
|
||||
m3Actions: boxFor(".m3-control-actions"),
|
||||
controlList: boxFor("#control-list"),
|
||||
topbar: boxFor(".topbar")
|
||||
};
|
||||
const rootAfterScrollAttempt = {
|
||||
windowScrollY: window.scrollY,
|
||||
htmlScrollTop: document.documentElement.scrollTop,
|
||||
bodyScrollTop: document.body.scrollTop
|
||||
};
|
||||
const rootScrollLocked =
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2 &&
|
||||
rootAfterScrollAttempt.windowScrollY === 0 &&
|
||||
rootAfterScrollAttempt.htmlScrollTop === 0 &&
|
||||
rootAfterScrollAttempt.bodyScrollTop === 0;
|
||||
const keyTargets = selectorLabels.map(([selector, label]) => inspectHitTarget(selector, label));
|
||||
const drawerTargets = drawerSelectorLabels.map(([selector, label]) => inspectFirstHit(selector, label));
|
||||
const blockedKeyTargets = keyTargets.filter((target) => !target.ok);
|
||||
const blockedDrawerTargets = drawerTargets.filter((target) => !target.ok);
|
||||
const explorerCollapsed = shell?.classList.contains("explorer-collapsed") === true;
|
||||
const explorerVisible = Boolean(
|
||||
boxes.explorer &&
|
||||
boxes.explorer.display !== "none" &&
|
||||
boxes.explorer.visibility !== "hidden" &&
|
||||
boxes.explorer.width > 1 &&
|
||||
boxes.explorer.height > 1
|
||||
);
|
||||
const isDesktop = viewport.width >= 861;
|
||||
const isMobile = !isDesktop;
|
||||
const centerWidthDelta = compareTo?.boxes?.center?.width && boxes.center
|
||||
? boxes.center.width - compareTo.boxes.center.width
|
||||
: null;
|
||||
const rightWidthDelta = compareTo?.boxes?.right?.width && boxes.right
|
||||
? boxes.right.width - compareTo.boxes.right.width
|
||||
: null;
|
||||
const overlapChecks = {
|
||||
railCenter: boxesOverlap(boxes.rail, boxes.center, -1),
|
||||
explorerCenter: explorerVisible && isDesktop && boxesOverlap(boxes.explorer, boxes.center, -1),
|
||||
centerRight: isDesktop && boxesOverlap(boxes.center, boxes.right, -1),
|
||||
m3ActionsControlList: boxesOverlap(boxes.m3Actions, boxes.controlList, -1),
|
||||
topbarCommandBar: boxesOverlap(boxes.topbar, boxes.commandBar, -1)
|
||||
};
|
||||
const noIncoherentOverlap = Object.values(overlapChecks).every((value) => value === false);
|
||||
const sidePanelScroll = await scrollableProbe("#panel-control");
|
||||
const sidePanelOverflowUsable = sidePanelScroll.exists && (sidePanelScroll.scrolls || sidePanelScroll.overflowY !== "hidden");
|
||||
const toggle = {
|
||||
text: toggleElement?.textContent?.replace(/\s+/gu, " ").trim() ?? "",
|
||||
ariaLabel: toggleElement?.getAttribute("aria-label") ?? "",
|
||||
title: toggleElement?.getAttribute("title") ?? "",
|
||||
ariaExpanded: toggleElement?.getAttribute("aria-expanded") ?? "",
|
||||
ariaPressed: toggleElement?.getAttribute("aria-pressed") ?? ""
|
||||
};
|
||||
const collapsedWidthGain = isDesktop && mode === "desktop-collapsed"
|
||||
? centerWidthDelta !== null && centerWidthDelta >= 180
|
||||
: true;
|
||||
const restoredWidth = isDesktop && mode === "desktop-restored"
|
||||
? centerWidthDelta !== null && Math.abs(centerWidthDelta) <= 4
|
||||
: true;
|
||||
const drawerReachable = isMobile && mode === "mobile-drawer"
|
||||
? drawerTargets.length >= 3 && blockedDrawerTargets.length === 0
|
||||
: true;
|
||||
const collapsedExpected = mode === "desktop-collapsed" || mode === "mobile-collapsed" || mode === "mobile-recollapsed";
|
||||
const expandedExpected = mode === "desktop-expanded" || mode === "desktop-restored" || mode === "mobile-drawer";
|
||||
const collapsedStateMatches = collapsedExpected
|
||||
? explorerCollapsed === true && toggle.ariaExpanded === "false" && toggle.ariaPressed === "true" && toggle.text === "展开资源树" && toggle.ariaLabel === "展开左侧资源树"
|
||||
: expandedExpected
|
||||
? explorerCollapsed === false && toggle.ariaExpanded === "true" && toggle.ariaPressed === "false" && toggle.text === "收起资源树" && toggle.ariaLabel === "收起左侧资源树"
|
||||
: true;
|
||||
const explorerStateMatches = collapsedExpected
|
||||
? isDesktop
|
||||
? boxes.explorer !== null && boxes.explorer.width <= 1
|
||||
: boxes.explorer?.display === "none"
|
||||
: explorerVisible;
|
||||
const defaultCollapsedMobile = mode === "mobile-collapsed"
|
||||
? explorerCollapsed === true
|
||||
: true;
|
||||
const keyTargetsReachable = blockedKeyTargets.length === 0;
|
||||
const keyTargetsRequirement = mode === "mobile-drawer" ? true : keyTargetsReachable;
|
||||
const pass =
|
||||
rootScrollLocked &&
|
||||
noIncoherentOverlap &&
|
||||
keyTargetsRequirement &&
|
||||
collapsedStateMatches &&
|
||||
explorerStateMatches &&
|
||||
collapsedWidthGain &&
|
||||
restoredWidth &&
|
||||
drawerReachable &&
|
||||
defaultCollapsedMobile &&
|
||||
sidePanelOverflowUsable;
|
||||
return {
|
||||
mode,
|
||||
viewport,
|
||||
pass,
|
||||
explorerCollapsed,
|
||||
explorerVisible,
|
||||
toggle,
|
||||
rootScrollLocked,
|
||||
rootAfterScrollAttempt,
|
||||
boxes,
|
||||
centerWidthDelta,
|
||||
rightWidthDelta,
|
||||
collapsedWidthGain,
|
||||
restoredWidth,
|
||||
overlapChecks,
|
||||
noIncoherentOverlap,
|
||||
keyTargetsReachable,
|
||||
keyTargetsRequirement,
|
||||
blockedKeyTargets,
|
||||
drawerReachable,
|
||||
blockedDrawerTargets,
|
||||
sidePanelScroll,
|
||||
sidePanelOverflowUsable,
|
||||
defaultCollapsedMobile
|
||||
};
|
||||
}, { mode, viewport, compareTo });
|
||||
}
|
||||
|
||||
async function inspectWorkbenchScrollContract(page, { panelSelector }) {
|
||||
return page.evaluate(async ({ panelSelector }) => {
|
||||
function metrics(selector) {
|
||||
@@ -3658,9 +4148,10 @@ function escapeRegExp(value) {
|
||||
export function printSmokeHelp() {
|
||||
return {
|
||||
status: "usage",
|
||||
command: "node scripts/dev-cloud-workbench-smoke.mjs --source | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-dom-only.json] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]",
|
||||
command: "node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-dom-only.json] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]",
|
||||
notes: [
|
||||
"Default/source mode reads repository files and emits SOURCE-level evidence only; it never calls the live provider and must not claim DEV-LIVE.",
|
||||
"--layout runs desktop and mobile browser geometry/hit-target checks for sidebar collapse/expand; with --url it remains layout-only live evidence and does not claim M3 acceptance.",
|
||||
"--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.",
|
||||
"--local-agent-fixture runs two local browser send/reply paths with a sanitized SOURCE fixture delayed beyond 4500ms and does not claim DEV-LIVE.",
|
||||
"--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.",
|
||||
|
||||
@@ -191,6 +191,9 @@ function setExplorerCollapsed(collapsed) {
|
||||
el.shell.classList.toggle("explorer-collapsed", collapsed);
|
||||
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
|
||||
el.explorerToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
el.explorerToggle.setAttribute("aria-label", collapsed ? "展开左侧资源树" : "收起左侧资源树");
|
||||
el.explorerToggle.title = collapsed ? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度";
|
||||
el.explorerToggle.textContent = collapsed ? "展开资源树" : "收起资源树";
|
||||
}
|
||||
|
||||
function syncMobileExplorer() {
|
||||
@@ -1252,7 +1255,7 @@ function renderM3ControlStatus() {
|
||||
|
||||
el.m3ControlStatus.textContent = label;
|
||||
el.m3ControlStatus.className = `state-tag tone-${toneClass(tone)}`;
|
||||
el.m3ControlStatus.title = operation?.blocker?.zh ?? operation?.evidenceState?.reason ?? m3ControlBlockedReason(contract);
|
||||
el.m3ControlStatus.title = m3ControlStatusTitle({ operation, contract, controlReady });
|
||||
const disabled = pending || !controlReady;
|
||||
for (const input of [el.m3GatewaySelect, el.m3BoxSelect, el.m3PortSelect, el.m3ValueSelect, el.m3WriteDo, el.m3ReadDi]) {
|
||||
input.disabled = disabled;
|
||||
@@ -1268,6 +1271,15 @@ function m3ControlCanOperate(contract = state.m3Control.contract) {
|
||||
contract?.readiness?.evidenceLevel === "DEV-LIVE";
|
||||
}
|
||||
|
||||
function m3ControlStatusTitle({ operation, contract, controlReady }) {
|
||||
if (operation?.blocker?.zh) return operation.blocker.zh;
|
||||
if (operation?.evidenceState?.reason) return operation.evidenceState.reason;
|
||||
if (controlReady) {
|
||||
return "M3 IO 只读 readiness 已确认;控制仅通过 cloud-api 受控路径执行,不直连 gateway/box-simu。";
|
||||
}
|
||||
return m3ControlBlockedReason(contract);
|
||||
}
|
||||
|
||||
function m3ControlBlockedReason(contract = state.m3Control.contract) {
|
||||
if (!contract) return "等待 cloud-api 返回 M3 IO 只读 readiness;未确认前控制保持阻塞。";
|
||||
return contract.readiness?.blocker?.zh ??
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
|
||||
<button class="rail-button" type="button" data-route="help" title="使用说明" aria-label="使用说明">使用说明</button>
|
||||
<span class="rail-spacer" aria-hidden="true"></span>
|
||||
<button class="rail-button" id="explorer-toggle" type="button" title="展开或收起资源树" aria-label="展开或收起资源树" aria-controls="resource-explorer" aria-expanded="true">资源树</button>
|
||||
<button class="rail-button" id="explorer-toggle" type="button" title="收起左侧资源树,给工作区更多宽度" aria-label="收起左侧资源树" aria-controls="resource-explorer" aria-expanded="true" aria-pressed="false">收起资源树</button>
|
||||
</aside>
|
||||
|
||||
<aside class="explorer" id="resource-explorer" aria-label="资源浏览与硬件资源树">
|
||||
|
||||
@@ -231,8 +231,18 @@ for (const workbenchElement of [
|
||||
]) {
|
||||
assert.match(html, new RegExp(workbenchElement));
|
||||
}
|
||||
assert.match(html, /id="explorer-toggle"/);
|
||||
assert.match(html, /aria-controls="resource-explorer"/);
|
||||
assert.match(html, /aria-expanded="true"/);
|
||||
assert.match(html, /aria-pressed="false"/);
|
||||
assert.match(html, /aria-label="收起左侧资源树"/);
|
||||
assert.match(html, />收起资源树<\/button>/);
|
||||
assert.match(app, /setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/);
|
||||
assert.match(app, /textContent = collapsed \? "展开资源树" : "收起资源树"/);
|
||||
assert.match(styles, /--explorer-width:\s*292px/);
|
||||
assert.match(styles, /--right-width:\s*clamp\(366px,\s*28vw,\s*440px\)/);
|
||||
assert.match(styles, /--right-width-expanded:\s*clamp\(366px,\s*26vw,\s*410px\)/);
|
||||
assert.match(styles, /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(560px,\s*1fr\)\s+var\(--right-width\);/s);
|
||||
for (const viewId of ["workspace", "gate"]) {
|
||||
assert.match(html, new RegExp(`data-view="${viewId}"`));
|
||||
}
|
||||
@@ -287,7 +297,7 @@ const defaultFirstViewportText = visibleTextFromHtml(defaultFirstViewportHtml(ht
|
||||
for (const backendTerm of ["Gate", "诊断", "验收", "BLOCKED", "SOURCE", "DRY-RUN", "DEV-LIVE", "M0-M5", "M3 基本", "trace/evidence", "执行轨迹", "OPENAI_API_KEY", "Secret", "secretRef", "hwlab-code-agent-provider", "m3-route-required", "waiting-for-dev-live"]) {
|
||||
assert.equal(textHasForbiddenTerm(defaultFirstViewportText, backendTerm), false, `default workbench must not expose backend term ${backendTerm}`);
|
||||
}
|
||||
for (const fullNavLabel of ["工作台", "内部复核", "使用说明", "资源树"]) {
|
||||
for (const fullNavLabel of ["工作台", "内部复核", "使用说明", "收起资源树"]) {
|
||||
assert.match(html, new RegExp(`>${escapeRegExp(fullNavLabel)}<`), `missing full nav label ${fullNavLabel}`);
|
||||
}
|
||||
for (const oneCharNavLabel of [">台<", ">证<", ">诊<", ">帮<"]) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export function runM3ControlPanelGuard() {
|
||||
for (const copy of [
|
||||
"M3 IO 控制请求失败",
|
||||
"M3 IO 只读 readiness 未 green",
|
||||
"M3 IO 只读 readiness 已确认",
|
||||
"受控路径受阻",
|
||||
"等待 cloud-api 返回 M3 IO 只读 readiness",
|
||||
"不可用时不会直连 gateway/box-simu",
|
||||
@@ -135,6 +136,14 @@ function assertControlPanelSource(app) {
|
||||
assert.match(canOperateBody, /readiness\?\.sourceKind === "DEV-LIVE"/u, "M3 controls must not unlock on SOURCE/DRY-RUN/BLOCKED readiness");
|
||||
assert.match(canOperateBody, /readiness\?\.evidenceLevel === "DEV-LIVE"/u, "M3 controls must require live readiness evidence level");
|
||||
|
||||
const statusBody = functionBody(app, "renderM3ControlStatus");
|
||||
assert.match(statusBody, /m3ControlStatusTitle\(\{ operation, contract, controlReady \}\)/u, "ready status title must use the control-ready-aware helper");
|
||||
|
||||
const statusTitleBody = functionBody(app, "m3ControlStatusTitle");
|
||||
assert.match(statusTitleBody, /if \(controlReady\)/u, "ready status title must not fall through to blocked copy");
|
||||
assert.match(statusTitleBody, /只读 readiness 已确认/u, "ready status title must show confirmed Chinese readiness copy");
|
||||
assert.match(statusTitleBody, /return m3ControlBlockedReason\(contract\)/u, "blocked status title must still surface Chinese blocked reason");
|
||||
|
||||
const controlRowsBody = functionBody(app, "controlRows");
|
||||
assert.match(controlRowsBody, /按钮只走受控后端/u, "control copy must describe the controlled backend route");
|
||||
assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path");
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
--warn: #dfaa55;
|
||||
--bad: #e76e5e;
|
||||
--info: #7ea6c9;
|
||||
--rail-width: 92px;
|
||||
--explorer-width: 292px;
|
||||
--right-width: clamp(366px, 28vw, 440px);
|
||||
--right-width-expanded: clamp(366px, 26vw, 410px);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--body: "Aptos", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
@@ -72,7 +76,7 @@ ul {
|
||||
max-height: 100dvh;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 92px 292px minmax(460px, 1fr) 366px;
|
||||
grid-template-columns: var(--rail-width) var(--explorer-width) minmax(460px, 1fr) var(--right-width-expanded);
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
@@ -118,6 +122,10 @@ ul {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
#explorer-toggle {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.rail-button:hover,
|
||||
.rail-button:focus-visible,
|
||||
.rail-button.active {
|
||||
@@ -142,7 +150,8 @@ ul {
|
||||
}
|
||||
|
||||
.explorer-collapsed {
|
||||
grid-template-columns: 92px 0 minmax(460px, 1fr) 366px;
|
||||
--explorer-width: 0px;
|
||||
grid-template-columns: var(--rail-width) 0 minmax(560px, 1fr) var(--right-width);
|
||||
}
|
||||
|
||||
.explorer-collapsed .explorer {
|
||||
@@ -921,7 +930,8 @@ h3 {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.side-panel[hidden] {
|
||||
@@ -1234,12 +1244,15 @@ tbody tr:last-child td {
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.workbench-shell {
|
||||
grid-template-columns: 86px 260px minmax(420px, 1fr);
|
||||
--rail-width: 86px;
|
||||
--explorer-width: 260px;
|
||||
grid-template-columns: var(--rail-width) var(--explorer-width) minmax(420px, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) clamp(220px, 36dvh, 360px);
|
||||
}
|
||||
|
||||
.explorer-collapsed {
|
||||
grid-template-columns: 86px 0 minmax(0, 1fr);
|
||||
--explorer-width: 0px;
|
||||
grid-template-columns: var(--rail-width) 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.right-sidebar {
|
||||
@@ -1258,7 +1271,8 @@ tbody tr:last-child td {
|
||||
@media (max-width: 860px) {
|
||||
.workbench-shell,
|
||||
.explorer-collapsed {
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
--rail-width: 68px;
|
||||
grid-template-columns: var(--rail-width) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) clamp(214px, 30dvh, 280px);
|
||||
}
|
||||
|
||||
@@ -1372,8 +1386,9 @@ tbody tr:last-child td {
|
||||
@media (max-width: 520px) {
|
||||
.workbench-shell,
|
||||
.explorer-collapsed {
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) clamp(200px, 28dvh, 250px);
|
||||
--rail-width: 64px;
|
||||
grid-template-columns: var(--rail-width) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) clamp(260px, 34dvh, 320px);
|
||||
}
|
||||
|
||||
.activity-rail {
|
||||
|
||||
Reference in New Issue
Block a user