@autobest-ui/agent 1.0.0
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 +182 -0
- package/bin/sync-assets.mjs +126 -0
- package/bin/sync-assets.test.mjs +64 -0
- package/mcp/azurepr-mcp-bridge/README.md +37 -0
- package/mcp/azurepr-mcp-bridge/azure-devops.js +327 -0
- package/mcp/azurepr-mcp-bridge/config.toml.example +7 -0
- package/mcp/azurepr-mcp-bridge/index.js +65 -0
- package/mcp/azurepr-mcp-bridge/index.test.js +116 -0
- package/mcp/azurepr-mcp-bridge/package.json +22 -0
- package/mcp/rag-mcp-bridge/README.md +42 -0
- package/mcp/rag-mcp-bridge/codex-system-prompt.md +20 -0
- package/mcp/rag-mcp-bridge/config.toml.example +12 -0
- package/mcp/rag-mcp-bridge/index.js +361 -0
- package/mcp/rag-mcp-bridge/index.test.js +56 -0
- package/mcp/rag-mcp-bridge/package.json +21 -0
- package/package.json +44 -0
- package/plugins/autobest-delivery/.codex-plugin/plugin.json +25 -0
- package/plugins/autobest-delivery/.mcp.json +11 -0
- package/plugins/autobest-delivery/README.md +164 -0
- package/plugins/autobest-delivery/assets/delivery-report-template.xlsx +0 -0
- package/plugins/autobest-delivery/mcp-server/npm-shrinkwrap.json +3511 -0
- package/plugins/autobest-delivery/mcp-server/package.json +23 -0
- package/plugins/autobest-delivery/mcp-server/src/paths.mjs +43 -0
- package/plugins/autobest-delivery/mcp-server/src/report.mjs +605 -0
- package/plugins/autobest-delivery/mcp-server/src/runner.mjs +489 -0
- package/plugins/autobest-delivery/mcp-server/src/server.mjs +199 -0
- package/plugins/autobest-delivery/mcp-server/tests/fixture-server.mjs +36 -0
- package/plugins/autobest-delivery/mcp-server/tests/fixtures/basic.feature.mjs +68 -0
- package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +83 -0
- package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +254 -0
- package/plugins/autobest-delivery/mcp-server/tests/runner.test.mjs +354 -0
- package/plugins/autobest-delivery/scripts/export-delivery-report.mjs +41 -0
- package/plugins/autobest-delivery/scripts/setup.mjs +295 -0
- package/plugins/autobest-delivery/scripts/setup.test.mjs +145 -0
- package/plugins/autobest-delivery/scripts/start-mcp.mjs +7 -0
- package/plugins/autobest-delivery/skills/code-audit/SKILL.md +24 -0
- package/plugins/autobest-delivery/skills/code-audit/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/code-craft/SKILL.md +27 -0
- package/plugins/autobest-delivery/skills/code-craft/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/delivery-loop/SKILL.md +43 -0
- package/plugins/autobest-delivery/skills/delivery-loop/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +235 -0
- package/plugins/autobest-delivery/skills/e2e-gen-spec/SKILL.md +35 -0
- package/plugins/autobest-delivery/skills/e2e-gen-spec/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/e2e-ui-checker/SKILL.md +30 -0
- package/plugins/autobest-delivery/skills/e2e-ui-checker/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/export-report/SKILL.md +66 -0
- package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +8 -0
- package/plugins/autobest-delivery/skills/ui-structure-guard/SKILL.md +24 -0
- package/plugins/autobest-delivery/skills/ui-structure-guard/agents/openai.yaml +7 -0
- package/skills/README.md +38 -0
- package/skills/common/figma-ui-capture/SKILL.md +197 -0
- package/skills/common/figma-ui-capture/agents/openai.yaml +4 -0
- package/skills/common/ui-prd-scope/SKILL.md +67 -0
- package/skills/common/ui-prd-scope/agents/openai.yaml +4 -0
- package/skills/common/ui-prd-scope/references/scope-schema.md +158 -0
- package/skills/common/ui-prd-scope/scripts/validate-scope-bundle.mjs +302 -0
- package/skills/react/react-code-standards/SKILL.md +78 -0
- package/skills/react/react-code-standards/agents/openai.yaml +4 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@autobest/delivery-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
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",
|
|
11
|
+
"test": "node --test --test-concurrency=1 tests/*.test.mjs",
|
|
12
|
+
"verify": "npm run lint && npm test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
16
|
+
"@playwright/test": "1.62.1",
|
|
17
|
+
"exceljs": "4.4.0",
|
|
18
|
+
"zod": "4.4.3"
|
|
19
|
+
},
|
|
20
|
+
"overrides": {
|
|
21
|
+
"uuid": "11.1.1"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class PathPolicyError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'PathPolicyError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isInside(root, target) {
|
|
12
|
+
const relative = path.relative(root, target);
|
|
13
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function resolveWorkspacePaths({ workspaceRoot, scenarioPath, outputDir }) {
|
|
17
|
+
const root = await fs.realpath(path.resolve(workspaceRoot));
|
|
18
|
+
const scenario = await fs.realpath(path.resolve(root, scenarioPath));
|
|
19
|
+
const output = path.resolve(root, outputDir);
|
|
20
|
+
|
|
21
|
+
if (!isInside(root, scenario)) {
|
|
22
|
+
throw new PathPolicyError('scenarioPath 必须位于 workspaceRoot 内');
|
|
23
|
+
}
|
|
24
|
+
if (!isInside(root, output)) {
|
|
25
|
+
throw new PathPolicyError('outputDir 必须位于 workspaceRoot 内');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await fs.mkdir(output, { recursive: true });
|
|
29
|
+
const realOutput = await fs.realpath(output);
|
|
30
|
+
if (!isInside(root, realOutput)) {
|
|
31
|
+
throw new PathPolicyError('outputDir 解析后的路径位于 workspaceRoot 外');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { root, scenario, output: realOutput };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveArtifactPath(outputDir, relativePath) {
|
|
38
|
+
const target = path.resolve(outputDir, relativePath);
|
|
39
|
+
if (!isInside(outputDir, target)) {
|
|
40
|
+
throw new PathPolicyError('产物路径必须位于 outputDir 内');
|
|
41
|
+
}
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import ExcelJS from 'exceljs';
|
|
7
|
+
|
|
8
|
+
const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
9
|
+
const defaultTemplatePath = path.join(pluginRoot, 'assets', 'delivery-report-template.xlsx');
|
|
10
|
+
const expectedHeaders = ['页面/模块', '自测点', '自测方式', '通过', '截图1', '截图2'];
|
|
11
|
+
|
|
12
|
+
function isInside(parent, child) {
|
|
13
|
+
const relative = path.relative(parent, child);
|
|
14
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fileExists(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
await access(filePath);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readJson(filePath, label) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(`${label} 无法读取或不是有效 JSON:${filePath} (${error.message})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function findWorkspaceRoot(startPath) {
|
|
35
|
+
let current = path.resolve(startPath);
|
|
36
|
+
while (true) {
|
|
37
|
+
if (await fileExists(path.join(current, '.git'))) {
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
const parent = path.dirname(current);
|
|
41
|
+
if (parent === current) {
|
|
42
|
+
throw new Error(`无法从功能目录定位 Git 工作区:${startPath}`);
|
|
43
|
+
}
|
|
44
|
+
current = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveEvidencePath(rawPath, { featureDir, workspaceRoot }) {
|
|
49
|
+
if (!rawPath || typeof rawPath !== 'string') {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const resolved = path.isAbsolute(rawPath)
|
|
53
|
+
? path.resolve(rawPath)
|
|
54
|
+
: path.resolve(workspaceRoot, rawPath);
|
|
55
|
+
if (!isInside(featureDir, resolved)) {
|
|
56
|
+
throw new Error(`证据路径越出功能目录:${rawPath}`);
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseSpecStories(specText) {
|
|
62
|
+
const section = specText.match(/(?:^|\n)## 用户故事\s*\n([\s\S]*?)(?=\n## |$)/);
|
|
63
|
+
if (!section) {
|
|
64
|
+
throw new Error('spec.md 缺少“用户故事”章节,无法生成自测点');
|
|
65
|
+
}
|
|
66
|
+
const stories = [];
|
|
67
|
+
const lines = section[1].split(/\r?\n/);
|
|
68
|
+
let current = null;
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const match = line.match(/^\s*(\d+)\.\s+(.+?)\s*$/);
|
|
71
|
+
if (match) {
|
|
72
|
+
current = {
|
|
73
|
+
number: Number(match[1]),
|
|
74
|
+
id: `story-${String(match[1]).padStart(2, '0')}`,
|
|
75
|
+
text: match[2]
|
|
76
|
+
};
|
|
77
|
+
stories.push(current);
|
|
78
|
+
} else if (current && line.trim()) {
|
|
79
|
+
current.text += ` ${line.trim()}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (stories.length === 0) {
|
|
83
|
+
throw new Error('spec.md 的“用户故事”章节没有编号功能点');
|
|
84
|
+
}
|
|
85
|
+
return stories;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function findLatestRunnerResult(featureDir) {
|
|
89
|
+
const runsDir = path.join(featureDir, 'e2e', 'runs');
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = await readdir(runsDir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const candidates = entries
|
|
97
|
+
.filter(entry => entry.isDirectory() && /^iteration-\d+$/.test(entry.name))
|
|
98
|
+
.map(entry => ({
|
|
99
|
+
iteration: Number(entry.name.slice('iteration-'.length)),
|
|
100
|
+
filePath: path.join(runsDir, entry.name, 'runner-result.json')
|
|
101
|
+
}))
|
|
102
|
+
.sort((left, right) => right.iteration - left.iteration);
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
if (await fileExists(candidate.filePath)) {
|
|
105
|
+
return candidate.filePath;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function selectRunnerResult(checker, context) {
|
|
112
|
+
if (checker.runnerResult) {
|
|
113
|
+
const declared = resolveEvidencePath(checker.runnerResult, context);
|
|
114
|
+
if (await fileExists(declared)) {
|
|
115
|
+
return declared;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return findLatestRunnerResult(context.featureDir);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mergeChecks(checker, runner) {
|
|
122
|
+
const merged = new Map();
|
|
123
|
+
for (const check of runner.checks || []) {
|
|
124
|
+
if (check && check.id) {
|
|
125
|
+
merged.set(check.id, check);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const check of checker.checks || []) {
|
|
129
|
+
if (check && check.id) {
|
|
130
|
+
merged.set(check.id, { ...(merged.get(check.id) || {}), ...check });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return merged;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function statusFor(check) {
|
|
137
|
+
if (check?.isPass === true) {
|
|
138
|
+
return '通过';
|
|
139
|
+
}
|
|
140
|
+
if (check?.isPass === false) {
|
|
141
|
+
return '不通过';
|
|
142
|
+
}
|
|
143
|
+
return 'Blocked';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function nonEmptyString(value) {
|
|
147
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasMetadataGrouping(stories, checks) {
|
|
151
|
+
return stories.every(story => {
|
|
152
|
+
const check = checks.get(story.id);
|
|
153
|
+
return ['reportModule', 'reportGroup', 'reportTitle', 'reportMethod']
|
|
154
|
+
.every(field => nonEmptyString(check?.[field]));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildMetadataGroups(stories, checks) {
|
|
159
|
+
const groups = new Map();
|
|
160
|
+
for (const story of stories) {
|
|
161
|
+
const check = checks.get(story.id);
|
|
162
|
+
const groupId = nonEmptyString(check.reportGroup);
|
|
163
|
+
let group = groups.get(groupId);
|
|
164
|
+
if (!group) {
|
|
165
|
+
group = {
|
|
166
|
+
id: groupId,
|
|
167
|
+
explicitGroup: groupId,
|
|
168
|
+
module: nonEmptyString(check.reportModule),
|
|
169
|
+
title: nonEmptyString(check.reportTitle),
|
|
170
|
+
method: nonEmptyString(check.reportMethod),
|
|
171
|
+
items: [],
|
|
172
|
+
};
|
|
173
|
+
groups.set(groupId, group);
|
|
174
|
+
} else if (
|
|
175
|
+
group.module !== nonEmptyString(check.reportModule) ||
|
|
176
|
+
group.title !== nonEmptyString(check.reportTitle) ||
|
|
177
|
+
group.method !== nonEmptyString(check.reportMethod)
|
|
178
|
+
) {
|
|
179
|
+
throw new Error(`reportGroup ${groupId} 的 module、title 或 method 不一致`);
|
|
180
|
+
}
|
|
181
|
+
group.items.push({ story, check });
|
|
182
|
+
}
|
|
183
|
+
return [...groups.values()].map(group => ({
|
|
184
|
+
...group,
|
|
185
|
+
status: worstStatus(group.items.map(item => statusFor(item.check)))
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function worstStatus(statuses) {
|
|
190
|
+
const priority = { '通过': 1, '不通过': 2, Blocked: 3 };
|
|
191
|
+
return statuses.reduce(
|
|
192
|
+
(worst, status) => priority[status] > priority[worst] ? status : worst,
|
|
193
|
+
'通过'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function emptyStatusCounts() {
|
|
198
|
+
return { '通过': 0, '不通过': 0, Blocked: 0 };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function reportMethodFor(group) {
|
|
202
|
+
const exceptions = group.items.filter(({ check }) => statusFor(check) !== '通过');
|
|
203
|
+
if (exceptions.length === 0) {
|
|
204
|
+
return group.method;
|
|
205
|
+
}
|
|
206
|
+
const exceptionLines = exceptions.map(({ story, check }) => {
|
|
207
|
+
if (!check) {
|
|
208
|
+
return `未执行:${story.text}`;
|
|
209
|
+
}
|
|
210
|
+
const expectation = check.expect || check.specSnippet || story.text;
|
|
211
|
+
const actual = check.actual ? `;实际:${check.actual}` : '';
|
|
212
|
+
return `${expectation}${actual}`;
|
|
213
|
+
});
|
|
214
|
+
return [
|
|
215
|
+
group.method,
|
|
216
|
+
`异常 ${exceptions.length} 项:`,
|
|
217
|
+
...exceptionLines,
|
|
218
|
+
].join('\n');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildCaptureRecords(runner) {
|
|
222
|
+
const mappings = runner.metadata?.visualMappings || [];
|
|
223
|
+
const mappingByCapture = new Map(mappings.map(mapping => [mapping.capture, mapping]));
|
|
224
|
+
return (runner.captures || []).map(rawPath => {
|
|
225
|
+
const basename = path.basename(rawPath);
|
|
226
|
+
const mapping = mappingByCapture.get(basename) || {};
|
|
227
|
+
const lower = basename.toLowerCase();
|
|
228
|
+
return {
|
|
229
|
+
rawPath,
|
|
230
|
+
basename,
|
|
231
|
+
mapping,
|
|
232
|
+
device: candidateDevice(mapping.reportDevice) ||
|
|
233
|
+
(lower.includes('mobile') ? 'mobile' : lower.includes('desktop') ? 'desktop' : null),
|
|
234
|
+
scene: mapping.scene || basename.replace(/\.(png|jpe?g)$/i, '')
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function candidateDevice(value) {
|
|
240
|
+
return value === 'desktop' || value === 'mobile' ? value : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function chooseGroupCapture(records, group, device) {
|
|
244
|
+
const manifestCapture = group[`${device}Capture`];
|
|
245
|
+
if (manifestCapture) {
|
|
246
|
+
const candidates = records.filter(candidate =>
|
|
247
|
+
candidate.rawPath === manifestCapture || candidate.basename === manifestCapture
|
|
248
|
+
);
|
|
249
|
+
if (candidates.length !== 1) {
|
|
250
|
+
throw new Error(`功能组 ${group.id} 的 ${device}Capture 无法唯一匹配:${manifestCapture}`);
|
|
251
|
+
}
|
|
252
|
+
return candidates[0];
|
|
253
|
+
}
|
|
254
|
+
let best = null;
|
|
255
|
+
let bestScore = -1;
|
|
256
|
+
for (const candidate of records) {
|
|
257
|
+
if (candidate.device !== device) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const mappingGroup = typeof candidate.mapping.reportGroup === 'string'
|
|
261
|
+
? candidate.mapping.reportGroup.trim()
|
|
262
|
+
: '';
|
|
263
|
+
const groupMatches = Boolean(group.explicitGroup && mappingGroup === group.explicitGroup);
|
|
264
|
+
if (!groupMatches) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const score = 20;
|
|
268
|
+
if (score > bestScore) {
|
|
269
|
+
best = candidate;
|
|
270
|
+
bestScore = score;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return best;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function sha256Text(value) {
|
|
277
|
+
return createHash('sha256').update(value).digest('hex');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function validateManifestSource(manifest, { specText, checker, runner }) {
|
|
281
|
+
const expected = {
|
|
282
|
+
specSha256: sha256Text(specText),
|
|
283
|
+
iteration: runner.iteration ?? checker.iteration ?? null,
|
|
284
|
+
scenarioSha256: runner.scenarioSha256 ?? checker.scenarioSha256 ?? null
|
|
285
|
+
};
|
|
286
|
+
if (!manifest?.source || manifest.source.specSha256 !== expected.specSha256) {
|
|
287
|
+
throw new Error('语义分组清单与当前 spec.md 不一致');
|
|
288
|
+
}
|
|
289
|
+
if (manifest.source.iteration !== expected.iteration) {
|
|
290
|
+
throw new Error('语义分组清单与当前 Runner iteration 不一致');
|
|
291
|
+
}
|
|
292
|
+
if (manifest.source.scenarioSha256 !== expected.scenarioSha256) {
|
|
293
|
+
throw new Error('语义分组清单与当前冻结场景 SHA-256 不一致');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildManifestGroups(manifest, stories, checks, sourceContext) {
|
|
298
|
+
if (manifest?.version !== 1 || !Array.isArray(manifest.groups) || manifest.groups.length === 0) {
|
|
299
|
+
throw new Error('语义分组清单必须使用 version 1 并包含非空 groups');
|
|
300
|
+
}
|
|
301
|
+
validateManifestSource(manifest, sourceContext);
|
|
302
|
+
const storyById = new Map(stories.map(story => [story.id, story]));
|
|
303
|
+
const assigned = new Set();
|
|
304
|
+
const groupIds = new Set();
|
|
305
|
+
const groups = manifest.groups.map(group => {
|
|
306
|
+
const id = nonEmptyString(group?.id);
|
|
307
|
+
const module = nonEmptyString(group?.module);
|
|
308
|
+
const title = nonEmptyString(group?.title);
|
|
309
|
+
const method = nonEmptyString(group?.method);
|
|
310
|
+
if (!id || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || !module || !title || !method) {
|
|
311
|
+
throw new Error('语义分组必须包含合法 id、module、title 和 method');
|
|
312
|
+
}
|
|
313
|
+
if (groupIds.has(id)) {
|
|
314
|
+
throw new Error(`语义分组 ID 重复:${id}`);
|
|
315
|
+
}
|
|
316
|
+
groupIds.add(id);
|
|
317
|
+
if (!Array.isArray(group.storyIds) || group.storyIds.length === 0) {
|
|
318
|
+
throw new Error(`语义分组 ${id} 必须包含非空 storyIds`);
|
|
319
|
+
}
|
|
320
|
+
const items = group.storyIds.map(storyId => {
|
|
321
|
+
const story = storyById.get(storyId);
|
|
322
|
+
if (!story) {
|
|
323
|
+
throw new Error(`语义分组 ${id} 引用了未知 story:${storyId}`);
|
|
324
|
+
}
|
|
325
|
+
if (assigned.has(storyId)) {
|
|
326
|
+
throw new Error(`story 被重复分组:${storyId}`);
|
|
327
|
+
}
|
|
328
|
+
assigned.add(storyId);
|
|
329
|
+
return { story, check: checks.get(storyId) };
|
|
330
|
+
});
|
|
331
|
+
for (const field of ['desktopCapture', 'mobileCapture']) {
|
|
332
|
+
if (group[field] !== undefined && !nonEmptyString(group[field])) {
|
|
333
|
+
throw new Error(`语义分组 ${id} 的 ${field} 必须是非空字符串`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
id,
|
|
338
|
+
explicitGroup: null,
|
|
339
|
+
module,
|
|
340
|
+
title,
|
|
341
|
+
method,
|
|
342
|
+
items,
|
|
343
|
+
desktopCapture: nonEmptyString(group.desktopCapture),
|
|
344
|
+
mobileCapture: nonEmptyString(group.mobileCapture),
|
|
345
|
+
status: worstStatus(items.map(item => statusFor(item.check)))
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
const missing = stories.filter(story => !assigned.has(story.id)).map(story => story.id);
|
|
349
|
+
if (missing.length > 0) {
|
|
350
|
+
throw new Error(`语义分组未覆盖全部 story:${missing.join('、')}`);
|
|
351
|
+
}
|
|
352
|
+
return groups;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function resolveReportGroups({ stories, checks, specText, checker, runner, context, groupingPath }) {
|
|
356
|
+
if (!groupingPath && hasMetadataGrouping(stories, checks)) {
|
|
357
|
+
return { groups: buildMetadataGroups(stories, checks), source: 'metadata', groupingPath: null };
|
|
358
|
+
}
|
|
359
|
+
let resolvedGroupingPath = groupingPath
|
|
360
|
+
? resolveEvidencePath(groupingPath, context)
|
|
361
|
+
: path.join(context.featureDir, 'report', 'report-groups.json');
|
|
362
|
+
if (!(await fileExists(resolvedGroupingPath))) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`旧证据缺少语义分组清单:${resolvedGroupingPath};不得按 scene 猜测组件或功能`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const manifest = await readJson(resolvedGroupingPath, '语义分组清单');
|
|
368
|
+
return {
|
|
369
|
+
groups: buildManifestGroups(manifest, stories, checks, { specText, checker, runner }),
|
|
370
|
+
source: 'manifest',
|
|
371
|
+
groupingPath: resolvedGroupingPath
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function pngDimensions(buffer) {
|
|
376
|
+
const signature = '89504e470d0a1a0a';
|
|
377
|
+
if (buffer.length >= 24 && buffer.subarray(0, 8).toString('hex') === signature) {
|
|
378
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function jpegDimensions(buffer) {
|
|
384
|
+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
let offset = 2;
|
|
388
|
+
while (offset + 9 < buffer.length) {
|
|
389
|
+
if (buffer[offset] !== 0xff) {
|
|
390
|
+
offset += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const marker = buffer[offset + 1];
|
|
394
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
395
|
+
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
|
|
396
|
+
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
|
|
397
|
+
}
|
|
398
|
+
if (length < 2) {
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
offset += length + 2;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function addScreenshot(workbook, worksheet, rowNumber, columnNumber, capture, context, imageCache) {
|
|
407
|
+
if (!capture) {
|
|
408
|
+
return { placed: false, added: false };
|
|
409
|
+
}
|
|
410
|
+
const imagePath = resolveEvidencePath(capture.rawPath, context);
|
|
411
|
+
if (!(await fileExists(imagePath))) {
|
|
412
|
+
return { placed: false, added: false };
|
|
413
|
+
}
|
|
414
|
+
const extension = path.extname(imagePath).toLowerCase();
|
|
415
|
+
if (!['.png', '.jpg', '.jpeg'].includes(extension)) {
|
|
416
|
+
return { placed: false, added: false };
|
|
417
|
+
}
|
|
418
|
+
let image = imageCache.get(imagePath);
|
|
419
|
+
let added = false;
|
|
420
|
+
if (!image) {
|
|
421
|
+
const buffer = await readFile(imagePath);
|
|
422
|
+
const dimensions = pngDimensions(buffer) || jpegDimensions(buffer);
|
|
423
|
+
if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) {
|
|
424
|
+
return { placed: false, added: false };
|
|
425
|
+
}
|
|
426
|
+
image = {
|
|
427
|
+
imageId: workbook.addImage({
|
|
428
|
+
buffer,
|
|
429
|
+
extension: extension === '.png' ? 'png' : 'jpeg'
|
|
430
|
+
}),
|
|
431
|
+
dimensions
|
|
432
|
+
};
|
|
433
|
+
imageCache.set(imagePath, image);
|
|
434
|
+
added = true;
|
|
435
|
+
}
|
|
436
|
+
const maxWidth = 220;
|
|
437
|
+
const maxHeight = 150;
|
|
438
|
+
const scale = Math.min(
|
|
439
|
+
maxWidth / image.dimensions.width,
|
|
440
|
+
maxHeight / image.dimensions.height,
|
|
441
|
+
1
|
|
442
|
+
);
|
|
443
|
+
const width = Math.max(1, Math.round(image.dimensions.width * scale));
|
|
444
|
+
const height = Math.max(1, Math.round(image.dimensions.height * scale));
|
|
445
|
+
worksheet.addImage(image.imageId, {
|
|
446
|
+
tl: { col: columnNumber - 1 + 0.08, row: rowNumber - 1 + 0.08 },
|
|
447
|
+
ext: { width, height },
|
|
448
|
+
editAs: 'oneCell'
|
|
449
|
+
});
|
|
450
|
+
worksheet.getRow(rowNumber).height = Math.max(worksheet.getRow(rowNumber).height || 0, height * 0.75 + 10);
|
|
451
|
+
return { placed: true, added };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function styleDataRow(row, status) {
|
|
455
|
+
const border = {
|
|
456
|
+
top: { style: 'thin', color: { argb: 'FFD9D9D9' } },
|
|
457
|
+
left: { style: 'thin', color: { argb: 'FFD9D9D9' } },
|
|
458
|
+
bottom: { style: 'thin', color: { argb: 'FFD9D9D9' } },
|
|
459
|
+
right: { style: 'thin', color: { argb: 'FFD9D9D9' } }
|
|
460
|
+
};
|
|
461
|
+
row.eachCell({ includeEmpty: true }, cell => {
|
|
462
|
+
cell.alignment = { vertical: 'middle', wrapText: true };
|
|
463
|
+
cell.border = border;
|
|
464
|
+
});
|
|
465
|
+
const statusCell = row.getCell(4);
|
|
466
|
+
statusCell.alignment = { horizontal: 'center', vertical: 'middle' };
|
|
467
|
+
const fills = {
|
|
468
|
+
'通过': 'FFE2F0D9',
|
|
469
|
+
'不通过': 'FFF4CCCC',
|
|
470
|
+
Blocked: 'FFFFE599'
|
|
471
|
+
};
|
|
472
|
+
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: fills[status] } };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function validateTemplate(workbook) {
|
|
476
|
+
const worksheet = workbook.worksheets[0];
|
|
477
|
+
if (!worksheet) {
|
|
478
|
+
throw new Error('Excel 模板没有工作表');
|
|
479
|
+
}
|
|
480
|
+
const actual = expectedHeaders.map((_, index) => String(worksheet.getCell(1, index + 1).value || '').trim());
|
|
481
|
+
if (actual.some((header, index) => header !== expectedHeaders[index])) {
|
|
482
|
+
throw new Error(`Excel 模板表头不匹配,期望:${expectedHeaders.join('、')}`);
|
|
483
|
+
}
|
|
484
|
+
return worksheet;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function configureWorksheet(worksheet) {
|
|
488
|
+
worksheet.views = [{ state: 'frozen', ySplit: 1 }];
|
|
489
|
+
worksheet.autoFilter = { from: 'A1', to: 'F1' };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function exportDeliveryReport(options) {
|
|
493
|
+
const featureDir = path.resolve(options.featureDir);
|
|
494
|
+
const workspaceRoot = path.resolve(options.workspaceRoot || await findWorkspaceRoot(featureDir));
|
|
495
|
+
if (!isInside(workspaceRoot, featureDir)) {
|
|
496
|
+
throw new Error(`功能目录不在工作区内:${featureDir}`);
|
|
497
|
+
}
|
|
498
|
+
const outputPath = path.resolve(options.outputPath || path.join(featureDir, 'delivery-report.xlsx'));
|
|
499
|
+
if (!isInside(featureDir, outputPath)) {
|
|
500
|
+
throw new Error(`报告输出路径必须位于功能目录内:${outputPath}`);
|
|
501
|
+
}
|
|
502
|
+
const context = { featureDir, workspaceRoot };
|
|
503
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
504
|
+
const checkerPath = path.join(featureDir, 'checker', 'checker-result.json');
|
|
505
|
+
if (!(await fileExists(specPath))) {
|
|
506
|
+
throw new Error(`缺少 spec.md:${specPath}`);
|
|
507
|
+
}
|
|
508
|
+
if (!(await fileExists(checkerPath))) {
|
|
509
|
+
throw new Error(`缺少 Checker 结果:${checkerPath}`);
|
|
510
|
+
}
|
|
511
|
+
const specText = await readFile(specPath, 'utf8');
|
|
512
|
+
const stories = parseSpecStories(specText);
|
|
513
|
+
const checker = await readJson(checkerPath, 'Checker 结果');
|
|
514
|
+
const runnerPath = await selectRunnerResult(checker, context);
|
|
515
|
+
if (!runnerPath) {
|
|
516
|
+
throw new Error(`缺少 Runner 结果:${path.join(featureDir, 'e2e', 'runs')}`);
|
|
517
|
+
}
|
|
518
|
+
const runner = await readJson(runnerPath, 'Runner 结果');
|
|
519
|
+
const checks = mergeChecks(checker, runner);
|
|
520
|
+
const captures = buildCaptureRecords(runner);
|
|
521
|
+
const grouping = await resolveReportGroups({
|
|
522
|
+
stories,
|
|
523
|
+
checks,
|
|
524
|
+
specText,
|
|
525
|
+
checker,
|
|
526
|
+
runner,
|
|
527
|
+
context,
|
|
528
|
+
groupingPath: options.groupingPath
|
|
529
|
+
});
|
|
530
|
+
const groups = grouping.groups;
|
|
531
|
+
|
|
532
|
+
const workbook = new ExcelJS.Workbook();
|
|
533
|
+
await workbook.xlsx.readFile(options.templatePath || defaultTemplatePath);
|
|
534
|
+
const worksheet = await validateTemplate(workbook);
|
|
535
|
+
for (const extraWorksheet of workbook.worksheets.slice(1)) {
|
|
536
|
+
workbook.removeWorksheet(extraWorksheet.id);
|
|
537
|
+
}
|
|
538
|
+
worksheet.name = '自测报告';
|
|
539
|
+
if (worksheet.rowCount > 1) {
|
|
540
|
+
worksheet.spliceRows(2, worksheet.rowCount - 1);
|
|
541
|
+
}
|
|
542
|
+
configureWorksheet(worksheet);
|
|
543
|
+
|
|
544
|
+
let embeddedScreenshots = 0;
|
|
545
|
+
let screenshotPlacements = 0;
|
|
546
|
+
const imageCache = new Map();
|
|
547
|
+
const counts = emptyStatusCounts();
|
|
548
|
+
for (const group of groups) {
|
|
549
|
+
counts[group.status] += 1;
|
|
550
|
+
const row = worksheet.addRow([
|
|
551
|
+
group.module,
|
|
552
|
+
group.title,
|
|
553
|
+
reportMethodFor(group),
|
|
554
|
+
group.status,
|
|
555
|
+
'',
|
|
556
|
+
''
|
|
557
|
+
]);
|
|
558
|
+
row.height = 54;
|
|
559
|
+
styleDataRow(row, group.status);
|
|
560
|
+
const desktop = chooseGroupCapture(captures, group, 'desktop');
|
|
561
|
+
const mobile = chooseGroupCapture(captures, group, 'mobile');
|
|
562
|
+
for (const [columnNumber, capture] of [[5, desktop], [6, mobile]]) {
|
|
563
|
+
const imageResult = await addScreenshot(
|
|
564
|
+
workbook,
|
|
565
|
+
worksheet,
|
|
566
|
+
row.number,
|
|
567
|
+
columnNumber,
|
|
568
|
+
capture,
|
|
569
|
+
context,
|
|
570
|
+
imageCache
|
|
571
|
+
);
|
|
572
|
+
if (imageResult.placed) {
|
|
573
|
+
screenshotPlacements += 1;
|
|
574
|
+
}
|
|
575
|
+
if (imageResult.added) {
|
|
576
|
+
embeddedScreenshots += 1;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
582
|
+
const temporaryPath = path.join(
|
|
583
|
+
path.dirname(outputPath),
|
|
584
|
+
`.${path.basename(outputPath)}.${process.pid}.${Date.now()}.tmp`
|
|
585
|
+
);
|
|
586
|
+
try {
|
|
587
|
+
await workbook.xlsx.writeFile(temporaryPath);
|
|
588
|
+
await rename(temporaryPath, outputPath);
|
|
589
|
+
} finally {
|
|
590
|
+
await rm(temporaryPath, { force: true });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
outputPath,
|
|
595
|
+
iteration: runner.iteration ?? checker.iteration ?? null,
|
|
596
|
+
checkerStatus: checker.status,
|
|
597
|
+
runnerStatus: runner.status,
|
|
598
|
+
rows: groups.length,
|
|
599
|
+
statuses: counts,
|
|
600
|
+
embeddedScreenshots,
|
|
601
|
+
screenshotPlacements,
|
|
602
|
+
groupingSource: grouping.source,
|
|
603
|
+
groupingPath: grouping.groupingPath
|
|
604
|
+
};
|
|
605
|
+
}
|