flower-trellis 0.5.2 → 0.5.4

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 (50) hide show
  1. package/enhancements/0.6/.agents/skills/trellis-release/SKILL.md +1 -1
  2. package/enhancements/0.6/.claude/skills/trellis-release/SKILL.md +1 -1
  3. package/enhancements/MANIFEST.json +2 -2
  4. package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +82 -32
  5. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/browser-controller.js +772 -0
  6. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/config.js +54 -0
  7. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +16 -13
  8. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +84 -78
  9. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +103 -212
  10. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -179
  11. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/network-capture.js +339 -0
  12. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +2 -1
  13. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
  14. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
  15. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
  16. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/logger.test.js +14 -0
  17. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
  18. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
  19. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/trace.test.js +36 -0
  20. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/control-client.js +95 -0
  21. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
  22. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/package.json +3 -0
  23. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +107 -40
  24. package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -32
  25. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/browser-controller.js +772 -0
  26. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/config.js +54 -0
  27. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +16 -13
  28. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +84 -78
  29. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +103 -212
  30. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -179
  31. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/network-capture.js +339 -0
  32. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +2 -1
  33. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
  34. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
  35. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
  36. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/logger.test.js +14 -0
  37. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
  38. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
  39. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/trace.test.js +36 -0
  40. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/control-client.js +95 -0
  41. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
  42. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/package.json +3 -0
  43. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +107 -40
  44. package/package.json +3 -3
  45. package/src/builtin-plugins/skill-garden/content-adapter.js +97 -4
  46. package/src/commands/init.js +13 -2
  47. package/src/commands/plugin.js +8 -4
  48. package/src/commands/update.js +7 -1
  49. package/src/lib/banner.js +10 -1
  50. package/src/lib/command-completion.js +43 -0
