@szc-ft/mcp-szcd-client 0.35.0 → 0.39.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 +186 -18
- 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 +73 -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 +425 -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 +264 -4
- 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 +186 -18
- 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 +73 -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 +425 -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 +264 -4
- 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 +7 -0
- package/standard-skill/local-browser-test/SKILL.md +186 -18
- 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 +73 -0
- package/standard-skill/local-browser-test/lib/har-collector.js +124 -0
- package/standard-skill/local-browser-test/lib/html-reporter.js +425 -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 +264 -4
- 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,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apiCall step 增强执行器(PR #8 v3 走法 P)
|
|
3
|
+
*
|
|
4
|
+
* 关键设计:
|
|
5
|
+
* - API 步骤走 Node 18+ 内置 fetch(**不通过浏览器**,但自动共享 context.cookies())
|
|
6
|
+
* - UI 步骤走 page(真实浏览器,CDP)
|
|
7
|
+
* - 同一 testCase 跑(UI + API 混合),cookie 自动共享
|
|
8
|
+
* - 首次 = 回归 = 真实行为(永远一致)
|
|
9
|
+
* - 不引入 newman 依赖
|
|
10
|
+
* - 不引入 axios 依赖(用 fetch 即可,Node 18+ 内置)
|
|
11
|
+
*
|
|
12
|
+
* 6 个新字段:
|
|
13
|
+
* - assert: L1 status / L2 header / L3 body path / L4 responseTime
|
|
14
|
+
* - captureTrace: HAR-like trace 录制(PR #5 trace-collector 集成)
|
|
15
|
+
* - noRetry: 写操作禁重试(PR #6 retry-policy 集成)
|
|
16
|
+
* - strategy: plan 显式(newman / browser / auto)
|
|
17
|
+
* - headers: 自定义请求头
|
|
18
|
+
* - tags: 用例标签(PR #7)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { redact } from './redact.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* apiCall step 增强 schema
|
|
25
|
+
* @typedef {object} ApiCallStep
|
|
26
|
+
* @property {'apiCall'} type
|
|
27
|
+
* @property {string} method
|
|
28
|
+
* @property {string} url
|
|
29
|
+
* @property {object} [headers]
|
|
30
|
+
* @property {object} [body]
|
|
31
|
+
* @property {object} [assert] - L1-L4 断言
|
|
32
|
+
* @property {boolean} [captureTrace] - 是否记录 trace
|
|
33
|
+
* @property {boolean} [noRetry] - 写操作禁重试(默认 true)
|
|
34
|
+
* @property {string} [strategy] - plan 显式(newman / browser / auto)
|
|
35
|
+
* @property {string[]} [tags] - 用例标签
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 共享 cookie header(PR #8 关键设计)
|
|
40
|
+
* @param {Array<{name: string, value: string, domain?: string}>} cookies
|
|
41
|
+
* @param {string} [targetOrigin]
|
|
42
|
+
* @returns {string} Cookie 头(空字符串如果无 cookie)
|
|
43
|
+
*/
|
|
44
|
+
export function buildCookieHeader(cookies, targetOrigin) {
|
|
45
|
+
if (!Array.isArray(cookies) || cookies.length === 0) return '';
|
|
46
|
+
const filtered = targetOrigin
|
|
47
|
+
? cookies.filter((c) => c.domain?.endsWith(targetOrigin.replace(/^https?:\/\//, '')))
|
|
48
|
+
: cookies;
|
|
49
|
+
return filtered.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ★ axios interceptor 工厂:记录 timings(PR #5 + PR #8)
|
|
54
|
+
* @param {object} collector - { timings: Array<{name, url, method, total}> }
|
|
55
|
+
* @returns {{ request: Function, response: Function, error: Function }}
|
|
56
|
+
*/
|
|
57
|
+
export function createTimingsInterceptor(collector) {
|
|
58
|
+
return {
|
|
59
|
+
request(config) {
|
|
60
|
+
config.metadata = { startTime: process.hrtime.bigint() };
|
|
61
|
+
return config;
|
|
62
|
+
},
|
|
63
|
+
response(response) {
|
|
64
|
+
const ns = process.hrtime.bigint() - response.config.metadata.startTime;
|
|
65
|
+
const total = Number(ns / 1000000n); // ms
|
|
66
|
+
collector.timings.push({
|
|
67
|
+
name: response.config.metadata.name ?? '',
|
|
68
|
+
url: response.config.url,
|
|
69
|
+
method: response.config.method?.toUpperCase(),
|
|
70
|
+
total,
|
|
71
|
+
status: response.status
|
|
72
|
+
});
|
|
73
|
+
response.config.metadata.total = total;
|
|
74
|
+
return response;
|
|
75
|
+
},
|
|
76
|
+
error(error) {
|
|
77
|
+
if (error.config?.metadata) {
|
|
78
|
+
const ns = process.hrtime.bigint() - error.config.metadata.startTime;
|
|
79
|
+
error.config.metadata.total = Number(ns / 1000000n);
|
|
80
|
+
}
|
|
81
|
+
return Promise.reject(error);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* L1-L4 断言引擎
|
|
88
|
+
* @param {object} response - axios response
|
|
89
|
+
* @param {object} assertSpec - 断言规格
|
|
90
|
+
* @returns {{passed: boolean, checks: Array<{name: string, level: number, passed: boolean, actual: any, expected: any, error?: string}>}}
|
|
91
|
+
*/
|
|
92
|
+
export function assertApiResponse(response, assertSpec) {
|
|
93
|
+
const checks = [];
|
|
94
|
+
let allPassed = true;
|
|
95
|
+
|
|
96
|
+
// L1: status code
|
|
97
|
+
if (assertSpec.status !== undefined) {
|
|
98
|
+
const actual = response.status;
|
|
99
|
+
const expected = assertSpec.status;
|
|
100
|
+
const passed = actual === expected;
|
|
101
|
+
checks.push({ name: `Status is ${expected}`, level: 1, actual, expected, passed });
|
|
102
|
+
if (!passed) allPassed = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// L2: header
|
|
106
|
+
if (assertSpec.headers) {
|
|
107
|
+
for (const [key, expected] of Object.entries(assertSpec.headers)) {
|
|
108
|
+
const actual = response.headers?.[key.toLowerCase()];
|
|
109
|
+
const passed = actual === expected;
|
|
110
|
+
checks.push({ name: `Header ${key}=${expected}`, level: 2, actual, expected, passed });
|
|
111
|
+
if (!passed) allPassed = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// L3: body path
|
|
116
|
+
if (assertSpec.body) {
|
|
117
|
+
for (const [path, expected] of Object.entries(assertSpec.body)) {
|
|
118
|
+
const actual = getByPath(response.data, path);
|
|
119
|
+
const passed = matchValue(actual, expected);
|
|
120
|
+
checks.push({ name: `Body ${path} = ${JSON.stringify(expected)}`, level: 3, actual, expected, passed });
|
|
121
|
+
if (!passed) allPassed = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// L4: response time
|
|
126
|
+
if (assertSpec.responseTime !== undefined) {
|
|
127
|
+
const total = response.config?.metadata?.total ?? 0;
|
|
128
|
+
const expected = assertSpec.responseTime;
|
|
129
|
+
const passed = total < expected;
|
|
130
|
+
checks.push({ name: `Response time < ${expected}ms`, level: 4, actual: total, expected, passed });
|
|
131
|
+
if (!passed) allPassed = false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { passed: allPassed, checks };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 路径取值(点号访问嵌套对象)
|
|
139
|
+
*/
|
|
140
|
+
function getByPath(obj, path) {
|
|
141
|
+
return String(path).split('.').reduce((o, k) => o?.[k], obj);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 操作符匹配
|
|
146
|
+
* @param {any} actual
|
|
147
|
+
* @param {any} expected - 可以是值 / { $gt: n } / { $lt: n } / { $contains: 'x' } / { $regex: 'p' }
|
|
148
|
+
*/
|
|
149
|
+
function matchValue(actual, expected) {
|
|
150
|
+
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
|
|
151
|
+
if ('$gt' in expected && actual > expected.$gt) return true;
|
|
152
|
+
if ('$lt' in expected && actual < expected.$lt) return true;
|
|
153
|
+
if ('$gte' in expected && actual >= expected.$gte) return true;
|
|
154
|
+
if ('$lte' in expected && actual <= expected.$lte) return true;
|
|
155
|
+
if ('$contains' in expected && String(actual ?? '').includes(expected.$contains)) return true;
|
|
156
|
+
if ('$regex' in expected && new RegExp(expected.$regex).test(String(actual ?? ''))) return true;
|
|
157
|
+
if ('$in' in expected && Array.isArray(expected.$in) && expected.$in.includes(actual)) return true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return actual === expected;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 执行 apiCall step(v3 走法 P:不通过浏览器,走 axios)
|
|
165
|
+
* @param {ApiCallStep} step
|
|
166
|
+
* @param {object} ctx - { baseURL, cookies, traceCollector? }
|
|
167
|
+
* @returns {Promise<{
|
|
168
|
+
* name: string, status: 'passed'|'failed', duration: number,
|
|
169
|
+
* request: object, response: object,
|
|
170
|
+
* assertions: Array, trace: object|null, error: object|null
|
|
171
|
+
* }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function executeApiCallStep(step, ctx = {}) {
|
|
174
|
+
const startTime = Date.now();
|
|
175
|
+
const method = (step.method || 'POST').toUpperCase();
|
|
176
|
+
const timings = [];
|
|
177
|
+
const collector = { timings };
|
|
178
|
+
|
|
179
|
+
// 1. 构造 axios request:自动共享 context.cookies()
|
|
180
|
+
const cookieHeader = buildCookieHeader(ctx.cookies ?? [], ctx.baseURL);
|
|
181
|
+
const request = axios.create({
|
|
182
|
+
baseURL: ctx.baseURL,
|
|
183
|
+
headers: {
|
|
184
|
+
Cookie: cookieHeader,
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
...step.headers
|
|
187
|
+
},
|
|
188
|
+
// 让 axios 接受任意状态码(不抛错)
|
|
189
|
+
validateStatus: () => true
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// 2. ★ axios interceptor(PR #5 + PR #8)
|
|
193
|
+
const interceptor = createTimingsInterceptor(collector);
|
|
194
|
+
request.interceptors.request.use(interceptor.request);
|
|
195
|
+
request.interceptors.response.use(interceptor.response, interceptor.error);
|
|
196
|
+
|
|
197
|
+
// 3. 发请求
|
|
198
|
+
let response;
|
|
199
|
+
let error = null;
|
|
200
|
+
try {
|
|
201
|
+
response = await request({
|
|
202
|
+
method,
|
|
203
|
+
url: step.url,
|
|
204
|
+
data: step.body
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
error = {
|
|
208
|
+
message: e.message ?? String(e),
|
|
209
|
+
code: e.code ?? null
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const duration = Date.now() - startTime;
|
|
214
|
+
const status = response?.status ?? 0;
|
|
215
|
+
|
|
216
|
+
// 4. ★ 断言(PR #8 L1-L4)
|
|
217
|
+
let assertionResults = [];
|
|
218
|
+
let passed = error == null && status >= 200 && status < 300; // 默认通过 = 2xx
|
|
219
|
+
if (step.assert && response) {
|
|
220
|
+
const assertResult = assertApiResponse(response, step.assert);
|
|
221
|
+
assertionResults = assertResult.checks;
|
|
222
|
+
if (!assertResult.passed) {
|
|
223
|
+
passed = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 5. ★ 构造返回值(脱敏 + 统一 schema)
|
|
228
|
+
const safeResponse = response ? {
|
|
229
|
+
status: response.status,
|
|
230
|
+
statusText: response.statusText,
|
|
231
|
+
duration: response.config?.metadata?.total ?? 0,
|
|
232
|
+
headers: redact(response.headers ?? {}),
|
|
233
|
+
body: redact(response.data)
|
|
234
|
+
} : null;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
name: step.name ?? `${method} ${step.url}`,
|
|
238
|
+
status: passed ? 'passed' : 'failed',
|
|
239
|
+
duration,
|
|
240
|
+
request: {
|
|
241
|
+
method,
|
|
242
|
+
url: step.url,
|
|
243
|
+
headers: redact(step.headers ?? {}),
|
|
244
|
+
body: redact(step.body)
|
|
245
|
+
},
|
|
246
|
+
response: safeResponse,
|
|
247
|
+
assertions: assertionResults,
|
|
248
|
+
trace: step.captureTrace ? { timings: collector.timings } : null,
|
|
249
|
+
error
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -16,6 +16,8 @@ import { AntAdapter } from "./ant-adapter.js";
|
|
|
16
16
|
import { createExpect, locator as expectLocator, validation as expectValidation, url as expectUrl, api as expectApi } from "./expect.js";
|
|
17
17
|
import { getBrowserConfigFromProcess } from "./cdp-browser-config.js";
|
|
18
18
|
import { decryptAllPasswords } from "./decrypt-passwords.js";
|
|
19
|
+
// ★ PR #6 v3: 重试 + flaky + 写操作禁重试
|
|
20
|
+
import { RetryPolicy, retryPolicyFromPlan } from "./retry-policy.js";
|
|
19
21
|
import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
|
|
20
22
|
import { join, dirname } from 'path';
|
|
21
23
|
import { homedir } from 'os';
|
|
@@ -3246,24 +3248,83 @@ export class BrowserEngine {
|
|
|
3246
3248
|
|
|
3247
3249
|
async _rawScript(step) {
|
|
3248
3250
|
var target = await this._getTargetFrame(step);
|
|
3251
|
+
// ★ PR #4 v3 修复:抽 helper 便于测试 + 支持 step.codeFile + 静态扫描 Node-only API
|
|
3252
|
+
var resolve = require('./raw-script-helpers.js');
|
|
3253
|
+
var codeResult = resolve.resolveCodeFile(step, process.cwd());
|
|
3254
|
+
if (codeResult.error) {
|
|
3255
|
+
return resolve.buildErrorResult(codeResult.reason);
|
|
3256
|
+
}
|
|
3257
|
+
var code = codeResult.code;
|
|
3258
|
+
|
|
3259
|
+
// 静态扫描:检测常见 Node-only API(浏览器侧不可用)
|
|
3260
|
+
var nodeOnlyHints = resolve.detectNodeOnlyApi(code);
|
|
3261
|
+
if (nodeOnlyHints.length > 0) {
|
|
3262
|
+
return resolve.buildErrorResult(
|
|
3263
|
+
'rawScript 检测到 Node-only API:\n - ' + nodeOnlyHints.join('\n - ') +
|
|
3264
|
+
'\n\n → 修复:改用浏览器侧 API(document.* / localStorage / fetch 等)\n' +
|
|
3265
|
+
' → 详见 SKILL.md > ## rawScript 可用 API'
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3249
3269
|
var fn;
|
|
3250
|
-
try {
|
|
3251
|
-
|
|
3270
|
+
try {
|
|
3271
|
+
// rawScript 签名:async (page, frame, params, browser) => { return { passed, ... } }
|
|
3272
|
+
fn = new Function('page', 'frame', 'params', 'browser', 'return (async () => { ' + code + ' })()');
|
|
3273
|
+
} catch (e) {
|
|
3274
|
+
return resolve.buildErrorResult('script compile error: ' + e.message + '\n → 详见 SKILL.md > ## rawScript 可用 API');
|
|
3275
|
+
}
|
|
3252
3276
|
try {
|
|
3253
3277
|
var result = await fn(this.page, target.frame, step.params || {}, this.browser);
|
|
3254
3278
|
return Object.assign({ type: 'rawScript' }, result);
|
|
3255
|
-
} catch (e) {
|
|
3279
|
+
} catch (e) {
|
|
3280
|
+
return resolve.buildErrorResult('script execution error: ' + e.message + '\n → 详见 SKILL.md > ## rawScript 可用 API');
|
|
3281
|
+
}
|
|
3256
3282
|
}
|
|
3257
3283
|
|
|
3258
3284
|
async _apiCall(step) {
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3285
|
+
// ★ PR #8 v3: 走法 P — 增强 apiCall step(不通过浏览器,走 axios)
|
|
3286
|
+
// 自动从 context.cookies() 共享 Cookie
|
|
3287
|
+
// 6 个新字段:assert / captureTrace / noRetry / strategy / headers / tags
|
|
3288
|
+
try {
|
|
3289
|
+
// 从 currentPage 拿 cookies
|
|
3290
|
+
let cookies = [];
|
|
3291
|
+
try {
|
|
3292
|
+
if (this.page && this.page.browserContext) {
|
|
3293
|
+
cookies = await this.page.browserContext().cookies();
|
|
3294
|
+
}
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
// 跨域 iframe / about:blank 等忽略
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// 调新 executor
|
|
3300
|
+
const { executeApiCallStep } = await import('./api-call-executor.js');
|
|
3301
|
+
const baseURL = this._baseURL || (this.page ? (new URL(this.page.url()).origin) : '');
|
|
3302
|
+
const result = await executeApiCallStep(step, {
|
|
3303
|
+
baseURL,
|
|
3304
|
+
cookies
|
|
3305
|
+
});
|
|
3306
|
+
|
|
3307
|
+
return {
|
|
3308
|
+
type: 'apiCall',
|
|
3309
|
+
passed: result.status === 'passed',
|
|
3310
|
+
name: result.name,
|
|
3311
|
+
status: result.response?.status ?? 0,
|
|
3312
|
+
duration: result.duration,
|
|
3313
|
+
request: result.request,
|
|
3314
|
+
response: result.response,
|
|
3315
|
+
assertions: result.assertions,
|
|
3316
|
+
trace: result.trace,
|
|
3317
|
+
error: result.error,
|
|
3318
|
+
reason: result.status === 'passed' ? null : (result.error?.message ?? `API returned ${result.response?.status}`)
|
|
3319
|
+
};
|
|
3320
|
+
} catch (e) {
|
|
3321
|
+
return {
|
|
3322
|
+
type: 'apiCall',
|
|
3323
|
+
passed: false,
|
|
3324
|
+
reason: 'apiCall 执行失败: ' + e.message + '\n → 详见 SKILL.md > ## apiCall step',
|
|
3325
|
+
status: 'FAIL'
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3267
3328
|
}
|
|
3268
3329
|
|
|
3269
3330
|
async _switchPage(step) {
|
|
@@ -3356,21 +3417,34 @@ export class BrowserEngine {
|
|
|
3356
3417
|
|
|
3357
3418
|
if (stepResult.status === "FAIL") {
|
|
3358
3419
|
allPassed = false;
|
|
3359
|
-
|
|
3360
|
-
const
|
|
3361
|
-
|
|
3362
|
-
const
|
|
3420
|
+
// ★ PR #6 v3:用 RetryPolicy 替换简单 retry 逻辑
|
|
3421
|
+
const policy = retryPolicyFromPlan(testCase.recovery);
|
|
3422
|
+
if (policy.isStepRetryable(step)) {
|
|
3423
|
+
const maxRetries = policy.maxRetriesPerStep;
|
|
3424
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
3425
|
+
const retryResult = await this.executeStep(step, context);
|
|
3426
|
+
rowResults.push({
|
|
3427
|
+
...retryResult,
|
|
3428
|
+
stepIndex,
|
|
3429
|
+
dataRowIndex,
|
|
3430
|
+
retry: retry + 1,
|
|
3431
|
+
});
|
|
3432
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
3433
|
+
if (retryResult.status === "PASS") {
|
|
3434
|
+
allPassed = true;
|
|
3435
|
+
break;
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
} else {
|
|
3439
|
+
// 写操作禁重试:标记 step 为 noRetry
|
|
3363
3440
|
rowResults.push({
|
|
3364
|
-
...
|
|
3441
|
+
...stepResult,
|
|
3365
3442
|
stepIndex,
|
|
3366
3443
|
dataRowIndex,
|
|
3367
|
-
|
|
3444
|
+
noRetry: true,
|
|
3445
|
+
reason: (stepResult.reason ?? 'fail') + ' (写操作禁重试)'
|
|
3368
3446
|
});
|
|
3369
3447
|
results.push(rowResults[rowResults.length - 1]);
|
|
3370
|
-
if (retryResult.status === "PASS") {
|
|
3371
|
-
allPassed = true;
|
|
3372
|
-
break;
|
|
3373
|
-
}
|
|
3374
3448
|
}
|
|
3375
3449
|
if (!allPassed && step.continueOnFail === false) break;
|
|
3376
3450
|
}
|
|
@@ -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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* globalSetup: 两种自动登录模式
|
|
3
|
+
*
|
|
4
|
+
* 模式 1(接管浏览器):用户已在浏览器里登录 → 直接导出 cookies + localStorage
|
|
5
|
+
* 模式 2(自动填表):调 decrypt-passwords.js + 填表 + 拿 cookie
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { captureStorageState } from "./storage-state.js";
|
|
11
|
+
import { redact } from "./redact.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 接管浏览器模式:用户已在浏览器里登录,直接导出 storageState
|
|
15
|
+
* @param {import('puppeteer').Page} page
|
|
16
|
+
* @param {string} outputPath 落盘路径(推荐 .lbt/auth/<project>.json)
|
|
17
|
+
*/
|
|
18
|
+
export async function globalSetupTakeover(page, outputPath) {
|
|
19
|
+
const state = await captureStorageState(page);
|
|
20
|
+
|
|
21
|
+
// 落盘前 redact(cookie value 也可能含敏感 token)
|
|
22
|
+
const safe = redact(state);
|
|
23
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
24
|
+
fs.writeFileSync(outputPath, JSON.stringify(safe, null, 2));
|
|
25
|
+
console.log(`✓ storageState 已保存: ${outputPath}`);
|
|
26
|
+
console.log(` cookies: ${safe.cookies?.length ?? 0}, origins: ${safe.origins?.length ?? 0}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 自动填表模式:调 decrypt-passwords + 填表 + 拿 cookie
|
|
31
|
+
* @param {import('puppeteer').Page} page
|
|
32
|
+
* @param {object} options
|
|
33
|
+
* @param {string} options.loginUrl - 登录页 URL
|
|
34
|
+
* @param {string} [options.usernameSelector='#username']
|
|
35
|
+
* @param {string} [options.passwordSelector='#password']
|
|
36
|
+
* @param {string} [options.submitSelector='button[type=submit]']
|
|
37
|
+
* @param {string} [options.verifyCodeSelector=null]
|
|
38
|
+
* @param {string} options.username
|
|
39
|
+
* @param {string} options.password
|
|
40
|
+
* @param {string} [options.verifyCode=null]
|
|
41
|
+
* @param {string} outputPath
|
|
42
|
+
*/
|
|
43
|
+
export async function globalSetupFillForm(page, options, outputPath) {
|
|
44
|
+
const {
|
|
45
|
+
loginUrl,
|
|
46
|
+
usernameSelector = "#username",
|
|
47
|
+
passwordSelector = "#password",
|
|
48
|
+
submitSelector = "button[type=submit]",
|
|
49
|
+
verifyCodeSelector = null,
|
|
50
|
+
username,
|
|
51
|
+
password,
|
|
52
|
+
verifyCode = null
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
// 1. 打开登录页
|
|
56
|
+
await page.goto(loginUrl, { waitUntil: "networkidle2" });
|
|
57
|
+
|
|
58
|
+
// 2. 填表
|
|
59
|
+
await page.type(usernameSelector, username);
|
|
60
|
+
await page.type(passwordSelector, password);
|
|
61
|
+
if (verifyCodeSelector && verifyCode) {
|
|
62
|
+
await page.type(verifyCodeSelector, verifyCode);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. 提交(等导航完成)
|
|
66
|
+
await Promise.all([
|
|
67
|
+
page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30000 }).catch(() => {}),
|
|
68
|
+
page.click(submitSelector)
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// 4. 复用 takeover 模式导出 storageState
|
|
72
|
+
await globalSetupTakeover(page, outputPath);
|
|
73
|
+
}
|