@epoint-testtech/stage-create 0.0.3-alpha.1
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/dist/index.d.ts +2 -0
- package/dist/index.js +129 -0
- package/dist/render-template.d.ts +10 -0
- package/dist/render-template.js +34 -0
- package/dist/stage-context.d.ts +27 -0
- package/dist/stage-context.js +175 -0
- package/package.json +33 -0
- package/templates/default/.env.example +18 -0
- package/templates/default/package.json.tpl +20 -0
- package/templates/default/playwright.config.ts +44 -0
- package/templates/default/setup-mac.sh +29 -0
- package/templates/default/setup-windows.cmd +13 -0
- package/templates/default/setup-windows.ps1 +26 -0
- package/templates/default/src/global.setup.ts +5 -0
- package/templates/default/tsconfig.json +12 -0
- package/templates/glue/.env.example +18 -0
- package/templates/glue/package.json.tpl +21 -0
- package/templates/glue/playwright.config.ts +56 -0
- package/templates/glue/src/data/stage-config.yaml.tpl +13 -0
- package/templates/glue/src/gap-executor/gap-executor.ts +162 -0
- package/templates/glue/src/gap-executor/gap-healer.ts +50 -0
- package/templates/glue/src/gap-executor/page-structure-observer.ts +102 -0
- package/templates/glue/src/gap-executor/runtime-runner.ts +817 -0
- package/templates/glue/src/report/glue-report.ts +855 -0
- package/templates/glue/src/report/run-info.ts +85 -0
- package/templates/glue/src/skeletons/crud.skeleton.ts +450 -0
- package/templates/glue/src/skeletons/export.skeleton.ts +114 -0
- package/templates/glue/src/skeletons/index.ts +18 -0
- package/templates/glue/src/skeletons/login.skeleton.ts +104 -0
- package/templates/glue/src/skeletons/menu.skeleton.ts +37 -0
- package/templates/glue/src/tests/example.spec.ts +99 -0
- package/templates/glue/src/web/component/anchor-types.ts +9 -0
- package/templates/glue/src/web/component/dataexport-component.ts +143 -0
- package/templates/glue/src/web/component/index.ts +2 -0
- package/templates/glue/src/web/component/listbox-component.ts +41 -0
- package/templates/glue/tsconfig.json +12 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// 类型定义(详见 docs/superpowers/specs/2026-06-09-glue-execution-report-design.md §5)
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/** 工作流执行来源:胶水编码生成或 Agent 推理 */
|
|
10
|
+
export type GlueWorkflowExecutionSource = 'glue_code' | 'agent_inferred';
|
|
11
|
+
|
|
12
|
+
/** 工作流执行状态 */
|
|
13
|
+
export type GlueWorkflowStatus =
|
|
14
|
+
| 'passed'
|
|
15
|
+
| 'failed'
|
|
16
|
+
| 'needs_review'
|
|
17
|
+
| 'blocked'
|
|
18
|
+
| 'skipped'
|
|
19
|
+
| 'unresolved';
|
|
20
|
+
|
|
21
|
+
/** 工作流业务类型 */
|
|
22
|
+
export type GlueWorkflowKind =
|
|
23
|
+
| 'create'
|
|
24
|
+
| 'read'
|
|
25
|
+
| 'update'
|
|
26
|
+
| 'delete'
|
|
27
|
+
| 'export'
|
|
28
|
+
| 'custom';
|
|
29
|
+
|
|
30
|
+
/** 证据来源类型 */
|
|
31
|
+
export type GlueEvidenceSource =
|
|
32
|
+
| 'crud_flow_slots'
|
|
33
|
+
| 'module_hints'
|
|
34
|
+
| 'upstream_html'
|
|
35
|
+
| 'upstream_java_action'
|
|
36
|
+
| 'upstream_doc'
|
|
37
|
+
| 'runtime_page'
|
|
38
|
+
| 'agent_reasoning';
|
|
39
|
+
|
|
40
|
+
/** 工作流内部步骤 */
|
|
41
|
+
export type GlueWorkflowStep = {
|
|
42
|
+
name: string;
|
|
43
|
+
elementAction: string;
|
|
44
|
+
assertionExpectation?: string;
|
|
45
|
+
status?: GlueWorkflowStatus;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 证据引用 */
|
|
49
|
+
export type GlueEvidenceRef = {
|
|
50
|
+
source: GlueEvidenceSource;
|
|
51
|
+
file?: string;
|
|
52
|
+
selector?: string;
|
|
53
|
+
text?: string;
|
|
54
|
+
note: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 调用方提交给 recorder 的工作流输入。
|
|
59
|
+
* status / startedAt / endedAt / durationMs / errorMessage 由 recorder 自动管理;
|
|
60
|
+
* 调用方仍可在 input 中预置 status(例如 'needs_review')以覆盖默认 'passed'。
|
|
61
|
+
*/
|
|
62
|
+
export type GlueWorkflowRecordInput = {
|
|
63
|
+
workflowId: string;
|
|
64
|
+
moduleId: string;
|
|
65
|
+
pageName?: string;
|
|
66
|
+
workflowKind: GlueWorkflowKind;
|
|
67
|
+
workflowName: string;
|
|
68
|
+
executionSource: GlueWorkflowExecutionSource;
|
|
69
|
+
status?: GlueWorkflowStatus;
|
|
70
|
+
businessIntent: string;
|
|
71
|
+
elementActions: string[];
|
|
72
|
+
assertionExpectation: string;
|
|
73
|
+
steps?: GlueWorkflowStep[];
|
|
74
|
+
evidenceSources: GlueEvidenceSource[];
|
|
75
|
+
evidenceRefs: GlueEvidenceRef[];
|
|
76
|
+
playwrightSuiteTitle?: string;
|
|
77
|
+
playwrightTestTitle?: string;
|
|
78
|
+
playwrightTestId?: string;
|
|
79
|
+
reviewReason?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 最终落盘的工作流记录 */
|
|
83
|
+
export type GlueWorkflowRecord = {
|
|
84
|
+
workflowId: string;
|
|
85
|
+
moduleId: string;
|
|
86
|
+
pageName?: string;
|
|
87
|
+
workflowKind: GlueWorkflowKind;
|
|
88
|
+
workflowName: string;
|
|
89
|
+
executionSource: GlueWorkflowExecutionSource;
|
|
90
|
+
status: GlueWorkflowStatus;
|
|
91
|
+
businessIntent: string;
|
|
92
|
+
elementActions: string[];
|
|
93
|
+
assertionExpectation: string;
|
|
94
|
+
steps?: GlueWorkflowStep[];
|
|
95
|
+
evidenceSources: GlueEvidenceSource[];
|
|
96
|
+
evidenceRefs: GlueEvidenceRef[];
|
|
97
|
+
startedAt?: string;
|
|
98
|
+
endedAt?: string;
|
|
99
|
+
durationMs?: number;
|
|
100
|
+
errorMessage?: string;
|
|
101
|
+
playwrightSuiteTitle?: string;
|
|
102
|
+
playwrightTestTitle?: string;
|
|
103
|
+
playwrightTestId?: string;
|
|
104
|
+
reviewReason?: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** 运行环境信息 */
|
|
108
|
+
export type GlueExecutionEnvironment = {
|
|
109
|
+
endpointName?: string;
|
|
110
|
+
operatingSystem?: string;
|
|
111
|
+
osLocale?: string;
|
|
112
|
+
browserName?: string;
|
|
113
|
+
browserVersion?: string;
|
|
114
|
+
viewport?: {
|
|
115
|
+
width: number;
|
|
116
|
+
height: number;
|
|
117
|
+
};
|
|
118
|
+
screen?: {
|
|
119
|
+
width: number;
|
|
120
|
+
height: number;
|
|
121
|
+
};
|
|
122
|
+
nodeVersion?: string;
|
|
123
|
+
playwrightVersion?: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** 执行报告整体结构 */
|
|
127
|
+
export type GlueExecutionReport = {
|
|
128
|
+
schemaVersion: 'glue-execution-report/v1';
|
|
129
|
+
runTimestamp: string;
|
|
130
|
+
moduleId: string;
|
|
131
|
+
moduleName?: string;
|
|
132
|
+
generatedSpecFile?: string;
|
|
133
|
+
suiteTitle?: string;
|
|
134
|
+
startedAt: string;
|
|
135
|
+
endedAt?: string;
|
|
136
|
+
durationMs?: number;
|
|
137
|
+
environment: GlueExecutionEnvironment;
|
|
138
|
+
summary: {
|
|
139
|
+
totalWorkflows: number;
|
|
140
|
+
glueCodeWorkflows: number;
|
|
141
|
+
agentInferredWorkflows: number;
|
|
142
|
+
passed: number;
|
|
143
|
+
failed: number;
|
|
144
|
+
blocked: number;
|
|
145
|
+
warnings: number;
|
|
146
|
+
needsReview: number;
|
|
147
|
+
unresolved: number;
|
|
148
|
+
};
|
|
149
|
+
workflows: GlueWorkflowRecord[];
|
|
150
|
+
playwrightReport?: {
|
|
151
|
+
title: string;
|
|
152
|
+
url: string;
|
|
153
|
+
path?: string;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Recorder 构造选项
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/** GlueWorkflowRecorder 构造选项 */
|
|
162
|
+
export type GlueWorkflowRecorderOptions = {
|
|
163
|
+
/** 报告产物根目录,例如 `e2e/glue-code-mvp` */
|
|
164
|
+
outputRoot: string;
|
|
165
|
+
/** 本次运行时间戳,格式 `YYYYMMDDHHmmss`;缺省时优先取环境变量 `STAGE_REPORT_TIMESTAMP` */
|
|
166
|
+
runTimestamp?: string;
|
|
167
|
+
moduleId: string;
|
|
168
|
+
moduleName?: string;
|
|
169
|
+
suiteTitle?: string;
|
|
170
|
+
generatedSpecFile?: string;
|
|
171
|
+
/** Playwright 原生报告入口;缺省时指向同批次目录下的 `./playwright-report/index.html` */
|
|
172
|
+
playwrightReportUrl?: string;
|
|
173
|
+
playwrightReportPath?: string;
|
|
174
|
+
playwrightReportTitle?: string;
|
|
175
|
+
/** 调用方可显式覆盖部分环境字段;其余字段从 os / process 自动采集 */
|
|
176
|
+
environment?: Partial<GlueExecutionEnvironment>;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// 工具函数
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 把数字补齐到指定位数,便于生成 YYYYMMDDHHmmss 时间戳。
|
|
185
|
+
*/
|
|
186
|
+
function pad(value: number, width: number): string {
|
|
187
|
+
return String(value).padStart(width, '0');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 按本地时间生成 `YYYYMMDDHHmmss` 格式时间戳。
|
|
192
|
+
*/
|
|
193
|
+
function formatLocalTimestamp(date: Date): string {
|
|
194
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1, 2)}${pad(date.getDate(), 2)}${pad(date.getHours(), 2)}${pad(date.getMinutes(), 2)}${pad(date.getSeconds(), 2)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 读取 playwright.config.ts 在测试启动时写入的 run-info.json,确保跨进程时间戳一致。
|
|
199
|
+
* 文件位于 glue-report/run-info.json,不存在时返回 undefined(回退到 formatLocalTimestamp)。
|
|
200
|
+
*/
|
|
201
|
+
function readRunInfoTimestamp(outputRoot: string): string | undefined {
|
|
202
|
+
try {
|
|
203
|
+
const filePath = path.join(outputRoot, 'glue-report', 'run-info.json');
|
|
204
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
205
|
+
return content.runTimeStamp ?? undefined;
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* HTML 转义,避免 workflow 内的业务文本注入 `<script>` 或破坏布局。
|
|
213
|
+
*/
|
|
214
|
+
function escapeHtml(value: unknown): string {
|
|
215
|
+
if (value === null || value === undefined) {
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
const text = String(value);
|
|
219
|
+
return text
|
|
220
|
+
.replace(/&/g, '&')
|
|
221
|
+
.replace(/</g, '<')
|
|
222
|
+
.replace(/>/g, '>')
|
|
223
|
+
.replace(/"/g, '"')
|
|
224
|
+
.replace(/'/g, ''');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 把 executionSource 转为业务人员可读的中文描述。
|
|
229
|
+
*/
|
|
230
|
+
function executionSourceLabel(source: GlueWorkflowExecutionSource): string {
|
|
231
|
+
return source === 'glue_code' ? '胶水编码执行' : 'Agent 推理执行';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 把工作流状态转为中文标签,便于报告展示。
|
|
236
|
+
*/
|
|
237
|
+
function statusLabel(status: GlueWorkflowStatus): string {
|
|
238
|
+
switch (status) {
|
|
239
|
+
case 'passed':
|
|
240
|
+
return '通过';
|
|
241
|
+
case 'failed':
|
|
242
|
+
return '失败';
|
|
243
|
+
case 'needs_review':
|
|
244
|
+
return '待审阅';
|
|
245
|
+
case 'blocked':
|
|
246
|
+
return '阻塞';
|
|
247
|
+
case 'skipped':
|
|
248
|
+
return '跳过';
|
|
249
|
+
case 'unresolved':
|
|
250
|
+
return '未解析';
|
|
251
|
+
default:
|
|
252
|
+
return status;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 自动采集运行环境字段,结果会与调用方传入的覆盖项合并。
|
|
258
|
+
*/
|
|
259
|
+
function detectEnvironment(
|
|
260
|
+
override: Partial<GlueExecutionEnvironment> | undefined,
|
|
261
|
+
): GlueExecutionEnvironment {
|
|
262
|
+
const detected: GlueExecutionEnvironment = {
|
|
263
|
+
endpointName: os.hostname(),
|
|
264
|
+
operatingSystem: `${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
|
|
265
|
+
osLocale:
|
|
266
|
+
process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || undefined,
|
|
267
|
+
nodeVersion: process.versions?.node ? `v${process.versions.node}` : undefined,
|
|
268
|
+
};
|
|
269
|
+
return { ...detected, ...(override ?? {}) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// GlueWorkflowRecorder
|
|
274
|
+
// ============================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 工作流级胶水执行报告记录器。
|
|
278
|
+
*
|
|
279
|
+
* 调用方通过 `runRecordedWorkflow()` 包装真实工作流执行,recorder 负责打时间戳、
|
|
280
|
+
* 累积 workflow record,最后通过 `writeReport()` 一次性落盘 JSON + HTML 报告。
|
|
281
|
+
*
|
|
282
|
+
* 设计约束:第一版只服务 `e2e/glue-code-mvp`,不进 `stage-core` 或 `stage-web`。
|
|
283
|
+
*/
|
|
284
|
+
export class GlueWorkflowRecorder {
|
|
285
|
+
private readonly options: GlueWorkflowRecorderOptions;
|
|
286
|
+
private readonly runTimestamp: string;
|
|
287
|
+
private readonly startedAt: string;
|
|
288
|
+
private readonly startedAtMs: number;
|
|
289
|
+
private readonly environment: GlueExecutionEnvironment;
|
|
290
|
+
private readonly workflows: GlueWorkflowRecord[] = [];
|
|
291
|
+
|
|
292
|
+
constructor(options: GlueWorkflowRecorderOptions) {
|
|
293
|
+
this.options = options;
|
|
294
|
+
this.runTimestamp =
|
|
295
|
+
options.runTimestamp ??
|
|
296
|
+
process.env.STAGE_REPORT_TIMESTAMP ??
|
|
297
|
+
readRunInfoTimestamp(options.outputRoot) ??
|
|
298
|
+
formatLocalTimestamp(new Date());
|
|
299
|
+
const now = new Date();
|
|
300
|
+
this.startedAt = now.toISOString();
|
|
301
|
+
this.startedAtMs = now.getTime();
|
|
302
|
+
this.environment = detectEnvironment(options.environment);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 暴露本次运行的时间戳,便于外部组织日志或产物路径 */
|
|
306
|
+
getRunTimestamp(): string {
|
|
307
|
+
return this.runTimestamp;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 追加一条已完成的 workflow 记录。
|
|
312
|
+
* 一般由 `runRecordedWorkflow()` 调用,调用方也可以直接 push 静态记录。
|
|
313
|
+
*/
|
|
314
|
+
addRecord(record: GlueWorkflowRecord): void {
|
|
315
|
+
this.workflows.push(record);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 浅 merge 部分环境字段到当前 environment,便于运行中途补全浏览器等信息。
|
|
320
|
+
*/
|
|
321
|
+
updateEnvironment(partial: Partial<GlueExecutionEnvironment>): void {
|
|
322
|
+
Object.assign(this.environment, partial);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 落盘 JSON + HTML 报告。
|
|
327
|
+
*
|
|
328
|
+
* @returns 报告根目录、JSON 路径、HTML 路径以及最终 report 对象
|
|
329
|
+
*/
|
|
330
|
+
writeReport(): {
|
|
331
|
+
reportRoot: string;
|
|
332
|
+
jsonPath: string;
|
|
333
|
+
htmlPath: string;
|
|
334
|
+
report: GlueExecutionReport;
|
|
335
|
+
} {
|
|
336
|
+
const endDate = new Date();
|
|
337
|
+
const endedAt = endDate.toISOString();
|
|
338
|
+
const durationMs = endDate.getTime() - this.startedAtMs;
|
|
339
|
+
|
|
340
|
+
const report: GlueExecutionReport = {
|
|
341
|
+
schemaVersion: 'glue-execution-report/v1',
|
|
342
|
+
runTimestamp: this.runTimestamp,
|
|
343
|
+
moduleId: this.options.moduleId,
|
|
344
|
+
moduleName: this.options.moduleName,
|
|
345
|
+
generatedSpecFile: this.options.generatedSpecFile,
|
|
346
|
+
suiteTitle: this.options.suiteTitle,
|
|
347
|
+
startedAt: this.startedAt,
|
|
348
|
+
endedAt,
|
|
349
|
+
durationMs,
|
|
350
|
+
environment: this.environment,
|
|
351
|
+
summary: this.buildSummary(),
|
|
352
|
+
workflows: this.workflows,
|
|
353
|
+
playwrightReport: {
|
|
354
|
+
title: this.options.playwrightReportTitle ?? '打开 Playwright 原生报告',
|
|
355
|
+
url: this.options.playwrightReportUrl ?? './playwright-report/index.html',
|
|
356
|
+
path: this.options.playwrightReportPath,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const glueReportRoot = path.join(this.options.outputRoot, 'glue-report', this.runTimestamp);
|
|
361
|
+
fs.mkdirSync(glueReportRoot, { recursive: true });
|
|
362
|
+
|
|
363
|
+
const jsonPath = path.join(glueReportRoot, `stage-glue-${this.runTimestamp}.json`);
|
|
364
|
+
const htmlPath = path.join(glueReportRoot, `stage-glue-${this.runTimestamp}.html`);
|
|
365
|
+
|
|
366
|
+
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf-8');
|
|
367
|
+
fs.writeFileSync(htmlPath, renderReportHtml(report), 'utf-8');
|
|
368
|
+
|
|
369
|
+
return { reportRoot: glueReportRoot, jsonPath, htmlPath, report };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 根据当前累积的 workflow 列表汇总统计字段 */
|
|
373
|
+
private buildSummary(): GlueExecutionReport['summary'] {
|
|
374
|
+
let glueCodeWorkflows = 0;
|
|
375
|
+
let agentInferredWorkflows = 0;
|
|
376
|
+
let passed = 0;
|
|
377
|
+
let failed = 0;
|
|
378
|
+
let blocked = 0;
|
|
379
|
+
let needsReview = 0;
|
|
380
|
+
let unresolved = 0;
|
|
381
|
+
|
|
382
|
+
for (const w of this.workflows) {
|
|
383
|
+
if (w.executionSource === 'glue_code') {
|
|
384
|
+
glueCodeWorkflows += 1;
|
|
385
|
+
} else {
|
|
386
|
+
agentInferredWorkflows += 1;
|
|
387
|
+
}
|
|
388
|
+
switch (w.status) {
|
|
389
|
+
case 'passed':
|
|
390
|
+
passed += 1;
|
|
391
|
+
break;
|
|
392
|
+
case 'failed':
|
|
393
|
+
failed += 1;
|
|
394
|
+
break;
|
|
395
|
+
case 'blocked':
|
|
396
|
+
blocked += 1;
|
|
397
|
+
break;
|
|
398
|
+
case 'needs_review':
|
|
399
|
+
needsReview += 1;
|
|
400
|
+
break;
|
|
401
|
+
case 'unresolved':
|
|
402
|
+
unresolved += 1;
|
|
403
|
+
break;
|
|
404
|
+
default:
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
totalWorkflows: this.workflows.length,
|
|
411
|
+
glueCodeWorkflows,
|
|
412
|
+
agentInferredWorkflows,
|
|
413
|
+
passed,
|
|
414
|
+
failed,
|
|
415
|
+
blocked,
|
|
416
|
+
warnings: needsReview + unresolved,
|
|
417
|
+
needsReview,
|
|
418
|
+
unresolved,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ============================================================================
|
|
424
|
+
// runRecordedWorkflow
|
|
425
|
+
// ============================================================================
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 在 recorder 上下文中执行 workflow body 并自动记录开始 / 结束 / 失败时间。
|
|
429
|
+
*
|
|
430
|
+
* - 执行成功时若调用方未预置状态,则置为 `passed`。
|
|
431
|
+
* - 执行失败时置为 `failed`、写 `errorMessage`,并 re-throw 让 Playwright 仍然识别失败。
|
|
432
|
+
* - 当 `executionSource === 'agent_inferred'` 时,会在进入 body 之前校验
|
|
433
|
+
* `evidenceSources` 必须包含 `agent_reasoning` 且 `reviewReason` 必填。
|
|
434
|
+
*
|
|
435
|
+
* @param recorder 工作流记录器实例
|
|
436
|
+
* @param input 工作流输入元数据
|
|
437
|
+
* @param body 工作流业务体(异步)
|
|
438
|
+
*/
|
|
439
|
+
export async function runRecordedWorkflow(
|
|
440
|
+
recorder: GlueWorkflowRecorder,
|
|
441
|
+
input: GlueWorkflowRecordInput,
|
|
442
|
+
body: () => Promise<void>,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
if (input.executionSource === 'agent_inferred') {
|
|
445
|
+
if (!input.evidenceSources.includes('agent_reasoning')) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`[glue-report] workflow ${input.workflowId} executionSource=agent_inferred 时 evidenceSources 必须包含 'agent_reasoning'`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (!input.reviewReason || input.reviewReason.trim() === '') {
|
|
451
|
+
throw new Error(
|
|
452
|
+
`[glue-report] workflow ${input.workflowId} executionSource=agent_inferred 时 reviewReason 必填`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const startDate = new Date();
|
|
458
|
+
const startedAt = startDate.toISOString();
|
|
459
|
+
const startedAtMs = startDate.getTime();
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
await body();
|
|
463
|
+
const endDate = new Date();
|
|
464
|
+
recorder.addRecord({
|
|
465
|
+
...input,
|
|
466
|
+
status: input.status ?? 'passed',
|
|
467
|
+
startedAt,
|
|
468
|
+
endedAt: endDate.toISOString(),
|
|
469
|
+
durationMs: endDate.getTime() - startedAtMs,
|
|
470
|
+
});
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const endDate = new Date();
|
|
473
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
474
|
+
recorder.addRecord({
|
|
475
|
+
...input,
|
|
476
|
+
status: 'failed',
|
|
477
|
+
startedAt,
|
|
478
|
+
endedAt: endDate.toISOString(),
|
|
479
|
+
durationMs: endDate.getTime() - startedAtMs,
|
|
480
|
+
errorMessage: message,
|
|
481
|
+
});
|
|
482
|
+
// 抛出原错误,确保 Playwright 仍然把该 test 标记为失败
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ============================================================================
|
|
488
|
+
// HTML 渲染
|
|
489
|
+
// ============================================================================
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 把毫秒转换为人类可读的耗时字符串(毫秒 / 秒 / 分秒)。
|
|
493
|
+
*/
|
|
494
|
+
function formatDuration(ms: number | undefined): string {
|
|
495
|
+
if (ms === undefined || Number.isNaN(ms)) {
|
|
496
|
+
return '-';
|
|
497
|
+
}
|
|
498
|
+
if (ms < 1000) {
|
|
499
|
+
return `${ms} ms`;
|
|
500
|
+
}
|
|
501
|
+
const totalSeconds = ms / 1000;
|
|
502
|
+
if (totalSeconds < 60) {
|
|
503
|
+
return `${totalSeconds.toFixed(2)} s`;
|
|
504
|
+
}
|
|
505
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
506
|
+
const seconds = (totalSeconds - minutes * 60).toFixed(1);
|
|
507
|
+
return `${minutes} m ${seconds} s`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 格式化为人类友好的显示时间戳,月/日/时不补零,分/秒补零。
|
|
512
|
+
* 例如 `2026-6-10 9:05:03`。
|
|
513
|
+
*/
|
|
514
|
+
function formatDisplayTimestamp(date: Date): string {
|
|
515
|
+
const y = date.getFullYear();
|
|
516
|
+
const m = date.getMonth() + 1;
|
|
517
|
+
const d = date.getDate();
|
|
518
|
+
const h = date.getHours();
|
|
519
|
+
const min = String(date.getMinutes()).padStart(2, '0');
|
|
520
|
+
const sec = String(date.getSeconds()).padStart(2, '0');
|
|
521
|
+
return `${y}-${m}-${d} ${h}:${min}:${sec}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 以毫秒精度格式化耗时,避免 55 秒被进位显示成 1 分钟。
|
|
526
|
+
*/
|
|
527
|
+
function formatPreciseDuration(ms: number | undefined): string {
|
|
528
|
+
if (ms === undefined || ms <= 0) {
|
|
529
|
+
return '-';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (ms < 1_000) {
|
|
533
|
+
return `${ms} 毫秒`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
537
|
+
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
|
538
|
+
const seconds = Math.floor((ms % 60_000) / 1_000);
|
|
539
|
+
const milliseconds = ms % 1_000;
|
|
540
|
+
|
|
541
|
+
if (hours > 0) {
|
|
542
|
+
return `${hours} 小时 ${minutes} 分 ${seconds}.${pad(milliseconds, 3)} 秒`;
|
|
543
|
+
}
|
|
544
|
+
if (minutes > 0) {
|
|
545
|
+
return `${minutes} 分 ${seconds}.${pad(milliseconds, 3)} 秒`;
|
|
546
|
+
}
|
|
547
|
+
return `${seconds}.${pad(milliseconds, 3)} 秒`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 格式化浏览器标签:chromium 映射为 `Chrome For Testing`,其他原样输出。
|
|
552
|
+
*/
|
|
553
|
+
function formatBrowserLabel(env: GlueExecutionEnvironment): string {
|
|
554
|
+
if (env.browserName === 'chromium') {
|
|
555
|
+
return `Chrome For Testing ${env.browserVersion ?? '-'}`;
|
|
556
|
+
}
|
|
557
|
+
return `${env.browserName ?? '-'} ${env.browserVersion ?? ''}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 渲染顶部环境/统计概览段。
|
|
562
|
+
*/
|
|
563
|
+
function renderHeaderSection(report: GlueExecutionReport): string {
|
|
564
|
+
const env = report.environment;
|
|
565
|
+
|
|
566
|
+
const overviewRows: Array<[string, string]> = [
|
|
567
|
+
['测试套件', report.suiteTitle ?? report.moduleName ?? report.moduleId],
|
|
568
|
+
['执行时间', formatDisplayTimestamp(new Date(report.startedAt))],
|
|
569
|
+
['执行耗时', formatPreciseDuration(report.durationMs)],
|
|
570
|
+
['浏览器', formatBrowserLabel(env)],
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
const overviewHtml = overviewRows
|
|
574
|
+
.map(
|
|
575
|
+
([label, value]) =>
|
|
576
|
+
`<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`,
|
|
577
|
+
)
|
|
578
|
+
.join('');
|
|
579
|
+
|
|
580
|
+
const summary = report.summary;
|
|
581
|
+
|
|
582
|
+
// 卡片数据结构:[标签, 数值, CSS kind, 筛选值]
|
|
583
|
+
const strategyCards: Array<[string, number, string, string]> = [
|
|
584
|
+
['总测试流程', summary.totalWorkflows, 'total', ''],
|
|
585
|
+
['胶水编码执行', summary.glueCodeWorkflows, 'glue', 'glue_code'],
|
|
586
|
+
['Agent推理执行', summary.agentInferredWorkflows, 'agent', 'agent_inferred'],
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
const resultCards: Array<[string, number, string, string]> = [
|
|
590
|
+
['通过', summary.passed, 'passed', 'passed'],
|
|
591
|
+
['失败', summary.failed, 'failed', 'failed'],
|
|
592
|
+
['阻塞', summary.blocked, 'blocked', 'blocked'],
|
|
593
|
+
['待审阅', summary.needsReview, 'review', 'needs_review'],
|
|
594
|
+
['未解析', summary.unresolved, 'unresolved', 'unresolved'],
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
const renderCards = (cards: Array<[string, number, string, string]>, filterGroup: string): string =>
|
|
598
|
+
cards
|
|
599
|
+
.map(
|
|
600
|
+
([label, value, kind, filterValue]) => `
|
|
601
|
+
<div class="summary-card summary-card--${kind}" data-filter-group="${filterGroup}" data-filter-value="${filterValue}" role="button" tabindex="0">
|
|
602
|
+
<div class="summary-card__value">${escapeHtml(value)}</div>
|
|
603
|
+
<div class="summary-card__label">${escapeHtml(label)}</div>
|
|
604
|
+
</div>`,
|
|
605
|
+
)
|
|
606
|
+
.join('');
|
|
607
|
+
|
|
608
|
+
return `
|
|
609
|
+
<section class="overview">
|
|
610
|
+
<h1>胶水编码-测试执行</h1>
|
|
611
|
+
<table class="overview-table">${overviewHtml}</table>
|
|
612
|
+
<h3 class="summary-section-title">执行策略</h3>
|
|
613
|
+
<div class="summary-grid summary-grid--strategy">${renderCards(strategyCards, 'strategy')}</div>
|
|
614
|
+
<h3 class="summary-section-title">执行结果</h3>
|
|
615
|
+
<div class="summary-grid summary-grid--result">${renderCards(resultCards, 'status')}</div>
|
|
616
|
+
</section>
|
|
617
|
+
`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 渲染单个工作流卡片。
|
|
622
|
+
*/
|
|
623
|
+
function renderWorkflowCard(record: GlueWorkflowRecord): string {
|
|
624
|
+
const actionsHtml = record.elementActions.length
|
|
625
|
+
? `<ul class="actions">${record.elementActions
|
|
626
|
+
.map((a) => `<li>${escapeHtml(a)}</li>`)
|
|
627
|
+
.join('')}</ul>`
|
|
628
|
+
: '<p class="muted">(无)</p>';
|
|
629
|
+
|
|
630
|
+
const evidenceHtml = record.evidenceRefs.length
|
|
631
|
+
? `<ul class="evidence">${record.evidenceRefs
|
|
632
|
+
.map(
|
|
633
|
+
(ref) => `<li>
|
|
634
|
+
<span class="evidence-source">${escapeHtml(ref.source)}</span>
|
|
635
|
+
${ref.selector ? `<code>${escapeHtml(ref.selector)}</code>` : ''}
|
|
636
|
+
${ref.file ? `<span class="evidence-file">${escapeHtml(ref.file)}</span>` : ''}
|
|
637
|
+
${ref.text ? `<span class="evidence-text">${escapeHtml(ref.text)}</span>` : ''}
|
|
638
|
+
<span class="evidence-note">${escapeHtml(ref.note)}</span>
|
|
639
|
+
</li>`,
|
|
640
|
+
)
|
|
641
|
+
.join('')}</ul>`
|
|
642
|
+
: '<p class="muted">(无证据引用)</p>';
|
|
643
|
+
|
|
644
|
+
const stepsHtml = record.steps?.length
|
|
645
|
+
? `<ol class="steps">${record.steps
|
|
646
|
+
.map(
|
|
647
|
+
(step) =>
|
|
648
|
+
`<li><strong>${escapeHtml(step.name)}</strong>:${escapeHtml(step.elementAction)}${
|
|
649
|
+
step.assertionExpectation
|
|
650
|
+
? ` <em>断言:${escapeHtml(step.assertionExpectation)}</em>`
|
|
651
|
+
: ''
|
|
652
|
+
}${step.status ? ` <span class="step-status">[${escapeHtml(statusLabel(step.status))}]</span>` : ''}</li>`,
|
|
653
|
+
)
|
|
654
|
+
.join('')}</ol>`
|
|
655
|
+
: '';
|
|
656
|
+
|
|
657
|
+
const errorHtml = record.errorMessage
|
|
658
|
+
? `<div class="error"><strong>错误信息:</strong><pre>${escapeHtml(record.errorMessage)}</pre></div>`
|
|
659
|
+
: '';
|
|
660
|
+
|
|
661
|
+
const reviewHtml = record.reviewReason
|
|
662
|
+
? `<div class="review"><strong>审阅原因:</strong>${escapeHtml(record.reviewReason)}</div>`
|
|
663
|
+
: '';
|
|
664
|
+
|
|
665
|
+
const pwHtml =
|
|
666
|
+
record.playwrightSuiteTitle || record.playwrightTestTitle
|
|
667
|
+
? `<div class="pw-ref">Playwright:${escapeHtml(record.playwrightSuiteTitle ?? '')} / ${escapeHtml(record.playwrightTestTitle ?? '')}</div>`
|
|
668
|
+
: '';
|
|
669
|
+
|
|
670
|
+
return `
|
|
671
|
+
<article class="workflow workflow--${escapeHtml(record.status)}" data-strategy="${escapeHtml(record.executionSource)}" data-status="${escapeHtml(record.status)}">
|
|
672
|
+
<header class="workflow__header">
|
|
673
|
+
<div class="workflow__title">
|
|
674
|
+
<h2>${escapeHtml(record.workflowName)}</h2>
|
|
675
|
+
<code class="workflow__id">${escapeHtml(record.workflowId)}</code>
|
|
676
|
+
</div>
|
|
677
|
+
<div class="workflow__meta">
|
|
678
|
+
<span class="badge badge--kind">${escapeHtml(record.workflowKind)}</span>
|
|
679
|
+
<span class="badge badge--source">${escapeHtml(executionSourceLabel(record.executionSource))}</span>
|
|
680
|
+
<span class="badge badge--status badge--${escapeHtml(record.status)}">${escapeHtml(statusLabel(record.status))}</span>
|
|
681
|
+
</div>
|
|
682
|
+
</header>
|
|
683
|
+
<dl class="workflow__body">
|
|
684
|
+
<dt>业务意图</dt><dd>${escapeHtml(record.businessIntent)}</dd>
|
|
685
|
+
<dt>断言预期</dt><dd>${escapeHtml(record.assertionExpectation)}</dd>
|
|
686
|
+
<dt>元素动作</dt><dd>${actionsHtml}</dd>
|
|
687
|
+
<dt>证据引用</dt><dd>${evidenceHtml}</dd>
|
|
688
|
+
${stepsHtml ? `<dt>内部步骤</dt><dd>${stepsHtml}</dd>` : ''}
|
|
689
|
+
</dl>
|
|
690
|
+
${errorHtml}
|
|
691
|
+
${reviewHtml}
|
|
692
|
+
${pwHtml}
|
|
693
|
+
</article>
|
|
694
|
+
`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 渲染底部 Playwright 原生报告入口区。
|
|
699
|
+
*/
|
|
700
|
+
function renderFooterSection(report: GlueExecutionReport): string {
|
|
701
|
+
const inner = report.playwrightReport
|
|
702
|
+
? `<a class="pw-link" href="${escapeHtml(report.playwrightReport.url)}" target="_blank" rel="noopener">${escapeHtml(report.playwrightReport.title)}</a>`
|
|
703
|
+
: '<p class="muted">本次未发现 Playwright 原生报告产物</p>';
|
|
704
|
+
return `
|
|
705
|
+
<footer class="footer">
|
|
706
|
+
${inner}
|
|
707
|
+
</footer>
|
|
708
|
+
`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* 渲染整份 HTML 报告(单文件、内联样式、无外部依赖)。
|
|
713
|
+
* @internal 测试专用导出,外部请勿直接调用。
|
|
714
|
+
*/
|
|
715
|
+
export function renderReportHtml(report: GlueExecutionReport): string {
|
|
716
|
+
const workflowsHtml =
|
|
717
|
+
report.workflows.length > 0
|
|
718
|
+
? report.workflows.map(renderWorkflowCard).join('\n')
|
|
719
|
+
: '<p class="muted">(本次运行未记录任何工作流)</p>';
|
|
720
|
+
|
|
721
|
+
const style = `
|
|
722
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: #f7f8fa; color: #1f2933; margin: 0; padding: 24px; }
|
|
723
|
+
h1 { margin: 0 0 16px; font-size: 22px; }
|
|
724
|
+
h2 { font-size: 16px; margin: 16px 0 8px; }
|
|
725
|
+
section.overview { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 24px; }
|
|
726
|
+
.overview-table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
|
727
|
+
.overview-table th, .overview-table td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #eee; font-size: 13px; }
|
|
728
|
+
.overview-table th { width: 140px; color: #52606d; font-weight: 600; }
|
|
729
|
+
.summary-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
|
730
|
+
.summary-card { background: #f0f4f8; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; }
|
|
731
|
+
.summary-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,0.1); transform: translateY(-1px); }
|
|
732
|
+
.summary-card--active { box-shadow: 0 0 0 2px #2563eb; }
|
|
733
|
+
.summary-card__value { font-size: 22px; font-weight: 600; }
|
|
734
|
+
.summary-card__label { font-size: 12px; color: #52606d; margin-top: 4px; }
|
|
735
|
+
.summary-card--passed { background: #e3fcec; }
|
|
736
|
+
.summary-card--failed { background: #ffe3e3; }
|
|
737
|
+
.summary-card--review { background: #fff4d6; }
|
|
738
|
+
.summary-card--unresolved { background: #f0e8ff; }
|
|
739
|
+
.summary-card--blocked { background: #ffe7d6; }
|
|
740
|
+
article.workflow { background: #fff; border-radius: 8px; padding: 16px 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-left: 4px solid #cbd2d9; }
|
|
741
|
+
article.workflow--passed { border-left-color: #2f9e44; }
|
|
742
|
+
article.workflow--failed { border-left-color: #e03131; }
|
|
743
|
+
article.workflow--needs_review { border-left-color: #f59f00; }
|
|
744
|
+
article.workflow--blocked { border-left-color: #d9480f; }
|
|
745
|
+
article.workflow--unresolved { border-left-color: #7048e8; }
|
|
746
|
+
.workflow__header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px; }
|
|
747
|
+
.workflow__title h2 { margin: 0; font-size: 16px; }
|
|
748
|
+
.workflow__id { font-size: 12px; color: #52606d; }
|
|
749
|
+
.workflow__meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
750
|
+
.badge { display: inline-block; padding: 2px 8px; font-size: 12px; border-radius: 12px; background: #e4e7eb; color: #1f2933; }
|
|
751
|
+
.badge--passed { background: #2f9e44; color: #fff; }
|
|
752
|
+
.badge--failed { background: #e03131; color: #fff; }
|
|
753
|
+
.badge--needs_review { background: #f59f00; color: #fff; }
|
|
754
|
+
.badge--blocked { background: #d9480f; color: #fff; }
|
|
755
|
+
.badge--unresolved { background: #7048e8; color: #fff; }
|
|
756
|
+
dl.workflow__body { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; margin: 12px 0 4px; font-size: 13px; }
|
|
757
|
+
dl.workflow__body dt { color: #52606d; font-weight: 600; }
|
|
758
|
+
ul.actions, ul.evidence, ol.steps { margin: 0; padding-left: 18px; }
|
|
759
|
+
code { background: #f0f4f8; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
|
|
760
|
+
.evidence-source { font-weight: 600; color: #334e68; margin-right: 4px; }
|
|
761
|
+
.evidence-note { color: #52606d; margin-left: 6px; }
|
|
762
|
+
.error pre { background: #fff5f5; color: #c92a2a; padding: 8px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
|
|
763
|
+
.review { background: #fff9db; padding: 8px 12px; border-radius: 4px; margin-top: 8px; font-size: 13px; }
|
|
764
|
+
.pw-ref { font-size: 12px; color: #52606d; margin-top: 8px; }
|
|
765
|
+
.muted { color: #829ab1; font-size: 13px; }
|
|
766
|
+
footer.footer { background: #fff; border-radius: 8px; padding: 16px 20px; margin-top: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
|
767
|
+
.pw-link { display: inline-block; padding: 8px 16px; background: #2563eb; color: #fff; border-radius: 4px; text-decoration: none; font-size: 14px; }
|
|
768
|
+
.pw-link:hover { background: #1d4ed8; }
|
|
769
|
+
.workflow-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 16px 0 8px; }
|
|
770
|
+
.workflow-toolbar h2 { margin: 0; }
|
|
771
|
+
.workflow-filters { display: flex; gap: 8px; }
|
|
772
|
+
.workflow-filters select { padding: 4px 8px; font-size: 13px; border: 1px solid #cbd2d9; border-radius: 4px; background: #fff; color: #1f2933; }
|
|
773
|
+
.summary-section-title { font-size: 13px; color: #52606d; margin: 12px 0 4px; font-weight: 600; }
|
|
774
|
+
`;
|
|
775
|
+
|
|
776
|
+
return `<!DOCTYPE html>
|
|
777
|
+
<html lang="zh-CN">
|
|
778
|
+
<head>
|
|
779
|
+
<meta charset="UTF-8" />
|
|
780
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
|
781
|
+
<title>胶水编码-测试执行</title>
|
|
782
|
+
<style>${style}</style>
|
|
783
|
+
</head>
|
|
784
|
+
<body>
|
|
785
|
+
${renderHeaderSection(report)}
|
|
786
|
+
<section class="workflows">
|
|
787
|
+
<div class="workflow-toolbar">
|
|
788
|
+
<h2>场景工作流</h2>
|
|
789
|
+
<div class="workflow-filters">
|
|
790
|
+
<select id="filter-strategy">
|
|
791
|
+
<option value="">全部执行策略</option>
|
|
792
|
+
<option value="glue_code">胶水编码执行</option>
|
|
793
|
+
<option value="agent_inferred">Agent推理执行</option>
|
|
794
|
+
</select>
|
|
795
|
+
<select id="filter-status">
|
|
796
|
+
<option value="">全部执行结果</option>
|
|
797
|
+
<option value="passed">通过</option>
|
|
798
|
+
<option value="failed">失败</option>
|
|
799
|
+
<option value="blocked">阻塞</option>
|
|
800
|
+
<option value="needs_review">待审阅</option>
|
|
801
|
+
<option value="unresolved">未解析</option>
|
|
802
|
+
</select>
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
805
|
+
${workflowsHtml}
|
|
806
|
+
</section>
|
|
807
|
+
${renderFooterSection(report)}
|
|
808
|
+
<script>
|
|
809
|
+
(function() {
|
|
810
|
+
var fs = document.getElementById('filter-strategy');
|
|
811
|
+
var fst = document.getElementById('filter-status');
|
|
812
|
+
var cards = document.querySelectorAll('article.workflow');
|
|
813
|
+
var summaryCards = document.querySelectorAll('.summary-card');
|
|
814
|
+
|
|
815
|
+
function apply() {
|
|
816
|
+
var s = fs.value, st = fst.value;
|
|
817
|
+
for (var i = 0; i < cards.length; i++) {
|
|
818
|
+
var c = cards[i];
|
|
819
|
+
var show = (!s || c.dataset.strategy === s) && (!st || c.dataset.status === st);
|
|
820
|
+
c.style.display = show ? '' : 'none';
|
|
821
|
+
}
|
|
822
|
+
updateCardHighlight();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function updateCardHighlight() {
|
|
826
|
+
for (var i = 0; i < summaryCards.length; i++) {
|
|
827
|
+
var card = summaryCards[i];
|
|
828
|
+
var group = card.dataset.filterGroup;
|
|
829
|
+
var value = card.dataset.filterValue;
|
|
830
|
+
var select = group === 'strategy' ? fs : fst;
|
|
831
|
+
var active = select.value !== '' && select.value === value;
|
|
832
|
+
card.classList.toggle('summary-card--active', active);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
fs.addEventListener('change', apply);
|
|
837
|
+
fst.addEventListener('change', apply);
|
|
838
|
+
|
|
839
|
+
for (var i = 0; i < summaryCards.length; i++) {
|
|
840
|
+
(function(card) {
|
|
841
|
+
card.addEventListener('click', function() {
|
|
842
|
+
var group = card.dataset.filterGroup;
|
|
843
|
+
var value = card.dataset.filterValue;
|
|
844
|
+
var select = group === 'strategy' ? fs : fst;
|
|
845
|
+
select.value = select.value === value ? '' : value;
|
|
846
|
+
apply();
|
|
847
|
+
});
|
|
848
|
+
})(summaryCards[i]);
|
|
849
|
+
}
|
|
850
|
+
})();
|
|
851
|
+
</script>
|
|
852
|
+
</body>
|
|
853
|
+
</html>
|
|
854
|
+
`;
|
|
855
|
+
}
|