@szc-ft/mcp-szcd-client 0.34.2 → 0.38.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/agents/build.js +59 -1
- package/agents/platforms.json +7 -0
- package/agents/szcd-component-expert.qoder.md +2 -0
- package/opencode-extension/opencode.json +9 -0
- package/opencode-extension/skills/local-browser-test/SKILL.md +91 -0
- package/opencode-extension/skills/local-browser-test/config.example.yaml +51 -0
- package/opencode-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
- package/opencode-extension/skills/local-browser-test/lib/config-loader.js +104 -0
- package/opencode-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
- package/opencode-extension/skills/local-browser-test/lib/global-setup.js +72 -0
- package/opencode-extension/skills/local-browser-test/lib/har-collector.js +124 -0
- package/opencode-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
- package/opencode-extension/skills/local-browser-test/lib/projects.js +46 -0
- package/opencode-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
- package/opencode-extension/skills/local-browser-test/lib/redact.js +94 -0
- package/opencode-extension/skills/local-browser-test/lib/report-retain.js +84 -0
- package/opencode-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
- package/opencode-extension/skills/local-browser-test/lib/storage-state.js +111 -0
- package/opencode-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
- package/opencode-extension/skills/local-browser-test/lib/test-plan.js +2 -0
- package/opencode-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
- package/opencode-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
- package/opencode-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +226 -2
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
- package/opencode-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
- package/opencode-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
- package/opencode-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
- package/opencode-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
- package/opencode-extension/skills/szcd-upload-zip/SKILL.md +45 -0
- package/package.json +70 -70
- package/qwen-extension/qwen-extension.json +10 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +91 -0
- package/qwen-extension/skills/local-browser-test/config.example.yaml +51 -0
- package/qwen-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
- package/qwen-extension/skills/local-browser-test/lib/config-loader.js +104 -0
- package/qwen-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
- package/qwen-extension/skills/local-browser-test/lib/global-setup.js +72 -0
- package/qwen-extension/skills/local-browser-test/lib/har-collector.js +124 -0
- package/qwen-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
- package/qwen-extension/skills/local-browser-test/lib/projects.js +46 -0
- package/qwen-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
- package/qwen-extension/skills/local-browser-test/lib/redact.js +94 -0
- package/qwen-extension/skills/local-browser-test/lib/report-retain.js +84 -0
- package/qwen-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
- package/qwen-extension/skills/local-browser-test/lib/storage-state.js +111 -0
- package/qwen-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
- package/qwen-extension/skills/local-browser-test/lib/test-plan.js +2 -0
- package/qwen-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
- package/qwen-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
- package/qwen-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +226 -2
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
- package/qwen-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
- package/qwen-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
- package/qwen-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
- package/qwen-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
- package/qwen-extension/skills/szcd-upload-zip/SKILL.md +45 -0
- package/scripts/lib/common.js +8 -1
- package/standard-skill/local-browser-test/SKILL.md +91 -0
- package/standard-skill/local-browser-test/config.example.yaml +51 -0
- package/standard-skill/local-browser-test/lib/api-call-executor.js +251 -0
- package/standard-skill/local-browser-test/lib/browser-engine.js +95 -21
- package/standard-skill/local-browser-test/lib/config-loader.js +104 -0
- package/standard-skill/local-browser-test/lib/console-archiver.js +100 -0
- package/standard-skill/local-browser-test/lib/global-setup.js +72 -0
- package/standard-skill/local-browser-test/lib/har-collector.js +124 -0
- package/standard-skill/local-browser-test/lib/html-reporter.js +248 -0
- package/standard-skill/local-browser-test/lib/projects.js +46 -0
- package/standard-skill/local-browser-test/lib/raw-script-helpers.js +93 -0
- package/standard-skill/local-browser-test/lib/redact.js +94 -0
- package/standard-skill/local-browser-test/lib/report-retain.js +84 -0
- package/standard-skill/local-browser-test/lib/retry-policy.js +160 -0
- package/standard-skill/local-browser-test/lib/storage-state.js +111 -0
- package/standard-skill/local-browser-test/lib/tag-runner.js +103 -0
- package/standard-skill/local-browser-test/lib/test-plan.js +2 -0
- package/standard-skill/local-browser-test/lib/trace-collector.js +94 -0
- package/standard-skill/local-browser-test/lib/trend-aggregator.js +97 -0
- package/standard-skill/local-browser-test/lib/workspace-init.js +178 -0
- package/standard-skill/local-browser-test/local-browser-executor.js +226 -2
- package/standard-skill/local-browser-test/tests/integration/pr-01.test.js +254 -0
- package/standard-skill/local-browser-test/tests/integration/pr-02.test.js +263 -0
- package/standard-skill/local-browser-test/tests/integration/pr-03.test.js +308 -0
- package/standard-skill/local-browser-test/tests/integration/pr-04.test.js +168 -0
- package/standard-skill/local-browser-test/tests/integration/pr-05.test.js +183 -0
- package/standard-skill/local-browser-test/tests/integration/pr-06.test.js +252 -0
- package/standard-skill/local-browser-test/tests/integration/pr-07.test.js +168 -0
- package/standard-skill/local-browser-test/tests/integration/pr-08.test.js +293 -0
- package/standard-skill/shadcn-design-to-code/SKILL.md +352 -0
- package/standard-skill/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
- package/standard-skill/shadcn-design-to-code/references/install-pitfalls.md +526 -0
- package/standard-skill/shadcn-design-to-code/references/layouts.md +551 -0
- package/standard-skill/shadcn-design-to-code/references/sketch-mapping.md +247 -0
- package/standard-skill/shadcn-design-to-code/references/tokens.md +534 -0
- package/standard-skill/szcd-upload-zip/SKILL.md +45 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一重试策略(PR #6 v3)
|
|
3
|
+
*
|
|
4
|
+
* 三层重试:
|
|
5
|
+
* 1. 步骤级 retries(验证码过期等场景,默认 2 次)
|
|
6
|
+
* 2. 用例级 retries(CI 抖动,--retries 参数,默认 0,CI 模式 2 次)
|
|
7
|
+
* 3. flaky 检测(5 次中 1 挂 4 过 → 标 flaky,仍算 PASS)
|
|
8
|
+
*
|
|
9
|
+
* 写操作禁重试白名单(act.click 文本匹配 + apiCall POST 默认):
|
|
10
|
+
* - 删除 / 提交 / 发布 / 上线 / 注销
|
|
11
|
+
* - delete / submit / publish / deploy / logout
|
|
12
|
+
* - apiCall POST 默认禁重试(避免重复写操作)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const NO_RETRY_KEYWORDS_ZH = ['删除', '提交', '发布', '上线', '注销'];
|
|
16
|
+
const NO_RETRY_KEYWORDS_EN = ['delete', 'submit', 'publish', 'deploy', 'logout'];
|
|
17
|
+
const NO_RETRY_TYPES = ['apiCall']; // apiCall 默认禁重试(除非显式 noRetry: false)
|
|
18
|
+
|
|
19
|
+
const DEFAULT_OPTIONS = {
|
|
20
|
+
maxRetries: 0, // 用例级 retries
|
|
21
|
+
maxRetriesPerStep: 2, // 步骤级 retries
|
|
22
|
+
flakyThreshold: 5, // flaky 检测:5 次中 1 挂 4 过
|
|
23
|
+
flakyPass: 4, // flaky 必须 ≥ 4 挂 1
|
|
24
|
+
noRetryActions: true, // 全局写操作禁重试
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class RetryPolicy {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_OPTIONS.maxRetries;
|
|
30
|
+
this.maxRetriesPerStep = options.maxRetriesPerStep ?? DEFAULT_OPTIONS.maxRetriesPerStep;
|
|
31
|
+
this.flakyThreshold = options.flakyThreshold ?? DEFAULT_OPTIONS.flakyThreshold;
|
|
32
|
+
this.flakyPass = options.flakyPass ?? DEFAULT_OPTIONS.flakyPass;
|
|
33
|
+
this.noRetryActions = options.noRetryActions ?? DEFAULT_OPTIONS.noRetryActions;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 判断某 step 是否允许重试
|
|
38
|
+
* @param {object} step
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
isStepRetryable(step) {
|
|
42
|
+
// 1. step 显式 noRetry 优先级最高
|
|
43
|
+
if (step.noRetry === true) return false;
|
|
44
|
+
if (step.noRetry === false) return true;
|
|
45
|
+
|
|
46
|
+
// 2. 全局写操作禁重试
|
|
47
|
+
if (this.noRetryActions) {
|
|
48
|
+
// act 步骤:检查文本含危险关键词
|
|
49
|
+
if (step.type === 'act' && step.click) {
|
|
50
|
+
const text = String(step.click).toLowerCase();
|
|
51
|
+
if (NO_RETRY_KEYWORDS_ZH.some((k) => text.includes(k.toLowerCase())) ||
|
|
52
|
+
NO_RETRY_KEYWORDS_EN.some((k) => text.includes(k))) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// apiCall 步骤:写方法(POST/PUT/DELETE/PATCH)默认禁重试
|
|
57
|
+
// 读方法(GET/HEAD/OPTIONS)允许重试
|
|
58
|
+
if (NO_RETRY_TYPES.includes(step.type)) {
|
|
59
|
+
const method = String(step.method ?? '').toUpperCase();
|
|
60
|
+
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 步骤级重试:执行 fn 直到成功或达到 maxRetries
|
|
70
|
+
* @param {object} step
|
|
71
|
+
* @param {() => Promise<object>} fn - 实际执行的函数,返回 { passed: bool, ... }
|
|
72
|
+
* @returns {Promise<{result: object, retryCount: number, aborted: boolean}>}
|
|
73
|
+
*/
|
|
74
|
+
async withStepRetry(step, fn) {
|
|
75
|
+
if (!this.isStepRetryable(step)) {
|
|
76
|
+
return { result: await fn(), retryCount: 0, aborted: false };
|
|
77
|
+
}
|
|
78
|
+
let lastResult;
|
|
79
|
+
for (let i = 0; i <= this.maxRetriesPerStep; i++) {
|
|
80
|
+
lastResult = await fn();
|
|
81
|
+
if (lastResult.passed !== false) {
|
|
82
|
+
return { result: lastResult, retryCount: i, aborted: false };
|
|
83
|
+
}
|
|
84
|
+
// 失败
|
|
85
|
+
}
|
|
86
|
+
return { result: lastResult, retryCount: this.maxRetriesPerStep, aborted: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 用例级重试:执行 fn 直到成功或达到 maxRetries
|
|
91
|
+
* @param {string} caseName
|
|
92
|
+
* @param {() => Promise<{passed: boolean, ...}>} fn
|
|
93
|
+
* @returns {Promise<{result: object, history: object[], retryCount: number, flaky: boolean}>}
|
|
94
|
+
*/
|
|
95
|
+
async withCaseRetry(caseName, fn) {
|
|
96
|
+
const history = [];
|
|
97
|
+
let lastResult;
|
|
98
|
+
for (let i = 0; i <= this.maxRetries; i++) {
|
|
99
|
+
lastResult = await fn();
|
|
100
|
+
history.push({ run: i + 1, passed: lastResult.passed ?? false });
|
|
101
|
+
if (lastResult.passed !== false) {
|
|
102
|
+
if (i > 0) {
|
|
103
|
+
console.log(` ✓ case retry ${i} succeeded: ${caseName}`);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
result: lastResult,
|
|
107
|
+
history,
|
|
108
|
+
retryCount: i,
|
|
109
|
+
flaky: this.detectFlaky(history)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
result: lastResult,
|
|
115
|
+
history,
|
|
116
|
+
retryCount: this.maxRetries,
|
|
117
|
+
flaky: this.detectFlaky(history)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* flaky 检测:5 次中 1 挂 4 过 → 标 flaky
|
|
123
|
+
* @param {Array<{run: number, passed: boolean}>} history
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
detectFlaky(history) {
|
|
127
|
+
if (history.length < this.flakyThreshold) return false;
|
|
128
|
+
const passed = history.filter((h) => h.passed).length;
|
|
129
|
+
const failed = history.length - passed;
|
|
130
|
+
return failed === this.flakyThreshold - this.flakyPass && passed === this.flakyPass;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 计算 summary.fidelity(基于 retries / passed / total 算可信度)
|
|
135
|
+
* @param {object} stats
|
|
136
|
+
* @returns {number} 0-1 之间的可信度分数
|
|
137
|
+
*/
|
|
138
|
+
computeFidelity(stats) {
|
|
139
|
+
if (stats.total === 0) return 1.0;
|
|
140
|
+
const base = stats.passed / stats.total;
|
|
141
|
+
const retryPenalty = Math.min((stats.retryCount ?? 0) * 0.1, 0.3);
|
|
142
|
+
const flakyPenalty = (stats.flaky ?? 0) * 0.05;
|
|
143
|
+
return Math.round((Math.max(0, base - retryPenalty - flakyPenalty)) * 100) / 100;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 工厂函数:从 plan.recovery 构造 RetryPolicy
|
|
149
|
+
* @param {object} planRecovery - plan.recovery 字段
|
|
150
|
+
* @returns {RetryPolicy}
|
|
151
|
+
*/
|
|
152
|
+
export function retryPolicyFromPlan(planRecovery = {}) {
|
|
153
|
+
return new RetryPolicy({
|
|
154
|
+
maxRetries: planRecovery.maxRetries ?? 0,
|
|
155
|
+
maxRetriesPerStep: planRecovery.maxRetriesPerStep ?? 2,
|
|
156
|
+
flakyThreshold: planRecovery.flakyThreshold ?? 5,
|
|
157
|
+
flakyPass: planRecovery.flakyPass ?? 4,
|
|
158
|
+
noRetryActions: planRecovery.noRetryActions ?? true
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storageState: puppeteer cookies ↔ Playwright 兼容格式
|
|
3
|
+
*
|
|
4
|
+
* Playwright storageState 格式:
|
|
5
|
+
* { cookies: [...], origins: [{ origin, localStorage: [{name, value}] }] }
|
|
6
|
+
*
|
|
7
|
+
* 用于:
|
|
8
|
+
* - 跨测试复用登录态(不重复登录)
|
|
9
|
+
* - 接管浏览器模式(globalSetupTakeover)
|
|
10
|
+
* - 自动填表模式(globalSetupFillForm)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 从当前 browser context 抓取 cookies + localStorage
|
|
17
|
+
* @param {import('puppeteer').Page} page
|
|
18
|
+
* @returns {Promise<{cookies: Array, origins: Array}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function captureStorageState(page) {
|
|
21
|
+
const context = page.browserContext();
|
|
22
|
+
const cookies = await context.cookies();
|
|
23
|
+
const origins = await extractLocalStorage(context);
|
|
24
|
+
|
|
25
|
+
return { cookies, origins };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function extractLocalStorage(context) {
|
|
29
|
+
const pages = await context.pages();
|
|
30
|
+
const originMap = new Map();
|
|
31
|
+
|
|
32
|
+
for (const p of pages) {
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(p.url());
|
|
35
|
+
const origin = url.origin;
|
|
36
|
+
const ls = await p.evaluate(() => {
|
|
37
|
+
const result = [];
|
|
38
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
39
|
+
const key = localStorage.key(i);
|
|
40
|
+
result.push({ name: key, value: localStorage.getItem(key) });
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
});
|
|
44
|
+
if (ls.length > 0) {
|
|
45
|
+
if (!originMap.has(origin)) originMap.set(origin, []);
|
|
46
|
+
originMap.get(origin).push(...ls);
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// 跨域 iframe / about:blank 等忽略
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Array.from(originMap.entries()).map(([origin, localStorage]) => ({
|
|
54
|
+
origin,
|
|
55
|
+
localStorage
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 加载 storageState 并应用到 browser context
|
|
61
|
+
* @param {import('puppeteer').BrowserContext} context
|
|
62
|
+
* @param {string} filePath
|
|
63
|
+
*/
|
|
64
|
+
export async function applyStorageState(context, filePath) {
|
|
65
|
+
if (!fs.existsSync(filePath)) {
|
|
66
|
+
console.warn(`⚠️ storageState 不存在: ${filePath},跳过`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
71
|
+
|
|
72
|
+
// 1. 注入 cookies
|
|
73
|
+
if (state.cookies?.length) {
|
|
74
|
+
await context.setCookie(...state.cookies);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. 注入 localStorage(需要在目标 origin 的 page 内执行)
|
|
78
|
+
if (state.origins?.length) {
|
|
79
|
+
for (const { origin, localStorage } of state.origins) {
|
|
80
|
+
if (!localStorage?.length) continue;
|
|
81
|
+
const page = await context.newPage();
|
|
82
|
+
try {
|
|
83
|
+
await page.goto(origin, { waitUntil: "domcontentloaded", timeout: 10000 });
|
|
84
|
+
await page.evaluate((items) => {
|
|
85
|
+
for (const { name, value } of items) {
|
|
86
|
+
localStorage.setItem(name, value);
|
|
87
|
+
}
|
|
88
|
+
}, localStorage);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.warn(`⚠️ 无法访问 ${origin} 注入 localStorage: ${e.message}`);
|
|
91
|
+
}
|
|
92
|
+
await page.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 从 storageState 提取 Cookie 头字符串(供 axios request 共享使用)
|
|
99
|
+
* @param {string} filePath
|
|
100
|
+
* @param {string} [targetOrigin] 可选,只取该 origin 的 cookies
|
|
101
|
+
* @returns {string} Cookie 头(空字符串如果文件不存在或无 cookies)
|
|
102
|
+
*/
|
|
103
|
+
export function buildCookieHeader(filePath, targetOrigin = null) {
|
|
104
|
+
if (!fs.existsSync(filePath)) return "";
|
|
105
|
+
const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
106
|
+
const cookies = state.cookies ?? [];
|
|
107
|
+
return cookies
|
|
108
|
+
.filter((c) => !targetOrigin || c.domain.endsWith(targetOrigin.replace(/^https?:\/\//, "")))
|
|
109
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
110
|
+
.join("; ");
|
|
111
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用例标签过滤(PR #7 v3)
|
|
3
|
+
*
|
|
4
|
+
* 标签格式:@smoke / @p0 / @p1 / @full / @flaky
|
|
5
|
+
* 匹配模式:any(默认,含任一)/ all(必须含全部)
|
|
6
|
+
* 应用层级:plan 顶层 + case 顶层
|
|
7
|
+
*
|
|
8
|
+
* 与 CI 集成:
|
|
9
|
+
* - PR 冒烟:--tags @smoke
|
|
10
|
+
* - 合并前:--tags "@p0,@p1" --tag-mode any
|
|
11
|
+
* - 定时全量:--tags @full
|
|
12
|
+
* - 排除 flaky:--tags @p0 --exclude-tags @flaky
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TAG_PATTERN = /^@[\w-]+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 解析逗号分隔的 tag 字符串
|
|
19
|
+
* @param {string|undefined|null} input
|
|
20
|
+
* @returns {string[]}
|
|
21
|
+
*/
|
|
22
|
+
export function parseTags(input) {
|
|
23
|
+
if (!input) return [];
|
|
24
|
+
if (Array.isArray(input)) return input.filter(Boolean);
|
|
25
|
+
return String(input)
|
|
26
|
+
.split(',')
|
|
27
|
+
.map((t) => t.trim())
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 判断 tags 数组是否匹配过滤条件
|
|
33
|
+
* @param {string[]} caseTags
|
|
34
|
+
* @param {string[]} filterTags
|
|
35
|
+
* @param {'any'|'all'} mode
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function matchTags(caseTags, filterTags, mode = 'any') {
|
|
39
|
+
if (!filterTags || filterTags.length === 0) return true; // 不过滤
|
|
40
|
+
if (!caseTags || caseTags.length === 0) return false; // 没 tag 不匹配
|
|
41
|
+
|
|
42
|
+
if (mode === 'all') {
|
|
43
|
+
return filterTags.every((t) => caseTags.includes(t));
|
|
44
|
+
}
|
|
45
|
+
// mode === 'any'(默认):含任一
|
|
46
|
+
return filterTags.some((t) => caseTags.includes(t));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 过滤 cases
|
|
51
|
+
* @param {Array} cases
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @returns {Array}
|
|
54
|
+
*/
|
|
55
|
+
export function filterCasesByTags(cases, options = {}) {
|
|
56
|
+
const {
|
|
57
|
+
tags = [],
|
|
58
|
+
excludeTags = [],
|
|
59
|
+
mode = 'any'
|
|
60
|
+
} = options;
|
|
61
|
+
|
|
62
|
+
return cases.filter((c) => {
|
|
63
|
+
const caseTags = c.tags ?? [];
|
|
64
|
+
// 排除优先:excludeTags 命中 → 跳过
|
|
65
|
+
if (excludeTags.length > 0 && matchTags(caseTags, excludeTags, 'any')) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// tags 不过滤 → 全部通过
|
|
69
|
+
if (tags.length === 0) return true;
|
|
70
|
+
// tags 命中 → 通过
|
|
71
|
+
return matchTags(caseTags, tags, mode);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 过滤 plans(testCase 数组)
|
|
77
|
+
* @param {Array} plans
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @returns {Array}
|
|
80
|
+
*/
|
|
81
|
+
export function filterPlansByTags(plans, options = {}) {
|
|
82
|
+
const { tags = [], excludeTags = [], mode = 'any' } = options;
|
|
83
|
+
return plans.filter((p) => {
|
|
84
|
+
const planTags = p.tags ?? [];
|
|
85
|
+
if (excludeTags.length > 0 && matchTags(planTags, excludeTags, 'any')) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (tags.length > 0 && !matchTags(planTags, tags, mode)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 校验 tag 格式(@ 前缀 + 字母数字下划线连字符)
|
|
97
|
+
* @param {string[]} tags
|
|
98
|
+
* @returns {{valid: boolean, invalid: string[]}}
|
|
99
|
+
*/
|
|
100
|
+
export function validateTags(tags) {
|
|
101
|
+
const invalid = tags.filter((t) => !TAG_PATTERN.test(t));
|
|
102
|
+
return { valid: invalid.length === 0, invalid };
|
|
103
|
+
}
|
|
@@ -18,6 +18,8 @@ export function compilePlan(plan) {
|
|
|
18
18
|
return {
|
|
19
19
|
title: plan.description || plan.planId || `auto-${Date.now()}`,
|
|
20
20
|
labels: plan.labels || [],
|
|
21
|
+
// ★ PR #7 v3: plan 顶层支持 tags(@smoke / @p0 / @p1 / @full / @flaky)
|
|
22
|
+
tags: plan.tags || [],
|
|
21
23
|
setup: {
|
|
22
24
|
findPage: plan.findPage || undefined,
|
|
23
25
|
findFrame: plan.findFrame || undefined,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* puppeteer trace 录制(PR #5)
|
|
3
|
+
*
|
|
4
|
+
* 用 CDP Tracing.start / Tracing.end 录制 trace。
|
|
5
|
+
* 输出 trace.zip(含 DOM snapshot + 网络 + console + 操作步骤)。
|
|
6
|
+
*
|
|
7
|
+
* 注意:puppeteer 自身不提供 zip 打包;这里输出 trace.json(CDP 原始格式)
|
|
8
|
+
* + meta.json(标签 + 时间戳),前端 HTML 报告可读这两个文件。
|
|
9
|
+
* 真正的 zip 打包可由 reporter 阶段用 archiver 完成(PR #5 范围外)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CATEGORIES = 'devtools.timeline,disabled-by-default-devtools.timeline,blink.user_timing';
|
|
16
|
+
|
|
17
|
+
export class TraceCollector {
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} runDir - 落盘目录(runs/<id>/)
|
|
20
|
+
*/
|
|
21
|
+
constructor(runDir) {
|
|
22
|
+
this.runDir = runDir;
|
|
23
|
+
this.collecting = false;
|
|
24
|
+
this.session = null;
|
|
25
|
+
this.events = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 启动 trace 录制
|
|
30
|
+
* @param {import('puppeteer').Page} page
|
|
31
|
+
*/
|
|
32
|
+
async start(page) {
|
|
33
|
+
if (this.collecting) return;
|
|
34
|
+
try {
|
|
35
|
+
this.session = await page.target().createCDPSession();
|
|
36
|
+
await this.session.send('Tracing.start', {
|
|
37
|
+
categories: DEFAULT_CATEGORIES,
|
|
38
|
+
options: 'sampling-frequency=10000',
|
|
39
|
+
transferMode: 'ReportEvents'
|
|
40
|
+
});
|
|
41
|
+
this.collecting = true;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.warn(`⚠️ TraceCollector.start 失败: ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 停止 trace 录制,落盘
|
|
49
|
+
* @returns {Promise<string|null>} trace.json 路径
|
|
50
|
+
*/
|
|
51
|
+
async stop() {
|
|
52
|
+
if (!this.collecting) return null;
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
console.warn('⚠️ TraceCollector.stop 超时(5s)');
|
|
56
|
+
resolve(null);
|
|
57
|
+
}, 5000);
|
|
58
|
+
|
|
59
|
+
this.session.once('Tracing.dataCollected', (data) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
try {
|
|
62
|
+
this.events.push(...(data.value ?? []));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn(`⚠️ 收集 trace 事件失败: ${e.message}`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.session.send('Tracing.end').then(() => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
// 给 dataCollected 1s 缓冲时间
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(this.runDir, { recursive: true });
|
|
74
|
+
const tracePath = path.join(this.runDir, 'trace.json');
|
|
75
|
+
fs.writeFileSync(tracePath, JSON.stringify({
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
capturedAt: new Date().toISOString(),
|
|
78
|
+
events: this.events
|
|
79
|
+
}, null, 2));
|
|
80
|
+
this.collecting = false;
|
|
81
|
+
resolve(tracePath);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn(`⚠️ TraceCollector 落盘失败: ${e.message}`);
|
|
84
|
+
resolve(null);
|
|
85
|
+
}
|
|
86
|
+
}, 1000);
|
|
87
|
+
}).catch((e) => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
console.warn(`⚠️ Tracing.end 失败: ${e.message}`);
|
|
90
|
+
resolve(null);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trend.json 按日聚合(v4 关键能力)
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* - schemaVersion: 1
|
|
6
|
+
* - byDay[YYYY-MM-DD]: { runs, total, passed, failed, flaky, skipped,
|
|
7
|
+
* byStrategy: {api, ui, newman, browser},
|
|
8
|
+
* byTag: {}, byProject: {}, byBranch: {} }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
updatedAt: null,
|
|
16
|
+
byDay: {}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 把一次 run 追加到 trend.json
|
|
21
|
+
* @param {object} result
|
|
22
|
+
* @param {string} trendPath
|
|
23
|
+
*/
|
|
24
|
+
export function appendToTrend(result, trendPath) {
|
|
25
|
+
const trend = fs.existsSync(trendPath)
|
|
26
|
+
? JSON.parse(fs.readFileSync(trendPath, "utf8"))
|
|
27
|
+
: structuredClone(DEFAULTS);
|
|
28
|
+
|
|
29
|
+
const day = (result.startedAt ?? new Date().toISOString()).slice(0, 10);
|
|
30
|
+
if (!trend.byDay[day]) {
|
|
31
|
+
trend.byDay[day] = {
|
|
32
|
+
runs: 0, total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0,
|
|
33
|
+
byStrategy: { api: 0, ui: 0, newman: 0, browser: 0 },
|
|
34
|
+
byTag: {},
|
|
35
|
+
byProject: {},
|
|
36
|
+
byBranch: {}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const d = trend.byDay[day];
|
|
41
|
+
d.runs += 1;
|
|
42
|
+
d.total += result.stats?.total ?? 0;
|
|
43
|
+
d.passed += result.stats?.passed ?? 0;
|
|
44
|
+
d.failed += result.stats?.failed ?? 0;
|
|
45
|
+
d.flaky += result.stats?.flaky ?? 0;
|
|
46
|
+
d.skipped += result.stats?.skipped ?? 0;
|
|
47
|
+
|
|
48
|
+
// ★ v4 新增:按 strategy 分桶
|
|
49
|
+
const strategy = result.strategy ?? "newman";
|
|
50
|
+
d.byStrategy[strategy] = (d.byStrategy[strategy] ?? 0) + (result.stats?.total ?? 0);
|
|
51
|
+
|
|
52
|
+
// 按 tag 分桶
|
|
53
|
+
if (result.tag) {
|
|
54
|
+
if (!d.byTag[result.tag]) d.byTag[result.tag] = { runs: 0, passed: 0, failed: 0 };
|
|
55
|
+
const t = d.byTag[result.tag];
|
|
56
|
+
t.runs += 1;
|
|
57
|
+
t.passed += result.stats?.passed ?? 0;
|
|
58
|
+
t.failed += result.stats?.failed ?? 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 按 project 分桶
|
|
62
|
+
if (result.project) {
|
|
63
|
+
if (!d.byProject[result.project]) d.byProject[result.project] = { runs: 0, passed: 0, failed: 0 };
|
|
64
|
+
const p = d.byProject[result.project];
|
|
65
|
+
p.runs += 1;
|
|
66
|
+
p.passed += result.stats?.passed ?? 0;
|
|
67
|
+
p.failed += result.stats?.failed ?? 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 按 branch 分桶
|
|
71
|
+
if (result.branch) {
|
|
72
|
+
if (!d.byBranch[result.branch]) d.byBranch[result.branch] = { runs: 0, passed: 0, failed: 0 };
|
|
73
|
+
const b = d.byBranch[result.branch];
|
|
74
|
+
b.runs += 1;
|
|
75
|
+
b.passed += result.stats?.passed ?? 0;
|
|
76
|
+
b.failed += result.stats?.failed ?? 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
trend.updatedAt = new Date().toISOString();
|
|
80
|
+
fs.writeFileSync(trendPath, JSON.stringify(trend, null, 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 裁剪 trend.json:保留最近 N 天
|
|
85
|
+
* @param {string} trendPath
|
|
86
|
+
* @param {number} days
|
|
87
|
+
*/
|
|
88
|
+
export function trimTrendToDays(trendPath, days) {
|
|
89
|
+
if (!fs.existsSync(trendPath)) return;
|
|
90
|
+
const trend = JSON.parse(fs.readFileSync(trendPath, "utf8"));
|
|
91
|
+
const cutoff = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
|
92
|
+
for (const day of Object.keys(trend.byDay)) {
|
|
93
|
+
if (day < cutoff) delete trend.byDay[day];
|
|
94
|
+
}
|
|
95
|
+
trend.updatedAt = new Date().toISOString();
|
|
96
|
+
fs.writeFileSync(trendPath, JSON.stringify(trend, null, 2));
|
|
97
|
+
}
|