@autobest-ui/agent 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/bin/sync-assets.test.mjs +4 -0
- package/mcp/code-mcp-pr/README.md +3 -2
- package/mcp/code-mcp-pr/azure-devops.js +81 -2
- package/mcp/code-mcp-pr/index.js +34 -1
- package/mcp/code-mcp-pr/index.test.js +161 -11
- package/mcp/trace-mcp-recorder/.env.example +6 -0
- package/mcp/trace-mcp-recorder/README.md +58 -0
- package/mcp/trace-mcp-recorder/config.toml.example +11 -0
- package/mcp/trace-mcp-recorder/index.js +469 -0
- package/mcp/trace-mcp-recorder/index.test.js +179 -0
- package/mcp/trace-mcp-recorder/package.json +25 -0
- package/package.json +9 -2
- package/plugins/autobest-delivery/.codex-plugin/plugin.json +1 -0
- package/plugins/autobest-delivery/README.md +25 -4
- package/plugins/autobest-delivery/mcp-server/package.json +1 -1
- package/plugins/autobest-delivery/mcp-server/src/fast-runner.mjs +470 -0
- package/plugins/autobest-delivery/mcp-server/src/paths.mjs +12 -7
- package/plugins/autobest-delivery/mcp-server/src/report.mjs +57 -12
- package/plugins/autobest-delivery/mcp-server/src/server.mjs +167 -2
- package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
- package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
- package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +69 -1
- package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
- package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +11 -0
- package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
- package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/export-report/SKILL.md +3 -3
- package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
- package/skills/README.md +2 -0
- package/skills/common/code-pr-submit/SKILL.md +25 -4
- package/skills/common/repo-setup/SKILL.md +91 -0
- package/skills/common/repo-setup/agents/openai.yaml +4 -0
- package/skills/common/trace-recorder/SKILL.md +25 -0
- package/skills/common/trace-recorder/agents/openai.yaml +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autobest-ui/agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Autobest Agent skills/plugins/mcp assets + sync cli",
|
|
6
6
|
"files": [
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"autobest-agent-sync": "./bin/sync-assets.mjs",
|
|
20
20
|
"autobest-code-mcp-pr": "./mcp/code-mcp-pr/index.js",
|
|
21
21
|
"autobest-azurepr-mcp": "./mcp/azurepr-mcp-bridge/index.js",
|
|
22
|
+
"autobest-trace-mcp-recorder": "./mcp/trace-mcp-recorder/index.js",
|
|
22
23
|
"autobest-delivery-setup": "./plugins/autobest-delivery/scripts/setup.mjs",
|
|
23
24
|
"autobest-rag-mcp": "./mcp/rag-mcp-bridge/index.js",
|
|
24
25
|
"figma-mcp-bridge": "./mcp/figma-mcp-bridge/src/index.js",
|
|
@@ -29,20 +30,26 @@
|
|
|
29
30
|
"mcp:code-mcp-pr": "node ./mcp/code-mcp-pr/index.js",
|
|
30
31
|
"mcp:figma": "node ./mcp/figma-mcp-bridge/src/index.js",
|
|
31
32
|
"mcp:rag": "node ./mcp/rag-mcp-bridge/index.js",
|
|
33
|
+
"mcp:trace-recorder": "node ./mcp/trace-mcp-recorder/index.js",
|
|
32
34
|
"test": "npm run test:skills && npm run test:mcp && npm run test:plugin",
|
|
33
35
|
"test:skills": "node --test ./bin/sync-assets.test.mjs ./bin/traceability-validators.test.mjs",
|
|
34
36
|
"test:mcp:azurepr": "node --test ./mcp/azurepr-mcp-bridge/index.test.js",
|
|
35
37
|
"test:mcp:code-mcp-pr": "node --test ./mcp/code-mcp-pr/index.test.js",
|
|
36
38
|
"test:mcp:figma": "node --test ./mcp/figma-mcp-bridge/index.test.js",
|
|
37
39
|
"test:mcp:rag": "node --test ./mcp/rag-mcp-bridge/index.test.js",
|
|
38
|
-
"test:mcp": "
|
|
40
|
+
"test:mcp:trace-recorder": "node --test ./mcp/trace-mcp-recorder/index.test.js",
|
|
41
|
+
"test:mcp": "npm run test:mcp:azurepr && npm run test:mcp:code-mcp-pr && npm run test:mcp:figma && npm run test:mcp:rag && npm run test:mcp:trace-recorder",
|
|
39
42
|
"test:plugin": "node --test ./plugins/autobest-delivery/scripts/setup.test.mjs ./plugins/figma-plugin/scripts/setup.test.mjs"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
45
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
46
|
+
"axios": "1.20.0",
|
|
43
47
|
"diff": "9.0.0",
|
|
48
|
+
"form-data": "4.0.6",
|
|
44
49
|
"fs-extra": "11.3.0",
|
|
45
50
|
"mdast-util-from-markdown": "2.0.2",
|
|
51
|
+
"playwright": "1.63.0",
|
|
52
|
+
"uuid": "14.0.2",
|
|
46
53
|
"ws": "8.18.3",
|
|
47
54
|
"zod": "4.4.3"
|
|
48
55
|
},
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"defaultPrompt": [
|
|
20
20
|
"\u9488\u5bf9\u5f53\u524d\u529f\u80fd\u89c4\u683c\u8fd0\u884c\u5b8c\u6574\u4ea4\u4ed8\u95ed\u73af\u3002",
|
|
21
|
+
"\u4f7f\u7528\u5feb\u901f\u4ea4\u4ed8\u95ed\u73af\u5b9e\u73b0\u5f53\u524d\u529f\u80fd\uff0c\u5b8c\u6210\u4ea4\u4e92\u5f0f Playwright \u81ea\u6d4b\u3001\u622a\u56fe\u548c\u72ec\u7acb\u4ee3\u7801\u5ba1\u8ba1\u3002",
|
|
21
22
|
"\u68c0\u67e5\u9694\u79bb E2E \u8fd0\u884c\u5668\u662f\u5426\u5df2\u7ecf\u5c31\u7eea\u3002"
|
|
22
23
|
]
|
|
23
24
|
},
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Autobest 交付插件
|
|
2
2
|
|
|
3
|
-
`autobest-delivery` 将 Autobest
|
|
3
|
+
`autobest-delivery` 将 Autobest 完整及快速交付闭环 Skill 与插件自带的 Playwright MCP 运行器打包在一起。目标业务仓库只需提供应用源码、功能规格和启动命令,不需要安装 Playwright 依赖。
|
|
4
4
|
|
|
5
5
|
## 隔离机制
|
|
6
6
|
|
|
7
|
-
运行器的 Node 依赖位于 `mcp-server/node_modules/`,Chromium 位于 `mcp-server/.runtime/ms-playwright
|
|
7
|
+
运行器的 Node 依赖位于 `mcp-server/node_modules/`,Chromium 位于 `mcp-server/.runtime/ms-playwright/`,这两个路径均被插件忽略。工具只接受绝对路径形式的目标工作区,并且仅允许读取所需输入、向工作区内指定功能输出目录写入证据。标准运行器会额外校验冻结场景在执行前后的 SHA-256。
|
|
8
8
|
|
|
9
|
-
运行器不会在目标工作区调用包管理器,也不会修改目标仓库的清单、锁文件、`node_modules
|
|
9
|
+
运行器不会在目标工作区调用包管理器,也不会修改目标仓库的清单、锁文件、`node_modules`、配置或源码。标准 `run_feature_e2e` 每次执行前后都会校验冻结场景的 SHA-256。
|
|
10
10
|
|
|
11
11
|
## 使用 npx 安装
|
|
12
12
|
|
|
@@ -71,6 +71,21 @@ npx --yes --package=@autobest-ui/agent@latest autobest-delivery-setup --help
|
|
|
71
71
|
|
|
72
72
|
新版场景通过 `scene(id, callback)` 隔离页面和执行错误。一个 scene 的定位器、弹窗或准备步骤阻塞时,运行器会记录该 blocker 并继续执行后续独立 scene,全部 scene 尝试结束后再返回 `blocked`。旧版直接使用顶层 `page` 的场景仍可运行,但无法在未捕获异常后继续。
|
|
73
73
|
|
|
74
|
+
### 快速交互式 Playwright 工具
|
|
75
|
+
|
|
76
|
+
`$delivery-loop-fast` 使用以下工具直接操作浏览器,不创建或执行 `e2e/e2e.feature.mjs`:
|
|
77
|
+
|
|
78
|
+
- `start_fast_check`:启动会话并导航到应用;API 模式支持 `real`、`mock` 和无需业务 API 证明的 `none`。
|
|
79
|
+
- `fast_browser_snapshot`:读取当前页面的可访问性快照。
|
|
80
|
+
- `fast_browser_action`:执行导航、点击、填写、选择、按键、等待和视口切换。
|
|
81
|
+
- `mock_fast_check_route`:仅在 `mock` 模式安装受控 JSON 响应。
|
|
82
|
+
- `fast_browser_check`:执行并记录结构化页面检查。
|
|
83
|
+
- `capture_fast_check`:保存按报告功能组映射的桌面端或移动端截图。
|
|
84
|
+
- `finish_fast_check`:写入结果 JSON、中文 Markdown、Trace 和截图索引后关闭会话。
|
|
85
|
+
- `abort_fast_check`:放弃未完成的会话并关闭浏览器。
|
|
86
|
+
|
|
87
|
+
每轮产物位于 `fast-check/runs/iteration-NN/`,并以 `evidenceSource: interactive-playwright` 明确标识为实现者交互式自测,不属于独立 Checker 证据。后续 `$export-report` 可直接读取最新快速结果和截图。
|
|
88
|
+
|
|
74
89
|
### `resolve_blocked_run`
|
|
75
90
|
|
|
76
91
|
用户查看 Blocked 浏览器后,由创建该会话的验收代理调用此工具。工具将选择写入 `human-decision.json` 并关闭浏览器;`accept` 或 `skip` 会形成 waiver,而不是伪装成完整通过。
|
|
@@ -144,7 +159,7 @@ export default async function run({
|
|
|
144
159
|
|
|
145
160
|
Checker 和代码审计生成的 Markdown 使用简体中文,包括标题、摘要、表头、检查说明、期望、实际结果和 finding。JSON 字段名、机器状态、ID、路径及原始工具错误保持协议格式;英文原始错误旁提供中文解释。
|
|
146
161
|
|
|
147
|
-
手动调用 `$export-report` 时,Excel
|
|
162
|
+
手动调用 `$export-report` 时,Excel 可读取标准 Runner/Checker 证据或快速交互式自测证据,并且只生成一个 `自测报告` 工作表。每行代表一个真实 UI 组件或完整业务功能;同一组件或功能的文案、样式、布局、响应式行为和相关检查合并到同一行,桌面端与移动端代表截图也放在该行。报告不展示 REQ/story ID、scene 名或原子检查明细。新证据通过 `reportModule`、`reportGroup`、`reportTitle`、`reportMethod` 和 `reportDevice` 提供通用分组,并通过 `reportCaptures` 留下运行时 UI 截图。`visualMappings` 只负责有 Figma 基准时的视觉一致性验收;普通报告截图不表示视觉比对通过。旧证据必须由导出 Skill 分析 `spec.md` 后生成经过哈希和完整覆盖校验的语义分组清单,禁止按 E2E scene 猜测。
|
|
148
163
|
|
|
149
164
|
## 交付流程
|
|
150
165
|
|
|
@@ -157,6 +172,12 @@ Checker 和代码审计生成的 Markdown 使用简体中文,包括标题、
|
|
|
157
172
|
5. Blocked 时进入人工决策;只有用户选择 `stop` 才终止。
|
|
158
173
|
6. 验收通过或用户接受 waiver 后,`$code-audit` 审查代码规范和规格符合度。
|
|
159
174
|
|
|
175
|
+
### 快速流程 `$delivery-loop-fast`
|
|
176
|
+
|
|
177
|
+
快速流程由用户显式选择,不进行风险评分,也不会自动切换为完整流程。当前 Agent 根据 spec 实现代码、执行仓库定向检查,并使用上述交互式 Playwright 工具自测、截图和自查自纠;业务代码或测试步骤变化后开启新的完整自测 iteration。最新快速自测通过后,再由全新的独立 `$code-audit` 代理审查固定范围。审计发现阻断问题时,当前 Agent 修复后必须重新完成仓库检查、快速自测和独立审计。
|
|
178
|
+
|
|
179
|
+
快速流程不生成持久化 E2E 场景、测试编写者结果或 Checker 结果。完成条件仍包括:最新交互式自测通过、每个报告功能组具备截图、独立代码审计通过,并且证据与状态一致。现有 `$delivery-loop` 的角色隔离、冻结 E2E 和可见验收流程保持不变。
|
|
180
|
+
|
|
160
181
|
运行器或工具故障不消耗实现者修复回合。隔离 scene 会先继续执行其他场景,再进入 `awaiting-decision`;用户可查看可见浏览器和完整的其余场景证据后重试、确认、跳过、分类为实现/测试缺陷或停止。
|
|
161
182
|
|
|
162
183
|
## 源码开发与验证
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"node": ">=20"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"lint": "node --check ../scripts/setup.mjs && node --check ../scripts/start-mcp.mjs && node --check ../scripts/export-delivery-report.mjs && node --check src/paths.mjs && node --check src/report.mjs && node --check src/runner.mjs && node --check src/server.mjs && node --check tests/fixture-server.mjs && node --check tests/report.test.mjs && node --check tests/runner.test.mjs && node --check tests/mcp-smoke.test.mjs && node --check tests/fixtures/basic.feature.mjs",
|
|
10
|
+
"lint": "node --check ../scripts/setup.mjs && node --check ../scripts/start-mcp.mjs && node --check ../scripts/export-delivery-report.mjs && node --check src/paths.mjs && node --check src/report.mjs && node --check src/runner.mjs && node --check src/fast-runner.mjs && node --check src/server.mjs && node --check tests/fixture-server.mjs && node --check tests/report.test.mjs && node --check tests/runner.test.mjs && node --check tests/fast-runner.test.mjs && node --check tests/mcp-smoke.test.mjs && node --check tests/fixtures/basic.feature.mjs",
|
|
11
11
|
"test": "node --test --test-concurrency=1 tests/*.test.mjs",
|
|
12
12
|
"verify": "npm run lint && npm test"
|
|
13
13
|
},
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { resolveArtifactPath, resolveWorkspaceOutputPaths } from './paths.mjs';
|
|
7
|
+
|
|
8
|
+
const fastSessions = new Map();
|
|
9
|
+
const requirementIdPattern = /^REQ-[A-Z][A-Z0-9]{1,15}-\d{3}$/;
|
|
10
|
+
const reportGroupPattern = /^[a-z0-9][a-z0-9._-]*$/;
|
|
11
|
+
const captureNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.png$/;
|
|
12
|
+
const defaultBrowserRoot = path.resolve(
|
|
13
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
14
|
+
'..',
|
|
15
|
+
'.runtime',
|
|
16
|
+
'ms-playwright'
|
|
17
|
+
);
|
|
18
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH =
|
|
19
|
+
process.env.AUTOBEST_DELIVERY_BROWSERS_PATH || defaultBrowserRoot;
|
|
20
|
+
|
|
21
|
+
async function loadPlaywright() {
|
|
22
|
+
return import('@playwright/test');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function errorDetail(error) {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toEvidencePath(root, absolutePath) {
|
|
30
|
+
return path.relative(root, absolutePath).split(path.sep).join('/');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sha256(filePath) {
|
|
34
|
+
const content = await fs.readFile(filePath);
|
|
35
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeJson(filePath, value) {
|
|
39
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
40
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireSession(sessionId) {
|
|
44
|
+
const session = fastSessions.get(sessionId);
|
|
45
|
+
if (!session) throw new Error(`快速验收会话不存在或已结束:${sessionId}`);
|
|
46
|
+
return session;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateReportFields(input) {
|
|
50
|
+
for (const field of ['reportModule', 'reportGroup', 'reportTitle', 'reportMethod']) {
|
|
51
|
+
if (typeof input[field] !== 'string' || !input[field].trim()) {
|
|
52
|
+
throw new TypeError(`${field} 必须是非空字符串`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!reportGroupPattern.test(input.reportGroup)) {
|
|
56
|
+
throw new TypeError('reportGroup 必须是稳定的 ASCII ID');
|
|
57
|
+
}
|
|
58
|
+
if (input.requirementId && !requirementIdPattern.test(input.requirementId)) {
|
|
59
|
+
throw new TypeError(`无效需求 ID:${input.requirementId}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateRequirement(session, requirementId) {
|
|
64
|
+
if (!requirementId || session.requirementIds.size === 0) return;
|
|
65
|
+
if (!session.requirementIds.has(requirementId)) {
|
|
66
|
+
throw new TypeError(`引用了未声明的需求 ID:${requirementId}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function closeSession(session) {
|
|
71
|
+
clearTimeout(session.timeoutId);
|
|
72
|
+
await session.context?.close().catch(() => undefined);
|
|
73
|
+
await session.browser?.close().catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function installPageEvidence(session, page) {
|
|
77
|
+
page.on('console', message => {
|
|
78
|
+
if (message.type() === 'error') session.consoleErrors.push(message.text());
|
|
79
|
+
});
|
|
80
|
+
page.on('requestfailed', request => {
|
|
81
|
+
session.networkFailures.push({
|
|
82
|
+
url: request.url(),
|
|
83
|
+
method: request.method(),
|
|
84
|
+
errorText: request.failure()?.errorText || 'unknown'
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
page.on('response', response => {
|
|
88
|
+
const request = response.request();
|
|
89
|
+
session.apiCalls.push({
|
|
90
|
+
url: response.url(),
|
|
91
|
+
method: request.method(),
|
|
92
|
+
status: response.status()
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function startFastCheck({
|
|
98
|
+
workspaceRoot,
|
|
99
|
+
baseUrl,
|
|
100
|
+
outputDir,
|
|
101
|
+
apiMode = 'none',
|
|
102
|
+
iteration = 1,
|
|
103
|
+
headed = false,
|
|
104
|
+
viewport = { width: 1400, height: 1000 },
|
|
105
|
+
requirementIds = [],
|
|
106
|
+
sessionTtlMs = 3600000
|
|
107
|
+
}) {
|
|
108
|
+
if (requirementIds.some(requirementId => !requirementIdPattern.test(requirementId))) {
|
|
109
|
+
throw new TypeError('requirementIds 包含格式错误的需求 ID');
|
|
110
|
+
}
|
|
111
|
+
if (new Set(requirementIds).size !== requirementIds.length) {
|
|
112
|
+
throw new TypeError('requirementIds 不得重复');
|
|
113
|
+
}
|
|
114
|
+
const { root, output } = await resolveWorkspaceOutputPaths({ workspaceRoot, outputDir });
|
|
115
|
+
const { chromium } = await loadPlaywright();
|
|
116
|
+
const browser = await chromium.launch({ headless: !headed });
|
|
117
|
+
const context = await browser.newContext({ viewport });
|
|
118
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
|
|
119
|
+
const page = await context.newPage();
|
|
120
|
+
const sessionId = crypto.randomUUID();
|
|
121
|
+
const session = {
|
|
122
|
+
sessionId,
|
|
123
|
+
root,
|
|
124
|
+
output,
|
|
125
|
+
baseUrl,
|
|
126
|
+
apiMode,
|
|
127
|
+
iteration,
|
|
128
|
+
headed,
|
|
129
|
+
viewport,
|
|
130
|
+
browser,
|
|
131
|
+
context,
|
|
132
|
+
page,
|
|
133
|
+
requirementIds: new Set(requirementIds),
|
|
134
|
+
checks: [],
|
|
135
|
+
captures: [],
|
|
136
|
+
actions: [],
|
|
137
|
+
apiCalls: [],
|
|
138
|
+
consoleErrors: [],
|
|
139
|
+
networkFailures: [],
|
|
140
|
+
blockers: [],
|
|
141
|
+
startedAt: new Date().toISOString()
|
|
142
|
+
};
|
|
143
|
+
installPageEvidence(session, page);
|
|
144
|
+
session.timeoutId = setTimeout(async () => {
|
|
145
|
+
fastSessions.delete(sessionId);
|
|
146
|
+
await closeSession(session);
|
|
147
|
+
}, sessionTtlMs);
|
|
148
|
+
session.timeoutId.unref?.();
|
|
149
|
+
fastSessions.set(sessionId, session);
|
|
150
|
+
try {
|
|
151
|
+
await page.goto(baseUrl);
|
|
152
|
+
session.actions.push({ action: 'navigate', url: page.url(), success: true });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
session.actions.push({ action: 'navigate', url: baseUrl, success: false, error: errorDetail(error) });
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
sessionId,
|
|
158
|
+
url: page.url(),
|
|
159
|
+
title: await page.title().catch(() => ''),
|
|
160
|
+
browserMode: headed ? 'headed' : 'headless',
|
|
161
|
+
outputDir: toEvidencePath(root, output),
|
|
162
|
+
expiresInMs: sessionTtlMs
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function fastBrowserSnapshot({ sessionId, timeoutMs = 10000 }) {
|
|
167
|
+
const session = requireSession(sessionId);
|
|
168
|
+
const snapshot = await session.page.locator('body').ariaSnapshot({ timeout: timeoutMs });
|
|
169
|
+
return {
|
|
170
|
+
sessionId,
|
|
171
|
+
url: session.page.url(),
|
|
172
|
+
title: await session.page.title(),
|
|
173
|
+
snapshot: snapshot.slice(0, 30000)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function fastBrowserAction({
|
|
178
|
+
sessionId,
|
|
179
|
+
action,
|
|
180
|
+
locator,
|
|
181
|
+
value,
|
|
182
|
+
url,
|
|
183
|
+
width,
|
|
184
|
+
height,
|
|
185
|
+
timeoutMs = 10000
|
|
186
|
+
}) {
|
|
187
|
+
const session = requireSession(sessionId);
|
|
188
|
+
const page = session.page;
|
|
189
|
+
try {
|
|
190
|
+
if (['click', 'fill', 'select', 'press', 'wait'].includes(action) && !locator) {
|
|
191
|
+
throw new TypeError(`${action} 操作必须提供 locator`);
|
|
192
|
+
}
|
|
193
|
+
if (action === 'viewport' && (!width || !height)) {
|
|
194
|
+
throw new TypeError('viewport 操作必须提供 width 和 height');
|
|
195
|
+
}
|
|
196
|
+
if (action === 'navigate') {
|
|
197
|
+
await page.goto(new URL(url || session.baseUrl, session.baseUrl).toString(), { timeout: timeoutMs });
|
|
198
|
+
} else if (action === 'click') {
|
|
199
|
+
await page.locator(locator).click({ timeout: timeoutMs });
|
|
200
|
+
} else if (action === 'fill') {
|
|
201
|
+
await page.locator(locator).fill(value ?? '', { timeout: timeoutMs });
|
|
202
|
+
} else if (action === 'select') {
|
|
203
|
+
await page.locator(locator).selectOption(value ?? '', { timeout: timeoutMs });
|
|
204
|
+
} else if (action === 'press') {
|
|
205
|
+
await page.locator(locator).press(value ?? '', { timeout: timeoutMs });
|
|
206
|
+
} else if (action === 'wait') {
|
|
207
|
+
await page.locator(locator).waitFor({ state: 'visible', timeout: timeoutMs });
|
|
208
|
+
} else if (action === 'viewport') {
|
|
209
|
+
await page.setViewportSize({ width, height });
|
|
210
|
+
session.viewport = { width, height };
|
|
211
|
+
} else {
|
|
212
|
+
throw new TypeError(`不支持的快速浏览器操作:${action}`);
|
|
213
|
+
}
|
|
214
|
+
const result = { action, locator: locator || null, value: value ?? null, url: page.url(), success: true };
|
|
215
|
+
session.actions.push(result);
|
|
216
|
+
return { sessionId, ...result };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
const result = {
|
|
219
|
+
action,
|
|
220
|
+
locator: locator || null,
|
|
221
|
+
value: value ?? null,
|
|
222
|
+
url: page.url(),
|
|
223
|
+
success: false,
|
|
224
|
+
error: errorDetail(error)
|
|
225
|
+
};
|
|
226
|
+
session.actions.push(result);
|
|
227
|
+
return { sessionId, ...result };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function mockFastCheckRoute({ sessionId, method = 'GET', pathname, status = 200, body = {} }) {
|
|
232
|
+
const session = requireSession(sessionId);
|
|
233
|
+
if (session.apiMode !== 'mock') throw new Error('只有 mock 模式可以安装请求响应');
|
|
234
|
+
await session.page.route(`**${pathname}`, async route => {
|
|
235
|
+
if (route.request().method() !== method) return route.fallback();
|
|
236
|
+
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
|
237
|
+
});
|
|
238
|
+
return { sessionId, method, pathname, status, success: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function evaluateCheck(session, input) {
|
|
242
|
+
const page = session.page;
|
|
243
|
+
const target = input.locator ? page.locator(input.locator) : null;
|
|
244
|
+
if (input.checkType !== 'url' && !target) {
|
|
245
|
+
throw new TypeError(`${input.checkType} 检查必须提供 locator`);
|
|
246
|
+
}
|
|
247
|
+
if (['text', 'value', 'url', 'attribute', 'css'].includes(input.checkType) && typeof input.expected !== 'string') {
|
|
248
|
+
throw new TypeError(`${input.checkType} 检查必须提供 expected`);
|
|
249
|
+
}
|
|
250
|
+
if (input.checkType === 'count' && !Number.isInteger(input.expectedNumber)) {
|
|
251
|
+
throw new TypeError('count 检查必须提供 expectedNumber');
|
|
252
|
+
}
|
|
253
|
+
if (['attribute', 'css'].includes(input.checkType) && !input.property) {
|
|
254
|
+
throw new TypeError(`${input.checkType} 检查必须提供 property`);
|
|
255
|
+
}
|
|
256
|
+
if (input.checkType === 'visible') return target.isVisible({ timeout: input.timeoutMs });
|
|
257
|
+
if (input.checkType === 'hidden') return !(await target.isVisible({ timeout: input.timeoutMs }));
|
|
258
|
+
if (input.checkType === 'text') {
|
|
259
|
+
const actual = (await target.textContent({ timeout: input.timeoutMs })) || '';
|
|
260
|
+
return { actual, isPass: input.exact ? actual.trim() === input.expected : actual.includes(input.expected) };
|
|
261
|
+
}
|
|
262
|
+
if (input.checkType === 'value') {
|
|
263
|
+
const actual = await target.inputValue({ timeout: input.timeoutMs });
|
|
264
|
+
return { actual, isPass: actual === input.expected };
|
|
265
|
+
}
|
|
266
|
+
if (input.checkType === 'count') {
|
|
267
|
+
const actual = await target.count();
|
|
268
|
+
return { actual, isPass: actual === input.expectedNumber };
|
|
269
|
+
}
|
|
270
|
+
if (input.checkType === 'url') {
|
|
271
|
+
const actual = page.url();
|
|
272
|
+
return { actual, isPass: input.exact ? actual === input.expected : actual.includes(input.expected) };
|
|
273
|
+
}
|
|
274
|
+
if (input.checkType === 'attribute') {
|
|
275
|
+
const actual = await target.getAttribute(input.property, { timeout: input.timeoutMs });
|
|
276
|
+
return { actual, isPass: actual === input.expected };
|
|
277
|
+
}
|
|
278
|
+
if (input.checkType === 'css') {
|
|
279
|
+
const actual = await target.evaluate((element, property) => getComputedStyle(element).getPropertyValue(property), input.property);
|
|
280
|
+
return { actual, isPass: actual.trim() === input.expected };
|
|
281
|
+
}
|
|
282
|
+
throw new TypeError(`不支持的检查类型:${input.checkType}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function fastBrowserCheck(input) {
|
|
286
|
+
const session = requireSession(input.sessionId);
|
|
287
|
+
validateReportFields(input);
|
|
288
|
+
validateRequirement(session, input.requirementId);
|
|
289
|
+
if (session.checks.some(check => check.id === input.id)) {
|
|
290
|
+
throw new TypeError(`检查 ID 不得重复:${input.id}`);
|
|
291
|
+
}
|
|
292
|
+
let actual;
|
|
293
|
+
let isPass;
|
|
294
|
+
try {
|
|
295
|
+
const evaluated = await evaluateCheck(session, input);
|
|
296
|
+
if (typeof evaluated === 'boolean') {
|
|
297
|
+
actual = evaluated ? '条件满足' : '条件不满足';
|
|
298
|
+
isPass = evaluated;
|
|
299
|
+
} else {
|
|
300
|
+
actual = evaluated.actual;
|
|
301
|
+
isPass = evaluated.isPass;
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
actual = errorDetail(error);
|
|
305
|
+
isPass = false;
|
|
306
|
+
}
|
|
307
|
+
const check = {
|
|
308
|
+
id: input.id,
|
|
309
|
+
requirementId: input.requirementId || null,
|
|
310
|
+
specSnippet: input.specSnippet,
|
|
311
|
+
reportModule: input.reportModule,
|
|
312
|
+
reportGroup: input.reportGroup,
|
|
313
|
+
reportTitle: input.reportTitle,
|
|
314
|
+
reportMethod: input.reportMethod,
|
|
315
|
+
checkType: input.checkType,
|
|
316
|
+
locator: input.locator || null,
|
|
317
|
+
expect: input.expect,
|
|
318
|
+
actual: actual === null || actual === undefined ? '' : String(actual),
|
|
319
|
+
isPass
|
|
320
|
+
};
|
|
321
|
+
session.checks.push(check);
|
|
322
|
+
return { sessionId: input.sessionId, check };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function captureFastCheck(input) {
|
|
326
|
+
const session = requireSession(input.sessionId);
|
|
327
|
+
validateReportFields(input);
|
|
328
|
+
validateRequirement(session, input.requirementId);
|
|
329
|
+
if (!captureNamePattern.test(input.capture)) {
|
|
330
|
+
throw new TypeError('capture 必须是简单的 PNG 文件名');
|
|
331
|
+
}
|
|
332
|
+
if (!['desktop', 'mobile'].includes(input.reportDevice)) {
|
|
333
|
+
throw new TypeError('reportDevice 必须是 desktop 或 mobile');
|
|
334
|
+
}
|
|
335
|
+
if (session.captures.some(capture => capture.capture === input.capture)) {
|
|
336
|
+
throw new TypeError(`截图文件名不得重复:${input.capture}`);
|
|
337
|
+
}
|
|
338
|
+
const capturePath = resolveArtifactPath(session.output, path.join('captures', input.capture));
|
|
339
|
+
await fs.mkdir(path.dirname(capturePath), { recursive: true });
|
|
340
|
+
if (input.locator) {
|
|
341
|
+
await session.page.locator(input.locator).screenshot({ path: capturePath });
|
|
342
|
+
} else {
|
|
343
|
+
await session.page.screenshot({ path: capturePath, fullPage: input.fullPage !== false });
|
|
344
|
+
}
|
|
345
|
+
const evidencePath = toEvidencePath(session.root, capturePath);
|
|
346
|
+
const capture = {
|
|
347
|
+
requirementId: input.requirementId || null,
|
|
348
|
+
capture: input.capture,
|
|
349
|
+
locator: input.locator || 'page',
|
|
350
|
+
reportModule: input.reportModule,
|
|
351
|
+
reportGroup: input.reportGroup,
|
|
352
|
+
reportTitle: input.reportTitle,
|
|
353
|
+
reportMethod: input.reportMethod,
|
|
354
|
+
reportDevice: input.reportDevice,
|
|
355
|
+
path: evidencePath,
|
|
356
|
+
sha256: await sha256(capturePath)
|
|
357
|
+
};
|
|
358
|
+
session.captures.push(capture);
|
|
359
|
+
return { sessionId: input.sessionId, capture };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function fastResultMarkdown(result) {
|
|
363
|
+
const lines = [
|
|
364
|
+
'# 快速 Playwright 自测结果',
|
|
365
|
+
'',
|
|
366
|
+
`- 状态:${result.status}`,
|
|
367
|
+
`- 证据来源:交互式 Playwright 自测`,
|
|
368
|
+
`- 迭代:${result.iteration}`,
|
|
369
|
+
`- API 模式:${result.apiMode}`,
|
|
370
|
+
'',
|
|
371
|
+
'## 检查结果',
|
|
372
|
+
'',
|
|
373
|
+
'| 检查 | 预期 | 实际 | 状态 |',
|
|
374
|
+
'| --- | --- | --- | --- |',
|
|
375
|
+
...result.checks.map(check =>
|
|
376
|
+
`| ${check.reportTitle} | ${check.expect} | ${String(check.actual).replaceAll('|', '\\|')} | ${check.isPass ? '通过' : '不通过'} |`
|
|
377
|
+
),
|
|
378
|
+
'',
|
|
379
|
+
'## 截图',
|
|
380
|
+
'',
|
|
381
|
+
...result.captureRecords.map(capture => `- ${capture.reportTitle}(${capture.reportDevice}):${capture.path}`),
|
|
382
|
+
''
|
|
383
|
+
];
|
|
384
|
+
return lines.join('\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function finishFastCheck({ sessionId }) {
|
|
388
|
+
const session = requireSession(sessionId);
|
|
389
|
+
fastSessions.delete(sessionId);
|
|
390
|
+
const tracePath = resolveArtifactPath(session.output, 'trace.zip');
|
|
391
|
+
try {
|
|
392
|
+
await session.context.tracing.stop({ path: tracePath });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
session.blockers.push({ kind: 'trace', detail: errorDetail(error) });
|
|
395
|
+
}
|
|
396
|
+
if (session.checks.length === 0) session.blockers.push({ kind: 'evidence', detail: '没有记录检查项' });
|
|
397
|
+
if (session.captures.length === 0) session.blockers.push({ kind: 'evidence', detail: '没有记录报告截图' });
|
|
398
|
+
const coveredRequirementIds = new Set(
|
|
399
|
+
session.checks.map(check => check.requirementId).filter(Boolean)
|
|
400
|
+
);
|
|
401
|
+
for (const requirementId of session.requirementIds) {
|
|
402
|
+
if (!coveredRequirementIds.has(requirementId)) {
|
|
403
|
+
session.blockers.push({ kind: 'traceability', detail: `需求 ${requirementId} 没有检查证据` });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const capturedGroups = new Set(session.captures.map(capture => capture.reportGroup));
|
|
407
|
+
for (const reportGroup of new Set(session.checks.map(check => check.reportGroup))) {
|
|
408
|
+
if (!capturedGroups.has(reportGroup)) {
|
|
409
|
+
session.blockers.push({ kind: 'report-capture', detail: `报告功能组 ${reportGroup} 没有截图` });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const status = session.blockers.length > 0
|
|
413
|
+
? 'blocked'
|
|
414
|
+
: session.checks.some(check => !check.isPass) ? 'failed' : 'passed';
|
|
415
|
+
const resultPath = resolveArtifactPath(session.output, 'fast-check-result.json');
|
|
416
|
+
const markdownPath = resolveArtifactPath(session.output, 'fast-check-result.md');
|
|
417
|
+
const result = {
|
|
418
|
+
evidenceSchemaVersion: 1,
|
|
419
|
+
evidenceSource: 'interactive-playwright',
|
|
420
|
+
status,
|
|
421
|
+
iteration: session.iteration,
|
|
422
|
+
scenarioPath: null,
|
|
423
|
+
scenarioSha256: null,
|
|
424
|
+
metadata: {
|
|
425
|
+
apiMode: session.apiMode,
|
|
426
|
+
requirementIds: [...session.requirementIds],
|
|
427
|
+
reportCaptures: session.captures.map(({ path: _path, sha256: _sha256, ...capture }) => capture),
|
|
428
|
+
visualMappings: []
|
|
429
|
+
},
|
|
430
|
+
apiMode: session.apiMode,
|
|
431
|
+
apiCalls: session.apiCalls,
|
|
432
|
+
checks: session.checks,
|
|
433
|
+
captures: session.captures.map(capture => capture.path),
|
|
434
|
+
captureRecords: session.captures,
|
|
435
|
+
actions: session.actions,
|
|
436
|
+
tracePath: session.blockers.some(blocker => blocker.kind === 'trace') ? null : toEvidencePath(session.root, tracePath),
|
|
437
|
+
consoleErrors: session.consoleErrors,
|
|
438
|
+
networkFailures: session.networkFailures,
|
|
439
|
+
blockers: session.blockers,
|
|
440
|
+
browserMode: session.headed ? 'headed' : 'headless',
|
|
441
|
+
startedAt: session.startedAt,
|
|
442
|
+
finishedAt: new Date().toISOString(),
|
|
443
|
+
resultPath: toEvidencePath(session.root, resultPath)
|
|
444
|
+
};
|
|
445
|
+
try {
|
|
446
|
+
await writeJson(resultPath, result);
|
|
447
|
+
await fs.writeFile(markdownPath, fastResultMarkdown(result), 'utf8');
|
|
448
|
+
return { ...result, markdownPath: toEvidencePath(session.root, markdownPath) };
|
|
449
|
+
} finally {
|
|
450
|
+
await closeSession(session);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function abortFastCheck({ sessionId }) {
|
|
455
|
+
const session = fastSessions.get(sessionId);
|
|
456
|
+
if (!session) return { status: 'not-found', sessionId };
|
|
457
|
+
fastSessions.delete(sessionId);
|
|
458
|
+
await closeSession(session);
|
|
459
|
+
return { status: 'aborted', sessionId };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export async function closeAllFastChecks() {
|
|
463
|
+
const sessions = [...fastSessions.values()];
|
|
464
|
+
fastSessions.clear();
|
|
465
|
+
await Promise.all(sessions.map(closeSession));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function activeFastCheckCount() {
|
|
469
|
+
return fastSessions.size;
|
|
470
|
+
}
|
|
@@ -13,14 +13,9 @@ function isInside(root, target) {
|
|
|
13
13
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export async function
|
|
16
|
+
export async function resolveWorkspaceOutputPaths({ workspaceRoot, outputDir }) {
|
|
17
17
|
const root = await fs.realpath(path.resolve(workspaceRoot));
|
|
18
|
-
const scenario = await fs.realpath(path.resolve(root, scenarioPath));
|
|
19
18
|
const output = path.resolve(root, outputDir);
|
|
20
|
-
|
|
21
|
-
if (!isInside(root, scenario)) {
|
|
22
|
-
throw new PathPolicyError('scenarioPath 必须位于 workspaceRoot 内');
|
|
23
|
-
}
|
|
24
19
|
if (!isInside(root, output)) {
|
|
25
20
|
throw new PathPolicyError('outputDir 必须位于 workspaceRoot 内');
|
|
26
21
|
}
|
|
@@ -30,8 +25,18 @@ export async function resolveWorkspacePaths({ workspaceRoot, scenarioPath, outpu
|
|
|
30
25
|
if (!isInside(root, realOutput)) {
|
|
31
26
|
throw new PathPolicyError('outputDir 解析后的路径位于 workspaceRoot 外');
|
|
32
27
|
}
|
|
28
|
+
return { root, output: realOutput };
|
|
29
|
+
}
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
export async function resolveWorkspacePaths({ workspaceRoot, scenarioPath, outputDir }) {
|
|
32
|
+
const root = await fs.realpath(path.resolve(workspaceRoot));
|
|
33
|
+
const scenario = await fs.realpath(path.resolve(root, scenarioPath));
|
|
34
|
+
|
|
35
|
+
if (!isInside(root, scenario)) {
|
|
36
|
+
throw new PathPolicyError('scenarioPath 必须位于 workspaceRoot 内');
|
|
37
|
+
}
|
|
38
|
+
const resolved = await resolveWorkspaceOutputPaths({ workspaceRoot: root, outputDir });
|
|
39
|
+
return { root, scenario, output: resolved.output };
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export function resolveArtifactPath(outputDir, relativePath) {
|