@@ -0,0 +1,339 @@
1
+ const { TextDecoder } = require('util');
2
+ const { MAX_NETWORK_BODY_BYTES } = require('./config');
3
+
4
+ const TEXT_CONTENT_TYPE_RE = /^(text\/)|json|xml|javascript|ecmascript|graphql|x-www-form-urlencoded|svg/i;
5
+ const BINARY_CONTENT_TYPE_RE = /^(image|audio|video|font)\/|octet-stream|zip|gzip|pdf|wasm/i;
6
+ const RECORDER_PATHS = new Set(['/log', '/events', '/session', '/dashboard', '/dashboard.html', '/health']);
7
+
8
+ /**
9
+ * 判断 URL 是否属于 recorder 自身流量。
10
+ *
11
+ * @param {string} rawUrl 请求 URL。
12
+ * @param {Object} [options] 过滤配置。
13
+ * @param {number} [options.port] logger 端口。
14
+ * @param {string[]} [options.hosts] 允许识别为本机的主机名。
15
+ * @return {boolean} 是否应从业务网络事件中排除。
16
+ */
17
+ function isRecorderRequest(rawUrl, options = {}) {
18
+ let url;
19
+ try {
20
+ url = new URL(rawUrl);
21
+ } catch (error) {
22
+ return false;
23
+ }
24
+ const hosts = new Set(options.hosts || ['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
25
+ const port = Number(options.port || 7777);
26
+ const actualPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
27
+ const ownPath = RECORDER_PATHS.has(url.pathname) || url.pathname.startsWith('/control/');
28
+ return hosts.has(url.hostname) && actualPort === port && ownPath;
29
+ }
30
+
31
+ /**
32
+ * 在字节上限内截取合法 UTF-8 文本。
33
+ *
34
+ * @param {Buffer} buffer 原始字节。
35
+ * @param {number} maxBytes 最大字节数。
36
+ * @return {{text:string,size:number,truncated:boolean}} 截取结果。
37
+ */
38
+ function truncateUtf8(buffer, maxBytes) {
39
+ const size = buffer.length;
40
+ const candidate = buffer.subarray(0, Math.min(size, maxBytes));
41
+ const decoded = new TextDecoder('utf-8').decode(candidate);
42
+ const validBuffer = Buffer.from(decoded, 'utf8');
43
+ if (validBuffer.length <= maxBytes) {
44
+ return { text: decoded, size, truncated: size > maxBytes };
45
+ }
46
+ const decoder = new TextDecoder('utf-8', { fatal: true });
47
+ for (let end = maxBytes; end >= Math.max(0, maxBytes - 4); end -= 1) {
48
+ try {
49
+ return { text: decoder.decode(validBuffer.subarray(0, end)), size, truncated: true };
50
+ } catch (error) {
51
+ // UTF-8 字符最多四字节,只需回退到当前字符起点。
52
+ }
53
+ }
54
+ return { text: '', size, truncated: true };
55
+ }
56
+
57
+ /**
58
+ * 判断无 Content-Type 的请求正文是否像可打印 UTF-8 文本。
59
+ *
60
+ * @param {Buffer} buffer 原始字节。
61
+ * @return {boolean} 是否可作为文本保存。
62
+ */
63
+ function isProbablyText(buffer) {
64
+ if (buffer.length === 0) return true;
65
+ let text;
66
+ try {
67
+ text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
68
+ } catch (error) {
69
+ return false;
70
+ }
71
+ let controls = 0;
72
+ for (const char of text) {
73
+ const code = char.codePointAt(0);
74
+ if ((code >= 0 && code < 9) || (code > 13 && code < 32)) controls += 1;
75
+ }
76
+ return controls / Math.max(1, text.length) < 0.02;
77
+ }
78
+
79
+ /**
80
+ * 解析 multipart 的字段和文件元数据,不保存文件正文。
81
+ *
82
+ * @param {Buffer} buffer multipart 原始字节。
83
+ * @param {string} contentType Content-Type。
84
+ * @param {number} maxBytes 普通字段最大保存字节数。
85
+ * @return {{parts:Array<Object>,parsed:boolean,reason?:string}} 解析结果。
86
+ */
87
+ function parseMultipartMetadata(buffer, contentType, maxBytes) {
88
+ const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
89
+ const boundary = match && (match[1] || match[2] || '').trim();
90
+ if (!boundary) return { parts: [], parsed: false, reason: 'multipart-missing-boundary' };
91
+
92
+ const raw = buffer.toString('latin1');
93
+ const chunks = raw.split(`--${boundary}`).slice(1, -1);
94
+ const parts = [];
95
+ for (const chunk of chunks.slice(0, 100)) {
96
+ const normalized = chunk.replace(/^\r\n/, '').replace(/\r\n$/, '');
97
+ const splitAt = normalized.indexOf('\r\n\r\n');
98
+ if (splitAt < 0) return { parts, parsed: false, reason: 'multipart-unparsed' };
99
+ const headerText = normalized.slice(0, splitAt);
100
+ const bodyText = normalized.slice(splitAt + 4);
101
+ const disposition = headerText.match(/content-disposition:\s*form-data;([^\r\n]+)/i);
102
+ const name = disposition && /name="([^"]*)"/i.exec(disposition[1]);
103
+ const filename = disposition && /filename="([^"]*)"/i.exec(disposition[1]);
104
+ const type = /content-type:\s*([^\r\n]+)/i.exec(headerText);
105
+ const bodyBuffer = Buffer.from(bodyText, 'latin1');
106
+ if (filename) {
107
+ parts.push({
108
+ name: name ? name[1] : '',
109
+ filename: filename[1],
110
+ contentType: type ? type[1].trim() : '',
111
+ size: bodyBuffer.length,
112
+ fileBodySkipped: true,
113
+ });
114
+ } else {
115
+ const captured = truncateUtf8(bodyBuffer, maxBytes);
116
+ parts.push({
117
+ name: name ? name[1] : '',
118
+ value: captured.text,
119
+ size: captured.size,
120
+ truncated: captured.truncated,
121
+ });
122
+ }
123
+ }
124
+ return { parts, parsed: chunks.length <= 100, reason: chunks.length > 100 ? 'multipart-part-limit' : undefined };
125
+ }
126
+
127
+ /**
128
+ * 按内容类型和字节上限生成正文捕获字段。
129
+ *
130
+ * @param {Buffer|null|undefined} input 原始正文。
131
+ * @param {string} contentType Content-Type。
132
+ * @param {Object} [options] 捕获配置。
133
+ * @param {number} [options.maxBytes] 最大正文字节数。
134
+ * @param {boolean} [options.allowUnknownText] 是否允许推断无类型请求正文。
135
+ * @return {Object} 正文、大小、截断或跳过信息。
136
+ */
137
+ function captureBody(input, contentType, options = {}) {
138
+ if (input === null || input === undefined) return { size: 0, body: null, truncated: false };
139
+ const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
140
+ const maxBytes = options.maxBytes || MAX_NETWORK_BODY_BYTES;
141
+ const normalizedType = String(contentType || '').toLowerCase();
142
+ if (normalizedType.includes('multipart/form-data')) {
143
+ const metadata = parseMultipartMetadata(buffer, String(contentType || ''), Math.min(maxBytes, 64 * 1024));
144
+ return {
145
+ size: buffer.length,
146
+ body: null,
147
+ truncated: false,
148
+ bodySkipped: metadata.parsed ? 'multipart-metadata-only' : metadata.reason,
149
+ multipart: metadata.parts,
150
+ };
151
+ }
152
+ if (BINARY_CONTENT_TYPE_RE.test(normalizedType)) {
153
+ return { size: buffer.length, body: null, truncated: false, bodySkipped: 'binary-content-type' };
154
+ }
155
+ const explicitText = TEXT_CONTENT_TYPE_RE.test(normalizedType);
156
+ const inferredText = !normalizedType && options.allowUnknownText && isProbablyText(buffer);
157
+ if (!explicitText && !inferredText) {
158
+ return { size: buffer.length, body: null, truncated: false, bodySkipped: normalizedType ? 'unsupported-content-type' : 'missing-content-type' };
159
+ }
160
+ const captured = truncateUtf8(buffer, maxBytes);
161
+ return { size: captured.size, body: captured.text, truncated: captured.truncated };
162
+ }
163
+
164
+ /**
165
+ * 安装 BrowserContext 级 Fetch/XHR 网络采集。
166
+ *
167
+ * @param {import('playwright').BrowserContext} context Playwright BrowserContext。
168
+ * @param {Object} options 采集配置。
169
+ * @param {(events:Array<Object>)=>void|Promise<void>} options.appendEvents JSONL 追加回调。
170
+ * @param {number} [options.port] logger 端口,用于过滤自身请求。
171
+ * @param {number} [options.maxBodyBytes] 单方向正文上限。
172
+ * @return {{dispose:()=>void}} 可移除监听器的句柄。
173
+ */
174
+ function installNetworkCapture(context, options) {
175
+ if (!options || typeof options.appendEvents !== 'function') {
176
+ throw new TypeError('appendEvents callback is required');
177
+ }
178
+ const requestStates = new WeakMap();
179
+ let sequence = 0;
180
+
181
+ function onRequest(request) {
182
+ if (!['fetch', 'xhr'].includes(request.resourceType())) return;
183
+ if (isRecorderRequest(request.url(), { port: options.port })) return;
184
+ const redirectedFrom = request.redirectedFrom();
185
+ const redirectedState = redirectedFrom ? requestStates.get(redirectedFrom) : null;
186
+ requestStates.set(request, {
187
+ requestId: `net-${++sequence}`,
188
+ redirectedFromRequestId: redirectedState ? redirectedState.requestId : null,
189
+ startedAt: Date.now(),
190
+ clientTime: new Date().toISOString(),
191
+ finalized: false,
192
+ });
193
+ }
194
+
195
+ async function finalize(request, terminal) {
196
+ const state = requestStates.get(request);
197
+ if (!state || state.finalized) return;
198
+ state.finalized = true;
199
+ const completedAt = Date.now();
200
+ let frame = null;
201
+ let serviceWorker = null;
202
+ try { frame = request.frame(); } catch (error) { /* Service Worker 请求没有 frame。 */ }
203
+ try { serviceWorker = request.serviceWorker(); } catch (error) { /* 旧 Playwright 不提供该方法。 */ }
204
+ let frameDepth = null;
205
+ let pageUrl = null;
206
+ if (frame) {
207
+ frameDepth = 0;
208
+ for (let parent = frame.parentFrame(); parent; parent = parent.parentFrame()) frameDepth += 1;
209
+ try { pageUrl = frame.page().url(); } catch (error) { pageUrl = null; }
210
+ }
211
+
212
+ let requestHeaders = {};
213
+ let requestHeaderError = null;
214
+ try { requestHeaders = await request.allHeaders(); } catch (error) { requestHeaderError = error.message; }
215
+ const requestContentType = requestHeaders['content-type'] || '';
216
+ let requestBody;
217
+ try {
218
+ requestBody = captureBody(request.postDataBuffer(), requestContentType, {
219
+ maxBytes: options.maxBodyBytes,
220
+ allowUnknownText: true,
221
+ });
222
+ } catch (error) {
223
+ requestBody = { size: 0, body: null, truncated: false, captureError: error.message };
224
+ }
225
+
226
+ let response = terminal.response || null;
227
+ if (!response && terminal.kind === 'finished') {
228
+ try { response = await request.response(); } catch (error) { response = null; }
229
+ }
230
+ let responseHeaders = {};
231
+ let responseHeaderError = null;
232
+ let responseBody = { size: 0, body: null, truncated: false };
233
+ if (response) {
234
+ try { responseHeaders = await response.allHeaders(); } catch (error) { responseHeaderError = error.message; }
235
+ const responseContentType = responseHeaders['content-type'] || '';
236
+ const declaredSize = Number.parseInt(responseHeaders['content-length'], 10);
237
+ const maxBodyBytes = options.maxBodyBytes || MAX_NETWORK_BODY_BYTES;
238
+ if (Number.isFinite(declaredSize) && declaredSize > maxBodyBytes) {
239
+ // Playwright 的 response.body() 会一次性物化完整响应;已知超限时直接跳过,避免先占满内存再截断。
240
+ responseBody = {
241
+ size: declaredSize,
242
+ body: null,
243
+ truncated: true,
244
+ bodySkipped: 'content-length-exceeds-capture-limit',
245
+ };
246
+ } else {
247
+ try {
248
+ responseBody = captureBody(await response.body(), responseContentType, {
249
+ maxBytes: maxBodyBytes,
250
+ allowUnknownText: false,
251
+ });
252
+ } catch (error) {
253
+ responseBody = { size: 0, body: null, truncated: false, captureError: error.message };
254
+ }
255
+ }
256
+ }
257
+
258
+ const failure = terminal.failure || null;
259
+ const event = {
260
+ kind: 'network',
261
+ type: request.resourceType(),
262
+ source: 'playwright',
263
+ requestId: state.requestId,
264
+ redirectedFromRequestId: state.redirectedFromRequestId,
265
+ clientTime: state.clientTime,
266
+ completedTime: new Date(completedAt).toISOString(),
267
+ durationMs: completedAt - state.startedAt,
268
+ method: request.method(),
269
+ requestUrl: request.url(),
270
+ requestHeaders,
271
+ requestHeaderCaptureError: requestHeaderError,
272
+ requestContentType,
273
+ requestBody: requestBody.body,
274
+ requestSize: requestBody.size,
275
+ requestBodyTruncated: requestBody.truncated,
276
+ requestBodySkipped: requestBody.bodySkipped || null,
277
+ requestBodyCaptureError: requestBody.captureError || null,
278
+ requestMultipart: requestBody.multipart || null,
279
+ status: response ? response.status() : null,
280
+ responseHeaders,
281
+ responseHeaderCaptureError: responseHeaderError,
282
+ responseContentType: responseHeaders['content-type'] || '',
283
+ responseBody: responseBody.body,
284
+ responseSize: responseBody.size,
285
+ responseBodyTruncated: responseBody.truncated,
286
+ responseBodySkipped: responseBody.bodySkipped || null,
287
+ responseBodyCaptureError: responseBody.captureError || null,
288
+ failure,
289
+ error: failure ? failure.errorText : null,
290
+ url: pageUrl || request.url(),
291
+ frame: frame ? { url: frame.url(), depth: frameDepth } : null,
292
+ context: {
293
+ pageUrl,
294
+ frameUrl: frame ? frame.url() : null,
295
+ frameDepth,
296
+ serviceWorkerUrl: serviceWorker ? serviceWorker.url() : null,
297
+ },
298
+ };
299
+ await options.appendEvents([event]);
300
+ }
301
+
302
+ function onFinished(request) {
303
+ void finalize(request, { kind: 'finished' }).catch(error => {
304
+ console.error('[network] finalize finished request failed:', error.message);
305
+ });
306
+ }
307
+
308
+ function onFailed(request) {
309
+ void finalize(request, { kind: 'failed', failure: request.failure() || { errorText: 'request failed' } }).catch(error => {
310
+ console.error('[network] finalize failed request failed:', error.message);
311
+ });
312
+ }
313
+
314
+ context.on('request', onRequest);
315
+ context.on('requestfinished', onFinished);
316
+ context.on('requestfailed', onFailed);
317
+
318
+ return {
319
+ /**
320
+ * 移除本次安装的全部 BrowserContext 网络监听器。
321
+ *
322
+ * @return {void}
323
+ */
324
+ dispose() {
325
+ context.off('request', onRequest);
326
+ context.off('requestfinished', onFinished);
327
+ context.off('requestfailed', onFailed);
328
+ },
329
+ };
330
+ }
331
+
332
+ module.exports = {
333
+ captureBody,
334
+ installNetworkCapture,
335
+ isProbablyText,
336
+ isRecorderRequest,
337
+ parseMultipartMetadata,
338
+ truncateUtf8,
339
+ };
@@ -5,7 +5,8 @@
5
5
  "description": "本地浏览器交互记录工具:持久化 profile 的 Chrome + 自动注入监听 + 本地落盘",
