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.
- package/enhancements/0.6/.agents/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/0.6/.claude/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/MANIFEST.json +2 -2
- package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +82 -32
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +103 -212
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -179
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +107 -40
- package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -32
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +103 -212
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -179
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +107 -40
- package/package.json +3 -3
- package/src/builtin-plugins/skill-garden/content-adapter.js +97 -4
- package/src/commands/init.js +13 -2
- package/src/commands/plugin.js +8 -4
- package/src/commands/update.js +7 -1
- package/src/lib/banner.js +10 -1
- package/src/lib/command-completion.js +43 -0
package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { EventEmitter } = require('node:events');
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const { MAX_NETWORK_BODY_BYTES } = require('../config');
|
|
5
|
+
const {
|
|
6
|
+
captureBody,
|
|
7
|
+
installNetworkCapture,
|
|
8
|
+
isRecorderRequest,
|
|
9
|
+
parseMultipartMetadata,
|
|
10
|
+
truncateUtf8,
|
|
11
|
+
} = require('../network-capture');
|
|
12
|
+
|
|
13
|
+
class FakeContext extends EventEmitter {}
|
|
14
|
+
|
|
15
|
+
function createFrame(url = 'https://example.test/page') {
|
|
16
|
+
return {
|
|
17
|
+
url: () => url,
|
|
18
|
+
name: () => '',
|
|
19
|
+
parentFrame: () => null,
|
|
20
|
+
page: () => ({ url: () => url }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createResponse(options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
status: () => options.status ?? 200,
|
|
27
|
+
allHeaders: async () => options.headers || { 'content-type': 'application/json' },
|
|
28
|
+
body: async () => {
|
|
29
|
+
if (options.onBody) options.onBody();
|
|
30
|
+
return Buffer.from(options.body ?? '{"ok":true}');
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createRequest(options = {}) {
|
|
36
|
+
const response = options.response === undefined ? createResponse() : options.response;
|
|
37
|
+
return {
|
|
38
|
+
resourceType: () => options.resourceType || 'fetch',
|
|
39
|
+
url: () => options.url || 'https://api.example.test/items',
|
|
40
|
+
method: () => options.method || 'POST',
|
|
41
|
+
redirectedFrom: () => options.redirectedFrom || null,
|
|
42
|
+
allHeaders: async () => options.headers || { 'content-type': 'application/json', authorization: 'Bearer raw-token' },
|
|
43
|
+
postDataBuffer: () => Buffer.from(options.body ?? '{"name":"测试"}'),
|
|
44
|
+
response: async () => response,
|
|
45
|
+
frame: () => options.frame || createFrame(),
|
|
46
|
+
serviceWorker: () => options.serviceWorker || null,
|
|
47
|
+
failure: () => options.failure || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nextTurn() {
|
|
52
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('默认网络正文捕获上限为单方向 20 MiB', () => {
|
|
56
|
+
assert.equal(MAX_NETWORK_BODY_BYTES, 20 * 1024 * 1024);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('文本正文可完整保存到 20 MiB 并在下一字节截断', () => {
|
|
60
|
+
const input = Buffer.alloc(MAX_NETWORK_BODY_BYTES + 1, 0x61);
|
|
61
|
+
const result = captureBody(input, 'text/plain');
|
|
62
|
+
assert.equal(Buffer.byteLength(result.body), MAX_NETWORK_BODY_BYTES);
|
|
63
|
+
assert.equal(result.size, MAX_NETWORK_BODY_BYTES + 1);
|
|
64
|
+
assert.equal(result.truncated, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('UTF-8 截断不切断多字节字符且不超过字节上限', () => {
|
|
68
|
+
const result = truncateUtf8(Buffer.from('你你你你'), 10);
|
|
69
|
+
assert.equal(result.text, '你你你');
|
|
70
|
+
assert.equal(Buffer.byteLength(result.text), 9);
|
|
71
|
+
assert.equal(result.size, 12);
|
|
72
|
+
assert.equal(result.truncated, true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('异常 UTF-8 字节也不会让替换字符突破上限', () => {
|
|
76
|
+
const result = truncateUtf8(Buffer.from([0xff, 0xff, 0x61]), 3);
|
|
77
|
+
assert.ok(Buffer.byteLength(result.text) <= 3);
|
|
78
|
+
assert.equal(result.text.includes('\ufffd'), true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('文本正文、二进制正文和无类型响应按契约分类', () => {
|
|
82
|
+
const text = captureBody(Buffer.from('{"ok":true}'), 'application/json', { maxBytes: 20 });
|
|
83
|
+
assert.equal(text.body, '{"ok":true}');
|
|
84
|
+
assert.equal(text.bodySkipped, undefined);
|
|
85
|
+
|
|
86
|
+
const binary = captureBody(Buffer.from([0, 1, 2]), 'image/png');
|
|
87
|
+
assert.equal(binary.body, null);
|
|
88
|
+
assert.equal(binary.bodySkipped, 'binary-content-type');
|
|
89
|
+
|
|
90
|
+
const unknownResponse = captureBody(Buffer.from('plain'), '', { allowUnknownText: false });
|
|
91
|
+
assert.equal(unknownResponse.body, null);
|
|
92
|
+
assert.equal(unknownResponse.bodySkipped, 'missing-content-type');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('multipart 仅保留普通字段和文件元数据', () => {
|
|
96
|
+
const raw = Buffer.from([
|
|
97
|
+
'--abc',
|
|
98
|
+
'Content-Disposition: form-data; name="title"',
|
|
99
|
+
'',
|
|
100
|
+
'hello',
|
|
101
|
+
'--abc',
|
|
102
|
+
'Content-Disposition: form-data; name="file"; filename="a.txt"',
|
|
103
|
+
'Content-Type: text/plain',
|
|
104
|
+
'',
|
|
105
|
+
'secret-file-body',
|
|
106
|
+
'--abc--',
|
|
107
|
+
'',
|
|
108
|
+
].join('\r\n'));
|
|
109
|
+
const result = parseMultipartMetadata(raw, 'multipart/form-data; boundary=abc', 1024);
|
|
110
|
+
assert.equal(result.parsed, true);
|
|
111
|
+
assert.deepEqual(result.parts[0], { name: 'title', value: 'hello', size: 5, truncated: false });
|
|
112
|
+
assert.equal(result.parts[1].filename, 'a.txt');
|
|
113
|
+
assert.equal(result.parts[1].fileBodySkipped, true);
|
|
114
|
+
assert.equal(result.parts[1].size, Buffer.byteLength('secret-file-body'));
|
|
115
|
+
assert.equal(result.parts[1].value, undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('multipart boundary 保持大小写', () => {
|
|
119
|
+
const raw = Buffer.from('--AbC\r\nContent-Disposition: form-data; name="v"\r\n\r\nx\r\n--AbC--\r\n');
|
|
120
|
+
const result = captureBody(raw, 'multipart/form-data; boundary=AbC');
|
|
121
|
+
assert.equal(result.bodySkipped, 'multipart-metadata-only');
|
|
122
|
+
assert.equal(result.multipart[0].value, 'x');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('recorder 自身路径只在匹配本机 host 与端口时过滤', () => {
|
|
126
|
+
assert.equal(isRecorderRequest('http://localhost:7777/log', { port: 7777 }), true);
|
|
127
|
+
assert.equal(isRecorderRequest('http://localhost:7777/session', { port: 7777 }), true);
|
|
128
|
+
assert.equal(isRecorderRequest('http://127.0.0.1:7777/control/click', { port: 7777 }), true);
|
|
129
|
+
assert.equal(isRecorderRequest('http://localhost:8888/log', { port: 7777 }), false);
|
|
130
|
+
assert.equal(isRecorderRequest('https://example.test/log', { port: 7777 }), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('已知超限响应不调用会整块物化正文的 response.body', async () => {
|
|
134
|
+
const context = new FakeContext();
|
|
135
|
+
const events = [];
|
|
136
|
+
let bodyCalled = false;
|
|
137
|
+
const response = createResponse({
|
|
138
|
+
headers: {
|
|
139
|
+
'content-type': 'text/plain',
|
|
140
|
+
'content-length': String(1025),
|
|
141
|
+
},
|
|
142
|
+
onBody: () => { bodyCalled = true; },
|
|
143
|
+
});
|
|
144
|
+
const request = createRequest({ response });
|
|
145
|
+
installNetworkCapture(context, { appendEvents: batch => events.push(...batch), port: 7777, maxBodyBytes: 1024 });
|
|
146
|
+
context.emit('request', request);
|
|
147
|
+
context.emit('requestfinished', request);
|
|
148
|
+
await nextTurn();
|
|
149
|
+
|
|
150
|
+
assert.equal(bodyCalled, false);
|
|
151
|
+
assert.equal(events[0].responseSize, 1025);
|
|
152
|
+
assert.equal(events[0].responseBody, null);
|
|
153
|
+
assert.equal(events[0].responseBodyTruncated, true);
|
|
154
|
+
assert.equal(events[0].responseBodySkipped, 'content-length-exceeds-capture-limit');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('成功请求只 finalize 一次并保存完整请求响应上下文', async () => {
|
|
158
|
+
const context = new FakeContext();
|
|
159
|
+
const events = [];
|
|
160
|
+
installNetworkCapture(context, { appendEvents: batch => events.push(...batch), port: 7777, maxBodyBytes: 1024 });
|
|
161
|
+
const request = createRequest();
|
|
162
|
+
context.emit('request', request);
|
|
163
|
+
context.emit('requestfinished', request);
|
|
164
|
+
context.emit('requestfinished', request);
|
|
165
|
+
await nextTurn();
|
|
166
|
+
|
|
167
|
+
assert.equal(events.length, 1);
|
|
168
|
+
assert.equal(events[0].status, 200);
|
|
169
|
+
assert.equal(events[0].requestHeaders.authorization, 'Bearer raw-token');
|
|
170
|
+
assert.equal(events[0].requestBody, '{"name":"测试"}');
|
|
171
|
+
assert.equal(events[0].responseBody, '{"ok":true}');
|
|
172
|
+
assert.equal(events[0].context.frameDepth, 0);
|
|
173
|
+
assert.equal(events[0].failure, null);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('失败请求与 redirect 链保留结构化失败和来源 requestId', async () => {
|
|
177
|
+
const context = new FakeContext();
|
|
178
|
+
const events = [];
|
|
179
|
+
installNetworkCapture(context, { appendEvents: batch => events.push(...batch), port: 7777, maxBodyBytes: 1024 });
|
|
180
|
+
|
|
181
|
+
const first = createRequest({ url: 'https://api.example.test/start', response: createResponse({ status: 302, headers: { location: '/next' }, body: '' }) });
|
|
182
|
+
const second = createRequest({ url: 'https://api.example.test/next', redirectedFrom: first });
|
|
183
|
+
const failed = createRequest({ url: 'https://127.0.0.1:1/fail', response: null, failure: { errorText: 'net::ERR_CONNECTION_REFUSED' } });
|
|
184
|
+
context.emit('request', first);
|
|
185
|
+
context.emit('request', second);
|
|
186
|
+
context.emit('request', failed);
|
|
187
|
+
context.emit('requestfinished', first);
|
|
188
|
+
context.emit('requestfinished', second);
|
|
189
|
+
context.emit('requestfailed', failed);
|
|
190
|
+
await nextTurn();
|
|
191
|
+
|
|
192
|
+
const firstEvent = events.find(event => event.requestUrl.endsWith('/start'));
|
|
193
|
+
const secondEvent = events.find(event => event.requestUrl.endsWith('/next'));
|
|
194
|
+
const failedEvent = events.find(event => event.requestUrl.includes('/fail'));
|
|
195
|
+
assert.equal(secondEvent.redirectedFromRequestId, firstEvent.requestId);
|
|
196
|
+
assert.equal(failedEvent.status, null);
|
|
197
|
+
assert.equal(failedEvent.failure.errorText, 'net::ERR_CONNECTION_REFUSED');
|
|
198
|
+
assert.equal(failedEvent.error, 'net::ERR_CONNECTION_REFUSED');
|
|
199
|
+
});
|
package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const test = require('node:test');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 写入可执行的测试替身。
|
|
10
|
+
*
|
|
11
|
+
* @param {string} target 文件路径。
|
|
12
|
+
* @param {string} content 脚本内容。
|
|
13
|
+
* @return {void}
|
|
14
|
+
*/
|
|
15
|
+
function writeExecutable(target, content) {
|
|
16
|
+
fs.writeFileSync(target, content, { mode: 0o755 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('run.sh 将旧运行时产物迁出 Plugin 管理树', { skip: process.platform === 'win32' }, t => {
|
|
20
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-runtime-layout-'));
|
|
21
|
+
const sourceSkill = path.resolve(__dirname, '..', '..');
|
|
22
|
+
const skillRoot = path.join(tempRoot, 'skill');
|
|
23
|
+
const recorderRoot = path.join(skillRoot, 'recorder');
|
|
24
|
+
const projectRoot = path.join(tempRoot, 'project');
|
|
25
|
+
const dataRoot = path.join(projectRoot, '.craft-rpa');
|
|
26
|
+
const fakeBin = path.join(tempRoot, 'bin');
|
|
27
|
+
let launchPid = null;
|
|
28
|
+
|
|
29
|
+
t.after(() => {
|
|
30
|
+
if (launchPid !== null) {
|
|
31
|
+
try { process.kill(launchPid, 'SIGTERM'); } catch (error) { /* 测试进程可能已结束。 */ }
|
|
32
|
+
}
|
|
33
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
fs.cpSync(sourceSkill, skillRoot, { recursive: true });
|
|
37
|
+
fs.mkdirSync(projectRoot, { recursive: true });
|
|
38
|
+
fs.mkdirSync(fakeBin, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const profileTarget = path.join(dataRoot, 'profile');
|
|
41
|
+
const sessionTarget = path.join(dataRoot, 'sessions', 'old', 'session.jsonl');
|
|
42
|
+
fs.mkdirSync(profileTarget, { recursive: true });
|
|
43
|
+
fs.mkdirSync(path.dirname(sessionTarget), { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(profileTarget, 'preserved.txt'), 'profile\n');
|
|
45
|
+
fs.writeFileSync(sessionTarget, '{"kind":"legacy"}\n');
|
|
46
|
+
fs.symlinkSync(profileTarget, path.join(recorderRoot, 'profile'), 'dir');
|
|
47
|
+
fs.symlinkSync(sessionTarget, path.join(recorderRoot, 'session.jsonl'));
|
|
48
|
+
fs.mkdirSync(path.join(recorderRoot, 'node_modules', 'playwright'), { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(recorderRoot, 'node_modules', 'playwright', 'package.json'), '{}\n');
|
|
50
|
+
|
|
51
|
+
writeExecutable(path.join(fakeBin, 'npm'), `#!/usr/bin/env bash
|
|
52
|
+
mkdir -p "$PWD/node_modules/playwright"
|
|
53
|
+
printf '{}\\n' > "$PWD/node_modules/playwright/package.json"
|
|
54
|
+
`);
|
|
55
|
+
writeExecutable(path.join(fakeBin, 'node'), `#!/usr/bin/env bash
|
|
56
|
+
printf '%s\\n' "$CRAFT_RPA_SESSION_FILE" > "$CRAFT_RPA_HOME/fake-session-file"
|
|
57
|
+
printf '%s\\n' "$CRAFT_RPA_PROFILE_DIR" > "$CRAFT_RPA_HOME/fake-profile-dir"
|
|
58
|
+
printf '%s\\n' "$CRAFT_RPA_PLAYWRIGHT_MODULE" > "$CRAFT_RPA_HOME/fake-playwright-module"
|
|
59
|
+
while true; do /bin/sleep 1; done
|
|
60
|
+
`);
|
|
61
|
+
|
|
62
|
+
const result = spawnSync('bash', [path.join(skillRoot, 'scripts', 'run.sh'), 'start', 'about:blank'], {
|
|
63
|
+
cwd: projectRoot,
|
|
64
|
+
env: {
|
|
65
|
+
...process.env,
|
|
66
|
+
CRAFT_RPA_HOME: dataRoot,
|
|
67
|
+
PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
|
|
68
|
+
},
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
timeout: 10000,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
74
|
+
launchPid = Number.parseInt(fs.readFileSync(path.join(dataRoot, '.launch.pid'), 'utf8'), 10);
|
|
75
|
+
assert.equal(Number.isInteger(launchPid), true);
|
|
76
|
+
assert.equal(fs.existsSync(path.join(recorderRoot, 'profile')), false);
|
|
77
|
+
assert.equal(fs.existsSync(path.join(recorderRoot, 'session.jsonl')), false);
|
|
78
|
+
assert.equal(fs.existsSync(path.join(recorderRoot, 'node_modules')), false);
|
|
79
|
+
assert.equal(fs.readFileSync(path.join(profileTarget, 'preserved.txt'), 'utf8'), 'profile\n');
|
|
80
|
+
assert.equal(fs.readFileSync(sessionTarget, 'utf8'), '{"kind":"legacy"}\n');
|
|
81
|
+
|
|
82
|
+
const runtimeRoot = path.join(dataRoot, 'runtime', 'recorder');
|
|
83
|
+
assert.equal(fs.existsSync(path.join(runtimeRoot, 'node_modules', 'playwright', 'package.json')), true);
|
|
84
|
+
assert.equal(
|
|
85
|
+
fs.readFileSync(path.join(dataRoot, 'fake-profile-dir'), 'utf8').trim(),
|
|
86
|
+
profileTarget,
|
|
87
|
+
);
|
|
88
|
+
assert.equal(
|
|
89
|
+
fs.readFileSync(path.join(dataRoot, 'fake-playwright-module'), 'utf8').trim(),
|
|
90
|
+
path.join(runtimeRoot, 'node_modules', 'playwright'),
|
|
91
|
+
);
|
|
92
|
+
const sessionFile = fs.readFileSync(path.join(dataRoot, 'fake-session-file'), 'utf8').trim();
|
|
93
|
+
assert.equal(sessionFile.startsWith(path.join(dataRoot, 'sessions')), true);
|
|
94
|
+
assert.equal(sessionFile.endsWith(path.join('', 'session.jsonl')), true);
|
|
95
|
+
assert.equal(fs.existsSync(sessionFile), true);
|
|
96
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { execFileSync } = require('node:child_process');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const test = require('node:test');
|
|
7
|
+
|
|
8
|
+
test('trace 同时兼容旧网络事件并展示新字段与 body 反查', () => {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-trace-'));
|
|
10
|
+
const jsonl = path.join(dir, 'session.jsonl');
|
|
11
|
+
const events = [
|
|
12
|
+
{
|
|
13
|
+
kind: 'network', type: 'fetch', method: 'GET', requestUrl: 'https://example.test/old', status: 200,
|
|
14
|
+
clientTime: '2026-01-01T00:00:00.000Z', serverTime: '2026-01-01T00:00:00.100Z',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
kind: 'network', type: 'xhr', source: 'playwright', requestId: 'net-2', method: 'POST',
|
|
18
|
+
requestUrl: 'https://example.test/new', status: 500, requestHeaders: { authorization: 'Bearer raw' },
|
|
19
|
+
requestContentType: 'application/json', requestBody: JSON.stringify({ value: '你'.repeat(1000) }),
|
|
20
|
+
requestSize: 3012, requestBodyTruncated: false, responseHeaders: { 'content-type': 'text/plain' },
|
|
21
|
+
responseContentType: 'text/plain', responseBody: 'failed\n```\nkept', responseSize: 15,
|
|
22
|
+
failure: null, context: { pageUrl: 'https://example.test/form', frameDepth: 0 },
|
|
23
|
+
clientTime: '2026-01-01T00:00:01.000Z', serverTime: '2026-01-01T00:00:01.100Z',
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
fs.writeFileSync(jsonl, `${events.map(event => JSON.stringify(event)).join('\n')}\n`);
|
|
27
|
+
const script = path.join(__dirname, '..', '..', 'scripts', 'jsonl-to-trace.js');
|
|
28
|
+
const output = execFileSync(process.execPath, [script, '--max-body-bytes', '1024', jsonl], { encoding: 'utf8' });
|
|
29
|
+
assert.match(output, /GET .*old/);
|
|
30
|
+
assert.match(output, /requestHeaders/);
|
|
31
|
+
assert.match(output, /Bearer raw/);
|
|
32
|
+
assert.match(output, /trace 展示截断/);
|
|
33
|
+
assert.match(output, /jsonl#2 字段 `\.requestBody`/);
|
|
34
|
+
assert.match(output, /frameDepth/);
|
|
35
|
+
assert.match(output, /````text\n failed\n ```\n kept\n ````/);
|
|
36
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getLoggerOrigin } = require(path.join(__dirname, '..', 'recorder', 'config'));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 调用本地 browser control API。
|
|
9
|
+
*
|
|
10
|
+
* @param {string} action 控制动作。
|
|
11
|
+
* @param {Object} [payload] 动作参数。
|
|
12
|
+
* @param {Object} [options] 客户端配置。
|
|
13
|
+
* @param {string} [options.origin] logger origin。
|
|
14
|
+
* @param {number} [options.timeoutMs] 请求超时。
|
|
15
|
+
* @return {Promise<Object|Array<Object>>} 结构化响应。
|
|
16
|
+
*/
|
|
17
|
+
function callControl(action, payload = {}, options = {}) {
|
|
18
|
+
const endpoint = new URL(`/control/${encodeURIComponent(action)}`, options.origin || getLoggerOrigin());
|
|
19
|
+
const body = JSON.stringify(payload);
|
|
20
|
+
const transport = endpoint.protocol === 'https:' ? https : http;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const request = transport.request(endpoint, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'Content-Length': Buffer.byteLength(body),
|
|
27
|
+
},
|
|
28
|
+
timeout: options.timeoutMs || 125000,
|
|
29
|
+
}, response => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
response.on('data', chunk => chunks.push(chunk));
|
|
32
|
+
response.on('end', () => {
|
|
33
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
37
|
+
} catch (error) {
|
|
38
|
+
reject(new Error(`control returned invalid JSON: ${raw.slice(0, 500)}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (response.statusCode >= 400) {
|
|
42
|
+
const detail = parsed.error || {};
|
|
43
|
+
const failure = new Error(detail.message || `control failed with HTTP ${response.statusCode}`);
|
|
44
|
+
failure.code = detail.code || 'CONTROL_FAILED';
|
|
45
|
+
failure.details = detail.details || null;
|
|
46
|
+
failure.statusCode = response.statusCode;
|
|
47
|
+
failure.response = parsed;
|
|
48
|
+
reject(failure);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
resolve(parsed);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
request.on('timeout', () => request.destroy(new Error('control request timed out')));
|
|
55
|
+
request.on('error', reject);
|
|
56
|
+
request.end(body);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解析 CLI 参数并输出 JSON 结果。
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} argv 命令行参数。
|
|
64
|
+
* @return {Promise<void>} 执行完成。
|
|
65
|
+
*/
|
|
66
|
+
async function main(argv) {
|
|
67
|
+
const action = argv[0];
|
|
68
|
+
if (!action) throw new Error('usage: control-client.js <action> [JSON]');
|
|
69
|
+
let payload = {};
|
|
70
|
+
if (argv[1]) {
|
|
71
|
+
try {
|
|
72
|
+
payload = JSON.parse(argv[1]);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new Error(`invalid JSON payload: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const result = await callControl(action, payload);
|
|
78
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (require.main === module) {
|
|
82
|
+
main(process.argv.slice(2)).catch(error => {
|
|
83
|
+
const payload = {
|
|
84
|
+
error: {
|
|
85
|
+
code: error.code || 'CLIENT_FAILED',
|
|
86
|
+
message: error.message,
|
|
87
|
+
details: error.details || null,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { callControl, main };
|
|
@@ -21,14 +21,17 @@
|
|
|
21
21
|
* sed -n '<N>p' <session.jsonl> | jq .url
|
|
22
22
|
*
|
|
23
23
|
* 用法:
|
|
24
|
-
* node jsonl-to-trace.js [--max-url-len <N>] <input.jsonl> [output.md]
|
|
25
|
-
* cat session.jsonl | node jsonl-to-trace.js [--max-url-len <N>]
|
|
24
|
+
* node jsonl-to-trace.js [--max-url-len <N>] [--max-body-bytes <N>] <input.jsonl> [output.md]
|
|
25
|
+
* cat session.jsonl | node jsonl-to-trace.js [--max-url-len <N>] [--max-body-bytes <N>]
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
const fs = require('fs');
|
|
29
29
|
const path = require('path');
|
|
30
|
+
const { truncateUtf8 } = require('../recorder/network-capture');
|
|
30
31
|
|
|
31
32
|
const DEFAULT_MAX_URL_LEN = 800;
|
|
33
|
+
const DEFAULT_MAX_BODY_DISPLAY_BYTES = 16 * 1024;
|
|
34
|
+
const DEFAULT_MAX_HEADERS_DISPLAY_BYTES = 64 * 1024;
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* 默认 noise hosts pattern(匹配 network 事件的 URL host 部分)。
|
|
@@ -89,6 +92,7 @@ const BIZ_NOISE_MIN_NEIGHBORS = 2;
|
|
|
89
92
|
let inputPath = null;
|
|
90
93
|
let outputPath = null;
|
|
91
94
|
let maxUrlLen = DEFAULT_MAX_URL_LEN;
|
|
95
|
+
let maxBodyBytes = DEFAULT_MAX_BODY_DISPLAY_BYTES;
|
|
92
96
|
const extraNoiseHosts = []; // 用户通过 --noise-host 追加的 pattern
|
|
93
97
|
let withContextHtml = false; // --with-context-html: 全 interaction 渲染 contextHTML
|
|
94
98
|
|
|
@@ -101,6 +105,13 @@ const BIZ_NOISE_MIN_NEIGHBORS = 2;
|
|
|
101
105
|
process.exit(2);
|
|
102
106
|
}
|
|
103
107
|
maxUrlLen = v;
|
|
108
|
+
} else if (a === '--max-body-bytes') {
|
|
109
|
+
const v = parseInt(argv[++i], 10);
|
|
110
|
+
if (!Number.isFinite(v) || v < 1024) {
|
|
111
|
+
process.stderr.write('[err] --max-body-bytes 必须是 ≥ 1024 的整数\n');
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
maxBodyBytes = v;
|
|
104
115
|
} else if (a === '--noise-host') {
|
|
105
116
|
const p = argv[++i];
|
|
106
117
|
if (!p) {
|
|
@@ -141,11 +152,11 @@ const BIZ_NOISE_MIN_NEIGHBORS = 2;
|
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
const events = parseJsonl(raw);
|
|
144
|
-
const md = generateTrace(events, { inputPath, maxUrlLen, noiseHosts, withContextHtml });
|
|
155
|
+
const md = generateTrace(events, { inputPath, maxUrlLen, maxBodyBytes, noiseHosts, withContextHtml });
|
|
145
156
|
|
|
146
157
|
if (outputPath) {
|
|
147
158
|
fs.writeFileSync(outputPath, md);
|
|
148
|
-
process.stderr.write(`[ok] wrote ${outputPath} (${events.length} events, maxUrlLen=${maxUrlLen}, noiseHosts=${noiseHosts.length}, withContextHtml=${withContextHtml})\n`);
|
|
159
|
+
process.stderr.write(`[ok] wrote ${outputPath} (${events.length} events, maxUrlLen=${maxUrlLen}, maxBodyBytes=${maxBodyBytes}, noiseHosts=${noiseHosts.length}, withContextHtml=${withContextHtml})\n`);
|
|
149
160
|
} else {
|
|
150
161
|
process.stdout.write(md);
|
|
151
162
|
}
|
|
@@ -161,6 +172,7 @@ function printHelp() {
|
|
|
161
172
|
'',
|
|
162
173
|
'Options:',
|
|
163
174
|
' --max-url-len <N> 单个 URL 字段最大长度(默认 800),超过则截断 + 标注反查行号',
|
|
175
|
+
' --max-body-bytes <N> 单个 request/response body 在 trace 中最大展示字节数(默认 16384)',
|
|
164
176
|
' --noise-host <p> 追加 noise host pattern(可多次):',
|
|
165
177
|
' - "online-metrix.net" → 子串匹配',
|
|
166
178
|
' - "/^foo\\..*$/i" → 正则字面量',
|
|
@@ -292,6 +304,7 @@ function generateTrace(events, ctx) {
|
|
|
292
304
|
lines.push(`- 时长: \`${formatDuration(duration)}\``);
|
|
293
305
|
lines.push(`- 事件总数: **${events.length}**`);
|
|
294
306
|
lines.push(`- 超长 URL 截断: ${truncatedCount} 处(阈值 maxUrlLen=${ctx.maxUrlLen};完整原文在原 jsonl,通过 jsonlLine 反查)`);
|
|
307
|
+
lines.push(`- 网络正文展示上限: 每个 body ${ctx.maxBodyBytes} bytes;session.jsonl 中的捕获上限与截断状态以事件字段为准`);
|
|
295
308
|
lines.push(`- noise/business 分类: NOISE ${noiseEventCount} · BUSINESS-IN-NOISE ${businessInNoiseCount} (基于 ${ctx.noiseHosts.length} 个 noise host pattern;仅标注,不删事件)`);
|
|
296
309
|
lines.push(`- contextHTML 渲染: ${contextHtmlRenderedCount} 处${ctx.withContextHtml ? ' (全 interaction;--with-context-html 启用)' : ' (仅 BUSINESS-IN-NOISE;session.jsonl 仍存全量,反查见下)'}`);
|
|
297
310
|
lines.push(`- URL 覆盖(去 query+hash):`);
|
|
@@ -580,11 +593,33 @@ function renderEvent(ev, ctx, classification) {
|
|
|
580
593
|
|
|
581
594
|
// network
|
|
582
595
|
if (ev.kind === 'network') {
|
|
596
|
+
if (ev.source) out.push(`- source: \`${ev.source}\``);
|
|
597
|
+
if (ev.requestId) out.push(`- requestId: \`${ev.requestId}\``);
|
|
598
|
+
if (ev.redirectedFromRequestId) out.push(`- redirectedFromRequestId: \`${ev.redirectedFromRequestId}\``);
|
|
583
599
|
if (ev.method) out.push(`- method: \`${ev.method}\``);
|
|
584
600
|
const reqLine = urlField('requestUrl', ev.requestUrl, ctx, ev.__jsonlLine);
|
|
585
601
|
if (reqLine) out.push(reqLine);
|
|
586
|
-
if (ev.
|
|
602
|
+
if (ev.completedTime) out.push(`- completedTime: \`${ev.completedTime}\``);
|
|
603
|
+
if (ev.status !== undefined && ev.status !== null) out.push(`- status: ${ev.status}`);
|
|
587
604
|
if (ev.durationMs !== undefined) out.push(`- durationMs: ${ev.durationMs}`);
|
|
605
|
+
if (ev.requestContentType) out.push(`- requestContentType: \`${ev.requestContentType}\``);
|
|
606
|
+
if (ev.requestHeaders && Object.keys(ev.requestHeaders).length) {
|
|
607
|
+
out.push(...renderJsonField('requestHeaders', ev.requestHeaders, DEFAULT_MAX_HEADERS_DISPLAY_BYTES, ev.__jsonlLine));
|
|
608
|
+
}
|
|
609
|
+
out.push(...renderNetworkBody('request', ev, ctx));
|
|
610
|
+
if (ev.responseContentType) out.push(`- responseContentType: \`${ev.responseContentType}\``);
|
|
611
|
+
if (ev.responseHeaders && Object.keys(ev.responseHeaders).length) {
|
|
612
|
+
out.push(...renderJsonField('responseHeaders', ev.responseHeaders, DEFAULT_MAX_HEADERS_DISPLAY_BYTES, ev.__jsonlLine));
|
|
613
|
+
}
|
|
614
|
+
out.push(...renderNetworkBody('response', ev, ctx));
|
|
615
|
+
if (ev.requestMultipart) {
|
|
616
|
+
out.push(...renderJsonField('requestMultipart', ev.requestMultipart, ctx.maxBodyBytes, ev.__jsonlLine));
|
|
617
|
+
}
|
|
618
|
+
if (ev.context) out.push(...renderJsonField('context', ev.context, 16 * 1024, ev.__jsonlLine));
|
|
619
|
+
if (ev.failure) out.push(...renderJsonField('failure', ev.failure, 16 * 1024, ev.__jsonlLine));
|
|
620
|
+
for (const field of ['requestHeaderCaptureError', 'requestBodyCaptureError', 'responseHeaderCaptureError', 'responseBodyCaptureError']) {
|
|
621
|
+
if (ev[field]) out.push(`- ${field}: \`${truncate(ev[field], 500)}\``);
|
|
622
|
+
}
|
|
588
623
|
if (ev.error) out.push(`- error: \`${ev.error}\``);
|
|
589
624
|
}
|
|
590
625
|
|
|
@@ -615,6 +650,88 @@ function renderEvent(ev, ctx, classification) {
|
|
|
615
650
|
return out;
|
|
616
651
|
}
|
|
617
652
|
|
|
653
|
+
/**
|
|
654
|
+
* 渲染 JSON 字段并限制 trace 展示体积,完整值仍保留在 JSONL。
|
|
655
|
+
*
|
|
656
|
+
* @param {string} label 字段名。
|
|
657
|
+
* @param {Object|Array<Object>} value JSON 值。
|
|
658
|
+
* @param {number} maxBytes 展示上限。
|
|
659
|
+
* @param {number} jsonlLine JSONL 行号。
|
|
660
|
+
* @return {string[]} Markdown 行。
|
|
661
|
+
*/
|
|
662
|
+
function renderJsonField(label, value, maxBytes, jsonlLine) {
|
|
663
|
+
const raw = JSON.stringify(value, null, 2);
|
|
664
|
+
const limited = truncateUtf8(Buffer.from(raw, 'utf8'), maxBytes);
|
|
665
|
+
const fence = markdownFence(limited.text);
|
|
666
|
+
const lines = [`- ${label}:`, ` ${fence}json`];
|
|
667
|
+
for (const line of limited.text.split('\n')) lines.push(` ${line}`);
|
|
668
|
+
lines.push(` ${fence}`);
|
|
669
|
+
if (limited.truncated) {
|
|
670
|
+
lines.push(` - trace 展示截断: ${limited.size} → ${maxBytes} bytes;完整值见 jsonl#${jsonlLine} 字段 \`.${label}\``);
|
|
671
|
+
}
|
|
672
|
+
return lines;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* 渲染 request/response body 的大小、跳过原因和有界正文。
|
|
677
|
+
*
|
|
678
|
+
* @param {'request'|'response'} prefix 字段前缀。
|
|
679
|
+
* @param {Object} event 网络事件。
|
|
680
|
+
* @param {Object} ctx trace 配置。
|
|
681
|
+
* @return {string[]} Markdown 行。
|
|
682
|
+
*/
|
|
683
|
+
function renderNetworkBody(prefix, event, ctx) {
|
|
684
|
+
const fieldPrefix = prefix === 'request' ? 'request' : 'response';
|
|
685
|
+
const bodyField = `${fieldPrefix}Body`;
|
|
686
|
+
const sizeField = `${fieldPrefix}Size`;
|
|
687
|
+
const truncatedField = `${fieldPrefix}BodyTruncated`;
|
|
688
|
+
const skippedField = `${fieldPrefix}BodySkipped`;
|
|
689
|
+
const body = event[bodyField];
|
|
690
|
+
const lines = [];
|
|
691
|
+
if (event[sizeField] !== undefined) lines.push(`- ${sizeField}: ${event[sizeField]} bytes`);
|
|
692
|
+
if (event[truncatedField]) lines.push(`- ${truncatedField}: true(session.jsonl 捕获时已截断)`);
|
|
693
|
+
if (event[skippedField]) lines.push(`- ${skippedField}: \`${event[skippedField]}\``);
|
|
694
|
+
if (body === undefined || body === null) return lines;
|
|
695
|
+
|
|
696
|
+
let display = String(body);
|
|
697
|
+
let language = 'text';
|
|
698
|
+
const contentType = String(event[`${fieldPrefix}ContentType`] || '');
|
|
699
|
+
if (/json/i.test(contentType)) {
|
|
700
|
+
try {
|
|
701
|
+
display = JSON.stringify(JSON.parse(display), null, 2);
|
|
702
|
+
language = 'json';
|
|
703
|
+
} catch (error) {
|
|
704
|
+
language = 'text';
|
|
705
|
+
}
|
|
706
|
+
} else if (/xml|html|svg/i.test(contentType)) {
|
|
707
|
+
language = 'html';
|
|
708
|
+
} else if (/javascript|ecmascript/i.test(contentType)) {
|
|
709
|
+
language = 'javascript';
|
|
710
|
+
}
|
|
711
|
+
const limited = truncateUtf8(Buffer.from(display, 'utf8'), ctx.maxBodyBytes);
|
|
712
|
+
const fence = markdownFence(limited.text);
|
|
713
|
+
lines.push(`- ${bodyField}:`);
|
|
714
|
+
lines.push(` ${fence}${language}`);
|
|
715
|
+
for (const line of limited.text.split('\n')) lines.push(` ${line}`);
|
|
716
|
+
lines.push(` ${fence}`);
|
|
717
|
+
if (limited.truncated) {
|
|
718
|
+
lines.push(` - trace 展示截断: ${limited.size} → ${ctx.maxBodyBytes} bytes;完整捕获值见 jsonl#${event.__jsonlLine} 字段 \`.${bodyField}\``);
|
|
719
|
+
}
|
|
720
|
+
return lines;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* 选择不会与正文反引号序列冲突的 Markdown 围栏。
|
|
725
|
+
*
|
|
726
|
+
* @param {string} value 围栏内正文。
|
|
727
|
+
* @return {string} 至少三个反引号组成的安全围栏。
|
|
728
|
+
*/
|
|
729
|
+
function markdownFence(value) {
|
|
730
|
+
const runs = String(value || '').match(/`+/g) || [];
|
|
731
|
+
const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
|
|
732
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
733
|
+
}
|
|
734
|
+
|
|
618
735
|
// ========== URL 字段(超长截断 + 反查标注) ==========
|
|
619
736
|
|
|
620
737
|
function urlField(label, url, ctx, jsonlLine) {
|