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,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
+ });
@@ -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.status !== undefined) out.push(`- status: ${ev.status}`);
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) {