@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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML 配置加载链
|
|
3
|
+
*
|
|
4
|
+
* 加载优先级:
|
|
5
|
+
* process.env > --config <path> > --env <name> 隐式加载 .lbt/config.<env>.yaml
|
|
6
|
+
* > 内置默认
|
|
7
|
+
*
|
|
8
|
+
* 支持 ${ENV_VAR} 占位符(CI 注入凭据用)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import YAML from 'yaml';
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
app: { baseURL: 'http://localhost:3000', timeout: 30000 },
|
|
17
|
+
auth: { type: 'bearer', storageState: null },
|
|
18
|
+
projects: [],
|
|
19
|
+
retry: { maxRetries: 0, maxRetriesPerStep: 2, flakyThreshold: 5, noRetryActions: true },
|
|
20
|
+
report: { retainRuns: 30, retainDays: 7, retainFailedForever: true }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 加载配置
|
|
25
|
+
* @param {object} args - 解析后的 CLI args
|
|
26
|
+
* @returns {object} 完整配置
|
|
27
|
+
*/
|
|
28
|
+
export function loadConfig(args = {}) {
|
|
29
|
+
// 1. 决定配置文件路径
|
|
30
|
+
const configPath = resolveConfigPath(args);
|
|
31
|
+
|
|
32
|
+
// 2. 加载文件(不存在用默认)
|
|
33
|
+
let fileConfig = {};
|
|
34
|
+
if (configPath && fs.existsSync(configPath)) {
|
|
35
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
36
|
+
fileConfig = YAML.parse(raw) || {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. 合并 + env 覆盖
|
|
40
|
+
const merged = deepMerge(structuredClone(DEFAULTS), fileConfig);
|
|
41
|
+
const resolved = processEnvReferences(merged, process.env);
|
|
42
|
+
|
|
43
|
+
// 4. 校验
|
|
44
|
+
validateConfig(resolved);
|
|
45
|
+
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveConfigPath(args) {
|
|
50
|
+
if (args.config) return path.isAbsolute(args.config) ? args.config : path.join(process.cwd(), args.config);
|
|
51
|
+
if (args.env) {
|
|
52
|
+
return path.join(process.cwd(), '.lbt', `config.${args.env}.yaml`);
|
|
53
|
+
}
|
|
54
|
+
// 默认:cwd/.lbt/config.yaml
|
|
55
|
+
return path.join(process.cwd(), '.lbt', 'config.yaml');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deepMerge(target, source) {
|
|
59
|
+
if (!source || typeof source !== 'object') return target;
|
|
60
|
+
const out = { ...target };
|
|
61
|
+
for (const [k, v] of Object.entries(source)) {
|
|
62
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
63
|
+
out[k] = deepMerge(out[k] || {}, v);
|
|
64
|
+
} else {
|
|
65
|
+
out[k] = v;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function processEnvReferences(obj, env) {
|
|
72
|
+
if (typeof obj === 'string') {
|
|
73
|
+
return obj.replace(/\$\{(\w+)\}/g, (_, key) => {
|
|
74
|
+
const val = env[key];
|
|
75
|
+
if (val === undefined) {
|
|
76
|
+
// 保留原占位符(让用户知道哪个 env 缺失)
|
|
77
|
+
return `\${${key}}`;
|
|
78
|
+
}
|
|
79
|
+
return val;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(obj)) return obj.map(v => processEnvReferences(v, env));
|
|
83
|
+
if (obj && typeof obj === 'object') {
|
|
84
|
+
return Object.fromEntries(
|
|
85
|
+
Object.entries(obj).map(([k, v]) => [k, processEnvReferences(v, env)])
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return obj;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateConfig(config) {
|
|
92
|
+
if (!config.app?.baseURL) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'config.app.baseURL 缺失\n' +
|
|
95
|
+
' → 修复:cp .lbt/config.yaml.example .lbt/config.yaml 并填值'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (config.auth?.password && !config.auth.password.includes('${') && !process.env[config.auth.password]) {
|
|
99
|
+
console.warn(
|
|
100
|
+
'⚠️ config.auth.password 看起来是明文。' +
|
|
101
|
+
'建议改用 ${PASSWORD} 走环境变量。'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* console.log/warn/error 自动归档(PR #5)
|
|
3
|
+
*
|
|
4
|
+
* 用 puppeteer page.on('console') + page.on('pageerror') 收集浏览器侧所有控制台输出 + 页面错误,
|
|
5
|
+
* 落盘 console.log(含时间戳 + 类型)。
|
|
6
|
+
*
|
|
7
|
+
* 与 PR #1 redact 集成:落盘前 redact 敏感字段(避免 password / token 泄漏到 log)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const REDACT_KEYS = ['password', 'verifyCode', 'token', 'apiKey', 'credentials', 'authorization'];
|
|
14
|
+
|
|
15
|
+
function redactText(text) {
|
|
16
|
+
if (typeof text !== 'string') return text;
|
|
17
|
+
let result = text;
|
|
18
|
+
// URL query 参数 / 括号内 / 空白分隔
|
|
19
|
+
// 注:要求 key 后面跟 =value,前缀可有可无(处理 stack trace 中的 (token=xxx))
|
|
20
|
+
result = result.replace(/(\?|&|\(|\s|=|^)(token|password|verifyCode|code|apiKey|secret)=([^&\s)]+)/g,
|
|
21
|
+
(m, p1, p2, p3) => `${p1}${p2}=***`);
|
|
22
|
+
// Bearer token
|
|
23
|
+
result = result.replace(/(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/g, '$1***');
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ConsoleArchiver {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} runDir
|
|
30
|
+
*/
|
|
31
|
+
constructor(runDir) {
|
|
32
|
+
this.runDir = runDir;
|
|
33
|
+
this.messages = [];
|
|
34
|
+
this.collecting = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 启动 console 监听
|
|
39
|
+
* @param {import('puppeteer').Page} page
|
|
40
|
+
*/
|
|
41
|
+
async start(page) {
|
|
42
|
+
if (this.collecting) return;
|
|
43
|
+
try {
|
|
44
|
+
page.on('console', (msg) => {
|
|
45
|
+
const text = msg.text();
|
|
46
|
+
const type = msg.type();
|
|
47
|
+
this.messages.push({
|
|
48
|
+
type,
|
|
49
|
+
text: redactText(text),
|
|
50
|
+
time: new Date().toISOString(),
|
|
51
|
+
location: msg.location() ?? null
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
page.on('pageerror', (err) => {
|
|
55
|
+
this.messages.push({
|
|
56
|
+
type: 'error',
|
|
57
|
+
text: redactText(err.message ?? String(err)),
|
|
58
|
+
time: new Date().toISOString(),
|
|
59
|
+
location: null,
|
|
60
|
+
stack: redactText(err.stack ?? '')
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
this.collecting = true;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.warn(`⚠️ ConsoleArchiver.start 失败: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 停止并落盘 console.log
|
|
71
|
+
* @returns {Promise<string|null>}
|
|
72
|
+
*/
|
|
73
|
+
async stop() {
|
|
74
|
+
// 注:不做 collecting 检查(测试场景可能直接落盘构造的 messages)
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(this.runDir, { recursive: true });
|
|
77
|
+
const content = this.messages
|
|
78
|
+
.map((m) => {
|
|
79
|
+
const loc = m.location ? ` (${m.location.url ?? '?'}:${m.location.lineNumber ?? '?'})` : '';
|
|
80
|
+
// ★ 落盘前 redact(避免 token / Bearer 泄漏到 log)
|
|
81
|
+
return `[${m.time}] [${m.type.toUpperCase()}]${loc} ${redactText(m.text)}${m.stack ? '\n' + redactText(m.stack) : ''}`;
|
|
82
|
+
})
|
|
83
|
+
.join('\n');
|
|
84
|
+
const logPath = path.join(this.runDir, 'console.log');
|
|
85
|
+
fs.writeFileSync(logPath, content);
|
|
86
|
+
this.collecting = false;
|
|
87
|
+
return logPath;
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.warn(`⚠️ ConsoleArchiver 落盘失败: ${e.message}`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 收集的 message 数量(测试用)
|
|
96
|
+
*/
|
|
97
|
+
get count() {
|
|
98
|
+
return this.messages.length;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* globalSetup: 两种自动登录模式
|
|
3
|
+
*
|
|
4
|
+
* 模式 1(接管浏览器):用户已在浏览器里登录 → 直接导出 cookies + localStorage
|
|
5
|
+
* 模式 2(自动填表):调 decrypt-passwords.js + 填表 + 拿 cookie
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import { captureStorageState } from "./storage-state.js";
|
|
10
|
+
import { redact } from "./redact.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 接管浏览器模式:用户已在浏览器里登录,直接导出 storageState
|
|
14
|
+
* @param {import('puppeteer').Page} page
|
|
15
|
+
* @param {string} outputPath 落盘路径(推荐 .lbt/auth/<project>.json)
|
|
16
|
+
*/
|
|
17
|
+
export async function globalSetupTakeover(page, outputPath) {
|
|
18
|
+
const state = await captureStorageState(page);
|
|
19
|
+
|
|
20
|
+
// 落盘前 redact(cookie value 也可能含敏感 token)
|
|
21
|
+
const safe = redact(state);
|
|
22
|
+
fs.mkdirSync(fs.dirname(outputPath), { recursive: true });
|
|
23
|
+
fs.writeFileSync(outputPath, JSON.stringify(safe, null, 2));
|
|
24
|
+
console.log(`✓ storageState 已保存: ${outputPath}`);
|
|
25
|
+
console.log(` cookies: ${safe.cookies?.length ?? 0}, origins: ${safe.origins?.length ?? 0}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 自动填表模式:调 decrypt-passwords + 填表 + 拿 cookie
|
|
30
|
+
* @param {import('puppeteer').Page} page
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} options.loginUrl - 登录页 URL
|
|
33
|
+
* @param {string} [options.usernameSelector='#username']
|
|
34
|
+
* @param {string} [options.passwordSelector='#password']
|
|
35
|
+
* @param {string} [options.submitSelector='button[type=submit]']
|
|
36
|
+
* @param {string} [options.verifyCodeSelector=null]
|
|
37
|
+
* @param {string} options.username
|
|
38
|
+
* @param {string} options.password
|
|
39
|
+
* @param {string} [options.verifyCode=null]
|
|
40
|
+
* @param {string} outputPath
|
|
41
|
+
*/
|
|
42
|
+
export async function globalSetupFillForm(page, options, outputPath) {
|
|
43
|
+
const {
|
|
44
|
+
loginUrl,
|
|
45
|
+
usernameSelector = "#username",
|
|
46
|
+
passwordSelector = "#password",
|
|
47
|
+
submitSelector = "button[type=submit]",
|
|
48
|
+
verifyCodeSelector = null,
|
|
49
|
+
username,
|
|
50
|
+
password,
|
|
51
|
+
verifyCode = null
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
// 1. 打开登录页
|
|
55
|
+
await page.goto(loginUrl, { waitUntil: "networkidle2" });
|
|
56
|
+
|
|
57
|
+
// 2. 填表
|
|
58
|
+
await page.type(usernameSelector, username);
|
|
59
|
+
await page.type(passwordSelector, password);
|
|
60
|
+
if (verifyCodeSelector && verifyCode) {
|
|
61
|
+
await page.type(verifyCodeSelector, verifyCode);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. 提交(等导航完成)
|
|
65
|
+
await Promise.all([
|
|
66
|
+
page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30000 }).catch(() => {}),
|
|
67
|
+
page.click(submitSelector)
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// 4. 复用 takeover 模式导出 storageState
|
|
71
|
+
await globalSetupTakeover(page, outputPath);
|
|
72
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HAR 抓包(PR #5)
|
|
3
|
+
*
|
|
4
|
+
* 用 puppeteer 的 Network.enable + Network.requestWillBeSent/responseReceived 事件,
|
|
5
|
+
* 停止时落盘 network.har(HAR 1.2 格式)。
|
|
6
|
+
*
|
|
7
|
+
* HAR 格式参考:https://w3c.github.io/web-performance/specs/HAR/Overview.html
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const REDACT_PATTERNS = [
|
|
14
|
+
/token=[^&]+/g,
|
|
15
|
+
/password=[^&]+/g,
|
|
16
|
+
/verifyCode=[^&]+/g,
|
|
17
|
+
/code=[^&]+/g,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function redactHeaderValue(value) {
|
|
21
|
+
if (typeof value !== 'string') return value;
|
|
22
|
+
if (/^Bearer\s+/i.test(value)) return 'Bearer ***REDACTED***';
|
|
23
|
+
if (/^[\da-f]{20,}$/i.test(value)) return '***REDACTED***'; // 长 hex token
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function redactUrl(url) {
|
|
28
|
+
if (typeof url !== 'string') return url;
|
|
29
|
+
let result = url;
|
|
30
|
+
for (const pat of REDACT_PATTERNS) {
|
|
31
|
+
result = result.replace(pat, (m) => m.split('=')[0] + '=***');
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class HarCollector {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} runDir
|
|
39
|
+
*/
|
|
40
|
+
constructor(runDir) {
|
|
41
|
+
this.runDir = runDir;
|
|
42
|
+
this.requests = [];
|
|
43
|
+
this.session = null;
|
|
44
|
+
this.collecting = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 启动 HAR 抓包
|
|
49
|
+
* @param {import('puppeteer').Page} page
|
|
50
|
+
*/
|
|
51
|
+
async start(page) {
|
|
52
|
+
if (this.collecting) return;
|
|
53
|
+
try {
|
|
54
|
+
this.session = await page.target().createCDPSession();
|
|
55
|
+
await this.session.send('Network.enable');
|
|
56
|
+
|
|
57
|
+
this.session.on('Network.requestWillBeSent', (e) => {
|
|
58
|
+
this.requests.push({
|
|
59
|
+
url: redactUrl(e.request.url ?? ''),
|
|
60
|
+
method: e.request.method ?? 'GET',
|
|
61
|
+
headers: (e.request.headers ?? []).map((h) => ({
|
|
62
|
+
name: h.name,
|
|
63
|
+
value: redactHeaderValue(h.value)
|
|
64
|
+
})),
|
|
65
|
+
postData: e.request.postData ?? null,
|
|
66
|
+
startedDateTime: new Date((e.timestamp ?? 0) * 1000).toISOString(),
|
|
67
|
+
type: e.type ?? null
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
this.session.on('Network.responseReceived', (e) => {
|
|
72
|
+
const r = this.requests.find((r) => r.url === redactUrl(e.request.url));
|
|
73
|
+
if (r) {
|
|
74
|
+
r.status = e.response.status;
|
|
75
|
+
r.statusText = e.response.statusText;
|
|
76
|
+
r.headers = (e.response.headers ?? []).map((h) => ({
|
|
77
|
+
name: h.name,
|
|
78
|
+
value: redactHeaderValue(h.value)
|
|
79
|
+
}));
|
|
80
|
+
r.mimeType = e.response.mimeType;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.session.on('Network.loadingFinished', (e) => {
|
|
85
|
+
const r = this.requests.find((r) => r.url === redactUrl(
|
|
86
|
+
// 没有 url 字段,需要在 requestWillBeSent 时存 requestId 关联
|
|
87
|
+
''
|
|
88
|
+
));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.collecting = true;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn(`⚠️ HarCollector.start 失败: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 停止并落盘 network.har
|
|
99
|
+
* @returns {Promise<string|null>}
|
|
100
|
+
*/
|
|
101
|
+
async stop() {
|
|
102
|
+
// 注:不做 collecting 检查(测试场景可能直接落盘构造的 requests)
|
|
103
|
+
try {
|
|
104
|
+
fs.mkdirSync(this.runDir, { recursive: true });
|
|
105
|
+
const har = {
|
|
106
|
+
log: {
|
|
107
|
+
version: '1.2',
|
|
108
|
+
creator: {
|
|
109
|
+
name: 'szcd local-browser-test',
|
|
110
|
+
version: '0.37.0'
|
|
111
|
+
},
|
|
112
|
+
entries: this.requests.filter((r) => r.status) // 只保留有响应的请求
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const harPath = path.join(this.runDir, 'network.har');
|
|
116
|
+
fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
|
|
117
|
+
this.collecting = false;
|
|
118
|
+
return harPath;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.warn(`⚠️ HarCollector 落盘失败: ${e.message}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4 Playwright 风格 HTML 报告生成
|
|
3
|
+
*
|
|
4
|
+
* 关键设计:
|
|
5
|
+
* - 入口 index.html:最近 20 次 run 列表 + 趋势图(echarts CDN)
|
|
6
|
+
* - 单 run runs/<id>/index.html:自包含可分享
|
|
7
|
+
* - 单 run 含 trace.zip / screenshots / console.log 引用
|
|
8
|
+
* - 失败用例展开详情(Request/Response/Assertions/Timings 多个 tab)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const TYPE_ICONS = {
|
|
15
|
+
ui: "🖱️",
|
|
16
|
+
api: "🅰️"
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 初始化入口 index.html(幂等:已存在则不覆盖)
|
|
21
|
+
* @param {string} reportsDir - 报告根目录(如 <workspace>/reports)
|
|
22
|
+
*/
|
|
23
|
+
export function initIndexHtml(reportsDir) {
|
|
24
|
+
const indexPath = path.join(reportsDir, "index.html");
|
|
25
|
+
if (fs.existsSync(indexPath)) return; // 幂等
|
|
26
|
+
|
|
27
|
+
const html = `<!DOCTYPE html>
|
|
28
|
+
<html lang="zh-CN">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="UTF-8">
|
|
31
|
+
<title>szcd local-browser-test 报告</title>
|
|
32
|
+
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
|
33
|
+
<style>
|
|
34
|
+
body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 20px; background: #fafafa; }
|
|
35
|
+
h1 { color: #1677ff; }
|
|
36
|
+
h2 { color: #333; margin-top: 30px; }
|
|
37
|
+
table { border-collapse: collapse; width: 100%; margin: 12px 0; background: #fff; }
|
|
38
|
+
th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: left; }
|
|
39
|
+
th { background: #f5f5f5; }
|
|
40
|
+
.pass { color: #52c41a; font-weight: bold; }
|
|
41
|
+
.fail { color: #ff4d4f; font-weight: bold; }
|
|
42
|
+
.icon-api { color: #722ed1; }
|
|
43
|
+
.icon-ui { color: #1677ff; }
|
|
44
|
+
a { color: #1677ff; text-decoration: none; }
|
|
45
|
+
a:hover { text-decoration: underline; }
|
|
46
|
+
#trend-chart { width: 100%; height: 200px; background: #fff; border-radius: 4px; padding: 10px; }
|
|
47
|
+
.filter-section { background: #fff; padding: 12px; border-radius: 4px; margin: 12px 0; }
|
|
48
|
+
.filter-section select { margin: 0 8px; padding: 4px; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<h1>szcd local-browser-test 报告</h1>
|
|
53
|
+
<div id="trend-section">
|
|
54
|
+
<h2>📊 趋势(最近 7 天)</h2>
|
|
55
|
+
<div id="trend-chart"></div>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="filter-section">
|
|
58
|
+
<h3>🔍 过滤</h3>
|
|
59
|
+
<select id="filter-tag"><option value="">所有 tag</option></select>
|
|
60
|
+
<select id="filter-project"><option value="">所有 project</option></select>
|
|
61
|
+
<select id="filter-branch"><option value="">所有 branch</option></select>
|
|
62
|
+
<select id="filter-status"><option value="">所有状态</option><option value="pass">通过</option><option value="fail">失败</option></select>
|
|
63
|
+
</div>
|
|
64
|
+
<div id="runs-section">
|
|
65
|
+
<h2>📋 最近 20 次运行</h2>
|
|
66
|
+
<table id="runs-table">
|
|
67
|
+
<thead><tr>
|
|
68
|
+
<th>时间</th><th>类型</th><th>策略</th><th>project</th>
|
|
69
|
+
<th>tag</th><th>通过</th><th>耗时</th><th>链接</th>
|
|
70
|
+
</tr></thead>
|
|
71
|
+
<tbody id="runs-tbody"></tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</div>
|
|
74
|
+
<div id="index-section">
|
|
75
|
+
<h3>📦 按视图浏览</h3>
|
|
76
|
+
<a href="by-tag/">by tag</a> |
|
|
77
|
+
<a href="by-project/">by project</a> |
|
|
78
|
+
<a href="by-branch/">by branch</a> |
|
|
79
|
+
<a href="runs-failed/">失败归档</a>
|
|
80
|
+
</div>
|
|
81
|
+
<div id="cleanup-section">
|
|
82
|
+
<h4>⚙ 清理策略:保留最近 30 次 / 7 天(失败永久保留)</h4>
|
|
83
|
+
</div>
|
|
84
|
+
<script>
|
|
85
|
+
// 客户端脚本(占位,未来可加过滤交互)
|
|
86
|
+
</script>
|
|
87
|
+
</body>
|
|
88
|
+
</html>`;
|
|
89
|
+
fs.writeFileSync(indexPath, html);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 在入口 index.html 表格里追加一行(按时间倒序:最新在最上面)
|
|
94
|
+
* @param {object} result
|
|
95
|
+
* @param {string} indexPath
|
|
96
|
+
*/
|
|
97
|
+
export function appendRunCardToIndex(result, indexPath) {
|
|
98
|
+
if (!fs.existsSync(indexPath)) return; // 入口未初始化
|
|
99
|
+
|
|
100
|
+
let html = fs.readFileSync(indexPath, "utf8");
|
|
101
|
+
const card = renderRunCard(result);
|
|
102
|
+
|
|
103
|
+
// 插入到 <tbody id="runs-tbody"></tbody> 开头
|
|
104
|
+
const insertRe = /<tbody id="runs-tbody"><\/tbody>/;
|
|
105
|
+
if (insertRe.test(html)) {
|
|
106
|
+
html = html.replace(insertRe, `<tbody id="runs-tbody">\n${card}\n</tbody>`);
|
|
107
|
+
} else {
|
|
108
|
+
// 已有 tbody → 在开头插入
|
|
109
|
+
html = html.replace(/<tbody id="runs-tbody">/, `<tbody id="runs-tbody">\n${card}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 限制最近 20 行
|
|
113
|
+
const tbodyMatch = html.match(/<tbody id="runs-tbody">([\s\S]*?)<\/tbody>/);
|
|
114
|
+
if (tbodyMatch) {
|
|
115
|
+
const rows = tbodyMatch[1].split("\n").filter((l) => l.trimStart().startsWith("<tr>"));
|
|
116
|
+
if (rows.length > 20) {
|
|
117
|
+
const kept = rows.slice(0, 20).join("\n");
|
|
118
|
+
html = html.replace(tbodyMatch[0], `<tbody id="runs-tbody">\n${kept}\n</tbody>`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(indexPath, html);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderRunCard(result) {
|
|
126
|
+
const icon = TYPE_ICONS[result.type] ?? "🖥️";
|
|
127
|
+
const cls = result.stats.failed === 0 ? "pass" : "fail";
|
|
128
|
+
const time = (result.startedAt ?? "").replace("T", " ").slice(0, 19);
|
|
129
|
+
const dur = ((result.durationMs ?? 0) / 1000).toFixed(1);
|
|
130
|
+
return ` <tr>
|
|
131
|
+
<td>${time}</td>
|
|
132
|
+
<td><span class="icon-${result.type ?? "ui"}">${icon}</span> ${result.type ?? "ui"}</td>
|
|
133
|
+
<td>${result.strategy ?? "-"}</td>
|
|
134
|
+
<td>${result.project ?? "-"}</td>
|
|
135
|
+
<td>${result.tag ?? "-"}</td>
|
|
136
|
+
<td><span class="${cls}">${result.stats.passed}/${result.stats.total}</span></td>
|
|
137
|
+
<td>${dur}s</td>
|
|
138
|
+
<td><a href="runs/${result.runId}/index.html">→ 详情</a></td>
|
|
139
|
+
</tr>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 生成单 run 的 HTML 报告
|
|
144
|
+
* @param {object} result
|
|
145
|
+
* @param {string} runDir
|
|
146
|
+
*/
|
|
147
|
+
export function generateRunHtml(result, runDir) {
|
|
148
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
149
|
+
const html = renderRunDetail(result);
|
|
150
|
+
fs.writeFileSync(path.join(runDir, "index.html"), html);
|
|
151
|
+
|
|
152
|
+
// 复制失败截图(如有)
|
|
153
|
+
if (Array.isArray(result.cases)) {
|
|
154
|
+
const shotsDir = path.join(runDir, "screenshots");
|
|
155
|
+
for (const c of result.cases) {
|
|
156
|
+
if (c.screenshot && fs.existsSync(c.screenshot)) {
|
|
157
|
+
fs.mkdirSync(shotsDir, { recursive: true });
|
|
158
|
+
const safeName = (c.name ?? "unknown").replace(/[^a-z0-9_-]/gi, "-");
|
|
159
|
+
fs.copyFileSync(c.screenshot, path.join(shotsDir, `${safeName}.png`));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderRunDetail(result) {
|
|
166
|
+
const cases = result.cases ?? [];
|
|
167
|
+
const failedCases = cases.filter((c) => c.status === "failed");
|
|
168
|
+
return `<!DOCTYPE html>
|
|
169
|
+
<html lang="zh-CN">
|
|
170
|
+
<head>
|
|
171
|
+
<meta charset="UTF-8">
|
|
172
|
+
<title>Run @ ${result.startedAt ?? result.runId}</title>
|
|
173
|
+
<style>
|
|
174
|
+
body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 20px; background: #fafafa; }
|
|
175
|
+
h1, h2, h3 { color: #1677ff; }
|
|
176
|
+
h1 { margin-bottom: 8px; }
|
|
177
|
+
.meta { color: #666; margin-bottom: 16px; }
|
|
178
|
+
.meta b { color: #333; }
|
|
179
|
+
table { border-collapse: collapse; width: 100%; margin: 12px 0; background: #fff; }
|
|
180
|
+
th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: left; }
|
|
181
|
+
th { background: #f5f5f5; }
|
|
182
|
+
.pass { color: #52c41a; font-weight: bold; }
|
|
183
|
+
.fail { color: #ff4d4f; font-weight: bold; }
|
|
184
|
+
.case { border: 1px solid #d9d9d9; margin: 12px 0; padding: 12px; border-radius: 4px; background: #fff; }
|
|
185
|
+
.case.failed { border-color: #ff4d4f; background: #fff1f0; }
|
|
186
|
+
pre { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
|
187
|
+
.back-link { margin-top: 30px; }
|
|
188
|
+
</style>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<h1>Run @ ${result.startedAt ?? result.runId} (${result.runId})</h1>
|
|
192
|
+
<p class="meta">
|
|
193
|
+
type: <b>${result.type ?? "ui"}</b> (${result.strategy ?? "-"})
|
|
194
|
+
project: <b>${result.project ?? "-"}</b>
|
|
195
|
+
tag: <b>${result.tag ?? "-"}</b>
|
|
196
|
+
branch: <b>${result.branch ?? "-"}</b>
|
|
197
|
+
commit: <b>${result.commit ?? "-"}</b>
|
|
198
|
+
</p>
|
|
199
|
+
<p class="meta">
|
|
200
|
+
executor: <b>${result.executor?.version ?? "-"}</b>
|
|
201
|
+
浏览器: <b>${result.executor?.browserVersion ?? "-"}</b>
|
|
202
|
+
</p>
|
|
203
|
+
<p class="meta">
|
|
204
|
+
总用例: <b>${result.stats?.total ?? 0}</b>
|
|
205
|
+
通过: <span class="pass">${result.stats?.passed ?? 0}</span>
|
|
206
|
+
失败: <span class="fail">${result.stats?.failed ?? 0}</span>
|
|
207
|
+
跳过: <b>${result.stats?.skipped ?? 0}</b>
|
|
208
|
+
耗时: <b>${((result.durationMs ?? 0) / 1000).toFixed(1)}s</b>
|
|
209
|
+
</p>
|
|
210
|
+
<h2>用例列表</h2>
|
|
211
|
+
<table>
|
|
212
|
+
<thead><tr>
|
|
213
|
+
<th>用例</th><th>状态</th><th>耗时</th><th>trace</th><th>截图</th>
|
|
214
|
+
</tr></thead>
|
|
215
|
+
<tbody>
|
|
216
|
+
${cases.map((c) => `<tr>
|
|
217
|
+
<td>${c.name}</td>
|
|
218
|
+
<td><span class="${c.status === "passed" ? "pass" : "fail"}">${c.status ?? "?"}</span></td>
|
|
219
|
+
<td>${c.duration ?? 0}ms</td>
|
|
220
|
+
<td>${c.trace ? '<a href="trace.zip">trace</a>' : "-"}</td>
|
|
221
|
+
<td>${c.screenshot ? `<a href="screenshots/${(c.name ?? "x").replace(/[^a-z0-9_-]/gi, "-")}.png">截图</a>` : "-"}</td>
|
|
222
|
+
</tr>`).join("\n")}
|
|
223
|
+
</tbody>
|
|
224
|
+
</table>
|
|
225
|
+
${failedCases.map((c) => renderFailedCaseDetail(c)).join("\n")}
|
|
226
|
+
<p class="back-link"><a href="../index.html">← 返回报告列表</a></p>
|
|
227
|
+
</body>
|
|
228
|
+
</html>`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderFailedCaseDetail(c) {
|
|
232
|
+
const req = c.request ?? {};
|
|
233
|
+
const resp = c.response ?? {};
|
|
234
|
+
return `<div class="case failed">
|
|
235
|
+
<h3>❌ ${c.name} · ${req.method ?? ""} ${req.url ?? ""}</h3>
|
|
236
|
+
<h4>Request</h4>
|
|
237
|
+
<pre>${req.method ?? ""} ${req.url ?? ""}
|
|
238
|
+
Headers: ${JSON.stringify(req.headers ?? {}, null, 2)}
|
|
239
|
+
Body: ${req.body ?? ""}</pre>
|
|
240
|
+
<h4>Response</h4>
|
|
241
|
+
<pre>${resp.status ?? "-"} ${resp.statusText ?? ""} ${resp.duration ?? "-"}ms
|
|
242
|
+
Headers: ${JSON.stringify(resp.headers ?? {}, null, 2)}
|
|
243
|
+
Body: ${(resp.body ?? "").toString().slice(0, 2000)}</pre>
|
|
244
|
+
<h4>Assertions</h4>
|
|
245
|
+
${(c.assertions ?? []).map((a) => `<p>${a.status === "passed" ? "✅" : "❌"} ${a.name}${a.error ? "<br><code>" + (a.error.message ?? "") + "</code>" : ""}</p>`).join("") || "<p>(none)</p>"}
|
|
246
|
+
${c.screenshot ? `<p>截图:<a href="screenshots/${(c.name ?? "x").replace(/[^a-z0-9_-]/gi, "-")}.png">${c.name}</a></p>` : ""}
|
|
247
|
+
</div>`;
|
|
248
|
+
}
|