@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.
Files changed (117) hide show
  1. package/agents/build.js +59 -1
  2. package/agents/platforms.json +7 -0
  3. package/agents/szcd-component-expert.qoder.md +2 -0
  4. package/opencode-extension/opencode.json +9 -0
  5. package/opencode-extension/skills/local-browser-test/SKILL.md +91 -0
  6. package/opencode-extension/skills/local-browser-test/config.example.yaml +51 -0
  7. package/opencode-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
  8. package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
  9. package/opencode-extension/skills/local-browser-test/lib/config-loader.js +104 -0
  10. package/opencode-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
  11. package/opencode-extension/skills/local-browser-test/lib/global-setup.js +72 -0
  12. package/opencode-extension/skills/local-browser-test/lib/har-collector.js +124 -0
  13. package/opencode-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
  14. package/opencode-extension/skills/local-browser-test/lib/projects.js +46 -0
  15. package/opencode-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
  16. package/opencode-extension/skills/local-browser-test/lib/redact.js +94 -0
  17. package/opencode-extension/skills/local-browser-test/lib/report-retain.js +84 -0
  18. package/opencode-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
  19. package/opencode-extension/skills/local-browser-test/lib/storage-state.js +111 -0
  20. package/opencode-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
  21. package/opencode-extension/skills/local-browser-test/lib/test-plan.js +2 -0
  22. package/opencode-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
  23. package/opencode-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
  24. package/opencode-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
  25. package/opencode-extension/skills/local-browser-test/local-browser-executor.js +226 -2
  26. package/opencode-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
  27. package/opencode-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
  28. package/opencode-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
  29. package/opencode-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
  30. package/opencode-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
  31. package/opencode-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
  32. package/opencode-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
  33. package/opencode-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
  34. package/opencode-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
  35. package/opencode-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
  36. package/opencode-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
  37. package/opencode-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
  38. package/opencode-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
  39. package/opencode-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
  40. package/opencode-extension/skills/szcd-upload-zip/SKILL.md +45 -0
  41. package/package.json +70 -70
  42. package/qwen-extension/qwen-extension.json +10 -1
  43. package/qwen-extension/skills/local-browser-test/SKILL.md +91 -0
  44. package/qwen-extension/skills/local-browser-test/config.example.yaml +51 -0
  45. package/qwen-extension/skills/local-browser-test/lib/api-call-executor.js +251 -0
  46. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +95 -21
  47. package/qwen-extension/skills/local-browser-test/lib/config-loader.js +104 -0
  48. package/qwen-extension/skills/local-browser-test/lib/console-archiver.js +100 -0
  49. package/qwen-extension/skills/local-browser-test/lib/global-setup.js +72 -0
  50. package/qwen-extension/skills/local-browser-test/lib/har-collector.js +124 -0
  51. package/qwen-extension/skills/local-browser-test/lib/html-reporter.js +248 -0
  52. package/qwen-extension/skills/local-browser-test/lib/projects.js +46 -0
  53. package/qwen-extension/skills/local-browser-test/lib/raw-script-helpers.js +93 -0
  54. package/qwen-extension/skills/local-browser-test/lib/redact.js +94 -0
  55. package/qwen-extension/skills/local-browser-test/lib/report-retain.js +84 -0
  56. package/qwen-extension/skills/local-browser-test/lib/retry-policy.js +160 -0
  57. package/qwen-extension/skills/local-browser-test/lib/storage-state.js +111 -0
  58. package/qwen-extension/skills/local-browser-test/lib/tag-runner.js +103 -0
  59. package/qwen-extension/skills/local-browser-test/lib/test-plan.js +2 -0
  60. package/qwen-extension/skills/local-browser-test/lib/trace-collector.js +94 -0
  61. package/qwen-extension/skills/local-browser-test/lib/trend-aggregator.js +97 -0
  62. package/qwen-extension/skills/local-browser-test/lib/workspace-init.js +178 -0
  63. package/qwen-extension/skills/local-browser-test/local-browser-executor.js +226 -2
  64. package/qwen-extension/skills/local-browser-test/tests/integration/pr-01.test.js +254 -0
  65. package/qwen-extension/skills/local-browser-test/tests/integration/pr-02.test.js +263 -0
  66. package/qwen-extension/skills/local-browser-test/tests/integration/pr-03.test.js +308 -0
  67. package/qwen-extension/skills/local-browser-test/tests/integration/pr-04.test.js +168 -0
  68. package/qwen-extension/skills/local-browser-test/tests/integration/pr-05.test.js +183 -0
  69. package/qwen-extension/skills/local-browser-test/tests/integration/pr-06.test.js +252 -0
  70. package/qwen-extension/skills/local-browser-test/tests/integration/pr-07.test.js +168 -0
  71. package/qwen-extension/skills/local-browser-test/tests/integration/pr-08.test.js +293 -0
  72. package/qwen-extension/skills/shadcn-design-to-code/SKILL.md +352 -0
  73. package/qwen-extension/skills/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
  74. package/qwen-extension/skills/shadcn-design-to-code/references/install-pitfalls.md +526 -0
  75. package/qwen-extension/skills/shadcn-design-to-code/references/layouts.md +551 -0
  76. package/qwen-extension/skills/shadcn-design-to-code/references/sketch-mapping.md +247 -0
  77. package/qwen-extension/skills/shadcn-design-to-code/references/tokens.md +534 -0
  78. package/qwen-extension/skills/szcd-upload-zip/SKILL.md +45 -0
  79. package/scripts/lib/common.js +8 -1
  80. package/standard-skill/local-browser-test/SKILL.md +91 -0
  81. package/standard-skill/local-browser-test/config.example.yaml +51 -0
  82. package/standard-skill/local-browser-test/lib/api-call-executor.js +251 -0
  83. package/standard-skill/local-browser-test/lib/browser-engine.js +95 -21
  84. package/standard-skill/local-browser-test/lib/config-loader.js +104 -0
  85. package/standard-skill/local-browser-test/lib/console-archiver.js +100 -0
  86. package/standard-skill/local-browser-test/lib/global-setup.js +72 -0
  87. package/standard-skill/local-browser-test/lib/har-collector.js +124 -0
  88. package/standard-skill/local-browser-test/lib/html-reporter.js +248 -0
  89. package/standard-skill/local-browser-test/lib/projects.js +46 -0
  90. package/standard-skill/local-browser-test/lib/raw-script-helpers.js +93 -0
  91. package/standard-skill/local-browser-test/lib/redact.js +94 -0
  92. package/standard-skill/local-browser-test/lib/report-retain.js +84 -0
  93. package/standard-skill/local-browser-test/lib/retry-policy.js +160 -0
  94. package/standard-skill/local-browser-test/lib/storage-state.js +111 -0
  95. package/standard-skill/local-browser-test/lib/tag-runner.js +103 -0
  96. package/standard-skill/local-browser-test/lib/test-plan.js +2 -0
  97. package/standard-skill/local-browser-test/lib/trace-collector.js +94 -0
  98. package/standard-skill/local-browser-test/lib/trend-aggregator.js +97 -0
  99. package/standard-skill/local-browser-test/lib/workspace-init.js +178 -0
  100. package/standard-skill/local-browser-test/local-browser-executor.js +226 -2
  101. package/standard-skill/local-browser-test/tests/integration/pr-01.test.js +254 -0
  102. package/standard-skill/local-browser-test/tests/integration/pr-02.test.js +263 -0
  103. package/standard-skill/local-browser-test/tests/integration/pr-03.test.js +308 -0
  104. package/standard-skill/local-browser-test/tests/integration/pr-04.test.js +168 -0
  105. package/standard-skill/local-browser-test/tests/integration/pr-05.test.js +183 -0
  106. package/standard-skill/local-browser-test/tests/integration/pr-06.test.js +252 -0
  107. package/standard-skill/local-browser-test/tests/integration/pr-07.test.js +168 -0
  108. package/standard-skill/local-browser-test/tests/integration/pr-08.test.js +293 -0
  109. package/standard-skill/shadcn-design-to-code/SKILL.md +352 -0
  110. package/standard-skill/shadcn-design-to-code/examples/1.1-overview-page.tsx +382 -0
  111. package/standard-skill/shadcn-design-to-code/references/install-pitfalls.md +526 -0
  112. package/standard-skill/shadcn-design-to-code/references/layouts.md +551 -0
  113. package/standard-skill/shadcn-design-to-code/references/sketch-mapping.md +247 -0
  114. package/standard-skill/shadcn-design-to-code/references/tokens.md +534 -0
  115. package/standard-skill/szcd-upload-zip/SKILL.md +45 -0
  116. package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
  117. package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +0 -742
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 通用脱敏函数。所有落盘 JSON 必走。
3
+ *
4
+ * 覆盖字段:password / verifyCode / credentials / token / apiKey / cookie 等
5
+ * 覆盖字符串:URL query 参数、Authorization header value、JSON 字段值
6
+ *
7
+ * 完整覆盖范围见 REDACT_KEYS / REDACT_PATTERNS
8
+ */
9
+
10
+ const REDACT_KEYS = [
11
+ 'password', 'verifyCode', 'credentials', 'auth',
12
+ 'token', 'accessToken', 'refreshToken', 'apiKey',
13
+ 'authorization', 'cookie', 'set-cookie', 'x-auth', 'x-token', 'x-api-key',
14
+ 'secret', 'privateKey', 'clientSecret'
15
+ ];
16
+
17
+ const REDACT_PATTERNS = [
18
+ // URL query 参数:(?token=abc | &password=xxx | ?code=6)
19
+ /([?&])(token|password|verifyCode|code|apiKey|secret)=([^&\s]+)/g,
20
+ // Authorization header value
21
+ /(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/g,
22
+ // JSON 字段值
23
+ /"(token|password|verifyCode|apiKey|secret)"\s*:\s*"[^"]+"/g,
24
+ ];
25
+
26
+ /**
27
+ * 递归脱敏对象
28
+ * @param {any} obj
29
+ * @returns {any} 新的脱敏后对象(深拷贝,不修改原对象)
30
+ */
31
+ export function redact(obj) {
32
+ if (obj === null || obj === undefined) return obj;
33
+ if (Array.isArray(obj)) return obj.map(redact);
34
+ if (typeof obj !== 'object') return redactString(String(obj));
35
+ return Object.fromEntries(
36
+ Object.entries(obj).map(([k, v]) => {
37
+ if (REDACT_KEYS.some(r => k.toLowerCase().includes(r.toLowerCase()))) {
38
+ return [k, '***REDACTED***'];
39
+ }
40
+ return [k, redact(v)];
41
+ })
42
+ );
43
+ }
44
+
45
+ function redactString(str) {
46
+ let result = str;
47
+ for (const pat of REDACT_PATTERNS) {
48
+ result = result.replace(pat, (match, p1, p2, p3) => {
49
+ if (p1 === '?' || p1 === '&') return `${p1}${p2}=***`;
50
+ if (p1 === 'Bearer ') return `${p1}***`;
51
+ // JSON 字段值:把 value 部分替换为 "***"
52
+ return match.replace(/:\s*"[^"]+"/, ': "***"');
53
+ });
54
+ }
55
+ return result;
56
+ }
57
+
58
+ /**
59
+ * 脱敏 HTTP headers 数组 [{ key, value }]
60
+ * @param {Array<{key: string, value: string}>} headers
61
+ * @returns {Array<{key: string, value: string}>}
62
+ */
63
+ export function redactHeaders(headers) {
64
+ if (!Array.isArray(headers)) return headers;
65
+ return headers.map(h => {
66
+ if (REDACT_KEYS.some(r => h.key.toLowerCase().includes(r.toLowerCase()))) {
67
+ return { key: h.key, value: '***REDACTED***' };
68
+ }
69
+ return h;
70
+ });
71
+ }
72
+
73
+ /**
74
+ * 脱敏 URL(保留路径和 host,redact query 参数)
75
+ * @param {string} url
76
+ * @returns {string}
77
+ */
78
+ export function redactUrl(url) {
79
+ if (typeof url !== 'string') return url;
80
+ return url.replace(
81
+ /([?&])(token|password|verifyCode|code|apiKey|secret)=([^&\s]+)/g,
82
+ '$1$2=***'
83
+ );
84
+ }
85
+
86
+ /**
87
+ * 脱敏 JSON 字符串中的敏感字段(用于已经序列化的字符串)
88
+ * @param {string} jsonStr
89
+ * @returns {string}
90
+ */
91
+ export function redactJsonString(jsonStr) {
92
+ if (typeof jsonStr !== 'string') return jsonStr;
93
+ return redactString(jsonStr);
94
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * v4 retain 策略:保留最近 N 次 + 最近 N 天 + 失败永久保留
3
+ *
4
+ * 设计:
5
+ * - retainRuns:保留最近 N 次 run(按 mtime 倒序)
6
+ * - retainRunsByDay:保留最近 N 天的 run
7
+ * - retainFailedForever:失败的 run 永久保留
8
+ * - 超出范围的 run 删 runs/ 下目录
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const DEFAULTS = {
15
+ retainRuns: 30,
16
+ retainRunsByDay: 7,
17
+ retainFailedForever: true
18
+ };
19
+
20
+ /**
21
+ * 应用 retain 策略
22
+ * @param {string} reportsDir
23
+ * @param {object} options
24
+ * @returns {{kept: number, removed: number}}
25
+ */
26
+ export function applyRetain(reportsDir, options = {}) {
27
+ const opts = { ...DEFAULTS, ...options };
28
+ const runsDir = path.join(reportsDir, "runs");
29
+ const failedDir = path.join(reportsDir, "runs-failed");
30
+
31
+ const allRuns = listRuns(runsDir).sort((a, b) => b.mtime - a.mtime);
32
+ const failedRuns = listRuns(failedDir);
33
+
34
+ // 计算保留集合
35
+ const keepByCount = new Set(allRuns.slice(0, opts.retainRuns).map((r) => r.id));
36
+ const cutoff = Date.now() - opts.retainRunsByDay * 24 * 3600 * 1000;
37
+ const keepByDay = new Set(allRuns.filter((r) => r.mtime >= cutoff).map((r) => r.id));
38
+ const keepFailed = new Set(failedRuns.map((r) => r.id));
39
+ const keep = new Set([...keepByCount, ...keepByDay, ...keepFailed]);
40
+
41
+ // 删除不在 keep 集合的
42
+ let removed = 0;
43
+ for (const run of allRuns) {
44
+ if (!keep.has(run.id)) {
45
+ fs.rmSync(run.path, { recursive: true, force: true });
46
+ removed++;
47
+ }
48
+ }
49
+
50
+ return { kept: keep.size, removed, total: allRuns.length };
51
+ }
52
+
53
+ /**
54
+ * 把失败的 run 归档到 runs-failed/(永久保留)
55
+ * @param {string} runId
56
+ * @param {string} runDir
57
+ * @param {string} failedDir
58
+ */
59
+ export function archiveFailedRun(runId, runDir, failedDir) {
60
+ fs.mkdirSync(failedDir, { recursive: true });
61
+ const target = path.join(failedDir, runId);
62
+ if (fs.existsSync(target)) {
63
+ // 已存在同名失败 run:覆盖(少见)
64
+ fs.rmSync(target, { recursive: true, force: true });
65
+ }
66
+ fs.renameSync(runDir, target);
67
+ }
68
+
69
+ function listRuns(dir) {
70
+ if (!fs.existsSync(dir)) return [];
71
+ return fs.readdirSync(dir)
72
+ .filter((name) => {
73
+ try {
74
+ return fs.statSync(path.join(dir, name)).isDirectory();
75
+ } catch {
76
+ return false;
77
+ }
78
+ })
79
+ .map((name) => ({
80
+ id: name,
81
+ path: path.join(dir, name),
82
+ mtime: fs.statSync(path.join(dir, name)).mtimeMs
83
+ }));
84
+ }
@@ -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
+ }