6
6
  "scripts": {
7
7
  "logger": "node logger.js",
8
- "launch": "node launch.js"
8
+ "launch": "node launch.js",
9
+ "test": "node --test test/*.test.js"
9
10
  },
10
11
  "dependencies": {
11
12
  "playwright": "^1.48.0"
@@ -0,0 +1,90 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const { BrowserController, ControlError, limitText } = require('../browser-controller');
4
+
5
+ function createLocator(visibleIndexes) {
6
+ return {
7
+ count: async () => 3,
8
+ nth: index => ({
9
+ isVisible: async () => visibleIndexes.includes(index),
10
+ evaluate: async () => ({ tag: 'button', id: `button-${index}`, role: 'button', name: `按钮 ${index}` }),
11
+ }),
12
+ };
13
+ }
14
+
15
+ function createPage(locator) {
16
+ const frame = {
17
+ url: () => 'https://example.test/frame',
18
+ name: () => 'main',
19
+ getByText: () => locator,
20
+ };
21
+ return {
22
+ frames: () => [frame],
23
+ mainFrame: () => frame,
24
+ };
25
+ }
26
+
27
+ test('可见文本按 UTF-8 字节边界截断', () => {
28
+ const result = limitText('你你你你', 10);
29
+ assert.equal(result.value, '你你你');
30
+ assert.equal(result.size, 12);
31
+ assert.equal(result.truncated, true);
32
+ });
33
+
34
+ test('未指定 nth 的多可见匹配返回 AMBIGUOUS_TARGET', async () => {
35
+ const controller = new BrowserController({ getContext: () => null, sessionDir: '/tmp' });
36
+ await assert.rejects(
37
+ controller.resolveTarget(createPage(createLocator([0, 2])), { target: { text: '提交', exact: true } }),
38
+ error => error instanceof ControlError && error.code === 'AMBIGUOUS_TARGET' && error.details.count === 2,
39
+ );
40
+ });
41
+
42
+ test('显式 nth 可以选择调用方接受的可见匹配', async () => {
43
+ const controller = new BrowserController({ getContext: () => null, sessionDir: '/tmp' });
44
+ const result = await controller.resolveTarget(createPage(createLocator([0, 2])), {
45
+ target: { text: '提交', exact: true, nth: 2 },
46
+ });
47
+ assert.equal(result.summary.id, 'button-2');
48
+ assert.equal(result.frame.index, 0);
49
+ });
50
+
51
+ test('target 同时提供两种主定位策略时拒绝执行', async () => {
52
+ const controller = new BrowserController({ getContext: () => null, sessionDir: '/tmp' });
53
+ await assert.rejects(
54
+ controller.resolveTarget(createPage(createLocator([0])), { target: { text: '提交', selector: '#submit' } }),
55
+ error => error instanceof ControlError && error.code === 'INVALID_TARGET',
56
+ );
57
+ });
58
+
59
+ test('禁用元素在动作前返回 TARGET_NOT_ACTIONABLE', async () => {
60
+ const controller = new BrowserController({ getContext: () => null, sessionDir: '/tmp' });
61
+ await assert.rejects(
62
+ controller.ensureTargetActionable({ isEnabled: async () => false }, 'click'),
63
+ error => error instanceof ControlError && error.code === 'TARGET_NOT_ACTIONABLE' && error.statusCode === 409,
64
+ );
65
+ });
66
+
67
+ test('observe 对不响应的 frame 应用整体超时', async () => {
68
+ const frame = {
69
+ parentFrame: () => null,
70
+ url: () => 'https://example.test/',
71
+ name: () => '',
72
+ locator: selector => selector === 'body'
73
+ ? {
74
+ innerText: async () => '',
75
+ ariaSnapshot: async () => '',
76
+ }
77
+ : { evaluateAll: async () => new Promise(() => {}) },
78
+ };
79
+ const page = {
80
+ frames: () => [frame],
81
+ mainFrame: () => frame,
82
+ url: () => 'https://example.test/',
83
+ };
84
+ const context = { pages: () => [page] };
85
+ const controller = new BrowserController({ getContext: () => context, sessionDir: '/tmp' });
86
+ await assert.rejects(
87
+ controller.observe({ timeoutMs: 100, includeText: false, includeAria: false }),
88
+ error => error instanceof ControlError && error.code === 'OBSERVE_TIMEOUT' && error.statusCode === 408,
89
+ );
90
+ });
@@ -0,0 +1,121 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const http = require('node:http');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const test = require('node:test');
7
+ const { createBrowserController } = require('../browser-controller');
8
+ const { installNetworkCapture } = require('../network-capture');
9
+
10
+ let chromium = null;
11
+ try {
12
+ ({ chromium } = require('playwright'));
13
+ } catch (error) {
14
+ // 普通单测不要求预装运行时依赖;显式 smoke 会在依赖可用时执行。
15
+ }
16
+
17
+ function listen(server) {
18
+ return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
19
+ }
20
+
21
+ function close(server) {
22
+ return new Promise(resolve => server.close(resolve));
23
+ }
24
+
25
+ test('真实浏览器可感知页面、操作元素并采集跨 frame Fetch/XHR', {
26
+ skip: process.env.CRAFT_RPA_BROWSER_SMOKE !== '1'
27
+ ? '未启用 CRAFT_RPA_BROWSER_SMOKE'
28
+ : chromium === null ? 'Playwright 运行时依赖不可用' : false,
29
+ timeout: 60000,
30
+ }, async t => {
31
+ const events = [];
32
+ const frameServer = http.createServer((req, res) => {
33
+ if (req.url === '/frame') {
34
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
35
+ res.end(`<!doctype html><button id="frame-button">Frame button</button><script>
36
+ fetch('/frame-api').catch(() => {});
37
+ const xhr = new XMLHttpRequest(); xhr.open('GET', '/frame-xhr'); xhr.send();
38
+ </script>`);
39
+ return;
40
+ }
41
+ res.writeHead(200, { 'Content-Type': 'application/json' });
42
+ res.end(JSON.stringify({ path: req.url, source: 'frame' }));
43
+ });
44
+ const framePort = await listen(frameServer);
45
+ t.after(() => close(frameServer));
46
+
47
+ const mainServer = http.createServer((req, res) => {
48
+ if (req.url === '/') {
49
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
50
+ res.end(`<!doctype html>
51
+ <label for="name">姓名</label><input id="name">
52
+ <label for="agree">同意</label><input id="agree" type="checkbox">
53
+ <label for="region">地区</label><select id="region"><option value="cn">中国</option><option value="sg">新加坡</option></select>
54
+ <button data-testid="submit">提交</button>
55
+ <input type="submit" value="原生提交">
56
+ <output id="result"></output>
57
+ <iframe src="http://127.0.0.1:${framePort}/frame"></iframe>
58
+ <script>
59
+ document.querySelector('[data-testid="submit"]').addEventListener('click', async () => {
60
+ const response = await fetch('/submit', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ name: document.querySelector('#name').value }),
64
+ });
65
+ document.querySelector('#result').textContent = await response.text();
66
+ });
67
+ document.querySelector('input[type="submit"]').addEventListener('click', event => {
68
+ event.preventDefault();
69
+ document.querySelector('#result').dataset.nativeClicked = 'yes';
70
+ });
71
+ </script>`);
72
+ return;
73
+ }
74
+ const chunks = [];
75
+ req.on('data', chunk => chunks.push(chunk));
76
+ req.on('end', () => {
77
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'X-Smoke': 'yes' });
78
+ res.end(JSON.stringify({ ok: true, requestBody: Buffer.concat(chunks).toString('utf8') }));
79
+ });
80
+ });
81
+ const mainPort = await listen(mainServer);
82
+ t.after(() => close(mainServer));
83
+
84
+ const browser = await chromium.launch({ channel: 'chrome', headless: true });
85
+ t.after(() => browser.close());
86
+ const context = await browser.newContext();
87
+ const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-smoke-'));
88
+ const controller = createBrowserController({ getContext: () => context, sessionDir });
89
+ await context.exposeBinding('__craftRpaAppendEvents', (source, payload) => {
90
+ events.push(...(Array.isArray(payload) ? payload : [payload]));
91
+ return { ok: true };
92
+ });
93
+ const injectContent = fs.readFileSync(path.join(__dirname, '..', 'inject.js'), 'utf8');
94
+ await context.addInitScript({ content: `globalThis.__CRAFT_RPA_CONFIG__ = {};\n${injectContent}` });
95
+ const capture = installNetworkCapture(context, { appendEvents: batch => events.push(...batch), port: 7777, maxBodyBytes: 20 * 1024 * 1024 });
96
+ t.after(() => capture.dispose());
97
+
98
+ const page = await context.newPage();
99
+ await page.goto(`http://127.0.0.1:${mainPort}/`, { waitUntil: 'networkidle' });
100
+ const observed = await controller.observe({ index: 0, screenshot: true, dom: true });
101
+ assert.equal(observed.frames.length, 2);
102
+ assert.equal(fs.existsSync(observed.artifacts.screenshot.path), true);
103
+ assert.equal(observed.artifacts.dom.length, 2);
104
+ const nativeSubmit = observed.frames[0].elements.find(element => element.name === '原生提交');
105
+ assert.deepEqual(nativeSubmit.target, { role: 'button', name: '原生提交', exact: true });
106
+ await controller.dispatch('click', { index: 0, target: nativeSubmit.target });
107
+ assert.equal(await page.locator('#result').getAttribute('data-native-clicked'), 'yes');
108
+
109
+ await controller.dispatch('fill', { index: 0, target: { label: '姓名', exact: true }, value: '静默花' });
110
+ await controller.dispatch('select', { index: 0, target: { label: '地区', exact: true }, value: 'sg' });
111
+ await controller.dispatch('check', { index: 0, target: { label: '同意', exact: true } });
112
+ await controller.dispatch('click', { index: 0, target: { testId: 'submit' } });
113
+ await page.waitForFunction(() => document.querySelector('#result').textContent.includes('requestBody'));
114
+ await page.waitForTimeout(700);
115
+
116
+ const networkEvents = events.filter(event => event.kind === 'network');
117
+ assert.equal(networkEvents.some(event => event.requestUrl.includes('/submit') && event.requestBody.includes('静默花')), true);
118
+ assert.equal(networkEvents.some(event => event.requestUrl.includes('/frame-api')), true);
119
+ assert.equal(networkEvents.some(event => event.requestUrl.includes('/frame-xhr')), true);
120
+ assert.equal(events.some(event => event.kind === 'interaction' && event.type === 'click'), true);
121
+ });
@@ -0,0 +1,100 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const http = require('node:http');
4
+ const net = require('node:net');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const test = require('node:test');
8
+ const { callControl } = require('../../scripts/control-client');
9
+ const { startLogger } = require('../logger');
10
+
11
+ function findFreePort() {
12
+ return new Promise((resolve, reject) => {
13
+ const server = net.createServer();
14
+ server.once('error', reject);
15
+ server.listen(0, '127.0.0.1', () => {
16
+ const port = server.address().port;
17
+ server.close(error => error ? reject(error) : resolve(port));
18
+ });
19
+ });
20
+ }
21
+
22
+ function request(url, options = {}, payload = null) {
23
+ return new Promise((resolve, reject) => {
24
+ const body = payload === null ? null : (Buffer.isBuffer(payload) ? payload : Buffer.from(JSON.stringify(payload)));
25
+ const req = http.request(url, {
26
+ method: options.method || 'GET',
27
+ headers: {
28
+ ...(options.headers || {}),
29
+ ...(body ? { 'Content-Type': 'application/json', 'Content-Length': body.length } : {}),
30
+ },
31
+ }, res => {
32
+ const chunks = [];
33
+ res.on('data', chunk => chunks.push(chunk));
34
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
35
+ });
36
+ req.on('error', reject);
37
+ req.end(body || undefined);
38
+ });
39
+ }
40
+
41
+ test('无 token 的本机客户端可控制,第三方 Origin 被拒绝,跨域 /log 保持可用', async t => {
42
+ const port = await findFreePort();
43
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-logger-'));
44
+ const logFile = path.join(dir, 'session.jsonl');
45
+ const controller = {
46
+ dispatch: async (action, body) => ({ ok: true, action, body }),
47
+ };
48
+ const server = startLogger({ port, host: '127.0.0.1', logFile, browserController: controller });
49
+ await new Promise(resolve => server.once('listening', resolve));
50
+ t.after(() => new Promise(resolve => server.close(resolve)));
51
+ const origin = `http://127.0.0.1:${port}`;
52
+
53
+ const cliResult = await callControl('click', { target: { testId: 'submit' } }, { origin });
54
+ assert.equal(cliResult.ok, true);
55
+ assert.equal(cliResult.action, 'click');
56
+
57
+ const forbidden = await request(`${origin}/control/click`, {
58
+ method: 'POST',
59
+ headers: { Origin: 'https://third-party.example' },
60
+ }, { target: { testId: 'submit' } });
61
+ assert.equal(forbidden.status, 403);
62
+ assert.equal(JSON.parse(forbidden.body).error.code, 'ORIGIN_FORBIDDEN');
63
+
64
+ const logged = await request(`${origin}/log`, {
65
+ method: 'POST',
66
+ headers: { Origin: 'https://third-party.example' },
67
+ }, { kind: 'interaction', type: 'click' });
68
+ assert.equal(logged.status, 200);
69
+ const event = JSON.parse(fs.readFileSync(logFile, 'utf8').trim());
70
+ assert.equal(event.kind, 'interaction');
71
+ assert.ok(event.serverTime);
72
+
73
+ server.appendEvents({
74
+ kind: 'network',
75
+ requestUrl: 'https://example.test/private',
76
+ requestHeaders: { authorization: 'Bearer local' },
77
+ requestBody: 'raw-request-body',
78
+ requestSize: 16,
79
+ responseHeaders: { 'set-cookie': 'session=raw-secret' },
80
+ responseBody: 'raw-response-body',
81
+ responseSize: 17,
82
+ });
83
+ const summaries = JSON.parse((await request(`${origin}/events`)).body);
84
+ const networkSummary = summaries.find(item => item.kind === 'network');
85
+ assert.equal(networkSummary.requestBody, undefined);
86
+ assert.equal(networkSummary.responseBody, undefined);
87
+ assert.equal(networkSummary.requestHeaders, undefined);
88
+ assert.equal(networkSummary.responseHeaders, undefined);
89
+ assert.equal(networkSummary.requestBodyCaptured, true);
90
+ assert.equal(networkSummary.responseBodyCaptured, true);
91
+
92
+ const session = await request(`${origin}/session`);
93
+ assert.equal(session.status, 200);
94
+ assert.match(session.body, /raw-request-body/);
95
+ assert.match(session.body, /Bearer local/);
96
+
97
+ const tooLarge = await request(`${origin}/control/click`, { method: 'POST' }, Buffer.alloc(2 * 1024 * 1024 + 1, 0x61));
98
+ assert.equal(tooLarge.status, 413);
99
+ assert.equal(JSON.parse(tooLarge.body).error.code, 'BODY_TOO_LARGE');
100
+ });
@@ -0,0 +1,14 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const { isProtectedOriginAllowed } = require('../logger');
4
+
5
+ test('无 Origin 的本机客户端和同源 Dashboard 可访问控制接口', () => {
6
+ assert.equal(isProtectedOriginAllowed(undefined, 'localhost:7777'), true);
7
+ assert.equal(isProtectedOriginAllowed('http://localhost:7777', 'localhost:7777'), true);
8
+ assert.equal(isProtectedOriginAllowed('http://127.0.0.1:3000', 'localhost:7777'), true);
9
+ });
10
+
11
+ test('第三方页面 Origin 不能调用控制接口', () => {
12
+ assert.equal(isProtectedOriginAllowed('https://example.test', 'localhost:7777'), false);
13
+ assert.equal(isProtectedOriginAllowed('invalid-origin', 'localhost:7777'), false);
14
+ });