@xtalpi/agentic-lab-skills 0.0.10 → 0.0.11

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 (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +14 -14
  3. package/skills/lab-flow-designer/SKILL.md +612 -612
  4. package/skills/lab-flow-designer/embedded-template/SKILL.md +103 -103
  5. package/skills/lab-flow-designer/embedded-template/pools//345/205/245/345/217/243/346/261/240.md +21 -21
  6. package/skills/lab-flow-designer/embedded-template/pools//345/207/272/345/217/243/346/261/240.md +21 -21
  7. package/skills/lab-flow-designer/embedded-template/scripts//347/244/272/344/276/213/346/225/260/346/215/256/344/270/216/346/240/241/351/252/214/351/227/250/346/216/247.js +142 -142
  8. package/skills/lab-flow-designer/embedded-template/valves//347/244/272/344/276/213/346/225/260/346/215/256/344/270/216/346/240/241/351/252/214/351/227/250/346/216/247.md +114 -114
  9. package/skills/lab-flow-designer/references/agentic-lab-processer.md +122 -122
  10. package/skills/lab-flow-designer/references/agentic-lab-sdk.md +534 -361
  11. package/skills/lab-flow-designer/references/rhea-api/README.md +7 -7
  12. package/skills/lab-flow-designer/references/rhea-api/execute_process_batch.md +58 -58
  13. package/skills/lab-flow-designer/references/skill-package-layout.md +268 -268
  14. package/skills/lab-flow-designer/references//344/270/232/345/212/241/346/265/201/347/250/213/346/226/207/346/241/243/346/240/207/345/207/206.md +216 -216
  15. package/skills/lab-flow-designer/templates//344/270/232/345/212/241/346/265/201/347/250/213/346/226/207/346/241/243/346/250/241/346/235/277.md +192 -192
  16. package/skills/lab-flow-designer/templates//344/270/232/345/212/241/346/265/201/347/250/213/346/226/207/346/241/243/347/244/272/344/276/213.md +207 -207
  17. package/skills/lab-flow-designer/testing/test-processer.mjs +1240 -1240
  18. package/skills/lab-nocobase-flow-generator/SKILL.md +164 -164
  19. package/skills/lab-nocobase-flow-generator/examples/setting/350/241/250/350/216/267/345/217/226/345/244/226/351/203/250/346/234/215/345/212/241.js +70 -70
  20. package/skills/lab-nocobase-flow-generator/examples//346/237/245/350/257/242/345/214/226/345/255/246/345/223/201/344/277/241/346/201/257.js +30 -30
  21. package/skills/lab-nocobase-flow-generator/references/doc-standard.md +84 -84
  22. package/skills/lab-nocobase-flow-generator/references/runtime-api.md +224 -224
  23. package/skills/lab-nocobase-flow-generator/templates//350/204/232/346/234/254/351/200/273/350/276/221/346/226/207/346/241/243/346/250/241/346/235/277.md +121 -121
  24. package/skills/lab-nocobase-flow-generator/templates//350/204/232/346/234/254/351/200/273/350/276/221/346/226/207/346/241/243/347/244/272/344/276/213.md +67 -67
  25. package/skills/lab-orbit-component-builder/SKILL.md +353 -353
  26. package/skills/lab-orbit-component-builder/examples/xnb-component-template/.env.local.example +27 -27
  27. package/skills/lab-orbit-component-builder/examples/xnb-component-template/.eslintignore +7 -7
  28. package/skills/lab-orbit-component-builder/examples/xnb-component-template/.eslintrc.cjs +88 -88
  29. package/skills/lab-orbit-component-builder/examples/xnb-component-template/.nvmrc +1 -1
  30. package/skills/lab-orbit-component-builder/examples/xnb-component-template/AgenticAppAPI.md +268 -268
  31. package/skills/lab-orbit-component-builder/examples/xnb-component-template/Jenkinsfile +106 -106
  32. package/skills/lab-orbit-component-builder/examples/xnb-component-template/OrbitAPI.md +453 -453
  33. package/skills/lab-orbit-component-builder/examples/xnb-component-template/README.md +176 -176
  34. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/public/index.html +12 -12
  35. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/App.vue +151 -151
  36. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/components/DevOpenerLauncher.vue +143 -143
  37. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/global.d.ts +77 -77
  38. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/main.ts +308 -308
  39. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/mockXNBBitable.ts +119 -119
  40. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/shims-vue.d.ts +6 -6
  41. package/skills/lab-orbit-component-builder/examples/xnb-component-template/dev/src/utils/devOpenerHost.ts +75 -75
  42. package/skills/lab-orbit-component-builder/examples/xnb-component-template/index.html +13 -13
  43. package/skills/lab-orbit-component-builder/examples/xnb-component-template/package.json +60 -60
  44. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/api/agenticlabTickets.ts +110 -110
  45. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/entries/bitable.ts +4 -4
  46. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/entries/custom-page.ts +4 -4
  47. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/index.ts +1 -1
  48. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/styles/orbit-quasar-host.scss +19 -19
  49. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/types/context.ts +15 -15
  50. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/types/xnb-context.ts +70 -70
  51. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/use/useBitablePage.ts +189 -189
  52. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/use/useSuperCellDemo.ts +257 -257
  53. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/use/useSuperTableBitableLifecycle.ts +555 -555
  54. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/utils/openerInitParams.ts +158 -158
  55. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/utils/openerTicketIds.ts +32 -32
  56. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/utils/orbitHttpClient.ts +110 -110
  57. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/utils/request.ts +92 -92
  58. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/views/bitable.vue +67 -67
  59. package/skills/lab-orbit-component-builder/examples/xnb-component-template/src/views/custom-page.vue +140 -140
  60. package/skills/lab-orbit-component-builder/examples/xnb-component-template/tsconfig.json +45 -45
  61. package/skills/lab-orbit-component-builder/examples/xnb-component-template/vite.config.ts +170 -170
  62. package/skills/lab-orbit-component-builder/examples/xnb-component-template/vite.dev.config.ts +58 -58
  63. package/skills/lab-orbit-component-builder/references/flow-document-human-ui.md +65 -65
  64. package/skills/lab-orbit-component-builder/references/orbit-vue-conventions.md +133 -133
  65. package/skills/lab-orbit-component-builder/references/pool-schema-to-columns.md +67 -67
  66. package/skills/lab-orbit-component-builder/references/vue-template-checklist.md +179 -179
  67. package/skills/lab-orbit-component-builder/references/xnb-context-vue-props.md +49 -49
  68. package/skills/lab-orbit-component-builder/references/xnbitable-vue-parity.md +32 -32
@@ -1,1240 +1,1240 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Processer script test harness for lab-flow-designer.
4
- * Zero external dependencies — Node.js built-ins only.
5
- *
6
- * Usage:
7
- * node test-processer.mjs \
8
- * --script <path-to-script.js> \
9
- * --pools-dir <path-to-pools-dir> \
10
- * --valves-dir <path-to-valves-dir> \
11
- * --valve-name <valve-base-name>
12
- */
13
-
14
- import { Script, createContext } from 'node:vm';
15
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
16
- import { resolve, join, basename } from 'node:path';
17
-
18
- // ─── CLI arg parsing ───────────────────────────────────────────────
19
-
20
- function parseArgs(argv) {
21
- const args = {};
22
- for (let i = 2; i < argv.length; i++) {
23
- if (argv[i] === '--script') args.script = argv[++i];
24
- else if (argv[i] === '--pools-dir') args.poolsDir = argv[++i];
25
- else if (argv[i] === '--valves-dir') args.valvesDir = argv[++i];
26
- else if (argv[i] === '--valve-name') args.valveName = argv[++i];
27
- else if (argv[i] === '--preview') args.preview = true;
28
- else if (argv[i] === '--data') args.dataPath = argv[++i];
29
- }
30
- if (!args.script) {
31
- console.error('Missing --script <path>');
32
- process.exit(1);
33
- }
34
- return args;
35
- }
36
-
37
- // ─── Markdown parsers ──────────────────────────────────────────────
38
-
39
- function parseMarkdownTable(text) {
40
- const lines = text.split('\n').filter(l => l.trim().startsWith('|'));
41
- if (lines.length < 3) return { headers: [], rows: [] };
42
- const split = line => line.split('|').map(c => c.trim()).filter(Boolean);
43
- const headers = split(lines[0]);
44
- const rows = lines.slice(2).map(split);
45
- return { headers, rows };
46
- }
47
-
48
- function parsePoolMarkdown(content) {
49
- const nameMatch = content.match(/^#\s+(.+)$/m);
50
- const name = nameMatch ? nameMatch[1].trim() : 'unknown';
51
-
52
- const schemaParts = content.split(/^##\s+Schema\s*$/m);
53
- if (schemaParts.length < 2) return { name, fields: [] };
54
-
55
- const schemaSection = schemaParts[1].split(/^##\s+/m)[0];
56
- const { headers, rows } = parseMarkdownTable(schemaSection);
57
-
58
- const colIdx = h => {
59
- const lower = h.toLowerCase();
60
- return headers.findIndex(hdr => hdr.toLowerCase() === lower || hdr === h);
61
- };
62
-
63
- const iKey = Math.max(colIdx('字段'), colIdx('key'), 0);
64
- const iLabel = Math.max(colIdx('字段标题'), colIdx('label'), -1);
65
- const iDesc = Math.max(colIdx('字段描述'), colIdx('description'), -1);
66
- const iType = Math.max(colIdx('字段类型'), colIdx('type'), -1);
67
- const iOptions = Math.max(colIdx('属性'), colIdx('options'), -1);
68
- const iDefault = Math.max(colIdx('默认值'), colIdx('default_value'), -1);
69
-
70
- const fields = rows.map(cells => {
71
- const key = (cells[iKey] || '').trim();
72
- const type = iType >= 0 ? (cells[iType] || 'text').trim() : 'text';
73
- const optRaw = iOptions >= 0 ? (cells[iOptions] || '').trim() : '';
74
- let options;
75
- if (optRaw && optRaw !== '-') {
76
- const enumMatch = optRaw.match(/^枚举[::]\s*(.+)/);
77
- if (enumMatch) {
78
- options = enumMatch[1].split(/[,,、]/).map(s => s.trim()).filter(Boolean);
79
- }
80
- }
81
- const defRaw = iDefault >= 0 ? (cells[iDefault] || '').trim() : '';
82
- const default_value = defRaw && defRaw !== '-' ? defRaw : undefined;
83
- return {
84
- key,
85
- label: iLabel >= 0 ? (cells[iLabel] || key).trim() : key,
86
- description: iDesc >= 0 ? (cells[iDesc] || '').trim() : '',
87
- type,
88
- options,
89
- default_value,
90
- };
91
- }).filter(f => f.key);
92
-
93
- return { name, fields };
94
- }
95
-
96
- function parseValveMarkdown(content) {
97
- const nameMatch = content.match(/^#\s+(.+)$/m);
98
- const name = nameMatch ? nameMatch[1].trim() : 'unknown';
99
-
100
- const input_pools = [];
101
- const output_pools = [];
102
-
103
- const yamlMatch = content.match(/```yaml\n([\s\S]*?)```/);
104
- if (yamlMatch) {
105
- const yaml = yamlMatch[1];
106
- const primaryMatch = yaml.match(/primary:\s*(.+)/);
107
- if (primaryMatch) input_pools.push({ name: primaryMatch[1].trim() });
108
- const secondaryMatches = yaml.matchAll(/secondary:\s*(.+)/g);
109
- for (const m of secondaryMatches) input_pools.push({ name: m[1].trim() });
110
- const poolMatches = yaml.matchAll(/(?:pool|Stash):\s*(.+)/g);
111
- for (const m of poolMatches) output_pools.push({ name: m[1].trim() });
112
- }
113
-
114
- if (input_pools.length === 0) {
115
- const m = content.match(/\*\*输入池\*\*[::]\s*(.+)/);
116
- if (m) input_pools.push({ name: m[1].trim() });
117
- }
118
- if (output_pools.length === 0) {
119
- const m = content.match(/\*\*输出池\*\*[::]\s*(.+)/);
120
- if (m) m[1].split(/[,,、]/).forEach(p => {
121
- const cleaned = p.replace(/[((].*?[))]/, '').trim();
122
- if (cleaned) output_pools.push({ name: cleaned });
123
- });
124
- }
125
-
126
- return { name, input_pools, output_pools };
127
- }
128
-
129
- function loadAllPools(poolsDir) {
130
- if (!poolsDir || !existsSync(poolsDir)) return [];
131
- return readdirSync(poolsDir)
132
- .filter(f => f.endsWith('.md'))
133
- .map(f => {
134
- const content = readFileSync(join(poolsDir, f), 'utf8');
135
- return parsePoolMarkdown(content);
136
- });
137
- }
138
-
139
- function loadValveConfig(valvesDir, valveName) {
140
- if (!valvesDir || !valveName) return { name: 'unknown', input_pools: [{ name: '入口池' }], output_pools: [{ name: '出口池' }] };
141
- const filePath = join(valvesDir, `${valveName}.md`);
142
- if (!existsSync(filePath)) return { name: valveName, input_pools: [{ name: '入口池' }], output_pools: [{ name: '出口池' }] };
143
- return parseValveMarkdown(readFileSync(filePath, 'utf8'));
144
- }
145
-
146
- // ─── Mock data generation ──────────────────────────────────────────
147
-
148
- function generateValueForType(field, seed) {
149
- const { type, options, default_value, key } = field;
150
- if (default_value && default_value !== '-') return default_value;
151
- if (options && options.length) return options[seed % options.length];
152
-
153
- switch ((type || '').toLowerCase()) {
154
- case 'number': case 'integer': case 'float':
155
- return seed * 10.5;
156
- case 'boolean':
157
- return seed % 2 === 0;
158
- case 'json': case 'object':
159
- return {};
160
- case 'array':
161
- return [];
162
- case 'date': case 'datetime':
163
- return '2026-01-15T10:00:00Z';
164
- default:
165
- return `mock_${key}_${seed}`;
166
- }
167
- }
168
-
169
- function generateAdversarialValue(field) {
170
- switch ((field.type || '').toLowerCase()) {
171
- case 'text': case 'string':
172
- return Math.random() > 0.5 ? null : 12345;
173
- case 'number': case 'integer': case 'float':
174
- return Math.random() > 0.5 ? null : 'not-a-number';
175
- case 'boolean':
176
- return null;
177
- default:
178
- return null;
179
- }
180
- }
181
-
182
- function generateDetailFromSchema(fields, seed, adversarial) {
183
- const detail = {};
184
- for (const field of fields) {
185
- if (field.key === 'id' || field.key === 'detail') continue;
186
- detail[field.key] = adversarial
187
- ? generateAdversarialValue(field)
188
- : generateValueForType(field, seed);
189
- }
190
- return detail;
191
- }
192
-
193
- function generateMockTickets(pools, valveConfig, adversarial) {
194
- const inputPoolName = valveConfig.input_pools[0]?.name;
195
- const inputPool = pools.find(p => p.name === inputPoolName);
196
- const fields = inputPool?.fields || [];
197
-
198
- return [1, 2, 3].map(i => ({
199
- id: i,
200
- flow_id: 1,
201
- pool_id: i <= 1 ? 1 : (valveConfig.input_pools.length > 1 ? 2 : 1),
202
- order_id: 1000 + i,
203
- parent_ticket_id: 0,
204
- detail: generateDetailFromSchema(fields, i, adversarial && i === 3),
205
- status: 'created',
206
- uuid: `mock-uuid-${i}`,
207
- }));
208
- }
209
-
210
- // ─── Mock context factory ──────────────────────────────────────────
211
-
212
- const KNOWN_SDK_NAMESPACES = new Set(['ticket', 'pool', 'compound', 'process', 'station']);
213
-
214
- const PAGINATED_METHODS = new Set([
215
- 'ticket.list', 'process.list', 'compound.getStockFromXfcSh',
216
- ]);
217
-
218
- function createMockContext(pools, valveConfig, adversarial) {
219
- const mockTickets = generateMockTickets(pools, valveConfig, adversarial);
220
- const callLog = [];
221
- const warnings = [];
222
-
223
- const rawContext = {
224
- ticket: {
225
- list: async (params) => {
226
- callLog.push({ method: 'ticket.list', params });
227
- if (PAGINATED_METHODS.has('ticket.list') && params?.limit === undefined) {
228
- warnings.push('ticket.list called without explicit limit — may truncate data');
229
- }
230
- return { list: mockTickets };
231
- },
232
- update: async (tickets) => {
233
- callLog.push({ method: 'ticket.update', count: Array.isArray(tickets) ? tickets.length : 0 });
234
- },
235
- append: async (tickets) => {
236
- callLog.push({ method: 'ticket.append', count: Array.isArray(tickets) ? tickets.length : 0 });
237
- return (tickets || []).map((t, i) => ({ ...t, id: 9000 + i }));
238
- },
239
- },
240
- pool: {
241
- getNext: async (valveId) => {
242
- callLog.push({ method: 'pool.getNext', valveId });
243
- return valveConfig.output_pools.map((p, i) => ({
244
- id: 100 + i,
245
- name: p.name,
246
- description: p.description || '',
247
- schema: { fields: [] },
248
- type: 'flow_process',
249
- }));
250
- },
251
- },
252
- compound: {
253
- getStockFromXfcSh: async (params) => {
254
- callLog.push({ method: 'compound.getStockFromXfcSh', params });
255
- const data = [1, 2].map(i => ({
256
- id: i, cas: `12345-67-${i}`, status: '可用',
257
- source: 'MockSupplier', barcode: `XFC-MOCK-${i}`,
258
- cmpd_id: `CA${1000 + i}`, owner: 'mock@example.com',
259
- }));
260
- return { data, meta: { count: data.length, page: 1, pageSize: 400, totalPage: 1 } };
261
- },
262
- },
263
- process: {
264
- list: async (params) => {
265
- callLog.push({ method: 'process.list', params });
266
- if (params?.limit === undefined) {
267
- warnings.push('process.list called without explicit limit');
268
- }
269
- return [];
270
- },
271
- execute: async (items, baseURL) => {
272
- callLog.push({ method: 'process.execute', itemCount: Array.isArray(items) ? items.length : 0, baseURL });
273
- return { results: { exp_id_list: (items || []).map(() => 'mock-exp-' + Math.random().toString(36).slice(2, 8)) } };
274
- },
275
- },
276
- station: {
277
- list: async () => {
278
- callLog.push({ method: 'station.list' });
279
- return [{ id: 1, name: 'Station-1' }];
280
- },
281
- },
282
- };
283
-
284
- const contextProxy = new Proxy(rawContext, {
285
- get(target, prop) {
286
- if (typeof prop === 'symbol') return target[prop];
287
- if (!KNOWN_SDK_NAMESPACES.has(prop)) {
288
- warnings.push(`Unknown SDK namespace accessed: context.${String(prop)}`);
289
- return new Proxy({}, {
290
- get(_, method) {
291
- return async () => {
292
- warnings.push(`Unknown SDK method called: context.${String(prop)}.${String(method)}`);
293
- return undefined;
294
- };
295
- },
296
- });
297
- }
298
- return target[prop];
299
- },
300
- });
301
-
302
- return { context: contextProxy, callLog, warnings, mockTickets };
303
- }
304
-
305
- // ─── Contract validators ───────────────────────────────────────────
306
-
307
- const CAMEL_CASE_PATTERN = /^[a-z]+[A-Z]/;
308
-
309
- function validateStartResult(result) {
310
- const errors = [];
311
- if (typeof result !== 'object' || result === null) {
312
- errors.push('start() must return an object, got ' + (result === null ? 'null' : typeof result));
313
- return errors;
314
- }
315
- if (typeof result.orbit_link !== 'string') {
316
- errors.push(`start().orbit_link must be string, got ${result.orbit_link === undefined ? 'undefined' : typeof result.orbit_link}`);
317
- }
318
- if (!Array.isArray(result.ticket_ids)) {
319
- errors.push(`start().ticket_ids must be array, got ${result.ticket_ids === undefined ? 'undefined' : typeof result.ticket_ids}`);
320
- } else if (result.ticket_ids.length > 0 && typeof result.ticket_ids[0] !== 'number') {
321
- errors.push(`start().ticket_ids[0] must be number, got ${typeof result.ticket_ids[0]}`);
322
- }
323
- for (const key of Object.keys(result)) {
324
- if (CAMEL_CASE_PATTERN.test(key)) {
325
- errors.push(`start() return key "${key}" is camelCase — must be snake_case`);
326
- }
327
- }
328
- return errors;
329
- }
330
-
331
- function validateCompleteResult(result) {
332
- const errors = [];
333
- if (typeof result !== 'object' || result === null) {
334
- errors.push('complete() must return an object, got ' + (result === null ? 'null' : typeof result));
335
- return errors;
336
- }
337
- if (result.new_tickets !== undefined) {
338
- if (!Array.isArray(result.new_tickets)) {
339
- errors.push(`complete().new_tickets must be array, got ${typeof result.new_tickets}`);
340
- } else {
341
- for (let i = 0; i < Math.min(result.new_tickets.length, 3); i++) {
342
- const t = result.new_tickets[i];
343
- if (typeof t.flow_id !== 'number') errors.push(`new_tickets[${i}].flow_id must be number, got ${typeof t.flow_id}`);
344
- if (typeof t.pool_id !== 'number') errors.push(`new_tickets[${i}].pool_id must be number, got ${typeof t.pool_id}`);
345
- if (typeof t.order_id !== 'number') errors.push(`new_tickets[${i}].order_id must be number, got ${typeof t.order_id}`);
346
- if (typeof t.detail !== 'object' || t.detail === null) errors.push(`new_tickets[${i}].detail must be object`);
347
- if (typeof t.status !== 'string') errors.push(`new_tickets[${i}].status must be string, got ${typeof t.status}`);
348
- }
349
- }
350
- }
351
- for (const key of Object.keys(result)) {
352
- if (CAMEL_CASE_PATTERN.test(key)) {
353
- errors.push(`complete() return key "${key}" is camelCase — must be snake_case`);
354
- }
355
- }
356
- return errors;
357
- }
358
-
359
- function validateRunResult(result) {
360
- const errors = [];
361
- if (typeof result !== 'object' || result === null) {
362
- errors.push('run() must return an object, got ' + (result === null ? 'null' : typeof result));
363
- return errors;
364
- }
365
- if (result.new_tickets !== undefined) {
366
- if (!Array.isArray(result.new_tickets)) {
367
- errors.push(`run().new_tickets must be array, got ${typeof result.new_tickets}`);
368
- } else {
369
- for (let i = 0; i < Math.min(result.new_tickets.length, 3); i++) {
370
- const t = result.new_tickets[i];
371
- if (typeof t.flow_id !== 'number') errors.push(`new_tickets[${i}].flow_id must be number, got ${typeof t.flow_id}`);
372
- if (typeof t.pool_id !== 'number') errors.push(`new_tickets[${i}].pool_id must be number, got ${typeof t.pool_id}`);
373
- if (typeof t.order_id !== 'number') errors.push(`new_tickets[${i}].order_id must be number, got ${typeof t.order_id}`);
374
- if (typeof t.detail !== 'object' || t.detail === null) errors.push(`new_tickets[${i}].detail must be object`);
375
- if (typeof t.status !== 'string') errors.push(`new_tickets[${i}].status must be string, got ${typeof t.status}`);
376
- }
377
- }
378
- }
379
- for (const key of Object.keys(result)) {
380
- if (CAMEL_CASE_PATTERN.test(key)) {
381
- errors.push(`run() return key "${key}" is camelCase — must be snake_case`);
382
- }
383
- }
384
- return errors;
385
- }
386
-
387
- // ─── Script loader via vm ──────────────────────────────────────────
388
-
389
- function loadProcesserClass(scriptContent, filename) {
390
- const wrapped = scriptContent + '\n;Processer;\n';
391
- const script = new Script(wrapped, { filename });
392
- const sandbox = {
393
- console: {
394
- log: () => {},
395
- info: () => {},
396
- warn: () => {},
397
- error: () => {},
398
- debug: () => {},
399
- },
400
- setTimeout, clearTimeout, setInterval, clearInterval,
401
- Promise, Array, Object, Map, Set, JSON, Math, Date,
402
- Number, String, Boolean, RegExp, Error, TypeError, ReferenceError,
403
- parseInt, parseFloat, isNaN, isFinite, undefined, NaN, Infinity,
404
- encodeURIComponent, decodeURIComponent, encodeURI, decodeURI,
405
- fetch: async (url, opts) => {
406
- const method = (opts && opts.method) || 'GET';
407
- sandbox.console.warn(`[test-processer] fetch called: ${method} ${url}`);
408
- return {
409
- ok: true,
410
- status: 200,
411
- json: async () => ({ data: {} }),
412
- text: async () => '{}',
413
- };
414
- },
415
- };
416
- const ctx = createContext(sandbox);
417
- return script.runInContext(ctx);
418
- }
419
-
420
- // ─── Preview: scenario data generation ────────────────────────────
421
-
422
- function generateScenarioValue(field, idx) {
423
- const { type, options, default_value, key, label } = field;
424
- if (options && options.length) return options[idx % options.length];
425
-
426
- const prefix = (label || key).replace(/\s+/g, '');
427
- switch ((type || '').toLowerCase()) {
428
- case 'number': case 'integer': case 'float':
429
- return 1000 + idx * 100 + Math.round(Math.random() * 50);
430
- case 'boolean':
431
- return idx % 2 === 0;
432
- case 'json': case 'object':
433
- return { [`${key}_data`]: idx };
434
- case 'array':
435
- return [`${prefix}_item_${idx}`];
436
- case 'date': case 'datetime':
437
- return `2026-0${1 + (idx % 9)}-${String(10 + idx).padStart(2, '0')}T10:00:00Z`;
438
- default:
439
- return `${prefix}_${1000 + idx}`;
440
- }
441
- }
442
-
443
- function generateScenarioTickets(pools, valveConfig) {
444
- const inputPoolName = valveConfig.input_pools[0]?.name;
445
- const inputPool = pools.find(p => p.name === inputPoolName);
446
- const fields = inputPool?.fields || [];
447
-
448
- return [1, 2, 3, 4, 5].map(i => {
449
- const detail = {};
450
- for (const field of fields) {
451
- if (field.key === 'id' || field.key === 'detail') continue;
452
- if (i === 4 && !field.default_value && !(field.options?.length)) {
453
- detail[field.key] = (field.type || '').match(/number|integer|float/i) ? 0 : '';
454
- } else if (i === 5 && field.options && field.options.length > 1) {
455
- detail[field.key] = field.options[1];
456
- } else {
457
- detail[field.key] = generateScenarioValue(field, i);
458
- }
459
- }
460
- return {
461
- id: i,
462
- flow_id: 1,
463
- pool_id: i <= 2 ? 1 : (valveConfig.input_pools.length > 1 ? 2 : 1),
464
- order_id: 1000 + i,
465
- parent_ticket_id: 0,
466
- detail,
467
- status: 'created',
468
- uuid: `scenario-uuid-${i}`,
469
- };
470
- });
471
- }
472
-
473
- // ─── Preview: user data loader ────────────────────────────────────
474
-
475
- function loadUserData(dataPath, valveConfig) {
476
- const raw = readFileSync(resolve(dataPath), 'utf8');
477
- const parsed = JSON.parse(raw);
478
- const tickets = parsed.tickets;
479
- if (!Array.isArray(tickets) || tickets.length === 0) {
480
- throw new Error('--data JSON must contain a non-empty "tickets" array');
481
- }
482
- const defaultPoolId = valveConfig.input_pools.length > 0 ? 1 : 0;
483
- return tickets.map((t, i) => ({
484
- id: t.id ?? i + 1,
485
- flow_id: t.flow_id ?? 1,
486
- pool_id: t.pool_id ?? defaultPoolId,
487
- order_id: t.order_id ?? 1000 + i + 1,
488
- parent_ticket_id: t.parent_ticket_id ?? 0,
489
- detail: t.detail ?? {},
490
- status: t.status ?? 'created',
491
- uuid: t.uuid ?? `user-data-${i + 1}`,
492
- }));
493
- }
494
-
495
- // ─── Preview: detail diff ─────────────────────────────────────────
496
-
497
- function diffDetail(before, after) {
498
- const changes = [];
499
- const allKeys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
500
- for (const key of allKeys) {
501
- const bv = before?.[key];
502
- const av = after?.[key];
503
- const bStr = JSON.stringify(bv);
504
- const aStr = JSON.stringify(av);
505
- if (bStr !== aStr) {
506
- changes.push({
507
- key,
508
- before: bv === undefined ? '(无)' : bStr,
509
- after: av === undefined ? '(无)' : aStr,
510
- type: bv === undefined ? 'added' : av === undefined ? 'removed' : 'changed',
511
- });
512
- }
513
- }
514
- return changes;
515
- }
516
-
517
- // ─── Preview: tracing context ─────────────────────────────────────
518
-
519
- function createTracingContext(pools, valveConfig, tickets) {
520
- const traceLog = [];
521
- let seq = 0;
522
- const ticketSnapshots = new Map();
523
- for (const t of tickets) {
524
- ticketSnapshots.set(t.id, JSON.parse(JSON.stringify(t.detail || {})));
525
- }
526
- const detailDiffs = [];
527
-
528
- function summarize(val) {
529
- if (Array.isArray(val)) return `${val.length} items`;
530
- if (val && typeof val === 'object' && val.list) return `${val.list.length} tickets`;
531
- return JSON.stringify(val)?.slice(0, 60) ?? 'undefined';
532
- }
533
-
534
- const rawContext = {
535
- ticket: {
536
- list: async (params) => {
537
- const result = { list: JSON.parse(JSON.stringify(tickets)) };
538
- traceLog.push({ seq: seq++, method: 'ticket.list', params: JSON.parse(JSON.stringify(params || {})), returnSummary: `${result.list.length} tickets` });
539
- return result;
540
- },
541
- update: async (updatedTickets) => {
542
- const diffs = [];
543
- for (const ut of (updatedTickets || [])) {
544
- const oldDetail = ticketSnapshots.get(ut.id);
545
- const newDetail = ut.detail || {};
546
- const changes = diffDetail(oldDetail, newDetail);
547
- if (changes.length > 0) {
548
- diffs.push({ ticketId: ut.id, changes });
549
- }
550
- ticketSnapshots.set(ut.id, JSON.parse(JSON.stringify(newDetail)));
551
- }
552
- const entry = { seq: seq++, method: 'ticket.update', params: { count: (updatedTickets || []).length }, returnSummary: 'void' };
553
- if (diffs.length > 0) {
554
- entry.detailDiffs = diffs;
555
- detailDiffs.push(...diffs);
556
- }
557
- traceLog.push(entry);
558
- },
559
- append: async (newTickets) => {
560
- const result = (newTickets || []).map((t, i) => ({ ...t, id: 9000 + i }));
561
- traceLog.push({ seq: seq++, method: 'ticket.append', params: { count: (newTickets || []).length }, returnSummary: `${result.length} created` });
562
- return result;
563
- },
564
- },
565
- pool: {
566
- getNext: async (valveId) => {
567
- const result = valveConfig.output_pools.map((p, i) => ({
568
- id: 100 + i, name: p.name, description: p.description || '',
569
- schema: { fields: [] }, type: 'flow_process',
570
- }));
571
- traceLog.push({ seq: seq++, method: 'pool.getNext', params: { valveId }, returnSummary: `${result.length} pools: ${result.map(p => p.name).join(', ')}` });
572
- return result;
573
- },
574
- },
575
- compound: {
576
- getStockFromXfcSh: async (params) => {
577
- const data = [1, 2].map(i => ({
578
- id: i, cas: `12345-67-${i}`, status: '可用',
579
- source: 'MockSupplier', barcode: `XFC-MOCK-${i}`,
580
- cmpd_id: `CA${1000 + i}`, owner: 'mock@example.com',
581
- }));
582
- const result = { data, meta: { count: data.length, page: 1, pageSize: 400, totalPage: 1 } };
583
- traceLog.push({ seq: seq++, method: 'compound.getStockFromXfcSh', params: JSON.parse(JSON.stringify(params || {})), returnSummary: `${data.length} records` });
584
- return result;
585
- },
586
- },
587
- process: {
588
- list: async (params) => {
589
- traceLog.push({ seq: seq++, method: 'process.list', params: JSON.parse(JSON.stringify(params || {})), returnSummary: '0 processes' });
590
- return [];
591
- },
592
- execute: async (items, baseURL) => {
593
- const result = { results: { exp_id_list: (items || []).map(() => 'mock-exp-' + Math.random().toString(36).slice(2, 8)) } };
594
- traceLog.push({ seq: seq++, method: 'process.execute', params: { itemCount: (items || []).length, baseURL }, returnSummary: `${(items || []).length} executed` });
595
- return result;
596
- },
597
- },
598
- station: {
599
- list: async () => {
600
- const result = [{ id: 1, name: 'Station-1' }];
601
- traceLog.push({ seq: seq++, method: 'station.list', params: {}, returnSummary: `${result.length} stations` });
602
- return result;
603
- },
604
- },
605
- };
606
-
607
- const contextProxy = new Proxy(rawContext, {
608
- get(target, prop) {
609
- if (typeof prop === 'symbol') return target[prop];
610
- if (!KNOWN_SDK_NAMESPACES.has(prop)) {
611
- traceLog.push({ seq: seq++, method: `(unknown) ${String(prop)}.*`, params: {}, returnSummary: 'unknown namespace' });
612
- return new Proxy({}, {
613
- get(_, method) {
614
- return async (...fnArgs) => {
615
- traceLog.push({ seq: seq++, method: `${String(prop)}.${String(method)}`, params: fnArgs, returnSummary: 'undefined (unknown)' });
616
- return undefined;
617
- };
618
- },
619
- });
620
- }
621
- return target[prop];
622
- },
623
- });
624
-
625
- return { context: contextProxy, traceLog, detailDiffs, ticketSnapshots };
626
- }
627
-
628
- // ─── Preview: report formatter ────────────────────────────────────
629
-
630
- function formatTicketsTable(tickets, maxDetailKeys) {
631
- if (tickets.length === 0) return '(无 tickets)\n';
632
- const detailKeys = maxDetailKeys || [...new Set(tickets.flatMap(t => Object.keys(t.detail || {})))].slice(0, 8);
633
- const headers = ['id', 'order_id', ...detailKeys, 'status'];
634
- const rows = tickets.map(t => {
635
- const d = t.detail || {};
636
- return [t.id, t.order_id, ...detailKeys.map(k => {
637
- const v = d[k];
638
- if (v === undefined || v === null) return '-';
639
- if (typeof v === 'object') return JSON.stringify(v).slice(0, 30);
640
- return String(v).slice(0, 30);
641
- }), t.status];
642
- });
643
- const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i]).length)));
644
- const pad = (s, w) => String(s).padEnd(w);
645
- const line = colWidths.map(w => '-'.repeat(w));
646
- const fmt = row => '| ' + row.map((c, i) => pad(c, colWidths[i])).join(' | ') + ' |';
647
- return [fmt(headers), '| ' + line.map(l => l).join(' | ') + ' |', ...rows.map(fmt)].join('\n') + '\n';
648
- }
649
-
650
- function formatPreviewReport(trace) {
651
- const { scriptPath, valveName, valveConfig, pools, dataSource,
652
- inputTickets, isAutoTrigger,
653
- startResult, startError, startTraceLog, startDetailDiffs,
654
- completeTickets, completeResult, completeError, completeTraceLog, completeDetailDiffs,
655
- runResult, runError, runTraceLog, runDetailDiffs } = trace;
656
-
657
- const lines = [];
658
- const push = (...args) => lines.push(...args);
659
-
660
- push('# 脚本预览报告', '');
661
-
662
- // Basic info
663
- push('## 基本信息');
664
- push(`- 脚本: \`${basename(scriptPath)}\``);
665
- push(`- 门控: ${valveName}`);
666
- const inputPoolNames = valveConfig.input_pools.map(p => p.name).join(', ') || '(未知)';
667
- const outputPoolNames = valveConfig.output_pools.map(p => p.name).join(', ') || '(未知)';
668
- push(`- 入口池: ${inputPoolNames}`);
669
- push(`- 出口池: ${outputPoolNames}`);
670
- push(`- 数据来源: ${dataSource}`);
671
- push(`- tickets 数: ${inputTickets.length}`);
672
- push(`- 触发方式: ${isAutoTrigger ? '自动触发 (run)' : '人工触发 (start/complete)'}`, '');
673
-
674
- if (isAutoTrigger) {
675
- // run() trace
676
- push('## run() 执行追踪', '');
677
- push(`### 输入数据 (${inputTickets.length} 条 tickets)`, '');
678
- push(formatTicketsTable(inputTickets));
679
-
680
- push('### SDK 调用链', '');
681
- for (const entry of (runTraceLog || [])) {
682
- const paramStr = JSON.stringify(entry.params);
683
- push(`${entry.seq + 1}. \`${entry.method}(${paramStr === '{}' ? '' : paramStr})\` → ${entry.returnSummary}`);
684
- }
685
- push('');
686
-
687
- if (runDetailDiffs && runDetailDiffs.length > 0) {
688
- push('### ticket.detail 变更 (run 阶段)', '');
689
- push('| ticket | 字段 | 变更前 | 变更后 |');
690
- push('|--------|------|--------|--------|');
691
- for (const d of runDetailDiffs) {
692
- for (const c of d.changes) {
693
- push(`| #${d.ticketId} | ${c.key} | ${c.before} | ${c.after} |`);
694
- }
695
- }
696
- push('');
697
- }
698
-
699
- if (runError) {
700
- push(`### run() 错误`, '');
701
- push(`\`\`\`\n${runError}\n\`\`\``, '');
702
- } else if (runResult) {
703
- push('### 数据路由', '');
704
- if (runResult.new_tickets && runResult.new_tickets.length > 0) {
705
- const routeMap = new Map();
706
- for (const nt of runResult.new_tickets) {
707
- const poolId = nt.pool_id;
708
- if (!routeMap.has(poolId)) routeMap.set(poolId, []);
709
- routeMap.get(poolId).push(nt);
710
- }
711
- push('| 目标池 | pool_id | 数量 | status |');
712
- push('|--------|---------|------|--------|');
713
- for (const [poolId, nts] of routeMap) {
714
- const poolName = valveConfig.output_pools.find((_, i) => 100 + i === poolId)?.name || `pool_${poolId}`;
715
- push(`| ${poolName} | ${poolId} | ${nts.length} | ${nts[0]?.status || '-'} |`);
716
- }
717
- push('');
718
- } else {
719
- push('(无 new_tickets 返回)', '');
720
- }
721
- }
722
-
723
- // Summary
724
- push('## 执行摘要', '');
725
- const usedMethods = new Set((runTraceLog || []).map(c => c.method));
726
- const allMethods = ['ticket.list', 'ticket.update', 'ticket.append', 'pool.getNext',
727
- 'compound.getStockFromXfcSh', 'process.list', 'process.execute', 'station.list'];
728
- const unused = allMethods.filter(m => !usedMethods.has(m));
729
-
730
- const runStatus = runError
731
- ? `✗ 失败: ${runError.split('\n')[0]}`
732
- : `✓ 成功 (${(runTraceLog || []).length} SDK 调用, ${(runResult?.new_tickets || []).length} new_tickets)`;
733
-
734
- push(`- run: ${runStatus}`);
735
- if (unused.length > 0) {
736
- push(`- 未调用的 SDK 方法: ${unused.join(', ')}`);
737
- }
738
- push('');
739
- } else {
740
- // start() trace
741
- push('## start() 执行追踪', '');
742
- push(`### 输入数据 (${inputTickets.length} 条 tickets)`, '');
743
- push(formatTicketsTable(inputTickets));
744
-
745
- push('### SDK 调用链', '');
746
- for (const entry of startTraceLog) {
747
- const paramStr = JSON.stringify(entry.params);
748
- push(`${entry.seq + 1}. \`${entry.method}(${paramStr === '{}' ? '' : paramStr})\` → ${entry.returnSummary}`);
749
- }
750
- push('');
751
-
752
- if (startDetailDiffs.length > 0) {
753
- push('### ticket.detail 变更 (start 阶段)', '');
754
- push('| ticket | 字段 | 变更前 | 变更后 |');
755
- push('|--------|------|--------|--------|');
756
- for (const d of startDetailDiffs) {
757
- for (const c of d.changes) {
758
- push(`| #${d.ticketId} | ${c.key} | ${c.before} | ${c.after} |`);
759
- }
760
- }
761
- push('');
762
- }
763
-
764
- if (startError) {
765
- push(`### start() 错误`, '');
766
- push(`\`\`\`\n${startError}\n\`\`\``, '');
767
- } else if (startResult) {
768
- push('### 返回值', '');
769
- push(`- orbit_link: \`${startResult.orbit_link}\``);
770
- push(`- ticket_ids: [${(startResult.ticket_ids || []).join(', ')}]`);
771
- push('');
772
- }
773
-
774
- // complete() trace
775
- push('## complete() 执行追踪', '');
776
- push(`### 输入 tickets (${(completeTickets || []).length} 条)`, '');
777
-
778
- push('### SDK 调用链', '');
779
- for (const entry of completeTraceLog) {
780
- const paramStr = JSON.stringify(entry.params);
781
- push(`${entry.seq + 1}. \`${entry.method}(${paramStr === '{}' ? '' : paramStr})\` → ${entry.returnSummary}`);
782
- }
783
- push('');
784
-
785
- if (completeDetailDiffs.length > 0) {
786
- push('### ticket.detail 变更 (complete 阶段)', '');
787
- push('| ticket | 字段 | 变更前 | 变更后 |');
788
- push('|--------|------|--------|--------|');
789
- for (const d of completeDetailDiffs) {
790
- for (const c of d.changes) {
791
- push(`| #${d.ticketId} | ${c.key} | ${c.before} | ${c.after} |`);
792
- }
793
- }
794
- push('');
795
- }
796
-
797
- if (completeError) {
798
- push(`### complete() 错误`, '');
799
- push(`\`\`\`\n${completeError}\n\`\`\``, '');
800
- } else if (completeResult) {
801
- push('### 数据路由', '');
802
- if (completeResult.new_tickets && completeResult.new_tickets.length > 0) {
803
- const routeMap = new Map();
804
- for (const nt of completeResult.new_tickets) {
805
- const poolId = nt.pool_id;
806
- if (!routeMap.has(poolId)) routeMap.set(poolId, []);
807
- routeMap.get(poolId).push(nt);
808
- }
809
- push('| 目标池 | pool_id | 数量 | status |');
810
- push('|--------|---------|------|--------|');
811
- for (const [poolId, nts] of routeMap) {
812
- const poolName = valveConfig.output_pools.find((_, i) => 100 + i === poolId)?.name || `pool_${poolId}`;
813
- push(`| ${poolName} | ${poolId} | ${nts.length} | ${nts[0]?.status || '-'} |`);
814
- }
815
- push('');
816
- } else {
817
- push('(无 new_tickets 返回)', '');
818
- }
819
- }
820
-
821
- // Summary
822
- push('## 执行摘要', '');
823
- const allCalls = [...startTraceLog, ...completeTraceLog];
824
- const usedMethods = new Set(allCalls.map(c => c.method));
825
- const allMethods = ['ticket.list', 'ticket.update', 'ticket.append', 'pool.getNext',
826
- 'compound.getStockFromXfcSh', 'process.list', 'process.execute', 'station.list'];
827
- const unused = allMethods.filter(m => !usedMethods.has(m));
828
-
829
- const startStatus = startError ? `✗ 失败: ${startError.split('\n')[0]}` : `✓ 成功 (${startTraceLog.length} SDK 调用, ${inputTickets.length} tickets)`;
830
- const completeStatus = completeError
831
- ? `✗ 失败: ${completeError.split('\n')[0]}`
832
- : `✓ 成功 (${completeTraceLog.length} SDK 调用, ${(completeResult?.new_tickets || []).length} new_tickets)`;
833
-
834
- push(`- start: ${startStatus}`);
835
- push(`- complete: ${completeStatus}`);
836
- if (unused.length > 0) {
837
- push(`- 未调用的 SDK 方法: ${unused.join(', ')}`);
838
- }
839
- push('');
840
- }
841
-
842
- return lines.join('\n');
843
- }
844
-
845
- // ─── Preview: main runner ─────────────────────────────────────────
846
-
847
- async function runPreview(args) {
848
- const scriptPath = resolve(args.script);
849
- const poolsDir = args.poolsDir ? resolve(args.poolsDir) : null;
850
- const valvesDir = args.valvesDir ? resolve(args.valvesDir) : null;
851
- const valveName = args.valveName || basename(scriptPath, '.js');
852
-
853
- let scriptContent;
854
- try {
855
- scriptContent = readFileSync(scriptPath, 'utf8');
856
- } catch (e) {
857
- console.error(`Cannot read script: ${e.message}`);
858
- process.exit(1);
859
- }
860
-
861
- let ProcesserClass;
862
- try {
863
- ProcesserClass = loadProcesserClass(scriptContent, basename(scriptPath));
864
- } catch (e) {
865
- console.error(`Cannot load Processer: ${e.message}`);
866
- process.exit(1);
867
- }
868
-
869
- const pools = loadAllPools(poolsDir);
870
- const valveConfig = loadValveConfig(valvesDir, valveName);
871
-
872
- let inputTickets;
873
- let dataSource;
874
- if (args.dataPath) {
875
- try {
876
- inputTickets = loadUserData(args.dataPath, valveConfig);
877
- dataSource = `用户数据 (${basename(args.dataPath)})`;
878
- } catch (e) {
879
- console.error(`Cannot load --data: ${e.message}`);
880
- process.exit(1);
881
- }
882
- } else {
883
- inputTickets = generateScenarioTickets(pools, valveConfig);
884
- dataSource = '自动生成';
885
- }
886
-
887
- const inputPoolIds = valveConfig.input_pools.map((_, i) => i + 1);
888
- const proto = ProcesserClass.prototype;
889
- const isAutoTrigger = typeof proto.run === 'function' && typeof proto.start !== 'function';
890
-
891
- const trace = {
892
- scriptPath, valveName, valveConfig, pools, dataSource, inputTickets, isAutoTrigger,
893
- startResult: null, startError: null, startTraceLog: [], startDetailDiffs: [],
894
- completeTickets: null, completeResult: null, completeError: null, completeTraceLog: [], completeDetailDiffs: [],
895
- runResult: null, runError: null, runTraceLog: [], runDetailDiffs: [],
896
- };
897
-
898
- if (isAutoTrigger) {
899
- // Auto-trigger: run()
900
- const tc = createTracingContext(pools, valveConfig, inputTickets);
901
- let processer;
902
- try {
903
- processer = new ProcesserClass(tc.context);
904
- } catch (e) {
905
- trace.runError = `constructor threw: ${e.message}\n${e.stack}`;
906
- console.log(formatPreviewReport(trace));
907
- process.exit(1);
908
- }
909
-
910
- try {
911
- trace.runResult = await processer.run({ valve_id: 1, pool_ids: inputPoolIds });
912
- } catch (e) {
913
- trace.runError = `${e.constructor.name}: ${e.message}\n${e.stack}`;
914
- }
915
- trace.runTraceLog = tc.traceLog;
916
- trace.runDetailDiffs = tc.detailDiffs;
917
- } else {
918
- // Manual-trigger: start() + complete()
919
- // Run start()
920
- {
921
- const tc = createTracingContext(pools, valveConfig, inputTickets);
922
- let processer;
923
- try {
924
- processer = new ProcesserClass(tc.context);
925
- } catch (e) {
926
- trace.startError = `constructor threw: ${e.message}\n${e.stack}`;
927
- console.log(formatPreviewReport(trace));
928
- process.exit(1);
929
- }
930
-
931
- try {
932
- trace.startResult = await processer.start({ valve_id: 1, pool_ids: inputPoolIds });
933
- } catch (e) {
934
- trace.startError = `${e.constructor.name}: ${e.message}\n${e.stack}`;
935
- }
936
- trace.startTraceLog = tc.traceLog;
937
- trace.startDetailDiffs = tc.detailDiffs;
938
- }
939
-
940
- // Run complete()
941
- {
942
- const completeTickets = inputTickets.map(t => ({ ...t, status: 'created' }));
943
- trace.completeTickets = completeTickets;
944
-
945
- const tc = createTracingContext(pools, valveConfig, completeTickets);
946
- let processer;
947
- try {
948
- processer = new ProcesserClass(tc.context);
949
- } catch (e) {
950
- trace.completeError = `constructor threw: ${e.message}\n${e.stack}`;
951
- console.log(formatPreviewReport(trace));
952
- process.exit(1);
953
- }
954
-
955
- try {
956
- trace.completeResult = await processer.complete({ valve_id: 1, tickets: completeTickets });
957
- } catch (e) {
958
- trace.completeError = `${e.constructor.name}: ${e.message}\n${e.stack}`;
959
- }
960
- trace.completeTraceLog = tc.traceLog;
961
- trace.completeDetailDiffs = tc.detailDiffs;
962
- }
963
- }
964
-
965
- console.log(formatPreviewReport(trace));
966
- const hasError = isAutoTrigger ? trace.runError : (trace.startError || trace.completeError);
967
- process.exit(hasError ? 1 : 0);
968
- }
969
-
970
- // ─── Main test runner ──────────────────────────────────────────────
971
-
972
- async function runTests(args) {
973
- const scriptPath = resolve(args.script);
974
- const poolsDir = args.poolsDir ? resolve(args.poolsDir) : null;
975
- const valvesDir = args.valvesDir ? resolve(args.valvesDir) : null;
976
- const valveName = args.valveName || basename(scriptPath, '.js');
977
-
978
- const result = {
979
- status: 'pass',
980
- script: scriptPath,
981
- phases: {
982
- syntax: { pass: false },
983
- structure: { pass: false, hasConstructor: false, hasStart: false, hasComplete: false, hasRun: false },
984
- normalRun: {
985
- start: { pass: false, returnValid: false, errors: [] },
986
- complete: { pass: false, returnValid: false, errors: [] },
987
- },
988
- adversarialRun: {
989
- start: { pass: false, errors: [] },
990
- complete: { pass: false, errors: [] },
991
- note: '对抗轮失败为警告,不阻断',
992
- },
993
- compliance: { pass: true, warnings: [] },
994
- },
995
- sdkCalls: [],
996
- errors: [],
997
- warnings: [],
998
- };
999
-
1000
- // Phase 1: Syntax check
1001
- let scriptContent;
1002
- try {
1003
- scriptContent = readFileSync(scriptPath, 'utf8');
1004
- } catch (e) {
1005
- result.errors.push(`Cannot read script file: ${e.message}`);
1006
- result.status = 'fail';
1007
- return result;
1008
- }
1009
-
1010
- let ProcesserClass;
1011
- try {
1012
- new Script(scriptContent, { filename: basename(scriptPath) });
1013
- result.phases.syntax.pass = true;
1014
- } catch (e) {
1015
- result.phases.syntax.pass = false;
1016
- result.phases.syntax.error = e.message;
1017
- result.errors.push(`SyntaxError: ${e.message}`);
1018
- result.status = 'fail';
1019
- return result;
1020
- }
1021
-
1022
- // Phase 2: Structure check
1023
- try {
1024
- ProcesserClass = loadProcesserClass(scriptContent, basename(scriptPath));
1025
- } catch (e) {
1026
- result.errors.push(`Failed to load Processer class: ${e.message}`);
1027
- result.status = 'fail';
1028
- return result;
1029
- }
1030
-
1031
- if (typeof ProcesserClass !== 'function') {
1032
- result.errors.push('Script does not define a Processer class (or class is not the last declaration)');
1033
- result.status = 'fail';
1034
- return result;
1035
- }
1036
-
1037
- const proto = ProcesserClass.prototype;
1038
- result.phases.structure.hasConstructor = typeof ProcesserClass === 'function';
1039
- result.phases.structure.hasStart = typeof proto.start === 'function';
1040
- result.phases.structure.hasComplete = typeof proto.complete === 'function';
1041
- result.phases.structure.hasRun = typeof proto.run === 'function';
1042
- result.phases.structure.pass =
1043
- result.phases.structure.hasConstructor &&
1044
- (
1045
- (result.phases.structure.hasStart && result.phases.structure.hasComplete) ||
1046
- result.phases.structure.hasRun
1047
- );
1048
-
1049
- if (!result.phases.structure.pass) {
1050
- if (!result.phases.structure.hasRun && !result.phases.structure.hasStart) result.errors.push('Processer missing start() method (manual-trigger) or run() method (auto-trigger)');
1051
- if (!result.phases.structure.hasRun && !result.phases.structure.hasComplete) result.errors.push('Processer missing complete() method (manual-trigger) or run() method (auto-trigger)');
1052
- result.status = 'fail';
1053
- return result;
1054
- }
1055
-
1056
- const isAutoTrigger = result.phases.structure.hasRun && !result.phases.structure.hasStart;
1057
-
1058
- // Load pool schemas and valve config
1059
- const pools = loadAllPools(poolsDir);
1060
- const valveConfig = loadValveConfig(valvesDir, valveName);
1061
- const inputPoolIds = valveConfig.input_pools.map((_, i) => i + 1);
1062
-
1063
- // Phase 3: Normal run
1064
- {
1065
- const mock = createMockContext(pools, valveConfig, false);
1066
- let processer;
1067
- try {
1068
- processer = new ProcesserClass(mock.context);
1069
- } catch (e) {
1070
- const phaseKey = isAutoTrigger ? 'run' : 'start';
1071
- if (!result.phases.normalRun[phaseKey]) result.phases.normalRun[phaseKey] = { pass: false, returnValid: false, errors: [] };
1072
- result.phases.normalRun[phaseKey].errors.push(`constructor threw: ${e.message}`);
1073
- result.errors.push(`Normal run constructor error: ${e.message}`);
1074
- result.status = 'fail';
1075
- return result;
1076
- }
1077
-
1078
- if (isAutoTrigger) {
1079
- // Auto-trigger: test run()
1080
- if (!result.phases.normalRun.run) result.phases.normalRun.run = { pass: false, returnValid: false, errors: [] };
1081
- try {
1082
- const runResult = await processer.run({ valve_id: 1, pool_ids: inputPoolIds });
1083
- const runErrors = validateRunResult(runResult);
1084
- result.phases.normalRun.run.returnValid = runErrors.length === 0;
1085
- result.phases.normalRun.run.errors = runErrors;
1086
- result.phases.normalRun.run.pass = runErrors.length === 0;
1087
- if (runErrors.length > 0) {
1088
- result.errors.push(...runErrors.map(e => `[normal run] ${e}`));
1089
- }
1090
- } catch (e) {
1091
- result.phases.normalRun.run.errors.push(e.message);
1092
- result.errors.push(`[normal run] Runtime error: ${e.message}`);
1093
- }
1094
- } else {
1095
- // Manual-trigger: test start() + complete()
1096
- // start
1097
- try {
1098
- const startResult = await processer.start({ valve_id: 1, pool_ids: inputPoolIds });
1099
- const startErrors = validateStartResult(startResult);
1100
- result.phases.normalRun.start.returnValid = startErrors.length === 0;
1101
- result.phases.normalRun.start.errors = startErrors;
1102
- result.phases.normalRun.start.pass = startErrors.length === 0;
1103
- if (startErrors.length > 0) {
1104
- result.errors.push(...startErrors.map(e => `[normal start] ${e}`));
1105
- }
1106
- } catch (e) {
1107
- result.phases.normalRun.start.errors.push(e.message);
1108
- result.errors.push(`[normal start] Runtime error: ${e.message}`);
1109
- }
1110
-
1111
- // complete — use mock tickets from the start phase (with updated details if any)
1112
- try {
1113
- const completeTickets = mock.mockTickets.map(t => ({ ...t, status: 'created' }));
1114
- const completeResult = await processer.complete({ valve_id: 1, tickets: completeTickets });
1115
- const completeErrors = validateCompleteResult(completeResult);
1116
- result.phases.normalRun.complete.returnValid = completeErrors.length === 0;
1117
- result.phases.normalRun.complete.errors = completeErrors;
1118
- result.phases.normalRun.complete.pass = completeErrors.length === 0;
1119
- if (completeErrors.length > 0) {
1120
- result.errors.push(...completeErrors.map(e => `[normal complete] ${e}`));
1121
- }
1122
- } catch (e) {
1123
- result.phases.normalRun.complete.errors.push(e.message);
1124
- result.errors.push(`[normal complete] Runtime error: ${e.message}`);
1125
- }
1126
- }
1127
-
1128
- result.sdkCalls = [...new Set(mock.callLog.map(c => c.method))];
1129
- result.phases.compliance.warnings.push(...mock.warnings);
1130
-
1131
- // Check limit/offset compliance
1132
- for (const call of mock.callLog) {
1133
- if (PAGINATED_METHODS.has(call.method) && call.params && call.params.limit === undefined) {
1134
- result.phases.compliance.warnings.push(
1135
- `${call.method} called without limit parameter`
1136
- );
1137
- }
1138
- }
1139
-
1140
- // Check for sandbox-unavailable APIs in source code
1141
- const UNAVAILABLE_APIS = ['URLSearchParams', 'TextEncoder', 'TextDecoder', 'Buffer'];
1142
- for (const api of UNAVAILABLE_APIS) {
1143
- if (scriptContent.includes(api)) {
1144
- result.phases.compliance.warnings.push(
1145
- `Script uses '${api}' which is not available in the gate script sandbox — use template literals + encodeURIComponent instead`
1146
- );
1147
- }
1148
- }
1149
- }
1150
-
1151
- const normalPass = isAutoTrigger
1152
- ? result.phases.normalRun.run?.pass
1153
- : result.phases.normalRun.start.pass && result.phases.normalRun.complete.pass;
1154
- if (!normalPass) {
1155
- result.status = 'fail';
1156
- }
1157
-
1158
- // Phase 4: Adversarial run
1159
- {
1160
- const mock = createMockContext(pools, valveConfig, true);
1161
- let processer;
1162
- try {
1163
- processer = new ProcesserClass(mock.context);
1164
- } catch (e) {
1165
- const phaseKey = isAutoTrigger ? 'run' : 'start';
1166
- if (!result.phases.adversarialRun[phaseKey]) result.phases.adversarialRun[phaseKey] = { pass: false, errors: [] };
1167
- result.phases.adversarialRun[phaseKey].errors.push(`constructor threw: ${e.message}`);
1168
- result.warnings.push(`[adversarial] constructor error: ${e.message}`);
1169
- }
1170
-
1171
- if (processer) {
1172
- if (isAutoTrigger) {
1173
- if (!result.phases.adversarialRun.run) result.phases.adversarialRun.run = { pass: false, errors: [] };
1174
- try {
1175
- await processer.run({ valve_id: 1, pool_ids: inputPoolIds });
1176
- result.phases.adversarialRun.run.pass = true;
1177
- } catch (e) {
1178
- result.phases.adversarialRun.run.errors.push(e.message);
1179
- result.warnings.push(`[adversarial run] ${e.constructor.name}: ${e.message}`);
1180
- }
1181
- } else {
1182
- try {
1183
- await processer.start({ valve_id: 1, pool_ids: inputPoolIds });
1184
- result.phases.adversarialRun.start.pass = true;
1185
- } catch (e) {
1186
- result.phases.adversarialRun.start.errors.push(e.message);
1187
- result.warnings.push(`[adversarial start] ${e.constructor.name}: ${e.message}`);
1188
- }
1189
-
1190
- try {
1191
- const completeTickets = mock.mockTickets.map(t => ({ ...t, status: 'created' }));
1192
- await processer.complete({ valve_id: 1, tickets: completeTickets });
1193
- result.phases.adversarialRun.complete.pass = true;
1194
- } catch (e) {
1195
- result.phases.adversarialRun.complete.errors.push(e.message);
1196
- result.warnings.push(`[adversarial complete] ${e.constructor.name}: ${e.message}`);
1197
- }
1198
- }
1199
-
1200
- result.phases.compliance.warnings.push(
1201
- ...mock.warnings.filter(w => !result.phases.compliance.warnings.includes(w))
1202
- );
1203
- }
1204
- }
1205
-
1206
- // Phase 5: Compliance
1207
- if (result.phases.compliance.warnings.length > 0) {
1208
- result.warnings.push(...result.phases.compliance.warnings);
1209
- }
1210
- result.phases.compliance.pass = result.phases.compliance.warnings.filter(
1211
- w => w.includes('Unknown SDK')
1212
- ).length === 0;
1213
-
1214
- if (!result.phases.compliance.pass) {
1215
- result.status = 'fail';
1216
- }
1217
-
1218
- return result;
1219
- }
1220
-
1221
- // ─── Entry point ───────────────────────────────────────────────────
1222
-
1223
- const args = parseArgs(process.argv);
1224
-
1225
- if (args.preview) {
1226
- runPreview(args).catch(e => {
1227
- console.error(`Preview failed: ${e.message}`);
1228
- process.exit(1);
1229
- });
1230
- } else {
1231
- runTests(args)
1232
- .then(result => {
1233
- console.log(JSON.stringify(result, null, 2));
1234
- process.exit(result.status === 'pass' ? 0 : 1);
1235
- })
1236
- .catch(e => {
1237
- console.error(JSON.stringify({ status: 'fail', errors: [e.message] }));
1238
- process.exit(1);
1239
- });
1240
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Processer script test harness for lab-flow-designer.
4
+ * Zero external dependencies — Node.js built-ins only.
5
+ *
6
+ * Usage:
7
+ * node test-processer.mjs \
8
+ * --script <path-to-script.js> \
9
+ * --pools-dir <path-to-pools-dir> \
10
+ * --valves-dir <path-to-valves-dir> \
11
+ * --valve-name <valve-base-name>
12
+ */
13
+
14
+ import { Script, createContext } from 'node:vm';
15
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
16
+ import { resolve, join, basename } from 'node:path';
17
+
18
+ // ─── CLI arg parsing ───────────────────────────────────────────────
19
+
20
+ function parseArgs(argv) {
21
+ const args = {};
22
+ for (let i = 2; i < argv.length; i++) {
23
+ if (argv[i] === '--script') args.script = argv[++i];
24
+ else if (argv[i] === '--pools-dir') args.poolsDir = argv[++i];
25
+ else if (argv[i] === '--valves-dir') args.valvesDir = argv[++i];
26
+ else if (argv[i] === '--valve-name') args.valveName = argv[++i];
27
+ else if (argv[i] === '--preview') args.preview = true;
28
+ else if (argv[i] === '--data') args.dataPath = argv[++i];
29
+ }
30
+ if (!args.script) {
31
+ console.error('Missing --script <path>');
32
+ process.exit(1);
33
+ }
34
+ return args;
35
+ }
36
+
37
+ // ─── Markdown parsers ──────────────────────────────────────────────
38
+
39
+ function parseMarkdownTable(text) {
40
+ const lines = text.split('\n').filter(l => l.trim().startsWith('|'));
41
+ if (lines.length < 3) return { headers: [], rows: [] };
42
+ const split = line => line.split('|').map(c => c.trim()).filter(Boolean);
43
+ const headers = split(lines[0]);
44
+ const rows = lines.slice(2).map(split);
45
+ return { headers, rows };
46
+ }
47
+
48
+ function parsePoolMarkdown(content) {
49
+ const nameMatch = content.match(/^#\s+(.+)$/m);
50
+ const name = nameMatch ? nameMatch[1].trim() : 'unknown';
51
+
52
+ const schemaParts = content.split(/^##\s+Schema\s*$/m);
53
+ if (schemaParts.length < 2) return { name, fields: [] };
54
+
55
+ const schemaSection = schemaParts[1].split(/^##\s+/m)[0];
56
+ const { headers, rows } = parseMarkdownTable(schemaSection);
57
+
58
+ const colIdx = h => {
59
+ const lower = h.toLowerCase();
60
+ return headers.findIndex(hdr => hdr.toLowerCase() === lower || hdr === h);
61
+ };
62
+
63
+ const iKey = Math.max(colIdx('字段'), colIdx('key'), 0);
64
+ const iLabel = Math.max(colIdx('字段标题'), colIdx('label'), -1);
65
+ const iDesc = Math.max(colIdx('字段描述'), colIdx('description'), -1);
66
+ const iType = Math.max(colIdx('字段类型'), colIdx('type'), -1);
67
+ const iOptions = Math.max(colIdx('属性'), colIdx('options'), -1);
68
+ const iDefault = Math.max(colIdx('默认值'), colIdx('default_value'), -1);
69
+
70
+ const fields = rows.map(cells => {
71
+ const key = (cells[iKey] || '').trim();
72
+ const type = iType >= 0 ? (cells[iType] || 'text').trim() : 'text';
73
+ const optRaw = iOptions >= 0 ? (cells[iOptions] || '').trim() : '';
74
+ let options;
75
+ if (optRaw && optRaw !== '-') {
76
+ const enumMatch = optRaw.match(/^枚举[::]\s*(.+)/);
77
+ if (enumMatch) {
78
+ options = enumMatch[1].split(/[,,、]/).map(s => s.trim()).filter(Boolean);
79
+ }
80
+ }
81
+ const defRaw = iDefault >= 0 ? (cells[iDefault] || '').trim() : '';
82
+ const default_value = defRaw && defRaw !== '-' ? defRaw : undefined;
83
+ return {
84
+ key,
85
+ label: iLabel >= 0 ? (cells[iLabel] || key).trim() : key,
86
+ description: iDesc >= 0 ? (cells[iDesc] || '').trim() : '',
87
+ type,
88
+ options,
89
+ default_value,
90
+ };
91
+ }).filter(f => f.key);
92
+
93
+ return { name, fields };
94
+ }
95
+
96
+ function parseValveMarkdown(content) {
97
+ const nameMatch = content.match(/^#\s+(.+)$/m);
98
+ const name = nameMatch ? nameMatch[1].trim() : 'unknown';
99
+
100
+ const input_pools = [];
101
+ const output_pools = [];
102
+
103
+ const yamlMatch = content.match(/```yaml\n([\s\S]*?)```/);
104
+ if (yamlMatch) {
105
+ const yaml = yamlMatch[1];
106
+ const primaryMatch = yaml.match(/primary:\s*(.+)/);
107
+ if (primaryMatch) input_pools.push({ name: primaryMatch[1].trim() });
108
+ const secondaryMatches = yaml.matchAll(/secondary:\s*(.+)/g);
109
+ for (const m of secondaryMatches) input_pools.push({ name: m[1].trim() });
110
+ const poolMatches = yaml.matchAll(/(?:pool|Stash):\s*(.+)/g);
111
+ for (const m of poolMatches) output_pools.push({ name: m[1].trim() });
112
+ }
113
+
114
+ if (input_pools.length === 0) {
115
+ const m = content.match(/\*\*输入池\*\*[::]\s*(.+)/);
116
+ if (m) input_pools.push({ name: m[1].trim() });
117
+ }
118
+ if (output_pools.length === 0) {
119
+ const m = content.match(/\*\*输出池\*\*[::]\s*(.+)/);
120
+ if (m) m[1].split(/[,,、]/).forEach(p => {
121
+ const cleaned = p.replace(/[((].*?[))]/, '').trim();
122
+ if (cleaned) output_pools.push({ name: cleaned });
123
+ });
124
+ }
125
+
126
+ return { name, input_pools, output_pools };
127
+ }
128
+
129
+ function loadAllPools(poolsDir) {
130
+ if (!poolsDir || !existsSync(poolsDir)) return [];
131
+ return readdirSync(poolsDir)
132
+ .filter(f => f.endsWith('.md'))
133
+ .map(f => {
134
+ const content = readFileSync(join(poolsDir, f), 'utf8');
135
+ return parsePoolMarkdown(content);
136
+ });
137
+ }
138
+
139
+ function loadValveConfig(valvesDir, valveName) {
140
+ if (!valvesDir || !valveName) return { name: 'unknown', input_pools: [{ name: '入口池' }], output_pools: [{ name: '出口池' }] };
141
+ const filePath = join(valvesDir, `${valveName}.md`);
142
+ if (!existsSync(filePath)) return { name: valveName, input_pools: [{ name: '入口池' }], output_pools: [{ name: '出口池' }] };
143
+ return parseValveMarkdown(readFileSync(filePath, 'utf8'));
144
+ }
145
+
146
+ // ─── Mock data generation ──────────────────────────────────────────
147
+
148
+ function generateValueForType(field, seed) {
149
+ const { type, options, default_value, key } = field;
150
+ if (default_value && default_value !== '-') return default_value;
151
+ if (options && options.length) return options[seed % options.length];
152
+
153
+ switch ((type || '').toLowerCase()) {
154
+ case 'number': case 'integer': case 'float':
155
+ return seed * 10.5;
156
+ case 'boolean':
157
+ return seed % 2 === 0;
158
+ case 'json': case 'object':
159
+ return {};
160
+ case 'array':
161
+ return [];
162
+ case 'date': case 'datetime':
163
+ return '2026-01-15T10:00:00Z';
164
+ default:
165
+ return `mock_${key}_${seed}`;
166
+ }
167
+ }
168
+
169
+ function generateAdversarialValue(field) {
170
+ switch ((field.type || '').toLowerCase()) {
171
+ case 'text': case 'string':
172
+ return Math.random() > 0.5 ? null : 12345;
173
+ case 'number': case 'integer': case 'float':
174
+ return Math.random() > 0.5 ? null : 'not-a-number';
175
+ case 'boolean':
176
+ return null;
177
+ default:
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function generateDetailFromSchema(fields, seed, adversarial) {
183
+ const detail = {};
184
+ for (const field of fields) {
185
+ if (field.key === 'id' || field.key === 'detail') continue;
186
+ detail[field.key] = adversarial
187
+ ? generateAdversarialValue(field)
188
+ : generateValueForType(field, seed);
189
+ }
190
+ return detail;
191
+ }
192
+
193
+ function generateMockTickets(pools, valveConfig, adversarial) {
194
+ const inputPoolName = valveConfig.input_pools[0]?.name;
195
+ const inputPool = pools.find(p => p.name === inputPoolName);
196
+ const fields = inputPool?.fields || [];
197
+
198
+ return [1, 2, 3].map(i => ({
199
+ id: i,
200
+ flow_id: 1,
201
+ pool_id: i <= 1 ? 1 : (valveConfig.input_pools.length > 1 ? 2 : 1),
202
+ order_id: 1000 + i,
203
+ parent_ticket_id: 0,
204
+ detail: generateDetailFromSchema(fields, i, adversarial && i === 3),
205
+ status: 'created',
206
+ uuid: `mock-uuid-${i}`,
207
+ }));
208
+ }
209
+
210
+ // ─── Mock context factory ──────────────────────────────────────────
211
+
212
+ const KNOWN_SDK_NAMESPACES = new Set(['ticket', 'pool', 'compound', 'process', 'station']);
213
+
214
+ const PAGINATED_METHODS = new Set([
215
+ 'ticket.list', 'process.list', 'compound.getStockFromXfcSh',
216
+ ]);
217
+
218
+ function createMockContext(pools, valveConfig, adversarial) {
219
+ const mockTickets = generateMockTickets(pools, valveConfig, adversarial);
220
+ const callLog = [];
221
+ const warnings = [];
222
+
223
+ const rawContext = {
224
+ ticket: {
225
+ list: async (params) => {
226
+ callLog.push({ method: 'ticket.list', params });
227
+ if (PAGINATED_METHODS.has('ticket.list') && params?.limit === undefined) {
228
+ warnings.push('ticket.list called without explicit limit — may truncate data');
229
+ }
230
+ return { list: mockTickets };
231
+ },
232
+ update: async (tickets) => {
233
+ callLog.push({ method: 'ticket.update', count: Array.isArray(tickets) ? tickets.length : 0 });
234
+ },
235
+ append: async (tickets) => {
236
+ callLog.push({ method: 'ticket.append', count: Array.isArray(tickets) ? tickets.length : 0 });
237
+ return (tickets || []).map((t, i) => ({ ...t, id: 9000 + i }));
238
+ },
239
+ },
240
+ pool: {
241
+ getNext: async (valveId) => {
242
+ callLog.push({ method: 'pool.getNext', valveId });
243
+ return valveConfig.output_pools.map((p, i) => ({
244
+ id: 100 + i,
245
+ name: p.name,
246
+ description: p.description || '',
247
+ schema: { fields: [] },
248
+ type: 'flow_process',
249
+ }));
250
+ },
251
+ },
252
+ compound: {
253
+ getStockFromXfcSh: async (params) => {
254
+ callLog.push({ method: 'compound.getStockFromXfcSh', params });
255
+ const data = [1, 2].map(i => ({
256
+ id: i, cas: `12345-67-${i}`, status: '可用',
257
+ source: 'MockSupplier', barcode: `XFC-MOCK-${i}`,
258
+ cmpd_id: `CA${1000 + i}`, owner: 'mock@example.com',
259
+ }));
260
+ return { data, meta: { count: data.length, page: 1, pageSize: 400, totalPage: 1 } };
261
+ },
262
+ },
263
+ process: {
264
+ list: async (params) => {
265
+ callLog.push({ method: 'process.list', params });
266
+ if (params?.limit === undefined) {
267
+ warnings.push('process.list called without explicit limit');
268
+ }
269
+ return [];
270
+ },
271
+ execute: async (items, baseURL) => {
272
+ callLog.push({ method: 'process.execute', itemCount: Array.isArray(items) ? items.length : 0, baseURL });
273
+ return { results: { exp_id_list: (items || []).map(() => 'mock-exp-' + Math.random().toString(36).slice(2, 8)) } };
274
+ },
275
+ },
276
+ station: {
277
+ list: async () => {
278
+ callLog.push({ method: 'station.list' });
279
+ return [{ id: 1, name: 'Station-1' }];
280
+ },
281
+ },
282
+ };
283
+
284
+ const contextProxy = new Proxy(rawContext, {
285
+ get(target, prop) {
286
+ if (typeof prop === 'symbol') return target[prop];
287
+ if (!KNOWN_SDK_NAMESPACES.has(prop)) {
288
+ warnings.push(`Unknown SDK namespace accessed: context.${String(prop)}`);
289
+ return new Proxy({}, {
290
+ get(_, method) {
291
+ return async () => {
292
+ warnings.push(`Unknown SDK method called: context.${String(prop)}.${String(method)}`);
293
+ return undefined;
294
+ };
295
+ },
296
+ });
297
+ }
298
+ return target[prop];
299
+ },
300
+ });
301
+
302
+ return { context: contextProxy, callLog, warnings, mockTickets };
303
+ }
304
+
305
+ // ─── Contract validators ───────────────────────────────────────────
306
+
307
+ const CAMEL_CASE_PATTERN = /^[a-z]+[A-Z]/;
308
+
309
+ function validateStartResult(result) {
310
+ const errors = [];
311
+ if (typeof result !== 'object' || result === null) {
312
+ errors.push('start() must return an object, got ' + (result === null ? 'null' : typeof result));
313
+ return errors;
314
+ }
315
+ if (typeof result.orbit_link !== 'string') {
316
+ errors.push(`start().orbit_link must be string, got ${result.orbit_link === undefined ? 'undefined' : typeof result.orbit_link}`);
317
+ }
318
+ if (!Array.isArray(result.ticket_ids)) {
319
+ errors.push(`start().ticket_ids must be array, got ${result.ticket_ids === undefined ? 'undefined' : typeof result.ticket_ids}`);
320
+ } else if (result.ticket_ids.length > 0 && typeof result.ticket_ids[0] !== 'number') {
321
+ errors.push(`start().ticket_ids[0] must be number, got ${typeof result.ticket_ids[0]}`);
322
+ }
323
+ for (const key of Object.keys(result)) {
324
+ if (CAMEL_CASE_PATTERN.test(key)) {
325
+ errors.push(`start() return key "${key}" is camelCase — must be snake_case`);
326
+ }
327
+ }
328
+ return errors;
329
+ }
330
+
331
+ function validateCompleteResult(result) {
332
+ const errors = [];
333
+ if (typeof result !== 'object' || result === null) {
334
+ errors.push('complete() must return an object, got ' + (result === null ? 'null' : typeof result));
335
+ return errors;
336
+ }
337
+ if (result.new_tickets !== undefined) {
338
+ if (!Array.isArray(result.new_tickets)) {
339
+ errors.push(`complete().new_tickets must be array, got ${typeof result.new_tickets}`);
340
+ } else {
341
+ for (let i = 0; i < Math.min(result.new_tickets.length, 3); i++) {
342
+ const t = result.new_tickets[i];
343
+ if (typeof t.flow_id !== 'number') errors.push(`new_tickets[${i}].flow_id must be number, got ${typeof t.flow_id}`);
344
+ if (typeof t.pool_id !== 'number') errors.push(`new_tickets[${i}].pool_id must be number, got ${typeof t.pool_id}`);
345
+ if (typeof t.order_id !== 'number') errors.push(`new_tickets[${i}].order_id must be number, got ${typeof t.order_id}`);
346
+ if (typeof t.detail !== 'object' || t.detail === null) errors.push(`new_tickets[${i}].detail must be object`);
347
+ if (typeof t.status !== 'string') errors.push(`new_tickets[${i}].status must be string, got ${typeof t.status}`);
348
+ }
349
+ }
350
+ }
351
+ for (const key of Object.keys(result)) {
352
+ if (CAMEL_CASE_PATTERN.test(key)) {
353
+ errors.push(`complete() return key "${key}" is camelCase — must be snake_case`);
354
+ }
355
+ }
356
+ return errors;
357
+ }
358
+
359
+ function validateRunResult(result) {
360
+ const errors = [];
361
+ if (typeof result !== 'object' || result === null) {
362
+ errors.push('run() must return an object, got ' + (result === null ? 'null' : typeof result));
363
+ return errors;
364
+ }
365
+ if (result.new_tickets !== undefined) {
366
+ if (!Array.isArray(result.new_tickets)) {
367
+ errors.push(`run().new_tickets must be array, got ${typeof result.new_tickets}`);
368
+ } else {
369
+ for (let i = 0; i < Math.min(result.new_tickets.length, 3); i++) {
370
+ const t = result.new_tickets[i];
371
+ if (typeof t.flow_id !== 'number') errors.push(`new_tickets[${i}].flow_id must be number, got ${typeof t.flow_id}`);
372
+ if (typeof t.pool_id !== 'number') errors.push(`new_tickets[${i}].pool_id must be number, got ${typeof t.pool_id}`);
373
+ if (typeof t.order_id !== 'number') errors.push(`new_tickets[${i}].order_id must be number, got ${typeof t.order_id}`);
374
+ if (typeof t.detail !== 'object' || t.detail === null) errors.push(`new_tickets[${i}].detail must be object`);
375
+ if (typeof t.status !== 'string') errors.push(`new_tickets[${i}].status must be string, got ${typeof t.status}`);
376
+ }
377
+ }
378
+ }
379
+ for (const key of Object.keys(result)) {
380
+ if (CAMEL_CASE_PATTERN.test(key)) {
381
+ errors.push(`run() return key "${key}" is camelCase — must be snake_case`);
382
+ }
383
+ }
384
+ return errors;
385
+ }
386
+
387
+ // ─── Script loader via vm ──────────────────────────────────────────
388
+
389
+ function loadProcesserClass(scriptContent, filename) {
390
+ const wrapped = scriptContent + '\n;Processer;\n';
391
+ const script = new Script(wrapped, { filename });
392
+ const sandbox = {
393
+ console: {
394
+ log: () => {},
395
+ info: () => {},
396
+ warn: () => {},
397
+ error: () => {},
398
+ debug: () => {},
399
+ },
400
+ setTimeout, clearTimeout, setInterval, clearInterval,
401
+ Promise, Array, Object, Map, Set, JSON, Math, Date,
402
+ Number, String, Boolean, RegExp, Error, TypeError, ReferenceError,
403
+ parseInt, parseFloat, isNaN, isFinite, undefined, NaN, Infinity,
404
+ encodeURIComponent, decodeURIComponent, encodeURI, decodeURI,
405
+ fetch: async (url, opts) => {
406
+ const method = (opts && opts.method) || 'GET';
407
+ sandbox.console.warn(`[test-processer] fetch called: ${method} ${url}`);
408
+ return {
409
+ ok: true,
410
+ status: 200,
411
+ json: async () => ({ data: {} }),
412
+ text: async () => '{}',
413
+ };
414
+ },
415
+ };
416
+ const ctx = createContext(sandbox);
417
+ return script.runInContext(ctx);
418
+ }
419
+
420
+ // ─── Preview: scenario data generation ────────────────────────────
421
+
422
+ function generateScenarioValue(field, idx) {
423
+ const { type, options, default_value, key, label } = field;
424
+ if (options && options.length) return options[idx % options.length];
425
+
426
+ const prefix = (label || key).replace(/\s+/g, '');
427
+ switch ((type || '').toLowerCase()) {
428
+ case 'number': case 'integer': case 'float':
429
+ return 1000 + idx * 100 + Math.round(Math.random() * 50);
430
+ case 'boolean':
431
+ return idx % 2 === 0;
432
+ case 'json': case 'object':
433
+ return { [`${key}_data`]: idx };
434
+ case 'array':
435
+ return [`${prefix}_item_${idx}`];
436
+ case 'date': case 'datetime':
437
+ return `2026-0${1 + (idx % 9)}-${String(10 + idx).padStart(2, '0')}T10:00:00Z`;
438
+ default:
439
+ return `${prefix}_${1000 + idx}`;
440
+ }
441
+ }
442
+
443
+ function generateScenarioTickets(pools, valveConfig) {
444
+ const inputPoolName = valveConfig.input_pools[0]?.name;
445
+ const inputPool = pools.find(p => p.name === inputPoolName);
446
+ const fields = inputPool?.fields || [];
447
+
448
+ return [1, 2, 3, 4, 5].map(i => {
449
+ const detail = {};
450
+ for (const field of fields) {
451
+ if (field.key === 'id' || field.key === 'detail') continue;
452
+ if (i === 4 && !field.default_value && !(field.options?.length)) {
453
+ detail[field.key] = (field.type || '').match(/number|integer|float/i) ? 0 : '';
454
+ } else if (i === 5 && field.options && field.options.length > 1) {
455
+ detail[field.key] = field.options[1];
456
+ } else {
457
+ detail[field.key] = generateScenarioValue(field, i);
458
+ }
459
+ }
460
+ return {
461
+ id: i,
462
+ flow_id: 1,
463
+ pool_id: i <= 2 ? 1 : (valveConfig.input_pools.length > 1 ? 2 : 1),
464
+ order_id: 1000 + i,
465
+ parent_ticket_id: 0,
466
+ detail,
467
+ status: 'created',
468
+ uuid: `scenario-uuid-${i}`,
469
+ };
470
+ });
471
+ }
472
+
473
+ // ─── Preview: user data loader ────────────────────────────────────
474
+
475
+ function loadUserData(dataPath, valveConfig) {
476
+ const raw = readFileSync(resolve(dataPath), 'utf8');
477
+ const parsed = JSON.parse(raw);
478
+ const tickets = parsed.tickets;
479
+ if (!Array.isArray(tickets) || tickets.length === 0) {
480
+ throw new Error('--data JSON must contain a non-empty "tickets" array');
481
+ }
482
+ const defaultPoolId = valveConfig.input_pools.length > 0 ? 1 : 0;
483
+ return tickets.map((t, i) => ({
484
+ id: t.id ?? i + 1,
485
+ flow_id: t.flow_id ?? 1,
486
+ pool_id: t.pool_id ?? defaultPoolId,
487
+ order_id: t.order_id ?? 1000 + i + 1,
488
+ parent_ticket_id: t.parent_ticket_id ?? 0,
489
+ detail: t.detail ?? {},
490
+ status: t.status ?? 'created',
491
+ uuid: t.uuid ?? `user-data-${i + 1}`,
492
+ }));
493
+ }
494
+
495
+ // ─── Preview: detail diff ─────────────────────────────────────────
496
+
497
+ function diffDetail(before, after) {
498
+ const changes = [];
499
+ const allKeys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
500
+ for (const key of allKeys) {
501
+ const bv = before?.[key];
502
+ const av = after?.[key];
503
+ const bStr = JSON.stringify(bv);
504
+ const aStr = JSON.stringify(av);
505
+ if (bStr !== aStr) {
506
+ changes.push({
507
+ key,
508
+ before: bv === undefined ? '(无)' : bStr,
509
+ after: av === undefined ? '(无)' : aStr,
510
+ type: bv === undefined ? 'added' : av === undefined ? 'removed' : 'changed',
511
+ });
512
+ }
513
+ }
514
+ return changes;
515
+ }
516
+
517
+ // ─── Preview: tracing context ─────────────────────────────────────
518
+
519
+ function createTracingContext(pools, valveConfig, tickets) {
520
+ const traceLog = [];
521
+ let seq = 0;
522
+ const ticketSnapshots = new Map();
523
+ for (const t of tickets) {
524
+ ticketSnapshots.set(t.id, JSON.parse(JSON.stringify(t.detail || {})));
525
+ }
526
+ const detailDiffs = [];
527
+
528
+ function summarize(val) {
529
+ if (Array.isArray(val)) return `${val.length} items`;
530
+ if (val && typeof val === 'object' && val.list) return `${val.list.length} tickets`;
531
+ return JSON.stringify(val)?.slice(0, 60) ?? 'undefined';
532
+ }
533
+
534
+ const rawContext = {
535
+ ticket: {
536
+ list: async (params) => {
537
+ const result = { list: JSON.parse(JSON.stringify(tickets)) };
538
+ traceLog.push({ seq: seq++, method: 'ticket.list', params: JSON.parse(JSON.stringify(params || {})), returnSummary: `${result.list.length} tickets` });
539
+ return result;
540
+ },
541
+ update: async (updatedTickets) => {
542
+ const diffs = [];
543
+ for (const ut of (updatedTickets || [])) {
544
+ const oldDetail = ticketSnapshots.get(ut.id);
545
+ const newDetail = ut.detail || {};
546
+ const changes = diffDetail(oldDetail, newDetail);
547
+ if (changes.length > 0) {
548
+ diffs.push({ ticketId: ut.id, changes });
549
+ }
550
+ ticketSnapshots.set(ut.id, JSON.parse(JSON.stringify(newDetail)));
551
+ }
552
+ const entry = { seq: seq++, method: 'ticket.update', params: { count: (updatedTickets || []).length }, returnSummary: 'void' };
553
+ if (diffs.length > 0) {
554
+ entry.detailDiffs = diffs;
555
+ detailDiffs.push(...diffs);
556
+ }
557
+ traceLog.push(entry);
558
+ },
559
+ append: async (newTickets) => {
560
+ const result = (newTickets || []).map((t, i) => ({ ...t, id: 9000 + i }));
561
+ traceLog.push({ seq: seq++, method: 'ticket.append', params: { count: (newTickets || []).length }, returnSummary: `${result.length} created` });
562
+ return result;
563
+ },
564
+ },
565
+ pool: {
566
+ getNext: async (valveId) => {
567
+ const result = valveConfig.output_pools.map((p, i) => ({
568
+ id: 100 + i, name: p.name, description: p.description || '',
569
+ schema: { fields: [] }, type: 'flow_process',
570
+ }));
571
+ traceLog.push({ seq: seq++, method: 'pool.getNext', params: { valveId }, returnSummary: `${result.length} pools: ${result.map(p => p.name).join(', ')}` });
572
+ return result;
573
+ },
574
+ },
575
+ compound: {
576
+ getStockFromXfcSh: async (params) => {
577
+ const data = [1, 2].map(i => ({
578
+ id: i, cas: `12345-67-${i}`, status: '可用',
579
+ source: 'MockSupplier', barcode: `XFC-MOCK-${i}`,
580
+ cmpd_id: `CA${1000 + i}`, owner: 'mock@example.com',
581
+ }));
582
+ const result = { data, meta: { count: data.length, page: 1, pageSize: 400, totalPage: 1 } };
583
+ traceLog.push({ seq: seq++, method: 'compound.getStockFromXfcSh', params: JSON.parse(JSON.stringify(params || {})), returnSummary: `${data.length} records` });
584
+ return result;
585
+ },
586
+ },
587
+ process: {
588
+ list: async (params) => {
589
+ traceLog.push({ seq: seq++, method: 'process.list', params: JSON.parse(JSON.stringify(params || {})), returnSummary: '0 processes' });
590
+ return [];
591
+ },
592
+ execute: async (items, baseURL) => {
593
+ const result = { results: { exp_id_list: (items || []).map(() => 'mock-exp-' + Math.random().toString(36).slice(2, 8)) } };
594
+ traceLog.push({ seq: seq++, method: 'process.execute', params: { itemCount: (items || []).length, baseURL }, returnSummary: `${(items || []).length} executed` });
595
+ return result;
596
+ },
597
+ },
598
+ station: {
599
+ list: async () => {
600
+ const result = [{ id: 1, name: 'Station-1' }];
601
+ traceLog.push({ seq: seq++, method: 'station.list', params: {}, returnSummary: `${result.length} stations` });
602
+ return result;
603
+ },
604
+ },
605
+ };
606
+
607
+ const contextProxy = new Proxy(rawContext, {
608
+ get(target, prop) {
609
+ if (typeof prop === 'symbol') return target[prop];
610
+ if (!KNOWN_SDK_NAMESPACES.has(prop)) {
611
+ traceLog.push({ seq: seq++, method: `(unknown) ${String(prop)}.*`, params: {}, returnSummary: 'unknown namespace' });
612
+ return new Proxy({}, {
613
+ get(_, method) {
614
+ return async (...fnArgs) => {
615
+ traceLog.push({ seq: seq++, method: `${String(prop)}.${String(method)}`, params: fnArgs, returnSummary: 'undefined (unknown)' });
616
+ return undefined;
617
+ };
618
+ },
619
+ });
620
+ }
621
+ return target[prop];
622
+ },
623
+ });
624
+
625
+ return { context: contextProxy, traceLog, detailDiffs, ticketSnapshots };
626
+ }
627
+
628
+ // ─── Preview: report formatter ────────────────────────────────────
629
+
630
+ function formatTicketsTable(tickets, maxDetailKeys) {
631
+ if (tickets.length === 0) return '(无 tickets)\n';
632
+ const detailKeys = maxDetailKeys || [...new Set(tickets.flatMap(t => Object.keys(t.detail || {})))].slice(0, 8);
633
+ const headers = ['id', 'order_id', ...detailKeys, 'status'];
634
+ const rows = tickets.map(t => {
635
+ const d = t.detail || {};
636
+ return [t.id, t.order_id, ...detailKeys.map(k => {
637
+ const v = d[k];
638
+ if (v === undefined || v === null) return '-';
639
+ if (typeof v === 'object') return JSON.stringify(v).slice(0, 30);
640
+ return String(v).slice(0, 30);
641
+ }), t.status];
642
+ });
643
+ const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i]).length)));
644
+ const pad = (s, w) => String(s).padEnd(w);
645
+ const line = colWidths.map(w => '-'.repeat(w));
646
+ const fmt = row => '| ' + row.map((c, i) => pad(c, colWidths[i])).join(' | ') + ' |';
647
+ return [fmt(headers), '| ' + line.map(l => l).join(' | ') + ' |', ...rows.map(fmt)].join('\n') + '\n';
648
+ }
649
+
650
+ function formatPreviewReport(trace) {
651
+ const { scriptPath, valveName, valveConfig, pools, dataSource,
652
+ inputTickets, isAutoTrigger,
653
+ startResult, startError, startTraceLog, startDetailDiffs,
654
+ completeTickets, completeResult, completeError, completeTraceLog, completeDetailDiffs,
655
+ runResult, runError, runTraceLog, runDetailDiffs } = trace;
656
+
657
+ const lines = [];
658
+ const push = (...args) => lines.push(...args);
659
+
660
+ push('# 脚本预览报告', '');
661
+
662
+ // Basic info
663
+ push('## 基本信息');
664
+ push(`- 脚本: \`${basename(scriptPath)}\``);
665
+ push(`- 门控: ${valveName}`);
666
+ const inputPoolNames = valveConfig.input_pools.map(p => p.name).join(', ') || '(未知)';
667
+ const outputPoolNames = valveConfig.output_pools.map(p => p.name).join(', ') || '(未知)';
668
+ push(`- 入口池: ${inputPoolNames}`);
669
+ push(`- 出口池: ${outputPoolNames}`);
670
+ push(`- 数据来源: ${dataSource}`);
671
+ push(`- tickets 数: ${inputTickets.length}`);
672
+ push(`- 触发方式: ${isAutoTrigger ? '自动触发 (run)' : '人工触发 (start/complete)'}`, '');
673
+
674
+ if (isAutoTrigger) {
675
+ // run() trace
676
+ push('## run() 执行追踪', '');
677
+ push(`### 输入数据 (${inputTickets.length} 条 tickets)`, '');
678
+ push(formatTicketsTable(inputTickets));
679
+
680
+ push('### SDK 调用链', '');
681
+ for (const entry of (runTraceLog || [])) {
682
+ const paramStr = JSON.stringify(entry.params);
683
+ push(`${entry.seq + 1}. \`${entry.method}(${paramStr === '{}' ? '' : paramStr})\` → ${entry.returnSummary}`);
684
+ }
685
+ push('');
686
+
687
+ if (runDetailDiffs && runDetailDiffs.length > 0) {
688
+ push('### ticket.detail 变更 (run 阶段)', '');
689
+ push('| ticket | 字段 | 变更前 | 变更后 |');
690
+ push('|--------|------|--------|--------|');
691
+ for (const d of runDetailDiffs) {
692
+ for (const c of d.changes) {
693
+ push(`| #${d.ticketId} | ${c.key} | ${c.before} | ${c.after} |`);
694
+ }
695
+ }
696
+ push('');
697
+ }
698
+
699
+ if (runError) {
700
+ push(`### run() 错误`, '');
701
+ push(`\`\`\`\n${runError}\n\`\`\``, '');
702
+ } else if (runResult) {
703
+ push('### 数据路由', '');
704
+ if (runResult.new_tickets && runResult.new_tickets.length > 0) {
705
+ const routeMap = new Map();
706
+ for (const nt of runResult.new_tickets) {
707
+ const poolId = nt.pool_id;
708
+ if (!routeMap.has(poolId)) routeMap.set(poolId, []);
709
+ routeMap.get(poolId).push(nt);
710
+ }
711
+ push('| 目标池 | pool_id | 数量 | status |');
712
+ push('|--------|---------|------|--------|');
713
+ for (const [poolId, nts] of routeMap) {
714
+ const poolName = valveConfig.output_pools.find((_, i) => 100 + i === poolId)?.name || `pool_${poolId}`;
715
+ push(`| ${poolName} | ${poolId} | ${nts.length} | ${nts[0]?.status || '-'} |`);
716
+ }
717
+ push('');
718
+ } else {
719
+ push('(无 new_tickets 返回)', '');
720
+ }
721
+ }
722
+
723
+ // Summary
724
+ push('## 执行摘要', '');
725
+ const usedMethods = new Set((runTraceLog || []).map(c => c.method));
726
+ const allMethods = ['ticket.list', 'ticket.update', 'ticket.append', 'pool.getNext',
727
+ 'compound.getStockFromXfcSh', 'process.list', 'process.execute', 'station.list'];
728
+ const unused = allMethods.filter(m => !usedMethods.has(m));
729
+
730
+ const runStatus = runError
731
+ ? `✗ 失败: ${runError.split('\n')[0]}`
732
+ : `✓ 成功 (${(runTraceLog || []).length} SDK 调用, ${(runResult?.new_tickets || []).length} new_tickets)`;
733
+
734
+ push(`- run: ${runStatus}`);
735
+ if (unused.length > 0) {
736
+ push(`- 未调用的 SDK 方法: ${unused.join(', ')}`);
737
+ }
738
+ push('');
739
+ } else {
740
+ // start() trace
741
+ push('## start() 执行追踪', '');
742
+ push(`### 输入数据 (${inputTickets.length} 条 tickets)`, '');
743
+ push(formatTicketsTable(inputTickets));
744
+
745
+ push('### SDK 调用链', '');
746
+ for (const entry of startTraceLog) {
747
+ const paramStr = JSON.stringify(entry.params);
748
+ push(`${entry.seq + 1}. \`${entry.method}(${paramStr === '{}' ? '' : paramStr})\` → ${entry.returnSummary}`);
749
+ }
750
+ push('');
751
+
752
+ if (startDetailDiffs.length > 0) {
753
+ push('### ticket.detail 变更 (start 阶段)', '');
754
+ push('| ticket | 字段 | 变更前 | 变更后 |');
755
+ push('|--------|------|--------|--------|');
756
+ for (const d of startDetailDiffs) {
757
+ for (const c of d.changes) {
758
+ push(`| #${d.ticketId} | ${c.key} | ${c.before} | ${c.after} |`);
759
+ }
760
+ }
761
+ push('');
762
+ }
763
+
764
+ if (startError) {
765
+ push(`### start() 错误`, '');
766
+ push(`\`\`\`\n${startError}\n\`\`\``, '');
767
+ } else if (startResult) {
768
+ push('### 返回值', '');
769
+ push(`- orbit_link: \`${startResult.orbit_link}\``);
770
+ push(`- ticket_ids: [${(startResult.ticket_ids || []).join(', ')}]`);
771
+ push('');
772
+ }
773
+
774
+ // complete() trace
775
+ push('## complete() 执行追踪', '');
776
+ push(`### 输入 tickets (${(completeTickets || []).length} 条)`, '');
777
+
778
+ push('### SDK 调用链', '');
779
+ for (const entry of completeTraceLog) {
780
+ const paramStr = JSON.stringify(entry.params);
781
+ push(`${entry.seq + 1}. \`${entry.method}(${paramStr === '{}' ? '' : paramStr})\` → ${entry.returnSummary}`);
782
+ }
783
+ push('');
784
+
785
+ if (completeDetailDiffs.length > 0) {
786
+ push('### ticket.detail 变更 (complete 阶段)', '');
787
+ push('| ticket | 字段 | 变更前 | 变更后 |');
788
+ push('|--------|------|--------|--------|');
789
+ for (const d of completeDetailDiffs) {
790
+ for (const c of d.changes) {
791
+ push(`| #${d.ticketId} | ${c.key} | ${c.before} | ${c.after} |`);
792
+ }
793
+ }
794
+ push('');
795
+ }
796
+
797
+ if (completeError) {
798
+ push(`### complete() 错误`, '');
799
+ push(`\`\`\`\n${completeError}\n\`\`\``, '');
800
+ } else if (completeResult) {
801
+ push('### 数据路由', '');
802
+ if (completeResult.new_tickets && completeResult.new_tickets.length > 0) {
803
+ const routeMap = new Map();
804
+ for (const nt of completeResult.new_tickets) {
805
+ const poolId = nt.pool_id;
806
+ if (!routeMap.has(poolId)) routeMap.set(poolId, []);
807
+ routeMap.get(poolId).push(nt);
808
+ }
809
+ push('| 目标池 | pool_id | 数量 | status |');
810
+ push('|--------|---------|------|--------|');
811
+ for (const [poolId, nts] of routeMap) {
812
+ const poolName = valveConfig.output_pools.find((_, i) => 100 + i === poolId)?.name || `pool_${poolId}`;
813
+ push(`| ${poolName} | ${poolId} | ${nts.length} | ${nts[0]?.status || '-'} |`);
814
+ }
815
+ push('');
816
+ } else {
817
+ push('(无 new_tickets 返回)', '');
818
+ }
819
+ }
820
+
821
+ // Summary
822
+ push('## 执行摘要', '');
823
+ const allCalls = [...startTraceLog, ...completeTraceLog];
824
+ const usedMethods = new Set(allCalls.map(c => c.method));
825
+ const allMethods = ['ticket.list', 'ticket.update', 'ticket.append', 'pool.getNext',
826
+ 'compound.getStockFromXfcSh', 'process.list', 'process.execute', 'station.list'];
827
+ const unused = allMethods.filter(m => !usedMethods.has(m));
828
+
829
+ const startStatus = startError ? `✗ 失败: ${startError.split('\n')[0]}` : `✓ 成功 (${startTraceLog.length} SDK 调用, ${inputTickets.length} tickets)`;
830
+ const completeStatus = completeError
831
+ ? `✗ 失败: ${completeError.split('\n')[0]}`
832
+ : `✓ 成功 (${completeTraceLog.length} SDK 调用, ${(completeResult?.new_tickets || []).length} new_tickets)`;
833
+
834
+ push(`- start: ${startStatus}`);
835
+ push(`- complete: ${completeStatus}`);
836
+ if (unused.length > 0) {
837
+ push(`- 未调用的 SDK 方法: ${unused.join(', ')}`);
838
+ }
839
+ push('');
840
+ }
841
+
842
+ return lines.join('\n');
843
+ }
844
+
845
+ // ─── Preview: main runner ─────────────────────────────────────────
846
+
847
+ async function runPreview(args) {
848
+ const scriptPath = resolve(args.script);
849
+ const poolsDir = args.poolsDir ? resolve(args.poolsDir) : null;
850
+ const valvesDir = args.valvesDir ? resolve(args.valvesDir) : null;
851
+ const valveName = args.valveName || basename(scriptPath, '.js');
852
+
853
+ let scriptContent;
854
+ try {
855
+ scriptContent = readFileSync(scriptPath, 'utf8');
856
+ } catch (e) {
857
+ console.error(`Cannot read script: ${e.message}`);
858
+ process.exit(1);
859
+ }
860
+
861
+ let ProcesserClass;
862
+ try {
863
+ ProcesserClass = loadProcesserClass(scriptContent, basename(scriptPath));
864
+ } catch (e) {
865
+ console.error(`Cannot load Processer: ${e.message}`);
866
+ process.exit(1);
867
+ }
868
+
869
+ const pools = loadAllPools(poolsDir);
870
+ const valveConfig = loadValveConfig(valvesDir, valveName);
871
+
872
+ let inputTickets;
873
+ let dataSource;
874
+ if (args.dataPath) {
875
+ try {
876
+ inputTickets = loadUserData(args.dataPath, valveConfig);
877
+ dataSource = `用户数据 (${basename(args.dataPath)})`;
878
+ } catch (e) {
879
+ console.error(`Cannot load --data: ${e.message}`);
880
+ process.exit(1);
881
+ }
882
+ } else {
883
+ inputTickets = generateScenarioTickets(pools, valveConfig);
884
+ dataSource = '自动生成';
885
+ }
886
+
887
+ const inputPoolIds = valveConfig.input_pools.map((_, i) => i + 1);
888
+ const proto = ProcesserClass.prototype;
889
+ const isAutoTrigger = typeof proto.run === 'function' && typeof proto.start !== 'function';
890
+
891
+ const trace = {
892
+ scriptPath, valveName, valveConfig, pools, dataSource, inputTickets, isAutoTrigger,
893
+ startResult: null, startError: null, startTraceLog: [], startDetailDiffs: [],
894
+ completeTickets: null, completeResult: null, completeError: null, completeTraceLog: [], completeDetailDiffs: [],
895
+ runResult: null, runError: null, runTraceLog: [], runDetailDiffs: [],
896
+ };
897
+
898
+ if (isAutoTrigger) {
899
+ // Auto-trigger: run()
900
+ const tc = createTracingContext(pools, valveConfig, inputTickets);
901
+ let processer;
902
+ try {
903
+ processer = new ProcesserClass(tc.context);
904
+ } catch (e) {
905
+ trace.runError = `constructor threw: ${e.message}\n${e.stack}`;
906
+ console.log(formatPreviewReport(trace));
907
+ process.exit(1);
908
+ }
909
+
910
+ try {
911
+ trace.runResult = await processer.run({ valve_id: 1, pool_ids: inputPoolIds });
912
+ } catch (e) {
913
+ trace.runError = `${e.constructor.name}: ${e.message}\n${e.stack}`;
914
+ }
915
+ trace.runTraceLog = tc.traceLog;
916
+ trace.runDetailDiffs = tc.detailDiffs;
917
+ } else {
918
+ // Manual-trigger: start() + complete()
919
+ // Run start()
920
+ {
921
+ const tc = createTracingContext(pools, valveConfig, inputTickets);
922
+ let processer;
923
+ try {
924
+ processer = new ProcesserClass(tc.context);
925
+ } catch (e) {
926
+ trace.startError = `constructor threw: ${e.message}\n${e.stack}`;
927
+ console.log(formatPreviewReport(trace));
928
+ process.exit(1);
929
+ }
930
+
931
+ try {
932
+ trace.startResult = await processer.start({ valve_id: 1, pool_ids: inputPoolIds });
933
+ } catch (e) {
934
+ trace.startError = `${e.constructor.name}: ${e.message}\n${e.stack}`;
935
+ }
936
+ trace.startTraceLog = tc.traceLog;
937
+ trace.startDetailDiffs = tc.detailDiffs;
938
+ }
939
+
940
+ // Run complete()
941
+ {
942
+ const completeTickets = inputTickets.map(t => ({ ...t, status: 'created' }));
943
+ trace.completeTickets = completeTickets;
944
+
945
+ const tc = createTracingContext(pools, valveConfig, completeTickets);
946
+ let processer;
947
+ try {
948
+ processer = new ProcesserClass(tc.context);
949
+ } catch (e) {
950
+ trace.completeError = `constructor threw: ${e.message}\n${e.stack}`;
951
+ console.log(formatPreviewReport(trace));
952
+ process.exit(1);
953
+ }
954
+
955
+ try {
956
+ trace.completeResult = await processer.complete({ valve_id: 1, tickets: completeTickets });
957
+ } catch (e) {
958
+ trace.completeError = `${e.constructor.name}: ${e.message}\n${e.stack}`;
959
+ }
960
+ trace.completeTraceLog = tc.traceLog;
961
+ trace.completeDetailDiffs = tc.detailDiffs;
962
+ }
963
+ }
964
+
965
+ console.log(formatPreviewReport(trace));
966
+ const hasError = isAutoTrigger ? trace.runError : (trace.startError || trace.completeError);
967
+ process.exit(hasError ? 1 : 0);
968
+ }
969
+
970
+ // ─── Main test runner ──────────────────────────────────────────────
971
+
972
+ async function runTests(args) {
973
+ const scriptPath = resolve(args.script);
974
+ const poolsDir = args.poolsDir ? resolve(args.poolsDir) : null;
975
+ const valvesDir = args.valvesDir ? resolve(args.valvesDir) : null;
976
+ const valveName = args.valveName || basename(scriptPath, '.js');
977
+
978
+ const result = {
979
+ status: 'pass',
980
+ script: scriptPath,
981
+ phases: {
982
+ syntax: { pass: false },
983
+ structure: { pass: false, hasConstructor: false, hasStart: false, hasComplete: false, hasRun: false },
984
+ normalRun: {
985
+ start: { pass: false, returnValid: false, errors: [] },
986
+ complete: { pass: false, returnValid: false, errors: [] },
987
+ },
988
+ adversarialRun: {
989
+ start: { pass: false, errors: [] },
990
+ complete: { pass: false, errors: [] },
991
+ note: '对抗轮失败为警告,不阻断',
992
+ },
993
+ compliance: { pass: true, warnings: [] },
994
+ },
995
+ sdkCalls: [],
996
+ errors: [],
997
+ warnings: [],
998
+ };
999
+
1000
+ // Phase 1: Syntax check
1001
+ let scriptContent;
1002
+ try {
1003
+ scriptContent = readFileSync(scriptPath, 'utf8');
1004
+ } catch (e) {
1005
+ result.errors.push(`Cannot read script file: ${e.message}`);
1006
+ result.status = 'fail';
1007
+ return result;
1008
+ }
1009
+
1010
+ let ProcesserClass;
1011
+ try {
1012
+ new Script(scriptContent, { filename: basename(scriptPath) });
1013
+ result.phases.syntax.pass = true;
1014
+ } catch (e) {
1015
+ result.phases.syntax.pass = false;
1016
+ result.phases.syntax.error = e.message;
1017
+ result.errors.push(`SyntaxError: ${e.message}`);
1018
+ result.status = 'fail';
1019
+ return result;
1020
+ }
1021
+
1022
+ // Phase 2: Structure check
1023
+ try {
1024
+ ProcesserClass = loadProcesserClass(scriptContent, basename(scriptPath));
1025
+ } catch (e) {
1026
+ result.errors.push(`Failed to load Processer class: ${e.message}`);
1027
+ result.status = 'fail';
1028
+ return result;
1029
+ }
1030
+
1031
+ if (typeof ProcesserClass !== 'function') {
1032
+ result.errors.push('Script does not define a Processer class (or class is not the last declaration)');
1033
+ result.status = 'fail';
1034
+ return result;
1035
+ }
1036
+
1037
+ const proto = ProcesserClass.prototype;
1038
+ result.phases.structure.hasConstructor = typeof ProcesserClass === 'function';
1039
+ result.phases.structure.hasStart = typeof proto.start === 'function';
1040
+ result.phases.structure.hasComplete = typeof proto.complete === 'function';
1041
+ result.phases.structure.hasRun = typeof proto.run === 'function';
1042
+ result.phases.structure.pass =
1043
+ result.phases.structure.hasConstructor &&
1044
+ (
1045
+ (result.phases.structure.hasStart && result.phases.structure.hasComplete) ||
1046
+ result.phases.structure.hasRun
1047
+ );
1048
+
1049
+ if (!result.phases.structure.pass) {
1050
+ if (!result.phases.structure.hasRun && !result.phases.structure.hasStart) result.errors.push('Processer missing start() method (manual-trigger) or run() method (auto-trigger)');
1051
+ if (!result.phases.structure.hasRun && !result.phases.structure.hasComplete) result.errors.push('Processer missing complete() method (manual-trigger) or run() method (auto-trigger)');
1052
+ result.status = 'fail';
1053
+ return result;
1054
+ }
1055
+
1056
+ const isAutoTrigger = result.phases.structure.hasRun && !result.phases.structure.hasStart;
1057
+
1058
+ // Load pool schemas and valve config
1059
+ const pools = loadAllPools(poolsDir);
1060
+ const valveConfig = loadValveConfig(valvesDir, valveName);
1061
+ const inputPoolIds = valveConfig.input_pools.map((_, i) => i + 1);
1062
+
1063
+ // Phase 3: Normal run
1064
+ {
1065
+ const mock = createMockContext(pools, valveConfig, false);
1066
+ let processer;
1067
+ try {
1068
+ processer = new ProcesserClass(mock.context);
1069
+ } catch (e) {
1070
+ const phaseKey = isAutoTrigger ? 'run' : 'start';
1071
+ if (!result.phases.normalRun[phaseKey]) result.phases.normalRun[phaseKey] = { pass: false, returnValid: false, errors: [] };
1072
+ result.phases.normalRun[phaseKey].errors.push(`constructor threw: ${e.message}`);
1073
+ result.errors.push(`Normal run constructor error: ${e.message}`);
1074
+ result.status = 'fail';
1075
+ return result;
1076
+ }
1077
+
1078
+ if (isAutoTrigger) {
1079
+ // Auto-trigger: test run()
1080
+ if (!result.phases.normalRun.run) result.phases.normalRun.run = { pass: false, returnValid: false, errors: [] };
1081
+ try {
1082
+ const runResult = await processer.run({ valve_id: 1, pool_ids: inputPoolIds });
1083
+ const runErrors = validateRunResult(runResult);
1084
+ result.phases.normalRun.run.returnValid = runErrors.length === 0;
1085
+ result.phases.normalRun.run.errors = runErrors;
1086
+ result.phases.normalRun.run.pass = runErrors.length === 0;
1087
+ if (runErrors.length > 0) {
1088
+ result.errors.push(...runErrors.map(e => `[normal run] ${e}`));
1089
+ }
1090
+ } catch (e) {
1091
+ result.phases.normalRun.run.errors.push(e.message);
1092
+ result.errors.push(`[normal run] Runtime error: ${e.message}`);
1093
+ }
1094
+ } else {
1095
+ // Manual-trigger: test start() + complete()
1096
+ // start
1097
+ try {
1098
+ const startResult = await processer.start({ valve_id: 1, pool_ids: inputPoolIds });
1099
+ const startErrors = validateStartResult(startResult);
1100
+ result.phases.normalRun.start.returnValid = startErrors.length === 0;
1101
+ result.phases.normalRun.start.errors = startErrors;
1102
+ result.phases.normalRun.start.pass = startErrors.length === 0;
1103
+ if (startErrors.length > 0) {
1104
+ result.errors.push(...startErrors.map(e => `[normal start] ${e}`));
1105
+ }
1106
+ } catch (e) {
1107
+ result.phases.normalRun.start.errors.push(e.message);
1108
+ result.errors.push(`[normal start] Runtime error: ${e.message}`);
1109
+ }
1110
+
1111
+ // complete — use mock tickets from the start phase (with updated details if any)
1112
+ try {
1113
+ const completeTickets = mock.mockTickets.map(t => ({ ...t, status: 'created' }));
1114
+ const completeResult = await processer.complete({ valve_id: 1, tickets: completeTickets });
1115
+ const completeErrors = validateCompleteResult(completeResult);
1116
+ result.phases.normalRun.complete.returnValid = completeErrors.length === 0;
1117
+ result.phases.normalRun.complete.errors = completeErrors;
1118
+ result.phases.normalRun.complete.pass = completeErrors.length === 0;
1119
+ if (completeErrors.length > 0) {
1120
+ result.errors.push(...completeErrors.map(e => `[normal complete] ${e}`));
1121
+ }
1122
+ } catch (e) {
1123
+ result.phases.normalRun.complete.errors.push(e.message);
1124
+ result.errors.push(`[normal complete] Runtime error: ${e.message}`);
1125
+ }
1126
+ }
1127
+
1128
+ result.sdkCalls = [...new Set(mock.callLog.map(c => c.method))];
1129
+ result.phases.compliance.warnings.push(...mock.warnings);
1130
+
1131
+ // Check limit/offset compliance
1132
+ for (const call of mock.callLog) {
1133
+ if (PAGINATED_METHODS.has(call.method) && call.params && call.params.limit === undefined) {
1134
+ result.phases.compliance.warnings.push(
1135
+ `${call.method} called without limit parameter`
1136
+ );
1137
+ }
1138
+ }
1139
+
1140
+ // Check for sandbox-unavailable APIs in source code
1141
+ const UNAVAILABLE_APIS = ['URLSearchParams', 'TextEncoder', 'TextDecoder', 'Buffer'];
1142
+ for (const api of UNAVAILABLE_APIS) {
1143
+ if (scriptContent.includes(api)) {
1144
+ result.phases.compliance.warnings.push(
1145
+ `Script uses '${api}' which is not available in the gate script sandbox — use template literals + encodeURIComponent instead`
1146
+ );
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ const normalPass = isAutoTrigger
1152
+ ? result.phases.normalRun.run?.pass
1153
+ : result.phases.normalRun.start.pass && result.phases.normalRun.complete.pass;
1154
+ if (!normalPass) {
1155
+ result.status = 'fail';
1156
+ }
1157
+
1158
+ // Phase 4: Adversarial run
1159
+ {
1160
+ const mock = createMockContext(pools, valveConfig, true);
1161
+ let processer;
1162
+ try {
1163
+ processer = new ProcesserClass(mock.context);
1164
+ } catch (e) {
1165
+ const phaseKey = isAutoTrigger ? 'run' : 'start';
1166
+ if (!result.phases.adversarialRun[phaseKey]) result.phases.adversarialRun[phaseKey] = { pass: false, errors: [] };
1167
+ result.phases.adversarialRun[phaseKey].errors.push(`constructor threw: ${e.message}`);
1168
+ result.warnings.push(`[adversarial] constructor error: ${e.message}`);
1169
+ }
1170
+
1171
+ if (processer) {
1172
+ if (isAutoTrigger) {
1173
+ if (!result.phases.adversarialRun.run) result.phases.adversarialRun.run = { pass: false, errors: [] };
1174
+ try {
1175
+ await processer.run({ valve_id: 1, pool_ids: inputPoolIds });
1176
+ result.phases.adversarialRun.run.pass = true;
1177
+ } catch (e) {
1178
+ result.phases.adversarialRun.run.errors.push(e.message);
1179
+ result.warnings.push(`[adversarial run] ${e.constructor.name}: ${e.message}`);
1180
+ }
1181
+ } else {
1182
+ try {
1183
+ await processer.start({ valve_id: 1, pool_ids: inputPoolIds });
1184
+ result.phases.adversarialRun.start.pass = true;
1185
+ } catch (e) {
1186
+ result.phases.adversarialRun.start.errors.push(e.message);
1187
+ result.warnings.push(`[adversarial start] ${e.constructor.name}: ${e.message}`);
1188
+ }
1189
+
1190
+ try {
1191
+ const completeTickets = mock.mockTickets.map(t => ({ ...t, status: 'created' }));
1192
+ await processer.complete({ valve_id: 1, tickets: completeTickets });
1193
+ result.phases.adversarialRun.complete.pass = true;
1194
+ } catch (e) {
1195
+ result.phases.adversarialRun.complete.errors.push(e.message);
1196
+ result.warnings.push(`[adversarial complete] ${e.constructor.name}: ${e.message}`);
1197
+ }
1198
+ }
1199
+
1200
+ result.phases.compliance.warnings.push(
1201
+ ...mock.warnings.filter(w => !result.phases.compliance.warnings.includes(w))
1202
+ );
1203
+ }
1204
+ }
1205
+
1206
+ // Phase 5: Compliance
1207
+ if (result.phases.compliance.warnings.length > 0) {
1208
+ result.warnings.push(...result.phases.compliance.warnings);
1209
+ }
1210
+ result.phases.compliance.pass = result.phases.compliance.warnings.filter(
1211
+ w => w.includes('Unknown SDK')
1212
+ ).length === 0;
1213
+
1214
+ if (!result.phases.compliance.pass) {
1215
+ result.status = 'fail';
1216
+ }
1217
+
1218
+ return result;
1219
+ }
1220
+
1221
+ // ─── Entry point ───────────────────────────────────────────────────
1222
+
1223
+ const args = parseArgs(process.argv);
1224
+
1225
+ if (args.preview) {
1226
+ runPreview(args).catch(e => {
1227
+ console.error(`Preview failed: ${e.message}`);
1228
+ process.exit(1);
1229
+ });
1230
+ } else {
1231
+ runTests(args)
1232
+ .then(result => {
1233
+ console.log(JSON.stringify(result, null, 2));
1234
+ process.exit(result.status === 'pass' ? 0 : 1);
1235
+ })
1236
+ .catch(e => {
1237
+ console.error(JSON.stringify({ status: 'fail', errors: [e.message] }));
1238
+ process.exit(1);
1239
+ });
1240
+ }