@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,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
+ }